Skip to main content

jugar_probar/pixel_coverage/
config.rs

1//! Configuration Schema for Pixel Coverage (PIXEL-001 v2.1 Phase 9)
2//!
3//! Provides probar.toml compatible configuration for pixel coverage settings.
4//! Supports JSON/YAML deserialization via serde.
5
6use serde::{Deserialize, Serialize};
7
8/// Root configuration for pixel coverage in probar.toml
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct PixelCoverageConfig {
12    /// Enable pixel-perfect verification
13    pub enabled: bool,
14    /// Primary methodology: "falsification" or "simple"
15    pub methodology: String,
16    /// Coverage thresholds
17    pub thresholds: ThresholdConfig,
18    /// Verification metric settings
19    pub verification: VerificationConfig,
20    /// Output settings
21    pub output: OutputConfig,
22    /// Performance settings
23    pub performance: PerformanceConfig,
24}
25
26impl Default for PixelCoverageConfig {
27    fn default() -> Self {
28        Self {
29            enabled: true,
30            methodology: "falsification".to_string(),
31            thresholds: ThresholdConfig::default(),
32            verification: VerificationConfig::default(),
33            output: OutputConfig::default(),
34            performance: PerformanceConfig::default(),
35        }
36    }
37}
38
39/// Threshold configuration
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(default)]
42pub struct ThresholdConfig {
43    /// Minimum coverage percentage (0-100)
44    pub min_coverage: f32,
45    /// Maximum allowed gap size (percent of screen)
46    pub max_gap_size: f32,
47    /// Falsifiability gateway threshold (0-25)
48    pub falsifiability_threshold: f32,
49}
50
51impl Default for ThresholdConfig {
52    fn default() -> Self {
53        Self {
54            min_coverage: 85.0,
55            max_gap_size: 5.0,
56            falsifiability_threshold: 15.0,
57        }
58    }
59}
60
61/// Verification metric configuration
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(default)]
64pub struct VerificationConfig {
65    /// Structural Similarity Index threshold (0.0 - 1.0)
66    pub ssim_threshold: f32,
67    /// CIEDE2000 Color Difference threshold (JND)
68    pub delta_e_threshold: f32,
69    /// Perceptual Hash distance threshold
70    pub phash_distance: u32,
71    /// PSNR threshold (dB)
72    pub psnr_threshold: f32,
73}
74
75impl Default for VerificationConfig {
76    fn default() -> Self {
77        Self {
78            ssim_threshold: 0.99,
79            delta_e_threshold: 1.0,
80            phash_distance: 5,
81            psnr_threshold: 40.0,
82        }
83    }
84}
85
86/// Output configuration
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default)]
89pub struct OutputConfig {
90    /// Generate PNG heatmap
91    pub heatmap: bool,
92    /// Enable rich terminal output
93    pub terminal_gui: bool,
94    /// Color palette: "viridis", "magma", "heat"
95    pub palette: String,
96    /// Show gap highlighting
97    pub highlight_gaps: bool,
98    /// Show legend
99    pub show_legend: bool,
100    /// Show confidence intervals
101    pub show_confidence: bool,
102}
103
104impl Default for OutputConfig {
105    fn default() -> Self {
106        Self {
107            heatmap: true,
108            terminal_gui: true,
109            palette: "viridis".to_string(),
110            highlight_gaps: true,
111            show_legend: true,
112            show_confidence: true,
113        }
114    }
115}
116
117/// Performance configuration
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(default)]
120pub struct PerformanceConfig {
121    /// Enable parallel processing (via Rayon)
122    pub parallel: bool,
123    /// Number of threads (0 = auto-detect)
124    pub threads: usize,
125    /// Enable downscaling for rapid L1 checks
126    pub enable_downscaling: bool,
127    /// Downscale factor for rapid checks (e.g., 2 = 50% resolution)
128    pub downscale_factor: u32,
129    /// Cache perceptual hashes
130    pub cache_hashes: bool,
131}
132
133impl Default for PerformanceConfig {
134    fn default() -> Self {
135        Self {
136            parallel: true,
137            threads: 0, // Auto-detect
138            enable_downscaling: true,
139            downscale_factor: 2,
140            cache_hashes: true,
141        }
142    }
143}
144
145impl PixelCoverageConfig {
146    /// Load configuration from JSON string
147    ///
148    /// # Errors
149    /// Returns error if JSON parsing fails
150    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
151        serde_json::from_str(json)
152    }
153
154    /// Load configuration from YAML string
155    ///
156    /// # Errors
157    /// Returns error if YAML parsing fails
158    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
159        serde_yaml_ng::from_str(yaml)
160    }
161
162    /// Serialize to JSON
163    ///
164    /// # Errors
165    /// Returns error if serialization fails
166    pub fn to_json(&self) -> Result<String, serde_json::Error> {
167        serde_json::to_string_pretty(self)
168    }
169
170    /// Serialize to YAML
171    ///
172    /// # Errors
173    /// Returns error if serialization fails
174    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
175        serde_yaml_ng::to_string(self)
176    }
177
178    /// Validate configuration values
179    #[must_use]
180    pub fn validate(&self) -> Vec<ConfigValidationError> {
181        let mut errors = Vec::new();
182
183        // Threshold validation
184        if !(0.0..=100.0).contains(&self.thresholds.min_coverage) {
185            errors.push(ConfigValidationError {
186                field: "thresholds.min_coverage".to_string(),
187                message: "Must be between 0 and 100".to_string(),
188            });
189        }
190
191        if !(0.0..=100.0).contains(&self.thresholds.max_gap_size) {
192            errors.push(ConfigValidationError {
193                field: "thresholds.max_gap_size".to_string(),
194                message: "Must be between 0 and 100".to_string(),
195            });
196        }
197
198        if !(0.0..=25.0).contains(&self.thresholds.falsifiability_threshold) {
199            errors.push(ConfigValidationError {
200                field: "thresholds.falsifiability_threshold".to_string(),
201                message: "Must be between 0 and 25".to_string(),
202            });
203        }
204
205        // Verification validation
206        if !(0.0..=1.0).contains(&self.verification.ssim_threshold) {
207            errors.push(ConfigValidationError {
208                field: "verification.ssim_threshold".to_string(),
209                message: "Must be between 0 and 1".to_string(),
210            });
211        }
212
213        if self.verification.delta_e_threshold < 0.0 {
214            errors.push(ConfigValidationError {
215                field: "verification.delta_e_threshold".to_string(),
216                message: "Must be non-negative".to_string(),
217            });
218        }
219
220        // Output validation
221        let valid_palettes = ["viridis", "magma", "heat"];
222        if !valid_palettes.contains(&self.output.palette.as_str()) {
223            errors.push(ConfigValidationError {
224                field: "output.palette".to_string(),
225                message: format!("Must be one of: {}", valid_palettes.join(", ")),
226            });
227        }
228
229        // Performance validation
230        if self.performance.downscale_factor == 0 {
231            errors.push(ConfigValidationError {
232                field: "performance.downscale_factor".to_string(),
233                message: "Must be at least 1".to_string(),
234            });
235        }
236
237        errors
238    }
239
240    /// Check if configuration is valid
241    #[must_use]
242    pub fn is_valid(&self) -> bool {
243        self.validate().is_empty()
244    }
245
246    /// Normalize thresholds to 0.0-1.0 range
247    #[must_use]
248    pub fn normalized_min_coverage(&self) -> f32 {
249        self.thresholds.min_coverage / 100.0
250    }
251
252    /// Normalize max gap size to 0.0-1.0 range
253    #[must_use]
254    pub fn normalized_max_gap(&self) -> f32 {
255        self.thresholds.max_gap_size / 100.0
256    }
257}
258
259/// Configuration validation error
260#[derive(Debug, Clone)]
261pub struct ConfigValidationError {
262    /// Field that failed validation
263    pub field: String,
264    /// Error message
265    pub message: String,
266}
267
268impl std::fmt::Display for ConfigValidationError {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(f, "{}: {}", self.field, self.message)
271    }
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used)]
276mod tests {
277    use super::*;
278
279    // =========================================================================
280    // Config Tests (H0-CONFIG-XX)
281    // =========================================================================
282
283    #[test]
284    fn h0_config_01_default() {
285        let config = PixelCoverageConfig::default();
286        assert!(config.enabled);
287        assert_eq!(config.methodology, "falsification");
288        assert!((config.thresholds.min_coverage - 85.0).abs() < f32::EPSILON);
289    }
290
291    #[test]
292    fn h0_config_02_json_roundtrip() {
293        let config = PixelCoverageConfig::default();
294        let json = config.to_json().unwrap();
295        let parsed = PixelCoverageConfig::from_json(&json).unwrap();
296        assert!((parsed.thresholds.min_coverage - 85.0).abs() < f32::EPSILON);
297    }
298
299    #[test]
300    fn h0_config_03_yaml_roundtrip() {
301        let config = PixelCoverageConfig::default();
302        let yaml = config.to_yaml().unwrap();
303        let parsed = PixelCoverageConfig::from_yaml(&yaml).unwrap();
304        assert!((parsed.thresholds.min_coverage - 85.0).abs() < f32::EPSILON);
305    }
306
307    #[test]
308    fn h0_config_04_validation_pass() {
309        let config = PixelCoverageConfig::default();
310        assert!(config.is_valid());
311        assert!(config.validate().is_empty());
312    }
313
314    #[test]
315    fn h0_config_05_validation_fail() {
316        let mut config = PixelCoverageConfig::default();
317        config.thresholds.min_coverage = 150.0; // Invalid
318        assert!(!config.is_valid());
319        assert!(!config.validate().is_empty());
320    }
321
322    #[test]
323    fn h0_config_06_validation_ssim() {
324        let mut config = PixelCoverageConfig::default();
325        config.verification.ssim_threshold = 1.5; // Invalid
326        let errors = config.validate();
327        assert!(errors.iter().any(|e| e.field.contains("ssim")));
328    }
329
330    #[test]
331    fn h0_config_07_validation_palette() {
332        let mut config = PixelCoverageConfig::default();
333        config.output.palette = "invalid".to_string();
334        let errors = config.validate();
335        assert!(errors.iter().any(|e| e.field.contains("palette")));
336    }
337
338    #[test]
339    fn h0_config_08_normalized() {
340        let config = PixelCoverageConfig::default();
341        assert!((config.normalized_min_coverage() - 0.85).abs() < f32::EPSILON);
342        assert!((config.normalized_max_gap() - 0.05).abs() < f32::EPSILON);
343    }
344
345    #[test]
346    fn h0_config_09_threshold_defaults() {
347        let threshold = ThresholdConfig::default();
348        assert!((threshold.falsifiability_threshold - 15.0).abs() < f32::EPSILON);
349    }
350
351    #[test]
352    fn h0_config_10_performance_defaults() {
353        let perf = PerformanceConfig::default();
354        assert!(perf.parallel);
355        assert_eq!(perf.threads, 0);
356        assert!(perf.enable_downscaling);
357    }
358
359    #[test]
360    fn h0_config_11_output_defaults() {
361        let output = OutputConfig::default();
362        assert!(output.heatmap);
363        assert!(output.terminal_gui);
364        assert_eq!(output.palette, "viridis");
365    }
366
367    #[test]
368    fn h0_config_12_verification_defaults() {
369        let verify = VerificationConfig::default();
370        assert!((verify.ssim_threshold - 0.99).abs() < f32::EPSILON);
371        assert_eq!(verify.phash_distance, 5);
372    }
373
374    #[test]
375    fn h0_config_13_downscale_validation() {
376        let mut config = PixelCoverageConfig::default();
377        config.performance.downscale_factor = 0; // Invalid
378        let errors = config.validate();
379        assert!(errors.iter().any(|e| e.field.contains("downscale")));
380    }
381
382    #[test]
383    fn h0_config_14_error_display() {
384        let error = ConfigValidationError {
385            field: "test.field".to_string(),
386            message: "test message".to_string(),
387        };
388        let display = format!("{}", error);
389        assert!(display.contains("test.field"));
390        assert!(display.contains("test message"));
391    }
392}