mod built;
mod declared;
mod determinism;
mod device;
#[cfg(not(target_arch = "wasm32"))]
mod gpu;
mod report;
mod settings;
use std::error::Error;
use std::fmt;
use henad_compute::entry::{ModelEntry, ModelSet};
use henad_compute::fault::{Fault, catching, install_panic_hook};
use henad_compute::gpu::GpuContext;
use henad_compute::gpu::fault::catching_on;
use henad_core::metadata::Backend;
use henad_core::params::ParamValue;
use henad_core::view::{StatEntry, StatValue};
pub use device::TestDeviceRequest;
#[cfg(not(target_arch = "wasm32"))]
pub use device::headless_test_device;
pub use report::{CheckFailure, ModelReport, SetReport, SkipReason, SkippedCheck};
pub use settings::{CheckSettings, MIN_TICKS};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ModelCheck {
ModelId,
ParamIds,
StatLabels,
ActionIds,
Palette,
Metadata,
DefaultSetup,
DefaultsFit,
ApplyModes,
Views,
ParallelJobs,
Actions,
StatCount,
ThreadCount,
SameSeed,
SeedSensitivity,
SamplingCadence,
BaselineBuild,
FullSubmission,
SampledSlice,
}
impl ModelCheck {
pub const ALL: &'static [Self] = &[
Self::ModelId,
Self::ParamIds,
Self::StatLabels,
Self::ActionIds,
Self::Palette,
Self::Metadata,
Self::DefaultSetup,
Self::DefaultsFit,
Self::ApplyModes,
Self::Views,
Self::ParallelJobs,
Self::Actions,
Self::StatCount,
Self::ThreadCount,
Self::SameSeed,
Self::SeedSensitivity,
Self::SamplingCadence,
Self::BaselineBuild,
Self::FullSubmission,
Self::SampledSlice,
];
fn builds(self) -> bool {
!matches!(
self,
Self::ModelId
| Self::ParamIds
| Self::StatLabels
| Self::ActionIds
| Self::Palette
| Self::Metadata
| Self::DefaultSetup
| Self::DefaultsFit
)
}
fn applies_to(self, backend: Backend) -> bool {
match self {
Self::ThreadCount => backend == Backend::Cpu,
Self::DefaultsFit | Self::BaselineBuild | Self::FullSubmission | Self::SampledSlice => {
backend == Backend::Gpu
}
_ => true,
}
}
fn compares_runs(self) -> bool {
matches!(self, Self::SameSeed | Self::SeedSensitivity | Self::SamplingCadence)
}
}
impl fmt::Display for ModelCheck {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
#[must_use]
pub fn check_model(entry: &ModelEntry, settings: &CheckSettings) -> ModelReport {
check_model_requiring(entry, settings, device::gpu_required())
}
pub(crate) fn check_model_requiring(entry: &ModelEntry, settings: &CheckSettings, gpu_required: bool) -> ModelReport {
let mut report = ModelReport::new(entry.id());
let backend = entry.metadata().backend;
let gpu = if backend == Backend::Gpu {
settings.device()
} else {
None
};
let refused_override = settings.default_values(entry).err();
for &check in ModelCheck::ALL {
if let Some(reason) = skip_reason(entry, settings, check, gpu.is_some()) {
let exempt = settings.exemption(entry.id(), check).is_some();
match skipped_failure(check, &reason, exempt, refused_override.as_deref(), gpu_required) {
Some(message) => report.fail(check, message),
None => report.skip(check, reason),
}
continue;
}
let outcome = guarded(gpu, || run_check(entry, settings, check, gpu));
match outcome {
Ok(Ran::Passed) => {}
Ok(Ran::PassedAtJobs(jobs)) => report.set_thread_count_jobs(jobs),
Ok(Ran::Skipped(reason)) => report.skip(check, reason),
Err(message) => report.fail(check, message),
}
}
report
}
#[must_use]
pub fn check_model_set(models: &ModelSet, settings: &CheckSettings) -> SetReport {
let reports = models.iter().map(|entry| check_model(entry, settings)).collect();
let unknown_models = settings
.named_models()
.filter(|id| models.get(id).is_none())
.map(str::to_owned)
.collect();
SetReport::new(reports, unknown_models)
}
pub fn assert_set_conforms(models: &ModelSet, settings: &CheckSettings) {
install_panic_hook();
let report = check_model_set(models, settings);
report.assert_passed();
#[expect(clippy::print_stdout, reason = "libtest captures a test's standard output")]
{
println!("{report}");
}
}
const SEED: u64 = 1;
const CHECKING: &str = "checking the model";
enum Ran {
Passed,
PassedAtJobs(usize),
Skipped(SkipReason),
}
fn skip_reason(
entry: &ModelEntry,
settings: &CheckSettings,
check: ModelCheck,
has_device: bool,
) -> Option<SkipReason> {
let metadata = entry.metadata();
if !check.applies_to(metadata.backend) {
return Some(SkipReason::OtherBackend);
}
if check.compares_runs() && !metadata.replays_exactly {
return Some(SkipReason::InexactReplay);
}
if let Some(reason) = settings.exemption(entry.id(), check) {
return Some(SkipReason::Exempt(reason.to_owned()));
}
if cfg!(target_arch = "wasm32") && check == ModelCheck::ThreadCount {
return Some(SkipReason::NativeOnly);
}
if metadata.backend == Backend::Gpu && check.builds() {
if cfg!(target_arch = "wasm32") {
return Some(SkipReason::NativeOnly);
}
if !has_device {
return Some(SkipReason::NoDevice);
}
}
None
}
fn skipped_failure(
check: ModelCheck,
reason: &SkipReason,
exempt: bool,
refused_override: Option<&str>,
gpu_required: bool,
) -> Option<String> {
match (reason, refused_override) {
(SkipReason::OtherBackend | SkipReason::InexactReplay, _) if exempt => Some(format!(
"The settings exempt this check, and it does not apply to the model: {reason}."
)),
(SkipReason::NoDevice | SkipReason::NativeOnly, Some(message)) if check.builds() => Some(message.to_owned()),
(SkipReason::NoDevice, _) if gpu_required => {
Some("HENAD_REQUIRE_GPU is set, but the settings give no GPU device.".to_owned())
}
_ => None,
}
}
fn run_check(
entry: &ModelEntry,
settings: &CheckSettings,
check: ModelCheck,
gpu: Option<&GpuContext>,
) -> Result<Ran, String> {
match check {
ModelCheck::ModelId => declared::model_id(entry),
ModelCheck::ParamIds => declared::param_ids(entry),
ModelCheck::StatLabels => declared::stat_labels(entry),
ModelCheck::ActionIds => declared::action_ids(entry),
ModelCheck::Palette => declared::palette(entry),
ModelCheck::Metadata => declared::metadata(entry),
ModelCheck::DefaultSetup => declared::default_setup(entry),
ModelCheck::DefaultsFit => declared::defaults_fit(entry),
ModelCheck::ApplyModes => built::apply_modes(entry, &settings.check_values(entry)?, gpu),
ModelCheck::Views => built::views(entry, &settings.check_values(entry)?, gpu),
ModelCheck::ParallelJobs => built::parallel_jobs(entry, &settings.check_values(entry)?, gpu),
ModelCheck::Actions if gpu.is_some() => built::actions(entry, &settings.default_values(entry)?, gpu),
ModelCheck::Actions => built::actions(entry, &settings.check_values(entry)?, gpu),
ModelCheck::StatCount => built::stat_count(entry, &settings.check_values(entry)?, gpu),
ModelCheck::ThreadCount => return determinism::thread_count(entry, settings),
ModelCheck::SameSeed => determinism::same_seed(entry, settings, gpu),
ModelCheck::SeedSensitivity => determinism::seed_sensitivity(entry, settings, gpu),
ModelCheck::SamplingCadence => determinism::sampling_cadence(entry, settings, gpu),
#[cfg(not(target_arch = "wasm32"))]
ModelCheck::BaselineBuild => gpu::baseline_build(entry, settings, device(gpu)?),
#[cfg(not(target_arch = "wasm32"))]
ModelCheck::FullSubmission => gpu::full_submission(entry, settings, device(gpu)?),
#[cfg(not(target_arch = "wasm32"))]
ModelCheck::SampledSlice => gpu::sampled_slice(entry, settings, device(gpu)?),
#[cfg(target_arch = "wasm32")]
ModelCheck::BaselineBuild | ModelCheck::FullSubmission | ModelCheck::SampledSlice => {
return Ok(Ran::Skipped(SkipReason::NativeOnly));
}
}
.map(|()| Ran::Passed)
}
#[cfg(not(target_arch = "wasm32"))]
fn device(gpu: Option<&GpuContext>) -> Result<&GpuContext, String> {
gpu.ok_or_else(|| "A GPU check ran with no device.".to_owned())
}
fn guarded<T>(gpu: Option<&GpuContext>, body: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let Some(ctx) = gpu else {
return catching(CHECKING, body).map_err(|fault| fault_text(&fault))?;
};
drop(ctx.faults.take());
let result = catching_on(ctx, CHECKING, body).map_err(|fault| fault_text(&fault))?;
#[cfg(not(target_arch = "wasm32"))]
henad_compute::gpu::stepping::wait(ctx).map_err(|fault| fault_text(&fault))?;
if let Some(fault) = ctx.faults.take() {
return Err(fault_text(&fault));
}
result
}
fn fault_text(fault: &Fault) -> String {
format!("Fault {fault}.")
}
fn error_text(error: &(dyn Error + 'static)) -> String {
let mut text = error.to_string();
let mut current = error;
while !current.is::<Fault>()
&& let Some(source) = current.source()
{
text.push_str(": ");
text.push_str(&source.to_string());
current = source;
}
text
}
fn declared_defaults(entry: &ModelEntry) -> Vec<ParamValue> {
entry
.param_descriptors()
.iter()
.map(|descriptor| descriptor.kind.default_value())
.collect()
}
fn stat_bits(entries: &[StatEntry]) -> Vec<(&'static str, Vec<u64>)> {
entries
.iter()
.map(|entry| {
let bits = match &entry.value {
StatValue::Scalar(value) => vec![value.to_bits()],
StatValue::Vector2D { x, y } => vec![x.to_bits(), y.to_bits()],
StatValue::Histogram { edges, counts } => edges
.iter()
.map(|edge| edge.to_bits())
.chain(counts.iter().copied())
.collect(),
};
(entry.label, bits)
})
.collect()
}
fn first_stat_difference(first: &[StatEntry], second: &[StatEntry]) -> Option<String> {
let (first, second) = (stat_bits(first), stat_bits(second));
if first.len() != second.len() {
return Some(format!("{} stats against {}", first.len(), second.len()));
}
first
.iter()
.zip(&second)
.find(|(left, right)| left != right)
.map(|((label, _), _)| format!("stat '{label}'"))
}