use std::collections::VecDeque;
use std::sync::Arc;
use rustc_hash::FxHashMap;
use crate::analysis::matchtypes::MatchTypeIndex;
use crate::cyclotomic::IsRing;
use crate::geom::matches::{EdgeRange, Segment};
use crate::geom::patch::{GrowingPatch, PatchMatch, PatchSeed};
use crate::geom::tileset::TileSet;
use crate::geom::vertices::{EdgeInfo, OpenVertexType, TransitionSide};
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct NeighborhoodType {
pub central_tile_id: usize,
pub cw_anchor_on_central: usize,
pub ccw_anchor_on_central: usize,
pub cw_anchor_on_context: usize,
pub ccw_anchor_on_context: usize,
pub num_ctx_tiles: usize,
pub vt_seq: Vec<OpenVertexType>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SurroundedTile {
pub central_tile_id: usize,
pub is_closed: bool,
pub open_vertex_pos: usize,
pub num_tile_instances: usize,
pub angles: Vec<i8>,
pub edges: Vec<EdgeInfo>,
pub inner_chains: Vec<Vec<EdgeInfo>>,
pub patch_tile_ids: Vec<usize>,
}
impl SurroundedTile {
pub fn to_patch<T: IsRing>(
&self,
match_index: Arc<MatchTypeIndex<T>>,
) -> Option<GrowingPatch<T>> {
let n = self.angles.len();
debug_assert_eq!(self.edges.len(), n, "edges length mismatch");
debug_assert_eq!(self.inner_chains.len(), n, "inner_chains length mismatch");
debug_assert_eq!(
self.patch_tile_ids.len(),
n,
"patch_tile_ids length mismatch"
);
debug_assert_eq!(
self.num_tile_instances,
self.patch_tile_ids.iter().max().map_or(0, |m| m + 1),
"num_tile_instances doesn't match patch_tile_ids"
);
debug_assert!(
self.is_closed || self.open_vertex_pos < n,
"open_vertex_pos {} out of range for boundary len {}",
self.open_vertex_pos,
n
);
GrowingPatch::from_parts(
match_index,
self.angles.clone(),
self.edges.clone(),
self.inner_chains.clone(),
self.patch_tile_ids.clone(),
self.num_tile_instances,
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum NtEntry {
Phase1(NeighborhoodType),
Phase2(SurroundedTile),
}
impl NtEntry {
pub fn central_tile_id(&self) -> usize {
match self {
NtEntry::Phase1(nt) => nt.central_tile_id,
NtEntry::Phase2(st) => st.central_tile_id,
}
}
pub fn vt_seq(&self) -> &[OpenVertexType] {
match self {
NtEntry::Phase1(nt) => &nt.vt_seq,
NtEntry::Phase2(_) => &[],
}
}
pub fn as_phase1(&self) -> Option<&NeighborhoodType> {
match self {
NtEntry::Phase1(nt) => Some(nt),
_ => None,
}
}
pub fn as_phase2(&self) -> Option<&SurroundedTile> {
match self {
NtEntry::Phase2(st) => Some(st),
_ => None,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum NtKind {
Dead,
Undead,
Blessed,
Free,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct NtTransition {
pub src_id: usize,
pub dst_id: usize,
pub side: TransitionSide,
pub tile_id: usize,
pub tile_offset: usize,
}
pub struct NeighborhoodIndex<T: IsRing> {
tileset: Arc<TileSet<T>>,
entries: Vec<NtEntry>,
transitions: Vec<NtTransition>,
}
impl<T: IsRing> NeighborhoodIndex<T> {
pub fn new(tileset: Arc<TileSet<T>>) -> Self {
let match_index = Arc::new(MatchTypeIndex::new(Arc::clone(&tileset)));
let mut state = BfsState::default();
seed_phase(&mut state, &match_index);
bfs_phase(&mut state, &match_index);
let BfsState {
entries,
transitions,
..
} = state;
NeighborhoodIndex {
tileset,
entries,
transitions,
}
}
pub fn entries(&self) -> &[NtEntry] {
&self.entries
}
pub fn num_types(&self) -> usize {
self.entries.len()
}
pub fn num_phase1(&self) -> usize {
self.entries
.iter()
.filter(|e| matches!(e, NtEntry::Phase1(_)))
.count()
}
pub fn num_phase2(&self) -> usize {
self.entries
.iter()
.filter(|e| matches!(e, NtEntry::Phase2(_)))
.count()
}
pub fn transitions(&self) -> &[NtTransition] {
&self.transitions
}
pub fn tileset(&self) -> &Arc<TileSet<T>> {
&self.tileset
}
pub fn classify_all(&self) -> Vec<NtKind> {
let n = self.entries.len();
let mut is_closed_terminal = vec![false; n];
for (i, entry) in self.entries.iter().enumerate() {
if let NtEntry::Phase2(st) = entry
&& st.is_closed
{
is_closed_terminal[i] = true;
}
}
let mut succ_count = vec![0usize; n];
let mut preds: Vec<Vec<usize>> = vec![vec![]; n];
let mut has_outgoing = vec![false; n];
for t in &self.transitions {
let src_idx = t.src_id - 1;
let dst_idx = t.dst_id - 1;
has_outgoing[src_idx] = true;
succ_count[src_idx] += 1;
preds[dst_idx].push(src_idx);
}
let mut cursed = vec![false; n];
let mut remaining = succ_count.clone();
let mut queue: VecDeque<usize> = VecDeque::new();
for i in 0..n {
if !has_outgoing[i] && !is_closed_terminal[i] {
cursed[i] = true;
queue.push_back(i);
}
}
while let Some(v) = queue.pop_front() {
for &p in &preds[v] {
if cursed[p] {
continue;
}
remaining[p] -= 1;
if remaining[p] == 0 && succ_count[p] > 0 {
cursed[p] = true;
queue.push_back(p);
}
}
}
let mut blessed = vec![false; n];
let mut remaining = succ_count.clone();
let mut queue: VecDeque<usize> = VecDeque::new();
for i in 0..n {
if is_closed_terminal[i] {
blessed[i] = true;
queue.push_back(i);
}
}
while let Some(v) = queue.pop_front() {
for &p in &preds[v] {
if blessed[p] {
continue;
}
remaining[p] -= 1;
if remaining[p] == 0 {
blessed[p] = true;
queue.push_back(p);
}
}
}
(0..n)
.map(|i| {
if cursed[i] && !has_outgoing[i] {
NtKind::Dead
} else if cursed[i] {
NtKind::Undead
} else if blessed[i] {
NtKind::Blessed
} else {
NtKind::Free
}
})
.collect()
}
pub fn validate(&self) -> Vec<usize> {
let match_index = Arc::new(MatchTypeIndex::new(Arc::clone(&self.tileset)));
let num_tiles = self.tileset.num_tiles();
self.entries
.iter()
.enumerate()
.filter_map(|(idx, entry)| {
let ok = match entry {
NtEntry::Phase1(nt) => nt_is_valid(nt, &match_index),
NtEntry::Phase2(st) => {
st.central_tile_id < num_tiles
&& !st.angles.is_empty()
&& st.to_patch(Arc::clone(&match_index)).is_some()
}
};
if ok { None } else { Some(idx + 1) }
})
.collect()
}
pub fn to_collection(&self, ring: impl Into<String>) -> Collection {
let tile_angles = self
.tileset
.rats()
.iter()
.map(|r| r.seq().to_vec())
.collect();
Collection {
ring: ring.into(),
tile_angles,
entries: self.entries.clone(),
transitions: self.transitions.clone(),
kinds: self.classify_all(),
}
}
pub fn from_collection(tileset: Arc<TileSet<T>>, c: Collection) -> Result<Self, String> {
if c.kinds.len() != c.entries.len() {
return Err(format!(
"kinds.len()={} doesn't match entries.len()={}",
c.kinds.len(),
c.entries.len()
));
}
for t in &c.transitions {
if t.src_id < 1 || t.src_id > c.entries.len() {
return Err(format!(
"TRANS src_id {} out of range (have {} entries)",
t.src_id,
c.entries.len()
));
}
if t.dst_id < 1 || t.dst_id > c.entries.len() {
return Err(format!(
"TRANS dst_id {} out of range (have {} entries)",
t.dst_id,
c.entries.len()
));
}
}
let idx = NeighborhoodIndex {
tileset,
entries: c.entries,
transitions: c.transitions,
};
let computed_kinds = idx.classify_all();
for (i, (recorded, computed)) in c.kinds.iter().zip(computed_kinds.iter()).enumerate() {
if recorded != computed {
return Err(format!(
"entry id {} kind mismatch: file says {:?}, classify_all derives {:?}",
i + 1,
recorded,
computed
));
}
}
Ok(idx)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Collection {
pub ring: String,
pub tile_angles: Vec<Vec<i8>>,
pub entries: Vec<NtEntry>,
pub transitions: Vec<NtTransition>,
pub kinds: Vec<NtKind>,
}
#[derive(Default)]
struct BfsState {
entries: Vec<NtEntry>,
transitions: Vec<NtTransition>,
seen: FxHashMap<NtEntry, usize>,
queue: VecDeque<usize>,
}
fn seed_phase<T: IsRing>(state: &mut BfsState, match_index: &Arc<MatchTypeIndex<T>>) {
for id in 1..=match_index.num_types() {
let mt = match_index.get(id);
let seed = PatchSeed::new(Arc::clone(match_index.tileset()), mt.a.tile_id);
let pm = PatchMatch::new(
EdgeRange::new(mt.a.range.start_offset, mt.len()),
Segment::new(
mt.b.tile_id,
EdgeRange::new(mt.b.range.start_offset, mt.len()),
),
);
let mut patch = match seed.grow(&pm) {
Some(p) => p,
None => continue,
};
patch.normalize();
let patch_n = patch.boundary_len();
let all_juncs: Vec<(usize, OpenVertexType)> = (0..patch_n)
.filter_map(|i| patch.junction_vertex_type_at(i).map(|vt| (i, vt)))
.collect();
for third_pm in patch.get_all_matches() {
let mut trial = patch.clone();
if !trial.add_tile(&third_pm) {
continue;
}
if let Some(nt) = build_seed_nt(&third_pm, patch_n, &all_juncs, match_index) {
let entry = NtEntry::Phase1(nt);
if state.seen.contains_key(&entry) {
continue;
}
let NtEntry::Phase1(ref nt_ref) = entry else {
unreachable!()
};
if !nt_is_valid(nt_ref, match_index) {
continue;
}
let id = state.entries.len() + 1;
state.seen.insert(entry.clone(), id);
state.entries.push(entry);
state.queue.push_back(id);
}
}
}
}
fn build_seed_nt<T: IsRing>(
third_pm: &PatchMatch,
patch_n: usize,
all_juncs: &[(usize, OpenVertexType)],
match_index: &Arc<MatchTypeIndex<T>>,
) -> Option<NeighborhoodType> {
let central_tile_id = third_pm.b.tile_id;
let central_n = match_index.tileset().rat(central_tile_id).seq().len();
let cw_anchor_on_central = third_pm.b.range.start_offset;
let ccw_anchor_on_central = (cw_anchor_on_central + central_n - third_pm.len()) % central_n;
let filtered: Vec<&(usize, OpenVertexType)> = all_juncs
.iter()
.filter(|(pos, _)| {
(pos + patch_n - third_pm.a_range.start_offset) % patch_n <= third_pm.len()
})
.collect();
if filtered.is_empty() {
return None;
}
let first_junc = filtered[0].0;
let last_junc = filtered[filtered.len() - 1].0;
let vt_seq: Vec<OpenVertexType> = filtered.iter().map(|(_, vt)| vt.clone()).collect();
let cw_anchor_on_context = (first_junc + patch_n - third_pm.a_range.start_offset) % patch_n;
let ccw_anchor_pos = (third_pm.a_range.start_offset + third_pm.len()) % patch_n;
let ccw_anchor_on_context = (ccw_anchor_pos + patch_n - last_junc) % patch_n;
Some(NeighborhoodType {
central_tile_id,
cw_anchor_on_central,
ccw_anchor_on_central,
cw_anchor_on_context,
ccw_anchor_on_context,
num_ctx_tiles: 2,
vt_seq,
})
}
fn bfs_phase<T: IsRing>(state: &mut BfsState, match_index: &Arc<MatchTypeIndex<T>>) {
while let Some(src_id) = state.queue.pop_front() {
let entry = state.entries[src_id - 1].clone();
let outcomes = match &entry {
NtEntry::Phase1(nt) => explore_phase1(nt, match_index),
NtEntry::Phase2(st) => explore_phase2(st, match_index),
};
for outcome in outcomes {
let new_entry = match outcome.kind {
OutcomeKind::Phase1(nt) => NtEntry::Phase1(nt),
OutcomeKind::Phase2(st) => NtEntry::Phase2(st),
};
let needs_bfs = matches!(
&new_entry,
NtEntry::Phase1(_)
| NtEntry::Phase2(SurroundedTile {
is_closed: false,
..
})
);
let dst_id = if let Some(&id) = state.seen.get(&new_entry) {
id
} else {
let id = state.entries.len() + 1;
state.seen.insert(new_entry.clone(), id);
state.entries.push(new_entry);
if needs_bfs {
state.queue.push_back(id);
}
id
};
state.transitions.push(NtTransition {
src_id,
dst_id,
side: outcome.side,
tile_id: outcome.petal_pm.b.tile_id,
tile_offset: outcome.petal_pm.b.range.start_offset,
});
}
}
}
struct ExploreOutcome {
side: TransitionSide,
petal_pm: PatchMatch,
kind: OutcomeKind,
}
enum OutcomeKind {
Phase1(NeighborhoodType),
Phase2(SurroundedTile),
}
struct AttachedContext<T: IsRing> {
aug: GrowingPatch<T>,
central_ptid: usize,
frontier_pos_on_aug: usize,
frontier_is_junction_in_ctx: bool,
last_covered_ctx_edge: EdgeInfo,
gap_start: usize,
aug_n: usize,
}
fn build_attached_context<T: IsRing>(
nt: &NeighborhoodType,
match_index: &Arc<MatchTypeIndex<T>>,
) -> Option<AttachedContext<T>> {
let (ctx, junc_positions) =
GrowingPatch::construct_witness_from_vt_sequence(&nt.vt_seq, Arc::clone(match_index))?;
let ctx_n = ctx.boundary_len();
if ctx_n == 0 {
return None;
}
let first_junc = junc_positions[0];
let anchor_pos = (first_junc + ctx_n - nt.cw_anchor_on_context) % ctx_n;
let tileset = match_index.tileset();
let central_seq = tileset.rat(nt.central_tile_id).seq();
let central_n = central_seq.len();
let match_len = crate::stringmatch::forward_match_length(
ctx.angles(),
anchor_pos,
central_seq,
nt.cw_anchor_on_central,
);
if match_len == 0 || match_len >= central_n {
return None;
}
let frontier_on_ctx = (anchor_pos + match_len) % ctx_n;
let frontier_is_junction_in_ctx = ctx.is_junction(frontier_on_ctx);
let last_covered_ctx_edge = ctx.edges()[(anchor_pos + match_len - 1) % ctx_n];
let central_pm = PatchMatch::new(
EdgeRange::new(anchor_pos, match_len),
Segment::new(
nt.central_tile_id,
EdgeRange::new(nt.cw_anchor_on_central, match_len),
),
);
let mut aug = ctx.clone();
if !aug.add_tile(¢ral_pm) {
return None;
}
let aug_n = aug.boundary_len();
let gap_start = ctx_n - match_len;
let central_ptid = aug.patch_tile_ids()[gap_start];
let mut frontier_pos_on_aug = 0usize;
let mut found = false;
for _ in 0..aug_n {
if aug.is_junction(frontier_pos_on_aug) {
found = true;
break;
}
frontier_pos_on_aug = (frontier_pos_on_aug + 1) % aug_n;
}
if !found {
return None;
}
Some(AttachedContext {
aug,
central_ptid,
frontier_pos_on_aug,
frontier_is_junction_in_ctx,
last_covered_ctx_edge,
gap_start,
aug_n,
})
}
fn explore_phase1<T: IsRing>(
nt: &NeighborhoodType,
match_index: &Arc<MatchTypeIndex<T>>,
) -> Vec<ExploreOutcome> {
let ac = match build_attached_context(nt, match_index) {
Some(a) => a,
None => return Vec::new(),
};
let mut results = Vec::new();
let target_edge = ac.frontier_pos_on_aug;
let ccw_candidates = GrowingPatch::compute_candidates_covering_position(
ac.aug.match_index(),
ac.aug.angles(),
ac.aug.edges(),
ac.frontier_pos_on_aug,
);
for petal_pm in ccw_candidates {
if !match_absorbs_edge(&petal_pm, target_edge, ac.aug_n) {
continue;
}
if let Some(outcome) = try_step_ccw(nt, &ac, petal_pm, match_index) {
results.push(outcome);
}
}
results
}
fn explore_phase2<T: IsRing>(
st: &SurroundedTile,
match_index: &Arc<MatchTypeIndex<T>>,
) -> Vec<ExploreOutcome> {
debug_assert!(
!st.is_closed,
"explore_phase2 should not run on closed SurroundedTiles"
);
let corona = match st.to_patch(Arc::clone(match_index)) {
Some(p) => p,
None => return Vec::new(),
};
let corona_n = corona.boundary_len();
if corona_n == 0 {
return Vec::new();
}
let open_vertex_pos = st.open_vertex_pos;
let ccw_edge = open_vertex_pos;
let cw_edge = (open_vertex_pos + corona_n - 1) % corona_n;
let candidates = GrowingPatch::compute_candidates_covering_position(
corona.match_index(),
corona.angles(),
corona.edges(),
ccw_edge,
);
let mut results = Vec::new();
for petal_pm in candidates {
if !match_absorbs_edge(&petal_pm, ccw_edge, corona_n) {
continue;
}
let mut trial = corona.clone();
if !trial.add_tile(&petal_pm) {
continue;
}
let is_closed = match_absorbs_edge(&petal_pm, cw_edge, corona_n);
let open_vertex_trial_pos = if is_closed {
None
} else {
let ccw_on_corona = (petal_pm.a_range.start_offset + petal_pm.len()) % corona_n;
Some((open_vertex_pos + corona_n - ccw_on_corona) % corona_n)
};
let new_st = build_surrounded_tile_from_trial(
trial,
st.central_tile_id,
is_closed,
open_vertex_trial_pos,
);
results.push(ExploreOutcome {
side: TransitionSide::Ccw,
petal_pm,
kind: OutcomeKind::Phase2(new_st),
});
}
results
}
fn build_surrounded_tile_from_trial<T: IsRing>(
mut trial: GrowingPatch<T>,
central_tile_id: usize,
is_closed: bool,
open_vertex_trial_pos: Option<usize>,
) -> SurroundedTile {
let n = trial.boundary_len();
let rot = trial.normalize();
let open_vertex_pos = match open_vertex_trial_pos {
Some(p) if n > 0 => (p + n - rot) % n,
_ => 0,
};
let patch_tile_ids = trial.patch_tile_ids().to_vec();
let num_tile_instances = patch_tile_ids.iter().max().map_or(0, |m| m + 1);
SurroundedTile {
central_tile_id,
is_closed,
open_vertex_pos,
num_tile_instances,
angles: trial.angles().to_vec(),
edges: trial.edges().to_vec(),
inner_chains: trial.inner_chains().to_vec(),
patch_tile_ids,
}
}
fn match_absorbs_edge(pm: &PatchMatch, target_edge: usize, n: usize) -> bool {
if pm.is_empty() {
return false;
}
let end_inclusive = (pm.a_range.start_offset + pm.len() - 1) % n;
if pm.a_range.start_offset <= end_inclusive {
target_edge >= pm.a_range.start_offset && target_edge <= end_inclusive
} else {
target_edge >= pm.a_range.start_offset || target_edge <= end_inclusive
}
}
fn try_step_ccw<T: IsRing>(
nt: &NeighborhoodType,
ac: &AttachedContext<T>,
petal_pm: PatchMatch,
match_index: &Arc<MatchTypeIndex<T>>,
) -> Option<ExploreOutcome> {
let mut trial = ac.aug.clone();
if !trial.add_tile(&petal_pm) {
return None;
}
if !trial.patch_tile_ids().contains(&ac.central_ptid) {
let cw_outer_edge = (ac.gap_start + ac.aug_n - 1) % ac.aug_n;
let is_closed = match_absorbs_edge(&petal_pm, cw_outer_edge, ac.aug_n);
let canonical_open_trial_pos = if is_closed {
None
} else {
let ccw_pos_on_aug = (petal_pm.a_range.start_offset + petal_pm.len()) % ac.aug_n;
Some((ac.gap_start + ac.aug_n - ccw_pos_on_aug) % ac.aug_n)
};
let st = build_surrounded_tile_from_trial(
trial,
nt.central_tile_id,
is_closed,
canonical_open_trial_pos,
);
return Some(ExploreOutcome {
side: TransitionSide::Ccw,
petal_pm,
kind: OutcomeKind::Phase2(st),
});
}
let petal_n = match_index.tileset().rat(petal_pm.b.tile_id).seq().len();
let i_anchor = (ac.aug_n - petal_pm.a_range.start_offset) % ac.aug_n;
let petal_edge = EdgeInfo {
tile_id: petal_pm.b.tile_id,
tile_offset: (petal_pm.b.range.start_offset + petal_n - i_anchor) % petal_n,
};
let mut new_vt_seq = nt.vt_seq.clone();
if ac.frontier_is_junction_in_ctx {
let last = new_vt_seq.last_mut().expect("vt_seq is non-empty");
let old_ccw = last.ccw;
last.inner.push(old_ccw);
last.ccw = petal_edge;
} else {
new_vt_seq.push(OpenVertexType {
cw: ac.last_covered_ctx_edge,
inner: vec![],
ccw: petal_edge,
});
}
let new_nt = try_construct_nt_from_cw(
nt.central_tile_id,
nt.cw_anchor_on_central,
nt.cw_anchor_on_context,
nt.num_ctx_tiles + 1,
new_vt_seq,
match_index,
)?;
Some(ExploreOutcome {
side: TransitionSide::Ccw,
petal_pm,
kind: OutcomeKind::Phase1(new_nt),
})
}
fn canonicalize_vt_seq_on_ctx<T: IsRing>(
ctx: &GrowingPatch<T>,
cw_anchor_pos: usize,
ccw_anchor_pos: usize,
ctx_n: usize,
) -> Option<(Vec<OpenVertexType>, usize, usize)> {
let match_len = (ccw_anchor_pos + ctx_n - cw_anchor_pos) % ctx_n;
let mut juncs: Vec<(usize, OpenVertexType)> = Vec::new();
for off in 0..=match_len {
let pos = (cw_anchor_pos + off) % ctx_n;
if let Some(vt) = ctx.junction_vertex_type_at(pos) {
juncs.push((pos, vt));
}
}
if juncs.is_empty() {
return None;
}
let first_junc = juncs[0].0;
let last_junc = juncs[juncs.len() - 1].0;
let cw_on_ctx = (first_junc + ctx_n - cw_anchor_pos) % ctx_n;
let ccw_on_ctx = (ccw_anchor_pos + ctx_n - last_junc) % ctx_n;
let vt_seq = juncs.into_iter().map(|(_, vt)| vt).collect();
Some((vt_seq, cw_on_ctx, ccw_on_ctx))
}
fn try_construct_nt_from_cw<T: IsRing>(
central_tile_id: usize,
cw_anchor_on_central: usize,
cw_anchor_on_context: usize,
num_ctx_tiles: usize,
vt_seq: Vec<OpenVertexType>,
match_index: &Arc<MatchTypeIndex<T>>,
) -> Option<NeighborhoodType> {
let (mut ctx, jp) =
GrowingPatch::construct_witness_from_vt_sequence(&vt_seq, Arc::clone(match_index))?;
let ctx_n = ctx.boundary_len();
if ctx_n == 0 || jp.is_empty() {
return None;
}
let first_junc = jp[0];
let cw_anchor_pos = (first_junc + ctx_n - cw_anchor_on_context) % ctx_n;
let tileset = match_index.tileset();
let central_seq = tileset.rat(central_tile_id).seq();
let central_n = central_seq.len();
let match_len = crate::stringmatch::forward_match_length(
ctx.angles(),
cw_anchor_pos,
central_seq,
cw_anchor_on_central,
);
if match_len == 0 || match_len >= central_n {
return None;
}
let ccw_anchor_on_central = (cw_anchor_on_central + central_n - match_len) % central_n;
let ccw_anchor_pos = (cw_anchor_pos + match_len) % ctx_n;
let (canon_vt_seq, canon_cw_on_ctx, canon_ccw_on_ctx) =
canonicalize_vt_seq_on_ctx(&ctx, cw_anchor_pos, ccw_anchor_pos, ctx_n)?;
let pm = PatchMatch::new(
EdgeRange::new(cw_anchor_pos, match_len),
Segment::new(
central_tile_id,
EdgeRange::new(cw_anchor_on_central, match_len),
),
);
if !ctx.add_tile(&pm) {
return None;
}
Some(NeighborhoodType {
central_tile_id,
cw_anchor_on_central,
ccw_anchor_on_central,
cw_anchor_on_context: canon_cw_on_ctx,
ccw_anchor_on_context: canon_ccw_on_ctx,
num_ctx_tiles,
vt_seq: canon_vt_seq,
})
}
fn nt_is_valid<T: IsRing>(nt: &NeighborhoodType, match_index: &Arc<MatchTypeIndex<T>>) -> bool {
let Some(reconstructed) = try_construct_nt_from_cw(
nt.central_tile_id,
nt.cw_anchor_on_central,
nt.cw_anchor_on_context,
nt.num_ctx_tiles,
nt.vt_seq.clone(),
match_index,
) else {
return false;
};
reconstructed.ccw_anchor_on_central == nt.ccw_anchor_on_central
&& reconstructed.ccw_anchor_on_context == nt.ccw_anchor_on_context
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::matchtypes::MatchTypeIndex;
use crate::cyclotomic::ZZ12;
use crate::geom::patch::GrowingPatch;
use crate::geom::tileset;
use std::sync::OnceLock;
fn square_tileset() -> Arc<TileSet<ZZ12>> {
tileset::square::<ZZ12>()
}
fn hex_tileset() -> Arc<TileSet<ZZ12>> {
tileset::hex::<ZZ12>()
}
fn square_idx() -> &'static NeighborhoodIndex<ZZ12> {
static SQUARE_IDX: OnceLock<NeighborhoodIndex<ZZ12>> = OnceLock::new();
SQUARE_IDX.get_or_init(|| NeighborhoodIndex::new(square_tileset()))
}
fn hex_idx() -> &'static NeighborhoodIndex<ZZ12> {
static HEX_IDX: OnceLock<NeighborhoodIndex<ZZ12>> = OnceLock::new();
HEX_IDX.get_or_init(|| NeighborhoodIndex::new(hex_tileset()))
}
fn spectre_idx() -> &'static NeighborhoodIndex<ZZ12> {
static SPECTRE_IDX: OnceLock<NeighborhoodIndex<ZZ12>> = OnceLock::new();
SPECTRE_IDX.get_or_init(|| NeighborhoodIndex::new(spectre_tileset()))
}
#[allow(clippy::too_many_arguments)]
fn assert_counts<T: IsRing>(
idx: &NeighborhoodIndex<T>,
expected_types: usize,
expected_transitions: usize,
expected_closed_phase2: usize,
expected_dead: usize,
expected_undead: usize,
expected_blessed: usize,
expected_free: usize,
) {
let kinds = idx.classify_all();
let dead = kinds.iter().filter(|k| **k == NtKind::Dead).count();
let undead = kinds.iter().filter(|k| **k == NtKind::Undead).count();
let blessed = kinds.iter().filter(|k| **k == NtKind::Blessed).count();
let free = kinds.iter().filter(|k| **k == NtKind::Free).count();
let closed_phase2 = idx
.entries()
.iter()
.filter(|e| matches!(e, NtEntry::Phase2(st) if st.is_closed))
.count();
assert_eq!(idx.num_types(), expected_types, "num_types");
assert_eq!(idx.transitions().len(), expected_transitions, "transitions");
assert_eq!(closed_phase2, expected_closed_phase2, "closed_phase2");
assert_eq!(dead, expected_dead, "dead");
assert_eq!(undead, expected_undead, "undead");
assert_eq!(blessed, expected_blessed, "blessed");
assert_eq!(free, expected_free, "free");
assert_eq!(dead + undead + blessed + free, expected_types, "kinds sum");
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: square idx build ~21s in release, much slower in debug"
)]
fn square_counts() {
assert_counts(square_idx(), 240256, 698880, 65536, 0, 0, 240256, 0);
}
#[test]
fn hex_counts() {
assert_counts(hex_idx(), 102600, 335664, 46656, 0, 0, 102600, 0);
}
#[test]
fn spectre_counts() {
assert_counts(spectre_idx(), 9976, 11171, 251, 5561, 1472, 1029, 1914);
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: iterates square_idx (~21s build in release)"
)]
fn seeds_are_well_formed() {
for idx in [
square_idx() as &NeighborhoodIndex<ZZ12>,
hex_idx(),
spectre_idx(),
] {
assert!(idx.num_types() > 0, "expected non-empty seed collection");
let num_tiles = idx.tileset().num_tiles();
for entry in idx.entries() {
assert!(
entry.central_tile_id() < num_tiles,
"central_tile_id {} out of range (have {} tiles)",
entry.central_tile_id(),
num_tiles
);
match entry {
NtEntry::Phase1(nt) => assert!(
!nt.vt_seq.is_empty(),
"phase-1 seeds must have non-empty vt_seq"
),
NtEntry::Phase2(st) => assert!(
!st.angles.is_empty(),
"phase-2 entries must have non-empty boundary"
),
}
}
}
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: iterates square_idx (~21s build in release)"
)]
fn entries_have_no_partial_eq_duplicates() {
for (name, idx) in [
("square", square_idx() as &NeighborhoodIndex<ZZ12>),
("hex", hex_idx()),
("spectre", spectre_idx()),
] {
let mut keys: std::collections::HashSet<&NtEntry> = std::collections::HashSet::new();
for (i, entry) in idx.entries().iter().enumerate() {
assert!(
keys.insert(entry),
"{}: duplicate entry at id {} (vt_seq={:?})",
name,
i + 1,
entry.vt_seq()
);
}
}
}
fn assert_roundtrip<T: IsRing>(idx: &NeighborhoodIndex<T>) {
let collection = idx.to_collection("ZZ12");
let json = serde_json::to_string(&collection).expect("serialize");
let restored: Collection = serde_json::from_str(&json).expect("deserialize");
let parsed = NeighborhoodIndex::from_collection(Arc::clone(idx.tileset()), restored)
.expect("from_collection");
assert_eq!(
parsed.num_types(),
idx.num_types(),
"entries count mismatch"
);
for (i, (a, b)) in idx
.entries()
.iter()
.zip(parsed.entries().iter())
.enumerate()
{
assert_eq!(a, b, "entry {} mismatch", i);
}
assert_eq!(
parsed.transitions().len(),
idx.transitions().len(),
"transitions count mismatch"
);
for (i, (a, b)) in idx
.transitions()
.iter()
.zip(parsed.transitions().iter())
.enumerate()
{
assert_eq!(a.src_id, b.src_id, "transition {} src", i);
assert_eq!(a.dst_id, b.dst_id, "transition {} dst", i);
assert_eq!(a.tile_id, b.tile_id, "transition {} tile_id", i);
assert_eq!(a.tile_offset, b.tile_offset, "transition {} tile_offset", i);
}
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: square idx build ~21s in release, much slower in debug"
)]
fn square_roundtrip_collection() {
let idx = square_idx();
assert_roundtrip(idx);
}
#[test]
fn hex_roundtrip_collection() {
let idx = hex_idx();
assert_roundtrip(idx);
}
#[test]
fn match_absorbs_edge_unit() {
fn brute(start: usize, len: usize, target: usize, n: usize) -> bool {
if len == 0 || n == 0 {
return false;
}
(0..len).any(|i| (start + i) % n == target)
}
for n in [1usize, 2, 5, 13, 26] {
for start in 0..n {
for len in 0..=n {
for target in 0..n {
let pm = PatchMatch::new(
EdgeRange::new(start, len),
Segment::new(0, EdgeRange::new(0, len)),
);
let got = match_absorbs_edge(&pm, target, n);
let want = brute(start, len, target, n);
assert_eq!(got, want, "n={n} start={start} len={len} target={target}");
}
}
}
}
let pm = PatchMatch::new(EdgeRange::new(25, 1), Segment::new(0, EdgeRange::new(0, 1)));
assert!(match_absorbs_edge(&pm, 25, 26));
assert!(!match_absorbs_edge(&pm, 0, 26));
let pm = PatchMatch::new(EdgeRange::new(25, 2), Segment::new(0, EdgeRange::new(0, 2)));
assert!(match_absorbs_edge(&pm, 25, 26));
assert!(match_absorbs_edge(&pm, 0, 26));
assert!(!match_absorbs_edge(&pm, 1, 26));
}
#[test]
fn from_collection_rejects_kind_mismatch() {
let idx = square_idx();
let mut collection = idx.to_collection("ZZ12");
let original = collection.kinds[0];
assert_eq!(original, NtKind::Blessed);
collection.kinds[0] = NtKind::Dead;
let err = match NeighborhoodIndex::from_collection(Arc::clone(idx.tileset()), collection) {
Ok(_) => panic!("from_collection should reject kind mismatch"),
Err(e) => e,
};
assert!(
err.contains("kind mismatch"),
"expected kind-mismatch error, got: {err}"
);
}
fn spectre_tileset() -> Arc<TileSet<ZZ12>> {
tileset::spectre::<ZZ12>()
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: validate() reconstructs all square phase-2 patches (~28s in release)"
)]
fn validate_returns_empty_for_all_tilesets() {
for (name, idx) in [
("square", square_idx() as &NeighborhoodIndex<ZZ12>),
("hex", hex_idx()),
("spectre", spectre_idx()),
] {
let bad = idx.validate();
assert!(
bad.is_empty(),
"{} has {}/{} invalid entries (first few: {:?})",
name,
bad.len(),
idx.num_types(),
&bad[..bad.len().min(5)]
);
}
}
fn aug_boundary_fingerprint<T: IsRing>(
nt: &NeighborhoodType,
mi: &Arc<MatchTypeIndex<T>>,
) -> Option<(usize, usize, Vec<i8>)> {
let (mut ctx, jp) =
GrowingPatch::construct_witness_from_vt_sequence(&nt.vt_seq, Arc::clone(mi))?;
let ctx_n = ctx.boundary_len();
let first_junc = *jp.first()?;
let cw_anchor_pos = (first_junc + ctx_n - nt.cw_anchor_on_context) % ctx_n;
let pm = PatchMatch::new(
EdgeRange::new(
cw_anchor_pos,
(nt.cw_anchor_on_central + mi.tileset().rat(nt.central_tile_id).seq().len()
- nt.ccw_anchor_on_central)
% mi.tileset().rat(nt.central_tile_id).seq().len(),
),
Segment::new(
nt.central_tile_id,
EdgeRange::new(
nt.cw_anchor_on_central,
(nt.cw_anchor_on_central + mi.tileset().rat(nt.central_tile_id).seq().len()
- nt.ccw_anchor_on_central)
% mi.tileset().rat(nt.central_tile_id).seq().len(),
),
),
);
if !ctx.add_tile(&pm) {
return None;
}
let angles = ctx.angles().to_vec();
let rot = crate::geom::rat::lex_min_rot(&angles);
let mut canon = angles;
canon.rotate_left(rot);
Some((nt.central_tile_id, nt.cw_anchor_on_central, canon))
}
fn extended_match_window_fingerprint<T: IsRing>(
nt: &NeighborhoodType,
mi: &Arc<MatchTypeIndex<T>>,
) -> Option<(usize, usize, Vec<i8>, usize)> {
let ac = build_attached_context(nt, mi)?;
let aug_n = ac.aug_n;
let gap_start = ac.gap_start;
let aug = &ac.aug;
if gap_start == 0 {
return None;
}
let mut j_cw: Option<usize> = None;
let mut pos = (gap_start + aug_n - 1) % aug_n;
for _ in 0..aug_n {
if pos == gap_start {
break;
}
if aug.is_junction(pos) {
j_cw = Some(pos);
break;
}
pos = (pos + aug_n - 1) % aug_n;
}
let j_cw = j_cw?;
let mut j_ccw: Option<usize> = None;
let mut pos = 1usize % aug_n;
for _ in 0..aug_n {
if pos == 0 {
break;
}
if aug.is_junction(pos) {
j_ccw = Some(pos);
break;
}
pos = (pos + 1) % aug_n;
}
let j_ccw = j_ccw?;
let angles = aug.angles();
let mut window: Vec<i8> = Vec::new();
window.extend_from_slice(&angles[j_cw..=gap_start]);
window.extend_from_slice(&angles[gap_start + 1..aug_n]);
window.extend_from_slice(&angles[0..=j_ccw]);
let cw_anchor_in_window = gap_start - j_cw;
Some((
nt.central_tile_id,
nt.cw_anchor_on_central,
window,
cw_anchor_in_window,
))
}
type CanonicalClassFp = (usize, usize, Vec<i8>, usize);
fn canonical_class_fingerprint<T: IsRing>(
nt: &NeighborhoodType,
mi: &Arc<MatchTypeIndex<T>>,
) -> Option<CanonicalClassFp> {
let ac = build_attached_context(nt, mi)?;
let aug_n = ac.aug_n;
let angles = ac.aug.angles().to_vec();
let rot = crate::geom::rat::lex_min_rot(&angles);
let mut canon = angles;
canon.rotate_left(rot);
let cw_anchor_in_canon = (ac.gap_start + aug_n - rot) % aug_n;
Some((
nt.central_tile_id,
nt.cw_anchor_on_central,
canon,
cw_anchor_in_canon,
))
}
fn count_canonical_class_kind_violations<T: IsRing>(
idx: &NeighborhoodIndex<T>,
) -> (usize, Option<String>, usize) {
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let kinds = idx.classify_all();
let mut fp_to_kind: FxHashMap<CanonicalClassFp, (NtKind, usize)> = FxHashMap::default();
let mut violations = 0usize;
let mut first_violation: Option<String> = None;
for (i, entry) in idx.entries().iter().enumerate() {
let Some(nt) = entry.as_phase1() else {
continue;
};
let Some(fp) = canonical_class_fingerprint(nt, &mi) else {
continue;
};
let kind = kinds[i];
if let Some(&(prev_kind, prev_id)) = fp_to_kind.get(&fp) {
if kind != prev_kind {
violations += 1;
if first_violation.is_none() {
first_violation = Some(format!(
"entry id {} kind={:?} shares canonical class with id {} kind={:?}",
i + 1,
kind,
prev_id,
prev_kind
));
}
}
} else {
fp_to_kind.insert(fp, (kind, i + 1));
}
}
(violations, first_violation, fp_to_kind.len())
}
#[test]
fn hex_kind_constant_per_canonical_class() {
let (violations, msg, _) = count_canonical_class_kind_violations(hex_idx());
assert_eq!(violations, 0, "{:?}", msg);
}
#[test]
fn spectre_kind_constant_per_canonical_class() {
let (violations, msg, _) = count_canonical_class_kind_violations(spectre_idx());
assert_eq!(violations, 0, "{:?}", msg);
}
#[test]
#[ignore]
fn square_kind_constant_per_canonical_class() {
let (violations, msg, _) = count_canonical_class_kind_violations(square_idx());
assert_eq!(violations, 0, "{:?}", msg);
}
#[test]
#[ignore]
fn diagnose_extended_window_stats() {
for (name, idx) in [
("square", square_idx() as &NeighborhoodIndex<ZZ12>),
("hex", hex_idx()),
("spectre", spectre_idx()),
] {
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let kinds = idx.classify_all();
type P1Key = (usize, usize, Vec<i8>, usize);
type P2OpenKey = (usize, Vec<i8>, usize);
type P2ClosedKey = (usize, Vec<i8>);
let mut p1: FxHashMap<P1Key, Vec<NtKind>> = FxHashMap::default();
let mut p2_open: FxHashMap<P2OpenKey, Vec<NtKind>> = FxHashMap::default();
let mut p2_closed: FxHashMap<P2ClosedKey, Vec<NtKind>> = FxHashMap::default();
for (i, entry) in idx.entries().iter().enumerate() {
let kind = kinds[i];
match entry {
NtEntry::Phase1(nt) => {
let Some(fp) = extended_match_window_fingerprint(nt, &mi) else {
continue;
};
p1.entry(fp).or_default().push(kind);
}
NtEntry::Phase2(st) => {
let angles = st.angles.clone();
let n = angles.len();
let rot = crate::geom::rat::lex_min_rot(&angles);
let mut canon = angles;
canon.rotate_left(rot);
if st.is_closed {
p2_closed
.entry((st.central_tile_id, canon))
.or_default()
.push(kind);
} else {
let open_in_canon = if n > 0 {
(st.open_vertex_pos + n - rot) % n
} else {
0
};
p2_open
.entry((st.central_tile_id, canon, open_in_canon))
.or_default()
.push(kind);
}
}
}
}
fn tally<K>(buckets: &FxHashMap<K, Vec<NtKind>>) -> (usize, usize, [usize; 4]) {
let mut by_kind = [0usize; 4]; let mut mixed = 0usize;
for kinds in buckets.values() {
let first = kinds[0];
if kinds.iter().any(|k| *k != first) {
mixed += 1;
}
let idx = match first {
NtKind::Dead => 0,
NtKind::Undead => 1,
NtKind::Blessed => 2,
NtKind::Free => 3,
};
by_kind[idx] += 1;
}
(buckets.len(), mixed, by_kind)
}
let (n_p1, mixed_p1, k_p1) = tally(&p1);
let (n_p2o, mixed_p2o, k_p2o) = tally(&p2_open);
let (n_p2c, mixed_p2c, k_p2c) = tally(&p2_closed);
eprintln!("=== {name} ===");
eprintln!(" total entries: {}", idx.num_types());
eprintln!(
" phase-1 distinct aug-boundaries: {} (mixed-kind classes: {})",
n_p1, mixed_p1
);
eprintln!(
" dead={} undead={} blessed={} free={}",
k_p1[0], k_p1[1], k_p1[2], k_p1[3]
);
eprintln!(
" phase-2 open distinct boundaries: {} (mixed-kind classes: {})",
n_p2o, mixed_p2o
);
eprintln!(
" dead={} undead={} blessed={} free={}",
k_p2o[0], k_p2o[1], k_p2o[2], k_p2o[3]
);
eprintln!(
" phase-2 closed distinct boundaries: {} (mixed-kind classes: {})",
n_p2c, mixed_p2c
);
eprintln!(
" dead={} undead={} blessed={} free={}",
k_p2c[0], k_p2c[1], k_p2c[2], k_p2c[3]
);
}
}
#[test]
#[ignore]
fn diagnose_canonical_class_compression() {
for (name, idx) in [
("square", square_idx() as &NeighborhoodIndex<ZZ12>),
("hex", hex_idx()),
("spectre", spectre_idx()),
] {
let (violations, _, n_classes) = count_canonical_class_kind_violations(idx);
let n = idx.num_types();
eprintln!(
"{name}: entries={} canonical_classes={} ratio={:.2}x violations={}",
n,
n_classes,
n as f64 / n_classes as f64,
violations
);
}
}
fn assert_brute_completeness_and_transition_correctness<T>(idx: &NeighborhoodIndex<T>)
where
T: IsRing,
{
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let mut fp_set: FxHashMap<(usize, usize, Vec<i8>), ()> = FxHashMap::default();
let mut id_to_fp: Vec<Option<(usize, usize, Vec<i8>)>> =
Vec::with_capacity(idx.num_types());
for entry in idx.entries() {
let fp = match entry {
NtEntry::Phase1(nt) => aug_boundary_fingerprint(nt, &mi),
NtEntry::Phase2(_) => None,
};
if let Some(ref fp) = fp {
fp_set.insert(fp.clone(), ());
}
id_to_fp.push(fp);
}
type DstFp = Option<(usize, usize, Vec<i8>)>;
let mut recorded_by_key: FxHashMap<
(usize, usize, usize),
std::collections::HashSet<DstFp>,
> = FxHashMap::default();
for t in idx.transitions() {
let dst_fp: DstFp = id_to_fp[t.dst_id - 1].clone();
recorded_by_key
.entry((t.src_id, t.tile_id, t.tile_offset))
.or_default()
.insert(dst_fp);
}
for (i, entry) in idx.entries().iter().enumerate() {
let src_id = i + 1;
let Some(nt) = entry.as_phase1() else {
continue;
};
let Some(ac) = build_attached_context(nt, &mi) else {
continue;
};
let target_edge = ac.frontier_pos_on_aug;
let n_aug = ac.aug_n;
let aug_rat = crate::geom::rat::Rat::from_slice_unchecked(ac.aug.angles());
let mut seen: FxHashMap<(usize, usize, usize, usize), ()> = FxHashMap::default();
for tile_b in 0..mi.tileset().num_tiles() {
let other = mi.tileset().rat(tile_b);
let n_b = other.seq().len();
for pos in 0..n_aug {
for b_off in 0..n_b {
let (ns, len, ne) = aug_rat.get_match((pos as i64, b_off as i64), other);
if len < 1 {
continue;
}
let ns_u = ns.rem_euclid(n_aug as i64) as usize;
let ne_u = ne.rem_euclid(n_b as i64) as usize;
let pm = PatchMatch::new(
EdgeRange::new(ns_u, len),
Segment::new(tile_b, EdgeRange::new(ne_u, len)),
);
if !match_absorbs_edge(&pm, target_edge, n_aug) {
continue;
}
if seen.contains_key(&(ns_u, len, ne_u, tile_b)) {
continue;
}
let mut trial = ac.aug.clone();
if !trial.add_tile(&pm) {
continue;
}
seen.insert((ns_u, len, ne_u, tile_b), ());
let trial_dst_fp: DstFp =
if !trial.patch_tile_ids().contains(&ac.central_ptid) {
None
} else {
let trial_angles = trial.angles().to_vec();
let rot = crate::geom::rat::lex_min_rot(&trial_angles);
let mut canon = trial_angles;
canon.rotate_left(rot);
let fp = (nt.central_tile_id, nt.cw_anchor_on_central, canon);
assert!(
fp_set.contains_key(&fp),
"completeness violation: src={} brute glue \
(tile={}, start_a={}, len={}, start_b={}) \
produces state not in catalog",
src_id,
tile_b,
ns_u,
len,
ne_u
);
Some(fp)
};
let key = (src_id, tile_b, ne_u);
let recorded_set = recorded_by_key.get(&key);
assert!(
recorded_set.is_some_and(|s| s.contains(&trial_dst_fp)),
"transition fingerprint mismatch: src={} brute glue \
(tile={}, start_a={}, len={}, start_b={}) -> {} but \
catalog has no transition with that destination \
fingerprint for (src, tile, tile_offset)",
src_id,
tile_b,
ns_u,
len,
ne_u,
if trial_dst_fp.is_none() {
"CLOSED".to_string()
} else {
"Open(fp)".to_string()
}
);
}
}
}
}
}
#[test]
fn spectre_brute_complete_and_correct() {
assert_brute_completeness_and_transition_correctness(spectre_idx());
}
#[test]
fn hex_brute_complete_and_correct() {
assert_brute_completeness_and_transition_correctness(hex_idx());
}
#[test]
#[ignore]
fn square_brute_complete_and_correct() {
assert_brute_completeness_and_transition_correctness(square_idx());
}
fn assert_classify_invariants<T: IsRing>(idx: &NeighborhoodIndex<T>) {
let kinds = idx.classify_all();
let n = idx.num_types();
let is_closed_terminal: Vec<bool> = idx
.entries()
.iter()
.map(|e| matches!(e, NtEntry::Phase2(st) if st.is_closed))
.collect();
let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut has_outgoing = vec![false; n];
for t in idx.transitions() {
let src = t.src_id - 1;
has_outgoing[src] = true;
preds[t.dst_id - 1].push(src);
}
let propagate = |seeds: Vec<usize>| -> Vec<bool> {
let mut reached = vec![false; n];
let mut queue: VecDeque<usize> = VecDeque::new();
for s in seeds {
if !reached[s] {
reached[s] = true;
queue.push_back(s);
}
}
while let Some(v) = queue.pop_front() {
for &p in &preds[v] {
if !reached[p] {
reached[p] = true;
queue.push_back(p);
}
}
}
reached
};
let closing_seeds: Vec<usize> = (0..n).filter(|&i| is_closed_terminal[i]).collect();
let reaches_closed = propagate(closing_seeds);
let dead_seeds: Vec<usize> = (0..n)
.filter(|&i| !has_outgoing[i] && !is_closed_terminal[i])
.collect();
let reaches_dead = propagate(dead_seeds);
for (i, &kind) in kinds.iter().enumerate() {
let id = i + 1;
let expected = if is_closed_terminal[i] {
NtKind::Blessed
} else if !has_outgoing[i] {
NtKind::Dead
} else if !reaches_closed[i] {
NtKind::Undead
} else if !reaches_dead[i] {
NtKind::Blessed
} else {
NtKind::Free
};
assert_eq!(
kind,
expected,
"NT id {id} classify_all says {:?} but reachability says {:?} \
(has_outgoing={} reaches_closed={} reaches_dead={} closed_terminal={})",
kind,
expected,
has_outgoing[i],
reaches_closed[i],
reaches_dead[i],
is_closed_terminal[i]
);
}
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: square idx build ~21s in release, much slower in debug"
)]
fn square_classify_invariants() {
assert_classify_invariants(square_idx());
}
#[test]
fn hex_classify_invariants() {
assert_classify_invariants(hex_idx());
}
#[test]
fn spectre_classify_invariants() {
assert_classify_invariants(spectre_idx());
}
#[test]
fn classify_all_returns_one_per_entry() {
for idx in [square_idx(), hex_idx()] {
let kinds = idx.classify_all();
assert_eq!(kinds.len(), idx.num_types());
let n_blessed = kinds.iter().filter(|k| **k == NtKind::Blessed).count();
assert!(n_blessed > 0, "expected at least one Blessed entry");
}
}
#[test]
fn bfs_produces_transitions() {
let sq_idx = square_idx();
assert!(sq_idx.num_types() > 0);
assert!(
!sq_idx.transitions().is_empty(),
"BFS should produce transitions"
);
for t in sq_idx.transitions() {
assert!(t.src_id >= 1 && t.src_id <= sq_idx.num_types());
assert!(t.dst_id >= 1 && t.dst_id <= sq_idx.num_types());
}
let hex_idx = hex_idx();
assert!(hex_idx.num_types() > 0);
assert!(!hex_idx.transitions().is_empty());
for t in hex_idx.transitions() {
assert!(t.src_id >= 1 && t.src_id <= hex_idx.num_types());
assert!(t.dst_id >= 1 && t.dst_id <= hex_idx.num_types());
}
}
fn validate_seeds<T: IsRing>(idx: &NeighborhoodIndex<T>) -> Vec<String> {
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let mut errors = Vec::new();
for entry in idx.entries() {
let Some(nt) = entry.as_phase1() else {
continue;
};
let Some((mut ctx, junc_positions)) =
GrowingPatch::construct_witness_from_vt_sequence(&nt.vt_seq, Arc::clone(&mi))
else {
errors.push(format!(
"nt c={} ac={} ax={}: reconstruct failed",
nt.central_tile_id, nt.cw_anchor_on_central, nt.cw_anchor_on_context
));
continue;
};
let ctx_n = ctx.boundary_len();
let first_junc = junc_positions[0];
let anchor_pos = (first_junc + ctx_n - nt.cw_anchor_on_context) % ctx_n;
let matching: Vec<_> = ctx
.get_all_matches()
.into_iter()
.filter(|pm| {
pm.b.tile_id == nt.central_tile_id
&& pm.a_range.start_offset == anchor_pos
&& pm.b.range.start_offset == nt.cw_anchor_on_central
})
.collect();
let pm = match matching.len() {
0 => {
errors.push(format!(
"nt c={} ac={} ax={}: no match at anchor positions",
nt.central_tile_id, nt.cw_anchor_on_central, nt.cw_anchor_on_context
));
continue;
}
1 => matching.into_iter().next().unwrap(),
k => {
errors.push(format!(
"nt c={} ac={} ax={}: ambiguous reconstruction \
({} PatchMatches share the recorded anchor)",
nt.central_tile_id, nt.cw_anchor_on_central, nt.cw_anchor_on_context, k
));
continue;
}
};
if !ctx.add_tile(&pm) {
errors.push(format!(
"nt c={} ac={} ax={}: add_tile failed",
nt.central_tile_id, nt.cw_anchor_on_central, nt.cw_anchor_on_context
));
}
}
errors
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: square idx build ~21s in release, much slower in debug"
)]
fn square_seeds_validate() {
let idx = square_idx();
let errors = validate_seeds(idx);
assert!(
errors.is_empty(),
"validation errors:\n{}",
errors.join("\n")
);
}
#[test]
fn hex_seeds_validate() {
let idx = hex_idx();
let errors = validate_seeds(idx);
assert!(
errors.is_empty(),
"validation errors:\n{}",
errors.join("\n")
);
}
fn assert_seed_geometry<T: IsRing>(idx: &NeighborhoodIndex<T>) {
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
for (i, entry) in idx.entries().iter().enumerate() {
let Some(nt) = entry.as_phase1() else {
continue;
};
let ac = build_attached_context(nt, &mi).unwrap_or_else(|| {
panic!("seed {}: build_attached_context failed for nt {:?}", i, nt)
});
let gap_len = ac.aug_n - ac.gap_start;
assert!(gap_len > 0, "seed {}: gap should be non-empty", i);
assert!(
ac.gap_start < ac.aug_n,
"seed {}: gap_start out of range",
i
);
let central_len = idx.tileset().rat(nt.central_tile_id).seq().len();
assert!(
gap_len < central_len,
"seed {}: gap_len {} should be < central_len {}",
i,
gap_len,
central_len
);
assert!(
!explore_phase1(nt, &mi).is_empty(),
"seed {}: should have at least one successful petal",
i
);
}
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: square idx build ~21s in release, much slower in debug"
)]
fn square_seed_geometry() {
assert_seed_geometry(square_idx());
}
#[test]
fn hex_seed_geometry() {
assert_seed_geometry(hex_idx());
}
fn assert_seeds_match_brute<T: IsRing>(tileset: Arc<TileSet<T>>, idx: &NeighborhoodIndex<T>) {
type Class = (usize, usize, Vec<i8>, usize);
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(&tileset)));
let mut brute: FxHashMap<Class, ()> = FxHashMap::default();
let num_tiles = tileset.num_tiles();
for a in 0..num_tiles {
let tile_a = tileset.rat(a);
let n_a = tile_a.seq().len();
for b in 0..num_tiles {
let tile_b = tileset.rat(b);
let n_b = tile_b.seq().len();
let mut seen_ab: FxHashMap<(usize, usize, usize), ()> = FxHashMap::default();
for ia in 0..n_a {
for ib in 0..n_b {
let (ns, len, ne) = tile_a.get_match((ia as i64, ib as i64), tile_b);
if len == 0 {
continue;
}
let ns_u = ns.rem_euclid(n_a as i64) as usize;
let ne_u = ne.rem_euclid(n_b as i64) as usize;
if seen_ab.contains_key(&(ns_u, len, ne_u)) {
continue;
}
seen_ab.insert((ns_u, len, ne_u), ());
if !crate::geom::glue::junctions_glueable(
tile_a.seq(),
ns_u,
len,
tile_b.seq(),
ne_u,
) {
continue;
}
if tile_a
.try_glue_precomputed((ns, len, ne), tile_b, true)
.is_err()
{
continue;
}
let seed = PatchSeed::new(Arc::clone(&tileset), a);
let pm1 = PatchMatch::new(
EdgeRange::new(ns_u, len),
Segment::new(b, EdgeRange::new(ne_u, len)),
);
let mut patch = match seed.grow(&pm1) {
Some(p) => p,
None => continue,
};
patch.normalize();
let patch_n = patch.boundary_len();
let patch_rat = crate::geom::rat::Rat::from_slice_unchecked(patch.angles());
let mut seen_third: FxHashMap<(usize, usize, usize, usize), ()> =
FxHashMap::default();
for c in 0..num_tiles {
let tile_c = tileset.rat(c);
let n_c = tile_c.seq().len();
for ic in 0..patch_n {
for jc in 0..n_c {
let (ns3, len3, ne3) =
patch_rat.get_match((ic as i64, jc as i64), tile_c);
if len3 == 0 {
continue;
}
let ns3_u = ns3.rem_euclid(patch_n as i64) as usize;
let ne3_u = ne3.rem_euclid(n_c as i64) as usize;
if seen_third.contains_key(&(ns3_u, len3, ne3_u, c)) {
continue;
}
seen_third.insert((ns3_u, len3, ne3_u, c), ());
let pm3 = PatchMatch::new(
EdgeRange::new(ns3_u, len3),
Segment::new(c, EdgeRange::new(ne3_u, len3)),
);
let mut trial = patch.clone();
if !trial.add_tile(&pm3) {
continue;
}
let touches_junction = (0..patch_n).any(|jp| {
if !patch.is_junction(jp) {
return false;
}
(jp + patch_n - ns3_u) % patch_n <= len3
});
if !touches_junction {
continue;
}
let aug_angles = trial.angles().to_vec();
let aug_n = aug_angles.len();
let rot = crate::geom::rat::lex_min_rot(&aug_angles);
let mut canon = aug_angles;
canon.rotate_left(rot);
let gap_start = patch_n - len3;
let cw_anchor_in_canon = (gap_start + aug_n - rot) % aug_n;
brute.insert((c, ne3_u, canon, cw_anchor_in_canon), ());
}
}
}
}
}
}
}
let mut catalog: FxHashMap<Class, ()> = FxHashMap::default();
for entry in idx.entries() {
let Some(nt) = entry.as_phase1() else {
continue;
};
if nt.num_ctx_tiles != 2 {
continue;
}
if let Some(fp) = canonical_class_fingerprint(nt, &mi) {
catalog.insert(fp, ());
}
}
let brute_only: Vec<&Class> = brute.keys().filter(|k| !catalog.contains_key(*k)).collect();
let catalog_only: Vec<&Class> =
catalog.keys().filter(|k| !brute.contains_key(*k)).collect();
assert!(
brute_only.is_empty(),
"seed completeness: {} canonical seed classes found by brute but missing from catalog",
brute_only.len()
);
assert!(
catalog_only.is_empty(),
"seed soundness: {} catalog seed classes not produced by brute",
catalog_only.len()
);
}
#[test]
fn hex_seeds_match_brute() {
assert_seeds_match_brute(Arc::clone(hex_idx().tileset()), hex_idx());
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "release-only: square idx build ~21s in release, much slower in debug"
)]
fn square_seeds_match_brute() {
assert_seeds_match_brute(Arc::clone(square_idx().tileset()), square_idx());
}
#[test]
fn spectre_seeds_match_brute() {
assert_seeds_match_brute(Arc::clone(spectre_idx().tileset()), spectre_idx());
}
#[test]
fn gap_edges_are_central_tile() {
let idx = square_idx();
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
for (i, entry) in idx.entries().iter().enumerate() {
let Some(nt) = entry.as_phase1() else {
continue;
};
let ac = build_attached_context(nt, &mi)
.unwrap_or_else(|| panic!("seed {}: build_attached_context failed", i));
let edges = ac.aug.edges();
let ptids = ac.aug.patch_tile_ids();
let central_ptid = ptids[ac.gap_start];
for k in ac.gap_start..ac.aug_n {
assert_eq!(
edges[k].tile_id, nt.central_tile_id,
"seed {}: gap edge at pos {} should be central tile",
i, k
);
assert_eq!(
ptids[k], central_ptid,
"seed {}: gap edge at pos {} should have same ptid",
i, k
);
}
}
}
#[test]
fn ccw_frontier_at_gap_end() {
let idx = hex_idx();
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
for (i, entry) in idx.entries().iter().enumerate() {
let Some(nt) = entry.as_phase1() else {
continue;
};
let ac = build_attached_context(nt, &mi)
.unwrap_or_else(|| panic!("seed {}: build_attached_context failed", i));
assert_eq!(
ac.frontier_pos_on_aug, 0,
"seed {}: gap_end (pos 0 on aug) should be the CCW frontier junction",
i
);
assert!(
ac.aug.is_junction(0),
"seed {}: position 0 must be junction",
i
);
}
}
#[test]
fn explore_one_square_has_petals() {
let idx = square_idx();
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let nt = idx.entries()[0]
.as_phase1()
.expect("first entry should be phase-1");
assert!(
!explore_phase1(nt, &mi).is_empty(),
"should have at least one petal outcome"
);
}
#[test]
fn explore_one_hex_seeds_reconstruct() {
let idx = hex_idx();
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let mut checked = 0usize;
let mut errors = Vec::new();
for (i, entry) in idx.entries().iter().enumerate() {
let Some(nt) = entry.as_phase1() else {
continue;
};
for outcome in explore_phase1(nt, &mi) {
let new_nt = match &outcome.kind {
OutcomeKind::Phase2(_) => continue,
OutcomeKind::Phase1(nt) => nt.clone(),
};
let (mut ctx, junc_pos) = GrowingPatch::construct_witness_from_vt_sequence(
&new_nt.vt_seq,
Arc::clone(&mi),
)
.unwrap_or_else(|| {
panic!(
"seed {} result: reconstruct failed for new_nt {:?}",
i, new_nt
)
});
let first_junc = junc_pos[0];
let ctx_n = ctx.boundary_len();
let anchor_pos = (first_junc + ctx_n - new_nt.cw_anchor_on_context) % ctx_n;
let matching: Vec<_> = ctx
.get_all_matches()
.into_iter()
.filter(|pm| {
pm.b.tile_id == new_nt.central_tile_id
&& pm.a_range.start_offset == anchor_pos
&& pm.b.range.start_offset == new_nt.cw_anchor_on_central
})
.collect();
let pm = match matching.len() {
0 => {
errors.push(format!(
"seed {}: no match c={} ac={} ax={} (ctx_n={})",
i,
new_nt.central_tile_id,
new_nt.cw_anchor_on_central,
new_nt.cw_anchor_on_context,
ctx.boundary_len()
));
continue;
}
1 => matching.into_iter().next().unwrap(),
k => {
errors.push(format!(
"seed {}: ambiguous reconstruction \
({} PatchMatches share anchor)",
i, k
));
continue;
}
};
if !ctx.add_tile(&pm) {
errors.push(format!(
"seed {}: add_tile failed for new_nt {:?}",
i, new_nt
));
continue;
}
checked += 1;
}
}
assert!(checked > 0, "should have validated at least one new NT");
assert!(
errors.is_empty(),
"{} reattach errors (first few): {}",
errors.len(),
errors[..errors.len().min(10)].join("; ")
);
}
#[test]
fn explore_one_square_seeds_reconstruct() {
let idx = square_idx();
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(idx.tileset())));
let mut checked = 0usize;
let mut errors = Vec::new();
for (i, entry) in idx.entries().iter().enumerate() {
let Some(nt) = entry.as_phase1() else {
continue;
};
for outcome in explore_phase1(nt, &mi) {
let new_nt = match &outcome.kind {
OutcomeKind::Phase2(_) => continue,
OutcomeKind::Phase1(nt) => nt.clone(),
};
let (mut ctx, junc_pos) = GrowingPatch::construct_witness_from_vt_sequence(
&new_nt.vt_seq,
Arc::clone(&mi),
)
.unwrap_or_else(|| {
panic!(
"seed {} result: reconstruct failed for new_nt {:?}",
i, new_nt
)
});
let first_junc = junc_pos[0];
let ctx_n = ctx.boundary_len();
let anchor_pos = (first_junc + ctx_n - new_nt.cw_anchor_on_context) % ctx_n;
let matching: Vec<_> = ctx
.get_all_matches()
.into_iter()
.filter(|pm| {
pm.b.tile_id == new_nt.central_tile_id
&& pm.a_range.start_offset == anchor_pos
&& pm.b.range.start_offset == new_nt.cw_anchor_on_central
})
.collect();
let pm = match matching.len() {
0 => {
errors.push(format!(
"seed {}: no match c={} ac={} ax={} (ctx_n={})",
i,
new_nt.central_tile_id,
new_nt.cw_anchor_on_central,
new_nt.cw_anchor_on_context,
ctx.boundary_len()
));
continue;
}
1 => matching.into_iter().next().unwrap(),
k => {
errors.push(format!(
"seed {}: ambiguous reconstruction \
({} PatchMatches share anchor)",
i, k
));
continue;
}
};
if !ctx.add_tile(&pm) {
errors.push(format!(
"seed {}: add_tile failed for new_nt {:?}",
i, new_nt
));
continue;
}
checked += 1;
}
}
assert!(checked > 0, "should have validated at least one new NT");
assert!(
errors.is_empty(),
"{} reattach errors (first few): {}",
errors.len(),
errors[..errors.len().min(10)].join("; ")
);
}
}