#![forbid(unsafe_code)]
use crate::cdt::action::{ActionConfig, DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT};
use crate::cdt::metropolis::MetropolisConfig;
use crate::errors::{CdtError, CdtResult, ConfigurationSetting};
use clap::error::ErrorKind;
use clap::{ArgGroup, Error as ClapError, Parser, ValueEnum};
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::fmt::{self, Display};
use std::num::NonZeroU32;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CdtTopology {
#[default]
OpenBoundary,
Toroidal,
}
impl fmt::Display for CdtTopology {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OpenBoundary => formatter.write_str("open boundary"),
Self::Toroidal => formatter.write_str("toroidal"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdtConfig {
pub dimension: Option<u8>,
pub vertices: u32,
pub timeslices: u32,
pub volume_profile: Option<Vec<u32>>,
pub temperature: f64,
pub steps: u32,
pub thermalization_steps: u32,
pub measurement_frequency: u32,
pub coupling_0: f64,
pub coupling_2: f64,
pub cosmological_constant: f64,
pub simulate: bool,
pub seed: Option<u64>,
pub topology: CdtTopology,
pub output_csv: Option<PathBuf>,
pub output_json: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ValidatedCdtConfig {
config: CdtConfig,
metropolis_config: MetropolisConfig,
vertices: NonZeroU32,
timeslices: NonZeroU32,
initial_volume: ValidatedInitialVolumeData,
}
#[derive(Debug, Clone)]
enum ValidatedInitialVolumeData {
Regular { vertices_per_slice: NonZeroU32 },
ExplicitProfile(Vec<NonZeroU32>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidatedInitialVolume<'a> {
Regular {
vertices_per_slice: NonZeroU32,
},
ExplicitProfile(&'a [NonZeroU32]),
}
impl ValidatedCdtConfig {
pub fn new(config: CdtConfig) -> CdtResult<Self> {
config.ensure_valid()?;
let vertices = nonzero_config_count(ConfigurationSetting::Vertices, config.vertices)?;
let timeslices = nonzero_config_count(ConfigurationSetting::Timeslices, config.timeslices)?;
let initial_volume = if let Some(profile) = &config.volume_profile {
ValidatedInitialVolumeData::ExplicitProfile(
profile
.iter()
.copied()
.map(|volume| nonzero_config_count(ConfigurationSetting::VolumeProfile, volume))
.collect::<CdtResult<Vec<_>>>()?,
)
} else {
let vertices_per_slice = config.vertices / config.timeslices;
ValidatedInitialVolumeData::Regular {
vertices_per_slice: nonzero_config_count(
ConfigurationSetting::Vertices,
vertices_per_slice,
)?,
}
};
let metropolis_config = MetropolisConfig::new_with_seed(
config.temperature,
config.steps,
config.thermalization_steps,
config.measurement_frequency,
config.seed,
)?;
Ok(Self {
config,
metropolis_config,
vertices,
timeslices,
initial_volume,
})
}
#[must_use]
pub const fn config(&self) -> &CdtConfig {
&self.config
}
#[must_use]
pub fn into_config(self) -> CdtConfig {
self.config
}
#[must_use]
pub const fn dimension(&self) -> u8 {
match self.config.dimension {
Some(dimension) => dimension,
None => 2,
}
}
#[must_use]
pub const fn vertices(&self) -> NonZeroU32 {
self.vertices
}
#[must_use]
pub const fn timeslices(&self) -> NonZeroU32 {
self.timeslices
}
#[must_use]
pub fn volume_profile(&self) -> Option<&[NonZeroU32]> {
match &self.initial_volume {
ValidatedInitialVolumeData::Regular { .. } => None,
ValidatedInitialVolumeData::ExplicitProfile(profile) => Some(profile),
}
}
#[must_use]
pub const fn regular_vertices_per_slice(&self) -> Option<NonZeroU32> {
match &self.initial_volume {
ValidatedInitialVolumeData::Regular { vertices_per_slice } => Some(*vertices_per_slice),
ValidatedInitialVolumeData::ExplicitProfile(_) => None,
}
}
#[must_use]
pub fn initial_volume(&self) -> ValidatedInitialVolume<'_> {
match &self.initial_volume {
ValidatedInitialVolumeData::Regular { vertices_per_slice } => {
ValidatedInitialVolume::Regular {
vertices_per_slice: *vertices_per_slice,
}
}
ValidatedInitialVolumeData::ExplicitProfile(profile) => {
ValidatedInitialVolume::ExplicitProfile(profile)
}
}
}
#[must_use]
pub const fn topology(&self) -> CdtTopology {
self.config.topology
}
#[must_use]
pub const fn simulate(&self) -> bool {
self.config.simulate
}
#[must_use]
pub fn output_csv(&self) -> Option<&Path> {
self.config.output_csv.as_deref()
}
#[must_use]
pub fn output_json(&self) -> Option<&Path> {
self.config.output_json.as_deref()
}
#[must_use]
pub fn to_metropolis_config(&self) -> MetropolisConfig {
self.metropolis_config.clone()
}
#[must_use]
pub const fn to_action_config(&self) -> ActionConfig {
to_action_config(&self.config)
}
}
impl TryFrom<CdtConfig> for ValidatedCdtConfig {
type Error = CdtError;
fn try_from(config: CdtConfig) -> Result<Self, Self::Error> {
Self::new(config)
}
}
#[derive(Parser)]
#[command(
author,
version,
about = "Run 1+1-dimensional Causal Dynamical Triangulations simulations",
long_about = "Run 1+1-dimensional Causal Dynamical Triangulations simulations.\n\nConstruct a foliated CDT triangulation, optionally run the Metropolis move loop, and write CSV trace rows plus JSON summary metadata. Prefer --vertices-per-slice for regular initial data or --volume-profile for explicit nonuniform initial slice volumes; --vertices remains available when the total initial vertex count is already known.",
group = ArgGroup::new("vertex_count")
.required(true)
.args(["vertices", "vertices_per_slice", "volume_profile"])
)]
struct CdtCliArgs {
#[arg(
short,
long,
help = "Triangulation dimension; currently only 2 is implemented",
value_parser = clap::value_parser!(u8).range(2..3)
)]
dimension: Option<u8>,
#[arg(
short,
long,
help = "Total initial vertex count; must divide evenly by --timeslices",
value_parser = clap::value_parser!(u32).range(3..)
)]
vertices: Option<u32>,
#[arg(
long,
help = "Vertices per spatial slice; total vertices are computed as this value times --timeslices",
value_parser = clap::value_parser!(u32).range(1..)
)]
vertices_per_slice: Option<u32>,
#[arg(
short,
long,
help = "Number of time slices; open-boundary requires at least 2, toroidal at least 3",
required_unless_present = "volume_profile",
value_parser = clap::value_parser!(u32).range(1..)
)]
timeslices: Option<u32>,
#[arg(
long,
value_name = "N0,N1,...",
help = "Explicit vertices per time slice for nonuniform initial spatial volume",
long_help = "Explicit vertices per time slice for nonuniform initial spatial volume, for example 8,10,7,9. If --timeslices is also supplied it must match the number of profile entries."
)]
volume_profile: Option<String>,
#[arg(long, default_value = "1.0", help = "Positive Metropolis temperature")]
temperature: f64,
#[arg(
long,
default_value = "1000",
help = "Number of Metropolis proposals to evaluate when --simulate is set"
)]
steps: u32,
#[arg(
long,
default_value = "100",
help = "Number of initial simulation steps to exclude from measurements"
)]
thermalization_steps: u32,
#[arg(
long,
default_value = "10",
help = "Record one measurement every N simulation steps",
value_parser = clap::value_parser!(u32).range(1..)
)]
measurement_frequency: u32,
#[arg(
long,
default_value = "0.0",
help = "Regge action vertex coupling kappa_0"
)]
coupling_0: f64,
#[arg(
long,
default_value = "0.0",
help = "Regge action triangle coupling kappa_2"
)]
coupling_2: f64,
#[arg(
long,
default_value_t = DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT,
help = "Regge action cosmological constant lambda"
)]
cosmological_constant: f64,
#[arg(
long,
default_value_t = false,
help = "Run the Metropolis move loop after constructing the initial triangulation"
)]
simulate: bool,
#[arg(long, help = "RNG seed for reproducible move selection and acceptance")]
seed: Option<u64>,
#[arg(
long,
value_enum,
default_value_t = CdtTopology::default(),
help = "Topology and boundary conditions for the initial triangulation"
)]
topology: CdtTopology,
#[arg(
long,
value_name = "PATH",
help = "Write scalar trace rows to a CSV file",
long_help = "Write scalar trace rows to a CSV file for external analysis workflows. Relative paths are resolved from the current working directory, and parent directories are created when output is written."
)]
output_csv: Option<PathBuf>,
#[arg(
long,
value_name = "PATH",
help = "Write run summary, metadata, and final triangulation data to JSON",
long_help = "Write run summary, metadata, and final triangulation data to JSON. Relative paths are resolved from the current working directory, and parent directories are created when output is written."
)]
output_json: Option<PathBuf>,
}
impl CdtCliArgs {
fn into_config(self) -> Result<CdtConfig, ClapError> {
let (vertices, timeslices, volume_profile) = if let Some(raw_profile) = self.volume_profile
{
let profile = parse_volume_profile(&raw_profile).map_err(|err| {
ClapError::raw(
ErrorKind::ValueValidation,
format!("invalid --volume-profile: {err}"),
)
})?;
let profile_slices = u32::try_from(profile.len()).map_err(|err| {
ClapError::raw(
ErrorKind::ValueValidation,
format!("--volume-profile has too many entries for u32 timeslices: {err}"),
)
})?;
let timeslices = self.timeslices.unwrap_or(profile_slices);
if timeslices != profile_slices {
return Err(ClapError::raw(
ErrorKind::ValueValidation,
format!(
"--timeslices ({timeslices}) must match --volume-profile entry count ({profile_slices})"
),
));
}
let vertices = profile.iter().try_fold(0_u32, |total, &volume| {
total.checked_add(volume).ok_or_else(|| {
ClapError::raw(
ErrorKind::ValueValidation,
"--volume-profile total vertices exceed u32::MAX",
)
})
})?;
(vertices, timeslices, Some(profile))
} else {
let timeslices = self
.timeslices
.expect("required clap arguments should set timeslices without a volume profile");
let vertices = if let Some(vertices) = self.vertices {
vertices
} else {
let vertices_per_slice = self
.vertices_per_slice
.expect("required clap group should set one vertex count");
vertices_per_slice.checked_mul(timeslices).ok_or_else(|| {
ClapError::raw(
ErrorKind::ValueValidation,
format!(
"--vertices-per-slice ({vertices_per_slice}) × --timeslices ({timeslices}) exceeds u32::MAX"
),
)
})?
};
(vertices, timeslices, None)
};
Ok(CdtConfig {
dimension: self.dimension,
vertices,
timeslices,
volume_profile,
temperature: self.temperature,
steps: self.steps,
thermalization_steps: self.thermalization_steps,
measurement_frequency: self.measurement_frequency,
coupling_0: self.coupling_0,
coupling_2: self.coupling_2,
cosmological_constant: self.cosmological_constant,
simulate: self.simulate,
seed: self.seed,
topology: self.topology,
output_csv: self.output_csv,
output_json: self.output_json,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionOverride {
Value(u8),
Clear,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CdtConfigOverrides {
pub dimension: Option<DimensionOverride>,
pub vertices: Option<u32>,
pub timeslices: Option<u32>,
#[expect(
clippy::option_option,
reason = "None=no override, Some(None)=clear profile, Some(Some(v))=set profile"
)]
pub volume_profile: Option<Option<Vec<u32>>>,
pub temperature: Option<f64>,
pub steps: Option<u32>,
pub thermalization_steps: Option<u32>,
pub measurement_frequency: Option<u32>,
pub coupling_0: Option<f64>,
pub coupling_2: Option<f64>,
pub cosmological_constant: Option<f64>,
pub simulate: Option<bool>,
#[expect(
clippy::option_option,
reason = "None=no override, Some(None)=clear seed, Some(Some(v))=set seed"
)]
pub seed: Option<Option<u64>>,
pub topology: Option<CdtTopology>,
#[expect(
clippy::option_option,
reason = "None=no override, Some(None)=clear output path, Some(Some(v))=set output path"
)]
pub output_csv: Option<Option<PathBuf>>,
#[expect(
clippy::option_option,
reason = "None=no override, Some(None)=clear output path, Some(Some(v))=set output path"
)]
pub output_json: Option<Option<PathBuf>>,
}
impl CdtConfig {
pub fn into_validated(self) -> CdtResult<ValidatedCdtConfig> {
ValidatedCdtConfig::new(self)
}
pub fn merge_with_override(
&self,
overrides: &CdtConfigOverrides,
) -> CdtResult<ValidatedCdtConfig> {
let mut merged = self.clone();
if let Some(dimension_override) = overrides.dimension {
match dimension_override {
DimensionOverride::Value(value) => {
merged.dimension = Some(value);
}
DimensionOverride::Clear => {
merged.dimension = None;
}
}
}
if let Some(vertices) = overrides.vertices {
merged.vertices = vertices;
}
if let Some(timeslices) = overrides.timeslices {
merged.timeslices = timeslices;
}
match &overrides.volume_profile {
Some(Some(profile)) => {
let profile_vertices = profile.iter().try_fold(0_u32, |total, &vertices| {
total.checked_add(vertices).ok_or_else(|| {
invalid_config(
ConfigurationSetting::Vertices,
&format!("{profile:?}"),
&"volume profile sum <= u32::MAX",
)
})
})?;
let profile_timeslices = u32::try_from(profile.len()).map_err(|err| {
invalid_config(
ConfigurationSetting::Timeslices,
&profile.len(),
&format!("volume profile length must fit in u32: {err}"),
)
})?;
merged.vertices = profile_vertices;
merged.timeslices = profile_timeslices;
merged.volume_profile = Some(profile.clone());
}
Some(None) => merged.volume_profile = None,
None => {}
}
if let Some(temperature) = overrides.temperature {
merged.temperature = temperature;
}
if let Some(steps) = overrides.steps {
merged.steps = steps;
}
if let Some(thermalization_steps) = overrides.thermalization_steps {
merged.thermalization_steps = thermalization_steps;
}
if let Some(measurement_frequency) = overrides.measurement_frequency {
merged.measurement_frequency = measurement_frequency;
}
if let Some(coupling_0) = overrides.coupling_0 {
merged.coupling_0 = coupling_0;
}
if let Some(coupling_2) = overrides.coupling_2 {
merged.coupling_2 = coupling_2;
}
if let Some(cosmological_constant) = overrides.cosmological_constant {
merged.cosmological_constant = cosmological_constant;
}
if let Some(simulate) = overrides.simulate {
merged.simulate = simulate;
}
if let Some(seed) = overrides.seed {
merged.seed = seed;
}
if let Some(topology) = overrides.topology {
merged.topology = topology;
}
if let Some(output_csv) = &overrides.output_csv {
merged.output_csv.clone_from(output_csv);
}
if let Some(output_json) = &overrides.output_json {
merged.output_json.clone_from(output_json);
}
ValidatedCdtConfig::new(merged)
}
#[must_use]
pub fn resolve_path(base_dir: impl AsRef<Path>, candidate: impl AsRef<Path>) -> PathBuf {
let candidate = candidate.as_ref();
if candidate.is_absolute() {
return normalize_components(candidate);
}
if let Some(candidate_str) = candidate.to_str()
&& let Some(rest) = candidate_str.strip_prefix('~')
{
if rest.is_empty() {
if let Some(home) = home_dir() {
return normalize_components(&home);
}
} else if matches!(rest.chars().next(), Some('/' | '\\'))
&& let Some(home) = home_dir()
{
let stripped = rest.trim_start_matches(['/', '\\']);
let path = if stripped.is_empty() {
home
} else {
home.join(stripped)
};
return normalize_components(&path);
}
}
let joined = base_dir.as_ref().join(candidate);
normalize_components(&joined)
}
}
fn normalize_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
let mut components = normalized.components();
let at_root = match components.next() {
Some(Component::RootDir) => components.next().is_none(),
Some(Component::Prefix(_)) => {
matches!(components.next(), Some(Component::RootDir))
&& components.next().is_none()
}
_ => false,
};
let ends_with_parent = normalized
.components()
.next_back()
.is_some_and(|last| matches!(last, Component::ParentDir));
if at_root {
continue;
}
if normalized.as_os_str().is_empty() || ends_with_parent {
normalized.push(Component::ParentDir.as_os_str());
} else {
normalized.pop();
}
}
Component::RootDir | Component::Prefix(_) => {
normalized.push(component.as_os_str());
}
Component::Normal(segment) => {
normalized.push(segment);
}
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(Component::CurDir.as_os_str())
} else {
normalized
}
}
fn invalid_config(
setting: ConfigurationSetting,
provided_value: &impl Display,
expected: &impl Display,
) -> CdtError {
invalid_config_parts(setting, provided_value.to_string(), expected.to_string())
}
const fn invalid_config_parts(
setting: ConfigurationSetting,
provided_value: String,
expected: String,
) -> CdtError {
CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}
}
const fn invalid_sim_config_parts(
setting: ConfigurationSetting,
provided_value: String,
expected: String,
) -> CdtError {
CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
}
}
fn validate_coupling(setting: ConfigurationSetting, value: f64) -> CdtResult<()> {
if value.is_finite() {
Ok(())
} else {
Err(invalid_config(setting, &value, &"finite"))
}
}
fn nonzero_config_count(setting: ConfigurationSetting, value: u32) -> CdtResult<NonZeroU32> {
NonZeroU32::new(value).ok_or_else(|| invalid_config(setting, &value, &"≥ 1"))
}
fn parse_volume_profile(raw: &str) -> Result<Vec<u32>, String> {
let mut profile = Vec::new();
for (index, entry) in raw.split(',').enumerate() {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err(format!("volume profile entry {} is empty", index + 1));
}
let volume = trimmed.parse::<u32>().map_err(|err| {
format!(
"volume profile entry {} ({trimmed}) is not a positive integer: {err}",
index + 1
)
})?;
if volume == 0 {
return Err(format!(
"volume profile entry {} must be at least 1",
index + 1
));
}
profile.push(volume);
}
if profile.is_empty() {
Err("volume profile must contain at least one entry".to_string())
} else {
Ok(profile)
}
}
pub(crate) fn validate_schedule(
temperature: f64,
steps: u32,
thermalization_steps: u32,
measurement_frequency: u32,
mut error_for: impl FnMut(ConfigurationSetting, String, String) -> CdtError,
) -> CdtResult<()> {
let mut invalid = |setting: ConfigurationSetting, provided_value: String, expected: String| {
Err(error_for(setting, provided_value, expected))
};
if !temperature.is_finite() || temperature <= 0.0 {
return invalid(
ConfigurationSetting::Temperature,
temperature.to_string(),
"finite and positive".to_string(),
);
}
if steps == 0 {
return invalid(
ConfigurationSetting::Steps,
steps.to_string(),
"≥ 1".to_string(),
);
}
if measurement_frequency == 0 {
return invalid(
ConfigurationSetting::MeasurementFrequency,
measurement_frequency.to_string(),
"≥ 1".to_string(),
);
}
if measurement_frequency > steps {
return invalid(
ConfigurationSetting::MeasurementFrequency,
measurement_frequency.to_string(),
format!("≤ steps ({steps})"),
);
}
if thermalization_steps > steps {
return invalid(
ConfigurationSetting::ThermalizationSteps,
thermalization_steps.to_string(),
format!("≤ steps ({steps})"),
);
}
let first_post_thermalization_measurement = u64::from(thermalization_steps)
.div_ceil(u64::from(measurement_frequency))
* u64::from(measurement_frequency);
if first_post_thermalization_measurement > u64::from(steps) {
return invalid(
ConfigurationSetting::MeasurementSchedule,
format!(
"steps={steps}, thermalization_steps={thermalization_steps}, measurement_frequency={measurement_frequency}"
),
"at least one post-thermalization measurement".to_string(),
);
}
Ok(())
}
const fn to_action_config(config: &CdtConfig) -> ActionConfig {
ActionConfig::from_validated_parts(
config.coupling_0,
config.coupling_2,
config.cosmological_constant,
)
}
impl CdtConfig {
#[must_use]
pub fn from_args() -> Self {
CdtCliArgs::parse()
.into_config()
.unwrap_or_else(|err| err.exit())
}
pub fn try_from_args<I, T>(args: I) -> Result<Self, ClapError>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
CdtCliArgs::try_parse_from(args)?.into_config()
}
#[must_use]
pub const fn new(vertices: u32, timeslices: u32) -> Self {
Self {
dimension: Some(2),
vertices,
timeslices,
volume_profile: None,
temperature: 1.0,
steps: 1000,
thermalization_steps: 100,
measurement_frequency: 10,
coupling_0: 0.0,
coupling_2: 0.0,
cosmological_constant: DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT,
simulate: false,
seed: None,
topology: CdtTopology::OpenBoundary,
output_csv: None,
output_json: None,
}
}
#[must_use]
pub const fn dimension(&self) -> u8 {
match self.dimension {
Some(d) => d,
None => 2,
}
}
fn ensure_valid(&self) -> CdtResult<()> {
if self.vertices < 3 {
return Err(invalid_config(
ConfigurationSetting::Vertices,
&self.vertices,
&"≥ 3",
));
}
if self.timeslices == 0 {
return Err(invalid_config(
ConfigurationSetting::Timeslices,
&self.timeslices,
&"≥ 1",
));
}
if let Some(dim) = self.dimension
&& dim != 2
{
return Err(invalid_config(ConfigurationSetting::Dimension, &dim, &"2"));
}
validate_coupling(ConfigurationSetting::Coupling0, self.coupling_0)?;
validate_coupling(ConfigurationSetting::Coupling2, self.coupling_2)?;
validate_coupling(
ConfigurationSetting::CosmologicalConstant,
self.cosmological_constant,
)?;
self.validate_volume_constraints()?;
validate_schedule(
self.temperature,
self.steps,
self.thermalization_steps,
self.measurement_frequency,
invalid_sim_config_parts,
)
}
fn validate_volume_constraints(&self) -> CdtResult<()> {
let (minimum_slices, minimum_vertices_per_slice, topology_label) = match self.topology {
CdtTopology::OpenBoundary => (2, 4, "open-boundary topology"),
CdtTopology::Toroidal => (3, 3, "toroidal topology"),
};
if self.timeslices < minimum_slices {
return Err(invalid_config_parts(
ConfigurationSetting::Timeslices,
self.timeslices.to_string(),
format!("≥ {minimum_slices} for {topology_label}"),
));
}
if let Some(profile) = &self.volume_profile {
return self.validate_explicit_volume_profile(
profile,
minimum_vertices_per_slice,
topology_label,
);
}
if !self.vertices.is_multiple_of(self.timeslices) {
return Err(invalid_config_parts(
ConfigurationSetting::Vertices,
self.vertices.to_string(),
format!(
"divisible by timeslices ({}) for {topology_label}",
self.timeslices
),
));
}
let min_total = self
.timeslices
.checked_mul(minimum_vertices_per_slice)
.ok_or_else(|| {
invalid_config_parts(
ConfigurationSetting::Timeslices,
self.timeslices.to_string(),
format!(
"{minimum_vertices_per_slice} · timeslices must fit in u32 for {topology_label}"
),
)
})?;
if self.vertices < min_total {
return Err(invalid_config_parts(
ConfigurationSetting::Vertices,
self.vertices.to_string(),
format!(
"≥ {minimum_vertices_per_slice} · timeslices ({min_total}) for {topology_label}"
),
));
}
Ok(())
}
fn validate_explicit_volume_profile(
&self,
profile: &[u32],
minimum_vertices_per_slice: u32,
topology_label: &str,
) -> CdtResult<()> {
let expected_len = usize::try_from(self.timeslices).map_err(|err| {
invalid_config_parts(
ConfigurationSetting::Timeslices,
self.timeslices.to_string(),
format!("must fit usize for volume profile validation: {err}"),
)
})?;
if profile.len() != expected_len {
return Err(invalid_config_parts(
ConfigurationSetting::VolumeProfile,
format!("{} entries", profile.len()),
format!("{} entries for configured timeslices", self.timeslices),
));
}
let mut total = 0_u32;
for (slice, &volume) in profile.iter().enumerate() {
if volume < minimum_vertices_per_slice {
return Err(invalid_config_parts(
ConfigurationSetting::VolumeProfile,
format!("slice {slice} has {volume}"),
format!("each slice ≥ {minimum_vertices_per_slice} for {topology_label}"),
));
}
total = total.checked_add(volume).ok_or_else(|| {
invalid_config_parts(
ConfigurationSetting::VolumeProfile,
format!("{profile:?}"),
"sum must fit in u32".to_string(),
)
})?;
}
if total != self.vertices {
return Err(invalid_config_parts(
ConfigurationSetting::Vertices,
self.vertices.to_string(),
format!("sum of volume_profile ({total})"),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct TestConfig;
impl TestConfig {
#[must_use]
pub const fn small() -> CdtConfig {
CdtConfig {
dimension: Some(2),
vertices: 16,
timeslices: 2,
volume_profile: None,
temperature: 1.0,
steps: 10,
thermalization_steps: 2,
measurement_frequency: 2,
coupling_0: 0.0,
coupling_2: 0.0,
cosmological_constant: DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT,
simulate: false,
seed: None,
topology: CdtTopology::OpenBoundary,
output_csv: None,
output_json: None,
}
}
#[must_use]
pub const fn medium() -> CdtConfig {
CdtConfig {
dimension: Some(2),
vertices: 64,
timeslices: 4,
volume_profile: None,
temperature: 1.0,
steps: 100,
thermalization_steps: 20,
measurement_frequency: 5,
coupling_0: 0.0,
coupling_2: 0.0,
cosmological_constant: DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT,
simulate: false,
seed: None,
topology: CdtTopology::OpenBoundary,
output_csv: None,
output_json: None,
}
}
#[must_use]
pub const fn large() -> CdtConfig {
CdtConfig {
dimension: Some(2),
vertices: 256,
timeslices: 8,
volume_profile: None,
temperature: 1.0,
steps: 1000,
thermalization_steps: 100,
measurement_frequency: 10,
coupling_0: 0.0,
coupling_2: 0.0,
cosmological_constant: DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT,
simulate: false,
seed: None,
topology: CdtTopology::OpenBoundary,
output_csv: None,
output_json: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use dirs::home_dir;
use std::assert_matches;
use std::path::PathBuf;
#[test]
fn test_config_new() {
let config = CdtConfig::new(36, 3);
assert_eq!(config.vertices, 36);
assert_eq!(config.timeslices, 3);
assert_eq!(config.dimension(), 2);
assert!(!config.simulate);
assert_eq!(config.output_csv, None);
assert_eq!(config.output_json, None);
}
#[test]
fn try_from_args_parses_valid_cli_values() {
let config = CdtConfig::try_from_args([
"cdt",
"--vertices",
"36",
"--timeslices",
"3",
"--simulate",
"--seed",
"42",
])
.expect("valid CLI arguments should parse");
assert_eq!(config.vertices, 36);
assert_eq!(config.timeslices, 3);
assert!(config.simulate);
assert_eq!(config.seed, Some(42));
}
#[test]
fn try_from_args_computes_total_vertices_from_vertices_per_slice() {
let config = CdtConfig::try_from_args([
"cdt",
"--vertices-per-slice",
"8",
"--timeslices",
"6",
"--topology",
"toroidal",
])
.expect("per-slice CLI arguments should parse");
assert_eq!(config.vertices, 48);
assert_eq!(config.timeslices, 6);
assert_eq!(config.topology, CdtTopology::Toroidal);
}
#[test]
fn try_from_args_rejects_vertices_per_slice_overflow() {
let vertices_per_slice = u32::MAX.to_string();
let error = CdtConfig::try_from_args([
"cdt",
"--vertices-per-slice",
&vertices_per_slice,
"--timeslices",
"2",
])
.expect_err("overflowed per-slice total should be a parse error");
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
assert!(
error
.to_string()
.contains("--vertices-per-slice (4294967295) × --timeslices (2) exceeds u32::MAX"),
"unexpected error: {error}"
);
}
#[test]
fn try_from_args_returns_structured_error_without_exiting() {
let error = CdtConfig::try_from_args([
"cdt",
"--vertices",
"36",
"--timeslices",
"3",
"--dimension",
"3",
])
.expect_err("unsupported dimension should be reported as a parse error");
assert_matches!(
error.kind(),
clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue
);
}
#[test]
fn topology_serializes_with_cli_vocabulary() {
let json =
serde_json::to_string(&CdtTopology::OpenBoundary).expect("topology should serialize");
assert_eq!(json, "\"open-boundary\"");
let topology: CdtTopology =
serde_json::from_str("\"toroidal\"").expect("topology should deserialize");
assert_eq!(topology, CdtTopology::Toroidal);
}
#[test]
fn test_config_conversions() {
let config = CdtConfig::new(64, 4)
.into_validated()
.expect("test config should validate");
let metropolis_config = config.to_metropolis_config();
assert_relative_eq!(metropolis_config.temperature(), 1.0);
assert_eq!(metropolis_config.steps().get(), 1000);
let action_config = config.to_action_config();
assert_relative_eq!(action_config.coupling_0(), 0.0);
assert_relative_eq!(action_config.coupling_2(), 0.0);
assert_relative_eq!(
action_config.cosmological_constant(),
DEFAULT_CDT_1P1_EDGE_COSMOLOGICAL_CONSTANT
);
}
#[test]
fn validated_config_carries_initial_volume_proof() {
let regular = CdtConfig::new(64, 4)
.into_validated()
.expect("regular config should validate");
assert_eq!(regular.dimension(), 2);
assert_eq!(regular.vertices().get(), 64);
assert_eq!(regular.timeslices().get(), 4);
assert_eq!(
regular.regular_vertices_per_slice().map(NonZeroU32::get),
Some(16)
);
assert_matches!(
regular.initial_volume(),
ValidatedInitialVolume::Regular {
vertices_per_slice
} if vertices_per_slice.get() == 16
);
let profiled = CdtConfig {
vertices: 15,
timeslices: 3,
volume_profile: Some(vec![4, 6, 5]),
..CdtConfig::new(15, 3)
}
.into_validated()
.expect("profile config should validate");
assert_eq!(profiled.regular_vertices_per_slice(), None);
assert!(
profiled
.volume_profile()
.expect("explicit profile should be present")
.iter()
.map(|volume| volume.get())
.eq([4, 6, 5])
);
assert_matches!(
profiled.initial_volume(),
ValidatedInitialVolume::ExplicitProfile(profile)
if profile.iter().map(|volume| volume.get()).eq([4, 6, 5])
);
}
#[test]
fn validated_config_exposes_raw_runtime_fields() {
let raw = CdtConfig {
topology: CdtTopology::Toroidal,
simulate: false,
output_csv: Some(PathBuf::from("trace.csv")),
output_json: Some(PathBuf::from("summary.json")),
..CdtConfig::new(12, 3)
};
let validated =
ValidatedCdtConfig::try_from(raw.clone()).expect("toroidal config should validate");
assert_eq!(validated.config().vertices, 12);
assert_eq!(validated.volume_profile(), None);
assert_eq!(validated.topology(), CdtTopology::Toroidal);
assert!(!validated.simulate());
assert_eq!(
validated.output_csv(),
Some(PathBuf::from("trace.csv").as_path())
);
assert_eq!(
validated.output_json(),
Some(PathBuf::from("summary.json").as_path())
);
assert_eq!(validated.into_config().output_json, raw.output_json);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "validation test exercises the full structured configuration error matrix"
)]
fn test_config_validation() {
let valid_config = CdtConfig::new(36, 3);
assert!(valid_config.into_validated().is_ok());
let invalid_vertices = CdtConfig {
vertices: 2,
..CdtConfig::new(36, 3)
};
assert_matches!(
invalid_vertices.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Vertices && provided_value == "2" && expected == "≥ 3"
);
let invalid_timeslices = CdtConfig {
timeslices: 0,
..CdtConfig::new(36, 3)
};
assert_matches!(
invalid_timeslices.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Timeslices && provided_value == "0" && expected == "≥ 1"
);
let invalid_temperature = CdtConfig {
temperature: -1.0,
..CdtConfig::new(36, 3)
};
assert_matches!(
invalid_temperature.into_validated(),
Err(CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Temperature
&& provided_value == "-1"
&& expected == "finite and positive"
);
let invalid_measurement_frequency = CdtConfig {
measurement_frequency: 0,
..CdtConfig::new(36, 3)
};
assert_matches!(
invalid_measurement_frequency.into_validated(),
Err(CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::MeasurementFrequency
&& provided_value == "0"
&& expected == "≥ 1"
);
let invalid_steps = CdtConfig {
steps: 0,
..CdtConfig::new(36, 3)
};
assert_matches!(
invalid_steps.into_validated(),
Err(CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Steps && provided_value == "0" && expected == "≥ 1"
);
let invalid_dimension = CdtConfig {
dimension: Some(4),
..CdtConfig::new(36, 3)
};
assert_matches!(
invalid_dimension.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Dimension && provided_value == "4" && expected == "2"
);
for (setting, value) in [
(ConfigurationSetting::Coupling0, f64::NAN),
(ConfigurationSetting::Coupling2, f64::INFINITY),
(
ConfigurationSetting::CosmologicalConstant,
f64::NEG_INFINITY,
),
] {
let mut invalid_action_coupling = CdtConfig::new(36, 3);
match setting {
ConfigurationSetting::Coupling0 => invalid_action_coupling.coupling_0 = value,
ConfigurationSetting::Coupling2 => invalid_action_coupling.coupling_2 = value,
ConfigurationSetting::CosmologicalConstant => {
invalid_action_coupling.cosmological_constant = value;
}
_ => unreachable!("test case should name a known action coupling"),
}
assert_matches!(
invalid_action_coupling.into_validated(),
Err(CdtError::InvalidConfiguration {
setting: invalid_setting,
expected,
..
}) if invalid_setting == setting && expected == "finite"
);
}
let finite_action_couplings = CdtConfig {
coupling_0: -1.5,
coupling_2: 0.0,
cosmological_constant: 2.25,
..CdtConfig::new(36, 3)
};
assert!(
finite_action_couplings.into_validated().is_ok(),
"finite signed action couplings should be accepted"
);
let measurement_frequency_exceeds_steps = CdtConfig {
measurement_frequency: 2_000,
..CdtConfig::new(36, 3)
};
assert_matches!(
measurement_frequency_exceeds_steps.into_validated(),
Err(CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::MeasurementFrequency
&& provided_value == "2000"
&& expected == "≤ steps (1000)"
);
let boundary_aligned_measurement = CdtConfig {
steps: 11,
thermalization_steps: 10,
measurement_frequency: 10,
..CdtConfig::new(36, 3)
};
assert!(
boundary_aligned_measurement.into_validated().is_ok(),
"Configurations where thermalization ends on a measurement boundary should pass validation"
);
let boundary_aligned_final_measurement = CdtConfig {
steps: 10,
thermalization_steps: 10,
measurement_frequency: 5,
..CdtConfig::new(36, 3)
};
assert!(
boundary_aligned_final_measurement.into_validated().is_ok(),
"Configurations with a final-step post-thermalization measurement should pass validation"
);
let final_step_measurement = CdtConfig {
steps: 20,
thermalization_steps: 15,
measurement_frequency: 10,
..CdtConfig::new(36, 3)
};
assert!(
final_step_measurement.into_validated().is_ok(),
"A measurement taken exactly at the final completed step should satisfy the schedule"
);
let insufficient_measurements = CdtConfig {
steps: 19,
thermalization_steps: 15,
measurement_frequency: 10,
..CdtConfig::new(36, 3)
};
match insufficient_measurements.into_validated() {
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!("Unexpected validation result: {other:?}"),
}
let thermalization_exceeds_steps = CdtConfig {
steps: 10,
thermalization_steps: 11,
measurement_frequency: 1,
..CdtConfig::new(36, 3)
};
assert_matches!(
thermalization_exceeds_steps.into_validated(),
Err(CdtError::InvalidSimulationConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::ThermalizationSteps
&& provided_value == "11"
&& expected == "≤ steps (10)"
);
let overflowed_post_thermalization_boundary = CdtConfig {
steps: u32::MAX,
thermalization_steps: u32::MAX,
measurement_frequency: 2,
..CdtConfig::new(36, 3)
};
match overflowed_post_thermalization_boundary.into_validated() {
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!("Unexpected validation result: {other:?}"),
}
}
#[test]
fn test_config_validation_toroidal_topology() {
let valid_toroidal = CdtConfig {
topology: CdtTopology::Toroidal,
vertices: 12,
timeslices: 3,
..CdtConfig::new(12, 3)
};
assert!(
valid_toroidal.into_validated().is_ok(),
"Valid toroidal config (T=3, V=12) should validate"
);
let toroidal_t_too_small = CdtConfig {
topology: CdtTopology::Toroidal,
vertices: 6,
timeslices: 2,
..CdtConfig::new(6, 2)
};
assert_matches!(
toroidal_t_too_small.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Timeslices
&& provided_value == "2"
&& expected == "≥ 3 for toroidal topology"
);
let toroidal_indivisible = CdtConfig {
topology: CdtTopology::Toroidal,
vertices: 11,
timeslices: 3,
..CdtConfig::new(11, 3)
};
assert_matches!(
toroidal_indivisible.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Vertices
&& provided_value == "11"
&& expected == "divisible by timeslices (3) for toroidal topology"
);
let toroidal_too_few_per_slice = CdtConfig {
topology: CdtTopology::Toroidal,
vertices: 6,
timeslices: 3,
..CdtConfig::new(6, 3)
};
assert_matches!(
toroidal_too_few_per_slice.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Vertices
&& provided_value == "6"
&& expected == "≥ 3 · timeslices (9) for toroidal topology"
);
let toroidal_min_total_overflow = CdtConfig {
topology: CdtTopology::Toroidal,
vertices: u32::MAX,
timeslices: u32::MAX,
..CdtConfig::new(u32::MAX, u32::MAX)
};
assert_matches!(
toroidal_min_total_overflow.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Timeslices
&& provided_value == u32::MAX.to_string()
&& expected == "3 · timeslices must fit in u32 for toroidal topology"
);
}
#[test]
fn test_config_validation_open_boundary_regular_slices() {
let valid_open_boundary = CdtConfig {
topology: CdtTopology::OpenBoundary,
vertices: 12,
timeslices: 3,
..CdtConfig::new(12, 3)
};
assert!(
valid_open_boundary.into_validated().is_ok(),
"valid open-boundary config should validate"
);
let open_boundary_too_few_slices = CdtConfig {
topology: CdtTopology::OpenBoundary,
vertices: 4,
timeslices: 1,
..CdtConfig::new(4, 1)
};
assert_matches!(
open_boundary_too_few_slices.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Timeslices
&& provided_value == "1"
&& expected == "≥ 2 for open-boundary topology"
);
let open_boundary_indivisible = CdtConfig {
topology: CdtTopology::OpenBoundary,
vertices: 11,
timeslices: 3,
..CdtConfig::new(11, 3)
};
assert_matches!(
open_boundary_indivisible.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Vertices
&& provided_value == "11"
&& expected == "divisible by timeslices (3) for open-boundary topology"
);
let open_boundary_too_few_per_slice = CdtConfig {
topology: CdtTopology::OpenBoundary,
vertices: 9,
timeslices: 3,
..CdtConfig::new(9, 3)
};
assert_matches!(
open_boundary_too_few_per_slice.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Vertices
&& provided_value == "9"
&& expected == "≥ 4 · timeslices (12) for open-boundary topology"
);
}
#[test]
fn test_config_validation_open_boundary_volume_profile() {
let valid_profile = CdtConfig {
vertices: 15,
timeslices: 3,
volume_profile: Some(vec![4, 6, 5]),
..CdtConfig::new(12, 3)
};
assert!(
valid_profile.into_validated().is_ok(),
"nonuniform open-boundary profiles should not require divisible vertex counts"
);
let mismatched_sum = CdtConfig {
vertices: 14,
timeslices: 3,
volume_profile: Some(vec![4, 6, 5]),
..CdtConfig::new(12, 3)
};
assert_matches!(
mismatched_sum.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::Vertices
&& provided_value == "14"
&& expected == "sum of volume_profile (15)"
);
let mismatched_len = CdtConfig {
vertices: 15,
timeslices: 4,
volume_profile: Some(vec![4, 6, 5]),
..CdtConfig::new(16, 4)
};
assert_matches!(
mismatched_len.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::VolumeProfile
&& provided_value == "3 entries"
&& expected == "4 entries for configured timeslices"
);
}
#[test]
fn test_config_validation_toroidal_volume_profile_minimum_slice_size() {
let valid_profile = CdtConfig {
vertices: 16,
timeslices: 4,
topology: CdtTopology::Toroidal,
volume_profile: Some(vec![3, 4, 5, 4]),
..CdtConfig::new(16, 4)
};
assert!(valid_profile.into_validated().is_ok());
let too_small = CdtConfig {
vertices: 11,
timeslices: 4,
topology: CdtTopology::Toroidal,
volume_profile: Some(vec![3, 2, 3, 3]),
..CdtConfig::new(12, 4)
};
assert_matches!(
too_small.into_validated(),
Err(CdtError::InvalidConfiguration {
setting,
provided_value,
expected,
}) if setting == ConfigurationSetting::VolumeProfile
&& provided_value == "slice 1 has 2"
&& expected == "each slice ≥ 3 for toroidal topology"
);
}
#[test]
fn test_try_from_args_derives_counts_from_volume_profile() {
let config = CdtConfig::try_from_args(["cdt", "--volume-profile", "4, 6,5"])
.expect("volume profile CLI should derive counts");
assert_eq!(config.vertices, 15);
assert_eq!(config.timeslices, 3);
assert_eq!(config.volume_profile, Some(vec![4, 6, 5]));
}
#[test]
fn test_try_from_args_rejects_malformed_volume_profile() {
let error = CdtConfig::try_from_args(["cdt", "--volume-profile", "4,,5"])
.expect_err("empty profile entries should be rejected");
assert!(
error
.to_string()
.contains("invalid --volume-profile: volume profile entry 2 is empty"),
"{error}"
);
}
#[test]
fn test_dimension_defaults_to_two_when_unspecified() {
let config = CdtConfig {
dimension: None,
..CdtConfig::new(36, 3)
};
assert_eq!(config.dimension(), 2);
}
#[test]
fn test_preset_configs() {
let small = TestConfig::small();
assert!(small.clone().into_validated().is_ok());
assert_eq!(small.vertices, 16);
assert_eq!(small.steps, 10);
let medium = TestConfig::medium();
assert!(medium.clone().into_validated().is_ok());
assert_eq!(medium.vertices, 64);
assert_eq!(medium.steps, 100);
let large = TestConfig::large();
assert!(large.clone().into_validated().is_ok());
assert_eq!(large.vertices, 256);
assert_eq!(large.steps, 1000);
}
#[test]
fn test_merge_with_override_updates_specified_fields() {
let base = CdtConfig::new(10, 2);
let overrides = CdtConfigOverrides {
dimension: Some(DimensionOverride::Clear),
vertices: Some(42),
temperature: Some(2.5),
simulate: Some(false),
..CdtConfigOverrides::default()
};
let merged = base
.merge_with_override(&overrides)
.expect("override merge should succeed");
let merged = merged.config();
assert_eq!(merged.dimension, None);
assert_eq!(merged.dimension(), 2);
assert_eq!(merged.vertices, 42);
assert_relative_eq!(merged.temperature, 2.5);
assert!(!merged.simulate);
assert_eq!(merged.timeslices, base.timeslices);
assert_eq!(merged.steps, base.steps);
}
#[test]
fn test_merge_with_override_updates_remaining_fields() {
let base = CdtConfig::new(10, 2);
let overrides = CdtConfigOverrides {
timeslices: Some(5),
volume_profile: Some(Some(vec![4, 6, 5])),
steps: Some(250),
thermalization_steps: Some(25),
measurement_frequency: Some(5),
coupling_0: Some(1.5),
coupling_2: Some(2.5),
cosmological_constant: Some(0.25),
seed: Some(Some(99)),
topology: Some(CdtTopology::Toroidal),
output_csv: Some(Some(PathBuf::from("trace.csv"))),
output_json: Some(Some(PathBuf::from("summary.json"))),
..CdtConfigOverrides::default()
};
let merged = base
.merge_with_override(&overrides)
.expect("override merge should succeed");
let merged = merged.config();
assert_eq!(merged.vertices, 15);
assert_eq!(merged.timeslices, 3);
assert_eq!(merged.volume_profile, Some(vec![4, 6, 5]));
assert_eq!(merged.steps, 250);
assert_eq!(merged.thermalization_steps, 25);
assert_eq!(merged.measurement_frequency, 5);
assert_relative_eq!(merged.coupling_0, 1.5);
assert_relative_eq!(merged.coupling_2, 2.5);
assert_relative_eq!(merged.cosmological_constant, 0.25);
assert_eq!(merged.seed, Some(99));
assert_eq!(merged.topology, CdtTopology::Toroidal);
assert_eq!(merged.output_csv, Some(PathBuf::from("trace.csv")));
assert_eq!(merged.output_json, Some(PathBuf::from("summary.json")));
}
#[test]
fn test_merge_with_override_can_clear_seed() {
let base = CdtConfig {
seed: Some(77),
..CdtConfig::new(10, 2)
};
let overrides = CdtConfigOverrides {
seed: Some(None),
..CdtConfigOverrides::default()
};
let merged = base
.merge_with_override(&overrides)
.expect("override merge should succeed");
let merged = merged.config();
assert_eq!(merged.seed, None);
}
#[test]
fn test_merge_with_override_can_clear_volume_profile() {
let base = CdtConfig {
vertices: 15,
timeslices: 3,
volume_profile: Some(vec![4, 6, 5]),
..CdtConfig::new(12, 3)
};
let overrides = CdtConfigOverrides {
volume_profile: Some(None),
..CdtConfigOverrides::default()
};
let merged = base
.merge_with_override(&overrides)
.expect("override merge should succeed");
let merged = merged.config();
assert_eq!(merged.volume_profile, None);
assert_eq!(merged.vertices, 15);
assert_eq!(merged.timeslices, 3);
}
#[test]
fn test_merge_with_override_can_clear_dimension() {
let base = CdtConfig::new(10, 2);
let overrides = CdtConfigOverrides {
dimension: Some(DimensionOverride::Clear),
..CdtConfigOverrides::default()
};
let merged = base
.merge_with_override(&overrides)
.expect("override merge should succeed");
let merged = merged.config();
assert_eq!(merged.dimension, None);
assert_eq!(merged.dimension(), 2); }
#[test]
fn test_merge_with_override_rejects_volume_profile_vertex_overflow() {
let base = CdtConfig::new(10, 2);
let overrides = CdtConfigOverrides {
volume_profile: Some(Some(vec![u32::MAX, 1])),
..CdtConfigOverrides::default()
};
let result = base.merge_with_override(&overrides);
assert_matches!(
result,
Err(CdtError::InvalidConfiguration {
setting: ConfigurationSetting::Vertices,
ref provided_value,
ref expected,
}) if provided_value == "[4294967295, 1]"
&& expected == "volume profile sum <= u32::MAX"
);
}
#[test]
fn test_merge_with_override_rejects_invalid_schedule() {
let base = CdtConfig::new(10, 2);
let overrides = CdtConfigOverrides {
measurement_frequency: Some(0),
..CdtConfigOverrides::default()
};
let result = base.merge_with_override(&overrides);
assert_matches!(
result,
Err(CdtError::InvalidSimulationConfiguration {
setting: ConfigurationSetting::MeasurementFrequency,
ref provided_value,
ref expected,
}) if provided_value == "0" && expected == "≥ 1"
);
}
#[test]
fn test_merge_with_override_rejects_invalid_base_without_overrides() {
let base = CdtConfig {
measurement_frequency: 0,
..CdtConfig::new(10, 2)
};
let result = base.merge_with_override(&CdtConfigOverrides::default());
assert_matches!(
result,
Err(CdtError::InvalidSimulationConfiguration {
setting: ConfigurationSetting::MeasurementFrequency,
ref provided_value,
ref expected,
}) if provided_value == "0" && expected == "≥ 1"
);
}
#[test]
fn test_resolve_path_with_absolute_path() {
let abs = PathBuf::from("/tmp/example");
let resolved = CdtConfig::resolve_path("/does/not/matter", &abs);
assert_eq!(resolved, PathBuf::from("/tmp/example"));
}
#[test]
fn test_resolve_path_with_relative_path() {
let base = PathBuf::from("/tmp/base");
let candidate = PathBuf::from("config/settings.toml");
let resolved = CdtConfig::resolve_path(&base, &candidate);
assert_eq!(resolved, PathBuf::from("/tmp/base/config/settings.toml"));
}
#[test]
fn test_resolve_path_with_home_expansion() {
let home = home_dir().expect("Home directory must be resolvable for this test");
let resolved = CdtConfig::resolve_path("/tmp", PathBuf::from("~/config.toml"));
assert_eq!(resolved, home.join("config.toml"));
}
#[test]
fn test_resolve_path_with_home_directory_only() {
let home = home_dir().expect("Home directory must be resolvable for this test");
let resolved = CdtConfig::resolve_path("/tmp", PathBuf::from("~"));
assert_eq!(resolved, home);
}
#[test]
fn test_resolve_path_with_trailing_home_separator() {
let home = home_dir().expect("Home directory must be resolvable for this test");
let resolved = CdtConfig::resolve_path("/tmp", PathBuf::from("~/"));
assert_eq!(resolved, home);
}
#[test]
fn test_resolve_path_with_windows_style_home_separator() {
let home = home_dir().expect("Home directory must be resolvable for this test");
let resolved = CdtConfig::resolve_path("/tmp", PathBuf::from("~\\config.toml"));
assert_eq!(resolved, home.join("config.toml"));
}
#[test]
fn test_resolve_path_normalizes_navigation_components() {
let base = PathBuf::from("/tmp/base");
let candidate = PathBuf::from("configs/../settings.toml");
let resolved = CdtConfig::resolve_path(&base, candidate);
assert_eq!(resolved, PathBuf::from("/tmp/base/settings.toml"));
}
#[test]
fn test_resolve_path_preserves_relative_parent_prefix() {
let resolved = CdtConfig::resolve_path(".", PathBuf::from("../../settings.toml"));
assert_eq!(resolved, PathBuf::from("../../settings.toml"));
}
#[test]
fn test_resolve_path_cannot_escape_root() {
let candidate = PathBuf::from("/../etc/passwd");
let resolved = CdtConfig::resolve_path("/tmp", candidate);
assert_eq!(resolved, PathBuf::from("/etc/passwd"));
}
}