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/// One 4-connected component of the keep grid.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub(crate) struct Component {
404    /// Member block indices (row-major).
405    pub members: Vec<usize>,
406    /// Inclusive block-coordinate bounding box (r0, r1, c0, c1).
407    pub bbox: (usize, usize, usize, usize),
408}
409
410/// Extract 4-connected components of the keep grid, dropping components
411/// smaller than `min_size` (speckle).
412pub(crate) fn components(
413    keep: &[bool],
414    cols: usize,
415    rows: usize,
416    min_size: usize,
417) -> Vec<Component> {
418    let mut out = Vec::new();
419    let mut seen = vec![false; keep.len()];
420    let mut stack = Vec::new();
421    for start in 0..keep.len() {
422        if !keep[start] || seen[start] {
423            continue;
424        }
425        let (mut r0, mut r1, mut c0, mut c1) = (rows, 0usize, cols, 0usize);
426        let mut members = Vec::new();
427        stack.push(start);
428        seen[start] = true;
429        while let Some(i) = stack.pop() {
430            members.push(i);
431            let (r, c) = (i / cols, i % cols);
432            r0 = r0.min(r);
433            r1 = r1.max(r);
434            c0 = c0.min(c);
435            c1 = c1.max(c);
436            let mut push = |j: usize| {
437                if keep[j] && !seen[j] {
438                    seen[j] = true;
439                    stack.push(j);
440                }
441            };
442            if r > 0 {
443                push(i - cols);
444            }
445            if r + 1 < rows {
446                push(i + cols);
447            }
448            if c > 0 {
449                push(i - 1);
450            }
451            if c + 1 < cols {
452                push(i + 1);
453            }
454        }
455        if members.len() >= min_size {
456            out.push(Component {
457                members,
458                bbox: (r0, r1, c0, c1),
459            });
460        }
461    }
462    out
463}
464
465/// Component pass for the non-panel path: drop kept components smaller
466/// than `min_size` (speckle), fill the bounding box of each survivor.
467pub(crate) fn fill_components_bbox(
468    keep: &[bool],
469    cols: usize,
470    rows: usize,
471    min_size: usize,
472) -> Vec<bool> {
473    let mut out = vec![false; keep.len()];
474    for comp in components(keep, cols, rows, min_size) {
475        let (r0, r1, c0, c1) = comp.bbox;
476        for r in r0..=r1 {
477            for c in c0..=c1 {
478                out[r * cols + c] = true;
479            }
480        }
481    }
482    out
483}
484
485/// Panel-layer tuning (documented constants; builder knobs if tuning
486/// demands emerge).
487/// A panel counts as quiet when at most this fraction of its members moved.
488const PANEL_MOVING_FRAC: f32 = 0.20;
489/// Kept content must fill at least this fraction of its own bbox for the
490/// rect to present: real panels are dense rectangles; sparse kept scatter
491/// (e.g. a whole scene settling at once) must not bbox-fill into huge
492/// rects.
493const PANEL_DENSITY_FLOOR: f32 = 0.35;
494/// Kept-subset growth below this fraction of the footprint per tick is a
495/// straggler trickle, not assembly — do not postpone presentation for it.
496/// (Fixture trace: a settled 932-block tooltip gained 6-8 blocks per tick
497/// indefinitely; strict growth gating starved it until the next hover's
498/// onset superseded it.)
499const PANEL_GROWTH_SLACK: f32 = 0.02;
500/// A moving component belongs to a panel when at least this fraction of it
501/// lies inside the panel footprint (else it is neighboring motion).
502const PANEL_OVERLAP_FRAC: f32 = 0.30;
503/// Pending panels that never settle within this many multiples of K are
504/// gameplay churn: dropped.
505const PANEL_PENDING_DEADLINE_K: u16 = 8;
506/// A pending panel accepts footprint extensions only this many ticks after
507/// its onset (the measured fade-in stagger) — a pending panel is a
508/// short-lived hypothesis, not an accretion target. Without this bound,
509/// successive distinct onsets (cursor, tooltips, panel updates) chain into
510/// a region-sized mega-panel that never settles and starves real tooltips
511/// of their own panels (seen on the D4 fixture).
512const PANEL_ONSET_WINDOW: u16 = 3;
513
514#[derive(Debug, Clone)]
515pub(crate) struct Panel {
516    /// Co-onset footprint: the blocks that changed together (plus later
517    /// extensions from overlapping churn). Never pruned — late-settling
518    /// stragglers must still be able to join the kept subset.
519    members: Vec<usize>,
520    /// The rect rendered while presented; sticky through flux, snapped to
521    /// the kept-content bbox at each settle.
522    sticky: (usize, usize, usize, usize),
523    presented: bool,
524    /// Consecutive quiet ticks (<= PANEL_MOVING_FRAC of members moving).
525    quiet: u16,
526    /// Ticks since creation (pending only; deadline drops churn panels).
527    age: u16,
528    /// Kept-subset size at the previous evaluation (growth detector).
529    last_kept: usize,
530    /// Consecutive evaluations without slack-exceeding kept growth.
531    no_growth: u16,
532    /// Consecutive ticks (any, not just quiet) with kept support below
533    /// min_component — the settle-independent withdrawal clock.
534    low_support: u16,
535}
536
537/// Panel-level presentation driven by CO-ONSET: blocks that start changing
538/// together form one panel (the owner's insight — a UI panel re-renders as
539/// a unit). The panel settles as a GROUP (quantile: animated members and
540/// the cursor cannot hold it hostage) and presents its WHOLE rect at once,
541/// even when only part of its members individually re-stabilized (the
542/// per-block kept set can be fragmentary during fast browsing; the group
543/// decision is what reaches the output). Presented rects stay whole
544/// through churn and withdraw whole.
545#[derive(Debug, Default)]
546pub(crate) struct PanelTracker {
547    panels: Vec<Panel>,
548}
549
550fn bbox_of(
551    blocks: impl Iterator<Item = usize>,
552    cols: usize,
553) -> Option<(usize, usize, usize, usize)> {
554    let mut b: Option<(usize, usize, usize, usize)> = None;
555    for i in blocks {
556        let (r, c) = (i / cols, i % cols);
557        b = Some(match b {
558            None => (r, r, c, c),
559            Some((r0, r1, c0, c1)) => (r0.min(r), r1.max(r), c0.min(c), c1.max(c)),
560        });
561    }
562    b
563}
564
565impl PanelTracker {
566    pub(crate) fn reset(&mut self) {
567        self.panels.clear();
568    }
569
570    pub(crate) fn panels(&self) -> impl Iterator<Item = (bool, (usize, usize, usize, usize))> + '_ {
571        self.panels.iter().map(|p| (p.presented, p.sticky))
572    }
573
574    /// Advance one tick.
575    /// - `moving`: phase-1 movement votes (the onset signal);
576    /// - `keep_raw`: phase-2 per-block keep flags (the settled-content
577    ///   signal).
578    ///
579    /// Camera/scene motion needs no special-casing here: churn panels it
580    /// spawns either never settle (deadline) or settle over re-seeded
581    /// blocks and fail the group kept-test — the block machine's scene
582    /// wisdom is inherited through keep_raw. Returns the presented keep
583    /// grid.
584    pub(crate) fn tick(
585        &mut self,
586        moving: &[bool],
587        keep_raw: &[bool],
588        cols: usize,
589        rows: usize,
590        k_panel: u16,
591        min_component: usize,
592    ) -> Vec<bool> {
593        let n = cols * rows;
594        // Routable owners: presented panels (content churn belongs to
595        // them) and pending panels still inside their onset window. The
596        // map is dilated one ring: a staggered onset wave (a fade sweeping
597        // down a tooltip) lands ADJACENT to the footprint, not on it.
598        // Presented panels win collisions.
599        let mut owner = vec![usize::MAX; n];
600        // A pending panel is routable while its onset window is open OR
601        // once it has settled with content: a new onset over a panel that
602        // was about to present is a HAND-OFF (route in, re-settle, present
603        // the new content), not a stale hypothesis to supersede — the
604        // supersede race otherwise starves every tooltip whose hover
605        // interval is shorter than the presentation pipeline.
606        for pass in 0..2 {
607            for (pi, p) in self.panels.iter().enumerate() {
608                let settled_content = p.quiet >= k_panel && p.last_kept >= min_component;
609                let routable = if pass == 0 {
610                    !p.presented && (p.age < PANEL_ONSET_WINDOW || settled_content)
611                } else {
612                    p.presented
613                };
614                if routable {
615                    for &m in &p.members {
616                        owner[m] = pi;
617                    }
618                }
619            }
620        }
621        let core = owner.clone();
622        for (i, &own) in core.iter().enumerate() {
623            if own == usize::MAX {
624                continue;
625            }
626            let (r, c) = (i / cols, i % cols);
627            let mut mark = |j: usize| {
628                if owner[j] == usize::MAX {
629                    owner[j] = own;
630                }
631            };
632            if r > 0 {
633                mark(i - cols);
634            }
635            if r + 1 < rows {
636                mark(i + cols);
637            }
638            if c > 0 {
639                mark(i - 1);
640            }
641            if c + 1 < cols {
642                mark(i + 1);
643            }
644        }
645
646        // Route ALL moving components (min_size 1: sub-speckle motion
647        // inside a panel is churn, not noise to drop). A component may
648        // touch several panels — the spec's multi-match rule MERGES them
649        // (a bridging band of a disjoint-band reveal unifies its panel
650        // fragments). Presented panels require >=30% containment (edge
651        // motion must not bloat them); young pending panels match on any
652        // overlap. `redirect` resolves merged panel indices.
653        let len = self.panels.len();
654        let mut extend: Vec<Vec<usize>> = vec![Vec::new(); len];
655        let mut dead = vec![false; len];
656        let mut redirect: Vec<usize> = (0..len).collect();
657        let mut fresh: Vec<Component> = Vec::new();
658        fn resolve(redirect: &[usize], mut i: usize) -> usize {
659            while redirect[i] != i {
660                i = redirect[i];
661            }
662            i
663        }
664        for comp in components(moving, cols, rows, 1) {
665            let mut counts = vec![0usize; len];
666            for &m in &comp.members {
667                if owner[m] != usize::MAX {
668                    counts[resolve(&redirect, owner[m])] += 1;
669                }
670            }
671            let mut matched: Vec<usize> = (0..len)
672                .filter(|&pi| counts[pi] > 0 && !dead[pi])
673                .filter(|&pi| {
674                    let p = &self.panels[pi];
675                    !p.presented
676                        || counts[pi] as f32 / comp.members.len() as f32 >= PANEL_OVERLAP_FRAC
677                })
678                .collect();
679            if matched.is_empty() {
680                if comp.members.len() >= min_component {
681                    fresh.push(comp);
682                }
683                continue;
684            }
685            // Survivor: presented preferred (stable identity), else oldest
686            // (lowest index). Deterministic — no hash iteration order.
687            matched.sort_by_key(|&pi| (!self.panels[pi].presented, pi));
688            let survivor = matched[0];
689            for &victim in &matched[1..] {
690                let (v_members, v_presented, v_sticky, v_last) = {
691                    let v = &mut self.panels[victim];
692                    (
693                        std::mem::take(&mut v.members),
694                        v.presented,
695                        v.sticky,
696                        v.last_kept,
697                    )
698                };
699                let moved = std::mem::take(&mut extend[victim]);
700                let sv = &mut self.panels[survivor];
701                sv.members.extend(v_members);
702                sv.last_kept += v_last;
703                if v_presented {
704                    sv.presented = true;
705                    let a = sv.sticky;
706                    sv.sticky = (
707                        a.0.min(v_sticky.0),
708                        a.1.max(v_sticky.1),
709                        a.2.min(v_sticky.2),
710                        a.3.max(v_sticky.3),
711                    );
712                }
713                extend[survivor].extend(moved);
714                dead[victim] = true;
715                redirect[victim] = survivor;
716            }
717            extend[survivor].extend_from_slice(&comp.members);
718        }
719
720        // A fresh onset supersedes stale pending hypotheses it covers:
721        // the newest event owns the region.
722        let mut in_fresh = vec![false; n];
723        for comp in &fresh {
724            for &m in &comp.members {
725                in_fresh[m] = true;
726            }
727        }
728        for (pi, p) in self.panels.iter().enumerate() {
729            if dead[pi] || p.presented || !extend[pi].is_empty() {
730                continue;
731            }
732            let covered = p.members.iter().filter(|&&m| in_fresh[m]).count();
733            if covered * 2 >= p.members.len() {
734                dead[pi] = true;
735            }
736        }
737
738        // Update panels.
739        let deadline = k_panel.saturating_mul(PANEL_PENDING_DEADLINE_K);
740        for (pi, p) in self.panels.iter_mut().enumerate() {
741            if dead[pi] {
742                continue;
743            }
744            if !p.presented && p.age >= deadline {
745                dead[pi] = true; // never presented in time: churn/scatter
746                continue;
747            }
748            // Footprint extension: presented churn always; pending while
749            // the onset window is open or after settling with content
750            // (hand-off). Members are NEVER pruned — a late-settling
751            // straggler must always be able to join the kept subset
752            // (present-time pruning permanently truncated tooltips whose
753            // edge cohort settled after presentation).
754            if !extend[pi].is_empty() {
755                p.members.extend_from_slice(&extend[pi]);
756                p.members.sort_unstable();
757                p.members.dedup();
758            }
759            let total = p.members.len().max(1) as f32;
760            let mv = p.members.iter().filter(|&&m| moving[m]).count() as f32;
761            if mv / total <= PANEL_MOVING_FRAC {
762                p.quiet = p.quiet.saturating_add(1);
763            } else {
764                p.quiet = 0;
765                p.no_growth = 0;
766            }
767            p.age = p.age.saturating_add(1);
768
769            // Settle-independent withdrawal: a presented panel whose
770            // content support collapses must withdraw even if the pixels
771            // underneath never stop moving (dismissal into churning
772            // gameplay would otherwise leave a permanent live rect).
773            let support = p.members.iter().filter(|&&m| keep_raw[m]).count();
774            if p.presented {
775                if support < min_component {
776                    p.low_support = p.low_support.saturating_add(1);
777                    if p.low_support >= k_panel {
778                        dead[pi] = true; // withdraw whole
779                        continue;
780                    }
781                } else {
782                    p.low_support = 0;
783                }
784                // Sticky union while in flux.
785                if let Some(b) = bbox_of(p.members.iter().copied(), cols) {
786                    let sk = p.sticky;
787                    p.sticky = (sk.0.min(b.0), sk.1.max(b.1), sk.2.min(b.2), sk.3.max(b.3));
788                }
789            }
790
791            if p.quiet >= k_panel {
792                // The group is settled: evaluate its kept CONTENT each
793                // tick (never a one-shot — late-settling stragglers keep
794                // joining, and the onset halo must not dilute anything).
795                let kept: Vec<usize> = p.members.iter().copied().filter(|&m| keep_raw[m]).collect();
796                // Growth beyond the slack means assembly is in progress;
797                // DECREASES are flicker and a sub-slack trickle is
798                // stragglers. Presentation needs TWO consecutive
799                // non-growing evaluations (a one-tick plateau mid-assembly
800                // must not present a partial rect).
801                let slack = ((p.members.len() as f32 * PANEL_GROWTH_SLACK) as usize).max(2);
802                let growing = kept.len() > p.last_kept.saturating_add(slack);
803                p.last_kept = kept.len();
804                p.no_growth = if growing {
805                    0
806                } else {
807                    p.no_growth.saturating_add(1)
808                };
809                let dense_bbox = bbox_of(kept.iter().copied(), cols).filter(|b| {
810                    let area = ((b.1 - b.0 + 1) * (b.3 - b.2 + 1)) as f32;
811                    kept.len() as f32 / area >= PANEL_DENSITY_FLOOR
812                });
813                if p.presented {
814                    if let Some(b) = dense_bbox {
815                        if p.no_growth >= 2 {
816                            p.sticky = b; // stable content: atomic snap
817                        }
818                    }
819                } else if kept.len() >= min_component && p.no_growth >= 2 {
820                    // Novel content settled and stopped growing: present
821                    // the whole rect at once — if it is a dense panel and
822                    // not scene-settle scatter. Sparse panels wait (the
823                    // deadline reaps them).
824                    if let Some(b) = dense_bbox {
825                        p.presented = true;
826                        p.sticky = b;
827                    }
828                }
829            }
830        }
831        let mut di = dead.iter();
832        self.panels.retain(|_| !*di.next().unwrap());
833
834        // Fresh hypotheses from unrouted onsets.
835        for comp in fresh {
836            self.panels.push(Panel {
837                sticky: comp.bbox,
838                members: comp.members,
839                presented: false,
840                quiet: 0,
841                age: 0,
842                last_kept: 0,
843                no_growth: 0,
844                low_support: 0,
845            });
846        }
847
848        // Orphan-kept seeding: content that entered keep_raw WITHOUT ever
849        // tripping the movement vote (slow-drift fades, or content already
850        // settled when the grid reset) has no onset — seed panels from
851        // settled kept components no panel covers, or it can never
852        // present.
853        let mut member_of = vec![false; n];
854        for p in &self.panels {
855            for &m in &p.members {
856                member_of[m] = true;
857            }
858        }
859        let orphan: Vec<bool> = (0..n)
860            .map(|i| keep_raw[i] && !member_of[i] && !moving[i])
861            .collect();
862        for comp in components(&orphan, cols, rows, min_component) {
863            self.panels.push(Panel {
864                sticky: comp.bbox,
865                members: comp.members,
866                presented: false,
867                quiet: 0,
868                age: 0,
869                last_kept: 0,
870                no_growth: 0,
871                low_support: 0,
872            });
873        }
874
875        // Field-debugging facility (spec-documented): VC_PANEL_TRACE=1
876        // dumps per-tick lifecycles for panels of consequence.
877        if std::env::var_os("VC_PANEL_TRACE").is_some() {
878            for (pi, p) in self.panels.iter().enumerate() {
879                if p.members.len() >= 40 {
880                    let mv = p.members.iter().filter(|&&m| moving[m]).count();
881                    let kept = p.members.iter().filter(|&&m| keep_raw[m]).count();
882                    eprintln!(
883                        "PANEL {pi}: presented={} age={} quiet={} members={} moving={} kept={} last_kept={} sticky={:?}",
884                        p.presented, p.age, p.quiet, p.members.len(), mv, kept, p.last_kept, p.sticky
885                    );
886                }
887            }
888            eprintln!("PANEL --- tick done ({} panels)", self.panels.len());
889        }
890
891        let mut out = vec![false; n];
892        for p in self.panels.iter().filter(|p| p.presented) {
893            let (r0, r1, c0, c1) = p.sticky;
894            for r in r0..=r1 {
895                for c in c0..=c1 {
896                    out[r * cols + c] = true;
897                }
898            }
899        }
900        out
901    }
902}
903
904/// Temporal stability mask: passes through only newly-appeared, now-static
905/// overlays (item tooltips, dialogs); moving gameplay and the permanent
906/// scene render black. Kept blocks are byte-identical to the input. Tick it
907/// fast (5–10 Hz): the frame-diff gate downstream keeps detector cost
908/// unchanged while nothing changes.
909pub struct StabilityMask {
910    block: u32,
911    params: MaskParams,
912    dilate: u32,
913    scene_threshold: f32,
914    fill_components: bool,
915    min_component: usize,
916    atomic_panels: bool,
917    tracker: PanelTracker,
918    holdover_explicit: bool,
919    /// Grid state; reset when the view dimensions change.
920    state: Vec<Block>,
921    dims: (u32, u32),
922    tick: u64,
923    sinks: Vec<Box<dyn DebugSink>>,
924}
925
926impl Default for StabilityMask {
927    fn default() -> Self {
928        Self::new()
929    }
930}
931
932impl StabilityMask {
933    pub fn new() -> Self {
934        Self {
935            block: 16,
936            params: MaskParams {
937                stable_ticks: 6,
938                baseline_ticks: 100,
939                signature_tolerance: 10,
940                novelty_threshold: 18,
941                contrast_threshold: 14,
942                holdover_ticks: 12,
943                long_run: 18,
944            },
945            dilate: 1,
946            scene_threshold: 0.65,
947            fill_components: true,
948            min_component: 4,
949            atomic_panels: true,
950            tracker: PanelTracker::default(),
951            holdover_explicit: false,
952            state: Vec::new(),
953            dims: (0, 0),
954            tick: 0,
955            sinks: Vec::new(),
956        }
957    }
958
959    /// Analysis block size in pixels (default 16).
960    pub fn block_size(mut self, px: u32) -> Self {
961        assert!(px > 0, "block size must be positive");
962        self.block = px;
963        self
964    }
965
966    /// K: consecutive stable ticks before a block counts as settled
967    /// (default 6). Holdover (2K) and the long-run threshold (3K) derive
968    /// from this unless set explicitly.
969    pub fn stable_ticks(mut self, k: u32) -> Self {
970        let k = k.clamp(1, u16::MAX as u32) as u16;
971        self.params.stable_ticks = k;
972        self.params.long_run = k.saturating_mul(3);
973        if !self.holdover_explicit {
974            self.params.holdover_ticks = k.saturating_mul(2);
975        }
976        self
977    }
978
979    /// Duration form of [`Self::stable_ticks`]; converted via the watcher's
980    /// rate (tick-based internals keep paused-time tests deterministic).
981    pub fn stable_for(self, rate: Rate, duration: Duration) -> Self {
982        let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
983        self.stable_ticks(ticks.max(1))
984    }
985
986    /// T: baseline EMA horizon in ticks (default 100). The EMA only runs
987    /// while a block is idle and unfrozen; scene changes re-seed instantly.
988    pub fn baseline_ticks(mut self, t: u32) -> Self {
989        self.params.baseline_ticks = t.max(1);
990        self
991    }
992
993    /// Duration form of [`Self::baseline_ticks`].
994    pub fn baseline(self, rate: Rate, duration: Duration) -> Self {
995        let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
996        self.baseline_ticks(ticks.max(1))
997    }
998
999    /// Keep-mask dilation rings applied after the component pass
1000    /// (default 1) — keeps glyphs off the fill boundary.
1001    pub fn dilate(mut self, rings: u32) -> Self {
1002        self.dilate = rings;
1003        self
1004    }
1005
1006    /// Per-component movement tolerance on the signature vote (default 10).
1007    pub fn signature_tolerance(mut self, tol: u8) -> Self {
1008        self.params.signature_tolerance = tol;
1009        self
1010    }
1011
1012    /// Per-sub-mean novelty threshold on the baseline vote (default 18).
1013    pub fn baseline_threshold(mut self, thr: u8) -> Self {
1014        self.params.novelty_threshold = thr;
1015        self
1016    }
1017
1018    /// Contrast-byte novelty threshold (default 14) — the dark-on-dark
1019    /// discriminator: a flat panel over textured scene is novel even at
1020    /// identical mean luma.
1021    pub fn contrast_threshold(mut self, thr: u8) -> Self {
1022        self.params.contrast_threshold = thr;
1023        self
1024    }
1025
1026    /// Fraction of the frame that must perturb in one tick to count as a
1027    /// scene transition (default 0.65). Settling blocks then adopt the new
1028    /// scene instead of becoming "kept".
1029    pub fn scene_threshold(mut self, frac: f32) -> Self {
1030        assert!((0.0..=1.0).contains(&frac), "fraction in 0..=1");
1031        self.scene_threshold = frac;
1032        self
1033    }
1034
1035    /// Panel-level presentation (default on): kept blocks are grouped
1036    /// into panels with identity across ticks; a panel renders only once
1037    /// its assembly is quiet — then its WHOLE rect at once — stays whole
1038    /// through internal churn, and withdraws whole. Off = per-block v2
1039    /// rendering (`fill_components`/`close+dilate`).
1040    pub fn atomic_panels(mut self, on: bool) -> Self {
1041        self.atomic_panels = on;
1042        self
1043    }
1044
1045    /// Component pass (default on): drop kept components smaller than
1046    /// [`Self::min_component`], fill each survivor's bounding box. Off =
1047    /// v1-style morphological close.
1048    pub fn fill_components(mut self, on: bool) -> Self {
1049        self.fill_components = on;
1050        self
1051    }
1052
1053    /// Minimum kept-component size in blocks (default 4); smaller
1054    /// components are treated as speckle and dropped.
1055    pub fn min_component(mut self, blocks: usize) -> Self {
1056        self.min_component = blocks.max(1);
1057        self
1058    }
1059
1060    /// H: ticks a kept block keeps rendering after its content starts
1061    /// changing (default 2×stable_ticks) — the anti-patchwork hysteresis.
1062    pub fn holdover_ticks(mut self, h: u32) -> Self {
1063        self.params.holdover_ticks = h.clamp(1, u16::MAX as u32) as u16;
1064        self.holdover_explicit = true;
1065        self
1066    }
1067
1068    /// Attach a debug tap (e.g. [`PngDump`](crate::PngDump)) receiving
1069    /// per-tick Input/Baseline/Overlay/State/Output frames. Composable —
1070    /// multiple sinks allowed. Stage frames are only materialized when at
1071    /// least one sink is attached.
1072    pub fn debug_sink(mut self, sink: impl DebugSink) -> Self {
1073        self.sinks.push(Box::new(sink));
1074        self
1075    }
1076
1077    fn grid(&self, w: u32, h: u32) -> (usize, usize) {
1078        (
1079            (w as usize).div_ceil(self.block as usize),
1080            (h as usize).div_ceil(self.block as usize),
1081        )
1082    }
1083}
1084
1085/// Compute all block signatures in one pass over the view.
1086fn compute_signatures(
1087    view: &FrameView<'_>,
1088    block: u32,
1089    cols: usize,
1090    rows: usize,
1091) -> Vec<Signature> {
1092    let n = cols * rows;
1093    let b = block as usize;
1094    let mut sub_sum = vec![[0u64; 16]; n];
1095    let mut sub_cnt = vec![[0u32; 16]; n];
1096    let mut sum = vec![0u64; n];
1097    let mut sum_sq = vec![0u64; n];
1098    let mut cnt = vec![0u64; n];
1099
1100    for (y, row) in view.rows().enumerate() {
1101        let br = y / b;
1102        let sr = ((y % b) * 4) / b; // sub-row 0..4
1103        for (x, px) in row.chunks_exact(4).enumerate() {
1104            let bc = x / b;
1105            let sc = ((x % b) * 4) / b;
1106            // Integer BT.601 luma, same weights as the detectors.
1107            let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
1108            let bi = br * cols + bc;
1109            let si = sr * 4 + sc;
1110            sub_sum[bi][si] += luma as u64;
1111            sub_cnt[bi][si] += 1;
1112            sum[bi] += luma as u64;
1113            sum_sq[bi] += (luma as u64) * (luma as u64);
1114            cnt[bi] += 1;
1115        }
1116    }
1117
1118    (0..n)
1119        .map(|i| {
1120            let mut sig = [0u8; SIG_LEN];
1121            let mean = sum[i].checked_div(cnt[i]).unwrap_or(0) as u8;
1122            for s in 0..16 {
1123                // Empty sub-cells inherit the block mean (no phantom edges).
1124                sig[s] = sub_sum[i][s]
1125                    .checked_div(sub_cnt[i][s] as u64)
1126                    .map_or(mean, |v| v as u8);
1127            }
1128            sig[16] = if cnt[i] == 0 {
1129                0
1130            } else {
1131                let m = sum[i] as f64 / cnt[i] as f64;
1132                let var = (sum_sq[i] as f64 / cnt[i] as f64) - m * m;
1133                var.max(0.0).sqrt().min(255.0) as u8
1134            };
1135            Signature(sig)
1136        })
1137        .collect()
1138}
1139
1140impl Preprocessor for StabilityMask {
1141    fn process(&mut self, view: &FrameView<'_>) -> Result<Arc<Frame>, DetectorError> {
1142        let (w, h) = (view.width(), view.height());
1143        let (cols, rows) = self.grid(w, h);
1144        if self.dims != (w, h) {
1145            self.state = vec![Block::default(); cols * rows];
1146            self.tracker.reset();
1147            self.dims = (w, h);
1148        }
1149        let n = cols * rows;
1150        let p = &self.params;
1151
1152        // ---- Phase 1: signatures + frame-global signals ----
1153        let sigs = compute_signatures(view, self.block, cols, rows);
1154        let mut moving = vec![false; n];
1155        let mut initialized = 0usize;
1156        let mut idle = 0usize;
1157        let mut perturbed = 0usize;
1158        let mut census_eligible = vec![false; n];
1159        let mut census_count = 0usize;
1160        let mut would_enter = 0usize;
1161        for i in 0..n {
1162            let b = &self.state[i];
1163            if !b.initialized {
1164                continue;
1165            }
1166            initialized += 1;
1167            if b.phase == Phase::Idle {
1168                idle += 1;
1169            }
1170            let mv = is_moving(&sigs[i], &b.last, p.signature_tolerance);
1171            moving[i] = mv;
1172            if mv {
1173                perturbed += 1;
1174            } else {
1175                if b.unstable_run >= p.stable_ticks {
1176                    census_eligible[i] = true;
1177                    census_count += 1;
1178                }
1179                // Slow-drift candidates only: blocks ALREADY stable before
1180                // this tick (a settling pop-in has stable_ticks < K here).
1181                if b.phase == Phase::Idle
1182                    && b.stable_ticks >= p.stable_ticks
1183                    && is_novel_vs(
1184                        &sigs[i],
1185                        &narrow(&b.base),
1186                        p.novelty_threshold,
1187                        p.contrast_threshold,
1188                    )
1189                {
1190                    would_enter += 1;
1191                }
1192            }
1193        }
1194        let denom = initialized.max(1) as f32;
1195        let frac = perturbed as f32 / denom;
1196        let globals = Globals {
1197            scene_tick: frac >= self.scene_threshold,
1198            corroborated_tick: frac >= CORROBORATION_FRAC,
1199            census_hit: (census_count as f32 / denom) >= CENSUS_FRAC,
1200            drift_guard: (would_enter as f32 / idle.max(1) as f32) >= DRIFT_FRAC,
1201        };
1202
1203        // ---- Phase 2: per-block state machine ----
1204        let mut keep_raw = vec![false; n];
1205        for i in 0..n {
1206            keep_raw[i] = step_block(
1207                &mut self.state[i],
1208                &sigs[i],
1209                moving[i],
1210                census_eligible[i],
1211                &globals,
1212                p,
1213            );
1214        }
1215
1216        // ---- Render stage ----
1217        let keep = if self.atomic_panels {
1218            let presented = self.tracker.tick(
1219                &moving,
1220                &keep_raw,
1221                cols,
1222                rows,
1223                p.stable_ticks,
1224                self.min_component,
1225            );
1226            if self.dilate == 0 {
1227                presented
1228            } else {
1229                dilate_grid(&presented, cols, rows, self.dilate as usize)
1230            }
1231        } else if self.fill_components {
1232            let filled = fill_components_bbox(&keep_raw, cols, rows, self.min_component);
1233            if self.dilate == 0 {
1234                filled
1235            } else {
1236                dilate_grid(&filled, cols, rows, self.dilate as usize)
1237            }
1238        } else {
1239            close_and_dilate(&keep_raw, cols, rows, self.dilate)
1240        };
1241
1242        // Kept blocks byte-identical, suppressed blocks black.
1243        let mut data = vec![0u8; w as usize * h as usize * 4];
1244        for px in data.chunks_exact_mut(4) {
1245            px[3] = 255;
1246        }
1247        for (y, row) in view.rows().enumerate() {
1248            let br = y / self.block as usize;
1249            for bc in 0..cols {
1250                if !keep[br * cols + bc] {
1251                    continue;
1252                }
1253                let x0 = bc * self.block as usize * 4;
1254                let x1 = (((bc + 1) * self.block as usize) * 4).min(row.len());
1255                let dst = y * w as usize * 4;
1256                data[dst + x0..dst + x1].copy_from_slice(&row[x0..x1]);
1257            }
1258        }
1259        let output = Frame::new(w, h, data)
1260            .map(Arc::new)
1261            .map_err(|e| DetectorError::Other(format!("mask render: {e}")))?;
1262
1263        if !self.sinks.is_empty() {
1264            self.emit_debug(view, &keep, cols, &output);
1265        }
1266        self.tick += 1;
1267        Ok(output)
1268    }
1269
1270    fn set_label(&mut self, label: &str) {
1271        for sink in &mut self.sinks {
1272            sink.set_label(label);
1273        }
1274    }
1275}
1276
1277impl StabilityMask {
1278    /// Materialize the debug stage frames and fan them out. Only called
1279    /// when sinks exist; sinks themselves must never block (drop-on-lag).
1280    fn emit_debug(&mut self, view: &FrameView<'_>, keep: &[bool], cols: usize, output: &Frame) {
1281        let (w, h) = (view.width(), view.height());
1282        let input = Frame::new(w, h, view.to_vec()).expect("view-sized buffer");
1283
1284        // Baseline: each block's scene-reference mean as gray.
1285        // Overlay: input with suppressed blocks dimmed to 25%.
1286        // State: false-color block phases — IDLE black, frozen-IDLE dim
1287        // blue, KEPT green, HOLDOVER yellow, re-seed red.
1288        let mut baseline = vec![0u8; w as usize * h as usize * 4];
1289        let mut state = vec![0u8; w as usize * h as usize * 4];
1290        let mut overlay = input.data().to_vec();
1291        for y in 0..h as usize {
1292            let br = y / self.block as usize;
1293            for x in 0..w as usize {
1294                let bc = x / self.block as usize;
1295                let i = (y * w as usize + x) * 4;
1296                let blk = &self.state[br * cols + bc];
1297                let mean: u32 = blk.base[..16].iter().map(|v| (*v >> 8) as u32).sum::<u32>() / 16;
1298                baseline[i] = mean as u8;
1299                baseline[i + 1] = mean as u8;
1300                baseline[i + 2] = mean as u8;
1301                baseline[i + 3] = 255;
1302                let bgra: [u8; 4] = if blk.reseeded {
1303                    [0, 0, 220, 255] // red: re-seeded this tick
1304                } else {
1305                    match blk.phase {
1306                        Phase::Kept => [0, 200, 0, 255],                     // green
1307                        Phase::Holdover => [0, 200, 200, 255],               // yellow
1308                        Phase::Idle if blk.base_frozen => [120, 40, 0, 255], // dim blue
1309                        Phase::Idle => [0, 0, 0, 255],
1310                    }
1311                };
1312                state[i..i + 4].copy_from_slice(&bgra);
1313                if !keep[br * cols + bc] {
1314                    overlay[i] /= 4;
1315                    overlay[i + 1] /= 4;
1316                    overlay[i + 2] /= 4;
1317                }
1318            }
1319        }
1320        // Panel borders on the State view: white = presented, gray =
1321        // forming (drawn at block granularity).
1322        if self.atomic_panels {
1323            let bpx = self.block as usize;
1324            for (presented, (r0, r1, c0, c1)) in self.tracker.panels() {
1325                let shade = if presented { 255u8 } else { 110 };
1326                let mut paint = |br: usize, bc: usize| {
1327                    let y0 = br * bpx;
1328                    let x0 = bc * bpx;
1329                    for y in y0..(y0 + bpx).min(h as usize) {
1330                        for x in x0..(x0 + bpx).min(w as usize) {
1331                            let i = (y * w as usize + x) * 4;
1332                            state[i] = shade;
1333                            state[i + 1] = shade;
1334                            state[i + 2] = shade;
1335                        }
1336                    }
1337                };
1338                for bc in c0..=c1 {
1339                    paint(r0, bc);
1340                    paint(r1, bc);
1341                }
1342                for br in r0..=r1 {
1343                    paint(br, c0);
1344                    paint(br, c1);
1345                }
1346            }
1347        }
1348        let baseline = Frame::new(w, h, baseline).expect("view-sized buffer");
1349        let state = Frame::new(w, h, state).expect("view-sized buffer");
1350        let overlay = Frame::new(w, h, overlay).expect("view-sized buffer");
1351
1352        for sink in &mut self.sinks {
1353            sink.write(self.tick, DebugStage::Input, &input);
1354            sink.write(self.tick, DebugStage::Baseline, &baseline);
1355            sink.write(self.tick, DebugStage::Overlay, &overlay);
1356            sink.write(self.tick, DebugStage::State, &state);
1357            sink.write(self.tick, DebugStage::Output, output);
1358        }
1359    }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365    use std::sync::Mutex;
1366
1367    fn params() -> MaskParams {
1368        MaskParams {
1369            stable_ticks: 3,
1370            baseline_ticks: 20,
1371            signature_tolerance: 10,
1372            novelty_threshold: 18,
1373            contrast_threshold: 14,
1374            holdover_ticks: 6,
1375            long_run: 9,
1376        }
1377    }
1378
1379    /// Uniform-content signature: all sub-means equal, zero contrast.
1380    fn flat(luma: u8) -> Signature {
1381        let mut s = [luma; SIG_LEN];
1382        s[16] = 0;
1383        s.into()
1384    }
1385
1386    /// Textured signature: alternating sub-means around `luma`, contrast c.
1387    fn textured(luma: u8, spread: u8, c: u8) -> Signature {
1388        let mut s = [0u8; SIG_LEN];
1389        for (i, v) in s[..16].iter_mut().enumerate() {
1390            *v = if i % 2 == 0 {
1391                luma.saturating_add(spread)
1392            } else {
1393                luma.saturating_sub(spread)
1394            };
1395        }
1396        s[16] = c;
1397        Signature(s)
1398    }
1399
1400    impl From<[u8; SIG_LEN]> for Signature {
1401        fn from(v: [u8; SIG_LEN]) -> Self {
1402            Signature(v)
1403        }
1404    }
1405
1406    const QUIET: Globals = Globals {
1407        scene_tick: false,
1408        corroborated_tick: false,
1409        census_hit: false,
1410        drift_guard: false,
1411    };
1412
1413    /// Drive one block with (signature, globals) pairs; phase-1 movement is
1414    /// recomputed exactly like process() does.
1415    fn run(b: &mut Block, script: &[(Signature, Globals)], p: &MaskParams) -> Vec<bool> {
1416        script
1417            .iter()
1418            .map(|(sig, g)| {
1419                let mv = b.initialized && is_moving(sig, &b.last, p.signature_tolerance);
1420                let census = b.initialized && !mv && b.unstable_run >= p.stable_ticks;
1421                step_block(b, sig, mv, census, g, p)
1422            })
1423            .collect()
1424    }
1425
1426    fn quiet(sigs: &[Signature]) -> Vec<(Signature, Globals)> {
1427        sigs.iter().map(|s| (*s, QUIET)).collect()
1428    }
1429
1430    fn rep(sig: Signature, n: usize) -> Vec<Signature> {
1431        vec![sig; n]
1432    }
1433
1434    // ---------- signature and vote tests ----------
1435
1436    #[test]
1437    fn single_component_noise_cannot_flip_movement_or_novelty() {
1438        let a = flat(100);
1439        let mut b = a;
1440        b.0[3] = 140; // one sub-cell jumps
1441        assert!(!is_moving(&b, &a, 10), "one component is not movement");
1442        assert!(
1443            !is_novel_vs(&b, &a.0, 18, 14),
1444            "one component is not novelty"
1445        );
1446        b.0[7] = 140; // two sub-cells
1447        assert!(is_moving(&b, &a, 10));
1448        assert!(is_novel_vs(&b, &a.0, 18, 14));
1449    }
1450
1451    #[test]
1452    fn dark_on_dark_flat_panel_is_novel_via_contrast() {
1453        // Textured dark scene vs flat dark panel at the SAME mean luma:
1454        // v1's mean-only comparator called this "not novel" (the measured
1455        // hole-punching); the contrast clause must catch it.
1456        let scene = textured(30, 12, 40);
1457        let panel = flat(30);
1458        assert!(
1459            is_novel_vs(&panel, &scene.0, 18, 14),
1460            "flat panel over textured scene must be novel"
1461        );
1462    }
1463
1464    #[test]
1465    fn signatures_capture_subblock_structure() {
1466        let frame = Frame::from_fn(16, 16, |x, _| {
1467            if x < 8 {
1468                [0, 0, 0, 255]
1469            } else {
1470                [255, 255, 255, 255]
1471            }
1472        });
1473        let view = frame
1474            .view(visual_cortex_capture::PxRect {
1475                x: 0,
1476                y: 0,
1477                w: 16,
1478                h: 16,
1479            })
1480            .unwrap();
1481        let sigs = compute_signatures(&view, 16, 1, 1);
1482        let s = &sigs[0].0;
1483        assert!(s[0] < 10 && s[1] < 10, "left sub-cells dark");
1484        assert!(s[2] > 245 && s[3] > 245, "right sub-cells bright");
1485        assert!(s[16] > 100, "half-and-half block has high contrast");
1486    }
1487
1488    // ---------- state machine: v1 regressions ----------
1489
1490    #[test]
1491    fn constant_hud_block_is_never_kept() {
1492        let mut b = Block::default();
1493        let out = run(&mut b, &quiet(&rep(textured(100, 20, 30), 30)), &params());
1494        assert!(out.iter().all(|k| !k), "HUD must stay suppressed");
1495    }
1496
1497    #[test]
1498    fn noisy_gameplay_block_is_never_kept() {
1499        let mut b = Block::default();
1500        let sigs: Vec<Signature> = (0..30)
1501            .map(|i| flat(if i % 2 == 0 { 40 } else { 200 }))
1502            .collect();
1503        let out = run(&mut b, &quiet(&sigs), &params());
1504        assert!(out.iter().all(|k| !k), "churn must stay suppressed");
1505    }
1506
1507    #[test]
1508    fn tooltip_becomes_kept_after_k_stable_ticks() {
1509        let mut b = Block::default();
1510        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1511        let out = run(&mut b, &quiet(&rep(flat(200), 6)), &params());
1512        // Jump tick resets; settled at K=3 (index 3), then stays.
1513        assert_eq!(out, vec![false, false, false, true, true, true]);
1514        assert_eq!(b.phase, Phase::Kept);
1515    }
1516
1517    #[test]
1518    fn held_tooltip_survives_indefinitely() {
1519        let mut b = Block::default();
1520        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1521        let out = run(&mut b, &quiet(&rep(flat(200), 200)), &params());
1522        assert!(out[10..].iter().all(|k| *k), "no ghosting, ever");
1523        assert!(b.base_frozen, "baseline frozen under the kept overlay");
1524    }
1525
1526    #[test]
1527    fn dismissed_tooltip_thaws_and_suppresses() {
1528        let mut b = Block::default();
1529        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1530        run(&mut b, &quiet(&rep(flat(200), 10)), &params());
1531        let out = run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1532        assert_eq!(b.phase, Phase::Idle);
1533        assert!(!b.base_frozen, "baseline resumed after departure");
1534        assert!(!out.last().unwrap(), "restored scene suppressed");
1535    }
1536
1537    // ---------- red-team scenarios ----------
1538
1539    #[test]
1540    fn browsing_hand_off_never_goes_dark() {
1541        // Tooltip A held, then re-renders into tooltip B (different content),
1542        // then B is held: the block must RENDER CONTINUOUSLY (HOLDOVER) —
1543        // the v1 patchwork went black here for K ticks.
1544        let mut b = Block::default();
1545        run(&mut b, &quiet(&rep(flat(60), 10)), &params());
1546        run(&mut b, &quiet(&rep(flat(200), 10)), &params()); // A kept
1547        let mut script = rep(flat(150), 1); // one transition tick
1548        script.extend(rep(flat(230), 8)); // B settles and holds
1549        let out = run(&mut b, &quiet(&script), &params());
1550        assert!(
1551            out.iter().all(|k| *k),
1552            "hand-off must render every tick, got {out:?}"
1553        );
1554        assert_eq!(b.phase, Phase::Kept, "re-latched onto tooltip B");
1555    }
1556
1557    #[test]
1558    fn flash_over_held_tooltip_relatches_without_reseed() {
1559        // Red-team scenario 10 (the pinned invariant probe): a 2-tick
1560        // frame-global flash washes out a held tooltip. The block must ride
1561        // HOLDOVER through the flash and re-latch via the ovl snapshot the
1562        // moment the tooltip pixels return — no K wait, no re-seed.
1563        let p = params();
1564        let mut b = Block::default();
1565        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1566        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // tooltip kept
1567        let flash = Globals {
1568            scene_tick: true,
1569            corroborated_tick: true,
1570            census_hit: false,
1571            drift_guard: false,
1572        };
1573        let script = vec![
1574            (flat(255), flash),
1575            (flat(255), flash),
1576            (flat(200), QUIET), // tooltip pixels return
1577            (flat(200), QUIET),
1578        ];
1579        let out = run(&mut b, &script, &p);
1580        assert!(out.iter().all(|k| *k), "renders through the flash: {out:?}");
1581        assert_eq!(b.phase, Phase::Kept, "immediate ovl re-latch");
1582        assert!(!b.reseeded, "a flash is not a scene change");
1583        assert!(b.base_frozen, "invariant intact: base still frozen");
1584    }
1585
1586    #[test]
1587    fn reveal_after_occluded_scene_change_resolves_on_next_motion() {
1588        // The scene changes while a tooltip occludes a block, then the
1589        // tooltip is dismissed. Per-block, "revealed new scene" and "new
1590        // overlay" are indistinguishable (the base went stale unseen), so
1591        // the reveal is RENDERED (reveal-over-suppress: extra revealed
1592        // scene is benign; a suppressed tooltip is a hard failure). The
1593        // next corroborated motion episode re-anchors it.
1594        let p = params();
1595        let mut b = Block::default();
1596        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1597        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // tooltip kept
1598        let scene = Globals {
1599            scene_tick: true,
1600            corroborated_tick: true,
1601            census_hit: false,
1602            drift_guard: false,
1603        };
1604        run(&mut b, &[(flat(150), scene)], &p); // occluded scene event
1605        run(&mut b, &quiet(&rep(flat(200), 6)), &p); // tooltip re-latches
1606        assert_eq!(b.phase, Phase::Kept, "tooltip survives the event");
1607        // Dismissal reveals the NEW scene (120): ambiguous -> rendered.
1608        let out = run(&mut b, &quiet(&rep(flat(120), 8)), &p);
1609        assert!(*out.last().unwrap(), "ambiguous reveal stays rendered");
1610        // Next corroborated motion episode (player walks) re-anchors.
1611        let walking = Globals {
1612            scene_tick: false,
1613            corroborated_tick: true,
1614            census_hit: false,
1615            drift_guard: false,
1616        };
1617        let script: Vec<(Signature, Globals)> = (0..12)
1618            .map(|i| (flat(if i % 2 == 0 { 80 } else { 170 }), walking))
1619            .collect();
1620        run(&mut b, &script, &p);
1621        let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1622        assert!(!out.last().unwrap(), "re-anchored after motion");
1623        let base = narrow(&b.base);
1624        assert!(
1625            base[0].abs_diff(140) < 10,
1626            "baseline follows the real scene"
1627        );
1628    }
1629
1630    #[test]
1631    fn browsing_hand_off_survives_a_prior_flash() {
1632        // Regression for the review finding: scene_flag set during a flash
1633        // must NOT re-seed the next tooltip hand-off (which would corrupt
1634        // the baseline with overlay content and suppress tooltip B).
1635        let p = params();
1636        let mut b = Block::default();
1637        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1638        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // A kept
1639        let flash = Globals {
1640            scene_tick: true,
1641            corroborated_tick: true,
1642            census_hit: false,
1643            drift_guard: false,
1644        };
1645        run(&mut b, &[(flat(255), flash), (flat(255), flash)], &p);
1646        run(&mut b, &quiet(&rep(flat(200), 4)), &p); // re-latch on A
1647        assert_eq!(b.phase, Phase::Kept);
1648        // Hand off to tooltip B: must be KEPT, not re-seeded.
1649        let mut script = rep(flat(150), 1);
1650        script.extend(rep(flat(230), 8));
1651        let out = run(&mut b, &quiet(&script), &p);
1652        assert!(
1653            out.iter().all(|k| *k),
1654            "hand-off renders throughout: {out:?}"
1655        );
1656        assert_eq!(b.phase, Phase::Kept, "tooltip B kept");
1657        let base = narrow(&b.base);
1658        assert!(
1659            base[0].abs_diff(60) < 10,
1660            "baseline still the scene, never overlay content: base={} frozen={} scene_flag={}",
1661            base[0],
1662            b.base_frozen,
1663            b.scene_flag
1664        );
1665    }
1666
1667    #[test]
1668    fn walk_and_stop_reseeds_instead_of_keeping() {
1669        // Red-team scenario 3 / measured ratchet: sustained corroborated
1670        // motion (walking) then stopping must re-seed, not keep the grass.
1671        let p = params();
1672        let mut b = Block::default();
1673        run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1674        let walking = Globals {
1675            scene_tick: false,
1676            corroborated_tick: true, // frame-wide motion corroborates
1677            census_hit: false,
1678            drift_guard: false,
1679        };
1680        let script: Vec<(Signature, Globals)> = (0..12)
1681            .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), walking))
1682            .collect();
1683        run(&mut b, &script, &p); // 12 corroborated unstable ticks >= U=9
1684        let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1685        assert!(
1686            out.iter().all(|k| !k),
1687            "new scene must not be kept: {out:?}"
1688        );
1689        let base = narrow(&b.base);
1690        assert!(base[0].abs_diff(140) < 10, "re-seeded to the new scene");
1691    }
1692
1693    #[test]
1694    fn fast_browsing_churn_is_not_corroborated_and_final_tooltip_is_kept() {
1695        // Red-team scenario 7 (BREAKS without the corroboration gate):
1696        // tooltips changing every 2 ticks for a long stretch build a long
1697        // unstable run, but the churn is locally confined (quiet globals) —
1698        // it must NOT re-seed, and the finally-held tooltip must be kept.
1699        let p = params();
1700        let mut b = Block::default();
1701        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1702        // 20 ticks of item-flipping (uncorroborated churn), run >= U.
1703        let script: Vec<Signature> = (0..20)
1704            .map(|i| flat(if i % 2 == 0 { 200 } else { 120 }))
1705            .collect();
1706        run(&mut b, &quiet(&script), &p);
1707        assert!(!b.run_corroborated, "local churn is never corroborated");
1708        // User finally holds one item.
1709        let out = run(&mut b, &quiet(&rep(flat(200), 6)), &p);
1710        assert!(*out.last().unwrap(), "final tooltip must be kept: {out:?}");
1711        assert_eq!(b.phase, Phase::Kept);
1712    }
1713
1714    #[test]
1715    fn minimap_pulse_train_is_never_kept() {
1716        // Red-team scenario 6: N stable / 2 perturbed / N stable, returning
1717        // to identical pixels each time.
1718        let p = params();
1719        let mut b = Block::default();
1720        let mut script = Vec::new();
1721        for _ in 0..6 {
1722            script.extend(rep(textured(80, 15, 25), 8)); // stable HUD
1723            script.extend(rep(textured(140, 15, 25), 2)); // pulse
1724        }
1725        let out = run(&mut b, &quiet(&script), &p);
1726        assert!(out.iter().all(|k| !k), "pulsing HUD must stay suppressed");
1727    }
1728
1729    #[test]
1730    fn census_flag_reseeds_short_global_motion() {
1731        // A hard cut below theta_scene per-tick but caught by the census:
1732        // blocks with a long run settling on a census tick re-seed.
1733        let p = params();
1734        let mut b = Block::default();
1735        run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1736        let script: Vec<(Signature, Globals)> = (0..5)
1737            .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), QUIET))
1738            .collect();
1739        run(&mut b, &script, &p); // unstable_run = 5 >= K
1740        let census = Globals {
1741            scene_tick: false,
1742            corroborated_tick: false,
1743            census_hit: true,
1744            drift_guard: false,
1745        };
1746        // The census stays hot while the long-perturbed mass settles
1747        // (process() recomputes it per tick from grid state).
1748        let mut settle = vec![(flat(140), census); 3];
1749        settle.extend(quiet(&rep(flat(140), 6)));
1750        let out = run(&mut b, &settle, &p);
1751        assert!(out.iter().all(|k| !k), "census settle re-seeds: {out:?}");
1752    }
1753
1754    #[test]
1755    fn drift_guard_reseeds_slow_drift_but_never_pop_ins() {
1756        let p = params();
1757        let guard = Globals {
1758            scene_tick: false,
1759            corroborated_tick: false,
1760            census_hit: false,
1761            drift_guard: true,
1762        };
1763        // Slow drift: luma creeps below the movement vote each tick (steps
1764        // of 8 < tol 10) until it is novel vs the lagging baseline — with
1765        // the guard up this re-seeds (ambient lighting), never keeps.
1766        let mut b = Block::default();
1767        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1768        let script: Vec<(Signature, Globals)> =
1769            (0..12).map(|i| (flat(60 + 8 * (i + 1)), guard)).collect();
1770        let out = run(&mut b, &script, &p);
1771        assert!(out.iter().all(|k| !k), "ambient fade must not be kept");
1772        // Pop-in: trips the movement vote, so the guard must NOT apply —
1773        // a tooltip can legitimately cover most of a cropped region.
1774        let mut b = Block::default();
1775        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1776        let script: Vec<(Signature, Globals)> =
1777            rep(flat(200), 6).into_iter().map(|s| (s, guard)).collect();
1778        let out = run(&mut b, &script, &p);
1779        assert!(*out.last().unwrap(), "pop-in is kept even under the guard");
1780    }
1781
1782    #[test]
1783    fn holdover_expires_to_idle_when_content_keeps_churning() {
1784        let p = params();
1785        let mut b = Block::default();
1786        run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1787        run(&mut b, &quiet(&rep(flat(200), 10)), &p); // kept
1788                                                      // Content churns without ever settling: render for H ticks, then dark.
1789        let script: Vec<Signature> = (0..12)
1790            .map(|i| flat(if i % 2 == 0 { 90 } else { 170 }))
1791            .collect();
1792        let out = run(&mut b, &quiet(&script), &p);
1793        assert!(out[0], "holdover renders at first");
1794        assert!(!out.last().unwrap(), "expired holdover suppresses");
1795        assert_eq!(b.phase, Phase::Idle);
1796    }
1797
1798    // ---------- grid geometry ----------
1799
1800    #[test]
1801    fn component_fill_drops_speckle_and_fills_bboxes() {
1802        // 6x6 grid: an L-shaped 5-block component + a 1-block speckle.
1803        let cols = 6;
1804        let mut keep = vec![false; 36];
1805        for (r, c) in [(1, 1), (2, 1), (3, 1), (3, 2), (3, 3)] {
1806            keep[r * cols + c] = true;
1807        }
1808        keep[5 * cols + 5] = true; // speckle
1809        let out = fill_components_bbox(&keep, cols, 6, 4);
1810        assert!(out[cols + 2] && out[cols + 3], "bbox filled the L's notch");
1811        assert!(!out[5 * cols + 5], "speckle dropped");
1812    }
1813
1814    #[test]
1815    fn close_fills_interior_holes_and_dilate_expands() {
1816        let cols = 5;
1817        let mut keep = vec![false; 25];
1818        for (r, c) in [
1819            (1usize, 1usize),
1820            (1, 2),
1821            (1, 3),
1822            (2, 1),
1823            (2, 3),
1824            (3, 1),
1825            (3, 2),
1826            (3, 3),
1827        ] {
1828            keep[r * cols + c] = true;
1829        }
1830        let out = close_and_dilate(&keep, cols, 5, 0);
1831        assert!(out[2 * cols + 2], "interior hole must be closed");
1832        let out = close_and_dilate(&keep, cols, 5, 1);
1833        assert!(out[cols], "edge cell reached by one dilation ring");
1834        assert!(
1835            !out[0],
1836            "corner is two steps away; one ring must not reach it"
1837        );
1838    }
1839
1840    #[test]
1841    fn cold_start_keeps_nothing() {
1842        let mut b = Block::default();
1843        assert!(!step_block(
1844            &mut b,
1845            &flat(200),
1846            false,
1847            false,
1848            &QUIET,
1849            &params()
1850        ));
1851    }
1852
1853    // ---------- panel tracker (co-onset) ----------
1854
1855    /// Build a grid from rects (r0, r1, c0, c1) inclusive.
1856    fn grid(cols: usize, rows: usize, rects: &[(usize, usize, usize, usize)]) -> Vec<bool> {
1857        let mut g = vec![false; cols * rows];
1858        for &(r0, r1, c0, c1) in rects {
1859            for r in r0..=r1 {
1860                for c in c0..=c1 {
1861                    g[r * cols + c] = true;
1862                }
1863            }
1864        }
1865        g
1866    }
1867
1868    const K_PANEL: u16 = 3;
1869
1870    fn ptick(
1871        t: &mut PanelTracker,
1872        cols: usize,
1873        rows: usize,
1874        moving: &[(usize, usize, usize, usize)],
1875        keep: &[(usize, usize, usize, usize)],
1876    ) -> Vec<bool> {
1877        t.tick(
1878            &grid(cols, rows, moving),
1879            &grid(cols, rows, keep),
1880            cols,
1881            rows,
1882            K_PANEL,
1883            1,
1884        )
1885    }
1886
1887    /// The owner's screenshot bug, root form: the panel's blocks CHANGED
1888    /// together, but during fast browsing only a fraction re-stabilize
1889    /// individually (fragmentary keep_raw). The GROUP decision must
1890    /// present the whole rect anyway.
1891    #[test]
1892    fn onset_group_presents_whole_despite_fragmentary_keeps() {
1893        let (c, r) = (10, 10);
1894        let mut t = PanelTracker::default();
1895        let rect = (1, 6, 1, 6);
1896        // Onset: the whole footprint changes in one tick.
1897        let out = ptick(&mut t, c, r, &[rect], &[]);
1898        assert!(out.iter().all(|k| !k), "onset tick: nothing rendered");
1899        // Group quiets; per-block keeps are CONFETTI (only two fragments).
1900        let frags = [(1, 2, 1, 6), (5, 6, 1, 3)];
1901        for _ in 0..(K_PANEL + 1) {
1902            let out = ptick(&mut t, c, r, &[], &frags);
1903            assert!(out.iter().all(|k| !k), "still settling: no render");
1904        }
1905        // Kept subset stable across evaluations: the WHOLE kept bbox
1906        // presents at once (rows 1..=6 x cols 1..=6) despite the confetti.
1907        let out = ptick(&mut t, c, r, &[], &frags);
1908        assert_eq!(out, grid(c, r, &[(1, 6, 1, 6)]), "whole rect at once");
1909    }
1910
1911    #[test]
1912    fn gameplay_churn_never_presents_and_expires() {
1913        let (c, r) = (10, 10);
1914        let mut t = PanelTracker::default();
1915        // A region that keeps moving forever (grass), scattered keeps.
1916        for _ in 0..(K_PANEL * PANEL_PENDING_DEADLINE_K + 2) {
1917            let out = ptick(&mut t, c, r, &[(2, 7, 2, 7)], &[(3, 3, 3, 3)]);
1918            assert!(out.iter().all(|k| !k), "churn must never present");
1919        }
1920        // A fresh pending panel from the latest churn tick may exist, but
1921        // nothing ever presents and expired ones are gone.
1922        assert!(
1923            t.panels().all(|(presented, _)| !presented),
1924            "churn never presents"
1925        );
1926    }
1927
1928    #[test]
1929    fn camera_pan_settles_to_nothing_via_the_group_kept_test() {
1930        // A pan spawns a pending panel, but the block machine re-seeds the
1931        // settled scene (keep_raw empty), so the group kept-test drops it:
1932        // the tracker inherits the scene wisdom through keep_raw.
1933        let (c, r) = (10, 10);
1934        let mut t = PanelTracker::default();
1935        for _ in 0..3 {
1936            ptick(&mut t, c, r, &[(0, 9, 0, 9)], &[]);
1937        }
1938        for _ in 0..(K_PANEL * PANEL_PENDING_DEADLINE_K + 2) {
1939            let out = ptick(&mut t, c, r, &[], &[]);
1940            assert!(out.iter().all(|k| !k), "no panel from a pan");
1941        }
1942        assert_eq!(t.panels().count(), 0, "churn panel reaped at deadline");
1943    }
1944
1945    fn present_panel(t: &mut PanelTracker, c: usize, r: usize, rect: (usize, usize, usize, usize)) {
1946        ptick(t, c, r, &[rect], &[]);
1947        // Two consecutive non-growing evaluations required: K quiet ticks
1948        // to start evaluating, then two more.
1949        for _ in 0..(K_PANEL + 2) {
1950            ptick(t, c, r, &[], &[rect]);
1951        }
1952    }
1953
1954    #[test]
1955    fn hand_off_keeps_the_rect_and_dismissal_withdraws_whole() {
1956        let (c, r) = (10, 10);
1957        let mut t = PanelTracker::default();
1958        let rect = (1, 6, 1, 6);
1959        present_panel(&mut t, c, r, rect);
1960        let full = grid(c, r, &[rect]);
1961        // Hand-off: the panel's content churns for 2 ticks (rendered
1962        // throughout), then settles with new (smaller) keeps.
1963        for _ in 0..2 {
1964            let out = ptick(&mut t, c, r, &[rect], &[]);
1965            assert_eq!(out, full, "rect stays whole through churn");
1966        }
1967        for _ in 0..K_PANEL {
1968            let out = ptick(&mut t, c, r, &[], &[(1, 4, 1, 6)]);
1969            assert_eq!(out, full, "still whole while settling");
1970        }
1971        // Snap fires on the second non-growing evaluation: content rows 1..=4.
1972        let out = ptick(&mut t, c, r, &[], &[(1, 4, 1, 6)]);
1973        assert_eq!(out, grid(c, r, &[(1, 4, 1, 6)]), "atomic snap");
1974        // Dismissal: churn then nothing kept -> support collapses ->
1975        // withdraw whole after K low-support ticks.
1976        ptick(&mut t, c, r, &[(1, 4, 1, 6)], &[]);
1977        for _ in 0..(K_PANEL - 2) {
1978            let out = ptick(&mut t, c, r, &[], &[]);
1979            assert!(!out.iter().all(|k| !k), "grace while support collapses");
1980        }
1981        let out = ptick(&mut t, c, r, &[], &[]);
1982        assert!(out.iter().all(|k| !k), "withdrawn whole");
1983        assert_eq!(t.panels().count(), 0);
1984    }
1985
1986    #[test]
1987    fn straggler_trickle_does_not_postpone_presentation() {
1988        // Fixture trace regression: after the group settles, a couple of
1989        // blocks keep joining the kept subset EVERY tick with no plateau.
1990        // Presentation must not wait for one (the next hover would
1991        // supersede the panel first). Growth of 2 blocks/tick on a
1992        // 100-block footprint sits exactly at the slack floor.
1993        let (c, r) = (60, 4);
1994        let mut t = PanelTracker::default();
1995        ptick(&mut t, c, r, &[(1, 2, 1, 50)], &[]); // 100-block onset
1996        let mut presented = false;
1997        for i in 0..(K_PANEL as usize + 4) {
1998            let cols_kept = (30 + i).min(50);
1999            let out = ptick(&mut t, c, r, &[], &[(1, 2, 1, cols_kept)]);
2000            presented = out.iter().any(|k| *k);
2001            if presented {
2002                break;
2003            }
2004        }
2005        assert!(presented, "trickle must not starve presentation");
2006    }
2007
2008    #[test]
2009    fn stale_pending_hypotheses_never_starve_a_new_onset() {
2010        // The D4 fixture mega-panel regression: a region churns for a
2011        // while (building a stale pending panel past its onset window),
2012        // then a REAL tooltip onset lands there. The new onset must get
2013        // its own panel and present — the stale hypothesis must not
2014        // accrete it and block presentation forever.
2015        let (c, r) = (10, 10);
2016        let mut t = PanelTracker::default();
2017        // Churn builds a stale pending panel (past the onset window).
2018        for _ in 0..6 {
2019            ptick(&mut t, c, r, &[(1, 8, 1, 8)], &[]);
2020        }
2021        // Tooltip onset over a subregion; unrelated churn CONTINUES far
2022        // from the tooltip (>=2 blocks away so the dilated owner map
2023        // cannot route it there) while the tooltip settles.
2024        ptick(&mut t, c, r, &[(2, 6, 2, 6), (9, 9, 1, 8)], &[]);
2025        for _ in 0..(K_PANEL + 2) {
2026            ptick(&mut t, c, r, &[(9, 9, 1, 8)], &[(2, 6, 2, 6)]);
2027        }
2028        let out = ptick(&mut t, c, r, &[(9, 9, 1, 8)], &[(2, 6, 2, 6)]);
2029        assert_eq!(
2030            out,
2031            grid(c, r, &[(2, 6, 2, 6)]),
2032            "the settled tooltip must present despite the stale hypothesis"
2033        );
2034    }
2035
2036    #[test]
2037    fn sparse_scene_settle_scatter_never_presents() {
2038        // Round-5 pin: a whole region settling with sparse kept scatter
2039        // (~14% density) must never bbox-fill into a huge presented rect.
2040        let (c, r) = (20, 20);
2041        let mut t = PanelTracker::default();
2042        ptick(&mut t, c, r, &[(0, 19, 0, 19)], &[]);
2043        let scatter: Vec<(usize, usize, usize, usize)> = (0..400)
2044            .filter(|i| i % 7 == 0)
2045            .map(|i| (i / 20, i / 20, i % 20, i % 20))
2046            .collect();
2047        for _ in 0..(K_PANEL + 6) {
2048            let out = ptick(&mut t, c, r, &[], &scatter);
2049            assert!(out.iter().all(|k| !k), "sparse scatter must not present");
2050        }
2051    }
2052
2053    #[test]
2054    fn sparse_settle_waits_for_assembly_instead_of_dying() {
2055        // Round-6 pin: assembly flicker can leave the kept subset SPARSE
2056        // (scattered singles, low density in their own bbox) for a while
2057        // after the group quiets. The panel must WAIT (not be killed) and
2058        // present once dense content lands.
2059        let (c, r) = (10, 10);
2060        let mut t = PanelTracker::default();
2061        ptick(&mut t, c, r, &[(1, 6, 1, 6)], &[]);
2062        let scatter = [(1, 1, 1, 1), (3, 3, 3, 3), (5, 5, 5, 5), (6, 6, 2, 2)];
2063        for _ in 0..(K_PANEL * 4) {
2064            let out = ptick(&mut t, c, r, &[], &scatter);
2065            assert!(out.iter().all(|k| !k), "sparse: waiting, not rendering");
2066        }
2067        // Dense content finally settles.
2068        for _ in 0..2 {
2069            ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2070        }
2071        let out = ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2072        assert_eq!(out, grid(c, r, &[(1, 6, 1, 6)]), "presents once dense");
2073    }
2074
2075    #[test]
2076    fn disjoint_band_onsets_merge_into_one_panel() {
2077        // Spec merge rule: a reveal sweeping in disjoint adjacent bands is
2078        // ONE panel; a bridging band must unify earlier fragments so the
2079        // rect presents once, whole.
2080        let (c, r) = (10, 10);
2081        let mut t = PanelTracker::default();
2082        ptick(&mut t, c, r, &[(1, 2, 1, 6)], &[]); // band 1
2083        ptick(&mut t, c, r, &[(5, 6, 1, 6)], &[]); // band 2 (disjoint)
2084        ptick(&mut t, c, r, &[(3, 4, 1, 6)], &[]); // bridge: touches both
2085        for _ in 0..(K_PANEL + 1) {
2086            let out = ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2087            assert!(out.iter().all(|k| !k), "no partial band presents");
2088        }
2089        let out = ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2090        assert_eq!(out, grid(c, r, &[(1, 6, 1, 6)]), "one whole rect");
2091        assert_eq!(t.panels().count(), 1, "bands merged into one panel");
2092    }
2093
2094    #[test]
2095    fn disjoint_panels_present_and_withdraw_independently() {
2096        let (c, r) = (12, 12);
2097        let mut t = PanelTracker::default();
2098        let (a, b) = ((1, 3, 1, 3), (7, 10, 7, 10));
2099        // Both onset together; both settle kept.
2100        ptick(&mut t, c, r, &[a, b], &[]);
2101        for _ in 0..(K_PANEL + 2) {
2102            ptick(&mut t, c, r, &[], &[a, b]);
2103        }
2104        let both = grid(c, r, &[a, b]);
2105        let out = ptick(&mut t, c, r, &[], &[a, b]);
2106        assert_eq!(out, both, "both panels presented");
2107        // Dismiss only the second: its support collapses while the first
2108        // stays kept and presented.
2109        ptick(&mut t, c, r, &[b], &[a]);
2110        for _ in 0..(K_PANEL + 1) {
2111            ptick(&mut t, c, r, &[], &[a]);
2112        }
2113        let out = ptick(&mut t, c, r, &[], &[a]);
2114        assert_eq!(out, grid(c, r, &[a]), "only the first remains");
2115    }
2116
2117    #[test]
2118    fn slow_drift_kept_content_presents_via_orphan_seeding() {
2119        // A fade below the movement vote never produces an onset; the
2120        // orphan-kept seeding path must still present the settled content.
2121        let (c, r) = (10, 10);
2122        let mut t = PanelTracker::default();
2123        for _ in 0..(K_PANEL + 4) {
2124            ptick(&mut t, c, r, &[], &[(2, 6, 2, 6)]); // kept, never moving
2125        }
2126        let out = ptick(&mut t, c, r, &[], &[(2, 6, 2, 6)]);
2127        assert_eq!(out, grid(c, r, &[(2, 6, 2, 6)]), "fade content presents");
2128    }
2129
2130    #[test]
2131    fn cursor_blip_inside_presented_panel_is_harmless() {
2132        let (c, r) = (10, 10);
2133        let mut t = PanelTracker::default();
2134        let rect = (1, 6, 1, 6);
2135        present_panel(&mut t, c, r, rect);
2136        let full = grid(c, r, &[rect]);
2137        // A 1-block blip inside: routed to the panel (fully inside), the
2138        // group stays quiet (1/36 < 20%), rect never flinches.
2139        for _ in 0..6 {
2140            let out = ptick(&mut t, c, r, &[(3, 3, 3, 3)], &[rect]);
2141            assert_eq!(out, full);
2142        }
2143        assert_eq!(t.panels().count(), 1);
2144    }
2145
2146    #[test]
2147    fn neighboring_motion_does_not_extend_a_panel() {
2148        let (c, r) = (12, 12);
2149        let mut t = PanelTracker::default();
2150        let rect = (1, 4, 1, 4);
2151        present_panel(&mut t, c, r, rect);
2152        let full = grid(c, r, &[rect]);
2153        // Gameplay motion brushing the panel edge: mostly outside, so it
2154        // must not extend the footprint or bloat the rect.
2155        for _ in 0..4 {
2156            let out = ptick(&mut t, c, r, &[(5, 10, 1, 10)], &[rect]);
2157            assert_eq!(out, full, "edge motion must not bloat the rect");
2158        }
2159    }
2160
2161    // ---------- process-level regressions ----------
2162
2163    /// Review regression: a pop-in overlay covering most of the watched
2164    /// region (55-62%: above DRIFT/CENSUS floors, below the scene band)
2165    /// must be revealed — the drift guard and census apply to fades and
2166    /// long-perturbed mass settles, never to fresh pop-ins.
2167    #[test]
2168    fn large_pop_in_covering_most_of_the_region_is_revealed() {
2169        use visual_cortex_capture::PxRect;
2170        let mut mask = StabilityMask::new()
2171            .block_size(8)
2172            .stable_ticks(3)
2173            .min_component(1)
2174            .dilate(0);
2175        let full = PxRect {
2176            x: 0,
2177            y: 0,
2178            w: 48,
2179            h: 48,
2180        }; // 6x6 blocks
2181           // Tooltip covers the top 22 of 36 blocks (~61%).
2182        let bg = Frame::solid(48, 48, [60, 60, 60, 255]);
2183        let tip = Frame::from_fn(48, 48, |x, y| {
2184            if y < 24 || (y < 32 && x < 32) {
2185                [230, 230, 230, 255]
2186            } else {
2187                [60, 60, 60, 255]
2188            }
2189        });
2190        for _ in 0..8 {
2191            mask.process(&bg.view(full).unwrap()).unwrap();
2192        }
2193        let mut last = None;
2194        for _ in 0..8 {
2195            last = Some(mask.process(&tip.view(full).unwrap()).unwrap());
2196        }
2197        let out = last.unwrap();
2198        let px = &out.data()[(4 * 48 + 4) * 4..(4 * 48 + 4) * 4 + 3];
2199        assert_eq!(px, &[230, 230, 230], "pop-in region revealed, got {px:?}");
2200    }
2201
2202    // ---------- atomic panel presentation (process level) ----------
2203
2204    /// The owner's screenshot bug: blocks joining across ticks must never
2205    /// produce a partially-assembled rect in the output.
2206    #[test]
2207    fn staggered_pop_in_reveals_atomically_through_process() {
2208        use visual_cortex_capture::PxRect;
2209        let mut mask = StabilityMask::new()
2210            .block_size(8)
2211            .stable_ticks(3)
2212            .min_component(1)
2213            .dilate(0);
2214        let full = PxRect {
2215            x: 0,
2216            y: 0,
2217            w: 64,
2218            h: 64,
2219        }; // 8x8 blocks
2220        let bg = Frame::solid(64, 64, [60, 60, 60, 255]);
2221        // Wave 1: rows 8..24 of the tooltip; wave 2 (one tick later): rows
2222        // 8..40 — the fade-in stagger.
2223        let wave = |rows_to: u32| {
2224            Frame::from_fn(64, 64, move |x, y| {
2225                if (8..rows_to).contains(&y) && (8..40).contains(&x) {
2226                    [230, 230, 230, 255]
2227                } else {
2228                    [60, 60, 60, 255]
2229                }
2230            })
2231        };
2232        for _ in 0..8 {
2233            mask.process(&bg.view(full).unwrap()).unwrap();
2234        }
2235        let mut outputs = Vec::new();
2236        outputs.push(mask.process(&wave(24).view(full).unwrap()).unwrap());
2237        for _ in 0..12 {
2238            outputs.push(mask.process(&wave(40).view(full).unwrap()).unwrap());
2239        }
2240        // Classify each output within the final tooltip area: fraction of
2241        // its pixels visible must be 0.0 until presentation, then jump to
2242        // 1.0 in a single tick — no partial frames.
2243        let mut fractions = Vec::new();
2244        for out in &outputs {
2245            let mut vis = 0usize;
2246            let mut tot = 0usize;
2247            for y in 8..40usize {
2248                for x in 8..40usize {
2249                    let i = (y * 64 + x) * 4;
2250                    tot += 1;
2251                    if out.data()[i] == 230 {
2252                        vis += 1;
2253                    }
2254                }
2255            }
2256            fractions.push(vis as f64 / tot as f64);
2257        }
2258        assert!(
2259            fractions.iter().all(|f| *f < 0.05 || *f > 0.95),
2260            "no partial reveals allowed: {fractions:?}"
2261        );
2262        assert!(
2263            fractions.last().unwrap() > &0.95,
2264            "tooltip presented: {fractions:?}"
2265        );
2266    }
2267
2268    #[test]
2269    fn dismissal_withdraws_atomically_through_process() {
2270        use visual_cortex_capture::PxRect;
2271        let mut mask = StabilityMask::new()
2272            .block_size(8)
2273            .stable_ticks(3)
2274            .min_component(1)
2275            .dilate(0);
2276        let full = PxRect {
2277            x: 0,
2278            y: 0,
2279            w: 64,
2280            h: 64,
2281        };
2282        let bg = Frame::solid(64, 64, [60, 60, 60, 255]);
2283        let tip = Frame::from_fn(64, 64, |x, y| {
2284            if (8..40).contains(&y) && (8..40).contains(&x) {
2285                [230, 230, 230, 255]
2286            } else {
2287                [60, 60, 60, 255]
2288            }
2289        });
2290        for _ in 0..8 {
2291            mask.process(&bg.view(full).unwrap()).unwrap();
2292        }
2293        for _ in 0..10 {
2294            mask.process(&tip.view(full).unwrap()).unwrap();
2295        }
2296        let mut fractions = Vec::new();
2297        for _ in 0..10 {
2298            let out = mask.process(&bg.view(full).unwrap()).unwrap();
2299            let mut vis = 0usize;
2300            for y in 8..40usize {
2301                for x in 8..40usize {
2302                    if out.data()[(y * 64 + x) * 4] != 0 {
2303                        vis += 1;
2304                    }
2305                }
2306            }
2307            fractions.push(vis as f64 / 1024.0);
2308        }
2309        assert!(
2310            fractions.iter().all(|f| *f < 0.05 || *f > 0.95),
2311            "withdraw must be atomic: {fractions:?}"
2312        );
2313        assert!(fractions.last().unwrap() < &0.05, "gone: {fractions:?}");
2314    }
2315
2316    // ---------- builder derivations ----------
2317
2318    #[test]
2319    fn builder_derives_holdover_and_long_run_from_stable_ticks() {
2320        let m = StabilityMask::new().stable_ticks(5);
2321        assert_eq!(m.params.holdover_ticks, 10, "H = 2K");
2322        assert_eq!(m.params.long_run, 15, "U = 3K");
2323        // Explicit holdover survives a later stable_ticks call.
2324        let m = StabilityMask::new().holdover_ticks(5).stable_ticks(10);
2325        assert_eq!(m.params.holdover_ticks, 5, "explicit H wins");
2326        assert_eq!(m.params.long_run, 30);
2327        // Large K no longer clamps H (u16 storage).
2328        let m = StabilityMask::new().stable_ticks(300);
2329        assert_eq!(m.params.holdover_ticks, 600);
2330    }
2331
2332    // ---------- edge blocks ----------
2333
2334    #[test]
2335    fn partial_edge_blocks_average_available_pixels_only() {
2336        use visual_cortex_capture::PxRect;
2337        // 19x13 with block 16: right edge block is 3px wide, bottom-right
2338        // corner is 3x13... grid is 2x1 blocks wide? 19/16 -> 2 cols, 13/16
2339        // -> 1 row. Right block = 3x13 bright pixels.
2340        let frame = Frame::from_fn(19, 13, |x, _| {
2341            if x < 16 {
2342                [40, 40, 40, 255]
2343            } else {
2344                [220, 220, 220, 255]
2345            }
2346        });
2347        let view = frame
2348            .view(PxRect {
2349                x: 0,
2350                y: 0,
2351                w: 19,
2352                h: 13,
2353            })
2354            .unwrap();
2355        let sigs = compute_signatures(&view, 16, 2, 1);
2356        // Partial right block: every populated sub-cell averages only
2357        // bright pixels; empty sub-cells inherit the block mean (also
2358        // bright) — nothing collapses to 0.
2359        assert!(
2360            sigs[1].0[..16].iter().all(|v| *v > 200),
2361            "partial block averages available pixels: {:?}",
2362            sigs[1].0
2363        );
2364        assert!(sigs[0].0[..16].iter().all(|v| *v < 60));
2365    }
2366
2367    // ---------- debug sink fan-out ----------
2368
2369    #[test]
2370    fn debug_sinks_receive_all_five_stages_per_tick() {
2371        struct Recording(Arc<Mutex<Vec<(u64, DebugStage)>>>);
2372        impl DebugSink for Recording {
2373            fn write(&mut self, tick: u64, stage: DebugStage, _frame: &Frame) {
2374                self.0.lock().unwrap().push((tick, stage));
2375            }
2376        }
2377
2378        let log = Arc::new(Mutex::new(Vec::new()));
2379        let mut mask = StabilityMask::new()
2380            .block_size(8)
2381            .debug_sink(Recording(log.clone()));
2382        let frame = Frame::solid(8, 8, [60, 60, 60, 255]);
2383        let view = frame
2384            .view(visual_cortex_capture::PxRect {
2385                x: 0,
2386                y: 0,
2387                w: 8,
2388                h: 8,
2389            })
2390            .unwrap();
2391        mask.process(&view).unwrap();
2392        mask.process(&view).unwrap();
2393
2394        use DebugStage::*;
2395        assert_eq!(
2396            *log.lock().unwrap(),
2397            vec![
2398                (0, Input),
2399                (0, Baseline),
2400                (0, Overlay),
2401                (0, State),
2402                (0, Output),
2403                (1, Input),
2404                (1, Baseline),
2405                (1, Overlay),
2406                (1, State),
2407                (1, Output),
2408            ]
2409        );
2410    }
2411}