cascade_risk / tests /test_bootstrap_ci.py
Lucasoppem's picture
Sync from GitHub main (part 2)
36f9d47 verified
Raw
History Blame Contribute Delete
5.72 kB
"""Unit tests for bootstrap CI helpers in src/eval/metrics.py.
Locks the contract of bootstrap_macro_ci and aggregate_with_ci per
spec docs/superpowers/specs/2026-06-07-v07-bootstrap-ci-design.md Β§6.1.
"""
from __future__ import annotations
from datetime import date
import pytest
from src.eval.metrics import aggregate_with_ci, bootstrap_macro_ci
from src.models.schemas import GoldEvaluation
# -----------------------------------------------------------
# bootstrap_macro_ci β€” 7 unit cases
# -----------------------------------------------------------
def test_basic_smoke():
"""Fixed inputs produce mean = 0.55 with a CI that brackets the mean."""
out = bootstrap_macro_ci(
[0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
n_resamples=1000,
confidence=0.95,
seed=42,
)
assert out["mean"] == pytest.approx(0.55, abs=1e-9)
assert out["n"] == 6
assert out["n_resamples"] == 1000
assert out["confidence"] == 0.95
assert out["ci_low"] <= out["mean"] <= out["ci_high"]
assert 0.3 <= out["ci_low"] <= out["ci_high"] <= 0.8
def test_determinism():
"""Same input + same seed β†’ bit-identical output across calls."""
vals = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
a = bootstrap_macro_ci(vals, seed=42)
b = bootstrap_macro_ci(vals, seed=42)
assert a == b
def test_degenerate_n1():
"""Single value collapses CI onto the point estimate."""
out = bootstrap_macro_ci([0.5], seed=42)
assert out["mean"] == 0.5
assert out["ci_low"] == 0.5
assert out["ci_high"] == 0.5
assert out["n"] == 1
def test_degenerate_all_same():
"""Zero variance β†’ CI collapses to the mean."""
out = bootstrap_macro_ci([0.5] * 6, seed=42)
assert out["mean"] == 0.5
assert out["ci_low"] == 0.5
assert out["ci_high"] == 0.5
assert out["n"] == 6
def test_empty_raises():
"""Empty input is a programmer error β€” raise, do not silently NaN."""
with pytest.raises(ValueError):
bootstrap_macro_ci([])
def test_all_none_raises():
"""All-None input is a programmer error too."""
with pytest.raises(ValueError):
bootstrap_macro_ci([None, None, None])
def test_partial_none_skipped():
"""Mixed None / float β€” None entries are dropped; n reflects survivors."""
out = bootstrap_macro_ci(
[0.5, None, 0.5, 0.5, None, 0.5], seed=42
)
assert out["mean"] == 0.5
assert out["n"] == 4
assert out["ci_low"] == 0.5
assert out["ci_high"] == 0.5
# -----------------------------------------------------------
# aggregate_with_ci β€” 2 unit cases
# -----------------------------------------------------------
def _mk_eval(event_id: str, seed: int, *, cat_r: float, cat_sev: float,
cos_f1: float, cat_f1: float) -> GoldEvaluation:
"""Minimal GoldEvaluation builder for tests β€” only fields touched by
aggregate_with_ci need realistic values. Other required fields get
placeholder content.
"""
return GoldEvaluation(
event_id=event_id,
predicted_chain={
"event_id": event_id,
"trigger_summary": "test",
"trigger_country": "Testland",
"trigger_iso": "TST",
"trigger_date": date(2026, 6, 7),
"cascade_events": [],
},
gold_event_id=event_id,
gold_node_count=0,
predicted_node_count=0,
matches=[],
precision=0.0,
recall=0.0,
f1=cos_f1,
domain_jaccard=0.0,
severity_match_rate=None,
threshold=0.35,
embedder_model="all-MiniLM-L6-v2",
algorithm_version="v10",
gold_eval_fingerprint=f"fp_{event_id}_{seed}",
retrieval_info=[],
evaluated_at=date(2026, 6, 7),
category_precision=None,
category_recall=cat_r,
category_f1=cat_f1,
category_severity_match_rate=cat_sev,
category_matches=[],
)
def test_aggregate_with_ci_groups_by_event():
"""3 events Γ— 3 seeds β†’ seed-mean per event, then bootstrap over events."""
evals = []
for eid, recalls in [
("E1", [0.3, 0.4, 0.5]),
("E2", [0.6, 0.6, 0.6]),
("E3", [0.7, 0.8, 0.9]),
]:
for seed, r in zip([42, 1337, 2026], recalls):
evals.append(_mk_eval(eid, seed,
cat_r=r, cat_sev=0.5,
cos_f1=r, cat_f1=r))
out = aggregate_with_ci(evals, bootstrap_seed=42)
assert set(out.keys()) == {
"category_recall", "category_severity_match_rate",
"cosine_f1", "category_f1",
}
# Per-event seed-means: E1=0.4, E2=0.6, E3=0.8 β†’ grand mean = 0.6
cat_r = out["category_recall"]
assert cat_r["mean"] == pytest.approx(0.6, abs=1e-9)
assert cat_r["n"] == 3
assert cat_r["per_event_values"]["E1"] == pytest.approx(0.4, abs=1e-9)
assert cat_r["per_event_values"]["E2"] == pytest.approx(0.6, abs=1e-9)
assert cat_r["per_event_values"]["E3"] == pytest.approx(0.8, abs=1e-9)
assert cat_r["per_event_seed_values"]["E1"] == [0.3, 0.4, 0.5]
def test_aggregate_skips_outlier_events():
"""outlier_event_ids removes events from CI computation."""
evals = [
_mk_eval("E1", 42, cat_r=0.4, cat_sev=0.5, cos_f1=0.4, cat_f1=0.4),
_mk_eval("E2", 42, cat_r=0.6, cat_sev=0.5, cos_f1=0.6, cat_f1=0.6),
_mk_eval("OUTLIER", 42, cat_r=0.0, cat_sev=0.0,
cos_f1=0.0, cat_f1=0.0),
]
out = aggregate_with_ci(evals, outlier_event_ids={"OUTLIER"})
assert "OUTLIER" not in out["category_recall"]["per_event_values"]
assert out["category_recall"]["n"] == 2
assert out["category_recall"]["mean"] == pytest.approx(0.5, abs=1e-9)