Skip to main content

quietset/
block.rs

1//! Block-level trajectory stability: groups observations by `block_id` (e.g. a 32-sample
2//! training block) instead of `sample_id`, and classifies each block's training effect as
3//! stable growth/shrink, sensitive to one specific condition axis, or pathological.
4
5use crate::observation::Observation;
6use crate::scoring::{induced_rate, sign_agreement};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Thresholds driving [`BlockClass`] classification. All are operational defaults, not derived
11/// constants — tune via the `block-score` CLI flags or by constructing this directly.
12#[derive(Debug, Clone)]
13pub struct BlockThresholds {
14    /// `block_stability` at or above this is "stable" (growth/shrink still to be determined by
15    /// `trajectory_effect_mean`'s sign). Default `0.85`, matching `Decision`'s default keep
16    /// threshold.
17    pub stable: f64,
18    /// A consistency factor (seed/shuffle/checkpoint) below this is "the problem" driving an
19    /// unstable block's classification. Default `0.5`.
20    pub sensitivity: f64,
21    /// `dead_unit_rate`/`saturated_unit_rate` above this overrides every other signal —
22    /// destructive regardless of measured stability. Default `0.5`.
23    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/// Classification of a training block's stability, from [`compute_block_report`].
37///
38/// **`StableGrowth`/`StableShrink` are not "good"/"bad" labels** — they describe
39/// *reproducibility* of the sign of `trajectory_effect`, not whether that effect is desirable.
40/// A shrink accompanied by rising `dead_unit_rate`/`saturated_unit_rate` is caught separately by
41/// `Pathological`, but a shrink with clean unit health could just as well be healthy
42/// regularization. Recommended handling, pending outcome validation (held-out teacher precision,
43/// downstream match results) rather than trusting the classification alone:
44///
45/// | Class | Suggested handling |
46/// |-------|---------------------|
47/// | `Pathological` | Exclude from training |
48/// | `Insufficient` | Collect more observations before deciding |
49/// | `SeedSensitive` | Add another `seed` and re-classify |
50/// | `OrderSensitive` | Add another `shuffle_seed` and re-classify |
51/// | `TrajectorySensitive` | Re-evaluate at another checkpoint |
52/// | `StableGrowth` | Send to downstream validation — do not auto-keep |
53/// | `StableShrink` | Send to downstream validation — do not auto-drop |
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum BlockClass {
57    /// Stable across seed/shuffle/checkpoint, with net positive `trajectory_effect`. Not
58    /// inherently "good" — see the type-level doc for why growth/shrink need downstream
59    /// validation, not an automatic keep/drop rule.
60    StableGrowth,
61    /// Stable across seed/shuffle/checkpoint, with net negative `trajectory_effect`. Not
62    /// inherently "bad" — see the type-level doc.
63    StableShrink,
64    /// Unstable, and `checkpoint_reproducibility` is the lowest (or only sub-threshold) factor.
65    TrajectorySensitive,
66    /// Unstable, and `seed_effect_consistency` is the lowest (or only sub-threshold) factor.
67    SeedSensitive,
68    /// Unstable, and `shuffle_direction_consistency` is the lowest (or only sub-threshold) factor.
69    OrderSensitive,
70    /// `dead_unit_rate`/`saturated_unit_rate` exceeds `pathological_dead_rate` — destructive
71    /// regardless of any other signal.
72    Pathological,
73    /// Not enough condition-axis diversity (or a growth/shrink sign) to classify further.
74    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/// Stability report for one training block, aggregated across all its observations.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct BlockStabilityReport {
95    /// The block this report describes.
96    pub block_id: String,
97    /// Number of distinct `sample_id` values in this block.
98    pub n_samples: usize,
99    /// Number of observations used to compute this report.
100    pub n_observations: usize,
101    /// Sign-agreement of `trajectory_effect` across `seed` (init seed) groups' means. `None`
102    /// if fewer than 2 distinct seeds carry `trajectory_effect`.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub seed_effect_consistency: Option<f64>,
105    /// Sign-agreement of `update_cosine` across `shuffle_seed` groups' means. `None` if fewer
106    /// than 2 distinct shuffle seeds carry `update_cosine`.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub shuffle_direction_consistency: Option<f64>,
109    /// Sign-agreement of `trajectory_effect` across `model_id` (checkpoint) groups' means.
110    /// `None` if fewer than 2 distinct checkpoints carry `trajectory_effect`.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub checkpoint_reproducibility: Option<f64>,
113    /// Product of whichever of the 3 factors above are present. `None` if none are.
114    ///
115    /// **Not a calibrated probability of anything** (e.g. of a downstream playing-strength
116    /// improvement) — a quietset-specific operational heuristic that treats all three
117    /// reproducibility axes as non-substitutable (see the doc comment at its computation site
118    /// in `compute_block_report` for why the terms are multiplied rather than averaged).
119    /// `stable`/`sensitivity`/`pathological_dead_rate` in [`BlockThresholds`] are operational
120    /// defaults, not universal constants — validate on your own held-out data (and, for a game
121    /// engine, actual match results) before trusting this classification to gate training data.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub block_stability: Option<f64>,
124    /// Mean of `trajectory_effect` across the block's observations — positive means net
125    /// growth, negative net shrink. `None` if no observation carries it.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub trajectory_effect_mean: Option<f64>,
128    /// Fraction of observations carrying `dead_unit_count` where it is `> 0.0`. `None` if none
129    /// carry it.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub dead_unit_rate: Option<f64>,
132    /// Fraction of observations carrying `saturated_unit_count` where it is `> 0.0`. `None` if
133    /// none carry it.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub saturated_unit_rate: Option<f64>,
136    /// This block's stability classification.
137    pub classification: BlockClass,
138}
139
140/// A loss-recipe consistency problem found within one block's observations. See
141/// [`loss_recipe_issues`] and the override in [`compute_block_report`].
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case", tag = "kind")]
144pub enum LossRecipeIssue {
145    /// More than one distinct non-null `loss_recipe` present in this block. We do not
146    /// aggregate different loss recipes into one block score because their effects are
147    /// confounded with seed, shuffle order, and checkpoint variation — a block cannot be
148    /// "seed-stable" or "pathological" in a way attributable to any one axis if the recipe
149    /// itself also changed underneath it. Until recipe-level stability is modeled explicitly
150    /// (a dedicated `loss_recipe_consistency` factor), a mixed block is `Insufficient` rather
151    /// than evidence of instability.
152    Mixed { recipes: Vec<String> },
153    /// All present `loss_recipe` values agree, but some observations in this block carry
154    /// none at all. Classification is unaffected — this is only an operator warning that the
155    /// field isn't consistently populated.
156    IncompleteCoverage { recipe: String },
157}
158
159/// Distinct non-null `loss_recipe` values across `obs`, sorted for deterministic comparison.
160pub(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
168/// True when one block's `before`/`after` observations can be diffed as an ordinary trajectory
169/// effect: neither side mixes `loss_recipe` internally, and when both sides populate it they
170/// agree. Data that never uses `loss_recipe` at all (both sides empty) is comparable, same as
171/// before this check existed. Extracted from [`crate::compute_trajectory_audit`] so `preflight`
172/// reports the same rule rather than a copy that can drift from it.
173pub 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
196/// Scans every block in `observations` for a loss-recipe consistency problem (see
197/// [`LossRecipeIssue`]), in first-seen block order. Used by the `block-score`/
198/// `trajectory-audit` CLI commands to warn operators without failing the whole run over a
199/// partial data problem.
200pub 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
208/// Groups `obs` by `key_fn`, averages `value_fn` within each group, and returns the
209/// sign-agreement of those per-group means. `None` if fewer than 2 groups have a value.
210/// This is the cross-condition-axis analogue of `scoring::compute_range_sensitivity`: instead
211/// of a normalized range, it measures whether groups agree on the *sign* of their effect.
212pub(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
238/// First-match-wins classification decision tree, reading every field of `r` except
239/// `classification` itself. See [`BlockClass`] variants for what each branch means; tie-break
240/// among sub-threshold factors follows fixed declaration order (seed → shuffle → checkpoint),
241/// the same pattern as `StabilityComponents::weakest`.
242fn 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            // Zero or absent: can't tell growth from shrink without the effect field.
258            _ => BlockClass::Insufficient,
259        };
260    }
261    // Unstable overall. Name the cause via whichever available factor is lowest, if it's below
262    // `sensitivity` — a block can also land here with every individual factor above
263    // `sensitivity` (their product still undercuts `stable`); that's reported as Insufficient
264    // rather than guessing which axis is "the" cause.
265    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
282/// Compute a [`BlockStabilityReport`] for one block from its observations.
283///
284/// # Trust boundary
285///
286/// Same contract as [`crate::scoring::compute_report`]: `obs` is expected to already be
287/// validated (finite `trajectory_effect`/`update_cosine`/`dead_unit_count`/
288/// `saturated_unit_count`) — [`crate::parse_jsonl`]/[`crate::parse_csv`] already do this via
289/// [`Observation::validate`]. Checked via `debug_assert!` only (panics in debug builds,
290/// silently accepted in release builds) for observations built directly through the Rust API.
291pub 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    // We multiply the three consistency terms because block stability requires all three
323    // forms of reproducibility; a high value in one dimension should not compensate for
324    // instability in another (a block that reproduces perfectly across seeds but not at all
325    // across checkpoints is not "50% stable" — it is not reproducible). The result is an
326    // operational, quietset-specific score, not a calibrated probability of anything
327    // downstream (e.g. of an actual playing-strength improvement) — see docs/metrics.md.
328    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    // Mixed non-null loss_recipe values within a block override the classification above —
367    // see LossRecipeIssue::Mixed for why the recipe axis can't be aggregated like model_id/
368    // seed/shuffle_seed can. This takes priority even over Pathological: a mixed-recipe block's
369    // dead/saturated-unit rate can't be trusted to mean what Pathological's doc says it means.
370    if matches!(loss_recipe_issue(obs), Some(LossRecipeIssue::Mixed { .. })) {
371        report.classification = BlockClass::Insufficient;
372    }
373    report
374}
375
376/// Compute block reports for every `block_id` present in `observations`, in first-seen order.
377/// Observations with no `block_id` are ignored (see [`crate::group::group_by_block_id`]).
378pub 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        // 3 distinct seeds with trajectory_effect signs +/-/0 -> sign_agreement = 1/3, clearly
440        // below the 0.5 sensitivity threshold. shuffle_seed and model_id stay constant (1
441        // group each -> None), so seed is the only candidate factor.
442        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        // 3 distinct shuffle seeds with update_cosine signs +/-/0 -> sign_agreement = 1/3.
455        // seed and model_id stay constant, so shuffle is the only candidate factor.
456        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        // seed/model_id have only 1 distinct value here -> None -> not candidates.
464        assert_eq!(report.classification, BlockClass::OrderSensitive);
465    }
466
467    #[test]
468    fn test_trajectory_sensitive() {
469        // 3 distinct checkpoints with trajectory_effect signs +/-/0 -> sign_agreement = 1/3.
470        // seed and shuffle_seed stay constant, so checkpoint is the only candidate factor.
471        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        // Even though the fixture is otherwise a clean stable-growth case, a high dead_unit_rate
488        // must override it.
489        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    // --- edge cases: missing/insufficient data must never fabricate a score ---
523
524    #[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        // One observation can never populate 2 distinct groups on any axis, so every
540        // consistency factor is None regardless of how many fields it carries.
541        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        // 3 observations, but all share seed=1 -> only 1 distinct seed group -> None. The
557        // other two axes vary, so they alone drive block_stability.
558        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        // All observations share model_id -> only 1 distinct checkpoint group -> None.
571        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        // 3rd observation carries none of the trajectory fields at all -- it must be excluded
584        // from every factor's computation, not treated as a 0.0 or averaged in.
585        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        // Only seeds 1 and 2 carry trajectory_effect -> 2 groups, both positive -> agreement 1.0.
597        // Seed 3's group has no trajectory_effect at all, so it never enters the grouping.
598        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    // --- classification boundaries ---
615    //
616    // Using round thresholds (0.5) rather than the BlockThresholds defaults (0.85/0.5) so the
617    // exact boundary value is trivial to hit with a small, hand-verifiable fixture (an even
618    // split across 2 groups gives sign_agreement == 0.5 exactly) instead of hunting for a
619    // fraction that happens to equal 0.85.
620
621    #[test]
622    fn test_stable_boundary_at_exact_threshold_is_stable() {
623        // Single factor (seed) present, others None (constant shuffle/model) -> block_stability
624        // == seed_effect_consistency exactly. 2 seed groups, both positive -> 1.0 (well above
625        // any reasonable threshold) is the easy case; the real boundary check is the `>=`
626        // itself, exercised by setting the threshold to exactly the computed value.
627        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, // exactly equal to the computed block_stability
633            ..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, // just above the computed 1.0 block_stability
652            sensitivity: 0.0,   // no factor should register as "the problem" at this sensitivity
653            ..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        // 2 groups split 1/1 -> sign_agreement == 0.5 exactly. The filter is strict `<
663        // sensitivity`, so sensitivity == 0.5 must NOT flag this factor as the culprit.
664        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, // unreachable, forces the unstable branch
670            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, // just above 0.5, so the 0.5 factor now qualifies
692            ..BlockThresholds::default()
693        };
694        let report = compute_block_report("b1", &observations, &thresholds);
695        assert_eq!(report.classification, BlockClass::SeedSensitive);
696    }
697
698    // --- loss_recipe consistency: mixed recipes must not be silently aggregated ---
699
700    #[test]
701    fn test_mixed_loss_recipe_forces_insufficient_even_over_stable_growth() {
702        // Otherwise a clean stable_growth fixture (see test_stable_growth) -- but the two
703        // observations disagree on loss_recipe, so the classification must not be trusted.
704        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        // Even a block that would otherwise read as Pathological must not be trusted once
717        // its loss_recipe is mixed -- the dead_unit_rate itself is confounded.
718        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        // Same stable_growth fixture, but only one of the two observations carries
733        // loss_recipe (the other has none) -- both present values agree, so this is a
734        // coverage gap, not a mix, and must not affect the classification.
735        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            // B1: mixed non-null recipes.
748            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            // B2: one recipe, one missing -- incomplete coverage.
751            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            // B3: no loss_recipe at all on either observation -- clean, no issue.
754            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        // observations[3] (B2's other half) intentionally left with no loss_recipe.
771
772        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        // Extracted from compute_trajectory_audit -- covered end-to-end via that function's
787        // own tests in trajectory_audit.rs; this pins the extracted function directly.
788        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        // A block naturally spans multiple layers (see group_by_block_id's doc) -- this must
825        // not error, warn, or affect classification, unlike a mixed loss_recipe.
826        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    // --- determinism: computed values must not depend on input observation order ---
838
839    #[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        // trajectory_effect_mean sums floats in observation order, so forward vs. reversed can
861        // differ at the ULP level (float addition isn't associative) -- the same property
862        // score_mean already has elsewhere in this codebase. Epsilon comparison, not exact.
863        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        // The returned Vec's *order* legitimately follows first-seen block_id order (an
875        // IndexMap, like group_by_sample_id) -- that's a documented, intentional property, not
876        // a determinism bug. What must not change is each block's own computed values.
877        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}