#![forbid(unsafe_code)]
use crate::cdt::action::ActionConfig;
use crate::cdt::ergodic_moves::{ErgodicsSystem, MoveStatistics, MoveType};
use crate::cdt::results::{
CdtScalarTraceOutcome, CdtScalarTraceRow, Measurement, SimulationResultsBackend,
};
use crate::cdt::triangulation::SimulationEvent;
use crate::errors::{
CdtError, CdtResult, CheckpointResumeFailure, ConfigurationSetting,
MetropolisMoveApplicationFailure,
};
use crate::geometry::CdtTriangulation2D;
use markov_chain_monte_carlo::{
Chain, ChainCheckpoint, DelayedStep, DelayedStepError, Sampler, StepOutcome,
};
use rand::{SeedableRng, rngs::Xoshiro256PlusPlus};
use serde::{Deserialize, Serialize};
use std::num::NonZeroU32;
use std::time::{Duration, Instant};
use super::adapter::{
CdtProposal, CdtProposalError, CdtProposalInfo, CdtTarget, restore_checkpoint_state,
};
use super::checkpoint::{
CdtMcmcCheckpoint, CdtMcmcCheckpointParts, chain_counters, checkpoint_resume_failed,
validate_checkpoint_counters, validate_resume_compatible,
};
use super::helpers::{
action_for, actions_match, measurement_for, measurement_is_due, validate_metropolis_schedule,
};
use super::telemetry::{MonteCarloStep, MonteCarloStepOutcome, ProposalStatistics};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MetropolisConfig {
temperature: f64,
steps: NonZeroU32,
thermalization_steps: u32,
measurement_frequency: NonZeroU32,
seed: Option<u64>,
}
#[derive(Deserialize)]
struct MetropolisConfigWire {
temperature: f64,
steps: u32,
thermalization_steps: u32,
measurement_frequency: u32,
seed: Option<u64>,
}
impl<'de> Deserialize<'de> for MetropolisConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = MetropolisConfigWire::deserialize(deserializer)?;
Self::new_with_seed(
wire.temperature,
wire.steps,
wire.thermalization_steps,
wire.measurement_frequency,
wire.seed,
)
.map_err(serde::de::Error::custom)
}
}
impl Default for MetropolisConfig {
fn default() -> Self {
Self::from_validated_parts(
1.0,
default_step_count(),
100,
default_measurement_frequency(),
None,
)
}
}
impl MetropolisConfig {
pub fn new(
temperature: f64,
steps: u32,
thermalization_steps: u32,
measurement_frequency: u32,
) -> CdtResult<Self> {
Self::new_with_seed(
temperature,
steps,
thermalization_steps,
measurement_frequency,
None,
)
}
pub fn new_with_seed(
temperature: f64,
steps: u32,
thermalization_steps: u32,
measurement_frequency: u32,
seed: Option<u64>,
) -> CdtResult<Self> {
validate_metropolis_schedule(
temperature,
steps,
thermalization_steps,
measurement_frequency,
)?;
let Some(steps) = NonZeroU32::new(steps) else {
return Err(CdtError::InvalidSimulationConfiguration {
setting: ConfigurationSetting::Steps,
provided_value: steps.to_string(),
expected: "≥ 1".to_string(),
});
};
let Some(measurement_frequency) = NonZeroU32::new(measurement_frequency) else {
return Err(CdtError::InvalidSimulationConfiguration {
setting: ConfigurationSetting::MeasurementFrequency,
provided_value: measurement_frequency.to_string(),
expected: "≥ 1".to_string(),
});
};
Ok(Self::from_validated_parts(
temperature,
steps,
thermalization_steps,
measurement_frequency,
seed,
))
}
pub(crate) const fn from_validated_parts(
temperature: f64,
steps: NonZeroU32,
thermalization_steps: u32,
measurement_frequency: NonZeroU32,
seed: Option<u64>,
) -> Self {
Self {
temperature,
steps,
thermalization_steps,
measurement_frequency,
seed,
}
}
#[must_use]
pub const fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
#[must_use]
pub const fn temperature(&self) -> f64 {
self.temperature
}
#[must_use]
pub const fn steps(&self) -> NonZeroU32 {
self.steps
}
#[must_use]
pub const fn thermalization_steps(&self) -> u32 {
self.thermalization_steps
}
#[must_use]
pub const fn measurement_frequency(&self) -> NonZeroU32 {
self.measurement_frequency
}
#[must_use]
pub const fn seed(&self) -> Option<u64> {
self.seed
}
#[must_use]
pub fn beta(&self) -> f64 {
1.0 / self.temperature
}
pub fn validate(&self) {
debug_assert!(self.temperature.is_finite() && self.temperature > 0.0);
debug_assert!(self.thermalization_steps <= self.steps.get());
}
}
const fn default_step_count() -> NonZeroU32 {
match NonZeroU32::new(1000) {
Some(steps) => steps,
None => NonZeroU32::MIN,
}
}
const fn default_measurement_frequency() -> NonZeroU32 {
match NonZeroU32::new(10) {
Some(measurement_frequency) => measurement_frequency,
None => NonZeroU32::MIN,
}
}
struct MetropolisRunState {
triangulation: CdtTriangulation2D,
current_step: u32,
current_action: f64,
trace_seed: Option<u64>,
acceptance_rng: Xoshiro256PlusPlus,
ergodics: ErgodicsSystem,
move_stats: MoveStatistics,
proposal_stats: ProposalStatistics,
steps: Vec<MonteCarloStep>,
measurements: Vec<Measurement>,
scalar_trace_rows: Vec<CdtScalarTraceRow>,
elapsed_time: Duration,
}
pub struct MetropolisAlgorithm {
config: MetropolisConfig,
action_config: ActionConfig,
}
impl MetropolisAlgorithm {
#[must_use]
pub const fn new(config: MetropolisConfig, action_config: ActionConfig) -> Self {
Self {
config,
action_config,
}
}
pub(crate) const fn config(&self) -> &MetropolisConfig {
&self.config
}
pub(crate) const fn action_config(&self) -> &ActionConfig {
&self.action_config
}
pub fn run(&self, triangulation: CdtTriangulation2D) -> CdtResult<SimulationResultsBackend> {
Ok(self.run_to_checkpoint(triangulation)?.into_results())
}
pub fn run_with_checkpoint(
&self,
triangulation: CdtTriangulation2D,
) -> CdtResult<(SimulationResultsBackend, CdtMcmcCheckpoint)> {
let checkpoint = self.run_to_checkpoint(triangulation)?;
let results = checkpoint.clone().into_results();
Ok((results, checkpoint))
}
pub fn run_to_checkpoint(
&self,
triangulation: CdtTriangulation2D,
) -> CdtResult<CdtMcmcCheckpoint> {
self.config.validate();
self.action_config.validate();
let mut state = self.initial_state(triangulation)?;
self.run_steps(&mut state, self.config.steps)?;
state.into_checkpoint(self.config.clone(), self.action_config.clone())
}
pub fn resume_from_checkpoint(
&self,
checkpoint: CdtMcmcCheckpoint,
) -> CdtResult<SimulationResultsBackend> {
self.resume_to_checkpoint(checkpoint)
.map(CdtMcmcCheckpoint::into_results)
}
pub fn resume_to_checkpoint(
&self,
checkpoint: CdtMcmcCheckpoint,
) -> CdtResult<CdtMcmcCheckpoint> {
self.config.validate();
self.action_config.validate();
validate_resume_compatible(self, &checkpoint)?;
let mut result_config = checkpoint.config.clone();
let steps = checkpoint
.current_step
.get()
.checked_add(self.config.steps.get())
.ok_or_else(|| checkpoint_resume_failed(CheckpointResumeFailure::StepCountOverflow))?;
result_config.steps = NonZeroU32::new(steps)
.ok_or_else(|| checkpoint_resume_failed(CheckpointResumeFailure::StepCountOverflow))?;
let mut state = MetropolisRunState::from_checkpoint(checkpoint)?;
self.run_steps(&mut state, self.config.steps)?;
state.into_checkpoint(result_config, self.action_config.clone())
}
fn initial_state(
&self,
mut triangulation: CdtTriangulation2D,
) -> CdtResult<MetropolisRunState> {
let current_action = action_for(&self.action_config, &triangulation);
let mut measurements = Vec::new();
if measurement_is_due(
0,
self.config.thermalization_steps(),
self.config.measurement_frequency(),
) {
measurements.push(measurement_for(0, current_action, &triangulation)?);
triangulation.record_event(SimulationEvent::MeasurementTaken {
step: 0,
action: current_action,
});
}
Ok(MetropolisRunState {
triangulation,
current_step: 0,
current_action,
trace_seed: self.config.seed,
acceptance_rng: simulation_rng(self.config.seed),
ergodics: self.config.seed.map_or_else(ErgodicsSystem::new, |seed| {
ErgodicsSystem::with_seed(seed.wrapping_add(0x9E37_79B9_7F4A_7C15))
}),
move_stats: MoveStatistics::new(),
proposal_stats: ProposalStatistics::new(),
steps: Vec::new(),
measurements,
scalar_trace_rows: Vec::new(),
elapsed_time: Duration::ZERO,
})
}
fn run_steps(
&self,
state: &mut MetropolisRunState,
additional_steps: NonZeroU32,
) -> CdtResult<()> {
let start = Instant::now();
let target = CdtTarget::new(self.action_config.clone(), self.config.temperature())?;
let (accepted, rejected) = chain_counters(&state.move_stats)?;
let checkpoint = ChainCheckpoint::new(state.triangulation.clone(), accepted, rejected);
let chain = Chain::from_checkpoint(checkpoint, &target)?;
let mut proposal =
CdtProposal::from_ergodics(self.action_config.clone(), state.ergodics.clone());
let mut acceptance_rng = state.acceptance_rng.clone();
{
let mut sampler = Sampler::new(chain, &target, &mut proposal, &mut acceptance_rng)?;
for _ in 0..additional_steps.get() {
let step = state
.current_step
.checked_add(1)
.and_then(NonZeroU32::new)
.ok_or_else(|| {
checkpoint_resume_failed(CheckpointResumeFailure::StepCountOverflow)
})?;
let planned_step = match sampler.step_delayed() {
Ok(planned_step) => planned_step,
Err(err) => {
let error = planned_step_error(step.get(), err);
state.triangulation = sampler.chain_ref().state().clone();
drop(sampler);
state.acceptance_rng = acceptance_rng;
state.ergodics = proposal.into_ergodics();
state.elapsed_time += start.elapsed();
return Err(error);
}
};
debug_assert_eq!(
sampler.proposal_ref().last_step_info(),
planned_step.info,
"CDT proposal telemetry cache should mirror the upstream planned-step info"
);
record_planned_step(
self,
state,
step,
&planned_step,
planned_step
.info
.ok_or_else(|| missing_planned_step_info(step.get()))?,
sampler.proposal_ref().last_proposal_stats(),
sampler.chain_ref().state(),
)?;
sampler.replace_state(state.triangulation.clone())?;
state.current_step = step.get();
}
}
state.acceptance_rng = acceptance_rng;
state.ergodics = proposal.into_ergodics();
state.elapsed_time += start.elapsed();
Ok(())
}
}
impl MetropolisRunState {
fn from_checkpoint(checkpoint: CdtMcmcCheckpoint) -> CdtResult<Self> {
validate_checkpoint_counters(&checkpoint)?;
let stored_action = checkpoint.current_action();
let target = CdtTarget::new(
checkpoint.action_config.clone(),
checkpoint.config.temperature(),
)?;
let triangulation = restore_checkpoint_state(checkpoint.chain, &target)?;
triangulation.validate_evolved_cdt()?;
let actual_action = action_for(&checkpoint.action_config, &triangulation);
if !actions_match(actual_action, stored_action) {
return Err(checkpoint_resume_failed(
CheckpointResumeFailure::ActionMismatch {
stored: stored_action,
recomputed: actual_action,
},
));
}
Ok(Self {
triangulation,
current_step: checkpoint.current_step.get(),
current_action: stored_action,
trace_seed: checkpoint.config.seed(),
acceptance_rng: checkpoint.acceptance_rng,
ergodics: checkpoint.ergodics,
move_stats: checkpoint.move_stats,
proposal_stats: checkpoint.proposal_stats,
steps: checkpoint.steps,
measurements: checkpoint.measurements,
scalar_trace_rows: checkpoint.scalar_trace_rows,
elapsed_time: checkpoint.elapsed_time,
})
}
fn into_checkpoint(
self,
config: MetropolisConfig,
action_config: ActionConfig,
) -> CdtResult<CdtMcmcCheckpoint> {
self.triangulation.validate_evolved_cdt()?;
let (accepted, rejected) = chain_counters(&self.move_stats)?;
let current_step = NonZeroU32::new(self.current_step).ok_or_else(|| {
checkpoint_resume_failed(CheckpointResumeFailure::StepTelemetryLengthMismatch {
actual: 0,
expected: 1,
})
})?;
CdtMcmcCheckpoint::from_parts(CdtMcmcCheckpointParts {
triangulation: self.triangulation,
accepted,
rejected,
config,
action_config,
current_step,
current_action: self.current_action,
move_stats: self.move_stats,
proposal_stats: self.proposal_stats,
steps: self.steps,
measurements: self.measurements,
scalar_trace_rows: self.scalar_trace_rows,
elapsed_time: self.elapsed_time,
acceptance_rng: self.acceptance_rng,
ergodics: self.ergodics,
})
}
}
fn record_planned_step(
algorithm: &MetropolisAlgorithm,
state: &mut MetropolisRunState,
step: NonZeroU32,
planned_step: &DelayedStep<CdtProposalInfo>,
info: CdtProposalInfo,
proposal_stats: &ProposalStatistics,
triangulation: &CdtTriangulation2D,
) -> CdtResult<()> {
record_planned_step_parts(
algorithm,
state,
step,
PlannedStepRecord {
outcome: planned_step.outcome,
log_prob_after: planned_step.log_prob_after,
info,
proposal_stats,
triangulation,
},
)
}
#[derive(Clone, Copy)]
struct PlannedStepRecord<'a> {
outcome: StepOutcome,
log_prob_after: Option<f64>,
info: CdtProposalInfo,
proposal_stats: &'a ProposalStatistics,
triangulation: &'a CdtTriangulation2D,
}
const fn scalar_trace_outcome_for_step(
step: u32,
outcome: StepOutcome,
) -> CdtResult<CdtScalarTraceOutcome> {
match outcome {
StepOutcome::Accepted => Ok(CdtScalarTraceOutcome::Accepted),
StepOutcome::RejectedProposal => Ok(CdtScalarTraceOutcome::RejectedProposal),
StepOutcome::NoProposal => Ok(CdtScalarTraceOutcome::NoProposal),
outcome => Err(CdtError::UnexpectedPlannedStepOutcome { step, outcome }),
}
}
fn step_outcome_for_trace(
config: &MetropolisConfig,
step: NonZeroU32,
trace_outcome: CdtScalarTraceOutcome,
action_before: f64,
log_prob_after: Option<f64>,
info: &CdtProposalInfo,
) -> CdtResult<MonteCarloStepOutcome> {
match trace_outcome {
CdtScalarTraceOutcome::Accepted => {
let applied_action = info
.action_after
.or_else(|| {
log_prob_after.map(|log_prob_after| -config.temperature() * log_prob_after)
})
.ok_or_else(|| missing_planned_step_info(step.get()))?;
MonteCarloStepOutcome::accepted_transition(
step,
action_before,
applied_action,
applied_action - action_before,
)
}
CdtScalarTraceOutcome::RejectedProposal => {
MonteCarloStepOutcome::rejected_proposal(step, info.delta_action)
}
CdtScalarTraceOutcome::NoProposal => Ok(MonteCarloStepOutcome::NoProposal),
}
}
fn record_planned_step_parts(
algorithm: &MetropolisAlgorithm,
state: &mut MetropolisRunState,
step: NonZeroU32,
record: PlannedStepRecord<'_>,
) -> CdtResult<()> {
let PlannedStepRecord {
outcome,
log_prob_after,
info,
proposal_stats,
triangulation,
} = record;
let move_type = info.move_type;
let action_before = state.current_action;
let trace_outcome = scalar_trace_outcome_for_step(step.get(), outcome)?;
let step_outcome = step_outcome_for_trace(
&algorithm.config,
step,
trace_outcome,
action_before,
log_prob_after,
&info,
)?;
let action_after = step_outcome.action_after();
let delta_action = step_outcome.delta_action();
state.move_stats.record_attempt(move_type);
if let Some(applied_action) = action_after {
state.triangulation = triangulation.clone();
state.current_action = applied_action;
}
state
.triangulation
.record_event(SimulationEvent::MoveAttempted {
move_type,
step: step.get().into(),
});
if let Some(applied_action) = action_after {
state.move_stats.record_success(move_type);
state
.triangulation
.record_event(SimulationEvent::MoveAccepted {
move_type,
step: step.get().into(),
action_change: applied_action - action_before,
});
validate_evolved_cdt_if_due(state)?;
}
state.proposal_stats.extend(proposal_stats);
match trace_outcome {
CdtScalarTraceOutcome::Accepted => state.proposal_stats.record_accepted_transition(),
CdtScalarTraceOutcome::RejectedProposal => {
state.proposal_stats.record_metropolis_rejection();
}
CdtScalarTraceOutcome::NoProposal => {}
}
state.steps.push(MonteCarloStep::new(
step,
move_type,
action_before,
step_outcome,
)?);
state.scalar_trace_rows.push(CdtScalarTraceRow::new(
step,
trace_outcome,
-state.current_action / algorithm.config.temperature(),
state.current_action,
&state.triangulation,
move_type,
delta_action,
action_before,
action_after,
state.trace_seed,
)?);
if measurement_is_due(
step.get(),
algorithm.config.thermalization_steps(),
algorithm.config.measurement_frequency(),
) {
state.measurements.push(measurement_for(
step.get(),
state.current_action,
&state.triangulation,
)?);
state
.triangulation
.record_event(SimulationEvent::MeasurementTaken {
step: step.get().into(),
action: state.current_action,
});
}
Ok(())
}
fn planned_step_error(step: u32, error: DelayedStepError<CdtProposalError>) -> CdtError {
match error {
DelayedStepError::Mcmc(err) => CdtError::Mcmc(err),
DelayedStepError::Plan(err)
| DelayedStepError::ProposedLogProb(err)
| DelayedStepError::LogQRatio(err)
| DelayedStepError::Commit(err) => proposal_step_error(step, err),
unexpected => CdtError::PlannedProposalStepFailed {
step,
detail: unexpected.to_string(),
},
}
}
fn proposal_step_error(step: u32, error: CdtProposalError) -> CdtError {
match error {
CdtProposalError::ApplicationFailed {
move_type,
attempt,
source,
} => accepted_move_error(step, move_type, attempt, source),
}
}
const fn missing_planned_step_info(step: u32) -> CdtError {
CdtError::PlannedProposalTelemetryMissing { step }
}
fn validate_evolved_cdt_if_due(state: &MetropolisRunState) -> CdtResult<()> {
if state
.triangulation
.geometry()
.should_check_delaunay_after(state.move_stats.total_accepted())
{
state.triangulation.validate_evolved_cdt()?;
}
Ok(())
}
fn simulation_rng(seed: Option<u64>) -> Xoshiro256PlusPlus {
seed.map_or_else(rand::make_rng, Xoshiro256PlusPlus::seed_from_u64)
}
fn accepted_move_error(
step: u32,
move_type: MoveType,
attempts: usize,
source: CdtError,
) -> CdtError {
CdtError::MetropolisMoveApplicationFailed {
step,
move_type,
attempts,
source: MetropolisMoveApplicationFailure::from(source),
}
}
#[cfg(test)]
mod tests {
use super::super::adapter::{
CdtProposal, CdtProposalError, CdtProposalPlan, concrete_log_q_ratio, propose_concrete_plan,
};
use super::super::helpers::{SimplexCounts, proposed_delta_action, simplex_counts};
use super::super::telemetry::CdtProposalSiteRejection;
use super::*;
use crate::cdt::action::CDT_1P1_CRITICAL_TRIANGLE_COSMOLOGICAL_CONSTANT;
use crate::cdt::ergodic_moves::proposal_site_count;
use crate::cdt::triangulation::CdtTriangulation;
use crate::errors::{BackendMutationOperation, CheckpointMoveCounter, ConfigurationSetting};
use crate::geometry::traits::TriangulationQuery;
use approx::assert_relative_eq;
use markov_chain_monte_carlo::{
Chain, DelayedProposal, DelayedStepError, DiscreteProposalRatio, McmcError, Target,
};
use rand::{Rng, RngExt, SeedableRng, rngs::StdRng};
use serde_json::{from_str, to_string, to_value};
use std::assert_matches;
use std::error::Error;
use std::num::{NonZeroU32, NonZeroUsize};
fn metropolis_accept<R: Rng + ?Sized>(
delta_action: f64,
temperature: f64,
rng: &mut R,
) -> bool {
metropolis_accept_log_alpha(-delta_action / temperature, rng)
}
fn metropolis_accept_log_alpha<R: Rng + ?Sized>(log_alpha: f64, rng: &mut R) -> bool {
log_alpha >= 0.0 || rng.random::<f64>() < log_alpha.exp()
}
fn step_number(step: u32) -> NonZeroU32 {
NonZeroU32::new(step).expect("test step number should be nonzero")
}
fn accepted_step(
step: u32,
move_type: MoveType,
action_before: f64,
action_after: f64,
) -> MonteCarloStep {
MonteCarloStep::accepted_step(
step_number(step),
move_type,
action_before,
action_after,
action_after - action_before,
)
.expect("test accepted step should satisfy action invariants")
}
fn rejected_proposal_step(
step: u32,
move_type: MoveType,
action_before: f64,
delta_action: Option<f64>,
) -> MonteCarloStep {
MonteCarloStep::rejected_proposal(step_number(step), move_type, action_before, delta_action)
.expect("test rejected step should satisfy action invariants")
}
fn no_proposal_step(step: u32, move_type: MoveType, action_before: f64) -> MonteCarloStep {
MonteCarloStep::no_proposal(step_number(step), move_type, action_before)
.expect("test no-proposal step should satisfy action invariants")
}
fn assert_optional_relative_eq(left: Option<f64>, right: Option<f64>) {
match (left, right) {
(Some(left), Some(right)) => assert_relative_eq!(left, right, epsilon = 1e-12),
(None, None) => {}
other => panic!("expected matching optional floats, got {other:?}"),
}
}
type CanonicalVertexSignature = (Option<u32>, Vec<u64>);
type CanonicalFaceSignature = (Option<i32>, Vec<CanonicalVertexSignature>);
fn canonical_vertex_signatures(
triangulation: &CdtTriangulation2D,
) -> Vec<CanonicalVertexSignature> {
let geometry = triangulation.geometry();
let mut vertices = geometry
.vertices()
.map(|vertex| {
let coordinates = geometry
.vertex_coordinates(&vertex)
.expect("test vertex coordinates should resolve")
.into_iter()
.map(f64::to_bits)
.collect();
(
geometry.vertex_data_by_key(vertex.vertex_key()),
coordinates,
)
})
.collect::<Vec<_>>();
vertices.sort();
vertices
}
fn canonical_face_signatures(
triangulation: &CdtTriangulation2D,
) -> Vec<CanonicalFaceSignature> {
let geometry = triangulation.geometry();
let mut faces = geometry
.faces()
.map(|face| {
let mut vertices = geometry
.face_vertices(&face)
.expect("test face vertices should resolve")
.into_iter()
.map(|vertex| {
let coordinates = geometry
.vertex_coordinates(&vertex)
.expect("test face vertex coordinates should resolve")
.into_iter()
.map(f64::to_bits)
.collect();
(
geometry.vertex_data_by_key(vertex.vertex_key()),
coordinates,
)
})
.collect::<Vec<_>>();
vertices.sort();
(geometry.simplex_data_by_key(face.simplex_key()), vertices)
})
.collect::<Vec<_>>();
faces.sort();
faces
}
fn assert_canonical_triangulations_match(
left: &CdtTriangulation2D,
right: &CdtTriangulation2D,
) {
assert_eq!(left.vertex_count(), right.vertex_count());
assert_eq!(left.edge_count(), right.edge_count());
assert_eq!(left.face_count(), right.face_count());
assert_eq!(left.slice_sizes(), right.slice_sizes());
assert_eq!(left.volume_profile(), right.volume_profile());
assert_eq!(
left.metadata().time_slices(),
right.metadata().time_slices()
);
assert_eq!(left.metadata().dimension(), right.metadata().dimension());
assert_eq!(left.metadata().topology(), right.metadata().topology());
assert_eq!(
left.metadata().modification_count(),
right.metadata().modification_count()
);
assert_eq!(
to_value(left.metadata().simulation_history())
.expect("left simulation history should serialize"),
to_value(right.metadata().simulation_history())
.expect("right simulation history should serialize")
);
assert_eq!(
canonical_vertex_signatures(left),
canonical_vertex_signatures(right)
);
assert_eq!(
canonical_face_signatures(left),
canonical_face_signatures(right)
);
}
fn metropolis_config(
temperature: f64,
steps: u32,
thermalization_steps: u32,
measurement_frequency: u32,
) -> MetropolisConfig {
MetropolisConfig::new(
temperature,
steps,
thermalization_steps,
measurement_frequency,
)
.expect("test Metropolis config should be valid")
}
fn seeded_metropolis_config(
temperature: f64,
steps: u32,
thermalization_steps: u32,
measurement_frequency: u32,
seed: u64,
) -> MetropolisConfig {
MetropolisConfig::new_with_seed(
temperature,
steps,
thermalization_steps,
measurement_frequency,
Some(seed),
)
.expect("test Metropolis config should be valid")
}
fn action_config(coupling_0: f64, coupling_2: f64, cosmological_constant: f64) -> ActionConfig {
ActionConfig::new(coupling_0, coupling_2, cosmological_constant)
.expect("test action config should be valid")
}
fn short_checkpoint() -> CdtMcmcCheckpoint {
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 13),
ActionConfig::default(),
)
.run_to_checkpoint(triangulation)
.expect("short prefix run should checkpoint")
}
fn serializable_rejected_checkpoint(action_config: ActionConfig) -> CdtMcmcCheckpoint {
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let config = seeded_metropolis_config(1.0, 1, 0, 1, 13);
let algorithm = MetropolisAlgorithm::new(config.clone(), action_config.clone());
let mut state = algorithm
.initial_state(triangulation)
.expect("initial state should build");
let move_type = state.ergodics.select_random_move();
let _acceptance_rng_marker: f64 = state.acceptance_rng.random();
state.move_stats.record_attempt(move_type);
state
.triangulation
.record_event(SimulationEvent::MoveAttempted { move_type, step: 1 });
state.steps.push(rejected_proposal_step(
1,
move_type,
state.current_action,
proposed_delta_action(
&action_config,
simplex_counts(&state.triangulation),
move_type,
),
));
state.current_step = 1;
state.proposal_stats.record_move_family(1);
state.proposal_stats.record_metropolis_rejection();
state.scalar_trace_rows.push(
CdtScalarTraceRow::new(
step_number(1),
CdtScalarTraceOutcome::RejectedProposal,
-state.current_action / config.temperature(),
state.current_action,
&state.triangulation,
move_type,
state.steps[0].delta_action(),
state.current_action,
None,
config.seed(),
)
.expect("trace row should build"),
);
state.measurements.push(
measurement_for(1, state.current_action, &state.triangulation)
.expect("measurement should build"),
);
state
.triangulation
.record_event(SimulationEvent::MeasurementTaken {
step: 1,
action: state.current_action,
});
state
.into_checkpoint(config, action_config)
.expect("synthetic rejected checkpoint should validate")
}
fn synthetic_one_step_checkpoint_parts(accepted: bool) -> CdtMcmcCheckpointParts {
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let config = seeded_metropolis_config(1.0, 1, 0, 1, 13);
let action_config = ActionConfig::default();
let current_action = action_for(&action_config, &triangulation);
let temperature = config.temperature();
let seed = config.seed();
let move_type = MoveType::Move22;
let mut move_stats = MoveStatistics::new();
move_stats.record_attempt(move_type);
if accepted {
move_stats.record_success(move_type);
}
let outcome = if accepted {
CdtScalarTraceOutcome::Accepted
} else {
CdtScalarTraceOutcome::RejectedProposal
};
CdtMcmcCheckpointParts {
triangulation: triangulation.clone(),
accepted: usize::from(accepted),
rejected: usize::from(!accepted),
config,
action_config,
current_step: step_number(1),
current_action,
move_stats,
proposal_stats: if accepted {
ProposalStatistics::from_validated_parts(1, 1, 0, 0, 0, 0, 0, 1, 0)
} else {
ProposalStatistics::from_validated_parts(1, 1, 0, 0, 0, 0, 1, 0, 0)
},
steps: vec![if accepted {
accepted_step(1, move_type, current_action, current_action)
} else {
rejected_proposal_step(1, move_type, current_action, None)
}],
measurements: vec![
measurement_for(0, current_action, &triangulation)
.expect("initial measurement should build"),
measurement_for(1, current_action, &triangulation)
.expect("step measurement should build"),
],
scalar_trace_rows: vec![
CdtScalarTraceRow::new(
step_number(1),
outcome,
-current_action / temperature,
current_action,
&triangulation,
move_type,
accepted.then_some(0.0),
current_action,
accepted.then_some(current_action),
seed,
)
.expect("trace row should build"),
],
elapsed_time: Duration::ZERO,
acceptance_rng: simulation_rng(Some(1)),
ergodics: ErgodicsSystem::with_seed(2),
}
}
fn synthetic_one_step_checkpoint(accepted: bool) -> CdtMcmcCheckpoint {
CdtMcmcCheckpoint::from_parts(synthetic_one_step_checkpoint_parts(accepted))
.expect("synthetic checkpoint should validate")
}
fn empty_run_state(triangulation: CdtTriangulation2D) -> MetropolisRunState {
MetropolisRunState {
triangulation,
current_step: 0,
current_action: 0.0,
trace_seed: Some(1),
acceptance_rng: simulation_rng(Some(1)),
ergodics: ErgodicsSystem::with_seed(2),
move_stats: MoveStatistics::new(),
proposal_stats: ProposalStatistics::new(),
steps: Vec::new(),
measurements: Vec::new(),
scalar_trace_rows: Vec::new(),
elapsed_time: Duration::ZERO,
}
}
fn assert_checkpoint_resume_failed<T>(
result: CdtResult<T>,
matches_failure: impl FnOnce(&CheckpointResumeFailure) -> bool,
expected_detail: &str,
) {
let Err(CdtError::CheckpointResumeFailed { failure }) = result else {
panic!("expected checkpoint resume failure");
};
assert!(
matches_failure(&failure),
"unexpected checkpoint resume failure: {failure:?}"
);
let detail = failure.to_string();
assert!(
detail.contains(expected_detail),
"expected detail to contain {expected_detail:?}, got {detail:?}"
);
}
#[test]
fn full_validation_cadence_uses_delaunay_check_policy() {
let mut triangulation =
CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT");
triangulation.set_delaunay_check_interval(NonZeroUsize::new(1));
let vertex = triangulation
.geometry()
.vertices()
.find(|vertex| triangulation.time_label(vertex) == Some(1))
.expect("fixture has a slice-1 vertex");
triangulation
.set_vertex_data(&vertex, Some(0))
.expect("fixture vertex label can be edited");
let mut state = empty_run_state(triangulation);
state.move_stats.record_success(MoveType::Move22);
assert!(
validate_evolved_cdt_if_due(&state).is_err(),
"EveryN(1) should run full validation after the first accepted move"
);
}
#[test]
fn end_only_validation_policy_defers_until_checkpoint() {
let mut triangulation =
CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT");
triangulation.set_delaunay_check_interval(None);
let vertex = triangulation
.geometry()
.vertices()
.find(|vertex| triangulation.time_label(vertex) == Some(1))
.expect("fixture has a slice-1 vertex");
triangulation
.set_vertex_data(&vertex, Some(0))
.expect("fixture vertex label can be edited");
let mut state = empty_run_state(triangulation);
state.move_stats.record_success(MoveType::Move22);
validate_evolved_cdt_if_due(&state)
.expect("EndOnly should skip cadence validation on accepted moves");
assert!(
state
.into_checkpoint(MetropolisConfig::default(), ActionConfig::default())
.is_err(),
"mandatory checkpoint validation should still catch the invalid final state"
);
}
#[test]
fn test_metropolis_config() {
let config = metropolis_config(2.0, 500, 50, 5);
assert_relative_eq!(config.temperature(), 2.0);
assert_relative_eq!(config.beta(), 0.5);
assert_eq!(config.steps().get(), 500);
assert!(config.seed().is_none());
let seeded = config.with_seed(123);
assert_eq!(seeded.seed(), Some(123));
}
#[test]
fn backend_counts_vertices_edges() {
const TRIANGULATION_SEED: u64 = 53;
let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, TRIANGULATION_SEED)
.expect("Failed to create triangulation with fixed seed");
let geometry = triangulation.geometry();
assert!(
geometry.is_valid(),
"Triangulation should be structurally valid for backend queries"
);
assert_eq!(
geometry.vertex_count(),
5,
"Vertex count should match requested seeded generation"
);
assert!(geometry.edge_count() > 0, "Should have edges");
assert!(geometry.face_count() > 0, "Should have faces");
}
#[test]
fn test_action_calculation() {
let triangulation =
CdtTriangulation::from_random_points(5, 1, 2).expect("Failed to create triangulation");
let config = MetropolisConfig::default();
let action_config = ActionConfig::default();
let _algorithm = MetropolisAlgorithm::new(config, action_config.clone());
let geometry = triangulation.geometry();
let action = action_config.calculate_action(
geometry.vertex_count(),
geometry.edge_count(),
geometry.face_count(),
);
assert!(action.is_finite());
}
#[test]
fn test_cdt_target_log_prob() {
let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53)
.expect("Failed to create triangulation");
let target =
CdtTarget::new(ActionConfig::default(), 1.0).expect("valid target configuration");
let log_prob = Target::log_prob(&target, &triangulation);
assert!(log_prob.is_finite(), "log_prob should be finite");
let g = triangulation.geometry();
let action = ActionConfig::default().calculate_action(
g.vertex_count(),
g.edge_count(),
g.face_count(),
);
assert_relative_eq!(log_prob, -action);
}
#[test]
fn seeded_simulation_runs_moves() {
let config = seeded_metropolis_config(1.0, 10, 2, 2, 42);
let action_config = ActionConfig::default();
let algorithm = MetropolisAlgorithm::new(config, action_config);
let triangulation =
CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed to create");
let results = algorithm
.run(triangulation)
.expect("simulation should run with real move loop");
assert_eq!(results.steps().len(), 10);
assert_relative_eq!(
results.move_stats().total_acceptance_rate(),
results.acceptance_rate()
);
assert!(results.measurements().iter().all(|measurement| {
measurement.action().is_finite()
&& measurement.vertices().get() > 0
&& measurement.edges().get() > 0
&& measurement.triangles().get() > 0
}));
}
#[test]
fn run_skips_pre_thermalization_measurements() {
let config = seeded_metropolis_config(1.0, 4, 2, 2, 42);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let results = algorithm
.run(triangulation)
.expect("simulation should run with post-thermalization measurements");
let measurement_steps = results
.measurements()
.iter()
.map(Measurement::step)
.collect::<Vec<_>>();
assert_eq!(measurement_steps, vec![2, 4]);
assert!(
results
.measurements()
.iter()
.all(|measurement| measurement.step() >= results.config().thermalization_steps())
);
}
#[test]
fn run_with_checkpoint_returns_matching_results_and_checkpoint() {
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 3, 0, 1, 13),
ActionConfig::default(),
);
let (results, checkpoint) = algorithm
.run_with_checkpoint(triangulation)
.expect("checkpointed run should complete");
assert_eq!(checkpoint.current_step().get(), 3);
assert_eq!(results.steps().len(), checkpoint.steps().len());
assert_eq!(results.config(), checkpoint.config());
let checkpoint_results = checkpoint.into_results();
assert_eq!(
results.triangulation().vertex_count(),
checkpoint_results.triangulation().vertex_count()
);
checkpoint_results
.triangulation()
.validate()
.expect("checkpoint triangulation should satisfy evolved invariants");
}
#[test]
fn checkpoint_accessors_report_consistent_snapshot() {
let checkpoint = short_checkpoint();
let current_step = usize::try_from(checkpoint.current_step().get())
.expect("u32 step count should fit usize");
let accepted_moves = usize::try_from(checkpoint.move_stats().total_accepted())
.expect("test accepted move count should fit usize");
let last_step = checkpoint
.steps()
.last()
.expect("checkpoint should contain step telemetry");
let last_measurement = checkpoint
.measurements()
.last()
.expect("checkpoint should contain measurements");
assert_eq!(
checkpoint.chain().state().vertex_count(),
checkpoint.triangulation().vertex_count()
);
assert_eq!(checkpoint.chain().total_steps(), current_step);
assert_eq!(checkpoint.chain().accepted(), accepted_moves);
assert_eq!(checkpoint.config().steps(), checkpoint.current_step());
assert_eq!(checkpoint.action_config(), &ActionConfig::default());
assert!(checkpoint.current_action().is_finite());
assert_eq!(
checkpoint.move_stats().total_attempted(),
u64::from(checkpoint.current_step().get())
);
assert_eq!(
checkpoint.proposal_stats().move_family_proposals(),
u64::from(checkpoint.current_step().get())
);
assert_eq!(last_step.step(), checkpoint.current_step());
assert_eq!(last_measurement.step(), checkpoint.current_step().get());
assert_relative_eq!(
last_measurement.action(),
checkpoint.current_action(),
epsilon = 1e-12
);
}
#[test]
fn serialized_checkpoint_resumes_from_stored_rng_state() {
let action_config = ActionConfig::default();
let checkpoint = serializable_rejected_checkpoint(action_config.clone());
let checkpoint_json = to_string(&checkpoint).expect("checkpoint should serialize");
let checkpoint: CdtMcmcCheckpoint =
from_str(&checkpoint_json).expect("checkpoint should deserialize");
let alternate_checkpoint: CdtMcmcCheckpoint =
from_str(&checkpoint_json).expect("checkpoint should deserialize again");
let first_resume_algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 6, 0, 1, 999),
action_config.clone(),
);
let first_resumed = first_resume_algorithm
.resume_from_checkpoint(checkpoint)
.expect("resume should complete");
let second_resume_algorithm =
MetropolisAlgorithm::new(seeded_metropolis_config(1.0, 6, 0, 1, 123), action_config);
let second_resumed = second_resume_algorithm
.resume_from_checkpoint(alternate_checkpoint)
.expect("resume should ignore fresh seed and use checkpoint RNG state");
assert_eq!(first_resumed.config().steps().get(), 7);
assert_eq!(first_resumed.steps().len(), 7);
assert_eq!(first_resumed.steps()[1].step().get(), 2);
first_resumed
.triangulation()
.validate_topology()
.expect("resumed triangulation should preserve topology");
first_resumed
.triangulation()
.validate_foliation()
.expect("resumed triangulation should preserve foliation");
first_resumed
.triangulation()
.validate_causality()
.expect("resumed triangulation should preserve causality");
first_resumed
.triangulation()
.validate_simplex_classification()
.expect("resumed triangulation should preserve simplex classification");
assert_eq!(
to_value(first_resumed.steps()).expect("steps should serialize"),
to_value(second_resumed.steps()).expect("steps should serialize")
);
assert_eq!(
to_value(first_resumed.measurements()).expect("measurements should serialize"),
to_value(second_resumed.measurements()).expect("measurements should serialize")
);
assert_eq!(
to_value(first_resumed.move_stats()).expect("stats should serialize"),
to_value(second_resumed.move_stats()).expect("stats should serialize")
);
assert_eq!(
first_resumed.triangulation().vertex_count(),
second_resumed.triangulation().vertex_count()
);
assert_eq!(
first_resumed.triangulation().edge_count(),
second_resumed.triangulation().edge_count()
);
assert_eq!(
first_resumed.triangulation().face_count(),
second_resumed.triangulation().face_count()
);
assert_eq!(
first_resumed.triangulation().slice_sizes(),
second_resumed.triangulation().slice_sizes()
);
}
#[test]
fn chunked_checkpoint_resume_matches_one_shot_seeded_run() {
let action_config = ActionConfig::default();
let one_shot = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 10, 0, 1, 19),
action_config.clone(),
)
.run(CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"))
.expect("one-shot run should complete");
let prefix = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 4, 0, 1, 19),
action_config.clone(),
)
.run_to_checkpoint(
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"),
)
.expect("prefix run should checkpoint");
let chunked_checkpoint =
MetropolisAlgorithm::new(seeded_metropolis_config(1.0, 6, 0, 1, 999), action_config)
.resume_to_checkpoint(prefix)
.expect("chunked checkpoint resume should complete");
let chunked = chunked_checkpoint.into_results();
assert_eq!(chunked.config().steps().get(), 10);
assert_eq!(
to_value(one_shot.steps()).expect("steps should serialize"),
to_value(chunked.steps()).expect("steps should serialize")
);
assert_eq!(
to_value(one_shot.measurements()).expect("measurements should serialize"),
to_value(chunked.measurements()).expect("measurements should serialize")
);
assert_eq!(
to_value(one_shot.move_stats()).expect("move stats should serialize"),
to_value(chunked.move_stats()).expect("move stats should serialize")
);
assert_eq!(
to_value(one_shot.proposal_stats()).expect("proposal stats should serialize"),
to_value(chunked.proposal_stats()).expect("proposal stats should serialize")
);
assert_canonical_triangulations_match(one_shot.triangulation(), chunked.triangulation());
assert_eq!(
one_shot.triangulation().volume_profile(),
chunked.triangulation().volume_profile()
);
}
#[test]
fn serialized_checkpoint_missing_proposal_stats_rejects_nonempty_checkpoint() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload
.as_object_mut()
.expect("checkpoint payload should be an object")
.remove("proposal_stats");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("nonempty checkpoint missing proposal stats should be rejected");
};
assert!(
error
.to_string()
.contains("proposal move-family count mismatch"),
"unexpected error: {error}"
);
}
#[test]
fn serialized_checkpoint_rejects_zero_current_step() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload
.as_object_mut()
.expect("checkpoint payload should be an object")
.insert(
"current_step".to_string(),
to_value(0_u32).expect("step should serialize"),
);
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("checkpoint step zero should fail while parsing");
};
assert!(
error.to_string().contains("current_step must be nonzero"),
"unexpected serde error: {error}"
);
}
#[test]
fn serialized_checkpoint_rejects_mismatched_current_action() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload["current_action"] =
to_value(checkpoint.current_action() + 1.0).expect("action should serialize");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("checkpoint action mismatch should fail while parsing");
};
assert!(
error.to_string().contains("checkpoint action mismatch"),
"unexpected serde error: {error}"
);
}
#[test]
fn checkpoint_validation_rejects_nonfinite_current_action() {
for current_action in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut parts = synthetic_one_step_checkpoint_parts(false);
parts.current_action = current_action;
assert_checkpoint_resume_failed(
CdtMcmcCheckpoint::from_parts(parts),
|failure| match failure {
CheckpointResumeFailure::NonFiniteCheckpointAction { stored } => {
stored.to_bits() == current_action.to_bits()
}
_ => false,
},
"checkpoint action is non-finite",
);
}
}
#[test]
fn serialized_checkpoint_rejects_zero_step_telemetry() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload["steps"][0]["step"] = to_value(0_u32).expect("step should serialize");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("checkpoint telemetry step zero should fail while parsing");
};
assert!(
error.to_string().contains("nonzero") || error.to_string().contains("invalid value"),
"unexpected serde error: {error}"
);
}
#[test]
fn serialized_checkpoint_rejects_zero_scalar_trace_step() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload["scalar_trace_rows"][0]["step"] = to_value(0_u32).expect("step should serialize");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("zero scalar trace step should fail while parsing checkpoint");
};
assert!(
error
.to_string()
.contains("scalar trace step must be nonzero"),
"unexpected serde error: {error}"
);
}
#[test]
fn serialized_checkpoint_rejects_zero_measurement_counts_by_field() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
for field in ["vertices", "edges", "triangles"] {
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload["measurements"][0][field] = to_value(0_u32).expect("count should serialize");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("zero measurement count should fail while parsing checkpoint");
};
let message = error.to_string();
assert!(
message.contains("Invalid measurement count") && message.contains(field),
"unexpected serde error for {field}: {message}"
);
}
}
#[test]
fn serialized_checkpoint_rejects_zero_scalar_trace_counts_by_field() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
for field in ["vertices", "edges", "triangles"] {
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload["scalar_trace_rows"][0][field] =
to_value(0_u32).expect("count should serialize");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("zero scalar trace count should fail while parsing checkpoint");
};
let message = error.to_string();
assert!(
message.contains("Invalid scalar trace count") && message.contains(field),
"unexpected serde error for {field}: {message}"
);
}
}
#[test]
fn serialized_checkpoint_rejects_missing_scalar_trace_rows() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload
.as_object_mut()
.expect("checkpoint payload should be an object")
.remove("scalar_trace_rows");
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("missing scalar trace rows should fail while parsing checkpoint");
};
assert!(
error
.to_string()
.contains("missing field `scalar_trace_rows`"),
"unexpected serde error: {error}"
);
}
#[test]
fn serialized_checkpoint_rejects_counter_mismatch_on_restore() {
let checkpoint = serializable_rejected_checkpoint(ActionConfig::default());
let mut payload = to_value(&checkpoint).expect("checkpoint should serialize");
payload
.as_object_mut()
.expect("checkpoint payload should be an object")
.insert(
"current_step".to_string(),
to_value(2_u32).expect("step should serialize"),
);
let Err(error) = from_str::<CdtMcmcCheckpoint>(&payload.to_string()) else {
panic!("checkpoint counter mismatch should be rejected during deserialization");
};
assert!(
error
.to_string()
.contains("chain step count does not match checkpoint step"),
"unexpected serde error: {error}"
);
}
#[test]
fn resume_rejects_incompatible_action_config() {
let checkpoint = short_checkpoint();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
action_config(2.0, 1.0, 0.1),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::IncompatibleActionConfiguration
)
},
"action configuration",
);
}
#[test]
fn resume_to_checkpoint_rejects_incompatible_action_config() {
let checkpoint = short_checkpoint();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
action_config(2.0, 1.0, 0.1),
);
assert_checkpoint_resume_failed(
algorithm.resume_to_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::IncompatibleActionConfiguration
)
},
"action configuration",
);
}
#[test]
fn resume_rejects_incompatible_sampling_schedule() {
let checkpoint = short_checkpoint();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 2, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::IncompatibleMeasurementFrequency
)
},
"measurement frequency",
);
}
#[test]
fn resume_rejects_incompatible_temperature() {
let checkpoint = short_checkpoint();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(2.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| matches!(failure, CheckpointResumeFailure::IncompatibleTemperature),
"temperature differs",
);
}
#[test]
fn resume_rejects_incompatible_thermalization_schedule() {
let checkpoint = short_checkpoint();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 1, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::IncompatibleThermalizationSchedule
)
},
"thermalization schedule",
);
}
#[test]
fn resume_rejects_chain_step_mismatch() {
let mut checkpoint = short_checkpoint();
checkpoint.current_step = step_number(checkpoint.current_step.get() + 1);
let chain_steps = checkpoint.chain.total_steps();
let checkpoint_step = checkpoint.current_step.get();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::ChainStepMismatch {
chain_steps: actual_chain_steps,
checkpoint_step: actual_checkpoint_step,
} if *actual_chain_steps == chain_steps
&& *actual_checkpoint_step == checkpoint_step
)
},
"chain step count",
);
}
#[test]
fn resume_rejects_inconsistent_checkpoint_counters() {
let mut checkpoint = short_checkpoint();
checkpoint.move_stats.record_attempt(MoveType::Move22);
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::ChainCounterMismatch { .. }
)
},
"chain counters",
);
}
#[test]
fn resume_rejects_inconsistent_step_telemetry() {
let mut checkpoint = short_checkpoint();
checkpoint.steps.pop();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::StepTelemetryLengthMismatch { .. }
)
},
"step telemetry length",
);
}
#[test]
fn resume_rejects_nonsequential_step_telemetry() {
let mut checkpoint = short_checkpoint();
let move_type = checkpoint.steps[0].move_type();
let action_before = checkpoint.steps[0].action_before();
checkpoint.steps[0] = no_proposal_step(2, move_type, action_before);
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::StepTelemetrySequenceMismatch { .. }
)
},
"step telemetry must be sequential",
);
}
#[test]
fn resume_rejects_step_acceptance_counter_mismatch() {
let mut checkpoint = synthetic_one_step_checkpoint(true);
checkpoint.steps[0] = rejected_proposal_step(
1,
checkpoint.steps[0].move_type(),
checkpoint.current_action(),
None,
);
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::StepTelemetryAcceptedCountMismatch { .. }
)
},
"accepted step count mismatch",
);
}
#[test]
fn resume_rejects_checkpoint_proposal_hard_failures() {
let mut checkpoint = synthetic_one_step_checkpoint(false);
checkpoint.proposal_stats =
ProposalStatistics::from_validated_parts(1, 1, 0, 0, 0, 0, 0, 0, 1);
assert_checkpoint_resume_failed(
validate_checkpoint_counters(&checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::ProposalHardFailures { actual: 1 }
)
},
"hard failures",
);
}
#[test]
fn resume_rejects_checkpoint_proposal_accepted_count_mismatch() {
let mut checkpoint = synthetic_one_step_checkpoint(true);
checkpoint.proposal_stats =
ProposalStatistics::from_validated_parts(1, 1, 1, 0, 0, 0, 0, 0, 0);
assert_checkpoint_resume_failed(
validate_checkpoint_counters(&checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::ProposalAcceptedCountMismatch {
actual: 0,
expected: 1
}
)
},
"accepted-transition count mismatch",
);
}
#[test]
fn resume_rejects_checkpoint_proposal_rejected_count_mismatch() {
let mut checkpoint = synthetic_one_step_checkpoint(false);
checkpoint.proposal_stats =
ProposalStatistics::from_validated_parts(1, 0, 0, 0, 0, 0, 0, 0, 0);
assert_checkpoint_resume_failed(
validate_checkpoint_counters(&checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::ProposalRejectedCountMismatch {
actual: 0,
expected: 1
}
)
},
"rejected-transition count mismatch",
);
}
#[test]
fn step_constructor_rejects_nonfinite_action_before() {
let error = MonteCarloStep::no_proposal(step_number(1), MoveType::Move22, f64::NAN)
.expect_err("non-finite action_before should be rejected before storage");
assert_matches!(
error,
CdtError::CheckpointResumeFailed {
failure: CheckpointResumeFailure::NonFiniteStepActionBefore { step: 1 }
}
);
}
#[test]
fn step_constructor_rejects_nonfinite_delta_action() {
let error = MonteCarloStep::rejected_proposal(
step_number(1),
MoveType::Move22,
0.0,
Some(f64::NAN),
)
.expect_err("non-finite delta_action should be rejected before storage");
assert_matches!(
error,
CdtError::CheckpointResumeFailed {
failure: CheckpointResumeFailure::NonFiniteStepDeltaAction { step: 1 }
}
);
}
#[test]
fn step_constructor_rejects_action_after_delta_mismatch() {
let error = MonteCarloStep::accepted_step(step_number(1), MoveType::Move22, 0.0, 1.0, 0.0)
.expect_err("action_after/delta mismatch should be rejected before storage");
assert_matches!(
error,
CdtError::CheckpointResumeFailed {
failure: CheckpointResumeFailure::StepActionAfterDeltaMismatch { step: 1 }
}
);
}
#[test]
fn step_constructor_rejects_nonfinite_step_action_after() {
let error =
MonteCarloStep::accepted_step(step_number(1), MoveType::Move22, 0.0, f64::NAN, 0.0)
.expect_err("non-finite action_after should be rejected before storage");
assert_matches!(
error,
CdtError::CheckpointResumeFailed {
failure: CheckpointResumeFailure::NonFiniteStepActionAfter { step: 1 }
}
);
}
#[test]
fn resume_rejects_missing_scheduled_measurement() {
let mut checkpoint = short_checkpoint();
checkpoint.measurements.pop();
let algorithm = MetropolisAlgorithm::new(
seeded_metropolis_config(1.0, 2, 0, 1, 999),
ActionConfig::default(),
);
assert_checkpoint_resume_failed(
algorithm.resume_from_checkpoint(checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::MeasurementCountMismatch { .. }
)
},
"scheduled measurement count mismatch",
);
}
#[test]
fn resume_rejects_measurement_step_mismatch() {
let mut checkpoint = synthetic_one_step_checkpoint(true);
checkpoint.measurements[1] =
Measurement::try_new(2, checkpoint.measurements[1].action(), 12, 26, 12)
.expect("replacement measurement should satisfy invariants");
assert_checkpoint_resume_failed(
validate_checkpoint_counters(&checkpoint),
|failure| {
matches!(
failure,
CheckpointResumeFailure::MeasurementStepMismatch {
actual: 2,
expected: 1
}
)
},
"measurement telemetry step mismatch",
);
}
#[test]
fn checkpoint_from_parts_rejects_action_mismatch() {
let mut parts = synthetic_one_step_checkpoint_parts(false);
parts.current_action += 1.0;
assert_checkpoint_resume_failed(
CdtMcmcCheckpoint::from_parts(parts),
|failure| matches!(failure, CheckpointResumeFailure::ActionMismatch { .. }),
"checkpoint action mismatch",
);
}
#[test]
fn checkpoint_config_deserialization_rejects_invalid_metropolis_config() {
let payload = r#"{"temperature":0.0,"steps":2,"thermalization_steps":0,"measurement_frequency":1,"seed":13}"#;
let Err(error) = from_str::<MetropolisConfig>(payload) else {
panic!("expected Metropolis configuration failure");
};
let message = error.to_string();
assert!(
message.contains("temperature") && message.contains("finite and positive"),
"unexpected serde error: {message}"
);
}
#[test]
fn chain_counters_rejects_accepted_above_attempted() {
let stats = MoveStatistics::from_validated_parts(0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
let Err(CdtError::CheckpointResumeFailed { failure }) = chain_counters(&stats) else {
panic!("expected impossible move statistics to fail");
};
assert_matches!(
failure,
CheckpointResumeFailure::MoveAcceptedExceedsAttempted {
move_type: MoveType::Move22
}
);
let detail = failure.to_string();
assert!(detail.contains("accepted move count exceeds attempted move count"));
}
#[test]
fn chain_counters_rejects_counter_sum_overflow() {
let stats = MoveStatistics::from_validated_parts(u64::MAX, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
let Err(CdtError::CheckpointResumeFailed { failure }) = chain_counters(&stats) else {
panic!("expected overflowing move statistics to fail");
};
assert_matches!(
failure,
CheckpointResumeFailure::MoveCounterOverflow {
counter: CheckpointMoveCounter::Attempted
}
);
let detail = failure.to_string();
assert!(detail.contains("attempted move count exceeds u64::MAX"));
}
#[test]
fn chain_counters_rejects_nonzero_hard_failures() {
let stats = MoveStatistics::from_validated_parts(3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0);
let Err(CdtError::CheckpointResumeFailed { failure }) = chain_counters(&stats) else {
panic!("expected impossible hard-failure statistics to fail");
};
assert_matches!(
failure,
CheckpointResumeFailure::MoveHardFailures {
move_type: MoveType::Move22
}
);
let detail = failure.to_string();
assert!(detail.contains("Move22"));
assert!(detail.contains("hard-failure move count must be zero"));
}
#[test]
fn explicit_cdt_volume_profiles_count_time_slabs() {
let strip = CdtTriangulation::from_cdt_strip(4, 3).expect("create Delaunay strip");
assert_eq!(strip.volume_profile(), vec![6, 6, 0]);
let torus = CdtTriangulation::from_toroidal_cdt(3, 3).expect("create periodic torus");
assert_eq!(torus.volume_profile(), vec![6, 6, 6]);
}
#[test]
fn measurement_records_volume_profile_for_foliated_triangulation() {
let triangulation = CdtTriangulation::from_cdt_strip(4, 3).expect("create Delaunay strip");
let measurement =
measurement_for(0, 1.0, &triangulation).expect("measurement should build");
assert_eq!(measurement.volume_profile(), &[6, 6, 0]);
assert_eq!(
measurement.volume_profile().iter().sum::<u32>(),
measurement.triangles().get()
);
}
#[test]
fn volume_profile_is_empty_without_current_foliation() {
let triangulation =
CdtTriangulation::from_seeded_points(5, 2, 2, 53).expect("create seeded triangulation");
let measurement =
measurement_for(0, 1.0, &triangulation).expect("measurement should build");
assert!(!triangulation.has_foliation());
assert!(triangulation.volume_profile().is_empty());
assert!(measurement.volume_profile().is_empty());
}
#[test]
fn seeded_simulation_deterministic() {
let run = |seed: u64| {
let config = seeded_metropolis_config(1.0, 20, 5, 5, seed);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed");
algorithm.run(tri).expect("seeded simulation should run")
};
let first = run(123);
let second = run(123);
assert_eq!(first.steps().len(), second.steps().len());
for (first, second) in first.steps().iter().zip(second.steps().iter()) {
assert_eq!(first.move_type(), second.move_type());
assert_eq!(first.accepted(), second.accepted());
assert_relative_eq!(first.action_before(), second.action_before());
assert_optional_relative_eq(first.delta_action(), second.delta_action());
}
}
#[test]
fn delta_action_uses_count_deltas() {
let action_config = ActionConfig::default();
let before = SimplexCounts {
vertices: 5,
edges: 8,
triangles: 4,
};
assert_relative_eq!(
proposed_delta_action(&action_config, before, MoveType::Move22)
.expect("2,2 delta should be finite"),
0.0
);
assert_relative_eq!(
proposed_delta_action(&action_config, before, MoveType::EdgeFlip)
.expect("edge flip delta should be finite"),
0.0
);
assert_relative_eq!(
proposed_delta_action(&action_config, before, MoveType::Move13Add)
.expect("1,3 delta should be finite"),
2.0 * CDT_1P1_CRITICAL_TRIANGLE_COSMOLOGICAL_CONSTANT,
epsilon = 1e-12
);
assert_relative_eq!(
proposed_delta_action(&action_config, before, MoveType::Move31Remove)
.expect("3,1 delta should be finite"),
-2.0 * CDT_1P1_CRITICAL_TRIANGLE_COSMOLOGICAL_CONSTANT,
epsilon = 1e-12
);
}
#[test]
fn delta_action_rejects_bad_counts() {
let action_config = ActionConfig::default();
assert_eq!(
proposed_delta_action(
&action_config,
SimplexCounts {
vertices: 0,
edges: 2,
triangles: 1,
},
MoveType::Move31Remove,
),
None
);
assert_eq!(
proposed_delta_action(
&action_config,
SimplexCounts {
vertices: usize::MAX,
edges: 8,
triangles: 4,
},
MoveType::Move13Add,
),
None
);
}
#[test]
fn accepts_non_positive_delta() {
let mut rng = StdRng::seed_from_u64(7);
assert!(metropolis_accept(0.0, 1.0, &mut rng));
assert!(metropolis_accept(-1.0, 1.0, &mut rng));
}
#[test]
fn concrete_plan_site_rejection_is_self_loop_telemetry() {
let triangulation =
CdtTriangulation::from_seeded_points(3, 1, 2, 53).expect("Failed to create");
let action_config = ActionConfig::default();
let counts_before = simplex_counts(&triangulation);
let action_before = action_for(&action_config, &triangulation);
let mut moves = ErgodicsSystem::with_seed(7);
let mut proposal_stats = ProposalStatistics::new();
let result = propose_concrete_plan(
&triangulation,
&mut moves,
&mut proposal_stats,
&action_config,
MoveType::Move31Remove,
action_before,
)
.expect("site rejection is an ordinary proposal outcome");
assert!(result.is_none());
assert_eq!(proposal_stats.move_family_proposals(), 1);
assert_eq!(proposal_stats.no_site_proposals(), 1);
assert_eq!(simplex_counts(&triangulation), counts_before);
assert_relative_eq!(action_for(&action_config, &triangulation), action_before);
}
#[test]
fn proposal_statistics_saturate_extreme_counters() {
let mut stats = ProposalStatistics::from_validated_parts(
u64::MAX,
u64::MAX - 1,
u64::MAX,
u64::MAX,
u64::MAX,
u64::MAX,
u64::MAX,
u64::MAX,
u64::MAX,
);
stats.record_move_family(2);
stats.record_no_site();
stats.record_site_rejection(&CdtProposalSiteRejection::CausalityViolation);
stats.record_site_rejection(&CdtProposalSiteRejection::GeometricViolation);
stats.record_site_rejection(&CdtProposalSiteRejection::Kernel(
CdtError::InvalidSimulationConfiguration {
setting: ConfigurationSetting::Steps,
provided_value: "0".to_string(),
expected: "≥ 1".to_string(),
},
));
stats.record_metropolis_rejection();
stats.record_accepted_transition();
stats.record_hard_failure();
assert_eq!(stats.move_family_proposals(), u64::MAX);
assert_eq!(stats.observed_forward_sites(), u64::MAX);
assert_eq!(stats.no_site_proposals(), u64::MAX);
assert_eq!(stats.site_causality_rejections(), u64::MAX);
assert_eq!(stats.site_geometric_rejections(), u64::MAX);
assert_eq!(stats.site_backend_rejections(), u64::MAX);
assert_eq!(stats.metropolis_rejections(), u64::MAX);
assert_eq!(stats.accepted_transitions(), u64::MAX);
assert_eq!(stats.hard_failures(), u64::MAX);
}
#[test]
fn run_records_proposal_statistics_for_each_selected_move_family() {
let config = seeded_metropolis_config(1.0, 12, 0, 1, 2);
let algorithm = MetropolisAlgorithm::new(config.clone(), ActionConfig::default());
let triangulation =
CdtTriangulation::from_seeded_points(3, 1, 2, 53).expect("Failed to create");
let results = algorithm
.run(triangulation)
.expect("short run should finish");
let proposal_stats = results.proposal_stats();
let classified_proposals = proposal_stats.no_site_proposals()
+ proposal_stats.site_causality_rejections()
+ proposal_stats.site_geometric_rejections()
+ proposal_stats.site_backend_rejections()
+ proposal_stats.metropolis_rejections()
+ proposal_stats.accepted_transitions()
+ proposal_stats.hard_failures();
assert_eq!(
proposal_stats.move_family_proposals(),
u64::from(config.steps().get())
);
assert_eq!(
proposal_stats.move_family_proposals(),
results.move_stats().total_attempted()
);
assert_eq!(
proposal_stats.accepted_transitions(),
results.move_stats().total_accepted()
);
assert_eq!(classified_proposals, proposal_stats.move_family_proposals());
}
#[test]
fn accepted_planned_step_records_attempt_on_committed_state() {
let config = seeded_metropolis_config(1.0, 1, 0, 1, 2);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let committed = triangulation.clone();
let mut state = algorithm
.initial_state(triangulation)
.expect("initial state should build");
let action = state.current_action;
let move_type = MoveType::Move22;
let info = CdtProposalInfo {
move_type,
action_before: action,
action_after: Some(action),
delta_action: Some(0.0),
};
let proposal_stats = ProposalStatistics::from_validated_parts(1, 0, 0, 0, 0, 0, 0, 0, 0);
record_planned_step_parts(
&algorithm,
&mut state,
step_number(1),
PlannedStepRecord {
outcome: StepOutcome::Accepted,
log_prob_after: Some(-action),
info,
proposal_stats: &proposal_stats,
triangulation: &committed,
},
)
.expect("accepted planned step should record telemetry");
let history = state.triangulation.metadata().simulation_history();
assert!(
history.iter().any(|event| matches!(
event,
SimulationEvent::MoveAttempted {
move_type: recorded_move,
step
} if *recorded_move == move_type && *step == 1
)),
"attempt event should remain on the committed triangulation"
);
assert!(
history.iter().any(|event| matches!(
event,
SimulationEvent::MoveAccepted {
move_type: recorded_move,
step,
..
} if *recorded_move == move_type && *step == 1
)),
"accepted move event should remain on the committed triangulation"
);
}
#[test]
fn accepted_planned_step_reconstructs_missing_action_after_from_log_prob() {
let temperature = 2.0;
let config = seeded_metropolis_config(temperature, 1, 0, 1, 2);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let committed = triangulation.clone();
let mut state = algorithm
.initial_state(triangulation)
.expect("initial state should build");
let action_before = state.current_action;
let reconstructed_action = action_before + 0.25;
let move_type = MoveType::Move22;
let proposal_stats = ProposalStatistics::from_validated_parts(1, 0, 0, 0, 0, 0, 0, 0, 0);
record_planned_step_parts(
&algorithm,
&mut state,
step_number(1),
PlannedStepRecord {
outcome: StepOutcome::Accepted,
log_prob_after: Some(-reconstructed_action / temperature),
info: CdtProposalInfo {
move_type,
action_before,
action_after: None,
delta_action: None,
},
proposal_stats: &proposal_stats,
triangulation: &committed,
},
)
.expect("accepted planned step should record fallback action");
assert_relative_eq!(state.current_action, reconstructed_action, epsilon = 1e-12);
assert_relative_eq!(
state.steps[0]
.action_after()
.expect("accepted step should record reconstructed action"),
reconstructed_action,
epsilon = 1e-12
);
assert_relative_eq!(
state.steps[0]
.delta_action()
.expect("accepted step should record reconstructed action change"),
reconstructed_action - action_before,
epsilon = 1e-12
);
}
#[test]
fn accepted_planned_step_derives_delta_action_from_recorded_action_after() {
let temperature = 2.0;
let config = seeded_metropolis_config(temperature, 1, 0, 1, 2);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let committed = triangulation.clone();
let mut state = algorithm
.initial_state(triangulation)
.expect("initial state should build");
let action_before = state.current_action;
let action_after = action_before + 0.5;
let proposal_stats = ProposalStatistics::from_validated_parts(1, 0, 0, 0, 0, 0, 0, 0, 0);
record_planned_step_parts(
&algorithm,
&mut state,
step_number(1),
PlannedStepRecord {
outcome: StepOutcome::Accepted,
log_prob_after: None,
info: CdtProposalInfo {
move_type: MoveType::Move22,
action_before,
action_after: Some(action_after),
delta_action: Some(999.0),
},
proposal_stats: &proposal_stats,
triangulation: &committed,
},
)
.expect("accepted planned step should record explicit action_after");
assert_relative_eq!(
state.steps[0]
.delta_action()
.expect("accepted step should record action change"),
action_after - action_before,
epsilon = 1e-12
);
}
#[test]
fn accepted_planned_step_rejects_missing_action_evidence_without_mutating_state() {
let temperature = 2.0;
let config = seeded_metropolis_config(temperature, 1, 0, 1, 2);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let committed = triangulation.clone();
let mut state = algorithm
.initial_state(triangulation)
.expect("initial state should build");
let action_before = state.current_action;
let steps_before = state.steps.len();
let measurements_before = state.measurements.len();
let attempted_before = state.move_stats.total_attempted();
let accepted_before = state.move_stats.total_accepted();
let proposal_stats_before = state.proposal_stats.clone();
let proposal_stats = ProposalStatistics::from_validated_parts(1, 0, 0, 0, 0, 0, 0, 0, 0);
let err = record_planned_step_parts(
&algorithm,
&mut state,
step_number(23),
PlannedStepRecord {
outcome: StepOutcome::Accepted,
log_prob_after: None,
info: CdtProposalInfo {
move_type: MoveType::Move22,
action_before,
action_after: None,
delta_action: None,
},
proposal_stats: &proposal_stats,
triangulation: &committed,
},
)
.expect_err("accepted step without action evidence should be rejected");
assert_matches!(err, CdtError::PlannedProposalTelemetryMissing { step: 23 });
assert_relative_eq!(state.current_action, action_before, epsilon = 1e-12);
assert_eq!(state.steps.len(), steps_before);
assert_eq!(state.measurements.len(), measurements_before);
assert_eq!(state.move_stats.total_attempted(), attempted_before);
assert_eq!(state.move_stats.total_accepted(), accepted_before);
assert_eq!(state.proposal_stats, proposal_stats_before);
}
#[test]
fn run_steps_rejects_step_count_overflow_before_planned_sampler_step() {
let config = seeded_metropolis_config(1.0, 1, 0, 1, 2);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let triangulation =
CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build");
let mut state = algorithm
.initial_state(triangulation)
.expect("initial state should build");
state.current_step = u32::MAX;
let steps_before = state.steps.len();
let measurements_before = state.measurements.len();
let attempted_before = state.move_stats.total_attempted();
let accepted_before = state.move_stats.total_accepted();
let proposal_stats_before = state.proposal_stats.clone();
let err = algorithm
.run_steps(
&mut state,
NonZeroU32::new(1).expect("one additional step is nonzero"),
)
.expect_err("overflowing step counter should reject continuation");
assert_matches!(
err,
CdtError::CheckpointResumeFailed {
failure: CheckpointResumeFailure::StepCountOverflow
}
);
assert_eq!(state.current_step, u32::MAX);
assert_eq!(state.steps.len(), steps_before);
assert_eq!(state.measurements.len(), measurements_before);
assert_eq!(state.move_stats.total_attempted(), attempted_before);
assert_eq!(state.move_stats.total_accepted(), accepted_before);
assert_eq!(state.proposal_stats, proposal_stats_before);
}
#[test]
fn planned_step_error_preserves_upstream_mcmc_error() {
assert_matches!(
planned_step_error(23, DelayedStepError::Mcmc(McmcError::NanLogQRatio)),
CdtError::Mcmc(McmcError::NanLogQRatio)
);
}
#[test]
fn planned_step_error_maps_proposal_failures_to_accepted_move_error() {
fn proposal_error() -> CdtProposalError {
CdtProposalError::ApplicationFailed {
move_type: MoveType::Move31Remove,
attempt: 3,
source: CdtError::BackendMutationFailed {
operation: BackendMutationOperation::RemoveVertex,
target: "vertex VertexKey(7v1)".to_string(),
detail: "backend rejected removal".to_string(),
},
}
}
let cases = [
DelayedStepError::Plan(proposal_error()),
DelayedStepError::ProposedLogProb(proposal_error()),
DelayedStepError::LogQRatio(proposal_error()),
DelayedStepError::Commit(proposal_error()),
];
for error in cases {
assert_matches!(
planned_step_error(23, error),
CdtError::MetropolisMoveApplicationFailed {
step: 23,
move_type: MoveType::Move31Remove,
attempts: 3,
source: MetropolisMoveApplicationFailure::BackendMutation {
operation: BackendMutationOperation::RemoveVertex,
..
}
}
);
}
}
#[test]
fn missing_planned_step_info_reports_step_context() {
assert_matches!(
missing_planned_step_info(23),
CdtError::PlannedProposalTelemetryMissing { step: 23 }
);
}
#[test]
fn run_rejects_zero_frequency() {
let err = MetropolisConfig::new(1.0, 10, 2, 0).expect_err("zero cadence is invalid");
match err {
CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::MeasurementFrequency);
assert_eq!(provided_value, "0");
assert_eq!(expected, "≥ 1");
}
other => panic!("Expected InvalidSimulationConfiguration, got {other:?}"),
}
}
#[test]
fn run_rejects_bad_temperature() {
for bad_temp in [0.0, -1.0, f64::NAN, f64::INFINITY] {
let err =
MetropolisConfig::new(bad_temp, 10, 2, 2).expect_err("bad temperature is invalid");
match err {
CdtError::InvalidSimulationConfiguration {
setting, expected, ..
} => {
assert_eq!(setting, ConfigurationSetting::Temperature, "T={bad_temp}");
assert_eq!(expected, "finite and positive", "T={bad_temp}");
}
other => panic!(
"Expected InvalidSimulationConfiguration for T={bad_temp}, got {other:?}"
),
}
}
}
#[test]
fn validate_requires_measurement() {
let err = MetropolisConfig::new(1.0, 19, 15, 10).expect_err(
"Configuration should require at least one post-thermalization measurement",
);
match err {
CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::MeasurementSchedule);
assert!(
provided_value.contains("steps=19")
&& provided_value.contains("thermalization_steps=15")
&& provided_value.contains("measurement_frequency=10"),
"Unexpected provided value: {provided_value}"
);
assert_eq!(expected, "at least one post-thermalization measurement");
}
other => panic!("Expected InvalidSimulationConfiguration, got {other:?}"),
}
}
#[test]
fn validate_rejects_overflow() {
let err = MetropolisConfig::new(1.0, u32::MAX, u32::MAX, 2)
.expect_err("unreachable post-thermalization measurement should be rejected");
match err {
CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::MeasurementSchedule);
assert!(
provided_value.contains("steps=4294967295")
&& provided_value.contains("thermalization_steps=4294967295")
&& provided_value.contains("measurement_frequency=2"),
"Unexpected provided value: {provided_value}"
);
assert_eq!(expected, "at least one post-thermalization measurement");
}
other => panic!("Expected InvalidSimulationConfiguration, got {other:?}"),
}
}
#[test]
fn run_accepts_boundary_schedule() {
let config = seeded_metropolis_config(1.0, 20, 15, 10, 42);
let algorithm = MetropolisAlgorithm::new(config, ActionConfig::default());
let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed");
let results = algorithm.run(tri).expect("valid schedule should run");
assert_eq!(results.steps().len(), 20);
assert!(
results
.measurements()
.iter()
.any(|measurement| measurement.step() >= 15)
);
}
#[test]
fn run_validates_action_config() {
let err = ActionConfig::new(f64::INFINITY, 1.0, 0.1)
.expect_err("invalid action config should be rejected before simulation");
match err {
CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::Coupling0);
assert_eq!(provided_value, "inf");
assert_eq!(expected, "finite");
}
other => panic!("Expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn cdt_target_rejects_invalid_temperature() {
for temperature in [0.0, -1.0, f64::NAN, f64::INFINITY] {
let Err(err) = CdtTarget::new(ActionConfig::default(), temperature) else {
panic!("temperature {temperature:?} should be rejected");
};
match err {
CdtError::InvalidSimulationConfiguration {
setting,
provided_value: _,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::Temperature);
assert_eq!(expected, "finite and positive");
}
other => panic!("Expected InvalidSimulationConfiguration, got {other:?}"),
}
}
}
#[test]
fn cdt_target_rejects_invalid_action_config() {
let err = ActionConfig::new(f64::NAN, 1.0, 0.0)
.expect_err("invalid action config should be rejected");
match err {
CdtError::InvalidConfiguration {
setting,
provided_value: _,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::Coupling0);
assert_eq!(expected, "finite");
}
other => panic!("Expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn cdt_proposal_rejects_invalid_action_config() {
let err = ActionConfig::new(1.0, f64::NEG_INFINITY, 0.0)
.expect_err("invalid action config should be rejected");
match err {
CdtError::InvalidConfiguration {
setting,
provided_value: _,
expected,
} => {
assert_eq!(setting, ConfigurationSetting::Coupling2);
assert_eq!(expected, "finite");
}
other => panic!("Expected InvalidConfiguration, got {other:?}"),
}
assert!(ActionConfig::new(1.0, f64::NEG_INFINITY, 0.0).is_err());
}
#[test]
fn unseeded_config_uses_random_rng() {
let config = metropolis_config(1.0, 5, 1, 1); assert!(config.seed().is_none());
let mut rng = simulation_rng(config.seed());
let draw = rng.random::<f64>();
assert!((0.0..1.0).contains(&draw));
}
#[test]
fn cdt_proposal_scores_planned_proposal() {
let action_config = ActionConfig::default();
let target =
CdtTarget::new(action_config.clone(), 1.0).expect("valid target configuration");
let mut proposal = CdtProposal::with_seed(action_config, 7);
let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed");
let mut rng = StdRng::seed_from_u64(7);
let plan = proposal
.propose_plan(&triangulation, &mut rng)
.expect("planning should not fail")
.expect("CDT proposals always select a move type");
let info = proposal.info(&plan);
let proposed_log_prob = proposal
.proposed_log_prob(&triangulation, &plan, &target)
.expect("scoring should not fail");
assert_eq!(info.move_type, plan.move_type());
assert_optional_relative_eq(info.delta_action, plan.delta_action());
if let Some(action_after) = plan.action_after() {
assert_relative_eq!(proposed_log_prob, -action_after, epsilon = 1e-12);
} else {
assert!(proposed_log_prob.is_infinite() && proposed_log_prob.is_sign_negative());
}
}
#[test]
fn cdt_proposal_log_q_ratio_uses_forward_and_reverse_site_counts() {
let action_config = ActionConfig::default();
let triangulation =
CdtTriangulation::from_toroidal_cdt(4, 3).expect("toroidal CDT should build");
let mut moves = ErgodicsSystem::with_seed(19);
let mut proposal_stats = ProposalStatistics::new();
let action_before = action_for(&action_config, &triangulation);
let plan = propose_concrete_plan(
&triangulation,
&mut moves,
&mut proposal_stats,
&action_config,
MoveType::Move13Add,
action_before,
)
.expect("planning should not hard-fail")
.expect("toroidal triangulation should have a volume-add proposal");
let forward_sites = proposal_site_count(&triangulation, MoveType::Move13Add);
let reverse_sites = proposal_site_count(&plan.proposed_state, MoveType::Move31Remove);
assert!(forward_sites > 0);
assert!(reverse_sites > 0);
let expected = DiscreteProposalRatio::from_counts(forward_sites, reverse_sites)
.expect("positive forward proposal sites should build a ratio")
.log_q_ratio();
assert_relative_eq!(
concrete_log_q_ratio(&triangulation, &plan),
expected,
epsilon = 1e-12
);
let proposal = CdtProposal::new(action_config);
assert_relative_eq!(
proposal
.log_q_ratio(&triangulation, &plan)
.expect("proposal-ratio scoring should not fail"),
expected,
epsilon = 1e-12
);
}
#[test]
fn concrete_plan_does_not_mutate_ergodics_move_stats() {
let action_config = ActionConfig::default();
let triangulation =
CdtTriangulation::from_toroidal_cdt(4, 3).expect("toroidal CDT should build");
let mut moves = ErgodicsSystem::with_seed(19);
let mut proposal_stats = ProposalStatistics::new();
let action_before = action_for(&action_config, &triangulation);
let _plan = propose_concrete_plan(
&triangulation,
&mut moves,
&mut proposal_stats,
&action_config,
MoveType::Move13Add,
action_before,
)
.expect("planning should not hard-fail")
.expect("toroidal triangulation should have a volume-add proposal");
assert_eq!(moves.stats().total_attempted(), 0);
assert_eq!(moves.stats().total_accepted(), 0);
assert_eq!(moves.stats().total_hard_failures(), 0);
}
#[test]
fn cdt_proposal_scores_impossible_plan_as_negative_infinity() {
let action_config = ActionConfig::default();
let target =
CdtTarget::new(action_config.clone(), 1.0).expect("valid target configuration");
let proposal = CdtProposal::with_seed(action_config, 7);
let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed");
let plan = CdtProposalPlan {
move_type: MoveType::Move31Remove,
action_before: 1.0,
action_after: None,
delta_action: None,
forward_site_count: 0,
reverse_site_count: 0,
proposed_state: triangulation.clone(),
};
let proposed_log_prob = proposal
.proposed_log_prob(&triangulation, &plan, &target)
.expect("scoring an impossible count delta should not fail");
assert!(proposed_log_prob.is_infinite() && proposed_log_prob.is_sign_negative());
}
#[test]
fn cdt_proposal_uses_planned_sampler_path() {
let action_config = ActionConfig::default();
let target =
CdtTarget::new(action_config.clone(), 1.0).expect("valid target configuration");
let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed");
let mut chain = Chain::new(triangulation, &target)
.expect("initial state should have finite log probability");
let mut proposal = CdtProposal::with_seed(action_config, 7);
let mut rng = StdRng::seed_from_u64(11);
let step = chain
.step_delayed(&target, &mut proposal, &mut rng)
.expect("ordinary no-site outcomes must be planned-step rejections, not errors");
let info = step
.info
.expect("planned CDT steps should report proposal info");
assert_eq!(proposal.last_step_info(), Some(info));
assert_eq!(proposal.last_proposal_stats().move_family_proposals(), 1);
assert_eq!(proposal.last_proposal_stats().accepted_transitions(), 0);
assert_eq!(proposal.last_proposal_stats().metropolis_rejections(), 0);
assert!(!step.outcome.is_accepted() || step.log_prob_after.is_some());
}
#[test]
fn cdt_proposal_commit_applies_concrete_planned_state() {
let action_config = ActionConfig::default();
let mut proposal = CdtProposal::with_seed(action_config.clone(), 11);
let mut triangulation =
CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed to create");
let proposed_state =
CdtTriangulation::from_seeded_points(6, 1, 2, 59).expect("Failed to create proposal");
let proposed_counts = simplex_counts(&proposed_state);
let action_before = action_for(&action_config, &triangulation);
let action_after = action_for(&action_config, &proposed_state);
let plan = CdtProposalPlan {
move_type: MoveType::Move13Add,
action_before,
action_after: Some(action_after),
delta_action: Some(action_after - action_before),
forward_site_count: proposal_site_count(&triangulation, MoveType::Move13Add),
reverse_site_count: proposal_site_count(&proposed_state, MoveType::Move31Remove),
proposed_state,
};
let mut rng = StdRng::seed_from_u64(11);
proposal
.commit(&mut triangulation, plan, &mut rng)
.expect("committing a concrete plan should swap in the planned state");
assert_eq!(simplex_counts(&triangulation), proposed_counts);
assert_relative_eq!(action_for(&action_config, &triangulation), action_after);
}
#[test]
fn cdt_proposal_error_preserves_typed_sources() {
let source = CdtError::BackendMutationFailed {
operation: BackendMutationOperation::SetVertexDataByKey,
target: "vertex VertexKey(7)".to_string(),
detail: "missing vertex".to_string(),
};
let err = CdtProposalError::ApplicationFailed {
move_type: MoveType::Move13Add,
attempt: 2,
source: source.clone(),
};
assert_eq!(
Error::source(&err).map(ToString::to_string),
Some(source.to_string())
);
assert!(err.to_string().contains("Move13Add"));
assert!(err.to_string().contains("attempt 2"));
let cdt_error = CdtError::from(err);
assert_matches!(
cdt_error,
CdtError::ProposalApplicationFailed {
move_type: MoveType::Move13Add,
attempt: 2,
source: MetropolisMoveApplicationFailure::BackendMutation {
operation: BackendMutationOperation::SetVertexDataByKey,
..
},
}
);
let site_rejection = CdtProposalSiteRejection::Kernel(source.clone());
assert_eq!(
Error::source(&site_rejection).map(ToString::to_string),
Some(source.to_string())
);
}
}