Skip to main content

projective_grid/shared/
recovery_schedule.rs

1//! Geometry-only post-convergence recovery schedule (advanced tier).
2//!
3//! After a component-assembly pass has produced one self-consistent labelled
4//! component, recall on a foreshortened / partially-occluded grid is still
5//! bounded by how far the growth frontier reached before the per-edge band or
6//! the synthesized-axis voucher stalled it. This module composes the
7//! boundary-extension and interior-fill engines, interleaved with revalidation
8//! and the lattice-general drop filters, into a single fixed-point schedule
9//! that pushes recall up to the dense-recovery level the topological walk
10//! reaches — **without** any target-specific vocabulary.
11//!
12//! # Stage order
13//!
14//! The schedule mirrors the *geometry-only* subset of the chessboard
15//! detector's `run_converged_iteration` sequence (extension → fill → final
16//! geometry check), dropping the ChESS-coupled stages (slot-flip fix, cluster
17//! refit, NoCluster rescue) that have no meaning for a generic
18//! [`SquareAttachPolicy`]:
19//!
20//! 1. **boundary extension** — [`extend_via_local_homography`] fits a
21//!    per-candidate local homography from the K nearest labelled corners and
22//!    projects integer cells past the labelled boundary. Local-H tracks
23//!    perspective foreshortening where one global H cannot, so it is the
24//!    workhorse for the orientation-free perspective case. Followed by
25//!    [`extend_from_labelled`] (cardinal-BFS extension) which mops up cells the
26//!    local-H residual gate refused but a single-edge prediction can still
27//!    reach.
28//! 2. **interior fill** — [`fill_grid_holes`] enumerates still-empty cells
29//!    inside the labelled bounding box (plus a one-cell skirt) and attaches a
30//!    candidate at each using the same per-cell ladder as BFS grow.
31//! 3. **revalidation** — the shared [`validate`](crate::shared::validate::validate)
32//!    pass (line collinearity + local-H residual) drops any corner the
33//!    extension / fill attached that does not cohere with its neighbourhood.
34//! 4. **drop filters** — the lattice-general filters in
35//!    [`crate::shared::validate::wrong_label_filters`]: the topological wrong-label drops
36//!    (overlong / off-axis / duplicate-pixel edges), then the
37//!    largest-cardinally-connected-component filter (a square detection is one
38//!    connected planar graph; any stranded sub-component is a false positive).
39//!
40//! The whole sequence repeats until a full pass attaches zero new corners (a
41//! fixed point) or the iteration cap is reached. On a clean grid that the loop
42//! already recovered fully, the first extension pass attaches nothing and the
43//! schedule returns immediately.
44//!
45//! # Precision contract
46//!
47//! Every attachment runs through the same [`SquareAttachPolicy`] gates as BFS
48//! grow (`is_eligible`, `required_label_at` / `label_of`, `accept_candidate`,
49//! `edge_ok`) plus the extension residual gate, then through revalidation and
50//! the drop filters. A corner whose geometry does not cohere is *dropped*, not
51//! mislabelled. The schedule can therefore only ever *raise* recall toward the
52//! true grid or *shrink* a component it cannot justify — it can never introduce
53//! a wrong `(i, j)` label that the gates would not have caught on the BFS path.
54//!
55//! # Gating
56//!
57//! The schedule is opt-in via [`RecoverySchedule`] on the caller's params; the
58//! facade enables it for the orientation-free / position paths, while the
59//! chessboard topological adapter (which disables the facade validate/fit and
60//! runs its own `CornerStage`-coupled recovery) leaves it off so its production
61//! output stays byte-identical.
62
63use std::collections::{HashMap, HashSet};
64
65use nalgebra::{Point2, Vector2};
66
67use crate::shared::extension::{extend_via_local_homography, LocalExtensionParams};
68use crate::shared::fill::{fill_grid_holes, FillParams};
69use crate::shared::grow::{GrowParams, GrowResult, SquareAttachPolicy};
70use crate::shared::grow_extend::extend_from_labelled;
71use crate::shared::validate::{self as pg_validate, ValidationParams};
72
73/// Tuning for the geometry-only recovery schedule.
74///
75/// Defaults are conservative: a single fixed-point sweep of extension + fill
76/// with the engines' own defaults, followed by revalidation and the
77/// component / wrong-label drop filters. Raise [`max_sweeps`](Self::max_sweeps)
78/// to let a strongly foreshortened grid propagate further outward.
79#[non_exhaustive]
80#[derive(Clone, Debug)]
81pub struct RecoveryParams {
82    /// Local-homography boundary extension knobs.
83    pub local_extension: LocalExtensionParams,
84    /// Cardinal-BFS boundary extension knobs (the mop-up pass after local-H).
85    pub bfs_extension: GrowParams,
86    /// Interior-fill knobs.
87    pub fill: FillParams,
88    /// Maximum number of (extend → fill → validate → drop) sweeps. Each sweep
89    /// is idempotent on a converged grid, so the schedule stops early on the
90    /// first zero-attachment sweep.
91    pub max_sweeps: u32,
92    /// Whether to apply the topological wrong-label drop filter (overlong /
93    /// off-axis / duplicate-pixel edges) after revalidation. The orientation-
94    /// free path enables it; it is the strongest guard against a synthesized-
95    /// axis mislabel slipping through the per-edge band.
96    pub apply_wrong_label_drops: bool,
97    /// Whether to keep only the largest cardinally-connected component after
98    /// the drop filters. A square detection is one connected planar graph, so
99    /// any stranded sub-component a drop orphaned is a false positive.
100    pub apply_largest_component: bool,
101}
102
103impl Default for RecoveryParams {
104    fn default() -> Self {
105        Self {
106            local_extension: LocalExtensionParams::default(),
107            bfs_extension: GrowParams::default(),
108            fill: FillParams::default(),
109            max_sweeps: 4,
110            apply_wrong_label_drops: true,
111            apply_largest_component: true,
112        }
113    }
114}
115
116/// Whether a detection path runs the geometry-only recovery schedule.
117///
118/// The default is [`Auto`](Self::Auto): the detection facade enables the
119/// schedule for the synthesized-axis paths (`Evidence::Positions` /
120/// `Evidence::Oriented1`, whose recall is bounded by the BFS frontier) and
121/// leaves it off for the native `Evidence::Oriented2` path (which stays
122/// byte-compatible). A caller that runs its own `CornerStage`-coupled recovery
123/// downstream — the chessboard topological adapter — sets it explicitly to
124/// [`Off`](Self::Off) so the facade adds nothing, keeping production output
125/// byte-identical.
126#[non_exhaustive]
127#[derive(Clone, Debug, Default)]
128pub enum RecoverySchedule {
129    /// Facade decides per evidence kind (default): on for synthesized-axis
130    /// paths, off for native `Oriented2`.
131    #[default]
132    Auto,
133    /// Run no post-convergence recovery (the explicit byte-compat opt-out for
134    /// callers that recover downstream themselves).
135    Off,
136    /// Always run the geometry-only recovery schedule with the given tuning.
137    On(RecoveryParams),
138}
139
140impl RecoverySchedule {
141    /// Resolve the schedule for a concrete dispatch: `Auto` becomes the default
142    /// recovery params when `synthesized_axes` is set, otherwise off. `Off`
143    /// stays off; `On(p)` always runs with `p`.
144    pub(crate) fn resolve(&self, synthesized_axes: bool) -> Option<RecoveryParams> {
145        match self {
146            RecoverySchedule::Auto if synthesized_axes => Some(RecoveryParams::default()),
147            RecoverySchedule::Auto => None,
148            RecoverySchedule::Off => None,
149            RecoverySchedule::On(p) => Some(p.clone()),
150        }
151    }
152}
153
154/// Summary of one [`run_schedule`] invocation. Data carrier.
155#[derive(Clone, Debug, Default)]
156pub struct RecoveryStats {
157    /// Number of (extend → fill → validate → drop) sweeps actually run.
158    pub sweeps: u32,
159    /// Net corners added across the whole schedule (attachments minus drops).
160    pub net_added: i64,
161    /// Total corners attached by the extension + fill engines.
162    pub attached: usize,
163    /// Total corners dropped by revalidation + the drop filters.
164    pub dropped: usize,
165}
166
167/// Run the geometry-only recovery schedule over a converged labelled component.
168///
169/// `grow` carries the converged labelled set plus the seed-derived axis
170/// vectors (used by the cardinal-BFS extension for its prediction direction).
171/// `cell_size` is the component's estimated cell pitch. `validate_params` is
172/// the same [`ValidationParams`] the convergence loop used, so revalidation is
173/// consistent with the inner gates. The schedule mutates `grow.labelled` /
174/// `grow.by_corner` in place and returns a [`RecoveryStats`] summary.
175///
176/// `strength_of` maps a corner index to a detector-response strength; it is
177/// reserved for callers that want a weak-leaf peel (the facade passes a
178/// constant, so the weak-leaf pass is a no-op and only the connectivity /
179/// wrong-label filters fire). Determinism: the engines and filters break ties
180/// by index / sorted coordinate, so repeated runs are byte-identical.
181pub fn run_schedule<V: SquareAttachPolicy>(
182    positions: &[Point2<f32>],
183    grow: &mut GrowResult,
184    cell_size: f32,
185    policy: &V,
186    params: &RecoveryParams,
187    validate_params: &ValidationParams,
188) -> RecoveryStats {
189    let mut stats = RecoveryStats::default();
190    if grow.labelled.len() < 4 {
191        return stats;
192    }
193    ensure_axes(grow, positions);
194
195    let start = grow.labelled.len() as i64;
196    for _sweep in 0..params.max_sweeps.max(1) {
197        stats.sweeps += 1;
198        let before = grow.labelled.len();
199
200        // Stage 1: boundary extension (local-H then cardinal-BFS mop-up).
201        let local = extend_via_local_homography(
202            positions,
203            grow,
204            cell_size,
205            &params.local_extension,
206            policy,
207        );
208        stats.attached += local.attached;
209        let bfs = extend_from_labelled(positions, grow, cell_size, &params.bfs_extension, policy);
210        stats.attached += bfs.attached;
211
212        // Stage 2: interior fill.
213        let fill = fill_grid_holes(positions, grow, cell_size, &params.fill, policy);
214        stats.attached += fill.added;
215
216        // Stage 3 + 4: revalidate, then apply the lattice-general drop filters.
217        let dropped = revalidate_and_filter(positions, grow, cell_size, params, validate_params);
218        stats.dropped += dropped;
219
220        // Fixed point: a sweep that neither grew nor shrank the labelled set
221        // cannot make progress on the next one (the engines are idempotent on
222        // a stable set), so stop.
223        if grow.labelled.len() == before {
224            break;
225        }
226    }
227
228    stats.net_added = grow.labelled.len() as i64 - start;
229    stats
230}
231
232/// Revalidate the labelled set and apply the wrong-label / largest-component
233/// drop filters. Returns the number of corners dropped this sweep.
234fn revalidate_and_filter(
235    positions: &[Point2<f32>],
236    grow: &mut GrowResult,
237    cell_size: f32,
238    params: &RecoveryParams,
239    validate_params: &ValidationParams,
240) -> usize {
241    // The validate + wrong-label + largest-component composition (and its
242    // deterministic input ordering) lives in the shared `drop_set` helper,
243    // which the chessboard detector's final geometry check also routes
244    // through; only the application to this `GrowResult` stays here.
245    let result = pg_validate::wrong_label_filters::drop_set(
246        &grow.labelled,
247        |idx| positions[idx],
248        cell_size,
249        validate_params,
250        params.apply_wrong_label_drops,
251        params.apply_largest_component,
252    );
253
254    if result.drop.is_empty() {
255        return 0;
256    }
257    let mut removed = 0usize;
258    grow.labelled.retain(|_, &mut idx| {
259        if result.drop.contains(&idx) {
260            removed += 1;
261            false
262        } else {
263            true
264        }
265    });
266    grow.by_corner.retain(|idx, _| !result.drop.contains(idx));
267    removed
268}
269
270/// Ensure `grow.axis_i` / `grow.axis_j` are usable unit vectors for the
271/// cardinal-BFS extension. The facade reconstructs `GrowResult` from a labelled
272/// map and may not carry seed axes, so estimate them from the labelled set's
273/// mean cardinal edges when they are degenerate.
274fn ensure_axes(grow: &mut GrowResult, positions: &[Point2<f32>]) {
275    let needs = grow.axis_i.norm() < 1e-3 || grow.axis_j.norm() < 1e-3;
276    if !needs {
277        return;
278    }
279    let (mut sum_i, mut n_i) = (Vector2::<f32>::zeros(), 0u32);
280    let (mut sum_j, mut n_j) = (Vector2::<f32>::zeros(), 0u32);
281    for (&(i, j), &idx) in &grow.labelled {
282        let here = positions[idx];
283        if let Some(&n) = grow.labelled.get(&(i + 1, j)) {
284            sum_i += positions[n] - here;
285            n_i += 1;
286        }
287        if let Some(&n) = grow.labelled.get(&(i, j + 1)) {
288            sum_j += positions[n] - here;
289            n_j += 1;
290        }
291    }
292    if n_i > 0 {
293        let v = sum_i / n_i as f32;
294        if v.norm() > 1e-3 {
295            grow.axis_i = v.normalize();
296        }
297    }
298    if n_j > 0 {
299        let v = sum_j / n_j as f32;
300        if v.norm() > 1e-3 {
301            grow.axis_j = v.normalize();
302        }
303    }
304    if grow.axis_i.norm() < 1e-3 {
305        grow.axis_i = Vector2::new(1.0, 0.0);
306    }
307    if grow.axis_j.norm() < 1e-3 {
308        grow.axis_j = Vector2::new(0.0, 1.0);
309    }
310}
311
312/// Run the geometry-only recovery schedule over a set of merged components,
313/// masking each component's recovery against the corners owned by the others
314/// (single-claim across components), then rebasing each recovered component to
315/// the non-negative `(i, j)` origin. Shared by both facades' synthesized-axis
316/// path.
317/// Shared borrows threaded through the recovery entry points. Bundling them
318/// keeps the public `recover_components` / `recover_positions_component`
319/// signatures within the workspace argument-count limit without an inline
320/// clippy allow.
321#[derive(Clone, Copy)]
322pub(crate) struct RecoveryInputs<'a> {
323    /// Features carrying positions + (synthesized) axes.
324    pub features: &'a [crate::feature::OrientedFeature<2>],
325    /// Corner positions, indexed 1:1 with `features`.
326    pub positions: &'a [Point2<f32>],
327    /// Per-corner robust local pitch (see [`local_pitch_of`]).
328    pub local_pitch: &'a [f32],
329    /// Recovery schedule tuning.
330    pub params: &'a RecoveryParams,
331    /// Validation tuning reused by the schedule's revalidation pass.
332    pub validate_params: &'a ValidationParams,
333}
334
335pub(crate) fn recover_components(
336    merged: Vec<HashMap<(i32, i32), usize>>,
337    inputs: RecoveryInputs<'_>,
338) -> Vec<HashMap<(i32, i32), usize>> {
339    let RecoveryInputs { positions, .. } = inputs;
340    // Recover largest-first. A perspective grid that the convergence loop
341    // fragmented into one large component plus a few small ones (the BFS
342    // frontier stalled on the foreshortened side, then re-seeded) is best
343    // healed by letting the *largest* component's extension / fill absorb the
344    // fragments' corners — the fragments carry their own (incompatible) local
345    // origin, so the merge could not reunite them, but the big component's
346    // local-H extension reaches them directly. So a corner is masked for a
347    // component only if an *already-recovered* (i.e. larger) component claimed
348    // it; a corner still sitting in a smaller, not-yet-recovered fragment is
349    // left available for the larger component to absorb.
350    let mut order: Vec<usize> = (0..merged.len()).collect();
351    order.sort_by(|&a, &b| {
352        merged[b]
353            .len()
354            .cmp(&merged[a].len())
355            .then_with(|| min_index(&merged[a]).cmp(&min_index(&merged[b])))
356    });
357
358    let mut claimed: HashSet<usize> = HashSet::new();
359    let mut recovered_by_slot: Vec<HashMap<(i32, i32), usize>> = vec![HashMap::new(); merged.len()];
360    for &k in &order {
361        // Drop any corner an already-recovered (larger) component absorbed, so
362        // two solutions never reference the same corner index. A fragment whose
363        // members were fully absorbed collapses to empty and is filtered out.
364        let comp: HashMap<(i32, i32), usize> = merged[k]
365            .iter()
366            .filter(|(_, &idx)| !claimed.contains(&idx))
367            .map(|(&k, &v)| (k, v))
368            .collect();
369        if comp.len() < 4 {
370            for &idx in comp.values() {
371                claimed.insert(idx);
372            }
373            recovered_by_slot[k] = if comp.is_empty() {
374                HashMap::new()
375            } else {
376                rebase_to_origin(&comp)
377            };
378            continue;
379        }
380        let cell_size = cell_size_of(&comp, positions);
381        let own: HashSet<usize> = comp.values().copied().collect();
382        let masked: HashSet<usize> = claimed.difference(&own).copied().collect();
383        let recovered = recover_positions_component(&comp, &masked, cell_size, inputs);
384        claimed.extend(recovered.values().copied());
385        recovered_by_slot[k] = rebase_to_origin(&recovered);
386    }
387    // Drop fragments that collapsed to empty after absorption.
388    recovered_by_slot.retain(|m| !m.is_empty());
389    recovered_by_slot
390}
391
392/// Smallest feature index in a labelled map (deterministic tie-break key).
393fn min_index(labelled: &HashMap<(i32, i32), usize>) -> usize {
394    labelled.values().copied().min().unwrap_or(usize::MAX)
395}
396
397/// Number of nearest neighbours pooled per corner for the robust local-pitch
398/// estimate.
399const LOCAL_PITCH_NEIGHBOURS: usize = 5;
400
401/// Per-corner robust local pitch (upper-median of the nearest-neighbour
402/// distances). Tracks perspective foreshortening while tolerating a minority of
403/// off-lattice points sitting closer than the pitch. The topological facade's
404/// synthesized-axis recovery entry uses this to gate per-edge growth against
405/// the local cell scale.
406pub(crate) fn local_pitch_of(positions: &[Point2<f32>]) -> Vec<f32> {
407    use kiddo::{KdTree, SquaredEuclidean};
408    let n = positions.len();
409    if n < 2 {
410        return vec![0.0; n];
411    }
412    let mut tree: KdTree<f32, 2> = KdTree::new();
413    for (i, p) in positions.iter().enumerate() {
414        tree.add(&[p.x, p.y], i as u64);
415    }
416    positions
417        .iter()
418        .enumerate()
419        .map(|(i, p)| {
420            let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], LOCAL_PITCH_NEIGHBOURS + 1);
421            let mut dists: Vec<f32> = hits
422                .into_iter()
423                .filter(|nn| nn.item as usize != i)
424                .map(|nn| nn.distance.sqrt())
425                .filter(|d| d.is_finite() && *d > 1e-3)
426                .collect();
427            if dists.is_empty() {
428                return 0.0;
429            }
430            dists.sort_by(|a, b| a.total_cmp(b));
431            dists[dists.len() / 2]
432        })
433        .collect()
434}
435
436/// Mean labelled-pair cardinal edge length for a component (the recovery
437/// schedule's `cell_size`). Mirrors the facade's `estimate_cell_size`.
438fn cell_size_of(labelled: &HashMap<(i32, i32), usize>, positions: &[Point2<f32>]) -> f32 {
439    let mut sum = 0.0_f32;
440    let mut count = 0usize;
441    for (&(i, j), &idx) in labelled {
442        let here = positions[idx];
443        for (di, dj) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
444            if let Some(&n) = labelled.get(&(i + di, j + dj)) {
445                sum += (positions[n] - here).norm();
446                count += 1;
447            }
448        }
449    }
450    if count == 0 {
451        1.0
452    } else {
453        sum / count as f32
454    }
455}
456
457/// Rebase a labelled component so its bounding-box minimum sits at `(0, 0)`.
458fn rebase_to_origin(labelled: &HashMap<(i32, i32), usize>) -> HashMap<(i32, i32), usize> {
459    let min_i = labelled.keys().map(|&(i, _)| i).min().unwrap_or(0);
460    let min_j = labelled.keys().map(|&(_, j)| j).min().unwrap_or(0);
461    if min_i == 0 && min_j == 0 {
462        return labelled.clone();
463    }
464    labelled
465        .iter()
466        .map(|(&(i, j), &idx)| ((i - min_i, j - min_j), idx))
467        .collect()
468}
469
470/// Run the geometry-only recovery schedule over a labelled `(i, j) → index`
471/// component using the geometry-first [`PositionsAttachPolicy`].
472///
473/// This is the entry the topological facade uses for the synthesized-axis
474/// (`Evidence::Positions` / `Evidence::Oriented1`) path. `features` carries
475/// positions + synthesized axes; `masked` lists corner
476/// indices owned by *other* components (so the recovery can't steal them).
477/// Returns the recovered (NOT yet rebased) labelled map; the caller rebases to
478/// the non-negative `(i, j)` origin.
479pub(crate) fn recover_positions_component(
480    labelled: &HashMap<(i32, i32), usize>,
481    masked: &HashSet<usize>,
482    cell_size: f32,
483    inputs: RecoveryInputs<'_>,
484) -> HashMap<(i32, i32), usize> {
485    use crate::shared::positions_policy::{PositionsAttachPolicy, PositionsTolerances};
486
487    // 50° soft axis tolerance / 0.40 edge band — the position-policy defaults
488    // documented in the facade.
489    let tol = PositionsTolerances {
490        soft_axis_tol_rad: 0.872_664_6,
491        edge_length_tol: 0.40,
492        cell_size,
493    };
494    let inner =
495        PositionsAttachPolicy::new(inputs.features, inputs.positions, inputs.local_pitch, tol);
496    let policy = MaskedPolicy {
497        inner: &inner,
498        masked,
499    };
500    let mut grow = grow_result_from_labelled(labelled, inputs.positions);
501    run_schedule(
502        inputs.positions,
503        &mut grow,
504        cell_size,
505        &policy,
506        inputs.params,
507        inputs.validate_params,
508    );
509    grow.labelled
510}
511
512/// Wrap a [`SquareAttachPolicy`] to additionally mask out corner indices owned
513/// by another component (single-claim across components during recovery).
514struct MaskedPolicy<'a, V: SquareAttachPolicy> {
515    inner: &'a V,
516    masked: &'a HashSet<usize>,
517}
518
519impl<V: SquareAttachPolicy> SquareAttachPolicy for MaskedPolicy<'_, V> {
520    fn is_eligible(&self, idx: usize) -> bool {
521        !self.masked.contains(&idx) && self.inner.is_eligible(idx)
522    }
523    fn required_label_at(&self, i: i32, j: i32) -> Option<u8> {
524        self.inner.required_label_at(i, j)
525    }
526    fn label_of(&self, idx: usize) -> Option<u8> {
527        self.inner.label_of(idx)
528    }
529    fn accept_candidate(
530        &self,
531        idx: usize,
532        at: (i32, i32),
533        prediction: Point2<f32>,
534        neighbours: &[crate::shared::grow::LabelledNeighbour],
535    ) -> crate::shared::grow::Admit {
536        self.inner.accept_candidate(idx, at, prediction, neighbours)
537    }
538    fn edge_ok(&self, c: usize, n: usize, ac: (i32, i32), an: (i32, i32)) -> bool {
539        self.inner.edge_ok(c, n, ac, an)
540    }
541}
542
543/// Reconstruct a [`GrowResult`] from a labelled `(i, j) → index` map for the
544/// recovery schedule. Estimates the axis vectors from the labelled set; the
545/// schedule's internal axis-repair step also defends against a degenerate
546/// estimate.
547pub(crate) fn grow_result_from_labelled(
548    labelled: &HashMap<(i32, i32), usize>,
549    positions: &[Point2<f32>],
550) -> GrowResult {
551    let by_corner: HashMap<usize, (i32, i32)> = labelled.iter().map(|(&k, &v)| (v, k)).collect();
552    let mut grow = GrowResult {
553        labelled: labelled.clone(),
554        by_corner,
555        ..Default::default()
556    };
557    ensure_axes(&mut grow, positions);
558    grow
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::shared::grow::{Admit, LabelledNeighbour};
565
566    /// Open policy: every corner eligible, no label constraint, accept all,
567    /// edges within ±40% of the local cell size. Mirrors the geometry-only
568    /// facade policy on a synthetic grid.
569    struct OpenPolicy<'a> {
570        positions: &'a [Point2<f32>],
571        cell_size: f32,
572    }
573
574    impl SquareAttachPolicy for OpenPolicy<'_> {
575        fn is_eligible(&self, _idx: usize) -> bool {
576            true
577        }
578        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
579            None
580        }
581        fn label_of(&self, _idx: usize) -> Option<u8> {
582            None
583        }
584        fn accept_candidate(
585            &self,
586            _idx: usize,
587            _at: (i32, i32),
588            _prediction: Point2<f32>,
589            _neighbours: &[LabelledNeighbour],
590        ) -> Admit {
591            Admit::Accept
592        }
593        fn edge_ok(&self, c: usize, n: usize, _ac: (i32, i32), _an: (i32, i32)) -> bool {
594            let len = (self.positions[c] - self.positions[n]).norm();
595            let r = len / self.cell_size;
596            (0.6..=1.4).contains(&r)
597        }
598    }
599
600    /// Positions in row-major order plus a `(i, j) → index` map.
601    type SyntheticGrid = (Vec<Point2<f32>>, HashMap<(i32, i32), usize>);
602
603    /// Build an axis-aligned `rows × cols` grid.
604    fn grid(rows: i32, cols: i32, s: f32) -> SyntheticGrid {
605        let mut pos = Vec::new();
606        let mut map = HashMap::new();
607        let mut idx = 0usize;
608        for j in 0..rows {
609            for i in 0..cols {
610                pos.push(Point2::new(i as f32 * s + 40.0, j as f32 * s + 40.0));
611                map.insert((i, j), idx);
612                idx += 1;
613            }
614        }
615        (pos, map)
616    }
617
618    #[test]
619    fn fills_interior_holes_and_extends_boundary() {
620        let s = 30.0_f32;
621        let (pos, full) = grid(7, 7, s);
622        // Seed only the inner 3x3 block; the schedule must extend outward and
623        // fill to recover the full 7x7.
624        let mut seed: HashMap<(i32, i32), usize> = HashMap::new();
625        for j in 2..5 {
626            for i in 2..5 {
627                seed.insert((i, j), full[&(i, j)]);
628            }
629        }
630        let mut grow = grow_result_from_labelled(&seed, &pos);
631        let policy = OpenPolicy {
632            positions: &pos,
633            cell_size: s,
634        };
635        let params = RecoveryParams::default();
636        let vp = ValidationParams::default();
637        let stats = run_schedule(&pos, &mut grow, s, &policy, &params, &vp);
638        assert!(
639            grow.labelled.len() >= 45,
640            "recovered only {}/49 (sweeps {})",
641            grow.labelled.len(),
642            stats.sweeps
643        );
644        // Zero wrong labels: every recovered cell maps to the same index the
645        // ground-truth grid assigned (up to the schedule's rebase, which is
646        // identity here because the seed block sat at the interior).
647        for (&cell, &idx) in &grow.labelled {
648            assert_eq!(
649                full.get(&cell),
650                Some(&idx),
651                "cell {cell:?} mislabelled to index {idx}"
652            );
653        }
654    }
655
656    #[test]
657    fn decoys_off_lattice_are_never_labelled() {
658        let s = 30.0_f32;
659        let (mut pos, full) = grid(6, 6, s);
660        let grid_n = pos.len();
661        // Add off-lattice decoys: points sitting between cells and far away.
662        // None of them sit on an integer lattice node, so a precision-correct
663        // schedule must never attach them.
664        let decoys = [
665            Point2::new(40.0 + 0.5 * s, 40.0 + 0.5 * s), // cell centre
666            Point2::new(40.0 + 2.5 * s, 40.0 + 1.5 * s),
667            Point2::new(40.0 - 3.0 * s, 40.0 + 2.0 * s), // far outside
668            Point2::new(40.0 + 9.0 * s, 40.0 + 9.0 * s),
669        ];
670        for d in decoys {
671            pos.push(d);
672        }
673        // Seed an inner block, recover, and assert no decoy index is labelled.
674        let mut seed: HashMap<(i32, i32), usize> = HashMap::new();
675        for j in 1..4 {
676            for i in 1..4 {
677                seed.insert((i, j), full[&(i, j)]);
678            }
679        }
680        let mut grow = grow_result_from_labelled(&seed, &pos);
681        let policy = OpenPolicy {
682            positions: &pos,
683            cell_size: s,
684        };
685        let stats = run_schedule(
686            &pos,
687            &mut grow,
688            s,
689            &policy,
690            &RecoveryParams::default(),
691            &ValidationParams::default(),
692        );
693        for &(_, idx) in grow
694            .by_corner
695            .iter()
696            .map(|(idx, c)| (c, idx))
697            .collect::<Vec<_>>()
698            .iter()
699        {
700            assert!(
701                *idx < grid_n,
702                "a decoy (index {idx} ≥ {grid_n}) was labelled (sweeps {})",
703                stats.sweeps
704            );
705        }
706        // And the true grid corners carry their true labels.
707        for (&cell, &idx) in &grow.labelled {
708            assert_eq!(full.get(&cell), Some(&idx), "cell {cell:?} mislabelled");
709        }
710    }
711
712    #[test]
713    fn idempotent_on_clean_full_grid() {
714        let s = 30.0_f32;
715        let (pos, full) = grid(5, 5, s);
716        let mut grow = grow_result_from_labelled(&full, &pos);
717        let policy = OpenPolicy {
718            positions: &pos,
719            cell_size: s,
720        };
721        let before = grow.labelled.len();
722        let stats = run_schedule(
723            &pos,
724            &mut grow,
725            s,
726            &policy,
727            &RecoveryParams::default(),
728            &ValidationParams::default(),
729        );
730        assert_eq!(grow.labelled.len(), before, "schedule altered a full grid");
731        assert_eq!(stats.net_added, 0);
732    }
733}