1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const LEAN_FRONTIER_SCHEMA_VERSION: &str = "1.0.0";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum LeanDiagnosticSeverity {
17 Error,
18 Warning,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct LeanFrontierDiagnostic {
23 pub severity: LeanDiagnosticSeverity,
24 pub file_path: String,
25 pub line: u32,
26 pub column: u32,
27 pub error_code: String,
28 pub failure_mode: String,
29 pub signature: String,
30 pub message: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct LeanFrontierBucket {
35 pub bucket_id: String,
36 pub failure_mode: String,
37 pub error_code: String,
38 pub count: usize,
39 pub signatures: Vec<String>,
40 pub sample_locations: Vec<String>,
41 pub linked_gap_ids: Vec<String>,
42 pub linked_bead_ids: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct LeanFrontierReport {
47 pub schema_version: String,
48 pub report_id: String,
49 pub generated_by: String,
50 pub source_log: String,
51 pub bucket_ordering: String,
52 pub diagnostics_total: usize,
53 pub errors_total: usize,
54 pub warnings_total: usize,
55 pub buckets: Vec<LeanFrontierBucket>,
56}
57
58#[derive(Debug, Default)]
59struct GapLinks {
60 gap_ids: BTreeSet<String>,
61 bead_ids: BTreeSet<String>,
62}
63
64pub fn extract_frontier_report(
71 log_text: &str,
72 source_log: &str,
73 gap_plan_json: Option<&str>,
74) -> LeanFrontierReport {
75 let gap_links = parse_gap_links(gap_plan_json);
76
77 let diagnostics = log_text
78 .lines()
79 .filter_map(parse_diagnostic_line)
80 .collect::<Vec<_>>();
81 let errors_total = diagnostics
82 .iter()
83 .filter(|d| d.severity == LeanDiagnosticSeverity::Error)
84 .count();
85 let warnings_total = diagnostics
86 .iter()
87 .filter(|d| d.severity == LeanDiagnosticSeverity::Warning)
88 .count();
89
90 let mut grouped = BTreeMap::<(String, String), Vec<LeanFrontierDiagnostic>>::new();
91 for diagnostic in diagnostics
92 .iter()
93 .filter(|d| d.severity == LeanDiagnosticSeverity::Error)
94 {
95 grouped
96 .entry((
97 diagnostic.failure_mode.clone(),
98 diagnostic.error_code.clone(),
99 ))
100 .or_default()
101 .push(diagnostic.clone());
102 }
103
104 let mut buckets = Vec::with_capacity(grouped.len());
105 for ((failure_mode, error_code), mut entries) in grouped {
106 entries.sort_by(|left, right| {
107 (
108 left.file_path.as_str(),
109 left.line,
110 left.column,
111 left.signature.as_str(),
112 )
113 .cmp(&(
114 right.file_path.as_str(),
115 right.line,
116 right.column,
117 right.signature.as_str(),
118 ))
119 });
120
121 let signatures = entries
122 .iter()
123 .map(|entry| entry.signature.clone())
124 .collect::<BTreeSet<_>>()
125 .into_iter()
126 .collect::<Vec<_>>();
127 let sample_locations = entries
128 .iter()
129 .map(|entry| format!("{}:{}:{}", entry.file_path, entry.line, entry.column))
130 .collect::<BTreeSet<_>>()
131 .into_iter()
132 .take(12)
133 .collect::<Vec<_>>();
134 let (linked_gap_ids, linked_bead_ids) = gap_links
135 .get(&failure_mode)
136 .map(|links| {
137 (
138 links.gap_ids.iter().cloned().collect::<Vec<_>>(),
139 links.bead_ids.iter().cloned().collect::<Vec<_>>(),
140 )
141 })
142 .unwrap_or_default();
143
144 buckets.push(LeanFrontierBucket {
145 bucket_id: format!("{failure_mode}.{error_code}"),
146 failure_mode,
147 error_code,
148 count: entries.len(),
149 signatures,
150 sample_locations,
151 linked_gap_ids,
152 linked_bead_ids,
153 });
154 }
155
156 LeanFrontierReport {
157 schema_version: LEAN_FRONTIER_SCHEMA_VERSION.to_string(),
158 report_id: "lean.frontier.buckets.v1".to_string(),
159 generated_by: "bd-1dorb".to_string(),
160 source_log: source_log.to_string(),
161 bucket_ordering: "lexicographic(failure_mode,error_code)".to_string(),
162 diagnostics_total: diagnostics.len(),
163 errors_total,
164 warnings_total,
165 buckets,
166 }
167}
168
169fn parse_gap_links(gap_plan_json: Option<&str>) -> BTreeMap<String, GapLinks> {
170 let mut by_failure_mode = BTreeMap::<String, GapLinks>::new();
171 let Some(json) = gap_plan_json else {
172 return by_failure_mode;
173 };
174
175 let Ok(plan) = serde_json::from_str::<Value>(json) else {
176 return by_failure_mode;
177 };
178 let Some(gaps) = plan.get("gaps").and_then(Value::as_array) else {
179 return by_failure_mode;
180 };
181
182 for gap in gaps {
183 let Some(failure_mode) = gap.get("failure_mode").and_then(Value::as_str) else {
184 continue;
185 };
186 let Some(gap_id) = gap.get("id").and_then(Value::as_str) else {
187 continue;
188 };
189 let links = by_failure_mode.entry(failure_mode.to_string()).or_default();
190 links.gap_ids.insert(gap_id.to_string());
191 if let Some(linked_beads) = gap.get("linked_beads").and_then(Value::as_array) {
192 for bead in linked_beads.iter().filter_map(Value::as_str) {
193 links.bead_ids.insert(bead.to_string());
194 }
195 }
196 }
197
198 by_failure_mode
199}
200
201fn parse_diagnostic_line(line: &str) -> Option<LeanFrontierDiagnostic> {
202 let (severity, rest) = if let Some(rest) = line.strip_prefix("error: ") {
203 (LeanDiagnosticSeverity::Error, rest)
204 } else {
205 let rest = line.strip_prefix("warning: ")?;
206 (LeanDiagnosticSeverity::Warning, rest)
207 };
208
209 let mut parts = rest.splitn(4, ':');
210 let raw_file = parts.next()?.trim();
211 let line_number = parts.next()?.trim().parse::<u32>().ok()?;
212 let column = parts.next()?.trim().parse::<u32>().ok()?;
213 let message = parts.next()?.trim().to_string();
214 if raw_file.is_empty() || message.is_empty() {
215 return None;
216 }
217
218 let file_path = normalize_file_path(raw_file);
219 let error_code = classify_error_code(&message).to_string();
220 let failure_mode = classify_failure_mode(&error_code).to_string();
221 let signature = format!(
222 "{file_path}:{error_code}:{}",
223 normalize_message_for_signature(&message)
224 );
225
226 Some(LeanFrontierDiagnostic {
227 severity,
228 file_path,
229 line: line_number,
230 column,
231 error_code,
232 failure_mode,
233 signature,
234 message,
235 })
236}
237
238fn normalize_file_path(raw_file: &str) -> String {
239 raw_file.rsplit('/').next().unwrap_or(raw_file).to_string()
240}
241
242fn classify_error_code(message: &str) -> &'static str {
243 let lower = message.to_ascii_lowercase();
244 if lower.starts_with("unknown identifier") {
245 return "unknown-identifier";
246 }
247 if lower.starts_with("alternative `") && lower.contains("has not been provided") {
248 return "constructor-alternative-missing";
249 }
250 if lower.contains("maximum recursion depth has been reached") {
251 return "tactic-max-rec-depth";
252 }
253 if lower.contains("tactic `simp` failed with a nested error") {
254 return "tactic-simp-nested-error";
255 }
256 if lower.contains("unsolved goals") {
257 return "unsolved-goals";
258 }
259 if lower.starts_with("application type mismatch") {
260 return "application-type-mismatch";
261 }
262 if lower.starts_with("type mismatch") {
263 return "type-mismatch";
264 }
265 if lower.contains("tactic `rewrite` failed") {
266 return "rewrite-failed";
267 }
268 if lower.contains("tactic `subst` failed") {
269 return "subst-failed";
270 }
271 if lower.contains("no goals to be solved") {
272 return "no-goals";
273 }
274 if lower.contains("unexpected token") {
275 return "parse-unexpected-token";
276 }
277 if lower.contains("omega could not prove the goal") {
278 return "omega-goal-not-proved";
279 }
280 if lower.contains("simp made no progress") {
281 return "simp-no-progress";
282 }
283 "other"
284}
285
286fn classify_failure_mode(error_code: &str) -> &'static str {
287 match error_code {
288 "unknown-identifier" => "declaration-order",
289 "constructor-alternative-missing" => "missing-lemma",
290 "tactic-max-rec-depth" | "tactic-simp-nested-error" | "simp-no-progress" => {
291 "tactic-instability"
292 }
293 "application-type-mismatch"
294 | "type-mismatch"
295 | "rewrite-failed"
296 | "subst-failed"
297 | "unsolved-goals"
298 | "no-goals"
299 | "parse-unexpected-token"
300 | "omega-goal-not-proved"
301 | "other" => "proof-shape",
302 _ => "proof-shape",
303 }
304}
305
306fn normalize_message_for_signature(message: &str) -> String {
307 let mut out = String::with_capacity(message.len());
308 let mut in_backticks = false;
309 let mut digit_run = false;
310 let mut previous_was_space = false;
311
312 for ch in message.chars() {
313 if ch == '`' {
314 if in_backticks {
315 if !out.ends_with("<id>") {
316 out.push_str("<id>");
317 }
318 previous_was_space = false;
319 }
320 in_backticks = !in_backticks;
321 continue;
322 }
323 if in_backticks {
324 continue;
325 }
326 if ch.is_ascii_digit() {
327 if !digit_run {
328 out.push('#');
329 digit_run = true;
330 previous_was_space = false;
331 }
332 continue;
333 }
334 digit_run = false;
335 let lowered = ch.to_ascii_lowercase();
336 if lowered.is_ascii_alphanumeric() || lowered == '-' || lowered == '_' {
337 out.push(lowered);
338 previous_was_space = false;
339 continue;
340 }
341 if !previous_was_space {
342 out.push(' ');
343 previous_was_space = true;
344 }
345 }
346
347 out.split_whitespace().collect::<Vec<_>>().join(" ")
348}
349
350#[cfg(test)]
351mod tests {
352 use super::{
353 LEAN_FRONTIER_SCHEMA_VERSION, LeanDiagnosticSeverity, extract_frontier_report,
354 normalize_message_for_signature, parse_diagnostic_line,
355 };
356
357 #[test]
358 fn parse_diagnostic_line_extracts_expected_fields() {
359 let line = "error: Asupersync.lean:2874:2: Alternative `cancelChild` has not been provided";
360 let diagnostic = parse_diagnostic_line(line).expect("must parse");
361 assert_eq!(diagnostic.severity, LeanDiagnosticSeverity::Error);
362 assert_eq!(diagnostic.file_path, "Asupersync.lean");
363 assert_eq!(diagnostic.line, 2874);
364 assert_eq!(diagnostic.column, 2);
365 assert_eq!(diagnostic.error_code, "constructor-alternative-missing");
366 assert_eq!(diagnostic.failure_mode, "missing-lemma");
367 }
368
369 #[test]
370 fn signature_normalization_stabilizes_identifiers_and_numbers() {
371 let message = "Unknown identifier `setRegion_structural_preserves_wellformed` at 2335";
372 let normalized = normalize_message_for_signature(message);
373 assert_eq!(normalized, "unknown identifier <id> at #");
374 }
375
376 #[test]
377 fn extraction_is_deterministic_for_same_input() {
378 let log = "\
379error: Asupersync.lean:10:2: Unknown identifier `x`\n\
380error: Asupersync.lean:11:2: Unknown identifier `y`\n\
381error: Asupersync.lean:12:3: Type mismatch\n\
382warning: Asupersync.lean:13:5: unused variable `h`\n\
383";
384 let report_a = extract_frontier_report(log, "sample.log", None);
385 let report_b = extract_frontier_report(log, "sample.log", None);
386 assert_eq!(report_a, report_b);
387 assert_eq!(report_a.schema_version, LEAN_FRONTIER_SCHEMA_VERSION);
388 assert_eq!(report_a.diagnostics_total, 4);
389 assert_eq!(report_a.errors_total, 3);
390 assert_eq!(report_a.warnings_total, 1);
391 }
392}