1use crate::observation::Observation;
6use crate::scoring::{induced_rate, sign_agreement};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
13pub struct BlockThresholds {
14 pub stable: f64,
18 pub sensitivity: f64,
21 pub pathological_dead_rate: f64,
24}
25
26impl Default for BlockThresholds {
27 fn default() -> Self {
28 Self {
29 stable: 0.85,
30 sensitivity: 0.5,
31 pathological_dead_rate: 0.5,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum BlockClass {
57 StableGrowth,
61 StableShrink,
64 TrajectorySensitive,
66 SeedSensitive,
68 OrderSensitive,
70 Pathological,
73 Insufficient,
75}
76
77impl std::fmt::Display for BlockClass {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 let s = match self {
80 BlockClass::StableGrowth => "stable_growth",
81 BlockClass::StableShrink => "stable_shrink",
82 BlockClass::TrajectorySensitive => "trajectory_sensitive",
83 BlockClass::SeedSensitive => "seed_sensitive",
84 BlockClass::OrderSensitive => "order_sensitive",
85 BlockClass::Pathological => "pathological",
86 BlockClass::Insufficient => "insufficient",
87 };
88 write!(f, "{s}")
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct BlockStabilityReport {
95 pub block_id: String,
97 pub n_samples: usize,
99 pub n_observations: usize,
101 #[serde(skip_serializing_if = "Option::is_none")]
104 pub seed_effect_consistency: Option<f64>,
105 #[serde(skip_serializing_if = "Option::is_none")]
108 pub shuffle_direction_consistency: Option<f64>,
109 #[serde(skip_serializing_if = "Option::is_none")]
112 pub checkpoint_reproducibility: Option<f64>,
113 #[serde(skip_serializing_if = "Option::is_none")]
123 pub block_stability: Option<f64>,
124 #[serde(skip_serializing_if = "Option::is_none")]
127 pub trajectory_effect_mean: Option<f64>,
128 #[serde(skip_serializing_if = "Option::is_none")]
131 pub dead_unit_rate: Option<f64>,
132 #[serde(skip_serializing_if = "Option::is_none")]
135 pub saturated_unit_rate: Option<f64>,
136 pub classification: BlockClass,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case", tag = "kind")]
144pub enum LossRecipeIssue {
145 Mixed { recipes: Vec<String> },
153 IncompleteCoverage { recipe: String },
157}
158
159pub(crate) fn distinct_loss_recipes(obs: &[Observation]) -> Vec<String> {
161 obs.iter()
162 .filter_map(|o| o.loss_recipe.clone())
163 .collect::<std::collections::BTreeSet<_>>()
164 .into_iter()
165 .collect()
166}
167
168pub fn loss_recipe_comparable(before: &[Observation], after: &[Observation]) -> bool {
174 let before_recipes = distinct_loss_recipes(before);
175 let after_recipes = distinct_loss_recipes(after);
176 !(before_recipes.len() > 1
177 || after_recipes.len() > 1
178 || (!before_recipes.is_empty()
179 && !after_recipes.is_empty()
180 && before_recipes != after_recipes))
181}
182
183fn loss_recipe_issue(obs: &[Observation]) -> Option<LossRecipeIssue> {
184 let recipes = distinct_loss_recipes(obs);
185 match recipes.len() {
186 0 => None,
187 1 => obs.iter().any(|o| o.loss_recipe.is_none()).then(|| {
188 LossRecipeIssue::IncompleteCoverage {
189 recipe: recipes[0].clone(),
190 }
191 }),
192 _ => Some(LossRecipeIssue::Mixed { recipes }),
193 }
194}
195
196pub fn loss_recipe_issues(observations: &[Observation]) -> Vec<(String, LossRecipeIssue)> {
201 let groups = crate::group::group_by_block_id(observations.iter().cloned());
202 groups
203 .iter()
204 .filter_map(|(id, obs)| loss_recipe_issue(obs).map(|issue| (id.clone(), issue)))
205 .collect()
206}
207
208pub(crate) fn cross_group_effect_consistency<'a, K, FK, FV>(
213 obs: &'a [Observation],
214 key_fn: FK,
215 value_fn: FV,
216) -> Option<f64>
217where
218 K: std::hash::Hash + Eq,
219 FK: Fn(&'a Observation) -> Option<K>,
220 FV: Fn(&'a Observation) -> Option<f64>,
221{
222 let mut groups: HashMap<K, Vec<f64>> = HashMap::new();
223 for o in obs {
224 if let (Some(k), Some(v)) = (key_fn(o), value_fn(o)) {
225 groups.entry(k).or_default().push(v);
226 }
227 }
228 if groups.len() < 2 {
229 return None;
230 }
231 let group_means: Vec<f64> = groups
232 .values()
233 .map(|vs| vs.iter().sum::<f64>() / vs.len() as f64)
234 .collect();
235 sign_agreement(&group_means)
236}
237
238fn classify(r: &BlockStabilityReport, t: &BlockThresholds) -> BlockClass {
243 if r.dead_unit_rate
244 .is_some_and(|v| v > t.pathological_dead_rate)
245 || r.saturated_unit_rate
246 .is_some_and(|v| v > t.pathological_dead_rate)
247 {
248 return BlockClass::Pathological;
249 }
250 let Some(stability) = r.block_stability else {
251 return BlockClass::Insufficient;
252 };
253 if stability >= t.stable {
254 return match r.trajectory_effect_mean {
255 Some(x) if x > 0.0 => BlockClass::StableGrowth,
256 Some(x) if x < 0.0 => BlockClass::StableShrink,
257 _ => BlockClass::Insufficient,
259 };
260 }
261 let candidates: [(BlockClass, Option<f64>); 3] = [
266 (BlockClass::SeedSensitive, r.seed_effect_consistency),
267 (BlockClass::OrderSensitive, r.shuffle_direction_consistency),
268 (
269 BlockClass::TrajectorySensitive,
270 r.checkpoint_reproducibility,
271 ),
272 ];
273 candidates
274 .into_iter()
275 .filter_map(|(class, val)| val.map(|v| (class, v)))
276 .filter(|(_, v)| *v < t.sensitivity)
277 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
278 .map(|(class, _)| class)
279 .unwrap_or(BlockClass::Insufficient)
280}
281
282pub fn compute_block_report(
292 block_id: &str,
293 obs: &[Observation],
294 thresholds: &BlockThresholds,
295) -> BlockStabilityReport {
296 for o in obs {
297 for (field, value) in [
298 ("trajectory_effect", o.trajectory_effect),
299 ("update_cosine", o.update_cosine),
300 ("dead_unit_count", o.dead_unit_count),
301 ("saturated_unit_count", o.saturated_unit_count),
302 ] {
303 debug_assert!(
304 value.is_none_or(f64::is_finite),
305 "compute_block_report: non-finite {field} (call Observation::validate() before scoring)"
306 );
307 }
308 }
309 let n_samples = obs
310 .iter()
311 .map(|o| o.sample_id.as_str())
312 .collect::<std::collections::HashSet<_>>()
313 .len();
314
315 let seed_effect_consistency =
316 cross_group_effect_consistency(obs, |o| o.seed, |o| o.trajectory_effect);
317 let shuffle_direction_consistency =
318 cross_group_effect_consistency(obs, |o| o.shuffle_seed, |o| o.update_cosine);
319 let checkpoint_reproducibility =
320 cross_group_effect_consistency(obs, |o| o.model_id.as_deref(), |o| o.trajectory_effect);
321
322 let factors: Vec<f64> = [
329 seed_effect_consistency,
330 shuffle_direction_consistency,
331 checkpoint_reproducibility,
332 ]
333 .into_iter()
334 .flatten()
335 .collect();
336 let block_stability = if factors.is_empty() {
337 None
338 } else {
339 Some(factors.iter().product())
340 };
341
342 let trajectory_effects: Vec<f64> = obs.iter().filter_map(|o| o.trajectory_effect).collect();
343 let trajectory_effect_mean = if trajectory_effects.is_empty() {
344 None
345 } else {
346 Some(trajectory_effects.iter().sum::<f64>() / trajectory_effects.len() as f64)
347 };
348
349 let dead_unit_rate = induced_rate(obs, |o| o.dead_unit_count);
350 let saturated_unit_rate = induced_rate(obs, |o| o.saturated_unit_count);
351
352 let mut report = BlockStabilityReport {
353 block_id: block_id.to_string(),
354 n_samples,
355 n_observations: obs.len(),
356 seed_effect_consistency,
357 shuffle_direction_consistency,
358 checkpoint_reproducibility,
359 block_stability,
360 trajectory_effect_mean,
361 dead_unit_rate,
362 saturated_unit_rate,
363 classification: BlockClass::Insufficient,
364 };
365 report.classification = classify(&report, thresholds);
366 if matches!(loss_recipe_issue(obs), Some(LossRecipeIssue::Mixed { .. })) {
371 report.classification = BlockClass::Insufficient;
372 }
373 report
374}
375
376pub fn score_all_blocks(
379 observations: Vec<Observation>,
380 thresholds: &BlockThresholds,
381) -> Vec<BlockStabilityReport> {
382 let groups = crate::group::group_by_block_id(observations.into_iter());
383 groups
384 .iter()
385 .map(|(id, obs)| compute_block_report(id, obs, thresholds))
386 .collect()
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn obs(
394 sample_id: &str,
395 seed: Option<u64>,
396 shuffle_seed: Option<u64>,
397 model_id: Option<&str>,
398 trajectory_effect: Option<f64>,
399 update_cosine: Option<f64>,
400 ) -> Observation {
401 Observation {
402 sample_id: sample_id.into(),
403 block_id: Some("b1".into()),
404 seed,
405 shuffle_seed,
406 model_id: model_id.map(String::from),
407 trajectory_effect,
408 update_cosine,
409 ..Default::default()
410 }
411 }
412
413 #[test]
414 fn test_stable_growth() {
415 let observations = vec![
416 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
417 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
418 obs("s2", Some(1), Some(1), Some("ckpt1"), Some(0.2), Some(0.7)),
419 obs("s2", Some(2), Some(2), Some("ckpt2"), Some(0.5), Some(0.6)),
420 ];
421 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
422 assert_eq!(report.classification, BlockClass::StableGrowth);
423 assert_eq!(report.n_samples, 2);
424 assert_eq!(report.block_stability, Some(1.0));
425 }
426
427 #[test]
428 fn test_stable_shrink() {
429 let observations = vec![
430 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(-0.3), Some(0.9)),
431 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(-0.4), Some(0.8)),
432 ];
433 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
434 assert_eq!(report.classification, BlockClass::StableShrink);
435 }
436
437 #[test]
438 fn test_seed_sensitive() {
439 let observations = vec![
443 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
444 obs("s1", Some(2), Some(1), Some("ckpt1"), Some(-0.5), Some(0.9)),
445 obs("s1", Some(3), Some(1), Some("ckpt1"), Some(0.0), Some(0.9)),
446 ];
447 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
448 assert!((report.seed_effect_consistency.unwrap() - 1.0 / 3.0).abs() < 1e-9);
449 assert_eq!(report.classification, BlockClass::SeedSensitive);
450 }
451
452 #[test]
453 fn test_order_sensitive() {
454 let observations = vec![
457 obs("s1", Some(1), Some(1), Some("ckpt1"), None, Some(0.5)),
458 obs("s1", Some(1), Some(2), Some("ckpt1"), None, Some(-0.5)),
459 obs("s1", Some(1), Some(3), Some("ckpt1"), None, Some(0.0)),
460 ];
461 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
462 assert!((report.shuffle_direction_consistency.unwrap() - 1.0 / 3.0).abs() < 1e-9);
463 assert_eq!(report.classification, BlockClass::OrderSensitive);
465 }
466
467 #[test]
468 fn test_trajectory_sensitive() {
469 let observations = vec![
472 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
473 obs("s1", Some(1), Some(1), Some("ckpt2"), Some(-0.5), Some(0.9)),
474 obs("s1", Some(1), Some(1), Some("ckpt3"), Some(0.0), Some(0.9)),
475 ];
476 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
477 assert!((report.checkpoint_reproducibility.unwrap() - 1.0 / 3.0).abs() < 1e-9);
478 assert_eq!(report.classification, BlockClass::TrajectorySensitive);
479 }
480
481 #[test]
482 fn test_pathological_overrides_everything() {
483 let mut observations = vec![
484 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
485 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
486 ];
487 observations[0].dead_unit_count = Some(5.0);
490 observations[1].dead_unit_count = Some(3.0);
491 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
492 assert_eq!(report.dead_unit_rate, Some(1.0));
493 assert_eq!(report.classification, BlockClass::Pathological);
494 }
495
496 #[test]
497 fn test_insufficient_with_no_condition_axes() {
498 let observations = vec![Observation {
499 sample_id: "s1".into(),
500 block_id: Some("b1".into()),
501 ..Default::default()
502 }];
503 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
504 assert_eq!(report.block_stability, None);
505 assert_eq!(report.classification, BlockClass::Insufficient);
506 }
507
508 #[test]
509 fn test_score_all_blocks_skips_observations_without_block_id() {
510 let observations = vec![
511 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
512 Observation {
513 sample_id: "s2".into(),
514 ..Default::default()
515 },
516 ];
517 let reports = score_all_blocks(observations, &BlockThresholds::default());
518 assert_eq!(reports.len(), 1);
519 assert_eq!(reports[0].n_observations, 1);
520 }
521
522 #[test]
525 fn test_zero_observations_is_insufficient_not_a_panic() {
526 let report = compute_block_report("b1", &[], &BlockThresholds::default());
527 assert_eq!(report.n_samples, 0);
528 assert_eq!(report.n_observations, 0);
529 assert_eq!(report.seed_effect_consistency, None);
530 assert_eq!(report.shuffle_direction_consistency, None);
531 assert_eq!(report.checkpoint_reproducibility, None);
532 assert_eq!(report.block_stability, None);
533 assert_eq!(report.dead_unit_rate, None);
534 assert_eq!(report.classification, BlockClass::Insufficient);
535 }
536
537 #[test]
538 fn test_single_observation_is_insufficient_even_with_every_field_present() {
539 let observations = vec![obs(
542 "s1",
543 Some(1),
544 Some(1),
545 Some("ckpt1"),
546 Some(0.5),
547 Some(0.9),
548 )];
549 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
550 assert_eq!(report.block_stability, None);
551 assert_eq!(report.classification, BlockClass::Insufficient);
552 }
553
554 #[test]
555 fn test_single_seed_only_leaves_seed_factor_none() {
556 let observations = vec![
559 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
560 obs("s1", Some(1), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
561 ];
562 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
563 assert_eq!(report.seed_effect_consistency, None);
564 assert!(report.shuffle_direction_consistency.is_some());
565 assert!(report.checkpoint_reproducibility.is_some());
566 }
567
568 #[test]
569 fn test_single_checkpoint_only_leaves_checkpoint_factor_none() {
570 let observations = vec![
572 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
573 obs("s1", Some(2), Some(2), Some("ckpt1"), Some(0.4), Some(0.8)),
574 ];
575 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
576 assert_eq!(report.checkpoint_reproducibility, None);
577 assert!(report.seed_effect_consistency.is_some());
578 assert!(report.shuffle_direction_consistency.is_some());
579 }
580
581 #[test]
582 fn test_partially_missing_fields_only_use_observations_that_carry_them() {
583 let observations = vec![
586 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
587 obs("s1", Some(2), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
588 Observation {
589 sample_id: "s1".into(),
590 block_id: Some("b1".into()),
591 seed: Some(3),
592 ..Default::default()
593 },
594 ];
595 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
596 assert_eq!(report.seed_effect_consistency, Some(1.0));
599 }
600
601 #[test]
602 #[cfg(debug_assertions)]
603 #[should_panic(expected = "call Observation::validate() before scoring")]
604 fn test_compute_block_report_panics_on_non_finite_field_bypassing_parser() {
605 let observations = vec![Observation {
606 sample_id: "s1".into(),
607 block_id: Some("b1".into()),
608 dead_unit_count: Some(f64::NAN),
609 ..Default::default()
610 }];
611 let _ = compute_block_report("b1", &observations, &BlockThresholds::default());
612 }
613
614 #[test]
622 fn test_stable_boundary_at_exact_threshold_is_stable() {
623 let observations = vec![
628 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
629 obs("s1", Some(2), Some(1), Some("ckpt1"), Some(0.4), Some(0.8)),
630 ];
631 let thresholds = BlockThresholds {
632 stable: 1.0, ..BlockThresholds::default()
634 };
635 let report = compute_block_report("b1", &observations, &thresholds);
636 assert_eq!(report.block_stability, Some(1.0));
637 assert_eq!(
638 report.classification,
639 BlockClass::StableGrowth,
640 "block_stability == stable_threshold exactly must count as stable (>=, not >)"
641 );
642 }
643
644 #[test]
645 fn test_stable_boundary_just_below_threshold_is_not_stable() {
646 let observations = vec![
647 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
648 obs("s1", Some(2), Some(1), Some("ckpt1"), Some(0.4), Some(0.8)),
649 ];
650 let thresholds = BlockThresholds {
651 stable: 1.0 + 1e-9, sensitivity: 0.0, ..BlockThresholds::default()
654 };
655 let report = compute_block_report("b1", &observations, &thresholds);
656 assert_ne!(report.classification, BlockClass::StableGrowth);
657 assert_ne!(report.classification, BlockClass::StableShrink);
658 }
659
660 #[test]
661 fn test_sensitivity_boundary_exact_value_is_not_flagged() {
662 let observations = vec![
665 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
666 obs("s1", Some(2), Some(1), Some("ckpt1"), Some(-0.5), Some(0.9)),
667 ];
668 let thresholds = BlockThresholds {
669 stable: 2.0, sensitivity: 0.5,
671 ..BlockThresholds::default()
672 };
673 let report = compute_block_report("b1", &observations, &thresholds);
674 assert_eq!(report.seed_effect_consistency, Some(0.5));
675 assert_eq!(
676 report.classification,
677 BlockClass::Insufficient,
678 "a factor exactly at `sensitivity` must not be flagged (strict <), so no cause \
679 qualifies here"
680 );
681 }
682
683 #[test]
684 fn test_sensitivity_boundary_just_below_value_is_flagged() {
685 let observations = vec![
686 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.5), Some(0.9)),
687 obs("s1", Some(2), Some(1), Some("ckpt1"), Some(-0.5), Some(0.9)),
688 ];
689 let thresholds = BlockThresholds {
690 stable: 2.0,
691 sensitivity: 0.5 + 1e-9, ..BlockThresholds::default()
693 };
694 let report = compute_block_report("b1", &observations, &thresholds);
695 assert_eq!(report.classification, BlockClass::SeedSensitive);
696 }
697
698 #[test]
701 fn test_mixed_loss_recipe_forces_insufficient_even_over_stable_growth() {
702 let mut observations = vec![
705 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
706 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
707 ];
708 observations[0].loss_recipe = Some("recipe_a".into());
709 observations[1].loss_recipe = Some("recipe_b".into());
710 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
711 assert_eq!(report.classification, BlockClass::Insufficient);
712 }
713
714 #[test]
715 fn test_mixed_loss_recipe_overrides_pathological_too() {
716 let mut observations = vec![
719 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
720 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
721 ];
722 observations[0].dead_unit_count = Some(5.0);
723 observations[1].dead_unit_count = Some(3.0);
724 observations[0].loss_recipe = Some("recipe_a".into());
725 observations[1].loss_recipe = Some("recipe_b".into());
726 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
727 assert_eq!(report.classification, BlockClass::Insufficient);
728 }
729
730 #[test]
731 fn test_incomplete_loss_recipe_coverage_does_not_change_classification() {
732 let mut observations = vec![
736 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
737 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
738 ];
739 observations[0].loss_recipe = Some("recipe_a".into());
740 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
741 assert_eq!(report.classification, BlockClass::StableGrowth);
742 }
743
744 #[test]
745 fn test_loss_recipe_issues_reports_mixed_and_incomplete_but_not_clean_blocks() {
746 let mut observations = vec![
747 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
749 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
750 obs("s2", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
752 obs("s2", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
753 obs("s3", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
755 obs("s3", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
756 ];
757 for o in observations.iter_mut() {
758 o.block_id = Some(
759 match o.sample_id.as_str() {
760 "s1" => "B1",
761 "s2" => "B2",
762 _ => "B3",
763 }
764 .into(),
765 );
766 }
767 observations[0].loss_recipe = Some("recipe_a".into());
768 observations[1].loss_recipe = Some("recipe_b".into());
769 observations[2].loss_recipe = Some("recipe_a".into());
770 let issues = loss_recipe_issues(&observations);
773 assert_eq!(issues.len(), 2, "{issues:?}");
774 let b1 = issues.iter().find(|(id, _)| id == "B1").unwrap();
775 assert!(matches!(&b1.1, LossRecipeIssue::Mixed { recipes }
776 if *recipes == vec!["recipe_a".to_string(), "recipe_b".to_string()]));
777 let b2 = issues.iter().find(|(id, _)| id == "B2").unwrap();
778 assert!(
779 matches!(&b2.1, LossRecipeIssue::IncompleteCoverage { recipe } if recipe == "recipe_a")
780 );
781 assert!(issues.iter().all(|(id, _)| id != "B3"));
782 }
783
784 #[test]
785 fn test_loss_recipe_comparable_matches_mixed_and_cross_side_cases() {
786 let mut same_recipe_before = vec![obs("s1", Some(1), None, None, None, None)];
789 let mut same_recipe_after = vec![obs("s1", Some(1), None, None, None, None)];
790 same_recipe_before[0].loss_recipe = Some("baseline".into());
791 same_recipe_after[0].loss_recipe = Some("baseline".into());
792 assert!(loss_recipe_comparable(
793 &same_recipe_before,
794 &same_recipe_after
795 ));
796
797 let mut mismatched_before = vec![obs("s1", Some(1), None, None, None, None)];
798 let mut mismatched_after = vec![obs("s1", Some(1), None, None, None, None)];
799 mismatched_before[0].loss_recipe = Some("baseline".into());
800 mismatched_after[0].loss_recipe = Some("teacher_conflict_masking".into());
801 assert!(!loss_recipe_comparable(
802 &mismatched_before,
803 &mismatched_after
804 ));
805
806 let mut mixed_within_before = vec![
807 obs("s1", Some(1), None, None, None, None),
808 obs("s2", Some(1), None, None, None, None),
809 ];
810 mixed_within_before[0].loss_recipe = Some("recipe_a".into());
811 mixed_within_before[1].loss_recipe = Some("recipe_b".into());
812 let clean_after = vec![obs("s1", Some(1), None, None, None, None)];
813 assert!(!loss_recipe_comparable(&mixed_within_before, &clean_after));
814
815 let no_recipe_either_side = vec![obs("s1", Some(1), None, None, None, None)];
816 assert!(loss_recipe_comparable(
817 &no_recipe_either_side,
818 &no_recipe_either_side
819 ));
820 }
821
822 #[test]
823 fn test_multiple_layer_ids_within_one_block_is_normal() {
824 let mut observations = vec![
827 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
828 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
829 ];
830 observations[0].layer_id = Some("ft".into());
831 observations[1].layer_id = Some("l2".into());
832 let report = compute_block_report("b1", &observations, &BlockThresholds::default());
833 assert_eq!(report.classification, BlockClass::StableGrowth);
834 assert!(loss_recipe_issues(&observations).is_empty());
835 }
836
837 #[test]
840 fn test_compute_block_report_is_order_independent() {
841 let forward = vec![
842 obs("s1", Some(1), Some(1), Some("ckpt1"), Some(0.3), Some(0.9)),
843 obs("s1", Some(2), Some(2), Some("ckpt2"), Some(0.4), Some(0.8)),
844 obs("s2", Some(1), Some(1), Some("ckpt1"), Some(0.2), Some(0.7)),
845 obs("s2", Some(2), Some(2), Some("ckpt2"), Some(0.5), Some(0.6)),
846 ];
847 let mut reversed = forward.clone();
848 reversed.reverse();
849
850 let thresholds = BlockThresholds::default();
851 let a = compute_block_report("b1", &forward, &thresholds);
852 let b = compute_block_report("b1", &reversed, &thresholds);
853 assert_eq!(a.seed_effect_consistency, b.seed_effect_consistency);
854 assert_eq!(
855 a.shuffle_direction_consistency,
856 b.shuffle_direction_consistency
857 );
858 assert_eq!(a.checkpoint_reproducibility, b.checkpoint_reproducibility);
859 assert_eq!(a.block_stability, b.block_stability);
860 assert!(
864 (a.trajectory_effect_mean.unwrap() - b.trajectory_effect_mean.unwrap()).abs() < 1e-9,
865 "{:?} vs {:?}",
866 a.trajectory_effect_mean,
867 b.trajectory_effect_mean
868 );
869 assert_eq!(a.classification, b.classification);
870 }
871
872 #[test]
873 fn test_score_all_blocks_values_are_order_independent_per_block_id() {
874 let mut observations = vec![
878 obs("s1", Some(1), Some(1), Some("ckptA"), Some(0.3), Some(0.9)),
879 obs("s1", Some(2), Some(2), Some("ckptB"), Some(0.4), Some(0.8)),
880 obs("q1", Some(1), Some(1), Some("ckptA"), Some(-0.3), Some(0.9)),
881 obs("q1", Some(2), Some(2), Some("ckptB"), Some(-0.4), Some(0.8)),
882 ];
883 observations[0].block_id = Some("B1".into());
884 observations[1].block_id = Some("B1".into());
885 observations[2].block_id = Some("B2".into());
886 observations[3].block_id = Some("B2".into());
887
888 let mut shuffled = observations.clone();
889 shuffled.swap(0, 3);
890 shuffled.swap(1, 2);
891
892 let thresholds = BlockThresholds::default();
893 let forward: std::collections::HashMap<String, BlockStabilityReport> =
894 score_all_blocks(observations, &thresholds)
895 .into_iter()
896 .map(|r| (r.block_id.clone(), r))
897 .collect();
898 let reordered: std::collections::HashMap<String, BlockStabilityReport> =
899 score_all_blocks(shuffled, &thresholds)
900 .into_iter()
901 .map(|r| (r.block_id.clone(), r))
902 .collect();
903
904 for block_id in ["B1", "B2"] {
905 let f = &forward[block_id];
906 let r = &reordered[block_id];
907 assert_eq!(f.block_stability, r.block_stability, "block {block_id}");
908 assert_eq!(f.classification, r.classification, "block {block_id}");
909 }
910 }
911}