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