backhopper-core 0.37.0

Core types, snapshot I/O, store, and compatibility analysis for backhopper
Documentation
// Copyright (C) 2026 Michael S. Klishin and Contributors
// SPDX-License-Identifier: Apache-2.0 OR MIT
// See LICENSE-APACHE and LICENSE-MIT for details.

use backhopper_core::model::eval::{
    BreakAttribution, BuildOutcome, CorpusEntry, Ratio, evaluate_corpus,
};
use backhopper_core::model::evaluation::AggregateVerdict;
use backhopper_core::model::fingerprint::VerdictFingerprint;
use backhopper_core::model::resolver_coverage::{ResolverClass, ResolverCoverage};

fn ratio(hits: usize, total: usize) -> Ratio {
    let mut r = Ratio::zero();
    for _ in 0..hits {
        r.hit();
    }
    for _ in 0..(total - hits) {
        r.miss();
    }
    r
}

/// A fingerprint carrying `label` in its low bytes, distinct labels
/// giving distinct fingerprints: these tests only need identity, not a
/// real digest.
fn fingerprint_labelled(label: &str) -> VerdictFingerprint {
    let mut digest = [0u8; 16];
    let bytes = label.as_bytes();
    let len = bytes.len().min(digest.len());
    digest[..len].copy_from_slice(&bytes[..len]);
    VerdictFingerprint::from_digest(digest)
}

fn entry(
    fp: &str,
    verdict: AggregateVerdict,
    outcome: BuildOutcome,
    coverage: Option<ResolverCoverage>,
) -> CorpusEntry {
    CorpusEntry {
        fingerprint: fingerprint_labelled(fp),
        verdict,
        outcome,
        coverage,
    }
}

fn compilation_failed(class: ResolverClass) -> BuildOutcome {
    BuildOutcome::CompilationFailed { class: Some(class) }
}

fn coverage_of(classes: &[ResolverClass]) -> ResolverCoverage {
    let names: Vec<String> = classes
        .iter()
        .map(|c| {
            serde_json::to_value(c)
                .unwrap()
                .as_str()
                .unwrap()
                .to_owned()
        })
        .collect();
    serde_json::from_value(serde_json::json!({ "checked": names })).unwrap()
}

#[test]
fn vacuous_trust_counts_clean_verdicts_that_built_clean() {
    let rows = [
        entry(
            "a",
            AggregateVerdict::Inapplicable,
            BuildOutcome::BuiltClean,
            None,
        ),
        entry(
            "b",
            AggregateVerdict::Compatible,
            BuildOutcome::BuiltClean,
            None,
        ),
        entry(
            "c",
            AggregateVerdict::Inapplicable,
            compilation_failed(ResolverClass::Macro),
            None,
        ),
    ];
    let report = evaluate_corpus(&rows);
    assert_eq!(report.vacuous_trust, ratio(2, 3));
}

#[test]
fn recall_counts_flagged_breaks_and_missed_records_the_rest() {
    let rows = [
        entry(
            "flagged",
            AggregateVerdict::Incompatible,
            compilation_failed(ResolverClass::Macro),
            Some(coverage_of(&[ResolverClass::Macro])),
        ),
        entry(
            "missed",
            AggregateVerdict::Inapplicable,
            compilation_failed(ResolverClass::LocalCall),
            Some(coverage_of(&[ResolverClass::LocalCall])),
        ),
        entry(
            "clean",
            AggregateVerdict::Compatible,
            BuildOutcome::BuiltClean,
            None,
        ),
    ];
    let report = evaluate_corpus(&rows);
    assert_eq!(report.recall, ratio(1, 2));
    assert_eq!(report.missed_breaks.len(), 1);
    let missed = &report.missed_breaks[0];
    assert_eq!(missed.fingerprint, fingerprint_labelled("missed"));
    assert_eq!(missed.break_class, Some(ResolverClass::LocalCall));
    assert_eq!(missed.attribution, BreakAttribution::ResolverBug);
}

#[test]
fn precision_counts_real_breaks_among_flagged_rows() {
    let rows = [
        entry(
            "real",
            AggregateVerdict::Incompatible,
            compilation_failed(ResolverClass::Macro),
            None,
        ),
        entry(
            "false_alarm",
            AggregateVerdict::RequiresAdaptation,
            BuildOutcome::BuiltClean,
            None,
        ),
    ];
    let report = evaluate_corpus(&rows);
    assert_eq!(report.precision, ratio(1, 2));
}

#[test]
fn breaks_by_class_ranks_compile_failures_only() {
    let rows = [
        entry(
            "a",
            AggregateVerdict::Incompatible,
            compilation_failed(ResolverClass::Macro),
            None,
        ),
        entry(
            "b",
            AggregateVerdict::Inapplicable,
            compilation_failed(ResolverClass::Macro),
            None,
        ),
        entry(
            "c",
            AggregateVerdict::RequiresAdaptation,
            BuildOutcome::ApplyConflicted,
            None,
        ),
    ];
    let report = evaluate_corpus(&rows);
    assert_eq!(report.rows, 3);
    assert!(report.breaks_by_class.contains(&(ResolverClass::Macro, 2)));
    // an apply conflict carries no resolver class, so it ranks nothing
    assert_eq!(report.breaks_by_class.len(), 1);
}

// The same break class routes bug-vs-gap by the row's recorded coverage, not the running binary's.
#[test]
fn bug_vs_gap_routes_by_the_rows_recorded_coverage() {
    let rows = [
        entry(
            "bug",
            AggregateVerdict::Inapplicable,
            compilation_failed(ResolverClass::Macro),
            Some(coverage_of(&[ResolverClass::Macro])),
        ),
        entry(
            "gap",
            AggregateVerdict::Inapplicable,
            compilation_failed(ResolverClass::Macro),
            Some(coverage_of(&[ResolverClass::Record])),
        ),
        entry(
            "unknown",
            AggregateVerdict::Inapplicable,
            compilation_failed(ResolverClass::Macro),
            None,
        ),
    ];
    let report = evaluate_corpus(&rows);
    let bug_of = |fp: &str| {
        report
            .missed_breaks
            .iter()
            .find(|m| m.fingerprint == fingerprint_labelled(fp))
            .unwrap()
            .attribution
    };
    assert_eq!(bug_of("bug"), BreakAttribution::ResolverBug);
    assert_eq!(bug_of("gap"), BreakAttribution::CoverageGap);
    assert_eq!(bug_of("unknown"), BreakAttribution::Unknown);
}

// An apply or test miss is a predictor miss on a different axis: no class, no bug flag.
#[test]
fn an_apply_miss_is_not_a_compile_coverage_gap() {
    let rows = [entry(
        "apply",
        AggregateVerdict::Inapplicable,
        BuildOutcome::ApplyConflicted,
        Some(ResolverCoverage::current()),
    )];
    let report = evaluate_corpus(&rows);
    assert_eq!(report.missed_breaks.len(), 1);
    let missed = &report.missed_breaks[0];
    assert_eq!(missed.break_class, None);
    assert_eq!(missed.attribution, BreakAttribution::Unknown);
    assert_eq!(missed.outcome, BuildOutcome::ApplyConflicted);
}

#[test]
fn an_empty_corpus_yields_zero_rates() {
    let report = evaluate_corpus(&[]);
    assert_eq!(report.rows, 0);
    assert_eq!(report.recall, ratio(0, 0));
    assert!(report.missed_breaks.is_empty());
}