facett-core 0.1.10

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
//! **imgscan** — the reusable IMAGE-ANALYSIS oracle (SCAN-THE-PIXELS law).
//!
//! A robot-UI / render test that claims it "sees" a frame MUST actually run image
//! analysis on the received pixels and assert the result is the EXPECTED structure.
//! "non-blank" / "has lit pixels" / "wrote a PNG" is NOT seeing — it passes on a
//! smear, a starburst, a garbage frame. (2026-06-21: the OSM-3D map smeared green
//! spokes across the whole frame in the deployed wasm demo while every render test
//! was green, because no test analyzed the image.)
//!
//! This module is **pure Rust** with no heavy deps: it operates on an RGBA byte
//! buffer (`&[u8]`, 4 bytes/pixel, row-major) + `width`/`height` — the exact shape
//! `egui_kittest`'s `harness.render()` produces (an `image::RgbaImage` derefs to
//! that slice via `.as_raw()`). Every analyzer returns a real number so a render
//! test can assert a viewport-tight, sensitivity-proven invariant ON THE PIXELS.
//!
//! The analyzers:
//! - [`spoke_score`] — thin radiating high-contrast lines (the smear signature).
//! - [`high_freq_ratio`] — high-pass vs total energy.
//! - [`coverage`] — fraction of pixels differing from the background.
//! - [`painted_centroid_and_bbox`] — centroid + bbox of the non-background mass.
//! - [`scan`] returning a [`ScanReport`] with all of the above.

/// An RGBA colour (0..=255 per channel) — the background reference for coverage /
/// centroid / bbox analysis. The alpha channel is ignored (frames are opaque).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgba {
    pub r: u8,
    pub g: u8,
    pub b: u8,
    pub a: u8,
}

impl Rgba {
    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
        Self { r, g, b, a }
    }
    /// Opaque colour helper.
    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b, a: 255 }
    }
}

/// An axis-aligned bounding box in pixel coordinates (inclusive `min`, exclusive
/// `max`, i.e. `[min_x, max_x)`). Empty (no painted mass) is reported as a
/// zero-area box at the origin via [`BBox::EMPTY`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BBox {
    pub min_x: u32,
    pub min_y: u32,
    pub max_x: u32,
    pub max_y: u32,
}

impl BBox {
    /// The canonical "nothing painted" box (zero width and height).
    pub const EMPTY: BBox = BBox { min_x: 0, min_y: 0, max_x: 0, max_y: 0 };

    pub fn width(&self) -> u32 {
        self.max_x.saturating_sub(self.min_x)
    }
    pub fn height(&self) -> u32 {
        self.max_y.saturating_sub(self.min_y)
    }
    pub fn is_empty(&self) -> bool {
        self.width() == 0 || self.height() == 0
    }
}

/// The full structural read of a frame — the one thing a render test asserts on.
#[derive(Clone, Copy, Debug)]
pub struct ScanReport {
    /// Fraction of edge energy that forms LONG THIN runs (the spoke/starburst
    /// signature). Clean filled render → ~0; a radiating smear → high. See
    /// [`spoke_score`].
    pub spoke_score: f64,
    /// High-pass / total energy ratio. A few thin bright lines push this up. See
    /// [`high_freq_ratio`].
    pub high_freq_ratio: f64,
    /// Fraction of pixels differing from the background by the threshold. See
    /// [`coverage`].
    pub coverage: f64,
    /// Centroid `(cx, cy)` of the non-background mass, in pixels. `(0,0)` when the
    /// frame is blank.
    pub centroid: (f64, f64),
    /// Bounding box of the non-background mass.
    pub bbox: BBox,
}

// ── byte-buffer helpers ────────────────────────────────────────────────────────

#[inline]
fn idx(x: u32, y: u32, w: u32) -> usize {
    (y as usize * w as usize + x as usize) * 4
}

/// Per-pixel luminance (Rec. 601) from RGB, 0.0..=255.0. Pure, so the whole oracle
/// is deterministic and snapshot-stable.
#[inline]
fn luma_at(px: &[u8], x: u32, y: u32, w: u32) -> f64 {
    let i = idx(x, y, w);
    0.299 * px[i] as f64 + 0.587 * px[i + 1] as f64 + 0.114 * px[i + 2] as f64
}

/// Validate the buffer is the right length for `w*h` RGBA pixels. A short buffer is
/// a programming error in the caller, so we treat "too small" as an empty image
/// rather than panic deep inside an analyzer.
#[inline]
fn ok_dims(px: &[u8], w: u32, h: u32) -> bool {
    w > 0 && h > 0 && px.len() >= (w as usize * h as usize * 4)
}

/// Build the luminance plane once (reused by the edge + high-pass analyzers).
fn luma_plane(px: &[u8], w: u32, h: u32) -> Vec<f64> {
    let mut out = vec![0.0; (w as usize) * (h as usize)];
    for y in 0..h {
        for x in 0..w {
            out[(y as usize) * (w as usize) + (x as usize)] = luma_at(px, x, y, w);
        }
    }
    out
}

// ── 1. spoke / starburst detection ──────────────────────────────────────────────

/// **Spoke / starburst score** — detect thin radiating high-contrast lines (the
/// smear signature) and return the *fraction of edge energy that forms long thin
/// runs*, in `0.0..=1.0`.
///
/// Approach (the SCAN-THE-PIXELS law's "edge filter → long thin runs"):
/// 1. Sobel gradient magnitude over the luminance plane → an edge map.
/// 2. Threshold to a binary "strong edge" mask (relative to the mean edge energy,
///    so it scales with content, not absolute brightness).
/// 3. A spoke pixel is a strong-edge pixel that is **thin** — its local
///    neighbourhood is *mostly background*, i.e. it is a 1-px line rather than the
///    boundary of a filled region. A filled rectangle's edge has fill on one side;
///    a 1-px spoke has background on BOTH sides. We measure this as: of the 8
///    neighbours, how many are themselves NOT lit (background). High → thin line.
/// 4. The score is `Σ(edge_magnitude over thin pixels) / Σ(edge_magnitude over all
///    strong-edge pixels)`. A clean filled render has thick boundaries → low score;
///    a starburst is all thin lines → high score.
///
/// A blank or fully-uniform frame has no edge energy → score `0.0`.
pub fn spoke_score(px: &[u8], w: u32, h: u32) -> f64 {
    if !ok_dims(px, w, h) || w < 3 || h < 3 {
        return 0.0;
    }
    let luma = luma_plane(px, w, h);
    let at = |x: u32, y: u32| luma[(y as usize) * (w as usize) + (x as usize)];

    // Sobel magnitude per interior pixel.
    let mut mag = vec![0.0f64; (w as usize) * (h as usize)];
    let mut sum_mag = 0.0;
    for y in 1..h - 1 {
        for x in 1..w - 1 {
            let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1))
                - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
            let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1))
                - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
            let m = (gx * gx + gy * gy).sqrt();
            mag[(y as usize) * (w as usize) + (x as usize)] = m;
            sum_mag += m;
        }
    }
    if sum_mag <= f64::EPSILON {
        return 0.0;
    }
    let n_interior = ((w - 2) as f64) * ((h - 2) as f64);
    let mean_mag = sum_mag / n_interior;
    // Strong edge = clearly above the mean gradient. The factor is a relative gate
    // (not an absolute brightness), so it tracks content; 2× mean isolates the
    // genuine edges from the diffuse low-gradient field of a filled region.
    let strong_gate = (mean_mag * 2.0).max(8.0);

    // A "lit" pixel for the thinness test: brighter than the modal background.
    // Use the global luma median as a cheap background estimate.
    let bg_luma = median(&luma);
    let lit = |x: u32, y: u32| (at(x, y) - bg_luma).abs() > 24.0;

    let magv = |x: u32, y: u32| mag[(y as usize) * (w as usize) + (x as usize)];

    let mut strong_energy = 0.0;
    let mut thin_energy = 0.0;
    for y in 1..h - 1 {
        for x in 1..w - 1 {
            let m = magv(x, y);
            if m < strong_gate {
                continue;
            }
            strong_energy += m;
            // Count lit neighbours among the 8-neighbourhood. A thin line has few
            // (background on both sides); a filled-region boundary has many.
            let mut lit_neighbours = 0u32;
            for (dx, dy) in
                [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
            {
                let nx = x as i32 + dx;
                let ny = y as i32 + dy;
                if lit(nx as u32, ny as u32) {
                    lit_neighbours += 1;
                }
            }
            // <= 2 lit neighbours ⇒ the pixel is a thin filament (a 1-px line
            // continues along its axis → ~2 lit neighbours), NOT the edge of a solid
            // mass (whose boundary keeps ≥3 lit neighbours on the fill side).
            if lit_neighbours <= 2 {
                thin_energy += m;
            }
        }
    }
    if strong_energy <= f64::EPSILON {
        0.0
    } else {
        thin_energy / strong_energy
    }
}

/// Median of a slice (used as a cheap background-luma estimate). O(n log n); the
/// planes are small (a frame), so this is fine and keeps the oracle dep-free.
fn median(v: &[f64]) -> f64 {
    if v.is_empty() {
        return 0.0;
    }
    let mut s: Vec<f64> = v.to_vec();
    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    s[s.len() / 2]
}

// ── 2. high-frequency ratio ──────────────────────────────────────────────────────

/// **High-frequency energy ratio** in `0.0..=1.0` — a cheap stand-in for the FFT
/// high-pass/low-pass split the law calls for.
///
/// We build a low-pass image by box-blurring the luminance plane (radius 2), then
/// the high-pass is `original − low_pass`. The ratio is
/// `Σ|high_pass| / (Σ|high_pass| + Σ|low_pass_variation|)` — i.e. the share of the
/// signal's total variation that lives at high frequency. A broad, smooth render
/// (filled regions) keeps this low; a few thin bright spokes inject sharp local
/// transitions and push it up.
///
/// A blank/uniform frame has no variation → `0.0`.
pub fn high_freq_ratio(px: &[u8], w: u32, h: u32) -> f64 {
    if !ok_dims(px, w, h) || w < 2 || h < 2 {
        return 0.0;
    }
    let luma = luma_plane(px, w, h);
    let lp = box_blur(&luma, w, h, 2);

    let mut hf = 0.0; // high-pass energy: |original - lowpass|
    let mut lf = 0.0; // low-pass variation around its own mean
    let mean: f64 = luma.iter().sum::<f64>() / luma.len() as f64;
    for i in 0..luma.len() {
        hf += (luma[i] - lp[i]).abs();
        lf += (lp[i] - mean).abs();
    }
    let total = hf + lf;
    if total <= f64::EPSILON {
        0.0
    } else {
        hf / total
    }
}

/// Separable box blur (radius `r`) over a single-channel plane. Two 1-D passes →
/// O(n·r); pure, deterministic. Edges clamp to the border.
fn box_blur(plane: &[f64], w: u32, h: u32, r: u32) -> Vec<f64> {
    let (wu, hu) = (w as usize, h as usize);
    let r = r as i32;
    // horizontal
    let mut tmp = vec![0.0f64; plane.len()];
    for y in 0..hu {
        for x in 0..wu {
            let mut acc = 0.0;
            let mut cnt = 0.0;
            for dx in -r..=r {
                let sx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
                acc += plane[y * wu + sx];
                cnt += 1.0;
            }
            tmp[y * wu + x] = acc / cnt;
        }
    }
    // vertical
    let mut out = vec![0.0f64; plane.len()];
    for y in 0..hu {
        for x in 0..wu {
            let mut acc = 0.0;
            let mut cnt = 0.0;
            for dy in -r..=r {
                let sy = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
                acc += tmp[sy * wu + x];
                cnt += 1.0;
            }
            out[y * wu + x] = acc / cnt;
        }
    }
    out
}

// ── 3. coverage ──────────────────────────────────────────────────────────────────

/// True when pixel `(x,y)` differs from `bg` by more than the per-channel-sum
/// threshold. The threshold (`> 24` summed over RGB) ignores anti-aliasing noise
/// while catching any genuine paint.
#[inline]
fn differs(px: &[u8], x: u32, y: u32, w: u32, bg: Rgba) -> bool {
    let i = idx(x, y, w);
    let d = (px[i] as i32 - bg.r as i32).abs()
        + (px[i + 1] as i32 - bg.g as i32).abs()
        + (px[i + 2] as i32 - bg.b as i32).abs();
    d > 24
}

/// **Coverage** — fraction of pixels (`0.0..=1.0`) that differ from the background
/// colour `bg` by the threshold. Catches a blank frame (≈0) and a frame-filling
/// smear (≈1); a sane render sits in a band between.
pub fn coverage(px: &[u8], w: u32, h: u32, bg: Rgba) -> f64 {
    if !ok_dims(px, w, h) {
        return 0.0;
    }
    let mut painted = 0u64;
    for y in 0..h {
        for x in 0..w {
            if differs(px, x, y, w, bg) {
                painted += 1;
            }
        }
    }
    painted as f64 / (w as f64 * h as f64)
}

// ── 4. centroid + bbox ───────────────────────────────────────────────────────────

/// **Centroid + bounding box of the non-background mass.** The centroid `(cx,cy)`
/// is the mean position of every painted pixel; the bbox is the tightest box that
/// contains them. Catches geometry flung to a corner (centroid far from centre) or
/// a degenerate render (empty bbox). A blank frame returns `((0,0), BBox::EMPTY)`.
pub fn painted_centroid_and_bbox(px: &[u8], w: u32, h: u32, bg: Rgba) -> ((f64, f64), BBox) {
    if !ok_dims(px, w, h) {
        return ((0.0, 0.0), BBox::EMPTY);
    }
    let mut sum_x = 0.0;
    let mut sum_y = 0.0;
    let mut n = 0u64;
    let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
    for y in 0..h {
        for x in 0..w {
            if differs(px, x, y, w, bg) {
                sum_x += x as f64;
                sum_y += y as f64;
                n += 1;
                min_x = min_x.min(x);
                min_y = min_y.min(y);
                max_x = max_x.max(x);
                max_y = max_y.max(y);
            }
        }
    }
    if n == 0 {
        return ((0.0, 0.0), BBox::EMPTY);
    }
    let centroid = (sum_x / n as f64, sum_y / n as f64);
    // max is inclusive above; report exclusive (+1) so width/height count pixels.
    let bbox = BBox { min_x, min_y, max_x: max_x + 1, max_y: max_y + 1 };
    (centroid, bbox)
}

// ── 5. one-shot scan ─────────────────────────────────────────────────────────────

/// Run every analyzer and bundle the result. This is the single entry a render /
/// robot-UI test calls — `let r = scan(pixels, w, h, bg);` then assert each field
/// against a viewport-tight, sensitivity-proven bound.
pub fn scan(px: &[u8], w: u32, h: u32, bg: Rgba) -> ScanReport {
    let (centroid, bbox) = painted_centroid_and_bbox(px, w, h, bg);
    ScanReport {
        spoke_score: spoke_score(px, w, h),
        high_freq_ratio: high_freq_ratio(px, w, h),
        coverage: coverage(px, w, h, bg),
        centroid,
        bbox,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const W: u32 = 200;
    const H: u32 = 200;
    const BG: Rgba = Rgba::rgb(12, 12, 18); // dark background

    /// Allocate a frame filled with `bg`.
    fn frame(w: u32, h: u32, bg: Rgba) -> Vec<u8> {
        let mut v = vec![0u8; (w as usize) * (h as usize) * 4];
        for p in v.chunks_exact_mut(4) {
            p[0] = bg.r;
            p[1] = bg.g;
            p[2] = bg.b;
            p[3] = bg.a;
        }
        v
    }

    fn put(px: &mut [u8], x: i32, y: i32, w: u32, h: u32, c: Rgba) {
        if x < 0 || y < 0 || x as u32 >= w || y as u32 >= h {
            return;
        }
        let i = ((y as u32 * w + x as u32) * 4) as usize;
        px[i] = c.r;
        px[i + 1] = c.g;
        px[i + 2] = c.b;
        px[i + 3] = c.a;
    }

    fn fill_rect(px: &mut [u8], x0: u32, y0: u32, rw: u32, rh: u32, w: u32, h: u32, c: Rgba) {
        for y in y0..(y0 + rh).min(h) {
            for x in x0..(x0 + rw).min(w) {
                put(px, x as i32, y as i32, w, h, c);
            }
        }
    }

    /// Bresenham line (thin, 1-px) — used to draw spokes.
    fn line(px: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, w: u32, h: u32, c: Rgba) {
        let dx = (x1 - x0).abs();
        let dy = -(y1 - y0).abs();
        let sx = if x0 < x1 { 1 } else { -1 };
        let sy = if y0 < y1 { 1 } else { -1 };
        let mut err = dx + dy;
        let (mut x, mut y) = (x0, y0);
        loop {
            put(px, x, y, w, h, c);
            if x == x1 && y == y1 {
                break;
            }
            let e2 = 2 * err;
            if e2 >= dy {
                err += dy;
                x += sx;
            }
            if e2 <= dx {
                err += dx;
                y += sy;
            }
        }
    }

    /// A CLEAN frame: a few solid filled rectangles on the dark bg, centred.
    fn clean_frame() -> Vec<u8> {
        let mut f = frame(W, H, BG);
        // Solid filled regions with real area (a clean render is compact fill, not
        // perimeter): boundaries are thick, interiors lit → low thin-run fraction.
        fill_rect(&mut f, 40, 40, 70, 70, W, H, Rgba::rgb(220, 80, 80));
        fill_rect(&mut f, 100, 50, 60, 90, W, H, Rgba::rgb(80, 200, 120));
        fill_rect(&mut f, 60, 110, 90, 60, W, H, Rgba::rgb(90, 140, 230));
        f
    }

    /// A SMEARED frame: thin bright lines radiating from the centre to the edges
    /// (the green-spoke starburst the live OSM-3D demo produced).
    fn smeared_frame() -> Vec<u8> {
        let mut f = frame(W, H, BG);
        let (cx, cy) = (W as i32 / 2, H as i32 / 2);
        let spoke = Rgba::rgb(60, 230, 90); // the smear green
        // 24 spokes radiating to points around the frame border.
        for k in 0..24 {
            let a = std::f64::consts::TAU * k as f64 / 24.0;
            let ex = (cx as f64 + a.cos() * 1000.0) as i32; // shoot well past the edge
            let ey = (cy as f64 + a.sin() * 1000.0) as i32;
            line(&mut f, cx, cy, ex, ey, W, H, spoke);
        }
        f
    }

    // ── the SENSITIVITY proof: clean vs smeared must be clearly separable ──

    #[test]
    fn clean_frame_scans_low_and_centred() {
        let f = clean_frame();
        let r = scan(&f, W, H, BG);
        eprintln!("[imgscan] CLEAN  {r:?}");
        // spoke + high-freq are LOW for compact filled regions.
        assert!(r.spoke_score < 0.30, "clean spoke_score should be low: {}", r.spoke_score);
        assert!(r.high_freq_ratio < 0.30, "clean hf_ratio should be low: {}", r.high_freq_ratio);
        // coverage in a sane band (not blank, not frame-filling).
        assert!(r.coverage > 0.02 && r.coverage < 0.45, "clean coverage band: {}", r.coverage);
        // centroid near image centre (the rects are clustered mid-frame).
        assert!((r.centroid.0 - W as f64 / 2.0).abs() < W as f64 * 0.25, "cx near centre: {:?}", r.centroid);
        assert!((r.centroid.1 - H as f64 / 2.0).abs() < H as f64 * 0.25, "cy near centre: {:?}", r.centroid);
        assert!(!r.bbox.is_empty(), "clean frame has a real bbox");
    }

    #[test]
    fn smeared_frame_scans_high() {
        let f = smeared_frame();
        let r = scan(&f, W, H, BG);
        eprintln!("[imgscan] SMEAR  {r:?}");
        assert!(r.spoke_score > 0.45, "smear spoke_score should be high: {}", r.spoke_score);
        assert!(r.high_freq_ratio > 0.30, "smear hf_ratio should be high: {}", r.high_freq_ratio);
    }

    /// The oracle PROVES it can go red on the bug: there is a clear separating
    /// threshold between clean and smeared for BOTH spoke_score and hf_ratio. If
    /// this gap ever collapses, the oracle is blind and this test fails.
    #[test]
    fn clean_and_smeared_are_clearly_separated() {
        let clean = scan(&clean_frame(), W, H, BG);
        let smear = scan(&smeared_frame(), W, H, BG);
        eprintln!(
            "[imgscan] SEPARATION spoke: clean={:.3} smear={:.3} | hf: clean={:.3} smear={:.3}",
            clean.spoke_score, smear.spoke_score, clean.high_freq_ratio, smear.high_freq_ratio
        );
        // spoke_score: pick a threshold strictly between the two and assert it splits.
        let t_spoke = 0.25;
        assert!(
            clean.spoke_score < t_spoke && t_spoke < smear.spoke_score,
            "spoke_score must split at {t_spoke}: clean={} < {t_spoke} < smear={}",
            clean.spoke_score,
            smear.spoke_score
        );
        // high_freq_ratio: a threshold strictly between the two.
        let t_hf = 0.30;
        assert!(
            clean.high_freq_ratio < t_hf && t_hf < smear.high_freq_ratio,
            "hf_ratio must split at {t_hf}: clean={} < {t_hf} < smear={}",
            clean.high_freq_ratio,
            smear.high_freq_ratio
        );
        // And the gaps are comfortably wide (not a knife-edge that flips on noise).
        assert!(smear.spoke_score - clean.spoke_score > 0.25, "spoke gap wide enough");
        assert!(smear.high_freq_ratio - clean.high_freq_ratio > 0.10, "hf gap wide enough");
    }

    // ── min / mid / max boundary coverage ──

    #[test]
    fn blank_frame_is_all_zero() {
        let f = frame(W, H, BG);
        let r = scan(&f, W, H, BG);
        assert_eq!(r.spoke_score, 0.0, "blank: no edges");
        assert_eq!(r.high_freq_ratio, 0.0, "blank: no variation");
        assert_eq!(r.coverage, 0.0, "blank: nothing differs from bg");
        assert_eq!(r.centroid, (0.0, 0.0), "blank: no centroid");
        assert!(r.bbox.is_empty(), "blank: empty bbox");
    }

    #[test]
    fn fully_filled_frame_is_full_coverage_low_freq() {
        // Every pixel a single solid non-bg colour: coverage ≈ 1, no internal
        // structure → no edges, no high-freq energy.
        let f = frame(W, H, Rgba::rgb(200, 30, 30));
        let r = scan(&f, W, H, BG);
        assert!((r.coverage - 1.0).abs() < 1e-9, "fully filled: coverage == 1: {}", r.coverage);
        assert_eq!(r.spoke_score, 0.0, "uniform fill: no thin lines");
        assert_eq!(r.high_freq_ratio, 0.0, "uniform fill: no high-freq energy");
        // bbox spans the whole frame; centroid at the centre.
        assert_eq!(r.bbox, BBox { min_x: 0, min_y: 0, max_x: W, max_y: H });
        assert!((r.centroid.0 - (W as f64 - 1.0) / 2.0).abs() < 1.0);
        assert!((r.centroid.1 - (H as f64 - 1.0) / 2.0).abs() < 1.0);
    }

    #[test]
    fn single_pixel_image_does_not_panic() {
        // 1×1 is below every analyzer's window; they must degrade gracefully to 0,
        // never index out of bounds.
        let mut f = frame(1, 1, BG);
        let r = scan(&f, 1, 1, BG);
        assert_eq!(r.coverage, 0.0);
        assert_eq!(r.spoke_score, 0.0);
        // a single lit pixel ⇒ coverage 1, centroid at (0,0), 1×1 bbox.
        put(&mut f, 0, 0, 1, 1, Rgba::rgb(255, 255, 255));
        let r2 = scan(&f, 1, 1, BG);
        assert!((r2.coverage - 1.0).abs() < 1e-9);
        assert_eq!(r2.centroid, (0.0, 0.0));
        assert_eq!(r2.bbox, BBox { min_x: 0, min_y: 0, max_x: 1, max_y: 1 });
    }

    #[test]
    fn corner_flung_geometry_moves_the_centroid() {
        // Geometry in the top-left corner ⇒ centroid far from centre (the "flung to
        // a corner" bug the law calls out).
        let mut f = frame(W, H, BG);
        fill_rect(&mut f, 0, 0, 20, 20, W, H, Rgba::rgb(240, 240, 240));
        let (cx, cy) = painted_centroid_and_bbox(&f, W, H, BG).0;
        assert!(cx < W as f64 * 0.2 && cy < H as f64 * 0.2, "centroid in the corner: ({cx},{cy})");
    }

    #[test]
    fn coverage_counts_only_above_threshold() {
        // A near-bg colour (within the AA threshold) must NOT count as painted.
        let mut f = frame(W, H, BG);
        fill_rect(&mut f, 10, 10, 50, 50, W, H, Rgba::rgb(BG.r + 5, BG.g + 5, BG.b + 5));
        assert_eq!(coverage(&f, W, H, BG), 0.0, "sub-threshold delta is not coverage");
    }

    #[test]
    fn bad_dims_degrade_gracefully() {
        // A buffer too short for the claimed dims ⇒ analyzers return the empty read,
        // never panic.
        let f = vec![0u8; 16]; // claims 100×100 but is 16 bytes
        assert_eq!(coverage(&f, 100, 100, BG), 0.0);
        assert_eq!(spoke_score(&f, 100, 100), 0.0);
        assert_eq!(high_freq_ratio(&f, 100, 100), 0.0);
        let (c, b) = painted_centroid_and_bbox(&f, 100, 100, BG);
        assert_eq!(c, (0.0, 0.0));
        assert!(b.is_empty());
    }
}