Skip to main content

lean_ctx/core/
output_verification.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Mutex, OnceLock};
5
6static STATS: OnceLock<VerificationStats> = OnceLock::new();
7
8fn global_stats() -> &'static VerificationStats {
9    STATS.get_or_init(VerificationStats::new)
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13#[serde(default)]
14pub struct VerificationConfig {
15    pub enabled: Option<bool>,
16    /// Optional explicit verification mode.
17    /// - "off": disable verifier entirely
18    /// - "warn": fail only on High severity warnings
19    /// - "fail": fail on Medium+High warnings (strict)
20    pub mode: Option<String>,
21    pub strict_mode: Option<bool>,
22    pub check_paths: Option<bool>,
23    pub check_identifiers: Option<bool>,
24    pub check_line_numbers: Option<bool>,
25    pub check_structure: Option<bool>,
26}
27
28impl VerificationConfig {
29    pub fn enabled_effective(&self) -> bool {
30        self.enabled.unwrap_or(true)
31    }
32    pub fn strict_mode_effective(&self) -> bool {
33        self.strict_mode.unwrap_or(false)
34    }
35    pub fn check_paths_effective(&self) -> bool {
36        self.check_paths.unwrap_or(true)
37    }
38    pub fn check_identifiers_effective(&self) -> bool {
39        self.check_identifiers.unwrap_or(true)
40    }
41    pub fn check_line_numbers_effective(&self) -> bool {
42        self.check_line_numbers.unwrap_or(false)
43    }
44    pub fn check_structure_effective(&self) -> bool {
45        self.check_structure.unwrap_or(true)
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum VerificationMode {
51    Off,
52    Warn,
53    Fail,
54}
55
56fn parse_mode(s: &str) -> VerificationMode {
57    match s.trim().to_lowercase().as_str() {
58        "off" | "disabled" | "none" => VerificationMode::Off,
59        "fail" | "strict" | "enforce" => VerificationMode::Fail,
60        _ => VerificationMode::Warn,
61    }
62}
63
64impl VerificationConfig {
65    fn effective_mode(&self) -> VerificationMode {
66        if let Some(m) = self.mode.as_deref() {
67            return parse_mode(m);
68        }
69        if !self.enabled_effective() {
70            return VerificationMode::Off;
71        }
72        if self.strict_mode_effective() {
73            VerificationMode::Fail
74        } else {
75            VerificationMode::Warn
76        }
77    }
78
79    fn is_enabled(&self) -> bool {
80        self.effective_mode() != VerificationMode::Off
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub enum WarningKind {
86    MissingPath,
87    MangledIdentifier,
88    LineNumberDrift,
89    TruncatedBlock,
90}
91
92impl std::fmt::Display for WarningKind {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::MissingPath => write!(f, "missing_path"),
96            Self::MangledIdentifier => write!(f, "mangled_identifier"),
97            Self::LineNumberDrift => write!(f, "line_drift"),
98            Self::TruncatedBlock => write!(f, "truncated_block"),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct VerificationWarning {
105    pub kind: WarningKind,
106    pub detail: String,
107    pub severity: WarningSeverity,
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
111pub enum WarningSeverity {
112    Low,
113    Medium,
114    High,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct VerificationResult {
119    pub pass: bool,
120    pub warnings: Vec<VerificationWarning>,
121    pub info_loss_score: f64,
122    pub paths_checked: usize,
123    pub identifiers_checked: usize,
124}
125
126impl VerificationResult {
127    pub fn ok() -> Self {
128        Self {
129            pass: true,
130            warnings: Vec::new(),
131            info_loss_score: 0.0,
132            paths_checked: 0,
133            identifiers_checked: 0,
134        }
135    }
136
137    pub fn format_compact(&self) -> String {
138        if self.warnings.is_empty() {
139            return "PASS".to_string();
140        }
141        let status = if self.pass { "WARN" } else { "FAIL" };
142        let mut counts = std::collections::BTreeMap::<String, u32>::new();
143        for w in &self.warnings {
144            *counts.entry(w.kind.to_string()).or_insert(0) += 1;
145        }
146        let counts: Vec<String> = counts
147            .into_iter()
148            .map(|(k, v)| format!("{k}={v}"))
149            .collect();
150        format!(
151            "{status}({}) loss={:.1}%",
152            counts.join(", "),
153            self.info_loss_score * 100.0
154        )
155    }
156}
157
158pub fn verify_output(
159    source: &str,
160    compressed: &str,
161    config: &VerificationConfig,
162) -> VerificationResult {
163    if !config.is_enabled() || source.is_empty() || compressed.is_empty() {
164        return VerificationResult::ok();
165    }
166
167    // No-op compression should never produce warnings.
168    if source == compressed {
169        return VerificationResult::ok();
170    }
171
172    let mut warnings = Vec::new();
173    let mut paths_checked = 0;
174    let mut identifiers_checked = 0;
175
176    if config.check_paths_effective() {
177        let (path_warnings, count) = check_paths(source, compressed);
178        paths_checked = count;
179        warnings.extend(path_warnings);
180    }
181
182    if config.check_identifiers_effective() {
183        let (id_warnings, count) = check_identifiers(source, compressed);
184        identifiers_checked = count;
185        warnings.extend(id_warnings);
186    }
187
188    if config.check_line_numbers_effective() {
189        warnings.extend(check_line_numbers(source, compressed));
190    }
191
192    if config.check_structure_effective() {
193        warnings.extend(check_structure(source, compressed));
194    }
195
196    let total_checks = (paths_checked + identifiers_checked).max(1);
197    let loss_items = warnings
198        .iter()
199        .filter(|w| w.severity == WarningSeverity::High)
200        .count() as f64
201        * 2.0
202        + warnings
203            .iter()
204            .filter(|w| w.severity == WarningSeverity::Medium)
205            .count() as f64;
206    let info_loss_score = (loss_items / total_checks as f64).min(1.0);
207
208    let mode = config.effective_mode();
209    let pass = if mode == VerificationMode::Fail {
210        !warnings
211            .iter()
212            .any(|w| w.severity == WarningSeverity::High || w.severity == WarningSeverity::Medium)
213    } else {
214        !warnings.iter().any(|w| w.severity == WarningSeverity::High)
215    };
216
217    let result = VerificationResult {
218        pass,
219        warnings,
220        info_loss_score,
221        paths_checked,
222        identifiers_checked,
223    };
224
225    record_result(&result);
226    result
227}
228
229/// Run a deterministic end-to-end check of the output verifier.
230///
231/// This exercises path and identifier preservation with a known-good compact
232/// representation and records the result in the same counters as live output.
233pub fn run_self_check() -> VerificationResult {
234    const SOURCE: &str =
235        r#"fn lean_ctx_verification_probe() { include_str!("src/core/output_verification.rs"); }"#;
236    const COMPRESSED: &str = "fn lean_ctx_verification_probe() src/core/output_verification.rs";
237
238    verify_output(SOURCE, COMPRESSED, &VerificationConfig::default())
239}
240
241fn check_paths(source: &str, compressed: &str) -> (Vec<VerificationWarning>, usize) {
242    let paths = extract_file_paths(source);
243    let mut warnings = Vec::new();
244
245    for path in &paths {
246        let basename = path.rsplit('/').next().unwrap_or(path);
247        if !compressed.contains(basename) {
248            warnings.push(VerificationWarning {
249                kind: WarningKind::MissingPath,
250                detail: format!("Path reference lost: {path}"),
251                severity: WarningSeverity::Medium,
252            });
253        }
254    }
255
256    (warnings, paths.len())
257}
258
259fn check_identifiers(source: &str, compressed: &str) -> (Vec<VerificationWarning>, usize) {
260    let identifiers = extract_identifiers(source);
261    let mut warnings = Vec::new();
262    let significant: Vec<&str> = identifiers
263        .iter()
264        .filter(|id| id.len() >= 4)
265        .map(String::as_str)
266        .collect();
267
268    for id in &significant {
269        if !compressed.contains(id) {
270            warnings.push(VerificationWarning {
271                kind: WarningKind::MangledIdentifier,
272                detail: format!("Identifier lost: {id}"),
273                severity: if id.len() >= 8 {
274                    WarningSeverity::High
275                } else {
276                    WarningSeverity::Low
277                },
278            });
279        }
280    }
281
282    (warnings, significant.len())
283}
284
285fn check_line_numbers(source: &str, compressed: &str) -> Vec<VerificationWarning> {
286    let source_max = source.lines().count();
287    let mut warnings = Vec::new();
288
289    let re_like = Regex::new(r"(?:line\s+|L|:)(\d{1,6})")
290        .ok()
291        .or_else(|| Regex::new(r"(\d+)").ok());
292
293    if let Some(re_like) = re_like {
294        for cap in re_like.captures_iter(compressed) {
295            if let Some(m) = cap.get(1)
296                && let Ok(n) = m.as_str().parse::<usize>()
297                && n > source_max
298                && n < 999_999
299            {
300                warnings.push(VerificationWarning {
301                    kind: WarningKind::LineNumberDrift,
302                    detail: format!("Line {n} exceeds source max {source_max}"),
303                    severity: WarningSeverity::Low,
304                });
305            }
306        }
307    }
308
309    warnings
310}
311
312fn check_structure(source: &str, compressed: &str) -> Vec<VerificationWarning> {
313    let mut warnings = Vec::new();
314
315    let src_opens: usize = source.chars().filter(|&c| c == '{').count();
316    let src_closes: usize = source.chars().filter(|&c| c == '}').count();
317    let src_diff = (src_opens as i64 - src_closes as i64).unsigned_abs();
318
319    let opens: usize = compressed.chars().filter(|&c| c == '{').count();
320    let closes: usize = compressed.chars().filter(|&c| c == '}').count();
321    if opens > 0 || closes > 0 {
322        let diff = (opens as i64 - closes as i64).unsigned_abs();
323        // Only warn if compression materially worsened structural balance.
324        if diff > (src_diff + 2) && diff > 2 {
325            warnings.push(VerificationWarning {
326                kind: WarningKind::TruncatedBlock,
327                detail: format!("Brace mismatch: {{ {opens} vs }} {closes}"),
328                severity: WarningSeverity::Medium,
329            });
330        }
331    }
332
333    let src_parens_open: usize = source.chars().filter(|&c| c == '(').count();
334    let src_parens_close: usize = source.chars().filter(|&c| c == ')').count();
335    let src_parens_diff = (src_parens_open as i64 - src_parens_close as i64).unsigned_abs();
336
337    let parens_open: usize = compressed.chars().filter(|&c| c == '(').count();
338    let parens_close: usize = compressed.chars().filter(|&c| c == ')').count();
339    if parens_open > 0 || parens_close > 0 {
340        let diff = (parens_open as i64 - parens_close as i64).unsigned_abs();
341        if diff > (src_parens_diff + 3) && diff > 3 {
342            warnings.push(VerificationWarning {
343                kind: WarningKind::TruncatedBlock,
344                detail: format!("Paren mismatch: ( {parens_open} vs ) {parens_close}"),
345                severity: WarningSeverity::Low,
346            });
347        }
348    }
349
350    warnings
351}
352
353fn extract_file_paths(text: &str) -> Vec<String> {
354    let mut paths = Vec::new();
355    let re = Regex::new(
356        r#"(?:^|[\s"'`(,])([a-zA-Z0-9_./-]{2,}\.(?:rs|ts|tsx|js|jsx|py|go|java|rb|cpp|c|h|toml|yaml|yml|json|md))\b"#
357    )
358    .ok()
359    .or_else(|| Regex::new(r"(\S+\.\w+)").ok());
360
361    if let Some(re) = re {
362        for cap in re.captures_iter(text) {
363            if let Some(m) = cap.get(1) {
364                let p = m.as_str().to_string();
365                if !paths.contains(&p) && p.len() < 200 {
366                    paths.push(p);
367                }
368            }
369        }
370    }
371    paths
372}
373
374fn extract_identifiers(text: &str) -> Vec<String> {
375    let mut ids = Vec::new();
376    let re = Regex::new(
377        r"\b(fn|struct|enum|trait|type|class|function|const|let|var|def|pub)\s+([a-zA-Z_][a-zA-Z0-9_]*)"
378    )
379    .ok()
380    .or_else(|| Regex::new(r"([a-zA-Z_]\w+)").ok());
381
382    if let Some(re) = re {
383        for cap in re.captures_iter(text) {
384            if let Some(m) = cap.get(2) {
385                let id = m.as_str().to_string();
386                if !ids.contains(&id) {
387                    ids.push(id);
388                }
389            }
390        }
391    }
392    ids
393}
394
395struct VerificationStats {
396    pass_count: AtomicU64,
397    warn_run_count: AtomicU64,
398    warn_item_count: AtomicU64,
399    total_count: AtomicU64,
400    sum_info_loss_score_ppm: AtomicU64,
401    last_info_loss_score_ppm: AtomicU64,
402    recent_warnings: Mutex<Vec<VerificationWarning>>,
403}
404
405impl VerificationStats {
406    fn new() -> Self {
407        Self {
408            pass_count: AtomicU64::new(0),
409            warn_run_count: AtomicU64::new(0),
410            warn_item_count: AtomicU64::new(0),
411            total_count: AtomicU64::new(0),
412            sum_info_loss_score_ppm: AtomicU64::new(0),
413            last_info_loss_score_ppm: AtomicU64::new(0),
414            recent_warnings: Mutex::new(Vec::new()),
415        }
416    }
417}
418
419fn record_result(result: &VerificationResult) {
420    let stats = global_stats();
421    stats.total_count.fetch_add(1, Ordering::Relaxed);
422    if result.warnings.is_empty() {
423        stats.pass_count.fetch_add(1, Ordering::Relaxed);
424    } else {
425        stats.warn_run_count.fetch_add(1, Ordering::Relaxed);
426        stats
427            .warn_item_count
428            .fetch_add(result.warnings.len() as u64, Ordering::Relaxed);
429    }
430    let ppm = (result.info_loss_score.clamp(0.0, 1.0) * 1_000_000.0).round() as u64;
431    stats
432        .sum_info_loss_score_ppm
433        .fetch_add(ppm, Ordering::Relaxed);
434    stats.last_info_loss_score_ppm.store(ppm, Ordering::Relaxed);
435
436    if !result.warnings.is_empty() {
437        if let Ok(mut recent) = stats.recent_warnings.lock() {
438            for w in &result.warnings {
439                recent.push(w.clone());
440            }
441            if recent.len() > 200 {
442                let excess = recent.len() - 200;
443                recent.drain(..excess);
444            }
445        }
446
447        for w in &result.warnings {
448            crate::core::events::emit_verification_warning(
449                &w.kind.to_string(),
450                &w.detail,
451                &format!("{:?}", w.severity),
452            );
453        }
454    }
455}
456
457pub fn stats_snapshot() -> VerificationSnapshot {
458    let s = global_stats();
459    let total = s.total_count.load(Ordering::Relaxed);
460    let pass = s.pass_count.load(Ordering::Relaxed);
461    let warn_runs = s.warn_run_count.load(Ordering::Relaxed);
462    let warn_items = s.warn_item_count.load(Ordering::Relaxed);
463    let sum_ppm = s.sum_info_loss_score_ppm.load(Ordering::Relaxed);
464    let last_ppm = s.last_info_loss_score_ppm.load(Ordering::Relaxed);
465    let recent = s
466        .recent_warnings
467        .lock()
468        .map(|r| r.clone())
469        .unwrap_or_default();
470    VerificationSnapshot {
471        total,
472        pass,
473        warn_runs,
474        warn_items,
475        pass_rate: if total > 0 {
476            pass as f64 / total as f64
477        } else {
478            1.0
479        },
480        avg_info_loss_score: if total > 0 {
481            (sum_ppm as f64 / total as f64) / 1_000_000.0
482        } else {
483            0.0
484        },
485        last_info_loss_score: (last_ppm as f64) / 1_000_000.0,
486        recent_warnings: recent,
487    }
488}
489
490#[derive(Debug, Clone, Serialize)]
491pub struct VerificationSnapshot {
492    pub total: u64,
493    pub pass: u64,
494    pub warn_runs: u64,
495    pub warn_items: u64,
496    pub pass_rate: f64,
497    pub avg_info_loss_score: f64,
498    pub last_info_loss_score: f64,
499    pub recent_warnings: Vec<VerificationWarning>,
500}
501
502impl VerificationSnapshot {
503    pub fn format_compact(&self) -> String {
504        format!(
505            "Verification: {}/{} pass ({:.0}%), warn_runs={}, warn_items={}, loss(avg)={:.1}%",
506            self.pass,
507            self.total,
508            self.pass_rate * 100.0,
509            self.warn_runs,
510            self.warn_items,
511            self.avg_info_loss_score * 100.0
512        )
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn cfg() -> VerificationConfig {
521        VerificationConfig::default()
522    }
523
524    #[test]
525    fn empty_input_passes() {
526        let r = verify_output("", "", &cfg());
527        assert!(r.pass);
528    }
529
530    #[test]
531    fn identical_passes() {
532        let src = "fn hello() { println!(\"world\"); }";
533        let r = verify_output(src, src, &cfg());
534        assert!(r.pass);
535        assert!(r.warnings.is_empty());
536    }
537
538    #[test]
539    fn detects_missing_path() {
540        let src = "import { foo } from src/utils/helper.ts";
541        let compressed = "import foo";
542        let r = verify_output(src, compressed, &cfg());
543        assert!(
544            r.warnings
545                .iter()
546                .any(|w| w.kind == WarningKind::MissingPath)
547        );
548    }
549
550    #[test]
551    fn detects_lost_identifier() {
552        let src = "fn calculate_monthly_revenue(data: &[f64]) -> f64 { data.iter().sum() }";
553        let compressed = "fn calc() -> f64 { sum }";
554        let r = verify_output(src, compressed, &cfg());
555        assert!(
556            r.warnings
557                .iter()
558                .any(|w| w.kind == WarningKind::MangledIdentifier)
559        );
560    }
561
562    #[test]
563    fn detects_brace_mismatch() {
564        let src = "fn a() { if true { b(); } } fn c() { d(); } fn e() { f(); }";
565        let compressed = "fn a() { if true { b(); fn c() { d(); fn e() { f();";
566        let r = verify_output(src, compressed, &cfg());
567        assert!(
568            r.warnings
569                .iter()
570                .any(|w| w.kind == WarningKind::TruncatedBlock)
571        );
572    }
573
574    #[test]
575    fn preserved_identifiers_pass() {
576        let src = "fn process_data(input: Vec<u8>) -> Result<()> { Ok(()) }";
577        let compressed = "fn process_data(input: Vec<u8>) -> Result<()>";
578        let r = verify_output(src, compressed, &cfg());
579        let mangled = r
580            .warnings
581            .iter()
582            .filter(|w| w.kind == WarningKind::MangledIdentifier)
583            .count();
584        assert_eq!(mangled, 0);
585    }
586
587    #[test]
588    fn extract_paths_finds_common_extensions() {
589        let text = "see src/core/auth.rs and lib/utils.py for details";
590        let paths = extract_file_paths(text);
591        assert!(paths.iter().any(|p| p.contains("auth.rs")));
592        assert!(paths.iter().any(|p| p.contains("utils.py")));
593    }
594
595    #[test]
596    fn extract_identifiers_finds_functions() {
597        let text = "fn calculate_total(x: i32) -> i32 { x }\nstruct UserProfile { name: String }";
598        let ids = extract_identifiers(text);
599        assert!(ids.contains(&"calculate_total".to_string()));
600        assert!(ids.contains(&"UserProfile".to_string()));
601    }
602
603    #[test]
604    fn info_loss_score_bounded() {
605        let src = "fn very_long_function_name_here() {}\nfn another_significant_fn() {}";
606        let compressed = "compressed";
607        let r = verify_output(src, compressed, &cfg());
608        assert!(r.info_loss_score >= 0.0);
609        assert!(r.info_loss_score <= 1.0);
610    }
611
612    #[test]
613    fn snapshot_starts_clean() {
614        let snap = stats_snapshot();
615        assert!(snap.pass_rate >= 0.0);
616        assert!(snap.pass_rate <= 1.0);
617    }
618
619    #[test]
620    fn self_check_records_a_passing_run() {
621        let before = stats_snapshot().total;
622        let result = run_self_check();
623        let after = stats_snapshot();
624
625        assert!(result.pass);
626        assert!(result.warnings.is_empty());
627        assert!(after.total > before);
628    }
629
630    #[test]
631    fn disabled_config_passes() {
632        let mut c = cfg();
633        c.enabled = Some(false);
634        let r = verify_output("fn foo() {}", "bar", &c);
635        assert!(r.pass);
636    }
637
638    #[test]
639    fn strict_mode_fails_on_medium() {
640        let mut c = cfg();
641        c.strict_mode = Some(true);
642        let src = "import { foo } from src/utils/helper.ts";
643        let compressed = "import foo";
644        let r = verify_output(src, compressed, &c);
645        assert!(!r.pass, "strict mode should FAIL on medium warnings");
646        assert!(
647            r.format_compact().starts_with("FAIL("),
648            "compact should show FAIL: {}",
649            r.format_compact()
650        );
651    }
652
653    #[test]
654    fn compact_format_is_deterministic_and_sorted() {
655        let src = "fn calculate_monthly_revenue() {} see src/utils/helper.ts";
656        let compressed = "compressed";
657        let r = verify_output(src, compressed, &cfg());
658        let s = r.format_compact();
659        // Stable ordering for parsing: keys are lexicographically sorted.
660        let want_order = ["mangled_identifier", "missing_path"];
661        let mut idx = 0usize;
662        for k in want_order {
663            if let Some(pos) = s.find(k) {
664                assert!(pos >= idx, "expected sorted keys in: {s}");
665                idx = pos;
666            }
667        }
668    }
669}