Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 4 additions & 110 deletions chutoro-core/tests/chutoro.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
//! 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::{
DistanceCacheConfig, HnswParams, estimate_peak_bytes, estimate_peak_bytes_for_hnsw_params,
};
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;

Expand Down Expand Up @@ -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<ClusterId>,
#[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<ClusterId>,
#[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<usize, NonContiguousClusterIds> {
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 };
Expand Down
112 changes: 112 additions & 0 deletions chutoro-core/tests/chutoro/assignment_validation.rs
Original file line number Diff line number Diff line change
@@ -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<ClusterId>,
#[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<ClusterId>,
#[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<usize, NonContiguousClusterIds> {
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:?}"
);
}
}
}
}
18 changes: 4 additions & 14 deletions chutoro-core/tests/functional_ari_nmi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down
16 changes: 16 additions & 0 deletions chutoro-core/tests/functional_ari_nmi/quality.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
Loading