#[cfg(test)]
use crate::geom::glue::Validation;
#[cfg(test)]
use crate::geom::matches::Match;
#[cfg(test)]
use crate::geom::matches::MatchSeed;
use std::collections::{BTreeSet, HashMap, VecDeque};
use std::sync::Arc;
use crate::cyclotomic::IsRing;
use crate::geom::patch::{EPatch, PatchMatch};
use crate::geom::rat::Rat;
use crate::geom::tileset::TileSet;
use crate::geom::vertices::{ClosedJunctionType, EdgeInfo, OpenJunctionType, TransitionSide};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum JunctionKind {
Dead,
Undead,
Blessed,
Free,
}
pub struct OpenJunctionTypeInfo<T: IsRing> {
jtype: OpenJunctionType,
kind: JunctionKind,
is_initial: bool,
realizing_rat: Rat<T>,
witness: EPatch<T>,
witness_pos: usize,
gap_angle: i8,
cw_neighbor_offset: usize,
ccw_neighbor_offset: usize,
}
impl<T: IsRing> OpenJunctionTypeInfo<T> {
pub fn jtype(&self) -> &OpenJunctionType {
&self.jtype
}
pub fn kind(&self) -> JunctionKind {
self.kind
}
pub fn is_dead(&self) -> bool {
self.kind == JunctionKind::Dead
}
pub fn is_undead(&self) -> bool {
self.kind == JunctionKind::Undead
}
pub fn is_blessed(&self) -> bool {
self.kind == JunctionKind::Blessed
}
pub fn is_free(&self) -> bool {
self.kind == JunctionKind::Free
}
pub fn is_cursed(&self) -> bool {
matches!(self.kind, JunctionKind::Dead | JunctionKind::Undead)
}
pub fn is_alive(&self) -> bool {
matches!(self.kind, JunctionKind::Blessed | JunctionKind::Free)
}
pub fn is_initial(&self) -> bool {
self.is_initial
}
pub fn realizing_rat(&self) -> &Rat<T> {
&self.realizing_rat
}
pub fn gap_angle(&self) -> i8 {
self.gap_angle
}
pub fn witness(&self) -> &EPatch<T> {
&self.witness
}
pub fn witness_pos(&self) -> usize {
self.witness_pos
}
pub fn cw_neighbor_offset(&self) -> usize {
self.cw_neighbor_offset
}
pub fn ccw_neighbor_offset(&self) -> usize {
self.ccw_neighbor_offset
}
}
pub const CLOSED_ID: usize = 0;
pub struct TransitionInfo {
pub src_id: usize,
pub dst_id: usize,
pub side: TransitionSide,
pub tile_id: usize,
pub tile_offset: usize,
pub closed_jt_id: Option<usize>,
}
impl TransitionInfo {
pub fn is_closed(&self) -> bool {
self.dst_id == CLOSED_ID
}
}
pub struct ClosedJunctionTypeInfo {
jtype: ClosedJunctionType,
id: usize,
transition_count: usize,
}
impl ClosedJunctionTypeInfo {
pub fn jtype(&self) -> &ClosedJunctionType {
&self.jtype
}
pub fn id(&self) -> usize {
self.id
}
pub fn transition_count(&self) -> usize {
self.transition_count
}
}
pub struct OpenJunctionTypeIndex<T: IsRing> {
tileset: Arc<TileSet<T>>,
entries: Vec<OpenJunctionTypeInfo<T>>,
transitions: Vec<TransitionInfo>,
reverse: HashMap<OpenJunctionType, usize>,
closed_entries: Vec<ClosedJunctionTypeInfo>,
closed_reverse: HashMap<ClosedJunctionType, usize>,
}
impl<T: IsRing> OpenJunctionTypeIndex<T> {
pub fn new(tileset: Arc<TileSet<T>>) -> Self {
let mut state = BfsState::default();
seed_phase(&mut state, &tileset);
bfs_phase(&mut state, &tileset);
let BfsState {
all_types,
initial_types,
raw_transitions,
witness_store,
..
} = state;
let (vt_list, reverse) = build_id_map(all_types);
let (closed_list, closed_reverse) = build_closed_id_map(&raw_transitions);
let (succ_sets, transition_infos, closed_counts) = build_transition_arrays(
&reverse,
&closed_reverse,
closed_list.len(),
&raw_transitions,
);
let has_any_realized = compute_has_any_realized(vt_list.len(), &transition_infos);
let has_closing = compute_has_closing(vt_list.len(), &transition_infos);
let is_cursed = compute_cursed(&vt_list, &has_any_realized, &has_closing, &succ_sets);
let is_blessed = compute_blessed(&vt_list, &has_any_realized, &succ_sets);
let info_entries = classify_and_finalize(
vt_list,
&has_any_realized,
witness_store,
&initial_types,
&is_cursed,
&is_blessed,
);
let closed_entries: Vec<ClosedJunctionTypeInfo> = closed_list
.into_iter()
.enumerate()
.map(|(i, cjt)| ClosedJunctionTypeInfo {
jtype: cjt,
id: i + 1,
transition_count: closed_counts[i],
})
.collect();
OpenJunctionTypeIndex {
tileset,
entries: info_entries,
transitions: transition_infos,
reverse,
closed_entries,
closed_reverse,
}
}
pub fn num_types(&self) -> usize {
self.entries.len()
}
pub fn transitions(&self) -> &[TransitionInfo] {
&self.transitions
}
pub fn entries(&self) -> &[OpenJunctionTypeInfo<T>] {
&self.entries
}
pub fn range_by_cw(&self, tile_id: usize) -> &[OpenJunctionTypeInfo<T>] {
let start = self
.entries
.partition_point(|e| e.jtype().cw.tile_type_id < tile_id);
let end = self
.entries
.partition_point(|e| e.jtype().cw.tile_type_id <= tile_id);
&self.entries[start..end]
}
pub fn get_id(&self, jtype: &OpenJunctionType) -> Option<usize> {
self.reverse.get(jtype).copied()
}
pub fn get_type(&self, id: usize) -> &OpenJunctionType {
assert!(
id >= 1 && id <= self.entries.len(),
"junction type id out of range"
);
&self.entries[id - 1].jtype
}
pub fn get_info(&self, id: usize) -> &OpenJunctionTypeInfo<T> {
assert!(
id >= 1 && id <= self.entries.len(),
"junction type id out of range"
);
&self.entries[id - 1]
}
pub fn tileset(&self) -> &Arc<TileSet<T>> {
&self.tileset
}
pub fn num_closed_types(&self) -> usize {
self.closed_entries.len()
}
pub fn closed_entries(&self) -> &[ClosedJunctionTypeInfo] {
&self.closed_entries
}
pub fn get_closed_id(&self, cjt: &ClosedJunctionType) -> Option<usize> {
self.closed_reverse.get(cjt).copied()
}
pub fn get_closed_type(&self, id: usize) -> &ClosedJunctionType {
assert!(
id >= 1 && id <= self.closed_entries.len(),
"closed junction type id out of range"
);
&self.closed_entries[id - 1].jtype
}
pub fn get_closed_info(&self, id: usize) -> &ClosedJunctionTypeInfo {
assert!(
id >= 1 && id <= self.closed_entries.len(),
"closed junction type id out of range"
);
&self.closed_entries[id - 1]
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum RawDst {
Open(OpenJunctionType),
Closed(ClosedJunctionType),
}
type RawTransition = (OpenJunctionType, RawDst, TransitionSide, usize, usize);
struct BfsState<T: IsRing> {
all_types: BTreeSet<OpenJunctionType>,
initial_types: BTreeSet<OpenJunctionType>,
raw_transitions: BTreeSet<RawTransition>,
visited: BTreeSet<OpenJunctionType>,
queue: VecDeque<OpenJunctionType>,
witness_store: HashMap<OpenJunctionType, (EPatch<T>, usize, i8)>,
}
impl<T: IsRing> Default for BfsState<T> {
fn default() -> Self {
BfsState {
all_types: BTreeSet::new(),
initial_types: BTreeSet::new(),
raw_transitions: BTreeSet::new(),
visited: BTreeSet::new(),
queue: VecDeque::new(),
witness_store: HashMap::new(),
}
}
}
fn seed_phase<T: IsRing>(state: &mut BfsState<T>, tileset: &Arc<TileSet<T>>) {
for seed_id in 0..tileset.num_tiles() {
let seed = EPatch::single_tile(Arc::clone(tileset), seed_id);
for pm in seed.get_all_matches() {
let gp = match seed.with_tile(&pm) {
Some(g) => g,
None => continue,
};
for pos in 0..gp.len() {
if !gp.is_junction(pos) {
continue;
}
if let Some(jt) = gp.junction_type_at(pos) {
state.all_types.insert(jt.clone());
state.initial_types.insert(jt.clone());
state
.witness_store
.entry(jt.clone())
.or_insert_with(|| (gp.clone(), pos, gp.angles()[pos]));
if state.visited.insert(jt.clone()) {
state.queue.push_back(jt);
}
}
}
}
}
}
fn bfs_phase<T: IsRing>(state: &mut BfsState<T>, tileset: &Arc<TileSet<T>>) {
while let Some(jt) = state.queue.pop_front() {
let (touching, n) = {
let (gp, pos, _gap) = &state.witness_store[&jt];
let n = gp.len();
let t: Vec<PatchMatch> = gp.get_matches_touching_vertex(*pos);
if t.is_empty() {
continue;
}
(t, n)
};
let pos = state.witness_store[&jt].1;
for pm in touching {
let gp = &state.witness_store[&jt].0;
let Some(gp2) = gp.with_tile(&pm) else {
continue;
};
let edge_in_match = |edge: usize| -> bool { pm.a_range.covers_edge_cyclic(edge, n) };
let consumes_cw_edge = edge_in_match((pos + n - 1) % n);
let consumes_ccw_edge = edge_in_match(pos);
debug_assert!(
consumes_cw_edge || consumes_ccw_edge,
"touching match doesn't consume any incident edge of pos"
);
let side = match (consumes_cw_edge, consumes_ccw_edge) {
(true, true) => TransitionSide::Both,
(true, false) => TransitionSide::Cw,
(false, true) => TransitionSide::Ccw,
(false, false) => unreachable!(),
};
let edge_pos = match side {
TransitionSide::Ccw => pos,
TransitionSide::Cw | TransitionSide::Both => (pos + n - 1) % n,
};
let offset_in_match =
(edge_pos as i64 - pm.a_range.start_offset as i64).rem_euclid(n as i64) as usize;
let m = tileset.rat(pm.b.tile_id).len();
let tile_offset = (pm.b.range.start_offset as i64 - 1 - offset_in_match as i64)
.rem_euclid(m as i64) as usize;
let junction_pos = if pm.a_range.start_offset == pos {
n - pm.len()
} else {
0
};
if matches!(side, TransitionSide::Both) {
let closed = ClosedJunctionType::from_open_via_closure(&jt);
state.raw_transitions.insert((
jt.clone(),
RawDst::Closed(closed),
side,
pm.b.tile_id,
tile_offset,
));
} else if let Some(new_vt) = gp2.junction_type_at(junction_pos) {
state.raw_transitions.insert((
jt.clone(),
RawDst::Open(new_vt),
side,
pm.b.tile_id,
tile_offset,
));
}
for new_pos in 0..gp2.len() {
if !gp2.is_junction(new_pos) {
continue;
}
if let Some(nv) = gp2.junction_type_at(new_pos) {
state.all_types.insert(nv.clone());
if state.visited.insert(nv.clone()) {
state
.witness_store
.entry(nv.clone())
.or_insert_with(|| (gp2.clone(), new_pos, gp2.angles()[new_pos]));
state.queue.push_back(nv);
}
}
}
}
}
}
fn build_id_map(
all_types: BTreeSet<OpenJunctionType>,
) -> (Vec<OpenJunctionType>, HashMap<OpenJunctionType, usize>) {
let vt_list: Vec<OpenJunctionType> = all_types.into_iter().collect();
let reverse: HashMap<OpenJunctionType, usize> = vt_list
.iter()
.enumerate()
.map(|(i, jt)| (jt.clone(), i + 1))
.collect();
(vt_list, reverse)
}
fn build_closed_id_map(
raw_transitions: &BTreeSet<RawTransition>,
) -> (Vec<ClosedJunctionType>, HashMap<ClosedJunctionType, usize>) {
let mut all_closed: BTreeSet<ClosedJunctionType> = BTreeSet::new();
for (_src, dst, _side, _tid, _toff) in raw_transitions {
if let RawDst::Closed(c) = dst {
all_closed.insert(c.clone());
}
}
let closed_list: Vec<ClosedJunctionType> = all_closed.into_iter().collect();
let closed_reverse: HashMap<ClosedJunctionType, usize> = closed_list
.iter()
.enumerate()
.map(|(i, c)| (c.clone(), i + 1))
.collect();
(closed_list, closed_reverse)
}
fn build_transition_arrays(
reverse: &HashMap<OpenJunctionType, usize>,
closed_reverse: &HashMap<ClosedJunctionType, usize>,
num_closed: usize,
raw_transitions: &BTreeSet<RawTransition>,
) -> (Vec<BTreeSet<usize>>, Vec<TransitionInfo>, Vec<usize>) {
let n = reverse.len();
let mut succ_sets: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
let mut transition_infos: Vec<TransitionInfo> = Vec::new();
let mut closed_transition_counts: Vec<usize> = vec![0; num_closed];
for (src, dst, side, tid, toff) in raw_transitions {
let Some(&src_id) = reverse.get(src) else {
continue;
};
match dst {
RawDst::Open(dst_vt) => {
if let Some(&dst_id) = reverse.get(dst_vt) {
succ_sets[src_id - 1].insert(dst_id);
transition_infos.push(TransitionInfo {
src_id,
dst_id,
side: *side,
tile_id: *tid,
tile_offset: *toff,
closed_jt_id: None,
});
}
}
RawDst::Closed(cjt) => {
let cvt_id = closed_reverse.get(cjt).copied();
if let Some(id) = cvt_id {
closed_transition_counts[id - 1] += 1;
}
transition_infos.push(TransitionInfo {
src_id,
dst_id: CLOSED_ID,
side: *side,
tile_id: *tid,
tile_offset: *toff,
closed_jt_id: cvt_id,
});
}
}
}
(succ_sets, transition_infos, closed_transition_counts)
}
fn classify_and_finalize<T: IsRing>(
vt_list: Vec<OpenJunctionType>,
has_any_realized: &[bool],
mut witness_store: HashMap<OpenJunctionType, (EPatch<T>, usize, i8)>,
initial_types: &BTreeSet<OpenJunctionType>,
is_cursed: &[bool],
is_blessed: &[bool],
) -> Vec<OpenJunctionTypeInfo<T>> {
vt_list
.into_iter()
.enumerate()
.map(|(i, jt)| {
let (witness, witness_pos, gap_angle) = witness_store.remove(&jt).unwrap();
let rat = witness.to_rat();
let (cw_nbr, ccw_nbr) = witness
.neighbor_junction_offsets(witness_pos)
.unwrap_or((0, 0));
let no_transitions = !has_any_realized[i];
let kind = if no_transitions {
JunctionKind::Dead
} else if is_cursed[i] {
JunctionKind::Undead
} else if is_blessed[i] {
JunctionKind::Blessed
} else {
JunctionKind::Free
};
OpenJunctionTypeInfo {
kind,
is_initial: initial_types.contains(&jt),
realizing_rat: rat,
jtype: jt,
witness,
witness_pos,
gap_angle,
cw_neighbor_offset: cw_nbr,
ccw_neighbor_offset: ccw_nbr,
}
})
.collect()
}
fn compute_cursed(
entries: &[OpenJunctionType],
has_any_realized: &[bool],
has_closing: &[bool],
succ_sets: &[BTreeSet<usize>],
) -> Vec<bool> {
let n = entries.len();
let mut cursed: Vec<bool> = has_any_realized.iter().map(|&h| !h).collect();
let mut changed = true;
while changed {
changed = false;
for i in 0..n {
if cursed[i] {
continue;
}
if has_closing[i] {
continue;
}
let succs = &succ_sets[i];
if succs.is_empty() {
debug_assert!(
!has_any_realized[i],
"JT with no open successors and no closing transitions \
should have been Dead in the base case"
);
continue;
}
if succs.iter().all(|&s| cursed[s - 1]) {
cursed[i] = true;
changed = true;
}
}
}
cursed
}
fn compute_blessed(
entries: &[OpenJunctionType],
has_any_realized: &[bool],
succ_sets: &[BTreeSet<usize>],
) -> Vec<bool> {
let n = entries.len();
let mut blessed: Vec<bool> = vec![false; n];
let mut changed = true;
while changed {
changed = false;
for i in 0..n {
if blessed[i] {
continue;
}
if !has_any_realized[i] {
continue;
}
if succ_sets[i].iter().all(|&dst| blessed[dst - 1]) {
blessed[i] = true;
changed = true;
}
}
}
blessed
}
fn compute_has_any_realized(n: usize, transitions: &[TransitionInfo]) -> Vec<bool> {
let mut v = vec![false; n];
for t in transitions {
v[t.src_id - 1] = true;
}
v
}
fn compute_has_closing(n: usize, transitions: &[TransitionInfo]) -> Vec<bool> {
let mut v = vec![false; n];
for t in transitions {
if t.is_closed() {
v[t.src_id - 1] = true;
}
}
v
}
use crate::geom::matchtypes::MatchTypeIndex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WitnessSnapshot {
pub pos: usize,
pub angles: Vec<i8>,
pub edges: Vec<EdgeInfo>,
pub inner_petals: Vec<Vec<EdgeInfo>>,
pub patch_tile_ids: Vec<usize>,
pub next_tile_id: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JtypeRecord {
pub id: usize,
pub jtype: OpenJunctionType,
pub kind: JunctionKind,
pub cw_neighbor_offset: usize,
pub ccw_neighbor_offset: usize,
pub witness: WitnessSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionRecord {
pub src_id: usize,
pub dst_id: usize,
pub side: TransitionSide,
pub tile_id: usize,
pub tile_offset: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Collection {
pub ring: String,
pub tile_angles: Vec<Vec<i8>>,
pub jtypes: Vec<JtypeRecord>,
pub transitions: Vec<TransitionRecord>,
}
pub struct CompletenessReport {
pub matches_checked: usize,
pub missing: std::collections::BTreeSet<usize>,
}
impl CompletenessReport {
pub fn is_complete(&self) -> bool {
self.missing.is_empty()
}
}
impl Collection {
pub fn from_index<T>(idx: &OpenJunctionTypeIndex<T>, ring: impl Into<String>) -> Self
where
T: IsRing,
{
let tile_angles = idx
.tileset()
.rats()
.iter()
.map(|r| r.seq().to_vec())
.collect();
let mut jtypes = Vec::with_capacity(idx.num_types());
for id in 1..=idx.num_types() {
let info = idx.get_info(id);
let mut w = info.witness().clone();
let rot = w.normalize().rot;
let n = w.len();
let pos = (info.witness_pos() + n - rot) % n;
jtypes.push(JtypeRecord {
id,
jtype: info.jtype().clone(),
kind: info.kind(),
cw_neighbor_offset: info.cw_neighbor_offset(),
ccw_neighbor_offset: info.ccw_neighbor_offset(),
witness: WitnessSnapshot {
pos,
angles: w.angles().to_vec(),
edges: w.edges().to_vec(),
inner_petals: w.inner_petals().to_vec(),
patch_tile_ids: w.patch_tile_ids().to_vec(),
next_tile_id: w.next_tile_id(),
},
});
}
let transitions = idx
.transitions()
.iter()
.map(|t| TransitionRecord {
src_id: t.src_id,
dst_id: t.dst_id,
side: t.side,
tile_id: t.tile_id,
tile_offset: t.tile_offset,
})
.collect();
Collection {
ring: ring.into(),
tile_angles,
jtypes,
transitions,
}
}
pub fn reconstruct_witnesses<T>(
&self,
tile_ts: &Arc<TileSet<T>>,
) -> Result<HashMap<usize, EPatch<T>>, String>
where
T: IsRing,
{
let mi = Arc::new(MatchTypeIndex::new(Arc::clone(tile_ts)));
let mut out = HashMap::with_capacity(self.jtypes.len());
for v in &self.jtypes {
let n = v.witness.angles.len();
if v.witness.inner_petals.len() != n
|| v.witness.edges.len() != n
|| v.witness.patch_tile_ids.len() != n
{
return Err(format!(
"JT {}: witness arrays length mismatch (n={}, edges={}, inner={}, ptids={})",
v.id,
n,
v.witness.edges.len(),
v.witness.inner_petals.len(),
v.witness.patch_tile_ids.len(),
));
}
let gp = EPatch::from_parts(
Arc::clone(&mi),
v.witness.angles.clone(),
v.witness.edges.clone(),
v.witness.inner_petals.clone(),
v.witness.patch_tile_ids.clone(),
v.witness.next_tile_id,
)
.ok_or_else(|| format!("JT {}: from_parts failed", v.id))?;
out.insert(v.id, gp);
}
Ok(out)
}
pub fn jtype_errors<T>(&self, witnesses: &HashMap<usize, EPatch<T>>) -> Vec<(usize, String)>
where
T: IsRing,
{
let mut errors = Vec::new();
for v in &self.jtypes {
let Some(gp) = witnesses.get(&v.id) else {
errors.push((v.id, "no witness".to_string()));
continue;
};
let pos = v.witness.pos;
let (expected_cw, expected_ccw) = gp.neighbor_junction_offsets(pos).unwrap_or((0, 0));
if expected_cw != v.cw_neighbor_offset || expected_ccw != v.ccw_neighbor_offset {
errors.push((
v.id,
format!(
"neighbor offsets: expected ({}, {}), recorded ({}, {})",
expected_cw, expected_ccw, v.cw_neighbor_offset, v.ccw_neighbor_offset,
),
));
}
match gp.junction_type_at(pos) {
Some(actual) if actual.cw == v.jtype.cw && actual.ccw == v.jtype.ccw => {}
Some(actual) => errors.push((
v.id,
format!(
"jtype mismatch: recorded {:?}, witness {:?}",
v.jtype, actual
),
)),
None => errors.push((v.id, "junction_type_at returned None".to_string())),
}
}
errors
}
pub fn transition_errors<T>(
&self,
tile_ts: &Arc<TileSet<T>>,
witnesses: &HashMap<usize, EPatch<T>>,
) -> Vec<((usize, usize), String)>
where
T: IsRing,
{
let mut errors = Vec::new();
let known_ids: std::collections::BTreeSet<usize> =
self.jtypes.iter().map(|v| v.id).collect();
let witness_pos: HashMap<usize, usize> =
self.jtypes.iter().map(|v| (v.id, v.witness.pos)).collect();
for t in &self.transitions {
if !known_ids.contains(&t.src_id) {
errors.push(((t.src_id, t.dst_id), "unknown src id".to_string()));
continue;
}
if t.dst_id != CLOSED_ID && !known_ids.contains(&t.dst_id) {
errors.push(((t.src_id, t.dst_id), "unknown dst id".to_string()));
continue;
}
let Some(gp) = witnesses.get(&t.src_id) else {
errors.push(((t.src_id, t.dst_id), "no source witness".to_string()));
continue;
};
let pos = witness_pos[&t.src_id];
let n = gp.len();
let edge_pos = match t.side {
TransitionSide::Cw | TransitionSide::Both => (pos + n - 1) % n,
TransitionSide::Ccw => pos,
};
let cw_edge = (pos + n - 1) % n;
let ccw_edge = pos;
let edge_consumed =
|pm: &PatchMatch, edge: usize| -> bool { pm.a_range.covers_edge_cyclic(edge, n) };
let tile_len = tile_ts.rat(t.tile_id).len();
let mut found = false;
for pm in gp.get_all_matches() {
if pm.b.tile_id != t.tile_id {
continue;
}
if !edge_consumed(&pm, edge_pos) {
continue;
}
let offset_in_match = (edge_pos as i64 - pm.a_range.start_offset as i64)
.rem_euclid(n as i64) as usize;
let computed_offset = (pm.b.range.start_offset as i64 - 1 - offset_in_match as i64)
.rem_euclid(tile_len as i64) as usize;
if computed_offset != t.tile_offset {
continue;
}
let consumes_cw = edge_consumed(&pm, cw_edge);
let consumes_ccw = edge_consumed(&pm, ccw_edge);
let actual_side = match (consumes_cw, consumes_ccw) {
(true, true) => TransitionSide::Both,
(true, false) => TransitionSide::Cw,
(false, true) => TransitionSide::Ccw,
(false, false) => continue,
};
if actual_side != t.side {
continue;
}
let Some(gp2) = gp.with_tile(&pm) else {
continue;
};
if t.dst_id == CLOSED_ID {
found = true;
break;
}
let junction_pos = if pm.a_range.start_offset == pos {
n - pm.len()
} else {
0
};
if let Some(actual) = gp2.junction_type_at(junction_pos) {
let Some(dst_gp) = witnesses.get(&t.dst_id) else {
continue;
};
let Some(&dst_pos) = witness_pos.get(&t.dst_id) else {
continue;
};
if let Some(expected) = dst_gp.junction_type_at(dst_pos)
&& actual.cw == expected.cw
&& actual.ccw == expected.ccw
{
found = true;
break;
}
}
}
if !found {
errors.push(((t.src_id, t.dst_id), "no matching glue found".to_string()));
}
}
errors
}
pub fn completeness_errors<T>(
&self,
witnesses: &HashMap<usize, EPatch<T>>,
) -> CompletenessReport
where
T: IsRing,
{
let mut missing: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
let mut matches_checked = 0usize;
for v in &self.jtypes {
if matches!(v.kind, JunctionKind::Dead | JunctionKind::Undead) {
continue;
}
let Some(gp) = witnesses.get(&v.id) else {
continue;
};
let pos = v.witness.pos;
let old_n = gp.len();
let edge_consumed = |pm: &PatchMatch, edge: usize| -> bool {
pm.a_range.covers_edge_cyclic(edge, old_n)
};
for pm in gp.get_matches_touching_vertex(pos) {
matches_checked += 1;
let Some(gp2) = gp.with_tile(&pm) else {
continue;
};
let junction_pos = if pm.a_range.start_offset == pos {
old_n - pm.len()
} else {
0
};
let consumes_ccw = edge_consumed(&pm, pos);
let consumes_cw = edge_consumed(&pm, (pos + old_n - 1) % old_n);
if consumes_cw && consumes_ccw {
continue;
}
let Some(jtype) = gp2.junction_type_at(junction_pos) else {
continue;
};
let found = self.jtypes.iter().any(|other| other.jtype == jtype);
if !found {
missing.insert(v.id);
}
}
}
CompletenessReport {
matches_checked,
missing,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cyclotomic::{ZZ4, ZZ12};
use crate::geom::matches::{EdgeRange, Segment};
use crate::geom::tiles;
use crate::geom::tileset::{self, TileSet};
use std::sync::Arc;
#[test]
fn collection_roundtrip_validates_square_and_spectre() {
let cases: [(&str, Arc<TileSet<ZZ12>>); 2] = [
("square", tileset::square::<ZZ12>()),
("spectre", tileset::spectre::<ZZ12>()),
];
for (label, ts) in cases {
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
let collection = Collection::from_index(&idx, "ZZ12");
let json = serde_json::to_string(&collection).expect("serialize");
let restored: Collection = serde_json::from_str(&json).expect("deserialize");
let witnesses = restored.reconstruct_witnesses(&ts).expect("reconstruct");
let jt_errs = restored.jtype_errors(&witnesses);
assert!(jt_errs.is_empty(), "{label}: jtype errors: {jt_errs:?}");
let tr_errs = restored.transition_errors(&ts, &witnesses);
assert!(
tr_errs.is_empty(),
"{}: {} transition errors (first: {:?})",
label,
tr_errs.len(),
tr_errs.first(),
);
let report = restored.completeness_errors(&witnesses);
assert!(
report.is_complete(),
"{}: {} JTs produce unknown junction types: {:?}",
label,
report.missing.len(),
report.missing.iter().take(10).collect::<Vec<_>>(),
);
}
}
#[test]
fn hexagon_junction_type_counts() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
let alive = idx.entries.iter().filter(|e| e.is_alive()).count();
let dead = idx.entries.iter().filter(|e| e.is_dead()).count();
let cursed = idx.entries.iter().filter(|e| e.is_cursed()).count();
let blessed = idx.entries.iter().filter(|e| e.is_blessed()).count();
let free = idx.entries.iter().filter(|e| e.is_free()).count();
assert_eq!(idx.num_types(), 36, "hex JT count");
assert_eq!(alive, 36, "every hex JT is alive");
assert_eq!(dead, 0, "no dead hex JTs");
assert_eq!(cursed, 0, "no cursed hex JTs");
assert_eq!(blessed + free, 36, "alive = blessed + free");
}
#[test]
fn square_junction_type_counts() {
let sq: crate::geom::snake::Snake<ZZ4> = tiles::square();
let rat = Rat::try_from(&sq).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_eq!(idx.num_types(), 80, "square JT count");
let alive = idx.entries.iter().filter(|e| e.is_alive()).count();
assert_eq!(alive, 80, "every square JT is alive");
}
#[test]
fn hexagon_transitions_well_formed() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
let n_types = idx.num_types();
for t in idx.transitions() {
assert!(
t.src_id >= 1 && t.src_id <= n_types,
"src_id {} out of range",
t.src_id
);
assert!(
t.dst_id == CLOSED_ID || (t.dst_id >= 1 && t.dst_id <= n_types),
"dst_id {} out of range (and not CLOSED)",
t.dst_id
);
assert!(
t.tile_id < ts.num_tiles(),
"tile_id {} out of range",
t.tile_id
);
let tile_len = ts.rat(t.tile_id).len();
assert!(
t.tile_offset < tile_len,
"tile_offset {} out of range (tile {} has {} edges)",
t.tile_offset,
t.tile_id,
tile_len
);
}
}
#[test]
fn hexagon_cursed_fixpoint_invariant() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
for info in idx.entries() {
if info.is_cursed() {
continue;
}
let id = idx.get_id(info.jtype()).unwrap();
let outgoing: Vec<&TransitionInfo> = idx
.transitions()
.iter()
.filter(|t| t.src_id == id)
.collect();
assert!(
!outgoing.is_empty(),
"non-cursed JT id {id} should have outgoing transitions"
);
for t in &outgoing {
if t.is_closed() {
continue;
}
let dst_info = idx.get_info(t.dst_id);
assert!(
!dst_info.is_dead(),
"non-cursed JT id {id} has open transition to Dead JT id {}",
t.dst_id
);
}
}
}
#[test]
fn hexagon_blessed_fixpoint_invariant() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
for info in idx.entries() {
if !info.is_blessed() {
continue;
}
let id = idx.get_id(info.jtype()).unwrap();
let outgoing: Vec<&TransitionInfo> = idx
.transitions()
.iter()
.filter(|t| t.src_id == id)
.collect();
assert!(
!outgoing.is_empty(),
"Blessed JT id {id} must have at least one transition"
);
for t in &outgoing {
assert!(
t.is_closed() || idx.get_info(t.dst_id).is_blessed(),
"Blessed JT id {id} has open transition to non-Blessed JT id {}",
t.dst_id
);
}
}
}
fn assert_catalog_validity<T>(idx: &OpenJunctionTypeIndex<T>)
where
T: IsRing,
{
for info in idx.entries() {
let observed = info.witness().junction_type_at(info.witness_pos());
assert_eq!(
observed,
Some(info.jtype().clone()),
"validity violation: witness for catalog id {} does not \
realize its claimed JT",
idx.get_id(info.jtype()).unwrap()
);
}
}
fn assert_catalog_complete_brute<T>(idx: &OpenJunctionTypeIndex<T>)
where
T: IsRing,
{
let ts = idx.tileset();
for info in idx.entries() {
let src_id = idx.get_id(info.jtype()).unwrap();
let witness = info.witness();
let pos = info.witness_pos();
let n = witness.len();
let boundary_rat = Rat::from_slice_trusted(witness.angles());
let mut canonical: std::collections::BTreeSet<(usize, usize, usize, usize)> =
std::collections::BTreeSet::new();
for tile_id in 0..ts.num_tiles() {
let tile_rat = ts.rat(tile_id);
let m = tile_rat.len();
for start_a in 0..n {
for start_b in 0..m {
let (ext_start, len, ext_end) = boundary_rat
.get_match(MatchSeed::new(start_a as i64, start_b as i64), tile_rat)
.parts();
if len == 0 {
continue;
}
let es = ext_start;
let ee = ext_end;
canonical.insert((tile_id, es, len, ee));
}
}
}
for &(tile_id, ext_start, len, ext_end) in &canonical {
let in_range = |edge: usize| (edge + n - ext_start) % n < len;
let touches_cw = in_range((pos + n - 1) % n);
let touches_ccw = in_range(pos);
if !touches_cw && !touches_ccw {
continue;
}
let pm = PatchMatch::new(
EdgeRange::new(ext_start, len),
Segment::new(tile_id, EdgeRange::new(ext_end, len)),
);
let Some(gp2) = witness.with_tile(&pm) else {
continue;
};
for new_pos in 0..gp2.len() {
if !gp2.is_junction(new_pos) {
continue;
}
if let Some(nv) = gp2.junction_type_at(new_pos) {
assert!(
idx.get_id(&nv).is_some(),
"completeness violation: canonical glue \
(tile {tile_id}, start_a {ext_start}, start_b {ext_end}, len {len}) \
from src JT id {src_id} exposes JT at new_pos {new_pos} \
not in catalog"
);
}
}
}
}
}
#[test]
fn hexagon_catalog_validity() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_catalog_validity(&idx);
}
#[test]
fn square_catalog_validity() {
let sq: crate::geom::snake::Snake<ZZ4> = tiles::square();
let rat = Rat::try_from(&sq).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_catalog_validity(&idx);
}
#[test]
fn spectre_catalog_validity() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_catalog_validity(&idx);
}
#[test]
fn hexagon_catalog_complete_brute() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_catalog_complete_brute(&idx);
}
#[test]
fn square_catalog_complete_brute() {
let sq: crate::geom::snake::Snake<ZZ4> = tiles::square();
let rat = Rat::try_from(&sq).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_catalog_complete_brute(&idx);
}
#[test]
fn vt_witness_touching_matches_match_brute() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
for info in idx.entries() {
let witness = info.witness();
let pos = info.witness_pos();
let n = witness.len();
let boundary_rat = Rat::from_slice_trusted(witness.angles());
let mut filtered_brute: std::collections::BTreeSet<(usize, usize, usize, usize)> =
std::collections::BTreeSet::new();
for tile_id in 0..ts.num_tiles() {
let tile_rat = ts.rat(tile_id);
let m = tile_rat.len();
for start_a in 0..n {
for start_b in 0..m {
let (ns, len, ne) = boundary_rat
.get_match(MatchSeed::new(start_a as i64, start_b as i64), tile_rat)
.parts();
if len == 0 {
continue;
}
let ns_u = ns;
let ne_u = ne;
let in_range = |edge: usize| (edge + n - ns_u) % n < len;
if !in_range((pos + n - 1) % n) && !in_range(pos) {
continue;
}
if !crate::geom::glue::junctions_glueable(
witness.angles(),
ns_u,
len,
tile_rat.seq(),
ne_u,
) {
continue;
}
if boundary_rat
.try_glue_match(
Match::new(EdgeRange::new(ns, len), EdgeRange::new(ne, len)),
tile_rat,
Validation::Local,
)
.is_ok()
{
filtered_brute.insert((tile_id, ns_u, len, ne_u));
}
}
}
}
let api_set: std::collections::BTreeSet<(usize, usize, usize, usize)> = witness
.get_matches_touching_vertex(pos)
.into_iter()
.map(|pm| {
(
pm.b.tile_id,
pm.a_range.start_offset,
pm.len(),
pm.b.range.start_offset,
)
})
.collect();
assert_eq!(
filtered_brute,
api_set,
"JT id {} (witness n={n} pos={pos}): brute != api",
idx.get_id(info.jtype()).unwrap()
);
}
}
#[test]
#[ignore]
fn diagnose_spectre_touching_matches() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
let info = idx.get_info(1);
let witness = info.witness();
let pos = info.witness_pos();
let n = witness.len();
let boundary_rat = Rat::from_slice_trusted(witness.angles());
let mut filtered_brute: std::collections::BTreeSet<(usize, usize, usize, usize)> =
std::collections::BTreeSet::new();
let mut unfiltered_brute: std::collections::BTreeSet<(usize, usize, usize, usize)> =
std::collections::BTreeSet::new();
for tile_id in 0..ts.num_tiles() {
let tile_rat = ts.rat(tile_id);
let m = tile_rat.len();
for start_a in 0..n {
for start_b in 0..m {
let (ns, len, ne) = boundary_rat
.get_match(MatchSeed::new(start_a as i64, start_b as i64), tile_rat)
.parts();
if len == 0 {
continue;
}
let ns_u = ns;
let ne_u = ne;
let in_range = |edge: usize| (edge + n - ns_u) % n < len;
if !in_range((pos + n - 1) % n) && !in_range(pos) {
continue;
}
unfiltered_brute.insert((tile_id, ns_u, len, ne_u));
if !crate::geom::glue::junctions_glueable(
witness.angles(),
ns_u,
len,
tile_rat.seq(),
ne_u,
) {
continue;
}
if boundary_rat
.try_glue_match(
Match::new(EdgeRange::new(ns, len), EdgeRange::new(ne, len)),
tile_rat,
Validation::Local,
)
.is_ok()
{
filtered_brute.insert((tile_id, ns_u, len, ne_u));
}
}
}
}
let api_set: std::collections::BTreeSet<(usize, usize, usize, usize)> = witness
.get_matches_touching_vertex(pos)
.into_iter()
.map(|pm| {
(
pm.b.tile_id,
pm.a_range.start_offset,
pm.len(),
pm.b.range.start_offset,
)
})
.collect();
eprintln!(
"JT id 1: n={n} pos={pos} unfiltered={} filtered={} api={}",
unfiltered_brute.len(),
filtered_brute.len(),
api_set.len()
);
let only_filt: Vec<_> = filtered_brute.difference(&api_set).collect();
let only_api: Vec<_> = api_set.difference(&filtered_brute).collect();
eprintln!(
" filtered-only-not-api: {} | api-only: {}",
only_filt.len(),
only_api.len()
);
let tile_seq = ts.rat(0).seq();
let bnd_angles = witness.angles();
let edges = witness.edges();
for x in only_filt.iter() {
let &(_tile, sa, len, sb) = *x;
let m = tile_seq.len();
let next_sa = (sa + len) % n;
let underlying_tile = edges[sa].tile_type_id;
let underlying_off = edges[sa].canon_offset;
let underlying_next_tile = edges[next_sa].tile_type_id;
let underlying_next_off = edges[next_sa].canon_offset;
let underlying_seq = ts.rat(underlying_tile).seq();
let underlying_next_seq = ts.rat(underlying_next_tile).seq();
let bnd_left = bnd_angles[sa] as i32 + tile_seq[sb] as i32;
let bnd_right = bnd_angles[next_sa] as i32 + tile_seq[(sb + m - len) % m] as i32;
let clean_left = underlying_seq[underlying_off] as i32 + tile_seq[sb] as i32;
let clean_right = underlying_next_seq[underlying_next_off] as i32
+ tile_seq[(sb + m - len) % m] as i32;
let clean_tile_a = ts.rat(underlying_tile);
let (ns_clean, len_clean, ne_clean) = clean_tile_a
.get_match(MatchSeed::new(underlying_off as i64, sb as i64), ts.rat(0))
.parts();
eprintln!(
" miss (sa={sa},len={len},sb={sb}): \
underlying_off={underlying_off} \
clean canonical at (off={underlying_off}, sb={sb}): \
(ns={ns_clean},len={len_clean},ne={ne_clean})"
);
let _ = (bnd_left, bnd_right, clean_left, clean_right);
}
let mti = crate::geom::matchtypes::MatchTypeIndex::new(Arc::clone(&ts));
let cands = mti.candidates_starting_at(0, 0);
eprintln!(
" MatchTypeIndex.by_start[0][0] = {} candidates",
cands.len()
);
eprintln!(" filtered_brute entries:");
for x in filtered_brute.iter() {
let in_api = api_set.contains(x);
eprintln!(" brute {x:?} in_api={in_api}");
}
eprintln!(" api_set entries:");
for x in api_set.iter() {
eprintln!(" api {x:?}");
}
let mut juncs: Vec<usize> = Vec::new();
for i in 0..n {
let ei = witness.edges()[i];
if ts.rat(ei.tile_type_id).seq()[ei.canon_offset] != witness.angles()[i] {
juncs.push(i);
}
}
eprintln!(" junctions in witness: {juncs:?}");
eprintln!(
" edges[25]: tile_id={} tile_offset={}",
edges[25].tile_type_id, edges[25].canon_offset
);
for (i, e) in edges.iter().enumerate().skip(n - 3).take(3) {
eprintln!(
" edges[{i}]: tile_id={} tile_offset={}",
e.tile_type_id, e.canon_offset
);
}
for (i, e) in edges.iter().enumerate().take(3) {
eprintln!(
" edges[{i}]: tile_id={} tile_offset={}",
e.tile_type_id, e.canon_offset
);
}
let result = crate::geom::patch::EPatch::<ZZ12>::compute_all_candidates(
&Arc::new(crate::geom::matchtypes::MatchTypeIndex::new(Arc::clone(
&ts,
))),
witness.angles(),
witness.edges(),
);
eprintln!(" result[25] = {} entries:", result[25].len());
for pm in &result[25] {
eprintln!(
" cac: tile={} start_a={} len={} start_b={}",
pm.b.tile_id,
pm.a_range.start_offset,
pm.len(),
pm.b.range.start_offset
);
}
let bnd_rat = Rat::from_slice_trusted(witness.angles());
let mut seen: std::collections::HashSet<(usize, usize, usize, usize)> =
std::collections::HashSet::new();
eprintln!(" Simulated compute_candidates_at_position(pos=25, tile_id=0, off=0):");
for c in cands {
let tile_b = ts.rat(c.tile_id);
let (ns, len, ne) = bnd_rat
.get_match(MatchSeed::new(25i64, c.range.start_offset as i64), tile_b)
.parts();
if len == 0 {
continue;
}
let ns_u = ns;
let ne_u = ne;
let gap_ok = crate::geom::glue::junctions_glueable(
witness.angles(),
ns_u,
len,
tile_b.seq(),
ne_u,
);
let key = (ns_u, len, ne_u, c.tile_id);
let already_seen = seen.contains(&key);
let glue_ok = bnd_rat
.try_glue_match(
Match::new(EdgeRange::new(ns, len), EdgeRange::new(ne, len)),
tile_b,
Validation::Local,
)
.is_ok();
let dst = (c.tile_id, ns_u, len, ne_u);
let target_match = filtered_brute.contains(&dst) && dst.1 == 25;
if target_match || !already_seen {
eprintln!(
" cand sb={} len={} -> bnd ({ns_u},{len},{ne_u}) gap_ok={gap_ok} \
seen_already={already_seen} glue_ok={glue_ok} target={target_match}",
c.range.start_offset, c.range.len
);
}
seen.insert(key);
}
}
#[test]
#[ignore]
fn spectre_catalog_complete_brute() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert_catalog_complete_brute(&idx);
}
#[test]
fn range_by_cw_single_tile() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
let slice = idx.range_by_cw(0);
assert_eq!(
slice.len(),
idx.num_types(),
"single-tile tileset: all types have cw.tile_id == 0"
);
for info in slice {
assert_eq!(info.jtype().cw.tile_type_id, 0);
}
let empty = idx.range_by_cw(1);
assert!(empty.is_empty(), "no types with cw.tile_id == 1");
}
#[test]
fn range_by_cw_multi_tile() {
let sq: crate::geom::snake::Snake<ZZ12> = tiles::square();
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let sq_rat = Rat::try_from(&sq).unwrap();
let hex_rat = Rat::try_from(&hex).unwrap();
let ts = Arc::new(TileSet::new(vec![sq_rat, hex_rat]));
let idx = OpenJunctionTypeIndex::new(ts);
let n = idx.num_types();
assert!(n > 0);
let sq_slice = idx.range_by_cw(0);
let hex_slice = idx.range_by_cw(1);
assert_eq!(
sq_slice.len() + hex_slice.len(),
n,
"partition must cover all types"
);
assert!(
!sq_slice.is_empty(),
"should have types starting with square"
);
assert!(!hex_slice.is_empty(), "should have types starting with hex");
for info in sq_slice {
assert_eq!(info.jtype().cw.tile_type_id, 0);
}
for info in hex_slice {
assert_eq!(info.jtype().cw.tile_type_id, 1);
}
let empty = idx.range_by_cw(2);
assert!(empty.is_empty());
}
#[test]
fn spectre_junction_type_counts() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
let alive = idx.entries.iter().filter(|e| e.is_alive()).count();
let dead = idx.entries.iter().filter(|e| e.is_dead()).count();
let undead = idx.entries.iter().filter(|e| e.is_undead()).count();
let blessed = idx.entries.iter().filter(|e| e.is_blessed()).count();
let free = idx.entries.iter().filter(|e| e.is_free()).count();
let cursed = idx.entries.iter().filter(|e| e.is_cursed()).count();
eprintln!(
"spectre: {} types ({} alive: {} Blessed + {} Free, {} cursed: {} Dead + {} Undead, {} transitions)",
idx.num_types(),
alive,
blessed,
free,
cursed,
dead,
undead,
idx.transitions().len()
);
for e in idx.entries() {
let implied = crate::geom::patch::junction_angle_sequence(e.jtype(), ts.as_ref());
assert_eq!(
implied.last().copied(),
Some(e.witness().angles()[e.witness_pos()]),
"junction type must be FAITHFUL (inner reproduces the boundary angle): {:?}",
e.jtype()
);
}
assert_eq!(idx.num_types(), 179, "spectre JT count");
assert_eq!(alive + cursed, 179, "alive + cursed = total");
assert_eq!(dead + undead, cursed, "cursed = Dead + Undead");
assert_eq!(blessed + free, alive, "alive = Blessed + Free");
assert_eq!(alive, 67, "spectre alive count");
assert_eq!(blessed, 48, "spectre blessed count");
assert_eq!(free, 19, "spectre free count");
assert_eq!(cursed, 112, "spectre cursed count");
assert_eq!(dead, 92, "spectre dead count");
assert_eq!(undead, 20, "spectre undead count");
assert_eq!(idx.transitions().len(), 339, "spectre transition count");
}
fn reconstruct_transition_match<T: IsRing>(
idx: &OpenJunctionTypeIndex<T>,
ts: &Arc<TileSet<T>>,
t: &TransitionInfo,
) -> (
crate::geom::patch::EPatch<T>,
usize,
crate::geom::matches::PatchMatch,
) {
let info = idx.entries().get(t.src_id - 1).expect("src JT in range");
let witness = info.witness();
let pos = info.witness_pos();
let n = witness.len();
let m = ts.rat(t.tile_id).seq().len();
let touching = witness.get_matches_touching_vertex(pos);
let matching: Vec<&crate::geom::matches::PatchMatch> = touching
.iter()
.filter(|pm| {
if pm.b.tile_id != t.tile_id {
return false;
}
let edge_in_match =
|edge: usize| -> bool { (edge + n - pm.a_range.start_offset) % n < pm.len() };
let consumes_cw = edge_in_match((pos + n - 1) % n);
let consumes_ccw = edge_in_match(pos);
let pm_side = match (consumes_cw, consumes_ccw) {
(true, true) => TransitionSide::Both,
(true, false) => TransitionSide::Cw,
(false, true) => TransitionSide::Ccw,
(false, false) => return false,
};
if pm_side != t.side {
return false;
}
let edge_pos = match pm_side {
TransitionSide::Ccw => pos,
_ => (pos + n - 1) % n,
};
let offset_in_match = (edge_pos as i64 - pm.a_range.start_offset as i64)
.rem_euclid(n as i64) as usize;
let computed = (pm.b.range.start_offset as i64 - 1 - offset_in_match as i64)
.rem_euclid(m as i64) as usize;
computed == t.tile_offset
})
.collect();
assert_eq!(
matching.len(),
1,
"transition {:?} (src=#{}) must reconstruct to exactly ONE \
PatchMatch on its witness (encoding injectivity)",
(t.dst_id, t.side, t.tile_id, t.tile_offset),
t.src_id,
);
(witness.clone(), pos, *matching[0])
}
fn assert_vt_transition_metadata_is_injective<T: IsRing>(
idx: &OpenJunctionTypeIndex<T>,
ts: &Arc<TileSet<T>>,
) {
for t in idx.transitions() {
let (witness, pos, pm) = reconstruct_transition_match(idx, ts, t);
let n = witness.len();
let mut gp2 = witness.clone();
assert!(
gp2.add_tile(&pm).is_some(),
"transition {:?}: add_tile failed on reconstructed glue",
(t.src_id, t.dst_id, t.side, t.tile_id, t.tile_offset)
);
if t.dst_id == CLOSED_ID {
let info = idx.entries().get(t.src_id - 1).expect("src JT in range");
let closed = ClosedJunctionType::from_open_via_closure(info.jtype());
let recovered_cjt_id = idx.get_closed_id(&closed).expect("closed JT in catalog");
assert_eq!(
Some(recovered_cjt_id),
t.closed_jt_id,
"closing transition {:?} reconstructed closed_jt_id mismatch",
(t.src_id, t.side, t.tile_id, t.tile_offset)
);
} else {
let junction_pos = if pm.a_range.start_offset == pos {
n - pm.len()
} else {
0
};
let new_vt = gp2.junction_type_at(junction_pos).unwrap_or_else(|| {
panic!(
"open transition {:?}: post-glue junction missing at pos {}",
(t.src_id, t.dst_id, t.side, t.tile_id, t.tile_offset),
junction_pos
)
});
let dst_info = idx.entries().get(t.dst_id - 1).expect("dst JT in range");
assert_eq!(
&new_vt,
dst_info.jtype(),
"open transition {:?}: reconstructed dst JT doesn't match catalog",
(t.src_id, t.dst_id, t.side, t.tile_id, t.tile_offset)
);
}
}
}
#[test]
fn hex_vt_transition_metadata_is_injective() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
assert_vt_transition_metadata_is_injective(&idx, &ts);
}
#[test]
fn square_vt_transition_metadata_is_injective() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::square();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
assert_vt_transition_metadata_is_injective(&idx, &ts);
}
#[test]
fn spectre_vt_transition_metadata_is_injective() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
assert_vt_transition_metadata_is_injective(&idx, &ts);
}
#[test]
fn spectre_old_encoding_would_collapse_transitions() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
let mut old_keyed: std::collections::HashSet<(usize, TransitionSide, usize, usize, usize)> =
std::collections::HashSet::new();
for t in idx.transitions() {
let (witness, pos, pm) = reconstruct_transition_match(&idx, &ts, t);
let n = witness.len();
let m = ts.rat(t.tile_id).seq().len();
let edge_pos = match t.side {
TransitionSide::Ccw => pos,
_ => (pos + n - 1) % n,
};
let offset_in_match =
(edge_pos as i64 - pm.a_range.start_offset as i64).rem_euclid(n as i64) as usize;
let old_tile_offset = (pm.b.range.start_offset as i64 + pm.len() as i64
- offset_in_match as i64)
.rem_euclid(m as i64) as usize;
let dst_key = if t.dst_id == CLOSED_ID {
t.closed_jt_id.unwrap_or(0) + 1_000_000
} else {
t.dst_id
};
old_keyed.insert((t.src_id, t.side, t.tile_id, old_tile_offset, dst_key));
}
let collapsed_under_old = idx.transitions().len() - old_keyed.len();
assert_eq!(
collapsed_under_old,
3,
"expected exactly 3 OLD-encoding collisions in spectre catalog \
(new total {}, OLD-keyed total {})",
idx.transitions().len(),
old_keyed.len()
);
}
#[test]
fn spectre_transitions_well_formed() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
let n_types = idx.num_types();
for t in idx.transitions() {
assert!(
t.src_id >= 1 && t.src_id <= n_types,
"src_id {} out of range",
t.src_id
);
assert!(
t.dst_id == CLOSED_ID || (t.dst_id >= 1 && t.dst_id <= n_types),
"dst_id {} out of range (and not CLOSED)",
t.dst_id
);
assert!(t.tile_id < ts.num_tiles());
assert!(t.tile_offset < ts.rat(t.tile_id).len());
}
}
#[test]
fn spectre_cursed_invariant() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
for info in idx.entries() {
if !info.is_cursed() {
continue;
}
let id = idx.get_id(info.jtype()).unwrap();
let outgoing: Vec<&TransitionInfo> = idx
.transitions()
.iter()
.filter(|t| t.src_id == id)
.collect();
if info.is_dead() {
assert!(
outgoing.is_empty(),
"Dead JT id {id} must have no transitions, got {}",
outgoing.len()
);
continue;
}
assert!(
!outgoing.is_empty(),
"Undead JT id {id} must have at least one transition"
);
for t in &outgoing {
assert!(
!t.is_closed(),
"Undead JT id {id} has a closing transition — \
should have been classified Free"
);
let dst_info = idx.get_info(t.dst_id);
assert!(
dst_info.is_cursed(),
"Undead JT id {id} has open transition to non-cursed JT id {}",
t.dst_id
);
}
}
}
#[test]
fn spectre_blessed_invariant() {
let s: crate::geom::snake::Snake<ZZ12> = tiles::spectre();
let rat = Rat::try_from(&s).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(Arc::clone(&ts));
for info in idx.entries() {
if !info.is_blessed() {
continue;
}
let id = idx.get_id(info.jtype()).unwrap();
let outgoing: Vec<&TransitionInfo> = idx
.transitions()
.iter()
.filter(|t| t.src_id == id)
.collect();
assert!(
!outgoing.is_empty(),
"Blessed JT id {id} has no transitions"
);
for t in &outgoing {
assert!(
t.is_closed() || idx.get_info(t.dst_id).is_blessed(),
"Blessed JT id {id} has open transition to non-Blessed JT id {}",
t.dst_id
);
}
}
}
#[test]
fn hexagon_closed_junction_types_are_discovered() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert!(
idx.num_closed_types() >= 1,
"hex has at least one closed JT"
);
for cjt in idx.closed_entries() {
assert!(!cjt.jtype().is_empty());
assert!(cjt.transition_count() >= 1);
for e in cjt.jtype().edges() {
assert_eq!(e.tile_type_id, 0, "only one tile in this tileset");
assert!(e.canon_offset < 6);
}
}
let n_closing: usize = idx.transitions().iter().filter(|t| t.is_closed()).count();
assert!(n_closing > 0, "hex has at least one closing transition");
let total_per_vt: usize = idx
.closed_entries()
.iter()
.map(|c| c.transition_count())
.sum();
assert_eq!(
total_per_vt, n_closing,
"sum of per-closed-JT transition counts equals total closings"
);
}
#[test]
fn square_closed_junction_types_are_discovered() {
let sq: crate::geom::snake::Snake<ZZ4> = tiles::square();
let rat = Rat::try_from(&sq).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
assert!(
idx.num_closed_types() >= 1,
"square has at least one closed JT"
);
for cjt in idx.closed_entries() {
assert!(!cjt.jtype().is_empty());
assert!(cjt.transition_count() >= 1);
for e in cjt.jtype().edges() {
assert_eq!(e.tile_type_id, 0);
assert!(e.canon_offset < 4);
}
}
}
#[test]
fn closed_vts_are_lex_min_canonical() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
for cjt in idx.closed_entries() {
let canonical = ClosedJunctionType::from_cyclic(cjt.jtype().edges());
assert_eq!(cjt.jtype(), &canonical, "closed JT not in canonical form");
}
}
#[test]
fn closing_transitions_resolve_to_canonical_closed_vts() {
let hex: crate::geom::snake::Snake<ZZ12> = tiles::hexagon();
let rat = Rat::try_from(&hex).unwrap();
let ts = TileSet::single(rat);
let idx = OpenJunctionTypeIndex::new(ts);
for t in idx.transitions() {
if t.is_closed() {
let cvt_id = t.closed_jt_id.expect("closing transition has closed_jt_id");
assert!(cvt_id >= 1 && cvt_id <= idx.num_closed_types());
let cjt = idx.get_closed_type(cvt_id);
let src_open = idx.get_type(t.src_id);
let expected = ClosedJunctionType::from_open_via_closure(src_open);
assert_eq!(
cjt,
&expected,
"closed JT for transition {:?} differs from its derived form",
(t.src_id, t.tile_id)
);
} else {
assert!(
t.closed_jt_id.is_none(),
"non-closing transition has unexpected closed_jt_id"
);
}
}
}
}