#![forbid(unsafe_code)]
use serde::de::Error as DeError;
use serde::{Deserialize, Deserializer, Serialize};
use std::num::NonZeroU32;
use std::{error::Error, fmt};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EdgeType {
Spacelike,
Timelike,
Acausal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimplexType {
Up,
Down,
}
impl SimplexType {
#[must_use]
pub const fn to_i32(self) -> i32 {
match self {
Self::Up => 1,
Self::Down => -1,
}
}
#[must_use]
pub const fn from_i32(value: i32) -> Option<Self> {
match value {
1 => Some(Self::Up),
-1 => Some(Self::Down),
_ => None,
}
}
}
#[must_use]
pub fn classify_edge(t0: Option<u32>, t1: Option<u32>) -> Option<EdgeType> {
let t0 = t0?;
let t1 = t1?;
if t0 == t1 {
Some(EdgeType::Spacelike)
} else if t0.abs_diff(t1) == 1 {
Some(EdgeType::Timelike)
} else {
Some(EdgeType::Acausal)
}
}
#[must_use]
pub fn classify_simplex(t0: Option<u32>, t1: Option<u32>, t2: Option<u32>) -> Option<SimplexType> {
let t0 = t0?;
let t1 = t1?;
let t2 = t2?;
let min_t = t0.min(t1).min(t2);
let max_t = t0.max(t1).max(t2);
if max_t - min_t != 1 {
return None;
}
let lower_count = [t0, t1, t2].iter().filter(|&&t| t == min_t).count();
match lower_count {
2 => Some(SimplexType::Up), 1 => Some(SimplexType::Down), _ => None, }
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FoliationError {
EmptyFoliation,
SliceSizeMismatch {
slice_sizes_len: usize,
num_slices: u32,
},
LabelCountMismatch {
labeled: usize,
expected: usize,
},
MissingVertexLabel {
vertex: usize,
},
OutOfRangeVertexLabel {
vertex: usize,
label: u32,
expected_range_end: usize,
},
LabelMismatch {
slice: usize,
expected: usize,
actual: usize,
},
StaleBookkeeping {
synced_at_modification: Option<u64>,
current_modification_count: u64,
},
EmptySlice {
slice: usize,
},
SliceSizeSumMismatch {
sum: usize,
labeled: usize,
},
SpacelikeSubgraphSizeMismatch {
slice: usize,
observed: usize,
expected: usize,
},
SpacelikeDegreeViolation {
slice: usize,
vertex: String,
observed_degree: usize,
},
SpacelikeNonClosedRing {
slice: usize,
walked: usize,
expected: usize,
},
MissingTemporalWrapAround {
slice: usize,
missing_neighbor: usize,
},
}
impl fmt::Display for FoliationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyFoliation => write!(f, "foliation must contain at least one time slice"),
Self::SliceSizeMismatch {
slice_sizes_len,
num_slices,
} => write!(
f,
"slice_sizes length ({slice_sizes_len}) != num_slices ({num_slices})"
),
Self::LabelCountMismatch { labeled, expected } => write!(
f,
"labeled vertex count ({labeled}) does not match triangulation vertex count ({expected})"
),
Self::MissingVertexLabel { vertex } => {
write!(f, "vertex index {vertex} is missing a time label")
}
Self::OutOfRangeVertexLabel {
vertex,
label,
expected_range_end,
} => write!(
f,
"vertex index {vertex} has out-of-range time label {label}; expected 0..{expected_range_end}"
),
Self::LabelMismatch {
slice,
expected,
actual,
} => write!(
f,
"time slice {slice} has stored count {expected}, but live labels report {actual}"
),
Self::StaleBookkeeping {
synced_at_modification,
current_modification_count,
} => write!(
f,
"stored foliation bookkeeping is stale: synchronized at modification \
{synced_at_modification:?}, current modification {current_modification_count}"
),
Self::EmptySlice { slice } => write!(f, "time slice {slice} is empty"),
Self::SliceSizeSumMismatch { sum, labeled } => write!(
f,
"slice_sizes sum ({sum}) does not match labeled vertex count ({labeled})"
),
Self::SpacelikeSubgraphSizeMismatch {
slice,
observed,
expected,
} => write!(
f,
"toroidal spatial slice {slice} spacelike subgraph contains {observed} \
vertices but slice_sizes reports {expected}"
),
Self::SpacelikeDegreeViolation {
slice,
vertex,
observed_degree,
} => write!(
f,
"toroidal spatial slice {slice} vertex {vertex} has {observed_degree} \
spacelike neighbours, expected exactly 2 for a closed S¹"
),
Self::SpacelikeNonClosedRing {
slice,
walked,
expected,
} => write!(
f,
"toroidal spatial slice {slice} is not a single closed S¹: walked a \
cycle of length {walked} but slice has {expected} vertices"
),
Self::MissingTemporalWrapAround {
slice,
missing_neighbor,
} => write!(
f,
"toroidal foliation has no timelike edge from slice {slice} to slice \
{missing_neighbor}; toroidal topology requires temporal wrap-around"
),
}
}
}
impl Error for FoliationError {}
#[derive(Debug, Clone, Serialize)]
pub struct Foliation {
slice_sizes: Vec<usize>,
num_slices: NonZeroU32,
}
#[derive(Deserialize)]
struct SerializedFoliation {
slice_sizes: Vec<usize>,
num_slices: u32,
}
impl<'de> Deserialize<'de> for Foliation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let serialized = SerializedFoliation::deserialize(deserializer)?;
Self::from_raw_slice_sizes(serialized.slice_sizes, serialized.num_slices)
.map_err(D::Error::custom)
}
}
impl Foliation {
pub fn from_slice_sizes(
slice_sizes: Vec<usize>,
num_slices: NonZeroU32,
) -> Result<Self, FoliationError> {
Self::from_nonzero_slice_sizes(slice_sizes, num_slices)
}
fn from_raw_slice_sizes(
slice_sizes: Vec<usize>,
num_slices: u32,
) -> Result<Self, FoliationError> {
let Some(num_slices) = NonZeroU32::new(num_slices) else {
return Err(FoliationError::EmptyFoliation);
};
Self::from_nonzero_slice_sizes(slice_sizes, num_slices)
}
fn from_nonzero_slice_sizes(
slice_sizes: Vec<usize>,
num_slices: NonZeroU32,
) -> Result<Self, FoliationError> {
if slice_sizes.is_empty() {
return Err(FoliationError::EmptyFoliation);
}
if slice_sizes.len() != num_slices.get() as usize {
return Err(FoliationError::SliceSizeMismatch {
slice_sizes_len: slice_sizes.len(),
num_slices: num_slices.get(),
});
}
if let Some(slice) = slice_sizes.iter().position(|&slice_size| slice_size == 0) {
return Err(FoliationError::EmptySlice { slice });
}
Ok(Self {
slice_sizes,
num_slices,
})
}
#[must_use]
pub fn slice_sizes(&self) -> &[usize] {
&self.slice_sizes
}
#[must_use]
pub const fn num_slices(&self) -> NonZeroU32 {
self.num_slices
}
#[must_use]
pub fn labeled_vertex_count(&self) -> usize {
self.slice_sizes.iter().sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::from_str;
fn slice_count(value: u32) -> NonZeroU32 {
NonZeroU32::new(value).expect("test slice count should be nonzero")
}
#[test]
fn test_edge_type_equality() {
assert_eq!(EdgeType::Spacelike, EdgeType::Spacelike);
assert_eq!(EdgeType::Timelike, EdgeType::Timelike);
assert_ne!(EdgeType::Spacelike, EdgeType::Timelike);
}
#[test]
fn test_foliation_empty_slice_rejected() {
let result = Foliation::from_slice_sizes(vec![0, 0, 0], slice_count(3));
let err = result.expect_err("empty slices should be rejected");
assert_eq!(err, FoliationError::EmptySlice { slice: 0 });
}
#[test]
fn test_foliation_zero_slices_rejected() {
let result = Foliation::from_raw_slice_sizes(vec![], 0);
let err = result.expect_err("zero-slice foliations should be rejected");
assert_eq!(err, FoliationError::EmptyFoliation);
}
#[test]
fn test_foliation_populated() {
let fol = Foliation::from_slice_sizes(vec![3, 3], slice_count(2)).expect("valid foliation");
assert_eq!(fol.num_slices().get(), 2);
assert_eq!(fol.labeled_vertex_count(), 6);
assert_eq!(fol.slice_sizes()[0], 3);
assert_eq!(fol.slice_sizes()[1], 3);
}
#[test]
fn test_foliation_slice_size_mismatch() {
let result = Foliation::from_slice_sizes(vec![3, 3], slice_count(3));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(
err,
FoliationError::SliceSizeMismatch {
slice_sizes_len: 2,
num_slices: 3,
}
);
}
#[test]
fn foliation_deserialization_rejects_invalid_bookkeeping() {
let zero_slice_err = from_str::<Foliation>(r#"{"slice_sizes":[],"num_slices":0}"#)
.expect_err("zero slices should fail deserialization");
assert!(
zero_slice_err
.to_string()
.contains("at least one time slice")
);
let empty_err = from_str::<Foliation>(r#"{"slice_sizes":[0],"num_slices":1}"#)
.expect_err("empty slices should fail deserialization");
assert!(empty_err.to_string().contains("empty"));
let mismatch_err = from_str::<Foliation>(r#"{"slice_sizes":[3,3],"num_slices":3}"#)
.expect_err("mismatched slice counts should fail deserialization");
assert!(mismatch_err.to_string().contains("slice_sizes length"));
}
#[test]
fn test_classify_edge_spacelike() {
assert_eq!(classify_edge(Some(0), Some(0)), Some(EdgeType::Spacelike));
}
#[test]
fn test_classify_edge_timelike() {
assert_eq!(classify_edge(Some(0), Some(1)), Some(EdgeType::Timelike));
}
#[test]
fn test_classify_edge_unlabeled_returns_none() {
assert_eq!(classify_edge(Some(0), None), None);
assert_eq!(classify_edge(None, Some(1)), None);
assert_eq!(classify_edge(None, None), None);
}
#[test]
fn test_classify_edge_acausal() {
assert_eq!(
classify_edge(Some(0), Some(5)),
Some(EdgeType::Acausal),
"Edges with |Δt| > 1 should be Acausal"
);
assert_eq!(
classify_edge(Some(0), Some(2)),
Some(EdgeType::Acausal),
"Edges with |Δt| = 2 should be Acausal"
);
}
#[test]
fn test_simplex_type_encoding_roundtrip() {
assert_eq!(
SimplexType::from_i32(SimplexType::Up.to_i32()),
Some(SimplexType::Up)
);
assert_eq!(
SimplexType::from_i32(SimplexType::Down.to_i32()),
Some(SimplexType::Down)
);
}
#[test]
fn test_simplex_type_from_invalid_i32() {
assert_eq!(SimplexType::from_i32(0), None);
assert_eq!(SimplexType::from_i32(2), None);
assert_eq!(SimplexType::from_i32(-2), None);
}
#[test]
fn test_classify_simplex_up() {
assert_eq!(
classify_simplex(Some(0), Some(0), Some(1)),
Some(SimplexType::Up)
);
assert_eq!(
classify_simplex(Some(0), Some(1), Some(0)),
Some(SimplexType::Up)
);
assert_eq!(
classify_simplex(Some(1), Some(0), Some(0)),
Some(SimplexType::Up)
);
}
#[test]
fn test_classify_simplex_down() {
assert_eq!(
classify_simplex(Some(1), Some(1), Some(0)),
Some(SimplexType::Down)
);
assert_eq!(
classify_simplex(Some(1), Some(0), Some(1)),
Some(SimplexType::Down)
);
assert_eq!(
classify_simplex(Some(0), Some(1), Some(1)),
Some(SimplexType::Down)
);
}
#[test]
fn test_classify_simplex_same_slice_returns_none() {
assert_eq!(classify_simplex(Some(2), Some(2), Some(2)), None);
}
#[test]
fn test_classify_simplex_spans_two_slices_returns_none() {
assert_eq!(classify_simplex(Some(0), Some(1), Some(2)), None);
}
#[test]
fn test_classify_simplex_unlabeled_returns_none() {
assert_eq!(classify_simplex(Some(0), Some(0), None), None);
assert_eq!(classify_simplex(None, Some(0), Some(1)), None);
}
#[test]
fn test_foliation_error_label_count_mismatch_display() {
let err = FoliationError::LabelCountMismatch {
labeled: 5,
expected: 10,
};
let msg = err.to_string();
assert!(
msg.contains('5') && msg.contains("10"),
"Display should include both counts: {msg}"
);
}
#[test]
fn test_foliation_error_empty_slice_display() {
let err = FoliationError::EmptySlice { slice: 2 };
let msg = err.to_string();
assert!(
msg.contains('2') && msg.contains("empty"),
"Display should mention slice index: {msg}"
);
}
#[test]
fn test_foliation_error_empty_foliation_display() {
let err = FoliationError::EmptyFoliation;
assert_eq!(
err.to_string(),
"foliation must contain at least one time slice"
);
}
#[test]
fn test_foliation_error_missing_vertex_label_display() {
let err = FoliationError::MissingVertexLabel { vertex: 4 };
let msg = err.to_string();
assert!(
msg.contains('4') && msg.contains("missing"),
"Display should include vertex index and missing-label context: {msg}"
);
}
#[test]
fn test_foliation_error_label_mismatch_display() {
let err = FoliationError::LabelMismatch {
slice: 1,
expected: 3,
actual: 2,
};
let msg = err.to_string();
assert!(
msg.contains('1') && msg.contains('3') && msg.contains('2'),
"Display should include slice and both counts: {msg}"
);
}
#[test]
fn test_out_of_range_label_display() {
let err = FoliationError::OutOfRangeVertexLabel {
vertex: 2,
label: 9,
expected_range_end: 3,
};
let msg = err.to_string();
assert!(
msg.contains('2')
&& msg.contains('9')
&& msg.contains("out-of-range")
&& msg.contains("0..3"),
"Display should include vertex index, label, and expected range: {msg}"
);
}
#[test]
fn test_foliation_error_slice_size_sum_mismatch_display() {
let err = FoliationError::SliceSizeSumMismatch {
sum: 7,
labeled: 10,
};
let msg = err.to_string();
assert!(
msg.contains('7') && msg.contains("10"),
"Display should include both values: {msg}"
);
}
#[test]
fn stale_bookkeeping_display() {
let err = FoliationError::StaleBookkeeping {
synced_at_modification: Some(123),
current_modification_count: 456,
};
let msg = err.to_string();
assert!(
msg.contains("stale")
&& msg.contains("synchronized at modification")
&& msg.contains("Some(123)")
&& msg.contains("current modification 456"),
"Display should describe stale bookkeeping and both modification counts: {msg}"
);
}
#[test]
fn stale_bookkeeping_equality() {
let err = FoliationError::StaleBookkeeping {
synced_at_modification: Some(123),
current_modification_count: 456,
};
assert_eq!(
err,
FoliationError::StaleBookkeeping {
synced_at_modification: Some(123),
current_modification_count: 456,
}
);
assert_ne!(
err,
FoliationError::StaleBookkeeping {
synced_at_modification: None,
current_modification_count: 456,
}
);
assert_ne!(
err,
FoliationError::StaleBookkeeping {
synced_at_modification: Some(123),
current_modification_count: 789,
}
);
}
#[test]
fn test_foliation_error_equality() {
assert_eq!(
FoliationError::EmptySlice { slice: 0 },
FoliationError::EmptySlice { slice: 0 },
);
assert_ne!(
FoliationError::EmptySlice { slice: 0 },
FoliationError::EmptySlice { slice: 1 },
);
}
#[test]
fn test_foliation_error_spacelike_subgraph_size_mismatch_display() {
let err = FoliationError::SpacelikeSubgraphSizeMismatch {
slice: 2,
observed: 4,
expected: 5,
};
let msg = err.to_string();
assert!(
msg.contains("slice 2")
&& msg.contains("contains 4")
&& msg.contains("reports 5")
&& msg.contains("toroidal"),
"Display should describe the toroidal slice and both vertex counts: {msg}"
);
}
#[test]
fn test_foliation_error_spacelike_degree_violation_display() {
let err = FoliationError::SpacelikeDegreeViolation {
slice: 1,
vertex: "VertexKey(7v3)".to_string(),
observed_degree: 3,
};
let msg = err.to_string();
assert!(
msg.contains("slice 1")
&& msg.contains("VertexKey(7v3)")
&& msg.contains("3 spacelike neighbours")
&& msg.contains("closed S¹"),
"Display should identify the slice, vertex, observed degree, and S¹ expectation: {msg}"
);
}
#[test]
fn test_foliation_error_spacelike_non_closed_ring_display() {
let err = FoliationError::SpacelikeNonClosedRing {
slice: 0,
walked: 3,
expected: 6,
};
let msg = err.to_string();
assert!(
msg.contains("slice 0")
&& msg.contains("length 3")
&& msg.contains("6 vertices")
&& msg.contains("closed S¹"),
"Display should describe slice index, walked length, expected ring length, and S¹ expectation: {msg}"
);
}
#[test]
fn test_foliation_error_missing_temporal_wraparound_display() {
let err = FoliationError::MissingTemporalWrapAround {
slice: 0,
missing_neighbor: 2,
};
let msg = err.to_string();
assert!(
msg.contains("slice 0")
&& msg.contains("slice 2")
&& msg.contains("temporal wrap-around"),
"Display should describe missing wrap-around between specific slices: {msg}"
);
}
#[test]
fn test_foliation_error_spacelike_variant_equality() {
let a = FoliationError::SpacelikeSubgraphSizeMismatch {
slice: 2,
observed: 4,
expected: 5,
};
let b = a.clone();
let c = FoliationError::SpacelikeSubgraphSizeMismatch {
slice: 2,
observed: 5,
expected: 5,
};
assert_eq!(a, b, "identical Spacelike* variants should compare equal");
assert_ne!(
a, c,
"Spacelike* variants differing in any field should compare unequal"
);
}
}