Skip to main content

long_screen/kernels/
voting.rs

1//! Displacement-spread consistency voting (docs/ARCHITECTURE.md §七): a bounded ring of recent
2//! analysis-resolution frames, one region-local box-gray per moving region, compared against several
3//! partners whose world displacement clears `dmin`. This is the stateful `solve()`-side half of the
4//! world-consistency evidence; `consistency.rs` consumes the finalised verdicts during rendering.
5//!
6//! Every rule here — box geometry, interior mask, partner selection (nearest two, farthest, fewest-pairs
7//! per band), the ±radius local search, saturating score/comparison counters, the 75 % ratio threshold and
8//! the three-way verdict — is the measured behaviour of the historical adapter; see the invariants in
9//! ARCHITECTURE.md §十 before changing any of them.
10
11use crate::kernels::geometry::{js_ceil, js_floor, js_hypot, js_round};
12use crate::kernels::pool::SyncPtr;
13use crate::kernels::region::Region;
14
15pub const PARTNERS: usize = 6;
16pub const VERDICT_MIN: u32 = 3;
17
18/// Analysis-resolution box, in this region's own local coordinates, bounding the region rect with a 1-cell margin.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct Box {
21    pub x0: i32,
22    pub y0: i32,
23    pub w: i32,
24    pub h: i32,
25}
26
27impl Box {
28    pub fn of(rect: &crate::kernels::geometry::Rect, factor: i32) -> Box {
29        let f = factor as f64;
30        let x0 = js_floor(rect.x / f) - 1;
31        let y0 = js_floor(rect.y / f) - 1;
32        Box {
33            x0,
34            y0,
35            w: js_ceil((rect.x + rect.width) / f) - x0 + 1,
36            h: js_ceil((rect.y + rect.height) / f) - y0 + 1,
37        }
38    }
39    #[inline]
40    pub fn cells(&self) -> usize {
41        (self.w.max(0) as usize) * (self.h.max(0) as usize)
42    }
43}
44
45/// Per-run, per-moving-region constants.
46pub struct RegionSlot {
47    pub region: Region,
48    pub box_: Box,
49    pub dmin: f64,
50    /// 1 where the cell and its eight blur taps are inside the region and the search window fits the box.
51    pub interior: Vec<u8>,
52    /// 1 where the box cell maps to an in-frame analysis cell inside the region (the blur-tap test), constant
53    /// for the run; `box_gray` reads this instead of re-evaluating region membership nine times per cell.
54    pub inside: Vec<u8>,
55}
56
57/// One region's voting state in one ring frame.
58pub struct Layer {
59    pub canvas: u32,
60    pub pose_x: f64,
61    pub pose_y: f64,
62    pub score: Vec<i8>,
63    pub comparisons: Vec<u8>,
64    pub pairs: u32,
65    pub box_gray: Vec<u8>,
66}
67
68pub struct Frame {
69    pub index: u32,
70    pub bytes: usize,
71    /// Indexed by region slot; `None` when the region had no evidence this frame.
72    pub layers: Vec<Option<Layer>>,
73}
74
75/// Finalised verdict for one region of one frame; both bitsets are LSB-first over `box_.w × box_.h`.
76pub struct Verdict {
77    pub slot: usize,
78    pub bits: Vec<u8>,
79    pub clean: Vec<u8>,
80}
81
82pub struct Finalized {
83    pub index: u32,
84    pub verdicts: Vec<Verdict>,
85    pub voted_layers: u32,
86    pub thin_layers: u32,
87}
88
89pub struct Ring {
90    pub factor: i32,
91    pub tau: i32,
92    pub radius: i32,
93    pub native_width: f64,
94    pub native_height: f64,
95    pub analysis_width: usize,
96    pub analysis_height: usize,
97    pub budget_bytes: usize,
98    pub slots: Vec<RegionSlot>,
99    frames: Vec<Frame>,
100    bytes: usize,
101    /// Layers for the frame being built; committed by `push_frame`.
102    building: Vec<Option<Layer>>,
103}
104
105/// Ratio rule, a measured deviation from a literal "≥2 comparisons, net score < 0" majority: partners are
106/// spread across the displacement range, so one or two of ~6 land a clean pixel inside their OWN overlay
107/// footprint by coincidence and a bare majority would flip it. Comparisons in {2,3} still require unanimity
108/// (⌈0.75·2⌉ = 2, ⌈0.75·3⌉ = 3); ≥4 need a 75 % supermajority, which an overlay interior clears but a single
109/// coincidental agreement no longer blocks.
110#[inline]
111fn threshold(comparisons: u32) -> i32 {
112    comparisons as i32 - 2 * ((comparisons as f64 * 0.75).ceil() as i32)
113}
114
115/// True when some `gray[start..start+count]` is within `tau` of `value` (count ≤ 2·radius+1 ≤ 16 lanes).
116#[inline]
117fn window_agrees(gray: &[u8], start: usize, count: usize, value: u8, tau: u8) -> bool {
118    #[cfg(target_feature = "simd128")]
119    {
120        use core::arch::wasm32::*;
121        if count <= 16 && start + 16 <= gray.len() {
122            // SAFETY: the 16-byte load is bounds-checked above; lanes past `count` are masked out.
123            let a = unsafe { v128_load(gray.as_ptr().add(start) as *const v128) };
124            let v = u8x16_splat(value);
125            let diff = v128_or(u8x16_sub_sat(a, v), u8x16_sub_sat(v, a));
126            let within = u8x16_le(diff, u8x16_splat(tau));
127            let lanes = u8x16_lt(
128                u8x16(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
129                u8x16_splat(count as u8),
130            );
131            return v128_any_true(v128_and(within, lanes));
132        }
133    }
134    gray[start..start + count]
135        .iter()
136        .any(|&g| (value as i32 - g as i32).unsigned_abs() <= tau as u32)
137}
138
139impl Ring {
140    #[allow(clippy::too_many_arguments)]
141    pub fn new(
142        factor: i32,
143        noise: f64,
144        native_width: usize,
145        native_height: usize,
146        analysis_width: usize,
147        analysis_height: usize,
148        budget_bytes: usize,
149        regions: Vec<Region>,
150    ) -> Ring {
151        let factor = factor.max(1);
152        // CONSISTENCY_PHASE headroom for box-filter phase applies only at f>1; a decoded recording keeps 26.
153        let phase = if factor == 1 { 0 } else { 26 };
154        let tau = phase.max(js_round(noise * 2.6));
155        let radius = factor;
156        let (nw, nh) = (native_width as f64, native_height as f64);
157        let slots = regions
158            .into_iter()
159            .map(|region| {
160                let box_ = Box::of(&region.rect, factor);
161                let dmin = (0.25 * region.rect.width.min(region.rect.height)).max(64.0);
162                let (gw, gh) = (analysis_width as i32, analysis_height as i32);
163                let mut inside = vec![0u8; box_.cells()];
164                for ly in 0..box_.h {
165                    for lx in 0..box_.w {
166                        let (ax, ay) = (box_.x0 + lx, box_.y0 + ly);
167                        let ok = ax >= 0
168                            && ay >= 0
169                            && ax < gw
170                            && ay < gh
171                            && region.contains((ax * factor) as f64, (ay * factor) as f64, nw, nh);
172                        inside[(ly * box_.w + lx) as usize] = ok as u8;
173                    }
174                }
175                let mut interior = vec![0u8; box_.cells()];
176                for ly in 0..box_.h {
177                    for lx in 0..box_.w {
178                        if lx < radius
179                            || ly < radius
180                            || lx >= box_.w - radius
181                            || ly >= box_.h - radius
182                        {
183                            continue;
184                        }
185                        let mut ok = true;
186                        'taps: for oy in -1..=1 {
187                            for ox in -1..=1 {
188                                let x = ((box_.x0 + lx + ox) * factor) as f64;
189                                let y = ((box_.y0 + ly + oy) * factor) as f64;
190                                if !region.contains(x, y, nw, nh) {
191                                    ok = false;
192                                    break 'taps;
193                                }
194                            }
195                        }
196                        interior[(ly * box_.w + lx) as usize] = ok as u8;
197                    }
198                }
199                RegionSlot {
200                    region,
201                    box_,
202                    dmin,
203                    interior,
204                    inside,
205                }
206            })
207            .collect::<Vec<_>>();
208        let building = (0..slots.len()).map(|_| None).collect();
209        Ring {
210            factor,
211            tau,
212            radius,
213            native_width: nw,
214            native_height: nh,
215            analysis_width,
216            analysis_height,
217            budget_bytes,
218            slots,
219            frames: Vec::new(),
220            bytes: 0,
221            building,
222        }
223    }
224
225    pub fn resident_frames(&self) -> usize {
226        self.frames.len()
227    }
228
229    /// Region-masked box-blurred luma in box-local coordinates (plain copy at factor 1).
230    ///
231    /// `downscale_gray`'s box grid is anchored to screen pixel (0,0), so at f>1 the same world pixel lands at a
232    /// different sub-cell phase in two frames whenever their pose delta is not a multiple of the factor; on
233    /// sharp edges that moves the averaged luma by tens of levels on bit-identical content. The 3×3 blur
234    /// suppresses it. Out-of-region or off-frame taps are replaced by the CENTRE value (edge replication):
235    /// a region-unaware blur leaked a neighbouring fixed region's colour into the boundary row, and simply
236    /// dropping taps left a weaker average at the edge. At f=1 there is no phase term and the blur only
237    /// costs boundary accuracy, so it is off.
238    fn box_gray(&self, slot: &RegionSlot, gray: &[u8]) -> Vec<u8> {
239        let b = slot.box_;
240        let (gw, gh) = (self.analysis_width as i32, self.analysis_height as i32);
241        let bw = b.w as usize;
242        let mut out = vec![0u8; b.cells()];
243        for ly in 0..b.h {
244            let ay = b.y0 + ly;
245            if ay < 0 || ay >= gh {
246                continue;
247            }
248            let row = ly as usize * bw;
249            let gray_row = (ay * gw) as usize;
250            if self.factor == 1 {
251                let lx0 = (-b.x0).clamp(0, b.w);
252                let lx1 = (gw - b.x0).clamp(0, b.w);
253                if lx1 > lx0 {
254                    let ax0 = (b.x0 + lx0) as usize;
255                    out[row + lx0 as usize..row + lx1 as usize].copy_from_slice(
256                        &gray[gray_row + ax0..gray_row + ax0 + (lx1 - lx0) as usize],
257                    );
258                }
259                continue;
260            }
261            for lx in 0..b.w {
262                let ax = b.x0 + lx;
263                if ax < 0 || ax >= gw {
264                    continue;
265                }
266                let centre = gray[gray_row + ax as usize] as u32;
267                let mut sum = 0u32;
268                for oy in -1..=1 {
269                    let tly = ly + oy;
270                    let tap_row = ((ay + oy) * gw) as isize;
271                    for ox in -1..=1 {
272                        let tlx = lx + ox;
273                        // A tap outside the box is outside the region too: the box bounds the rect with a margin.
274                        let inside = tlx >= 0
275                            && tly >= 0
276                            && tlx < b.w
277                            && tly < b.h
278                            && slot.inside[tly as usize * bw + tlx as usize] != 0;
279                        sum += if inside {
280                            gray[(tap_row + (ax + ox) as isize) as usize] as u32
281                        } else {
282                            centre
283                        };
284                    }
285                }
286                // Math.round(sum / 9) for non-negative sum: floor(sum / 9 + 0.5) == (2·sum + 9) / 18.
287                out[row + lx as usize] = ((2 * sum + 9) / 18) as u8;
288            }
289        }
290        out
291    }
292
293    /// Partner frame indices (positions in `frames`) for `slot` at `pose` on `canvas`: candidates clearing
294    /// `dmin`, sorted by displacement; the NEAREST TWO and the FARTHEST always, the rest one per equal index
295    /// band, choosing the candidate with the FEWEST pairs so far (ties: larger displacement).
296    /// - Spread keeps the "partner's own overlay footprint" coincidences independent of each other.
297    /// - The nearest two are what give a leading-edge cell (visible in no past frame) its comparisons at all.
298    /// - Fairness stops the same anchor frames being re-picked, so frames whose clean counterparts lie in the
299    ///   future still get judged; `thin_layers` is the watchdog for this.
300    fn partners(&self, slot: usize, canvas: u32, pose_x: f64, pose_y: f64) -> Vec<usize> {
301        let dmin = self.slots[slot].dmin;
302        let mut candidates: Vec<(usize, f64, u32, u32)> = Vec::new();
303        for (at, frame) in self.frames.iter().enumerate() {
304            let Some(layer) = &frame.layers[slot] else {
305                continue;
306            };
307            if layer.canvas != canvas {
308                continue;
309            }
310            let d = js_hypot(pose_x - layer.pose_x, pose_y - layer.pose_y);
311            if d >= dmin {
312                candidates.push((at, d, layer.pairs, frame.index));
313            }
314        }
315        if candidates.len() <= PARTNERS {
316            return candidates.into_iter().map(|c| c.0).collect();
317        }
318        // Stable sort by displacement: equal distances keep ring (ascending frame) order like Array#sort.
319        candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
320        let n = candidates.len();
321        let mut seen: Vec<u32> = Vec::with_capacity(PARTNERS);
322        let mut out: Vec<usize> = Vec::with_capacity(PARTNERS);
323        let take = |c: &(usize, f64, u32, u32), seen: &mut Vec<u32>, out: &mut Vec<usize>| {
324            if !seen.contains(&c.3) {
325                seen.push(c.3);
326                out.push(c.0);
327            }
328        };
329        take(&candidates[0], &mut seen, &mut out);
330        take(&candidates[1], &mut seen, &mut out);
331        take(&candidates[n - 1], &mut seen, &mut out);
332        let bands = PARTNERS - out.len();
333        let (lo, hi) = (2usize, n - 1);
334        let mut band = 0usize;
335        while band < bands && hi > lo {
336            let from = lo + band * (hi - lo) / bands;
337            let to = lo + (band + 1) * (hi - lo) / bands;
338            let mut best: Option<&(usize, f64, u32, u32)> = None;
339            for c in &candidates[from..to] {
340                if seen.contains(&c.3) {
341                    continue;
342                }
343                let better = match best {
344                    None => true,
345                    Some(b) => c.2 < b.2 || (c.2 == b.2 && c.1 > b.1),
346                };
347                if better {
348                    best = Some(c);
349                }
350            }
351            if let Some(b) = best {
352                take(b, &mut seen, &mut out);
353            }
354            band += 1;
355        }
356        out
357    }
358
359    /// Compares `t` (the frame being placed) against ring partner `s`, scoring both. The ±radius local search
360    /// exists because the pose delta is a native quantity divided by the integer factor, so it is essentially
361    /// never a whole cell; rounding it lands off the true match on textured content often enough to swamp the
362    /// signal. Candidates outside the region are not re-checked: they can only make agreement MORE likely.
363    fn compare(&self, slot: usize, t: &mut Layer, s: &mut Layer) {
364        let b = self.slots[slot].box_;
365        let interior = &self.slots[slot].interior;
366        let f = self.factor as f64;
367        let dx = (t.pose_x - s.pose_x) / f;
368        let dy = (t.pose_y - s.pose_y) / f;
369        t.pairs += 1;
370        s.pairs += 1;
371        let radius = self.radius;
372        // "best ≤ tau" over the window is "some tap within tau": rows can stop as soon as one is found, in any
373        // order, without changing the verdict. tau ≥ 255 means every tap agrees.
374        let tau = self.tau.clamp(0, 255) as u8;
375        let bw = b.w as usize;
376        // The integer x-shift is the same for every cell in a row only when dx is; js_round(lx + dx) is
377        // evaluated per cell exactly as before so half-cell displacements round identically.
378        //
379        // The window search (the expensive part) is order-independent, so rows are split across the pool into a
380        // per-cell verdict; the saturating counters are then updated in the historical (ly, lx) order, because
381        // saturation makes the partner-side updates order-dependent if two cells ever meet one partner cell.
382        let rows = b.h.max(0) as usize;
383        let mut verdicts = vec![0u8; b.cells()];
384        let chunks = crate::kernels::pool::chunks_for(b.cells() * 4, 8 * 1024);
385        let out = SyncPtr(verdicts.as_mut_ptr());
386        let (tg, sg) = (&t.box_gray, &s.box_gray);
387        crate::kernels::pool::par_for(chunks, |c| {
388            for ly in crate::kernels::pool::split(rows, chunks, c) as i32
389                ..crate::kernels::pool::split(rows, chunks, c + 1) as i32
390            {
391                let sy_row = js_round(ly as f64 + dy);
392                if sy_row < 0 || sy_row >= b.h {
393                    continue;
394                }
395                let py_lo = (sy_row - radius).max(0);
396                let py_hi = (sy_row + radius).min(b.h - 1);
397                let row_i = ly as usize * bw;
398                let row_s = sy_row as usize * bw;
399                // SAFETY: row `ly` of `verdicts` belongs to this chunk alone.
400                let verdict_row =
401                    unsafe { std::slice::from_raw_parts_mut(out.get().add(row_i), bw) };
402                for lx in 0..b.w {
403                    let i = row_i + lx as usize;
404                    if interior[i] == 0 {
405                        continue;
406                    }
407                    let sx = js_round(lx as f64 + dx);
408                    if sx < 0 || sx >= b.w || interior[row_s + sx as usize] == 0 {
409                        continue;
410                    }
411                    let value = tg[i];
412                    let x_lo = (sx - radius).max(0) as usize;
413                    let count = ((sx + radius).min(b.w - 1) as usize + 1) - x_lo;
414                    // The matching row is by far the likeliest to agree, so it is tried first.
415                    let agree = tau == 255
416                        || window_agrees(sg, sy_row as usize * bw + x_lo, count, value, tau)
417                        || (py_lo..=py_hi).any(|py| {
418                            py != sy_row
419                                && window_agrees(sg, py as usize * bw + x_lo, count, value, tau)
420                        });
421                    verdict_row[lx as usize] = if agree { 1 } else { 2 };
422                }
423            }
424        });
425        for ly in 0..b.h {
426            let sy_row = js_round(ly as f64 + dy);
427            if sy_row < 0 || sy_row >= b.h {
428                continue;
429            }
430            let row_i = ly as usize * bw;
431            let row_s = sy_row as usize * bw;
432            for lx in 0..b.w {
433                let i = row_i + lx as usize;
434                if verdicts[i] == 0 {
435                    continue;
436                }
437                let si = row_s + js_round(lx as f64 + dx) as usize;
438                let delta: i8 = if verdicts[i] == 1 { 1 } else { -1 };
439                t.comparisons[i] = t.comparisons[i].saturating_add(1);
440                t.score[i] = t.score[i].saturating_add(delta);
441                s.comparisons[si] = s.comparisons[si].saturating_add(1);
442                s.score[si] = s.score[si].saturating_add(delta);
443            }
444        }
445    }
446
447    /// Adds this frame's evidence for one region: builds its box gray, votes against ring partners and holds the
448    /// layer until `push_frame`. `gray` is the whole analysis frame.
449    pub fn observe(&mut self, slot: usize, canvas: u32, pose_x: f64, pose_y: f64, gray: &[u8]) {
450        let cells = self.slots[slot].box_.cells();
451        let mut layer = Layer {
452            canvas,
453            pose_x,
454            pose_y,
455            score: vec![0i8; cells],
456            comparisons: vec![0u8; cells],
457            pairs: 0,
458            box_gray: self.box_gray(&self.slots[slot], gray),
459        };
460        for at in self.partners(slot, canvas, pose_x, pose_y) {
461            // Take the partner out for the duration of the comparison so `self` stays borrowable.
462            let mut partner = self.frames[at].layers[slot]
463                .take()
464                .expect("partner layer present");
465            self.compare(slot, &mut layer, &mut partner);
466            self.frames[at].layers[slot] = Some(partner);
467        }
468        self.building[slot] = Some(layer);
469    }
470
471    /// Commits the frame under construction (if any region observed) and finalises whatever the byte budget
472    /// pushes out, oldest first.
473    pub fn push_frame(&mut self, index: u32) -> Vec<Finalized> {
474        let layers = std::mem::replace(
475            &mut self.building,
476            (0..self.slots.len()).map(|_| None).collect(),
477        );
478        if layers.iter().all(|l| l.is_none()) {
479            return Vec::new();
480        }
481        let bytes = layers
482            .iter()
483            .flatten()
484            .map(|l| l.box_gray.len() + l.score.len() + l.comparisons.len())
485            .sum();
486        self.frames.push(Frame {
487            index,
488            bytes,
489            layers,
490        });
491        self.bytes += bytes;
492        let mut out = Vec::new();
493        while self.bytes > self.budget_bytes && self.frames.len() > 1 {
494            let evicted = self.frames.remove(0);
495            self.bytes -= evicted.bytes;
496            out.push(self.finalize(evicted));
497        }
498        out
499    }
500
501    /// Finalises every resident frame, oldest first (end of the solve pass).
502    pub fn drain(&mut self) -> Vec<Finalized> {
503        let frames = std::mem::take(&mut self.frames);
504        self.bytes = 0;
505        frames.into_iter().map(|f| self.finalize(f)).collect()
506    }
507
508    fn finalize(&self, frame: Frame) -> Finalized {
509        let mut verdicts = Vec::new();
510        let (mut voted, mut thin) = (0u32, 0u32);
511        for (slot, layer) in frame.layers.iter().enumerate() {
512            let Some(layer) = layer else {
513                continue;
514            };
515            voted += 1;
516            if layer.pairs < VERDICT_MIN {
517                thin += 1;
518            }
519            let bytes = layer.score.len().div_ceil(8);
520            let mut bits = vec![0u8; bytes];
521            let mut clean = vec![0u8; bytes];
522            let mut any = false;
523            for (i, (&score, &comparisons)) in
524                layer.score.iter().zip(&layer.comparisons).enumerate()
525            {
526                let (score, comparisons) = (score as i32, comparisons as u32);
527                if comparisons >= 2 && score <= threshold(comparisons) {
528                    bits[i >> 3] |= 1 << (i & 7);
529                    any = true;
530                } else if comparisons >= VERDICT_MIN && score >= -threshold(comparisons) {
531                    clean[i >> 3] |= 1 << (i & 7);
532                    any = true;
533                }
534            }
535            if any {
536                verdicts.push(Verdict { slot, bits, clean });
537            }
538        }
539        Finalized {
540            index: frame.index,
541            verdicts,
542            voted_layers: voted,
543            thin_layers: thin,
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn ratio_threshold_matches_the_measured_rule() {
554        assert_eq!(threshold(2), -2);
555        assert_eq!(threshold(3), -3);
556        assert_eq!(threshold(4), -2);
557        assert_eq!(threshold(5), -3);
558        assert_eq!(threshold(6), -4);
559    }
560
561    #[test]
562    fn box_gray_rounding_matches_math_round() {
563        for sum in 0u32..=(9 * 255) {
564            let expected = ((sum as f64) / 9.0 + 0.5).floor() as u32;
565            assert_eq!((2 * sum + 9) / 18, expected, "sum {sum}");
566        }
567    }
568}