Skip to main content

solverforge_console/
lib.rs

1//! Colorful console output for solver metrics.
2//!
3//! Provides a custom `tracing` layer that formats solver events with colors.
4//!
5//! ## Log Levels
6//!
7//! - **INFO**: Lifecycle events (solving/phase start/end)
8//! - **DEBUG**: Progress updates (1/sec with speed and score)
9//! - **TRACE**: Individual step evaluations
10
11use num_format::{Locale, ToFormattedString};
12use owo_colors::OwoColorize;
13use std::io::{self, Write};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::OnceLock;
16use std::time::Instant;
17use tracing::field::{Field, Visit};
18use tracing::{Event, Level, Subscriber};
19use tracing_subscriber::layer::Context;
20use tracing_subscriber::layer::SubscriberExt;
21use tracing_subscriber::util::SubscriberInitExt;
22use tracing_subscriber::{EnvFilter, Layer};
23
24static INIT: OnceLock<()> = OnceLock::new();
25static EPOCH: OnceLock<Instant> = OnceLock::new();
26static SOLVE_START_NANOS: AtomicU64 = AtomicU64::new(0);
27
28/// Package version for banner display.
29const VERSION: &str = env!("CARGO_PKG_VERSION");
30
31/// Initializes the solver console output.
32///
33/// Safe to call multiple times - only the first call has effect.
34/// Prints the SolverForge banner and sets up tracing.
35pub fn init() {
36    INIT.get_or_init(|| {
37        print_banner();
38
39        let filter = EnvFilter::builder()
40            .with_default_directive("solverforge_solver=info".parse().unwrap())
41            .from_env_lossy()
42            .add_directive("solverforge_dynamic=info".parse().unwrap());
43
44        let _ = tracing_subscriber::registry()
45            .with(filter)
46            .with(SolverConsoleLayer)
47            .try_init();
48    });
49}
50
51// Marks the start of solving for elapsed time tracking.
52fn mark_solve_start() {
53    let epoch = EPOCH.get_or_init(Instant::now);
54    let nanos = epoch.elapsed().as_nanos() as u64;
55    SOLVE_START_NANOS.store(nanos, Ordering::Relaxed);
56}
57
58// Returns elapsed time since solve start.
59fn elapsed_secs() -> f64 {
60    let Some(epoch) = EPOCH.get() else {
61        return 0.0;
62    };
63    let start_nanos = SOLVE_START_NANOS.load(Ordering::Relaxed);
64    let now_nanos = epoch.elapsed().as_nanos() as u64;
65    (now_nanos - start_nanos) as f64 / 1_000_000_000.0
66}
67
68fn print_banner() {
69    let banner = r#"
70 ____        _                 _____
71/ ___|  ___ | |_   _____ _ __ |  ___|__  _ __ __ _  ___
72\___ \ / _ \| \ \ / / _ \ '__|| |_ / _ \| '__/ _` |/ _ \
73 ___) | (_) | |\ V /  __/ |   |  _| (_) | | | (_| |  __/
74|____/ \___/|_| \_/ \___|_|   |_|  \___/|_|  \__, |\___|
75                                             |___/
76"#;
77
78    let version_line = format!(
79        "                   v{} - Zero-Erasure Constraint Solver\n",
80        VERSION
81    );
82
83    let mut stdout = io::stdout().lock();
84    let _ = writeln!(stdout, "{}", banner.bright_cyan());
85    let _ = writeln!(stdout, "{}", version_line.bright_white().bold());
86    let _ = stdout.flush();
87}
88
89/// A tracing layer that formats solver events with colors.
90pub struct SolverConsoleLayer;
91
92impl<S: Subscriber> Layer<S> for SolverConsoleLayer {
93    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
94        let metadata = event.metadata();
95        let target = metadata.target();
96
97        // Accept events from solver modules
98        if !target.starts_with("solverforge_solver")
99            && !target.starts_with("solverforge_dynamic")
100            && !target.starts_with("solverforge_py")
101            && !target.starts_with("solverforge::")
102        {
103            return;
104        }
105
106        let mut visitor = EventVisitor::default();
107        event.record(&mut visitor);
108
109        let level = *metadata.level();
110        let output = format_event(&visitor, level);
111        if !output.is_empty() {
112            let _ = writeln!(io::stdout(), "{}", output);
113        }
114    }
115}
116
117#[derive(Default)]
118struct EventVisitor {
119    event: Option<String>,
120    phase: Option<String>,
121    phase_index: Option<u64>,
122    steps: Option<u64>,
123    speed: Option<u64>,
124    score: Option<String>,
125    step: Option<u64>,
126    entity: Option<u64>,
127    accepted: Option<bool>,
128    duration_ms: Option<u64>,
129    entity_count: Option<u64>,
130    value_count: Option<u64>,
131    constraint_count: Option<u64>,
132    time_limit_secs: Option<u64>,
133    feasible: Option<bool>,
134    moves_speed: Option<u64>,
135    calc_speed: Option<u64>,
136    acceptance_rate: Option<String>,
137}
138
139impl Visit for EventVisitor {
140    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
141        let s = format!("{:?}", value);
142        match field.name() {
143            "event" => self.event = Some(s.trim_matches('"').to_string()),
144            "phase" => self.phase = Some(s.trim_matches('"').to_string()),
145            "score" => self.score = Some(s.trim_matches('"').to_string()),
146            _ => {}
147        }
148    }
149
150    fn record_u64(&mut self, field: &Field, value: u64) {
151        match field.name() {
152            "phase_index" => self.phase_index = Some(value),
153            "steps" => self.steps = Some(value),
154            "speed" => self.speed = Some(value),
155            "step" => self.step = Some(value),
156            "entity" => self.entity = Some(value),
157            "duration_ms" => self.duration_ms = Some(value),
158            "entity_count" => self.entity_count = Some(value),
159            "value_count" => self.value_count = Some(value),
160            "constraint_count" => self.constraint_count = Some(value),
161            "time_limit_secs" => self.time_limit_secs = Some(value),
162            "moves_speed" => self.moves_speed = Some(value),
163            "calc_speed" => self.calc_speed = Some(value),
164            _ => {}
165        }
166    }
167
168    fn record_i64(&mut self, field: &Field, value: i64) {
169        self.record_u64(field, value as u64);
170    }
171
172    fn record_bool(&mut self, field: &Field, value: bool) {
173        match field.name() {
174            "accepted" => self.accepted = Some(value),
175            "feasible" => self.feasible = Some(value),
176            _ => {}
177        }
178    }
179
180    fn record_str(&mut self, field: &Field, value: &str) {
181        match field.name() {
182            "event" => self.event = Some(value.to_string()),
183            "phase" => self.phase = Some(value.to_string()),
184            "score" => self.score = Some(value.to_string()),
185            "acceptance_rate" => self.acceptance_rate = Some(value.to_string()),
186            _ => {}
187        }
188    }
189}
190
191fn format_event(v: &EventVisitor, level: Level) -> String {
192    let event = v.event.as_deref().unwrap_or("");
193
194    match event {
195        "solve_start" => format_solve_start(v),
196        "solve_end" => format_solve_end(v),
197        "phase_start" => format_phase_start(v),
198        "phase_end" => format_phase_end(v),
199        "progress" => format_progress(v),
200        "step" => format_step(v, level),
201        _ => String::new(),
202    }
203}
204
205fn format_elapsed() -> String {
206    format!("{:>7.3}s", elapsed_secs())
207        .bright_black()
208        .to_string()
209}
210
211fn format_solve_start(v: &EventVisitor) -> String {
212    mark_solve_start();
213    let entities = v.entity_count.unwrap_or(0);
214    let values = v.value_count.unwrap_or(0);
215    let constraints = v.constraint_count.unwrap_or(0);
216    let time_limit = v.time_limit_secs.unwrap_or(0);
217    let scale = calculate_problem_scale(entities as usize, values as usize);
218
219    let mut output = format!(
220        "{} {} Solving │ {} entities │ {} values │ scale {}",
221        format_elapsed(),
222        "▶".bright_green().bold(),
223        entities.to_formatted_string(&Locale::en).bright_yellow(),
224        values.to_formatted_string(&Locale::en).bright_yellow(),
225        scale.bright_magenta()
226    );
227
228    if constraints > 0 {
229        output.push_str(&format!(
230            " │ {} constraints",
231            constraints.to_formatted_string(&Locale::en).bright_yellow()
232        ));
233    }
234
235    if time_limit > 0 {
236        output.push_str(&format!(
237            " │ {}s limit",
238            time_limit.to_formatted_string(&Locale::en).bright_yellow()
239        ));
240    }
241
242    output
243}
244
245fn format_solve_end(v: &EventVisitor) -> String {
246    let score = v.score.as_deref().unwrap_or("N/A");
247    let is_feasible = v
248        .feasible
249        .unwrap_or_else(|| !score.contains('-') || score.starts_with("0hard"));
250
251    let status = if is_feasible {
252        "FEASIBLE".bright_green().bold().to_string()
253    } else {
254        "INFEASIBLE".bright_red().bold().to_string()
255    };
256
257    let mut output = format!(
258        "{} {} Solving complete │ {} │ {}",
259        format_elapsed(),
260        "■".bright_cyan().bold(),
261        format_score(score),
262        status
263    );
264
265    // Summary box
266    output.push_str("\n\n");
267    output.push_str(
268        &"╔══════════════════════════════════════════════════════════╗"
269            .bright_cyan()
270            .to_string(),
271    );
272    output.push('\n');
273
274    let status_text = if is_feasible {
275        "FEASIBLE SOLUTION FOUND"
276    } else {
277        "INFEASIBLE (hard constraints violated)"
278    };
279    let inner_width: usize = 58;
280    let total_pad = inner_width.saturating_sub(status_text.len());
281    let left_pad = total_pad / 2;
282    let right_pad = total_pad - left_pad;
283    let status_colored = if is_feasible {
284        status_text.bright_green().bold().to_string()
285    } else {
286        status_text.bright_red().bold().to_string()
287    };
288    output.push_str(&format!(
289        "{}{}{}{}{}",
290        "║".bright_cyan(),
291        " ".repeat(left_pad),
292        status_colored,
293        " ".repeat(right_pad),
294        "║".bright_cyan()
295    ));
296    output.push('\n');
297
298    output.push_str(
299        &"╠══════════════════════════════════════════════════════════╣"
300            .bright_cyan()
301            .to_string(),
302    );
303    output.push('\n');
304
305    output.push_str(&format!(
306        "{}  {:<18}{:>36}  {}",
307        "║".bright_cyan(),
308        "Final Score:",
309        score,
310        "║".bright_cyan()
311    ));
312    output.push('\n');
313
314    output.push_str(
315        &"╚══════════════════════════════════════════════════════════╝"
316            .bright_cyan()
317            .to_string(),
318    );
319    output.push('\n');
320
321    output
322}
323
324fn format_phase_start(v: &EventVisitor) -> String {
325    let phase = v.phase.as_deref().unwrap_or("Unknown");
326
327    format!(
328        "{} {} {} started",
329        format_elapsed(),
330        "▶".bright_blue(),
331        phase.white().bold()
332    )
333}
334
335fn format_phase_end(v: &EventVisitor) -> String {
336    let phase = v.phase.as_deref().unwrap_or("Unknown");
337    let steps = v.steps.unwrap_or(0);
338    let moves_speed = v.moves_speed.unwrap_or(v.speed.unwrap_or(0));
339    let score = v.score.as_deref().unwrap_or("N/A");
340    let duration = v.duration_ms.unwrap_or(0);
341
342    let mut output = format!(
343        "{} {} {} ended │ {} │ {} steps │ {} moves/s",
344        format_elapsed(),
345        "◀".bright_blue(),
346        phase.white().bold(),
347        format_duration_ms(duration).yellow(),
348        steps.to_formatted_string(&Locale::en).white(),
349        moves_speed
350            .to_formatted_string(&Locale::en)
351            .bright_magenta()
352            .bold(),
353    );
354
355    if let Some(calc_speed) = v.calc_speed {
356        output.push_str(&format!(
357            " │ {} calcs/s",
358            calc_speed
359                .to_formatted_string(&Locale::en)
360                .bright_magenta()
361                .bold()
362        ));
363    }
364
365    if let Some(ref rate) = v.acceptance_rate {
366        output.push_str(&format!(" │ {} accepted", rate.bright_yellow()));
367    }
368
369    output.push_str(&format!(" │ {}", format_score(score)));
370
371    output
372}
373
374fn format_progress(v: &EventVisitor) -> String {
375    let steps = v.steps.unwrap_or(0);
376    let speed = v.speed.unwrap_or(0);
377    let score = v.score.as_deref().unwrap_or("N/A");
378
379    format!(
380        "{} {} {:>10} steps │ {:>12}/s │ {}",
381        format_elapsed(),
382        "⚡".bright_cyan(),
383        steps.to_formatted_string(&Locale::en).white(),
384        speed
385            .to_formatted_string(&Locale::en)
386            .bright_magenta()
387            .bold(),
388        format_score(score)
389    )
390}
391
392fn format_step(v: &EventVisitor, level: Level) -> String {
393    if level != Level::TRACE {
394        return String::new();
395    }
396
397    let step = v.step.unwrap_or(0);
398    let entity = v.entity.unwrap_or(0);
399    let score = v.score.as_deref().unwrap_or("N/A");
400    let accepted = v.accepted.unwrap_or(false);
401
402    let icon = if accepted {
403        "✓".bright_green().to_string()
404    } else {
405        "✗".bright_red().to_string()
406    };
407
408    format!(
409        "{} {} Step {:>10} │ Entity {:>6} │ {}",
410        format_elapsed(),
411        icon,
412        step.to_formatted_string(&Locale::en).bright_black(),
413        entity.to_formatted_string(&Locale::en).bright_black(),
414        format_score(score).bright_black()
415    )
416}
417
418fn format_duration_ms(ms: u64) -> String {
419    if ms < 1000 {
420        format!("{}ms", ms)
421    } else if ms < 60_000 {
422        format!("{:.2}s", ms as f64 / 1000.0)
423    } else {
424        let mins = ms / 60_000;
425        let secs = (ms % 60_000) / 1000;
426        format!("{}m {}s", mins, secs)
427    }
428}
429
430fn format_score(score: &str) -> String {
431    if score.contains("hard") {
432        let parts: Vec<&str> = score.split('/').collect();
433        if parts.len() == 2 {
434            let hard = parts[0].trim_end_matches("hard");
435            let soft = parts[1].trim_end_matches("soft");
436
437            let hard_num: f64 = hard.parse().unwrap_or(0.0);
438            let soft_num: f64 = soft.parse().unwrap_or(0.0);
439
440            let hard_str = if hard_num < 0.0 {
441                format!("{}hard", hard).bright_red().to_string()
442            } else {
443                format!("{}hard", hard).bright_green().to_string()
444            };
445
446            let soft_str = if soft_num < 0.0 {
447                format!("{}soft", soft).yellow().to_string()
448            } else if soft_num > 0.0 {
449                format!("{}soft", soft).bright_green().to_string()
450            } else {
451                format!("{}soft", soft).white().to_string()
452            };
453
454            return format!("{}/{}", hard_str, soft_str);
455        }
456    }
457
458    if let Ok(n) = score.parse::<i32>() {
459        if n < 0 {
460            return score.bright_red().to_string();
461        } else if n > 0 {
462            return score.bright_green().to_string();
463        }
464    }
465
466    score.white().to_string()
467}
468
469fn calculate_problem_scale(entity_count: usize, value_count: usize) -> String {
470    if entity_count == 0 || value_count == 0 {
471        return "0".to_string();
472    }
473
474    let log_scale = (entity_count as f64) * (value_count as f64).log10();
475    let exponent = log_scale.floor() as i32;
476    let mantissa = 10f64.powf(log_scale - exponent as f64);
477
478    format!("{:.3} x 10^{}", mantissa, exponent)
479}