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 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 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
229pub 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 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 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}