diff --git a/chutoro-core/tests/chutoro.rs b/chutoro-core/tests/chutoro.rs index 952c06f5..81a82507 100644 --- a/chutoro-core/tests/chutoro.rs +++ b/chutoro-core/tests/chutoro.rs @@ -1,12 +1,13 @@ //! Tests for the `Chutoro` orchestration API. +#[path = "chutoro/assignment_validation.rs"] +mod assignment_validation; mod common; #[path = "support/failable_source.rs"] mod failable_source; use chutoro_core::{ - ChutoroBuilder, ChutoroError, ClusterId, ClusteringResult, DataSource, DataSourceError, - ExecutionStrategy, MetricDescriptor, NonContiguousClusterIds, + ChutoroBuilder, ChutoroError, DataSource, DataSourceError, ExecutionStrategy, MetricDescriptor, }; #[cfg(feature = "cpu")] use chutoro_core::{ @@ -14,9 +15,8 @@ use chutoro_core::{ }; use common::Dummy; use failable_source::{FailableSource, FailureMode}; -use proptest::prelude::*; use rstest::{fixture, rstest}; -use std::{collections::HashSet, num::NonZeroUsize, sync::Arc}; +use std::{num::NonZeroUsize, sync::Arc}; use tracing::Level; use tracing_subscriber::layer::SubscriberExt; @@ -330,112 +330,6 @@ fn run_gpu_preferred_errors_until_backend_available(dummy: Dummy) { )); } -#[rstest] -#[case::single(vec![ClusterId::new(0)], 1)] -#[case::two_clusters(vec![ClusterId::new(0), ClusterId::new(1)], 2)] -fn cluster_count_matches_unique_assignments( - #[case] assignments: Vec, - #[case] expected: usize, -) { - let result = ClusteringResult::try_from_assignments(assignments) - .expect("test assignments must be contiguous"); - assert_eq!(result.cluster_count(), expected); -} - -#[rstest] -#[case::missing_zero(vec![ClusterId::new(1)], NonContiguousClusterIds::MissingZero, "assignments must start at zero")] -#[case::gap(vec![ClusterId::new(0), ClusterId::new(2)], NonContiguousClusterIds::Gap, "assignments must be contiguous")] -#[case::overflow(vec![ClusterId::new(u64::MAX)], NonContiguousClusterIds::Overflow, "assignments must stay within usize range")] -#[case::duplicate( - vec![ClusterId::new(0), ClusterId::new(0), ClusterId::new(2)], - NonContiguousClusterIds::Duplicate, - "assignments must not duplicate identifiers when clusters are missing", -)] -fn try_from_assignments_validates_contiguity( - #[case] assignments: Vec, - #[case] expected_error: NonContiguousClusterIds, - #[case] error_message: &str, -) { - let err = ClusteringResult::try_from_assignments(assignments).expect_err(error_message); - assert_eq!(err, expected_error); -} - -fn model_assignment_validation( - assignments: &[ClusterId], -) -> Result { - if assignments.is_empty() { - return Ok(0); - } - - let identifiers: Vec<_> = assignments - .iter() - .map(|assignment| assignment.get()) - .collect(); - if identifiers - .iter() - .any(|identifier| *identifier >= usize::MAX as u64) - { - return Err(NonContiguousClusterIds::Overflow); - } - - let unique: HashSet<_> = identifiers.iter().copied().collect(); - if !unique.contains(&0) { - return Err(NonContiguousClusterIds::MissingZero); - } - - let Some(maximum) = identifiers.iter().copied().max() else { - return Ok(0); - }; - let has_gap = (0..=maximum).any(|identifier| !unique.contains(&identifier)); - if !has_gap { - return Ok(unique.len()); - } - - if unique.len() == identifiers.len() { - Err(NonContiguousClusterIds::Gap) - } else { - Err(NonContiguousClusterIds::Duplicate) - } -} - -proptest! { - #[test] - fn try_from_assignments_matches_the_contiguity_model( - assignment_values in prop::collection::vec( - prop_oneof![ - 8 => 0_u64..=16, - 1 => Just(usize::MAX as u64), - 1 => Just(u64::MAX), - ], - 0..=24, - ), - ) { - let assignments: Vec<_> = assignment_values - .iter() - .copied() - .map(ClusterId::new) - .collect(); - let expected = model_assignment_validation(&assignments); - let actual = ClusteringResult::try_from_assignments(assignments.clone()); - - match (actual, expected) { - (Ok(result), Ok(cluster_count)) => { - prop_assert_eq!(result.assignments(), assignments.as_slice()); - prop_assert_eq!(result.cluster_count(), cluster_count); - } - (Err(actual_error), Err(expected_error)) => { - prop_assert_eq!(actual_error, expected_error); - } - (actual_result, expected_result) => { - prop_assert!( - false, - "actual result {actual_result:?} disagrees with model {expected_result:?}" - ); - } - } - } -} - #[test] fn datasource_error_display_includes_index() { let err = DataSourceError::OutOfBounds { index: 5 }; diff --git a/chutoro-core/tests/chutoro/assignment_validation.rs b/chutoro-core/tests/chutoro/assignment_validation.rs new file mode 100644 index 00000000..52e078e1 --- /dev/null +++ b/chutoro-core/tests/chutoro/assignment_validation.rs @@ -0,0 +1,112 @@ +//! Assignment validation examples and the independent contiguity model. + +use chutoro_core::{ClusterId, ClusteringResult, NonContiguousClusterIds}; +use proptest::prelude::*; +use rstest::rstest; +use std::collections::HashSet; + +#[rstest] +#[case::single(vec![ClusterId::new(0)], 1)] +#[case::two_clusters(vec![ClusterId::new(0), ClusterId::new(1)], 2)] +fn cluster_count_matches_unique_assignments( + #[case] assignments: Vec, + #[case] expected: usize, +) { + let result = ClusteringResult::try_from_assignments(assignments) + .expect("test assignments must be contiguous"); + assert_eq!(result.cluster_count(), expected); +} + +#[rstest] +#[case::missing_zero(vec![ClusterId::new(1)], NonContiguousClusterIds::MissingZero, "assignments must start at zero")] +#[case::gap(vec![ClusterId::new(0), ClusterId::new(2)], NonContiguousClusterIds::Gap, "assignments must be contiguous")] +#[case::overflow(vec![ClusterId::new(u64::MAX)], NonContiguousClusterIds::Overflow, "assignments must stay within usize range")] +#[case::duplicate( + vec![ClusterId::new(0), ClusterId::new(0), ClusterId::new(2)], + NonContiguousClusterIds::Duplicate, + "assignments must not duplicate identifiers when clusters are missing", +)] +fn try_from_assignments_validates_contiguity( + #[case] assignments: Vec, + #[case] expected_error: NonContiguousClusterIds, + #[case] error_message: &str, +) { + let err = ClusteringResult::try_from_assignments(assignments).expect_err(error_message); + assert_eq!(err, expected_error); +} + +fn model_assignment_validation( + assignments: &[ClusterId], +) -> Result { + if assignments.is_empty() { + return Ok(0); + } + + let identifiers: Vec<_> = assignments + .iter() + .map(|assignment| assignment.get()) + .collect(); + if identifiers + .iter() + .any(|identifier| *identifier >= usize::MAX as u64) + { + return Err(NonContiguousClusterIds::Overflow); + } + + let unique: HashSet<_> = identifiers.iter().copied().collect(); + if !unique.contains(&0) { + return Err(NonContiguousClusterIds::MissingZero); + } + + let Some(maximum) = identifiers.iter().copied().max() else { + return Ok(0); + }; + let has_gap = (0..=maximum).any(|identifier| !unique.contains(&identifier)); + if !has_gap { + return Ok(unique.len()); + } + + if unique.len() == identifiers.len() { + Err(NonContiguousClusterIds::Gap) + } else { + Err(NonContiguousClusterIds::Duplicate) + } +} + +proptest! { + #[test] + fn try_from_assignments_matches_the_contiguity_model( + assignment_values in prop::collection::vec( + prop_oneof![ + 8 => 0_u64..=16, + 1 => Just(usize::MAX as u64), + 1 => Just(u64::MAX), + ], + 0..=24, + ), + ) { + let assignments: Vec<_> = assignment_values + .iter() + .copied() + .map(ClusterId::new) + .collect(); + let expected = model_assignment_validation(&assignments); + let actual = ClusteringResult::try_from_assignments(assignments.clone()); + + match (actual, expected) { + (Ok(result), Ok(cluster_count)) => { + prop_assert_eq!(result.assignments(), assignments.as_slice()); + prop_assert_eq!(result.cluster_count(), cluster_count); + } + (Err(actual_error), Err(expected_error)) => { + prop_assert_eq!(actual_error, expected_error); + } + (actual_result, expected_result) => { + prop_assert!( + false, + "actual result {actual_result:?} disagrees with model {expected_result:?}" + ); + } + } + } +} diff --git a/chutoro-core/tests/functional_ari_nmi.rs b/chutoro-core/tests/functional_ari_nmi.rs index ee89fbba..3eae2153 100644 --- a/chutoro-core/tests/functional_ari_nmi.rs +++ b/chutoro-core/tests/functional_ari_nmi.rs @@ -18,20 +18,10 @@ use chutoro_core::{ parallel_kruskal, }; -/// Absolute tolerance for clustering-quality values derived from floating-point calculations. -const QUALITY_SCORE_TOLERANCE: f64 = 1.0e-12_f64; - -#[expect( - clippy::float_arithmetic, - reason = "derived clustering-quality checks need an absolute delta" -)] -fn assert_quality_score_is_one(score: f64) { - let delta = (score - 1.0_f64).abs(); - assert!( - delta <= QUALITY_SCORE_TOLERANCE, - "score={score}, delta={delta}, tolerance={QUALITY_SCORE_TOLERANCE}" - ); -} +#[path = "functional_ari_nmi/quality.rs"] +mod quality; + +use quality::assert_quality_score_is_one; /// Parses `dims` comma-separated floats from each non-blank line of `input`. /// diff --git a/chutoro-core/tests/functional_ari_nmi/quality.rs b/chutoro-core/tests/functional_ari_nmi/quality.rs new file mode 100644 index 00000000..02ac11f4 --- /dev/null +++ b/chutoro-core/tests/functional_ari_nmi/quality.rs @@ -0,0 +1,16 @@ +//! Assertions for expected clustering quality scores. + +/// Absolute tolerance for clustering-quality values derived from floating-point calculations. +const QUALITY_SCORE_TOLERANCE: f64 = 1.0e-12_f64; + +#[expect( + clippy::float_arithmetic, + reason = "derived clustering-quality checks need an absolute delta" +)] +pub(super) fn assert_quality_score_is_one(score: f64) { + let delta = (score - 1.0_f64).abs(); + assert!( + delta <= QUALITY_SCORE_TOLERANCE, + "score={score}, delta={delta}, tolerance={QUALITY_SCORE_TOLERANCE}" + ); +}