Skip to main content

visual_cortex_vision/
stability_mask.rs

1//! Temporal stability mask v2: scene-anchored novelty, per-block hysteresis,
2//! component fill. See docs/superpowers/specs/2026-07-06-stability-mask-v2-design.md
3//! for the measured failure analysis of v1 that drove this algebra.
4//!
5//! Core ideas:
6//! - 17-byte block signatures (16 sub-block luma means + contrast) compared
7//!   by vote, so single noisy components cannot flip decisions and flat
8//!   panels over textured scenes are legibly novel (dark-on-dark).
9//! - An IDLE/KEPT/HOLDOVER state machine per block: kept blocks that start
10//!   changing KEEP RENDERING (a re-rendering tooltip stays live instead of
11//!   going black) and re-latch when they settle.
12//! - Scene-anchored re-seeding: frame-global perturbation (a scene change)
13//!   makes settling blocks adopt the new scene as baseline instead of
14//!   becoming "kept" — novelty is always measured against the actual scene.
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use visual_cortex_capture::{Frame, FrameView, Rate};
20
21use crate::debug::{DebugSink, DebugStage};
22use crate::detector::DetectorError;
23use crate::preprocessor::Preprocessor;
24
25/// 16 sub-block means + 1 contrast byte.
26pub(crate) const SIG_LEN: usize = 17;
27/// Vote threshold: this many components must move/differ to flip a decision.
28const VOTE: usize = 2;
29/// Corroboration band: a perturbation episode only counts as "scene motion"
30/// if some tick of it perturbed at least this fraction of the frame.
31const CORROBORATION_FRAC: f32 = 0.35;
32/// Mass-settle census: long-perturbed blocks settling together must cover at
33/// least this fraction of the frame to trigger re-seeding.
34const CENSUS_FRAC: f32 = 0.60;
35/// Global-drift guard: if this fraction of the IDLE blocks would enter KEPT
36/// via the slow-drift rule on the same tick, it is ambient lighting (a fade
37/// below the movement vote) — re-seed instead. Fresh pop-in entries are
38/// never drift-guarded.
39const DRIFT_FRAC: f32 = 0.50;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub(crate) struct Signature(pub [u8; SIG_LEN]);
43
44impl Default for Signature {
45    fn default() -> Self {
46        Signature([0; SIG_LEN])
47    }
48}
49
50/// Per-component movement vote between consecutive ticks.
51pub(crate) fn is_moving(cur: &Signature, last: &Signature, tol: u8) -> bool {
52    cur.0
53        .iter()
54        .zip(&last.0)
55        .filter(|(a, b)| a.abs_diff(**b) > tol)
56        .count()
57        >= VOTE
58}
59
60/// Novelty vote against a u8 reference: >= VOTE sub-means differ beyond
61/// `nov`, OR the contrast byte differs beyond `con` (the dark-on-dark
62/// clause: a flat panel replacing textured scene at equal mean luma).
63pub(crate) fn is_novel_vs(cur: &Signature, reference: &[u8; SIG_LEN], nov: u8, con: u8) -> bool {
64    let sub_votes = cur.0[..16]
65        .iter()
66        .zip(&reference[..16])
67        .filter(|(a, b)| a.abs_diff(**b) > nov)
68        .count();
69    sub_votes >= VOTE || cur.0[16].abs_diff(reference[16]) > con
70}
71
72/// Baseline is stored 8.8 fixed-point so the EMA can move slowly.
73fn narrow(base: &[u16; SIG_LEN]) -> [u8; SIG_LEN] {
74    let mut out = [0u8; SIG_LEN];
75    for (o, b) in out.iter_mut().zip(base) {
76        *o = (b >> 8) as u8;
77    }
78    out
79}
80
81fn widen(sig: &Signature) -> [u16; SIG_LEN] {
82    let mut out = [0u16; SIG_LEN];
83    for (o, s) in out.iter_mut().zip(&sig.0) {
84        *o = (*s as u16) << 8;
85    }
86    out
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub(crate) enum Phase {
91    Idle,
92    Kept,
93    Holdover,
94}
95
96#[derive(Debug, Clone)]
97pub(crate) struct Block {
98    pub last: Signature,
99    /// Scene reference, 8.8 fixed-point EMA. Frozen while the block is kept.
100    pub base: [u16; SIG_LEN],
101    /// Snapshot of the kept content — the fast re-latch reference.
102    pub ovl: Signature,
103    pub stable_ticks: u16,
104    pub unstable_run: u16,
105    pub holdover: u16,
106    pub phase: Phase,
107    pub initialized: bool,
108    pub base_frozen: bool,
109    /// Set when a scene-scale event fires during this block's perturbation
110    /// episode (frame-global tick, or the mass-settle census). Resolved by
111    /// re-seed or by settling back to baseline.
112    pub scene_flag: bool,
113    /// The current perturbation episode was corroborated by frame-wide
114    /// motion — required for the long-run re-seed (locally confined churn,
115    /// e.g. fast tooltip browsing, must never re-seed).
116    pub run_corroborated: bool,
117    /// Debug: this block re-seeded this tick.
118    pub reseeded: bool,
119}
120
121impl Default for Block {
122    fn default() -> Self {
123        Self {
124            last: Signature::default(),
125            base: [0; SIG_LEN],
126            ovl: Signature::default(),
127            stable_ticks: 0,
128            unstable_run: 0,
129            holdover: 0,
130            phase: Phase::Idle,
131            initialized: false,
132            base_frozen: false,
133            scene_flag: false,
134            run_corroborated: false,
135            reseeded: false,
136        }
137    }
138}
139
140pub(crate) struct MaskParams {
141    /// K: consecutive stable ticks before "settled".
142    pub stable_ticks: u16,
143    /// T: EMA horizon (ticks) while IDLE and unfrozen.
144    pub baseline_ticks: u32,
145    /// Per-component movement tolerance.
146    pub signature_tolerance: u8,
147    /// Per-sub-mean novelty threshold.
148    pub novelty_threshold: u8,
149    /// Contrast-byte novelty threshold.
150    pub contrast_threshold: u8,
151    /// H: ticks a kept block keeps rendering after it starts changing.
152    pub holdover_ticks: u16,
153    /// U: minimum corroborated unstable run that re-seeds on settle.
154    pub long_run: u16,
155}
156
157/// Frame-global signals for one tick, computed in phase 1.
158#[derive(Debug, Clone, Copy, Default)]
159pub(crate) struct Globals {
160    /// The whole frame perturbed this tick (scene transition band).
161    pub scene_tick: bool,
162    /// Enough of the frame perturbed to corroborate motion episodes.
163    pub corroborated_tick: bool,
164    /// Mass-settle census fired: long-perturbed blocks settling together
165    /// cover a scene-scale area.
166    pub census_hit: bool,
167    /// Too many blocks would enter KEPT simultaneously — ambient change.
168    pub drift_guard: bool,
169}
170
171fn reseed(b: &mut Block, sig: &Signature) {
172    b.base = widen(sig);
173    b.base_frozen = false;
174    b.scene_flag = false;
175    b.run_corroborated = false;
176    b.unstable_run = 0;
177    b.phase = Phase::Idle;
178    b.reseeded = true;
179}
180
181fn ema_update(b: &mut Block, sig: &Signature, horizon: u32) {
182    let t = i64::from(horizon.max(1));
183    for (base, s) in b.base.iter_mut().zip(&sig.0) {
184        let target = i64::from(*s) << 8;
185        let delta = target - i64::from(*base);
186        let mut step = delta / t;
187        if step == 0 && delta != 0 {
188            step = delta.signum(); // truncation must never stall convergence
189        }
190        *base = (i64::from(*base) + step) as u16;
191    }
192}
193
194/// Advance one block one tick. `mv` and `census_eligible` come from phase 1.
195/// Returns whether the block renders (pre component-pass keep flag).
196pub(crate) fn step_block(
197    b: &mut Block,
198    sig: &Signature,
199    mv: bool,
200    census_eligible: bool,
201    g: &Globals,
202    p: &MaskParams,
203) -> bool {
204    b.reseeded = false;
205    if !b.initialized {
206        b.last = *sig;
207        b.base = widen(sig);
208        b.ovl = *sig;
209        b.initialized = true;
210        return false; // cold start: nothing is settled yet
211    }
212
213    // Tick bookkeeping (phase-independent).
214    b.last = *sig;
215    if mv {
216        b.stable_ticks = 0;
217        b.unstable_run = b.unstable_run.saturating_add(1);
218        if g.corroborated_tick {
219            b.run_corroborated = true;
220        }
221        if g.scene_tick {
222            b.scene_flag = true;
223        }
224    } else {
225        b.stable_ticks = b.stable_ticks.saturating_add(1);
226    }
227    if census_eligible && g.census_hit {
228        b.scene_flag = true;
229    }
230
231    let base_u8 = narrow(&b.base);
232    let stable = b.stable_ticks >= p.stable_ticks;
233    let settled_now = !mv && b.stable_ticks == p.stable_ticks;
234    let novel = is_novel_vs(sig, &base_u8, p.novelty_threshold, p.contrast_threshold);
235
236    match b.phase {
237        Phase::Kept => {
238            // Base stays frozen: the no-ghosting invariant — a held tooltip
239            // is never absorbed, so it stays revealed indefinitely.
240            if mv {
241                b.phase = Phase::Holdover;
242                b.holdover = p.holdover_ticks;
243            } else {
244                b.ovl = *sig;
245                b.unstable_run = 0;
246                b.run_corroborated = false;
247            }
248            true // renders in both arms: the re-render stays live
249        }
250        Phase::Holdover => {
251            // 1. ovl re-latch FIRST (ordering pinned by the
252            //    flash-over-held-tooltip test): if the content matches what
253            //    was kept, return to KEPT immediately — no K-tick wait, and
254            //    NO base_stale re-seed (a flash is not a scene change).
255            if !mv && !is_novel_vs(sig, &b.ovl.0, p.novelty_threshold, p.contrast_threshold) {
256                b.phase = Phase::Kept;
257                b.unstable_run = 0;
258                b.run_corroborated = false;
259                return true;
260            }
261            if settled_now {
262                if novel {
263                    // A different tooltip settled: seamless hand-off. If a
264                    // scene event fired while this block was occluded, the
265                    // base is suspect and we CANNOT tell "new overlay" from
266                    // "revealed new scene" — prefer revealing (a suppressed
267                    // tooltip is a hard failure; extra revealed scene is
268                    // benign and re-anchors on the next frame-global
269                    // motion). scene_flag rides along unresolved.
270                    b.phase = Phase::Kept;
271                    b.ovl = *sig;
272                    b.unstable_run = 0;
273                    b.run_corroborated = false;
274                    return true;
275                }
276                // Content matches the base: the scene did not actually
277                // change under the overlay — thaw, clear any suspect flag,
278                // and suppress.
279                b.phase = Phase::Idle;
280                b.base_frozen = false;
281                b.scene_flag = false;
282                b.unstable_run = 0;
283                b.run_corroborated = false;
284                return false;
285            }
286            b.holdover = b.holdover.saturating_sub(1);
287            if b.holdover == 0 {
288                b.phase = Phase::Idle;
289                return false;
290            }
291            true // still rendering the live (changing) content
292        }
293        Phase::Idle => {
294            if settled_now {
295                let long_run = b.unstable_run >= p.long_run && b.run_corroborated;
296                let scene = b.scene_flag;
297                b.unstable_run = 0;
298                b.run_corroborated = false;
299                if long_run || scene {
300                    // Sustained corroborated motion or a scene-scale event:
301                    // adopt the new scene, never keep it.
302                    reseed(b, sig);
303                    return false;
304                }
305                b.scene_flag = false;
306                if novel {
307                    // Fresh pop-in entry: deliberately NOT drift-guarded —
308                    // a tooltip may legitimately cover most of a cropped
309                    // watcher region (the guard is for drifting ambient
310                    // change, which never trips the movement vote).
311                    b.phase = Phase::Kept;
312                    b.base_frozen = true;
313                    b.ovl = *sig;
314                    return true;
315                }
316            } else if stable && novel {
317                // Slow-drift entry (fades that never trip the movement vote).
318                if g.drift_guard {
319                    reseed(b, sig);
320                    return false;
321                }
322                b.phase = Phase::Kept;
323                b.base_frozen = true;
324                b.ovl = *sig;
325                b.unstable_run = 0;
326                b.run_corroborated = false;
327                return true;
328            }
329            if b.base_frozen && stable && !novel {
330                b.base_frozen = false; // v1 thaw: overlay content actually left
331            }
332            // The EMA only absorbs content that AGREES with the baseline
333            // (slow scene evolution). Novel content is either a settling
334            // overlay (must not bias the scene reference — repeated
335            // browsing would drift the baseline toward tooltip luma) or a
336            // scene change (handled by re-seeding, not drift).
337            if !b.base_frozen && !mv && !novel {
338                ema_update(b, sig, p.baseline_ticks);
339            }
340            false
341        }
342    }
343}
344
345/// Morphological close (fill interior holes) then dilate by `dilate` rings,
346/// on a row-major block grid. Used when component fill is disabled.
347pub(crate) fn close_and_dilate(keep: &[bool], cols: usize, rows: usize, dilate: u32) -> Vec<bool> {
348    let d1 = dilate_grid(keep, cols, rows, 1);
349    let closed = erode_grid(&d1, cols, rows, 1);
350    if dilate == 0 {
351        closed
352    } else {
353        dilate_grid(&closed, cols, rows, dilate as usize)
354    }
355}
356
357pub(crate) fn dilate_grid(grid: &[bool], cols: usize, rows: usize, rings: usize) -> Vec<bool> {
358    let mut out = grid.to_vec();
359    for _ in 0..rings {
360        let src = out.clone();
361        for r in 0..rows {
362            for c in 0..cols {
363                if src[r * cols + c] {
364                    continue;
365                }
366                let neighbors_on = (r > 0 && src[(r - 1) * cols + c])
367                    || (r + 1 < rows && src[(r + 1) * cols + c])
368                    || (c > 0 && src[r * cols + c - 1])
369                    || (c + 1 < cols && src[r * cols + c + 1]);
370                if neighbors_on {
371                    out[r * cols + c] = true;
372                }
373            }
374        }
375    }
376    out
377}
378
379fn erode_grid(grid: &[bool], cols: usize, rows: usize, rings: usize) -> Vec<bool> {
380    let mut out = grid.to_vec();
381    for _ in 0..rings {
382        let src = out.clone();
383        for r in 0..rows {
384            for c in 0..cols {
385                if !src[r * cols + c] {
386                    continue;
387                }
388                let all_on = (r == 0 || src[(r - 1) * cols + c])
389                    && (r + 1 >= rows || src[(r + 1) * cols + c])
390                    && (c == 0 || src[r * cols + c - 1])
391                    && (c + 1 >= cols || src[r * cols + c + 1]);
392                if !all_on {
393                    out[r * cols + c] = false;
394                }
395            }
396        }
397    }
398    out
399}
400
401/// Component pass: 4-connected components over the keep grid; drop
402/// components smaller than `min_size` (speckle), fill the bounding box of
403/// each survivor (tooltips are rectangles — interior holes become impossible
404/// by construction).
405pub(crate) fn fill_components_bbox(
406    keep: &[bool],
407    cols: usize,
408    rows: usize,
409    min_size: usize,
410) -> Vec<bool> {
411    let mut out = vec![false; keep.len()];
412    let mut seen = vec![false; keep.len()];
413    let mut stack = Vec::new();
414    for start in 0..keep.len() {
415        if !keep[start] || seen[start] {
416            continue;
417        }
418        // Flood-fill one component, tracking size and bbox.
419        let (mut r0, mut r1, mut c0, mut c1) = (rows, 0usize, cols, 0usize);
420        let mut size = 0usize;
421        stack.push(start);
422        seen[start] = true;
423        while let Some(i) = stack.pop() {
424            size += 1;
425            let (r, c) = (i / cols, i % cols);
426            r0 = r0.min(r);
427            r1 = r1.max(r);
428            c0 = c0.min(c);
429            c1 = c1.max(c);
430            let mut push = |j: usize| {
431                if keep[j] && !seen[j] {
432                    seen[j] = true;
433                    stack.push(j);
434                }
435            };
436            if r > 0 {
437                push(i - cols);
438            }
439            if r + 1 < rows {
440                push(i + cols);
441            }
442            if c > 0 {
443                push(i - 1);
444            }
445            if c + 1 < cols {
446                push(i + 1);
447            }
448        }
449        if size < min_size {
450            continue; // speckle: dropped entirely
451        }
452        for r in r0..=r1 {
453            for c in c0..=c1 {
454                out[r * cols + c] = true;
455            }
456        }
457    }
458    out
459}
460
461/// Temporal stability mask: passes through only newly-appeared, now-static
462/// overlays (item tooltips, dialogs); moving gameplay and the permanent
463/// scene render black. Kept blocks are byte-identical to the input. Tick it
464/// fast (5–10 Hz): the frame-diff gate downstream keeps detector cost
465/// unchanged while nothing changes.
466pub struct StabilityMask {
467    block: u32,
468    params: MaskParams,
469    dilate: u32,
470    scene_threshold: f32,
471    fill_components: bool,
472    min_component: usize,
473    holdover_explicit: bool,
474    /// Grid state; reset when the view dimensions change.
475    state: Vec<Block>,
476    dims: (u32, u32),
477    tick: u64,
478    sinks: Vec<Box<dyn DebugSink>>,
479}
480
481impl Default for StabilityMask {
482    fn default() -> Self {
483        Self::new()
484    }
485}
486
487impl StabilityMask {
488    pub fn new() -> Self {
489        Self {
490            block: 16,
491            params: MaskParams {
492                stable_ticks: 6,
493                baseline_ticks: 100,
494                signature_tolerance: 10,
495                novelty_threshold: 18,
496                contrast_threshold: 14,
497                holdover_ticks: 12,
498                long_run: 18,
499            },
500            dilate: 1,
501            scene_threshold: 0.65,
502            fill_components: true,
503            min_component: 4,
504            holdover_explicit: false,
505            state: Vec::new(),
506            dims: (0, 0),
507            tick: 0,
508            sinks: Vec::new(),
509        }
510    }
511
512    /// Analysis block size in pixels (default 16).
513    pub fn block_size(mut self, px: u32) -> Self {
514        assert!(px > 0, "block size must be positive");
515        self.block = px;
516        self
517    }
518
519    /// K: consecutive stable ticks before a block counts as settled
520    /// (default 6). Holdover (2K) and the long-run threshold (3K) derive
521    /// from this unless set explicitly.
522    pub fn stable_ticks(mut self, k: u32) -> Self {
523        let k = k.clamp(1, u16::MAX as u32) as u16;
524        self.params.stable_ticks = k;
525        self.params.long_run = k.saturating_mul(3);
526        if !self.holdover_explicit {
527            self.params.holdover_ticks = k.saturating_mul(2);
528        }
529        self
530    }
531
532    /// Duration form of [`Self::stable_ticks`]; converted via the watcher's
533    /// rate (tick-based internals keep paused-time tests deterministic).
534    pub fn stable_for(self, rate: Rate, duration: Duration) -> Self {
535        let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
536        self.stable_ticks(ticks.max(1))
537    }
538
539    /// T: baseline EMA horizon in ticks (default 100). The EMA only runs
540    /// while a block is idle and unfrozen; scene changes re-seed instantly.
541    pub fn baseline_ticks(mut self, t: u32) -> Self {
542        self.params.baseline_ticks = t.max(1);
543        self
544    }
545
546    /// Duration form of [`Self::baseline_ticks`].
547    pub fn baseline(self, rate: Rate, duration: Duration) -> Self {
548        let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
549        self.baseline_ticks(ticks.max(1))
550    }
551
552    /// Keep-mask dilation rings applied after the component pass
553    /// (default 1) — keeps glyphs off the fill boundary.
554    pub fn dilate(mut self, rings: u32) -> Self {
555        self.dilate = rings;
556        self
557    }
558
559    /// Per-component movement tolerance on the signature vote (default 10).
560    pub fn signature_tolerance(mut self, tol: u8) -> Self {
561        self.params.signature_tolerance = tol;
562        self
563    }
564
565    /// Per-sub-mean novelty threshold on the baseline vote (default 18).
566    pub fn baseline_threshold(mut self, thr: u8) -> Self {
567        self.params.novelty_threshold = thr;
568        self
569    }
570
571    /// Contrast-byte novelty threshold (default 14) — the dark-on-dark
572    /// discriminator: a flat panel over textured scene is novel even at
573    /// identical mean luma.
574    pub fn contrast_threshold(mut self, thr: u8) -> Self {
575        self.params.contrast_threshold = thr;
576        self
577    }
578
579    /// Fraction of the frame that must perturb in one tick to count as a
580    /// scene transition (default 0.65). Settling blocks then adopt the new
581    /// scene instead of becoming "kept".
582    pub fn scene_threshold(mut self, frac: f32) -> Self {
583        assert!((0.0..=1.0).contains(&frac), "fraction in 0..=1");
584        self.scene_threshold = frac;
585        self
586    }
587
588    /// Component pass (default on): drop kept components smaller than
589    /// [`Self::min_component`], fill each survivor's bounding box. Off =
590    /// v1-style morphological close.
591    pub fn fill_components(mut self, on: bool) -> Self {
592        self.fill_components = on;
593        self
594    }
595
596    /// Minimum kept-component size in blocks (default 4); smaller
597    /// components are treated as speckle and dropped.
598    pub fn min_component(mut self, blocks: usize) -> Self {
599        self.min_component = blocks.max(1);
600        self
601    }
602
603    /// H: ticks a kept block keeps rendering after its content starts
604    /// changing (default 2×stable_ticks) — the anti-patchwork hysteresis.
605    pub fn holdover_ticks(mut self, h: u32) -> Self {
606        self.params.holdover_ticks = h.clamp(1, u16::MAX as u32) as u16;
607        self.holdover_explicit = true;
608        self
609    }
610
611    /// Attach a debug tap (e.g. [`PngDump`](crate::PngDump)) receiving
612    /// per-tick Input/Baseline/Overlay/State/Output frames. Composable —
613    /// multiple sinks allowed. Stage frames are only materialized when at
614    /// least one sink is attached.
615    pub fn debug_sink(mut self, sink: impl DebugSink) -> Self {
616        self.sinks.push(Box::new(sink));
617        self
618    }
619
620    fn grid(&self, w: u32, h: u32) -> (usize, usize) {
621        (
622            (w as usize).div_ceil(self.block as usize),
623            (h as usize).div_ceil(self.block as usize),
624        )
625    }
626}
627
628/// Compute all block signatures in one pass over the view.
629fn compute_signatures(
630    view: &FrameView<'_>,
631    block: u32,
632    cols: usize,
633    rows: usize,
634) -> Vec<Signature> {
635    let n = cols * rows;
636    let b = block as usize;
637    let mut sub_sum = vec![[0u64; 16]; n];
638    let mut sub_cnt = vec![[0u32; 16]; n];
639    let mut sum = vec![0u64; n];
640    let mut sum_sq = vec![0u64; n];
641    let mut cnt = vec![0u64; n];
642
643    for (y, row) in view.rows().enumerate() {
644        let br = y / b;
645        let sr = ((y % b) * 4) / b; // sub-row 0..4
646        for (x, px) in row.chunks_exact(4).enumerate() {
647            let bc = x / b;
648            let sc = ((x % b) * 4) / b;
649            // Integer BT.601 luma, same weights as the detectors.
650            let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
651            let bi = br * cols + bc;
652            let si = sr * 4 + sc;
653            sub_sum[bi][si] += luma as u64;
654            sub_cnt[bi][si] += 1;
655            sum[bi] += luma as u64;
656            sum_sq[bi] += (luma as u64) * (luma as u64);
657            cnt[bi] += 1;
658        }
659    }
660
661    (0..n)
662        .map(|i| {
663            let mut sig = [0u8; SIG_LEN];
664            let mean = sum[i].checked_div(cnt[i]).unwrap_or(0) as u8;
665            for s in 0..16 {
666                // Empty sub-cells inherit the block mean (no phantom edges).
667                sig[s] = sub_sum[i][s]
668                    .checked_div(sub_cnt[i][s] as u64)
669                    .map_or(mean, |v| v as u8);
670            }
671            sig[16] = if cnt[i] == 0 {
672                0
673            } else {
674                let m = sum[i] as f64 / cnt[i] as f64;
675                let var = (sum_sq[i] as f64 / cnt[i] as f64) - m * m;
676                var.max(0.0).sqrt().min(255.0) as u8
677            };
678            Signature(sig)
679        })
680        .collect()
681}
682
683impl Preprocessor for StabilityMask {
684    fn process(&mut self, view: &FrameView<'_>) -> Result<Arc<Frame>, DetectorError> {
685        let (w, h) = (view.width(), view.height());
686        let (cols, rows) = self.grid(w, h);
687        if self.dims != (w, h) {
688            self.state = vec![Block::default(); cols * rows];
689            self.dims = (w, h);
690        }
691        let n = cols * rows;
692        let p = &self.params;
693
694        // ---- Phase 1: signatures + frame-global signals ----
695        let sigs = compute_signatures(view, self.block, cols, rows);
696        let mut moving = vec![false; n];
697        let mut initialized = 0usize;
698        let mut idle = 0usize;
699        let mut perturbed = 0usize;
700        let mut census_eligible = vec![false; n];
701        let mut census_count = 0usize;
702        let mut would_enter = 0usize;
703        for i in 0..n {
704            let b = &self.state[i];
705            if !b.initialized {
706                continue;
707            }
708            initialized += 1;
709            if b.phase == Phase::Idle {
710                idle += 1;
711            }
712            let mv = is_moving(&sigs[i], &b.last, p.signature_tolerance);
713            moving[i] = mv;
714            if mv {
715                perturbed += 1;
716            } else {
717                if b.unstable_run >= p.stable_ticks {
718                    census_eligible[i] = true;
719                    census_count += 1;
720                }
721                // Slow-drift candidates only: blocks ALREADY stable before
722                // this tick (a settling pop-in has stable_ticks < K here).
723                if b.phase == Phase::Idle
724                    && b.stable_ticks >= p.stable_ticks
725                    && is_novel_vs(
726                        &sigs[i],
727                        &narrow(&b.base),
728                        p.novelty_threshold,
729                        p.contrast_threshold,
730                    )
731                {
732                    would_enter += 1;
733                }
734            }
735        }
736        let denom = initialized.max(1) as f32;
737        let frac = perturbed as f32 / denom;
738        let globals = Globals {
739            scene_tick: frac >= self.scene_threshold,
740            corroborated_tick: frac >= CORROBORATION_FRAC,
741            census_hit: (census_count as f32 / denom) >= CENSUS_FRAC,
742            drift_guard: (would_enter as f32 / idle.max(1) as f32) >= DRIFT_FRAC,
743        };
744
745        // ---- Phase 2: per-block state machine ----
746        let mut keep_raw = vec![false; n];
747        for i in 0..n {
748            keep_raw[i] = step_block(
749                &mut self.state[i],
750                &sigs[i],
751                moving[i],
752                census_eligible[i],
753                &globals,
754                p,
755            );
756        }
757
758        // ---- Render stage ----
759        let keep = if self.fill_components {
760            let filled = fill_components_bbox(&keep_raw, cols, rows, self.min_component);
761            if self.dilate == 0 {
762                filled
763            } else {
764                dilate_grid(&filled, cols, rows, self.dilate as usize)
765            }
766        } else {
767            close_and_dilate(&keep_raw, cols, rows, self.dilate)
768        };
769
770        // Kept blocks byte-identical, suppressed blocks black.
771        let mut data = vec![0u8; w as usize * h as usize * 4];
772        for px in data.chunks_exact_mut(4) {
773            px[3] = 255;
774        }
775        for (y, row) in view.rows().enumerate() {
776            let br = y / self.block as usize;
777            for bc in 0..cols {
778                if !keep[br * cols + bc] {
779                    continue;
780                }
781                let x0 = bc * self.block as usize * 4;
782                let x1 = (((bc + 1) * self.block as usize) * 4).min(row.len());
783                let dst = y * w as usize * 4;
784                data[dst + x0..dst + x1].copy_from_slice(&row[x0..x1]);
785            }
786        }
787        let output = Frame::new(w, h, data)
788            .map(Arc::new)
789            .map_err(|e| DetectorError::Other(format!("mask render: {e}")))?;
790
791        if !self.sinks.is_empty() {
792            self.emit_debug(view, &keep, cols, &output);
793        }
794        self.tick += 1;
795        Ok(output)
796    }
797
798    fn set_label(&mut self, label: &str) {
799        for sink in &mut self.sinks {
800            sink.set_label(label);
801        }
802    }
803}
804
805impl StabilityMask {
806    /// Materialize the debug stage frames and fan them out. Only called
807    /// when sinks exist; sinks themselves must never block (drop-on-lag).
808    fn emit_debug(&mut self, view: &FrameView<'_>, keep: &[bool], cols: usize, output: &Frame) {
809        let (w, h) = (view.width(), view.height());
810        let input = Frame::new(w, h, view.to_vec()).expect("view-sized buffer");
811
812        // Baseline: each block's scene-reference mean as gray.
813        // Overlay: input with suppressed blocks dimmed to 25%.
814        // State: false-color block phases — IDLE black, frozen-IDLE dim
815        // blue, KEPT green, HOLDOVER yellow, re-seed red.
816        let mut baseline = vec![0u8; w as usize * h as usize * 4];
817        let mut state = vec![0u8; w as usize * h as usize * 4];
818        let mut overlay = input.data().to_vec();
819        for y in 0..h as usize {
820            let br = y / self.block as usize;
821            for x in 0..w as usize {
822                let bc = x / self.block as usize;
823                let i = (y * w as usize + x) * 4;
824                let blk = &self.state[br * cols + bc];
825                let mean: u32 = blk.base[..16].iter().map(|v| (*v >> 8) as u32).sum::<u32>() / 16;
826                baseline[i] = mean as u8;
827                baseline[i + 1] = mean as u8;
828                baseline[i + 2] = mean as u8;
829                baseline[i + 3] = 255;
830                let bgra: [u8; 4] = if blk.reseeded {
831                    [0, 0, 220, 255] // red: re-seeded this tick
832                } else {
833                    match blk.phase {
834                        Phase::Kept => [0, 200, 0, 255],                     // green
835                        Phase::Holdover => [0, 200, 200, 255],               // yellow
836                        Phase::Idle if blk.base_frozen => [120, 40, 0, 255], // dim blue
837                        Phase::Idle => [0, 0, 0, 255],
838                    }
839                };
840                state[i..i + 4].copy_from_slice(&bgra);
841                if !keep[br * cols + bc] {
842                    overlay[i] /= 4;
843                    overlay[i + 1] /= 4;
844                    overlay[i + 2] /= 4;
845                }
846            }
847        }
848        let baseline = Frame::new(w, h, baseline).expect("view-sized buffer");
849        let state = Frame::new(w, h, state).expect("view-sized buffer");
850        let overlay = Frame::new(w, h, overlay).expect("view-sized buffer");
851
852        for sink in &mut self.sinks {
853            sink.write(self.tick, DebugStage::Input, &input);
854            sink.write(self.tick, DebugStage::Baseline, &baseline);
855            sink.write(self.tick, DebugStage::Overlay, &overlay);
856            sink.write(self.tick, DebugStage::State, &state);
857            sink.write(self.tick, DebugStage::Output, output);
858        }
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use std::sync::Mutex;
866
867    fn params() -> MaskParams {
868        MaskParams {
869            stable_ticks: 3,
870            baseline_ticks: 20,
871            signature_tolerance: 10,
872            novelty_threshold: 18,
873            contrast_threshold: 14,
874            holdover_ticks: 6,
875            long_run: 9,
876        }
877    }
878
879    /// Uniform-content signature: all sub-means equal, zero contrast.
880    fn flat(luma: u8) -> Signature {
881        let mut s = [luma; SIG_LEN];
882        s[16] = 0;
883        s.into()
884    }
885
886    /// Textured signature: alternating sub-means around `luma`, contrast c.
887    fn textured(luma: u8, spread: u8, c: u8) -> Signature {
888        let mut s = [0u8; SIG_LEN];
889        for (i, v) in s[..16].iter_mut().enumerate() {
890            *v = if i % 2 == 0 {
891                luma.saturating_add(spread)
892            } else {
893                luma.saturating_sub(spread)
894            };
895        }
896        s[16] = c;
897        Signature(s)
898    }
899
900    impl From<[u8; SIG_LEN]> for Signature {
901        fn from(v: [u8; SIG_LEN]) -> Self {
902            Signature(v)
903        }
904    }
905
906    const QUIET: Globals = Globals {
907        scene_tick: false,
908        corroborated_tick: false,
909        census_hit: false,
910        drift_guard: false,
911    };
912
913    /// Drive one block with (signature, globals) pairs; phase-1 movement is
914    /// recomputed exactly like process() does.
915    fn run(b: &mut Block, script: &[(Signature, Globals)], p: &MaskParams) -> Vec<bool> {
916        script
917            .iter()
918            .map(|(sig, g)| {
919                let mv = b.initialized && is_moving(sig, &b.last, p.signature_tolerance);
920                let census = b.initialized && !mv && b.unstable_run >= p.stable_ticks;
921                step_block(b, sig, mv, census, g, p)
922            })
923            .collect()
924    }
925
926    fn quiet(sigs: &[Signature]) -> Vec<(Signature, Globals)> {
927        sigs.iter().map(|s| (*s, QUIET)).collect()
928    }
929
930    fn rep(sig: Signature, n: usize) -> Vec<Signature> {
931        vec![sig; n]
932    }
933
934    // ---------- signature and vote tests ----------
935
936    #[test]
937    fn single_component_noise_cannot_flip_movement_or_novelty() {
938        let a = flat(100);
939        let mut b = a;
940        b.0[3] = 140; // one sub-cell jumps
941        assert!(!is_moving(&b, &a, 10), "one component is not movement");
942        assert!(
943            !is_novel_vs(&b, &a.0, 18, 14),
944            "one component is not novelty"
945        );
946        b.0[7] = 140; // two sub-cells
947        assert!(is_moving(&b, &a, 10));
948        assert!(is_novel_vs(&b, &a.0, 18, 14));
949    }
950
951    #[test]
952    fn dark_on_dark_flat_panel_is_novel_via_contrast() {
953        // Textured dark scene vs flat dark panel at the SAME mean luma:
954        // v1's mean-only comparator called this "not novel" (the measured
955        // hole-punching); the contrast clause must catch it.
956        let scene = textured(30, 12, 40);
957        let panel = flat(30);
958        assert!(
959            is_novel_vs(&panel, &scene.0, 18, 14),
960            "flat panel over textured scene must be novel"
961        );
962    }
963
964    #[test]
965    fn signatures_capture_subblock_structure() {
966        let frame = Frame::from_fn(16, 16, |x, _| {
967            if x < 8 {
968                [0, 0, 0, 255]
969            } else {
970                [255, 255, 255, 255]
971            }
972        });
973        let view = frame
974            .view(visual_cortex_capture::PxRect {
975                x: 0,
976                y: 0,
977                w: 16,
978                h: 16,
979            })
980            .unwrap();
981        let sigs = compute_signatures(&view, 16, 1, 1);
982        let s = &sigs[0].0;
983        assert!(s[0] < 10 && s[1] < 10, "left sub-cells dark");
984        assert!(s[2] > 245 && s[3] > 245, "right sub-cells bright");
985        assert!(s[16] > 100, "half-and-half block has high contrast");
986    }
987
988    // ---------- state machine: v1 regressions ----------
989
990    #[test]
991    fn constant_hud_block_is_never_kept() {
992        let mut b = Block::default();
993        let out = run(&mut b, &quiet(&rep(textured(100, 20, 30), 30)), &params());
994        assert!(out.iter().all(|k| !k), "HUD must stay suppressed");
995    }
996
997    #[test]
998    fn noisy_gameplay_block_is_never_kept() {
999        let mut b = Block::default();
1000        let sigs: Vec<Signature> = (0..30)
1001            .map(|i| flat(if i % 2 == 0 { 40 } else { 200 }))
1002            .collect();
1003        let out = run(&mut b, &quiet(&sigs), &params());
1004        assert!(out.iter().all(|k| !k), "churn must stay suppressed");
1005    }
1006
1007    #[test]
1008    fn tooltip_becomes_kept_after_k_stable_ticks() {
1009        let mut b = Block::default();
1010        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1011        let out = run(&mut b, &quiet(&rep(flat(200), 6)), &params());
1012        // Jump tick resets; settled at K=3 (index 3), then stays.
1013        assert_eq!(out, vec![false, false, false, true, true, true]);
1014        assert_eq!(b.phase, Phase::Kept);
1015    }
1016
1017    #[test]
1018    fn held_tooltip_survives_indefinitely() {
1019        let mut b = Block::default();
1020        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1021        let out = run(&mut b, &quiet(&rep(flat(200), 200)), &params());
1022        assert!(out[10..].iter().all(|k| *k), "no ghosting, ever");
1023        assert!(b.base_frozen, "baseline frozen under the kept overlay");
1024    }
1025
1026    #[test]
1027    fn dismissed_tooltip_thaws_and_suppresses() {
1028        let mut b = Block::default();
1029        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1030        run(&mut b, &quiet(&rep(flat(200), 10)), &params());
1031        let out = run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1032        assert_eq!(b.phase, Phase::Idle);
1033        assert!(!b.base_frozen, "baseline resumed after departure");
1034        assert!(!out.last().unwrap(), "restored scene suppressed");
1035    }
1036
1037    // ---------- red-team scenarios ----------
1038
1039    #[test]
1040    fn browsing_hand_off_never_goes_dark() {
1041        // Tooltip A held, then re-renders into tooltip B (different content),
1042        // then B is held: the block must RENDER CONTINUOUSLY (HOLDOVER) —
1043        // the v1 patchwork went black here for K ticks.
1044        let mut b = Block::default();
1045        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1046        run(&mut b, &quiet(&rep(flat(200), 10)), &params()); // A kept
1047        let mut script = rep(flat(150), 1); // one transition tick
1048        script.extend(rep(flat(230), 8)); // B settles and holds
1049        let out = run(&mut b, &quiet(&script), &params());
1050        assert!(
1051            out.iter().all(|k| *k),
1052            "hand-off must render every tick, got {out:?}"
1053        );
1054        assert_eq!(b.phase, Phase::Kept, "re-latched onto tooltip B");
1055    }
1056
1057    #[test]
1058    fn flash_over_held_tooltip_relatches_without_reseed() {
1059        // Red-team scenario 10 (the pinned invariant probe): a 2-tick
1060        // frame-global flash washes out a held tooltip. The block must ride
1061        // HOLDOVER through the flash and re-latch via the ovl snapshot the
1062        // moment the tooltip pixels return — no K wait, no re-seed.
1063        let p = params();
1064        let mut b = Block::default();
1065        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1066        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // tooltip kept
1067        let flash = Globals {
1068            scene_tick: true,
1069            corroborated_tick: true,
1070            census_hit: false,
1071            drift_guard: false,
1072        };
1073        let script = vec![
1074            (flat(255), flash),
1075            (flat(255), flash),
1076            (flat(200), QUIET), // tooltip pixels return
1077            (flat(200), QUIET),
1078        ];
1079        let out = run(&mut b, &script, &p);
1080        assert!(out.iter().all(|k| *k), "renders through the flash: {out:?}");
1081        assert_eq!(b.phase, Phase::Kept, "immediate ovl re-latch");
1082        assert!(!b.reseeded, "a flash is not a scene change");
1083        assert!(b.base_frozen, "invariant intact: base still frozen");
1084    }
1085
1086    #[test]
1087    fn reveal_after_occluded_scene_change_resolves_on_next_motion() {
1088        // The scene changes while a tooltip occludes a block, then the
1089        // tooltip is dismissed. Per-block, "revealed new scene" and "new
1090        // overlay" are indistinguishable (the base went stale unseen), so
1091        // the reveal is RENDERED (reveal-over-suppress: extra revealed
1092        // scene is benign; a suppressed tooltip is a hard failure). The
1093        // next corroborated motion episode re-anchors it.
1094        let p = params();
1095        let mut b = Block::default();
1096        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1097        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // tooltip kept
1098        let scene = Globals {
1099            scene_tick: true,
1100            corroborated_tick: true,
1101            census_hit: false,
1102            drift_guard: false,
1103        };
1104        run(&mut b, &[(flat(150), scene)], &p); // occluded scene event
1105        run(&mut b, &quiet(&rep(flat(200), 6)), &p); // tooltip re-latches
1106        assert_eq!(b.phase, Phase::Kept, "tooltip survives the event");
1107        // Dismissal reveals the NEW scene (120): ambiguous -> rendered.
1108        let out = run(&mut b, &quiet(&rep(flat(120), 8)), &p);
1109        assert!(*out.last().unwrap(), "ambiguous reveal stays rendered");
1110        // Next corroborated motion episode (player walks) re-anchors.
1111        let walking = Globals {
1112            scene_tick: false,
1113            corroborated_tick: true,
1114            census_hit: false,
1115            drift_guard: false,
1116        };
1117        let script: Vec<(Signature, Globals)> = (0..12)
1118            .map(|i| (flat(if i % 2 == 0 { 80 } else { 170 }), walking))
1119            .collect();
1120        run(&mut b, &script, &p);
1121        let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1122        assert!(!out.last().unwrap(), "re-anchored after motion");
1123        let base = narrow(&b.base);
1124        assert!(
1125            base[0].abs_diff(140) < 10,
1126            "baseline follows the real scene"
1127        );
1128    }
1129
1130    #[test]
1131    fn browsing_hand_off_survives_a_prior_flash() {
1132        // Regression for the review finding: scene_flag set during a flash
1133        // must NOT re-seed the next tooltip hand-off (which would corrupt
1134        // the baseline with overlay content and suppress tooltip B).
1135        let p = params();
1136        let mut b = Block::default();
1137        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1138        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // A kept
1139        let flash = Globals {
1140            scene_tick: true,
1141            corroborated_tick: true,
1142            census_hit: false,
1143            drift_guard: false,
1144        };
1145        run(&mut b, &[(flat(255), flash), (flat(255), flash)], &p);
1146        run(&mut b, &quiet(&rep(flat(200), 4)), &p); // re-latch on A
1147        assert_eq!(b.phase, Phase::Kept);
1148        // Hand off to tooltip B: must be KEPT, not re-seeded.
1149        let mut script = rep(flat(150), 1);
1150        script.extend(rep(flat(230), 8));
1151        let out = run(&mut b, &quiet(&script), &p);
1152        assert!(
1153            out.iter().all(|k| *k),
1154            "hand-off renders throughout: {out:?}"
1155        );
1156        assert_eq!(b.phase, Phase::Kept, "tooltip B kept");
1157        let base = narrow(&b.base);
1158        assert!(
1159            base[0].abs_diff(60) < 10,
1160            "baseline still the scene, never overlay content: base={} frozen={} scene_flag={}",
1161            base[0],
1162            b.base_frozen,
1163            b.scene_flag
1164        );
1165    }
1166
1167    #[test]
1168    fn walk_and_stop_reseeds_instead_of_keeping() {
1169        // Red-team scenario 3 / measured ratchet: sustained corroborated
1170        // motion (walking) then stopping must re-seed, not keep the grass.
1171        let p = params();
1172        let mut b = Block::default();
1173        run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1174        let walking = Globals {
1175            scene_tick: false,
1176            corroborated_tick: true, // frame-wide motion corroborates
1177            census_hit: false,
1178            drift_guard: false,
1179        };
1180        let script: Vec<(Signature, Globals)> = (0..12)
1181            .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), walking))
1182            .collect();
1183        run(&mut b, &script, &p); // 12 corroborated unstable ticks >= U=9
1184        let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1185        assert!(
1186            out.iter().all(|k| !k),
1187            "new scene must not be kept: {out:?}"
1188        );
1189        let base = narrow(&b.base);
1190        assert!(base[0].abs_diff(140) < 10, "re-seeded to the new scene");
1191    }
1192
1193    #[test]
1194    fn fast_browsing_churn_is_not_corroborated_and_final_tooltip_is_kept() {
1195        // Red-team scenario 7 (BREAKS without the corroboration gate):
1196        // tooltips changing every 2 ticks for a long stretch build a long
1197        // unstable run, but the churn is locally confined (quiet globals) —
1198        // it must NOT re-seed, and the finally-held tooltip must be kept.
1199        let p = params();
1200        let mut b = Block::default();
1201        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1202        // 20 ticks of item-flipping (uncorroborated churn), run >= U.
1203        let script: Vec<Signature> = (0..20)
1204            .map(|i| flat(if i % 2 == 0 { 200 } else { 120 }))
1205            .collect();
1206        run(&mut b, &quiet(&script), &p);
1207        assert!(!b.run_corroborated, "local churn is never corroborated");
1208        // User finally holds one item.
1209        let out = run(&mut b, &quiet(&rep(flat(200), 6)), &p);
1210        assert!(*out.last().unwrap(), "final tooltip must be kept: {out:?}");
1211        assert_eq!(b.phase, Phase::Kept);
1212    }
1213
1214    #[test]
1215    fn minimap_pulse_train_is_never_kept() {
1216        // Red-team scenario 6: N stable / 2 perturbed / N stable, returning
1217        // to identical pixels each time.
1218        let p = params();
1219        let mut b = Block::default();
1220        let mut script = Vec::new();
1221        for _ in 0..6 {
1222            script.extend(rep(textured(80, 15, 25), 8)); // stable HUD
1223            script.extend(rep(textured(140, 15, 25), 2)); // pulse
1224        }
1225        let out = run(&mut b, &quiet(&script), &p);
1226        assert!(out.iter().all(|k| !k), "pulsing HUD must stay suppressed");
1227    }
1228
1229    #[test]
1230    fn census_flag_reseeds_short_global_motion() {
1231        // A hard cut below theta_scene per-tick but caught by the census:
1232        // blocks with a long run settling on a census tick re-seed.
1233        let p = params();
1234        let mut b = Block::default();
1235        run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1236        let script: Vec<(Signature, Globals)> = (0..5)
1237            .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), QUIET))
1238            .collect();
1239        run(&mut b, &script, &p); // unstable_run = 5 >= K
1240        let census = Globals {
1241            scene_tick: false,
1242            corroborated_tick: false,
1243            census_hit: true,
1244            drift_guard: false,
1245        };
1246        // The census stays hot while the long-perturbed mass settles
1247        // (process() recomputes it per tick from grid state).
1248        let mut settle = vec![(flat(140), census); 3];
1249        settle.extend(quiet(&rep(flat(140), 6)));
1250        let out = run(&mut b, &settle, &p);
1251        assert!(out.iter().all(|k| !k), "census settle re-seeds: {out:?}");
1252    }
1253
1254    #[test]
1255    fn drift_guard_reseeds_slow_drift_but_never_pop_ins() {
1256        let p = params();
1257        let guard = Globals {
1258            scene_tick: false,
1259            corroborated_tick: false,
1260            census_hit: false,
1261            drift_guard: true,
1262        };
1263        // Slow drift: luma creeps below the movement vote each tick (steps
1264        // of 8 < tol 10) until it is novel vs the lagging baseline — with
1265        // the guard up this re-seeds (ambient lighting), never keeps.
1266        let mut b = Block::default();
1267        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1268        let script: Vec<(Signature, Globals)> =
1269            (0..12).map(|i| (flat(60 + 8 * (i + 1)), guard)).collect();
1270        let out = run(&mut b, &script, &p);
1271        assert!(out.iter().all(|k| !k), "ambient fade must not be kept");
1272        // Pop-in: trips the movement vote, so the guard must NOT apply —
1273        // a tooltip can legitimately cover most of a cropped region.
1274        let mut b = Block::default();
1275        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1276        let script: Vec<(Signature, Globals)> =
1277            rep(flat(200), 6).into_iter().map(|s| (s, guard)).collect();
1278        let out = run(&mut b, &script, &p);
1279        assert!(*out.last().unwrap(), "pop-in is kept even under the guard");
1280    }
1281
1282    #[test]
1283    fn holdover_expires_to_idle_when_content_keeps_churning() {
1284        let p = params();
1285        let mut b = Block::default();
1286        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1287        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // kept
1288                                                      // Content churns without ever settling: render for H ticks, then dark.
1289        let script: Vec<Signature> = (0..12)
1290            .map(|i| flat(if i % 2 == 0 { 90 } else { 170 }))
1291            .collect();
1292        let out = run(&mut b, &quiet(&script), &p);
1293        assert!(out[0], "holdover renders at first");
1294        assert!(!out.last().unwrap(), "expired holdover suppresses");
1295        assert_eq!(b.phase, Phase::Idle);
1296    }
1297
1298    // ---------- grid geometry ----------
1299
1300    #[test]
1301    fn component_fill_drops_speckle_and_fills_bboxes() {
1302        // 6x6 grid: an L-shaped 5-block component + a 1-block speckle.
1303        let cols = 6;
1304        let mut keep = vec![false; 36];
1305        for (r, c) in [(1, 1), (2, 1), (3, 1), (3, 2), (3, 3)] {
1306            keep[r * cols + c] = true;
1307        }
1308        keep[5 * cols + 5] = true; // speckle
1309        let out = fill_components_bbox(&keep, cols, 6, 4);
1310        assert!(out[cols + 2] && out[cols + 3], "bbox filled the L's notch");
1311        assert!(!out[5 * cols + 5], "speckle dropped");
1312    }
1313
1314    #[test]
1315    fn close_fills_interior_holes_and_dilate_expands() {
1316        let cols = 5;
1317        let mut keep = vec![false; 25];
1318        for (r, c) in [
1319            (1usize, 1usize),
1320            (1, 2),
1321            (1, 3),
1322            (2, 1),
1323            (2, 3),
1324            (3, 1),
1325            (3, 2),
1326            (3, 3),
1327        ] {
1328            keep[r * cols + c] = true;
1329        }
1330        let out = close_and_dilate(&keep, cols, 5, 0);
1331        assert!(out[2 * cols + 2], "interior hole must be closed");
1332        let out = close_and_dilate(&keep, cols, 5, 1);
1333        assert!(out[cols], "edge cell reached by one dilation ring");
1334        assert!(
1335            !out[0],
1336            "corner is two steps away; one ring must not reach it"
1337        );
1338    }
1339
1340    #[test]
1341    fn cold_start_keeps_nothing() {
1342        let mut b = Block::default();
1343        assert!(!step_block(
1344            &mut b,
1345            &flat(200),
1346            false,
1347            false,
1348            &QUIET,
1349            &params()
1350        ));
1351    }
1352
1353    // ---------- process-level regressions ----------
1354
1355    /// Review regression: a pop-in overlay covering most of the watched
1356    /// region (55-62%: above DRIFT/CENSUS floors, below the scene band)
1357    /// must be revealed — the drift guard and census apply to fades and
1358    /// long-perturbed mass settles, never to fresh pop-ins.
1359    #[test]
1360    fn large_pop_in_covering_most_of_the_region_is_revealed() {
1361        use visual_cortex_capture::PxRect;
1362        let mut mask = StabilityMask::new()
1363            .block_size(8)
1364            .stable_ticks(3)
1365            .min_component(1)
1366            .dilate(0);
1367        let full = PxRect {
1368            x: 0,
1369            y: 0,
1370            w: 48,
1371            h: 48,
1372        }; // 6x6 blocks
1373           // Tooltip covers the top 22 of 36 blocks (~61%).
1374        let bg = Frame::solid(48, 48, [60, 60, 60, 255]);
1375        let tip = Frame::from_fn(48, 48, |x, y| {
1376            if y < 24 || (y < 32 && x < 32) {
1377                [230, 230, 230, 255]
1378            } else {
1379                [60, 60, 60, 255]
1380            }
1381        });
1382        for _ in 0..8 {
1383            mask.process(&bg.view(full).unwrap()).unwrap();
1384        }
1385        let mut last = None;
1386        for _ in 0..8 {
1387            last = Some(mask.process(&tip.view(full).unwrap()).unwrap());
1388        }
1389        let out = last.unwrap();
1390        let px = &out.data()[(4 * 48 + 4) * 4..(4 * 48 + 4) * 4 + 3];
1391        assert_eq!(px, &[230, 230, 230], "pop-in region revealed, got {px:?}");
1392    }
1393
1394    // ---------- builder derivations ----------
1395
1396    #[test]
1397    fn builder_derives_holdover_and_long_run_from_stable_ticks() {
1398        let m = StabilityMask::new().stable_ticks(5);
1399        assert_eq!(m.params.holdover_ticks, 10, "H = 2K");
1400        assert_eq!(m.params.long_run, 15, "U = 3K");
1401        // Explicit holdover survives a later stable_ticks call.
1402        let m = StabilityMask::new().holdover_ticks(5).stable_ticks(10);
1403        assert_eq!(m.params.holdover_ticks, 5, "explicit H wins");
1404        assert_eq!(m.params.long_run, 30);
1405        // Large K no longer clamps H (u16 storage).
1406        let m = StabilityMask::new().stable_ticks(300);
1407        assert_eq!(m.params.holdover_ticks, 600);
1408    }
1409
1410    // ---------- edge blocks ----------
1411
1412    #[test]
1413    fn partial_edge_blocks_average_available_pixels_only() {
1414        use visual_cortex_capture::PxRect;
1415        // 19x13 with block 16: right edge block is 3px wide, bottom-right
1416        // corner is 3x13... grid is 2x1 blocks wide? 19/16 -> 2 cols, 13/16
1417        // -> 1 row. Right block = 3x13 bright pixels.
1418        let frame = Frame::from_fn(19, 13, |x, _| {
1419            if x < 16 {
1420                [40, 40, 40, 255]
1421            } else {
1422                [220, 220, 220, 255]
1423            }
1424        });
1425        let view = frame
1426            .view(PxRect {
1427                x: 0,
1428                y: 0,
1429                w: 19,
1430                h: 13,
1431            })
1432            .unwrap();
1433        let sigs = compute_signatures(&view, 16, 2, 1);
1434        // Partial right block: every populated sub-cell averages only
1435        // bright pixels; empty sub-cells inherit the block mean (also
1436        // bright) — nothing collapses to 0.
1437        assert!(
1438            sigs[1].0[..16].iter().all(|v| *v > 200),
1439            "partial block averages available pixels: {:?}",
1440            sigs[1].0
1441        );
1442        assert!(sigs[0].0[..16].iter().all(|v| *v < 60));
1443    }
1444
1445    // ---------- debug sink fan-out ----------
1446
1447    #[test]
1448    fn debug_sinks_receive_all_five_stages_per_tick() {
1449        struct Recording(Arc<Mutex<Vec<(u64, DebugStage)>>>);
1450        impl DebugSink for Recording {
1451            fn write(&mut self, tick: u64, stage: DebugStage, _frame: &Frame) {
1452                self.0.lock().unwrap().push((tick, stage));
1453            }
1454        }
1455
1456        let log = Arc::new(Mutex::new(Vec::new()));
1457        let mut mask = StabilityMask::new()
1458            .block_size(8)
1459            .debug_sink(Recording(log.clone()));
1460        let frame = Frame::solid(8, 8, [60, 60, 60, 255]);
1461        let view = frame
1462            .view(visual_cortex_capture::PxRect {
1463                x: 0,
1464                y: 0,
1465                w: 8,
1466                h: 8,
1467            })
1468            .unwrap();
1469        mask.process(&view).unwrap();
1470        mask.process(&view).unwrap();
1471
1472        use DebugStage::*;
1473        assert_eq!(
1474            *log.lock().unwrap(),
1475            vec![
1476                (0, Input),
1477                (0, Baseline),
1478                (0, Overlay),
1479                (0, State),
1480                (0, Output),
1481                (1, Input),
1482                (1, Baseline),
1483                (1, Overlay),
1484                (1, State),
1485                (1, Output),
1486            ]
1487        );
1488    }
1489}