use crate::observation::Observation;
use crate::scoring::{induced_rate, sign_agreement};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct BlockThresholds {
pub stable: f64,
pub sensitivity: f64,
pub pathological_dead_rate: f64,
}
impl Default for BlockThresholds {
fn default() -> Self {
Self {
stable: 0.85,
sensitivity: 0.5,
pathological_dead_rate: 0.5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockClass {
StableGrowth,
StableShrink,
TrajectorySensitive,
SeedSensitive,
OrderSensitive,
Pathological,
Insufficient,
}
impl std::fmt::Display for BlockClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
BlockClass::StableGrowth => "stable_growth",
BlockClass::StableShrink => "stable_shrink",
BlockClass::TrajectorySensitive => "trajectory_sensitive",
BlockClass::SeedSensitive => "seed_sensitive",
BlockClass::OrderSensitive => "order_sensitive",
BlockClass::Pathological => "pathological",
BlockClass::Insufficient => "insufficient",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockStabilityReport {
pub block_id: String,
pub n_samples: usize,
pub n_observations: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed_effect_consistency: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shuffle_direction_consistency: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpoint_reproducibility: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_stability: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trajectory_effect_mean: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dead_unit_rate: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub saturated_unit_rate: Option<f64>,
pub classification: BlockClass,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum LossRecipeIssue {
Mixed { recipes: Vec<String> },
IncompleteCoverage { recipe: String },
}
pub(crate) fn distinct_loss_recipes(obs: &[Observation]) -> Vec<String> {
obs.iter()
.filter_map(|o| o.loss_recipe.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn loss_recipe_comparable(before: &[Observation], after: &[Observation]) -> bool {
let before_recipes = distinct_loss_recipes(before);
let after_recipes = distinct_loss_recipes(after);
!(before_recipes.len() > 1
|| after_recipes.len() > 1
|| (!before_recipes.is_empty()
&& !after_recipes.is_empty()
&& before_recipes != after_recipes))
}
fn loss_recipe_issue(obs: &[Observation]) -> Option<LossRecipeIssue> {
let recipes = distinct_loss_recipes(obs);
match recipes.len() {
0 => None,
1 => obs.iter().any(|o| o.loss_recipe.is_none()).then(|| {
LossRecipeIssue::IncompleteCoverage {
recipe: recipes[0].clone(),
}
}),
_ => Some(LossRecipeIssue::Mixed { recipes }),
}
}
pub fn loss_recipe_issues(observations: &[Observation]) -> Vec<(String, LossRecipeIssue)> {
let groups = crate::group::group_by_block_id(observations.iter().cloned());
groups
.iter()
.filter_map(|(id, obs)| loss_recipe_issue(obs).map(|issue| (id.clone(), issue)))
.collect()
}
pub(crate) fn cross_group_effect_consistency<'a, K, FK, FV>(
obs: &'a [Observation],
key_fn: FK,
value_fn: FV,
) -> Option<f64>
where
K: std::hash::Hash + Eq,
FK: Fn(&'a Observation) -> Option<K>,
FV: Fn(&'a Observation) -> Option<f64>,
{
let mut groups: HashMap<K, Vec<f64>> = HashMap::new();
for o in obs {
if let (Some(k), Some(v)) = (key_fn(o), value_fn(o)) {
groups.entry(k).or_default().push(v);
}
}
if groups.len() < 2 {
return None;
}
let group_means: Vec<f64> = groups
.values()
.map(|vs| vs.iter().sum::<f64>() / vs.len() as f64)
.collect();
sign_agreement(&group_means)
}
fn classify(r: &BlockStabilityReport, t: &BlockThresholds) -> BlockClass {
if r.dead_unit_rate
.is_some_and(|v| v > t.pathological_dead_rate)
|| r.saturated_unit_rate
.is_some_and(|v| v > t.pathological_dead_rate)
{
return BlockClass::Pathological;
}
let Some(stability) = r.block_stability else {
return BlockClass::Insufficient;
};
if stability >= t.stable {
return match r.trajectory_effect_mean {
Some(x) if x > 0.0 => BlockClass::StableGrowth,
Some(x) if x < 0.0 => BlockClass::StableShrink,
_ => BlockClass::Insufficient,
};
}
let candidates: [(BlockClass, Option<f64>); 3] = [
(BlockClass::SeedSensitive, r.seed_effect_consistency),
(BlockClass::OrderSensitive, r.shuffle_direction_consistency),
(
BlockClass::TrajectorySensitive,
r.checkpoint_reproducibility,
),
];
candidates
.into_iter()
.filter_map(|(class, val)| val.map(|v| (class, v)))
.filter(|(_, v)| *v < t.sensitivity)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(class, _)| class)
.unwrap_or(BlockClass::Insufficient)
}
pub fn compute_block_report(
block_id: &str,
obs: &[Observation],
thresholds: &BlockThresholds,
) -> BlockStabilityReport {
for o in obs {
for (field, value) in [
("trajectory_effect", o.trajectory_effect),
("update_cosine", o.update_cosine),
("dead_unit_count", o.dead_unit_count),
("saturated_unit_count", o.saturated_unit_count),
] {
debug_assert!(
value.is_none_or(f64::is_finite),
"compute_block_report: non-finite {field} (call Observation::validate() before scoring)"
);
}
}
let n_samples = obs
.iter()
.map(|o| o.sample_id.as_str())
.collect::<std::collections::HashSet<_>>()
.len();
let seed_effect_consistency =
cross_group_effect_consistency(obs, |o| o.seed, |o| o.trajectory_effect);
let shuffle_direction_consistency =
cross_group_effect_consistency(obs, |o| o.shuffle_seed, |o| o.update_cosine);
let checkpoint_reproducibility =
cross_group_effect_consistency(obs, |o| o.model_id.as_deref(), |o| o.trajectory_effect);
let factors: Vec<f64> = [
seed_effect_consistency,
shuffle_direction_consistency,
checkpoint_reproducibility,
]
.into_iter()
.flatten()
.collect();
let block_stability = if factors.is_empty() {
None
} else {
Some(factors.iter().product())
};
let trajectory_effects: Vec<f64> = obs.iter().filter_map(|o| o.trajectory_effect).collect();
let trajectory_effect_mean = if trajectory_effects.is_empty() {
None
} else {
Some(trajectory_effects.iter().sum::<f64>() / trajectory_effects.len() as f64)
};
let dead_unit_rate = induced_rate(obs, |o| o.dead_unit_count);
let saturated_unit_rate = induced_rate(obs, |o| o.saturated_unit_count);
let mut report = BlockStabilityReport {
block_id: block_id.to_string(),
n_samples,
n_observations: obs.len(),
seed_effect_consistency,
shuffle_direction_consistency,
checkpoint_reproducibility,
block_stability,
trajectory_effect_mean,
dead_unit_rate,
saturated_unit_rate,
classification: BlockClass::Insufficient,
};
report.classification = classify(&report, thresholds);
if matches!(loss_recipe_issue(obs), Some(LossRecipeIssue::Mixed { .. })) {
report.classification = BlockClass::Insufficient;
}
report
}
pub fn score_all_blocks(
observations: Vec<Observation>,
thresholds: &BlockThresholds,
) -> Vec<BlockStabilityReport> {
let groups = crate::group::group_by_block_id(observations.into_iter());
groups
.iter()
.map(|(id, obs)| compute_block_report(id, obs, thresholds))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(
sample_id: &str,
seed: Option<u64>,
shuffle_seed: Option<u64>,
model_id: Option<&str>,
trajectory_effect: Option<f64>,
update_cosine: Option<f64>,
) -> Observation {
Observation {
sample_id: sample_id.into(),
block_id: Some("b1".into()),
seed,
shuffle_seed,
model_id: model_id.map(String::from),
trajectory_effect,
update_cosine,
..Default::default()
}
}
#[test]
fn test_stable_growth() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
obs("s2", Some(1), Some(1), Some("ckpt1"), Some(0.2), Some(0.7)),
obs("s2", Some(2), Some(2), Some("ckpt2"), Some(0.5), Some(0.6)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.classification, BlockClass::StableGrowth);
assert_eq!(report.n_samples, 2);
assert_eq!(report.block_stability, Some(1.0));
}
#[test]
fn test_stable_shrink() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(-0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(-0.4), Some(0.8)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.classification, BlockClass::StableShrink);
}
#[test]
fn test_seed_sensitive() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
obs("s1", Some(2), Some(1), Some("ckpt1"), Some(-0.5), Some(0.9)),
obs("s1", Some(3), Some(1), Some("ckpt1"), Some(0.0), Some(0.9)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert!((report.seed_effect_consistency.unwrap() - 1.0 / 3.0).abs() < 1e-9);
assert_eq!(report.classification, BlockClass::SeedSensitive);
}
#[test]
fn test_order_sensitive() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), None, Some(0.5)),
obs("s1", Some(1), Some(2), Some("ckpt1"), None, Some(-0.5)),
obs("s1", Some(1), Some(3), Some("ckpt1"), None, Some(0.0)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert!((report.shuffle_direction_consistency.unwrap() - 1.0 / 3.0).abs() < 1e-9);
assert_eq!(report.classification, BlockClass::OrderSensitive);
}
#[test]
fn test_trajectory_sensitive() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
obs("s1", Some(1), Some(1), Some("ckpt2"), Some(-0.5), Some(0.9)),
obs("s1", Some(1), Some(1), Some("ckpt3"), Some(0.0), Some(0.9)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert!((report.checkpoint_reproducibility.unwrap() - 1.0 / 3.0).abs() < 1e-9);
assert_eq!(report.classification, BlockClass::TrajectorySensitive);
}
#[test]
fn test_pathological_overrides_everything() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
observations[0].dead_unit_count = Some(5.0);
observations[1].dead_unit_count = Some(3.0);
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.dead_unit_rate, Some(1.0));
assert_eq!(report.classification, BlockClass::Pathological);
}
#[test]
fn test_insufficient_with_no_condition_axes() {
let observations = vec![Observation {
sample_id: "s1".into(),
block_id: Some("b1".into()),
..Default::default()
}];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.block_stability, None);
assert_eq!(report.classification, BlockClass::Insufficient);
}
#[test]
fn test_score_all_blocks_skips_observations_without_block_id() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
Observation {
sample_id: "s2".into(),
..Default::default()
},
];
let reports = score_all_blocks(observations, &BlockThresholds::default());
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].n_observations, 1);
}
#[test]
fn test_zero_observations_is_insufficient_not_a_panic() {
let report = compute_block_report("b1", &[], &BlockThresholds::default());
assert_eq!(report.n_samples, 0);
assert_eq!(report.n_observations, 0);
assert_eq!(report.seed_effect_consistency, None);
assert_eq!(report.shuffle_direction_consistency, None);
assert_eq!(report.checkpoint_reproducibility, None);
assert_eq!(report.block_stability, None);
assert_eq!(report.dead_unit_rate, None);
assert_eq!(report.classification, BlockClass::Insufficient);
}
#[test]
fn test_single_observation_is_insufficient_even_with_every_field_present() {
let observations = vec![obs(
"s1",
Some(1),
Some(1),
Some("ckpt1"),
Some(0.5),
Some(0.9),
)];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.block_stability, None);
assert_eq!(report.classification, BlockClass::Insufficient);
}
#[test]
fn test_single_seed_only_leaves_seed_factor_none() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(1), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.seed_effect_consistency, None);
assert!(report.shuffle_direction_consistency.is_some());
assert!(report.checkpoint_reproducibility.is_some());
}
#[test]
fn test_single_checkpoint_only_leaves_checkpoint_factor_none() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt1"), Some(0.4), Some(0.8)),
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.checkpoint_reproducibility, None);
assert!(report.seed_effect_consistency.is_some());
assert!(report.shuffle_direction_consistency.is_some());
}
#[test]
fn test_partially_missing_fields_only_use_observations_that_carry_them() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
obs("s1", Some(2), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
Observation {
sample_id: "s1".into(),
block_id: Some("b1".into()),
seed: Some(3),
..Default::default()
},
];
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.seed_effect_consistency, Some(1.0));
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "call Observation::validate() before scoring")]
fn test_compute_block_report_panics_on_non_finite_field_bypassing_parser() {
let observations = vec![Observation {
sample_id: "s1".into(),
block_id: Some("b1".into()),
dead_unit_count: Some(f64::NAN),
..Default::default()
}];
let _ = compute_block_report("b1", &observations, &BlockThresholds::default());
}
#[test]
fn test_stable_boundary_at_exact_threshold_is_stable() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(1), Some("ckpt1"), Some(0.4), Some(0.8)),
];
let thresholds = BlockThresholds {
stable: 1.0, ..BlockThresholds::default()
};
let report = compute_block_report("b1", &observations, &thresholds);
assert_eq!(report.block_stability, Some(1.0));
assert_eq!(
report.classification,
BlockClass::StableGrowth,
"block_stability == stable_threshold exactly must count as stable (>=, not >)"
);
}
#[test]
fn test_stable_boundary_just_below_threshold_is_not_stable() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(1), Some("ckpt1"), Some(0.4), Some(0.8)),
];
let thresholds = BlockThresholds {
stable: 1.0 + 1e-9, sensitivity: 0.0, ..BlockThresholds::default()
};
let report = compute_block_report("b1", &observations, &thresholds);
assert_ne!(report.classification, BlockClass::StableGrowth);
assert_ne!(report.classification, BlockClass::StableShrink);
}
#[test]
fn test_sensitivity_boundary_exact_value_is_not_flagged() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
obs("s1", Some(2), Some(1), Some("ckpt1"), Some(-0.5), Some(0.9)),
];
let thresholds = BlockThresholds {
stable: 2.0, sensitivity: 0.5,
..BlockThresholds::default()
};
let report = compute_block_report("b1", &observations, &thresholds);
assert_eq!(report.seed_effect_consistency, Some(0.5));
assert_eq!(
report.classification,
BlockClass::Insufficient,
"a factor exactly at `sensitivity` must not be flagged (strict <), so no cause \
qualifies here"
);
}
#[test]
fn test_sensitivity_boundary_just_below_value_is_flagged() {
let observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
obs("s1", Some(2), Some(1), Some("ckpt1"), Some(-0.5), Some(0.9)),
];
let thresholds = BlockThresholds {
stable: 2.0,
sensitivity: 0.5 + 1e-9, ..BlockThresholds::default()
};
let report = compute_block_report("b1", &observations, &thresholds);
assert_eq!(report.classification, BlockClass::SeedSensitive);
}
#[test]
fn test_mixed_loss_recipe_forces_insufficient_even_over_stable_growth() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
observations[0].loss_recipe = Some("recipe_a".into());
observations[1].loss_recipe = Some("recipe_b".into());
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.classification, BlockClass::Insufficient);
}
#[test]
fn test_mixed_loss_recipe_overrides_pathological_too() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
observations[0].dead_unit_count = Some(5.0);
observations[1].dead_unit_count = Some(3.0);
observations[0].loss_recipe = Some("recipe_a".into());
observations[1].loss_recipe = Some("recipe_b".into());
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.classification, BlockClass::Insufficient);
}
#[test]
fn test_incomplete_loss_recipe_coverage_does_not_change_classification() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
observations[0].loss_recipe = Some("recipe_a".into());
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.classification, BlockClass::StableGrowth);
}
#[test]
fn test_loss_recipe_issues_reports_mixed_and_incomplete_but_not_clean_blocks() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
obs("s2", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s2", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
obs("s3", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s3", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
for o in observations.iter_mut() {
o.block_id = Some(
match o.sample_id.as_str() {
"s1" => "B1",
"s2" => "B2",
_ => "B3",
}
.into(),
);
}
observations[0].loss_recipe = Some("recipe_a".into());
observations[1].loss_recipe = Some("recipe_b".into());
observations[2].loss_recipe = Some("recipe_a".into());
let issues = loss_recipe_issues(&observations);
assert_eq!(issues.len(), 2, "{issues:?}");
let b1 = issues.iter().find(|(id, _)| id == "B1").unwrap();
assert!(matches!(&b1.1, LossRecipeIssue::Mixed { recipes }
if *recipes == vec!["recipe_a".to_string(), "recipe_b".to_string()]));
let b2 = issues.iter().find(|(id, _)| id == "B2").unwrap();
assert!(
matches!(&b2.1, LossRecipeIssue::IncompleteCoverage { recipe } if recipe == "recipe_a")
);
assert!(issues.iter().all(|(id, _)| id != "B3"));
}
#[test]
fn test_loss_recipe_comparable_matches_mixed_and_cross_side_cases() {
let mut same_recipe_before = vec![obs("s1", Some(1), None, None, None, None)];
let mut same_recipe_after = vec![obs("s1", Some(1), None, None, None, None)];
same_recipe_before[0].loss_recipe = Some("baseline".into());
same_recipe_after[0].loss_recipe = Some("baseline".into());
assert!(loss_recipe_comparable(
&same_recipe_before,
&same_recipe_after
));
let mut mismatched_before = vec![obs("s1", Some(1), None, None, None, None)];
let mut mismatched_after = vec![obs("s1", Some(1), None, None, None, None)];
mismatched_before[0].loss_recipe = Some("baseline".into());
mismatched_after[0].loss_recipe = Some("teacher_conflict_masking".into());
assert!(!loss_recipe_comparable(
&mismatched_before,
&mismatched_after
));
let mut mixed_within_before = vec![
obs("s1", Some(1), None, None, None, None),
obs("s2", Some(1), None, None, None, None),
];
mixed_within_before[0].loss_recipe = Some("recipe_a".into());
mixed_within_before[1].loss_recipe = Some("recipe_b".into());
let clean_after = vec![obs("s1", Some(1), None, None, None, None)];
assert!(!loss_recipe_comparable(&mixed_within_before, &clean_after));
let no_recipe_either_side = vec![obs("s1", Some(1), None, None, None, None)];
assert!(loss_recipe_comparable(
&no_recipe_either_side,
&no_recipe_either_side
));
}
#[test]
fn test_multiple_layer_ids_within_one_block_is_normal() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
];
observations[0].layer_id = Some("ft".into());
observations[1].layer_id = Some("l2".into());
let report = compute_block_report("b1", &observations, &BlockThresholds::default());
assert_eq!(report.classification, BlockClass::StableGrowth);
assert!(loss_recipe_issues(&observations).is_empty());
}
#[test]
fn test_compute_block_report_is_order_independent() {
let forward = vec![
obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
obs("s2", Some(1), Some(1), Some("ckpt1"), Some(0.2), Some(0.7)),
obs("s2", Some(2), Some(2), Some("ckpt2"), Some(0.5), Some(0.6)),
];
let mut reversed = forward.clone();
reversed.reverse();
let thresholds = BlockThresholds::default();
let a = compute_block_report("b1", &forward, &thresholds);
let b = compute_block_report("b1", &reversed, &thresholds);
assert_eq!(a.seed_effect_consistency, b.seed_effect_consistency);
assert_eq!(
a.shuffle_direction_consistency,
b.shuffle_direction_consistency
);
assert_eq!(a.checkpoint_reproducibility, b.checkpoint_reproducibility);
assert_eq!(a.block_stability, b.block_stability);
assert!(
(a.trajectory_effect_mean.unwrap() - b.trajectory_effect_mean.unwrap()).abs() < 1e-9,
"{:?} vs {:?}",
a.trajectory_effect_mean,
b.trajectory_effect_mean
);
assert_eq!(a.classification, b.classification);
}
#[test]
fn test_score_all_blocks_values_are_order_independent_per_block_id() {
let mut observations = vec![
obs("s1", Some(1), Some(1), Some("ckptA"), Some(0.3), Some(0.9)),
obs("s1", Some(2), Some(2), Some("ckptB"), Some(0.4), Some(0.8)),
obs("q1", Some(1), Some(1), Some("ckptA"), Some(-0.3), Some(0.9)),
obs("q1", Some(2), Some(2), Some("ckptB"), Some(-0.4), Some(0.8)),
];
observations[0].block_id = Some("B1".into());
observations[1].block_id = Some("B1".into());
observations[2].block_id = Some("B2".into());
observations[3].block_id = Some("B2".into());
let mut shuffled = observations.clone();
shuffled.swap(0, 3);
shuffled.swap(1, 2);
let thresholds = BlockThresholds::default();
let forward: std::collections::HashMap<String, BlockStabilityReport> =
score_all_blocks(observations, &thresholds)
.into_iter()
.map(|r| (r.block_id.clone(), r))
.collect();
let reordered: std::collections::HashMap<String, BlockStabilityReport> =
score_all_blocks(shuffled, &thresholds)
.into_iter()
.map(|r| (r.block_id.clone(), r))
.collect();
for block_id in ["B1", "B2"] {
let f = &forward[block_id];
let r = &reordered[block_id];
assert_eq!(f.block_stability, r.block_stability, "block {block_id}");
assert_eq!(f.classification, r.classification, "block {block_id}");
}
}
}