Skip to main content

kizzasi_logic/
visualization.rs

1//! Visualization and debugging tools for constraints
2//!
3//! This module provides utilities for:
4//! - Inspecting constraint states and violations
5//! - Debugging constraint composition and decomposition
6//! - Analyzing constraint satisfaction over time
7//! - Generating human-readable constraint reports
8
9use crate::ViolationComputable;
10use std::collections::HashMap;
11use std::fmt;
12
13/// Statistics about constraint violations over a sequence of values
14#[derive(Debug, Clone)]
15pub struct ViolationStats {
16    /// Total number of samples
17    pub num_samples: usize,
18    /// Number of violations
19    pub num_violations: usize,
20    /// Mean violation magnitude
21    pub mean_violation: f32,
22    /// Maximum violation magnitude
23    pub max_violation: f32,
24    /// Minimum violation magnitude (for violated samples)
25    pub min_violation: f32,
26    /// Standard deviation of violations
27    pub std_violation: f32,
28}
29
30impl ViolationStats {
31    /// Create violation statistics from a sequence of values
32    pub fn from_values<C: ViolationComputable>(constraint: &C, values: &[f32]) -> Self {
33        let num_samples = values.len();
34        let mut num_violations = 0;
35        let mut violation_sum = 0.0f32;
36        let mut max_violation = 0.0f32;
37        let mut min_violation = f32::MAX;
38        let mut violations = Vec::new();
39
40        for &val in values {
41            let viol = constraint.violation(&[val]);
42            if viol > 1e-6 {
43                num_violations += 1;
44                violation_sum += viol;
45                max_violation = max_violation.max(viol);
46                min_violation = min_violation.min(viol);
47                violations.push(viol);
48            }
49        }
50
51        let mean_violation = if num_violations > 0 {
52            violation_sum / num_violations as f32
53        } else {
54            0.0
55        };
56
57        let std_violation = if num_violations > 0 {
58            let variance: f32 = violations
59                .iter()
60                .map(|&v| (v - mean_violation).powi(2))
61                .sum::<f32>()
62                / num_violations as f32;
63            variance.sqrt()
64        } else {
65            0.0
66        };
67
68        if min_violation == f32::MAX {
69            min_violation = 0.0;
70        }
71
72        Self {
73            num_samples,
74            num_violations,
75            mean_violation,
76            max_violation,
77            min_violation,
78            std_violation,
79        }
80    }
81
82    /// Violation rate (fraction of samples that violate)
83    pub fn violation_rate(&self) -> f32 {
84        if self.num_samples == 0 {
85            0.0
86        } else {
87            self.num_violations as f32 / self.num_samples as f32
88        }
89    }
90
91    /// Satisfaction rate (fraction of samples that satisfy)
92    pub fn satisfaction_rate(&self) -> f32 {
93        1.0 - self.violation_rate()
94    }
95}
96
97impl fmt::Display for ViolationStats {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        writeln!(f, "Violation Statistics:")?;
100        writeln!(f, "  Samples: {}", self.num_samples)?;
101        writeln!(
102            f,
103            "  Violations: {} ({:.1}%)",
104            self.num_violations,
105            self.violation_rate() * 100.0
106        )?;
107        writeln!(
108            f,
109            "  Satisfactions: {} ({:.1}%)",
110            self.num_samples - self.num_violations,
111            self.satisfaction_rate() * 100.0
112        )?;
113        writeln!(f, "  Mean violation: {:.4}", self.mean_violation)?;
114        writeln!(f, "  Max violation: {:.4}", self.max_violation)?;
115        writeln!(f, "  Min violation: {:.4}", self.min_violation)?;
116        writeln!(f, "  Std violation: {:.4}", self.std_violation)?;
117        Ok(())
118    }
119}
120
121/// Time series analysis of constraint satisfaction
122pub struct ConstraintTimeSeries {
123    name: String,
124    values: Vec<f32>,
125    violations: Vec<f32>,
126    timestamps: Vec<usize>,
127}
128
129impl ConstraintTimeSeries {
130    /// Create a new time series tracker
131    pub fn new(name: impl Into<String>) -> Self {
132        Self {
133            name: name.into(),
134            values: Vec::new(),
135            violations: Vec::new(),
136            timestamps: Vec::new(),
137        }
138    }
139
140    /// Add a sample to the time series
141    pub fn add_sample<C: ViolationComputable>(
142        &mut self,
143        constraint: &C,
144        value: f32,
145        timestamp: usize,
146    ) {
147        let violation = constraint.violation(&[value]);
148        self.values.push(value);
149        self.violations.push(violation);
150        self.timestamps.push(timestamp);
151    }
152
153    /// Get statistics for the entire time series
154    pub fn stats(&self) -> ViolationStats {
155        let num_samples = self.values.len();
156        let mut num_violations = 0;
157        let mut violation_sum = 0.0f32;
158        let mut max_violation = 0.0f32;
159        let mut min_violation = f32::MAX;
160        let mut active_violations = Vec::new();
161
162        for &viol in &self.violations {
163            if viol > 1e-6 {
164                num_violations += 1;
165                violation_sum += viol;
166                max_violation = max_violation.max(viol);
167                min_violation = min_violation.min(viol);
168                active_violations.push(viol);
169            }
170        }
171
172        let mean_violation = if num_violations > 0 {
173            violation_sum / num_violations as f32
174        } else {
175            0.0
176        };
177
178        let std_violation = if num_violations > 0 {
179            let variance: f32 = active_violations
180                .iter()
181                .map(|&v| (v - mean_violation).powi(2))
182                .sum::<f32>()
183                / num_violations as f32;
184            variance.sqrt()
185        } else {
186            0.0
187        };
188
189        if min_violation == f32::MAX {
190            min_violation = 0.0;
191        }
192
193        ViolationStats {
194            num_samples,
195            num_violations,
196            mean_violation,
197            max_violation,
198            min_violation,
199            std_violation,
200        }
201    }
202
203    /// Get the name of this time series
204    pub fn name(&self) -> &str {
205        &self.name
206    }
207
208    /// Get violation history
209    pub fn violations(&self) -> &[f32] {
210        &self.violations
211    }
212
213    /// Get value history
214    pub fn values(&self) -> &[f32] {
215        &self.values
216    }
217
218    /// Get timestamps
219    pub fn timestamps(&self) -> &[usize] {
220        &self.timestamps
221    }
222
223    /// Find periods of continuous violation
224    pub fn violation_periods(&self) -> Vec<(usize, usize, f32)> {
225        let mut periods = Vec::new();
226        let mut in_violation = false;
227        let mut start = 0;
228        let mut max_viol_in_period = 0.0f32;
229
230        for (i, &viol) in self.violations.iter().enumerate() {
231            if viol > 1e-6 {
232                if !in_violation {
233                    in_violation = true;
234                    start = i;
235                    max_viol_in_period = viol;
236                } else {
237                    max_viol_in_period = max_viol_in_period.max(viol);
238                }
239            } else if in_violation {
240                periods.push((start, i - 1, max_viol_in_period));
241                in_violation = false;
242                max_viol_in_period = 0.0;
243            }
244        }
245
246        // Handle case where violation continues to end
247        if in_violation {
248            periods.push((start, self.violations.len() - 1, max_viol_in_period));
249        }
250
251        periods
252    }
253}
254
255/// Debugging report for multiple constraints
256pub struct ConstraintReport {
257    constraint_names: Vec<String>,
258    stats: HashMap<String, ViolationStats>,
259}
260
261impl ConstraintReport {
262    /// Create a new constraint report
263    pub fn new() -> Self {
264        Self {
265            constraint_names: Vec::new(),
266            stats: HashMap::new(),
267        }
268    }
269
270    /// Add constraint statistics to the report
271    pub fn add_constraint(&mut self, name: impl Into<String>, stats: ViolationStats) {
272        let name = name.into();
273        self.constraint_names.push(name.clone());
274        self.stats.insert(name, stats);
275    }
276
277    /// Get statistics for a specific constraint
278    pub fn get_stats(&self, name: &str) -> Option<&ViolationStats> {
279        self.stats.get(name)
280    }
281
282    /// Get all constraint names
283    pub fn constraint_names(&self) -> &[String] {
284        &self.constraint_names
285    }
286
287    /// Generate a summary report
288    pub fn summary(&self) -> String {
289        let mut report = String::from("Constraint Report\n");
290        report.push_str("=================\n\n");
291
292        for name in &self.constraint_names {
293            if let Some(stats) = self.stats.get(name) {
294                report.push_str(&format!("Constraint: {}\n", name));
295                report.push_str(&format!(
296                    "  Violation rate: {:.1}%\n",
297                    stats.violation_rate() * 100.0
298                ));
299                report.push_str(&format!("  Mean violation: {:.4}\n", stats.mean_violation));
300                report.push_str(&format!("  Max violation: {:.4}\n", stats.max_violation));
301                report.push('\n');
302            }
303        }
304
305        report
306    }
307
308    /// Find the most frequently violated constraint
309    pub fn most_violated(&self) -> Option<(&str, &ViolationStats)> {
310        self.stats
311            .iter()
312            .max_by(|a, b| {
313                a.1.violation_rate()
314                    .partial_cmp(&b.1.violation_rate())
315                    .unwrap_or(std::cmp::Ordering::Equal)
316            })
317            .map(|(name, stats)| (name.as_str(), stats))
318    }
319
320    /// Find the constraint with the largest violations
321    pub fn largest_violations(&self) -> Option<(&str, &ViolationStats)> {
322        self.stats
323            .iter()
324            .max_by(|a, b| {
325                a.1.max_violation
326                    .partial_cmp(&b.1.max_violation)
327                    .unwrap_or(std::cmp::Ordering::Equal)
328            })
329            .map(|(name, stats)| (name.as_str(), stats))
330    }
331}
332
333impl Default for ConstraintReport {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339impl fmt::Display for ConstraintReport {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        write!(f, "{}", self.summary())
342    }
343}
344
345/// Constraint inspector for interactive debugging
346pub struct ConstraintInspector {
347    samples: Vec<f32>,
348}
349
350impl ConstraintInspector {
351    /// Create a new constraint inspector
352    pub fn new() -> Self {
353        Self {
354            samples: Vec::new(),
355        }
356    }
357
358    /// Add a sample value to inspect
359    pub fn add_sample(&mut self, value: f32) {
360        self.samples.push(value);
361    }
362
363    /// Clear all samples
364    pub fn clear(&mut self) {
365        self.samples.clear();
366    }
367
368    /// Inspect a constraint against all samples
369    pub fn inspect<C: ViolationComputable + ?Sized>(
370        &self,
371        constraint: &C,
372        name: &str,
373    ) -> InspectionResult {
374        let mut satisfied = Vec::new();
375        let mut violated = Vec::new();
376
377        for (i, &val) in self.samples.iter().enumerate() {
378            let viol = constraint.violation(&[val]);
379            if viol <= 1e-6 {
380                satisfied.push((i, val));
381            } else {
382                violated.push((i, val, viol));
383            }
384        }
385
386        InspectionResult {
387            constraint_name: name.to_string(),
388            total_samples: self.samples.len(),
389            satisfied,
390            violated,
391        }
392    }
393
394    /// Get sample count
395    pub fn sample_count(&self) -> usize {
396        self.samples.len()
397    }
398
399    /// Get all samples
400    pub fn samples(&self) -> &[f32] {
401        &self.samples
402    }
403}
404
405impl Default for ConstraintInspector {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411/// Result of constraint inspection
412pub struct InspectionResult {
413    constraint_name: String,
414    total_samples: usize,
415    satisfied: Vec<(usize, f32)>,
416    violated: Vec<(usize, f32, f32)>,
417}
418
419impl InspectionResult {
420    /// Get constraint name
421    pub fn constraint_name(&self) -> &str {
422        &self.constraint_name
423    }
424
425    /// Get total number of samples
426    pub fn total_samples(&self) -> usize {
427        self.total_samples
428    }
429
430    /// Get satisfied samples (index, value)
431    pub fn satisfied(&self) -> &[(usize, f32)] {
432        &self.satisfied
433    }
434
435    /// Get violated samples (index, value, violation)
436    pub fn violated(&self) -> &[(usize, f32, f32)] {
437        &self.violated
438    }
439
440    /// Get violation rate
441    pub fn violation_rate(&self) -> f32 {
442        if self.total_samples == 0 {
443            0.0
444        } else {
445            self.violated.len() as f32 / self.total_samples as f32
446        }
447    }
448
449    /// Print a summary
450    pub fn print_summary(&self) {
451        println!("Inspection: {}", self.constraint_name);
452        println!("  Total samples: {}", self.total_samples);
453        println!(
454            "  Satisfied: {} ({:.1}%)",
455            self.satisfied.len(),
456            (self.satisfied.len() as f32 / self.total_samples as f32) * 100.0
457        );
458        println!(
459            "  Violated: {} ({:.1}%)",
460            self.violated.len(),
461            self.violation_rate() * 100.0
462        );
463    }
464
465    /// Print detailed violation information
466    pub fn print_violations(&self, max_items: usize) {
467        println!("\nViolations for {}:", self.constraint_name);
468        let n = self.violated.len().min(max_items);
469        for (i, &(idx, val, viol)) in self.violated.iter().take(n).enumerate() {
470            println!(
471                "  [{}] Sample {}: value={:.4}, violation={:.4}",
472                i + 1,
473                idx,
474                val,
475                viol
476            );
477        }
478        if self.violated.len() > max_items {
479            println!(
480                "  ... and {} more violations",
481                self.violated.len() - max_items
482            );
483        }
484    }
485}
486
487// ============================================================================
488// SVG-based constraint visualization
489// ============================================================================
490
491/// Color maps for heatmaps
492#[derive(Debug, Clone, Copy, Default)]
493pub enum Colormap {
494    #[default]
495    /// Blue → green → yellow (approximate viridis)
496    Viridis,
497    /// Red (violation) → green (satisfied)
498    RdGn,
499    /// Grayscale
500    Grayscale,
501}
502
503impl Colormap {
504    /// Map scalar value `t` in `[0, 1]` to an RGB triple.
505    pub fn map(&self, t: f64) -> (u8, u8, u8) {
506        let t = t.clamp(0.0, 1.0);
507        match self {
508            Colormap::Viridis => {
509                let r = (255.0 * (t * t * t * 0.5 + t * 0.5)).min(255.0) as u8;
510                let g = (255.0 * (0.5 * (1.0 - (2.0 * t - 1.0).abs()))).min(255.0) as u8;
511                let b = (255.0 * ((1.0 - t) * 0.9)).min(255.0) as u8;
512                (r, g, b)
513            }
514            Colormap::RdGn => {
515                let r = (255.0 * (1.0 - t)).min(255.0) as u8;
516                let g = (255.0 * t).min(255.0) as u8;
517                (r, g, 0)
518            }
519            Colormap::Grayscale => {
520                let v = (255.0 * t) as u8;
521                (v, v, v)
522            }
523        }
524    }
525}
526
527/// 2D plot configuration
528#[derive(Debug, Clone)]
529pub struct PlotConfig {
530    /// SVG width in pixels
531    pub width: usize,
532    /// SVG height in pixels
533    pub height: usize,
534    /// x-axis range `(min, max)`
535    pub x_range: (f64, f64),
536    /// y-axis range `(min, max)`
537    pub y_range: (f64, f64),
538    /// Optional title drawn at the top of the SVG
539    pub title: String,
540    /// Draw background grid
541    pub grid: bool,
542    /// Colormap used for heatmap rendering
543    pub colormap: Colormap,
544}
545
546impl Default for PlotConfig {
547    fn default() -> Self {
548        Self {
549            width: 600,
550            height: 600,
551            x_range: (-3.0, 3.0),
552            y_range: (-3.0, 3.0),
553            title: String::new(),
554            grid: true,
555            colormap: Colormap::Viridis,
556        }
557    }
558}
559
560// ────────────────────────────────────────────────────────────────────────────
561// SvgBuilder
562// ────────────────────────────────────────────────────────────────────────────
563
564/// Lightweight SVG string builder — no external dependencies required.
565pub struct SvgBuilder {
566    width: usize,
567    height: usize,
568    elements: Vec<String>,
569}
570
571impl SvgBuilder {
572    /// Create a new builder for an SVG canvas of `width × height` pixels.
573    pub fn new(width: usize, height: usize) -> Self {
574        Self {
575            width,
576            height,
577            elements: Vec::new(),
578        }
579    }
580
581    /// Append a `<rect>` element.
582    pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str, opacity: f64) -> &mut Self {
583        self.elements.push(format!(
584            r#"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="{}" opacity="{:.3}"/>"#,
585            x, y, w, h, fill, opacity
586        ));
587        self
588    }
589
590    /// Append a `<circle>` element.
591    pub fn circle(&mut self, cx: f64, cy: f64, r: f64, fill: &str, stroke: &str) -> &mut Self {
592        self.elements.push(format!(
593            r#"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="{}" stroke="{}"/>"#,
594            cx, cy, r, fill, stroke
595        ));
596        self
597    }
598
599    /// Append a `<line>` element.
600    pub fn line(
601        &mut self,
602        x1: f64,
603        y1: f64,
604        x2: f64,
605        y2: f64,
606        stroke: &str,
607        width: f64,
608    ) -> &mut Self {
609        self.elements.push(format!(
610            r#"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="{}" stroke-width="{:.3}"/>"#,
611            x1, y1, x2, y2, stroke, width
612        ));
613        self
614    }
615
616    /// Append a `<text>` element.
617    pub fn text(&mut self, x: f64, y: f64, content: &str, size: usize, anchor: &str) -> &mut Self {
618        self.elements.push(format!(
619            r#"<text x="{:.3}" y="{:.3}" font-size="{}" text-anchor="{}">{}</text>"#,
620            x,
621            y,
622            size,
623            anchor,
624            escape_xml(content)
625        ));
626        self
627    }
628
629    /// Serialise all accumulated elements into a complete SVG document.
630    pub fn build(&self) -> String {
631        let mut svg = format!(
632            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
633            self.width, self.height, self.width, self.height
634        );
635        for elem in &self.elements {
636            svg.push('\n');
637            svg.push_str(elem);
638        }
639        svg.push_str("\n</svg>");
640        svg
641    }
642}
643
644/// Escape `<`, `>`, `&`, `"` for safe inclusion inside SVG text nodes.
645fn escape_xml(s: &str) -> String {
646    s.replace('&', "&amp;")
647        .replace('<', "&lt;")
648        .replace('>', "&gt;")
649        .replace('"', "&quot;")
650}
651
652// ────────────────────────────────────────────────────────────────────────────
653// ConstraintPlotter
654// ────────────────────────────────────────────────────────────────────────────
655
656/// Renders 2-D constraint regions and trajectories as SVG.
657pub struct ConstraintPlotter {
658    config: PlotConfig,
659}
660
661impl ConstraintPlotter {
662    /// Construct a plotter with the given configuration.
663    pub fn new(config: PlotConfig) -> Self {
664        Self { config }
665    }
666
667    // ── coordinate helpers ────────────────────────────────────────────────
668
669    /// Convert world coordinates `(x, y)` to SVG pixel coordinates.
670    fn world_to_svg(&self, x: f64, y: f64) -> (f64, f64) {
671        let px = (x - self.config.x_range.0) / (self.config.x_range.1 - self.config.x_range.0)
672            * self.config.width as f64;
673        let py = (1.0
674            - (y - self.config.y_range.0) / (self.config.y_range.1 - self.config.y_range.0))
675            * self.config.height as f64;
676        (px, py)
677    }
678
679    // ── optional background helpers ───────────────────────────────────────
680
681    fn push_background(&self, svg: &mut SvgBuilder) {
682        svg.rect(
683            0.0,
684            0.0,
685            self.config.width as f64,
686            self.config.height as f64,
687            "#ffffff",
688            1.0,
689        );
690    }
691
692    fn push_grid(&self, svg: &mut SvgBuilder) {
693        if !self.config.grid {
694            return;
695        }
696        let n_lines = 10usize;
697        let w = self.config.width as f64;
698        let h = self.config.height as f64;
699        for i in 0..=n_lines {
700            let frac = i as f64 / n_lines as f64;
701            let x = frac * w;
702            let y = frac * h;
703            svg.line(x, 0.0, x, h, "#e0e0e0", 0.5);
704            svg.line(0.0, y, w, y, "#e0e0e0", 0.5);
705        }
706    }
707
708    fn push_title(&self, svg: &mut SvgBuilder) {
709        if !self.config.title.is_empty() {
710            svg.text(
711                self.config.width as f64 / 2.0,
712                16.0,
713                &self.config.title,
714                14,
715                "middle",
716            );
717        }
718    }
719
720    // ── public API ────────────────────────────────────────────────────────
721
722    /// Plot the 2D constraint region for `constraint_fn(x, y)`.
723    ///
724    /// Values `< 0` are considered *satisfied* (green / dark for viridis),
725    /// values `≥ 0` are *violated* and rendered with a colour that encodes
726    /// the magnitude.
727    ///
728    /// Returns a self-contained SVG string.
729    pub fn plot_constraint_region<F>(&self, constraint_fn: F, resolution: usize) -> String
730    where
731        F: Fn(f64, f64) -> f64,
732    {
733        let res = resolution.max(1);
734        let (x0, x1) = self.config.x_range;
735        let (y0, y1) = self.config.y_range;
736        let dx = (x1 - x0) / res as f64;
737        let dy = (y1 - y0) / res as f64;
738
739        // First pass: compute all values and find the maximum violation magnitude.
740        let mut values = Vec::with_capacity(res * res);
741        let mut max_viol = 1e-9_f64;
742        for row in 0..res {
743            for col in 0..res {
744                let cx = x0 + (col as f64 + 0.5) * dx;
745                let cy = y0 + (row as f64 + 0.5) * dy;
746                let v = constraint_fn(cx, cy);
747                if v > max_viol {
748                    max_viol = v;
749                }
750                values.push((cx, cy, v));
751            }
752        }
753
754        let cell_w = self.config.width as f64 / res as f64;
755        let cell_h = self.config.height as f64 / res as f64;
756
757        let mut svg = SvgBuilder::new(self.config.width, self.config.height);
758        self.push_background(&mut svg);
759        self.push_grid(&mut svg);
760
761        for (cx, cy, v) in &values {
762            // t = 0 → satisfied (constraint boundary), t = 1 → maximally violated
763            let t = if *v <= 0.0 {
764                0.0
765            } else {
766                (*v / max_viol).clamp(0.0, 1.0)
767            };
768            let (r, g, b) = self.config.colormap.map(t);
769            let fill = format!("#{:02X}{:02X}{:02X}", r, g, b);
770            let (px, py) = self.world_to_svg(*cx - dx * 0.5, *cy + dy * 0.5);
771            svg.rect(px, py, cell_w, cell_h, &fill, 0.85);
772        }
773
774        self.push_title(&mut svg);
775        svg.build()
776    }
777
778    /// Plot the *feasible region* where **all** constraints are satisfied.
779    ///
780    /// Each cell is coloured green when all constraints hold (every `f(x,y) ≤ 0`)
781    /// and red when at least one is violated.
782    pub fn plot_feasible_region<F>(&self, constraints: &[F], resolution: usize) -> String
783    where
784        F: Fn(f64, f64) -> f64,
785    {
786        // Degenerate: no constraints → everything is feasible.
787        if constraints.is_empty() {
788            let mut svg = SvgBuilder::new(self.config.width, self.config.height);
789            self.push_background(&mut svg);
790            self.push_grid(&mut svg);
791            svg.rect(
792                0.0,
793                0.0,
794                self.config.width as f64,
795                self.config.height as f64,
796                "#00cc00",
797                0.4,
798            );
799            self.push_title(&mut svg);
800            return svg.build();
801        }
802
803        let res = resolution.max(1);
804        let (x0, x1) = self.config.x_range;
805        let (y0, y1) = self.config.y_range;
806        let dx = (x1 - x0) / res as f64;
807        let dy = (y1 - y0) / res as f64;
808        let cell_w = self.config.width as f64 / res as f64;
809        let cell_h = self.config.height as f64 / res as f64;
810
811        let mut svg = SvgBuilder::new(self.config.width, self.config.height);
812        self.push_background(&mut svg);
813        self.push_grid(&mut svg);
814
815        for row in 0..res {
816            for col in 0..res {
817                let cx = x0 + (col as f64 + 0.5) * dx;
818                let cy = y0 + (row as f64 + 0.5) * dy;
819
820                let max_viol = constraints
821                    .iter()
822                    .map(|f| f(cx, cy))
823                    .fold(f64::NEG_INFINITY, f64::max);
824
825                let fill = if max_viol <= 0.0 {
826                    "#00cc00".to_string()
827                } else {
828                    "#cc0000".to_string()
829                };
830                let (px, py) = self.world_to_svg(cx - dx * 0.5, cy + dy * 0.5);
831                svg.rect(px, py, cell_w, cell_h, &fill, 0.6);
832            }
833        }
834
835        self.push_title(&mut svg);
836        svg.build()
837    }
838
839    /// Plot a sequence of 2-D points as a connected trajectory.
840    ///
841    /// If `constraint_fn` is provided, points that violate the constraint
842    /// (`f(x, y) > 0`) are rendered in red; satisfied points are green.
843    pub fn plot_trajectory(
844        &self,
845        points: &[(f64, f64)],
846        constraint_fn: Option<&dyn Fn(f64, f64) -> f64>,
847    ) -> String {
848        let mut svg = SvgBuilder::new(self.config.width, self.config.height);
849        self.push_background(&mut svg);
850        self.push_grid(&mut svg);
851
852        if points.is_empty() {
853            self.push_title(&mut svg);
854            return svg.build();
855        }
856
857        // Draw edges
858        let svg_pts: Vec<(f64, f64)> = points
859            .iter()
860            .map(|&(x, y)| self.world_to_svg(x, y))
861            .collect();
862
863        for i in 1..svg_pts.len() {
864            let (x1, y1) = svg_pts[i - 1];
865            let (x2, y2) = svg_pts[i];
866            svg.line(x1, y1, x2, y2, "#888888", 1.5);
867        }
868
869        // Draw nodes
870        for (idx, &(x, y)) in points.iter().enumerate() {
871            let violated = constraint_fn.is_some_and(|f| f(x, y) > 0.0);
872            let fill = if violated { "#cc0000" } else { "#00aa44" };
873            let (px, py) = svg_pts[idx];
874            svg.circle(px, py, 4.0, fill, "#333333");
875        }
876
877        self.push_title(&mut svg);
878        svg.build()
879    }
880}
881
882// ────────────────────────────────────────────────────────────────────────────
883// Constraint network
884// ────────────────────────────────────────────────────────────────────────────
885
886/// A node in a constraint dependency graph.
887#[derive(Debug, Clone)]
888pub struct ConstraintNetworkNode {
889    /// Unique numeric identifier for the node.
890    pub id: usize,
891    /// Human-readable label (e.g. "x >= 0").
892    pub label: String,
893    /// Category of the constraint (e.g. "range", "linear", "temporal").
894    pub constraint_type: String,
895    /// Satisfaction status: `Some(true)` = satisfied, `Some(false)` = violated,
896    /// `None` = unknown.
897    pub satisfied: Option<bool>,
898}
899
900/// A directed edge in a constraint dependency graph.
901#[derive(Debug, Clone)]
902pub struct ConstraintNetworkEdge {
903    /// Source node id.
904    pub from: usize,
905    /// Destination node id.
906    pub to: usize,
907    /// Relationship label (e.g. "AND", "implies").
908    pub label: String,
909}
910
911/// Render a constraint dependency network as an SVG diagram.
912///
913/// Nodes are laid out on a circle; edges are drawn as lines with labels.
914pub fn render_constraint_network(
915    nodes: &[ConstraintNetworkNode],
916    edges: &[ConstraintNetworkEdge],
917    width: usize,
918    height: usize,
919) -> String {
920    let mut svg = SvgBuilder::new(width, height);
921
922    // White background
923    svg.rect(0.0, 0.0, width as f64, height as f64, "#ffffff", 1.0);
924
925    if nodes.is_empty() {
926        return svg.build();
927    }
928
929    let center_x = width as f64 / 2.0;
930    let center_y = height as f64 / 2.0;
931    let radius = (width.min(height) as f64 / 2.0 - 60.0).max(20.0);
932    let node_r = 22.0_f64;
933
934    // Compute positions
935    let positions: Vec<(f64, f64)> = nodes
936        .iter()
937        .enumerate()
938        .map(|(i, _)| {
939            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (nodes.len() as f64);
940            let cx = center_x + radius * angle.cos();
941            let cy = center_y + radius * angle.sin();
942            (cx, cy)
943        })
944        .collect();
945
946    // Build a quick id → position index map
947    let id_to_idx: std::collections::HashMap<usize, usize> = nodes
948        .iter()
949        .enumerate()
950        .map(|(idx, n)| (n.id, idx))
951        .collect();
952
953    // Draw edges first (below nodes)
954    for edge in edges {
955        let from_idx = match id_to_idx.get(&edge.from) {
956            Some(&i) => i,
957            None => continue,
958        };
959        let to_idx = match id_to_idx.get(&edge.to) {
960            Some(&i) => i,
961            None => continue,
962        };
963        let (x1, y1) = positions[from_idx];
964        let (x2, y2) = positions[to_idx];
965        svg.line(x1, y1, x2, y2, "#666666", 1.5);
966
967        // Edge label at midpoint
968        if !edge.label.is_empty() {
969            svg.text(
970                (x1 + x2) / 2.0,
971                (y1 + y2) / 2.0 - 4.0,
972                &edge.label,
973                10,
974                "middle",
975            );
976        }
977    }
978
979    // Draw nodes
980    for (i, node) in nodes.iter().enumerate() {
981        let (cx, cy) = positions[i];
982        let fill = match node.satisfied {
983            Some(true) => "#44bb77",
984            Some(false) => "#cc4444",
985            None => "#8888cc",
986        };
987        svg.circle(cx, cy, node_r, fill, "#333333");
988        svg.text(cx, cy + 4.0, &node.label, 10, "middle");
989    }
990
991    svg.build()
992}
993
994// ────────────────────────────────────────────────────────────────────────────
995// Violation heatmap for scattered points
996// ────────────────────────────────────────────────────────────────────────────
997
998/// Render a scatter-plot heatmap where each point is coloured by its violation
999/// magnitude.
1000///
1001/// Points and violations must be the same length; any mismatch silently uses
1002/// the shorter length. Returns a self-contained SVG string.
1003pub fn render_violation_heatmap(
1004    points: &[(f64, f64)],
1005    violations: &[f64],
1006    config: &PlotConfig,
1007) -> String {
1008    let mut svg = SvgBuilder::new(config.width, config.height);
1009    svg.rect(
1010        0.0,
1011        0.0,
1012        config.width as f64,
1013        config.height as f64,
1014        "#ffffff",
1015        1.0,
1016    );
1017
1018    if config.grid {
1019        let n_lines = 10usize;
1020        let w = config.width as f64;
1021        let h = config.height as f64;
1022        for i in 0..=n_lines {
1023            let frac = i as f64 / n_lines as f64;
1024            svg.line(frac * w, 0.0, frac * w, h, "#e0e0e0", 0.5);
1025            svg.line(0.0, frac * h, w, frac * h, "#e0e0e0", 0.5);
1026        }
1027    }
1028
1029    let max_viol = violations.iter().cloned().fold(1e-9_f64, f64::max);
1030
1031    let n = points.len().min(violations.len());
1032    for i in 0..n {
1033        let (x, y) = points[i];
1034        let v = violations[i];
1035
1036        // Map world coords to SVG
1037        let px =
1038            (x - config.x_range.0) / (config.x_range.1 - config.x_range.0) * config.width as f64;
1039        let py = (1.0 - (y - config.y_range.0) / (config.y_range.1 - config.y_range.0))
1040            * config.height as f64;
1041
1042        let t = (v / max_viol).clamp(0.0, 1.0);
1043        let (r, g, b) = config.colormap.map(t);
1044        let fill = format!("#{:02X}{:02X}{:02X}", r, g, b);
1045
1046        svg.circle(px, py, 6.0, &fill, "#333333");
1047    }
1048
1049    if !config.title.is_empty() {
1050        svg.text(config.width as f64 / 2.0, 16.0, &config.title, 14, "middle");
1051    }
1052
1053    svg.build()
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use crate::ConstraintBuilder;
1060
1061    #[test]
1062    fn test_violation_stats() {
1063        let constraint = ConstraintBuilder::new()
1064            .name("test")
1065            .less_than(10.0)
1066            .build()
1067            .unwrap();
1068
1069        let values = vec![5.0, 8.0, 12.0, 15.0, 9.0];
1070        let stats = ViolationStats::from_values(&constraint, &values);
1071
1072        assert_eq!(stats.num_samples, 5);
1073        assert_eq!(stats.num_violations, 2); // 12.0 and 15.0 violate
1074        assert!(stats.violation_rate() > 0.39 && stats.violation_rate() < 0.41);
1075    }
1076
1077    #[test]
1078    fn test_constraint_time_series() {
1079        let constraint = ConstraintBuilder::new()
1080            .name("test")
1081            .less_than(10.0)
1082            .build()
1083            .unwrap();
1084
1085        let mut ts = ConstraintTimeSeries::new("test_series");
1086        ts.add_sample(&constraint, 5.0, 0);
1087        ts.add_sample(&constraint, 12.0, 1);
1088        ts.add_sample(&constraint, 15.0, 2);
1089        ts.add_sample(&constraint, 8.0, 3);
1090
1091        let stats = ts.stats();
1092        assert_eq!(stats.num_samples, 4);
1093        assert_eq!(stats.num_violations, 2);
1094
1095        let periods = ts.violation_periods();
1096        assert_eq!(periods.len(), 1); // One continuous period
1097        assert_eq!(periods[0].0, 1); // Starts at index 1
1098        assert_eq!(periods[0].1, 2); // Ends at index 2
1099    }
1100
1101    #[test]
1102    fn test_constraint_report() {
1103        let constraint1 = ConstraintBuilder::new()
1104            .name("c1")
1105            .less_than(10.0)
1106            .build()
1107            .unwrap();
1108
1109        let constraint2 = ConstraintBuilder::new()
1110            .name("c2")
1111            .less_than(5.0)
1112            .build()
1113            .unwrap();
1114
1115        let values = vec![3.0, 7.0, 12.0];
1116
1117        let mut report = ConstraintReport::new();
1118        report.add_constraint("c1", ViolationStats::from_values(&constraint1, &values));
1119        report.add_constraint("c2", ViolationStats::from_values(&constraint2, &values));
1120
1121        assert_eq!(report.constraint_names().len(), 2);
1122
1123        let most_violated = report.most_violated();
1124        assert!(most_violated.is_some());
1125    }
1126
1127    #[test]
1128    fn test_constraint_inspector() {
1129        let constraint = ConstraintBuilder::new()
1130            .name("test")
1131            .less_than(10.0)
1132            .build()
1133            .unwrap();
1134
1135        let mut inspector = ConstraintInspector::new();
1136        inspector.add_sample(5.0);
1137        inspector.add_sample(12.0);
1138        inspector.add_sample(8.0);
1139
1140        let result = inspector.inspect(&constraint, "test");
1141        assert_eq!(result.total_samples(), 3);
1142        assert_eq!(result.satisfied().len(), 2);
1143        assert_eq!(result.violated().len(), 1);
1144    }
1145
1146    // ── SVG visualization tests ───────────────────────────────────────────
1147
1148    #[test]
1149    fn test_svg_builder_produces_valid_xml() {
1150        let mut builder = SvgBuilder::new(100, 100);
1151        builder.rect(10.0, 10.0, 80.0, 80.0, "#ff0000", 1.0);
1152        builder.circle(50.0, 50.0, 20.0, "#00ff00", "none");
1153        let svg = builder.build();
1154        assert!(svg.starts_with("<svg"));
1155        assert!(svg.ends_with("</svg>"));
1156        assert!(svg.contains("rect"));
1157        assert!(svg.contains("circle"));
1158    }
1159
1160    #[test]
1161    fn test_colormap_viridis_range() {
1162        let cm = Colormap::Viridis;
1163        let (r0, g0, b0) = cm.map(0.0);
1164        let (r1, g1, b1) = cm.map(1.0);
1165        // Verify valid RGB ranges (u8 is always <= 255, but confirm values are assigned)
1166        let _ = (r0, g0, b0, r1, g1, b1);
1167    }
1168
1169    #[test]
1170    fn test_colormap_rdgn_extremes() {
1171        let cm = Colormap::RdGn;
1172        let (r_red, _, _) = cm.map(0.0);
1173        let (_, g_green, _) = cm.map(1.0);
1174        assert_eq!(r_red, 255); // fully red at 0
1175        assert_eq!(g_green, 255); // fully green at 1
1176    }
1177
1178    #[test]
1179    fn test_plot_constraint_region_returns_svg() {
1180        let config = PlotConfig {
1181            width: 100,
1182            height: 100,
1183            x_range: (-2.0, 2.0),
1184            y_range: (-2.0, 2.0),
1185            ..Default::default()
1186        };
1187        let plotter = ConstraintPlotter::new(config);
1188        // Circle constraint: x^2 + y^2 - 1 <= 0 (satisfied inside unit circle)
1189        let svg = plotter.plot_constraint_region(|x, y| x * x + y * y - 1.0, 10);
1190        assert!(svg.contains("<svg"));
1191        assert!(svg.contains("rect"));
1192    }
1193
1194    #[test]
1195    fn test_plot_feasible_region() {
1196        let config = PlotConfig::default();
1197        let plotter = ConstraintPlotter::new(config);
1198        let constraints: Vec<Box<dyn Fn(f64, f64) -> f64>> = vec![
1199            Box::new(|x, _y| x - 1.0), // x <= 1
1200            Box::new(|_x, y| y - 1.0), // y <= 1
1201        ];
1202        let svg = plotter.plot_feasible_region(&constraints, 5);
1203        assert!(!svg.is_empty());
1204    }
1205
1206    #[test]
1207    fn test_plot_trajectory() {
1208        let config = PlotConfig::default();
1209        let plotter = ConstraintPlotter::new(config);
1210        let pts = vec![(0.0_f64, 0.0), (1.0, 1.0), (2.0, 2.0)];
1211        let svg = plotter.plot_trajectory(&pts, None);
1212        assert!(svg.contains("<svg"));
1213    }
1214
1215    #[test]
1216    fn test_render_constraint_network() {
1217        let nodes = vec![
1218            ConstraintNetworkNode {
1219                id: 0,
1220                label: "x >= 0".to_string(),
1221                constraint_type: "range".to_string(),
1222                satisfied: Some(true),
1223            },
1224            ConstraintNetworkNode {
1225                id: 1,
1226                label: "x <= 1".to_string(),
1227                constraint_type: "range".to_string(),
1228                satisfied: Some(false),
1229            },
1230        ];
1231        let edges = vec![ConstraintNetworkEdge {
1232            from: 0,
1233            to: 1,
1234            label: "AND".to_string(),
1235        }];
1236        let svg = render_constraint_network(&nodes, &edges, 400, 400);
1237        assert!(svg.contains("<svg"));
1238        assert!(svg.contains("circle") || svg.contains("ellipse"));
1239    }
1240
1241    #[test]
1242    fn test_render_violation_heatmap() {
1243        let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)];
1244        let violations = vec![0.0, 0.5, 1.0];
1245        let config = PlotConfig {
1246            width: 100,
1247            height: 100,
1248            ..Default::default()
1249        };
1250        let svg = render_violation_heatmap(&pts, &violations, &config);
1251        assert!(!svg.is_empty());
1252    }
1253}