cascade_risk / tests /test_evaluator.py
Lucasoppem's picture
Sync from GitHub main (part 2)
36f9d47 verified
Raw
History Blame Contribute Delete
17.7 kB
"""Tests for Evaluator orchestration."""
from datetime import date
import json
from src.data.cascade_extractor import _infer_severity
from src.eval.evaluator import Evaluator, _build_description, judge_fingerprint
from src.models.schemas import (
CascadeChain,
CascadeNode,
EventEvaluation,
FloodEvent,
PredictionResult,
)
def _test_event(**overrides):
base = dict(
event_id="2025-0001-FRA",
country="France",
iso="FRA",
region="Europe",
location="Paris",
start_date=date(2025, 6, 1),
)
base.update(overrides)
return FloodEvent(**base)
def _predicted_chain():
return CascadeChain(
event_id="PRED-2025-06-01-FRA",
trigger_summary="Heavy rain flood in Paris",
trigger_country="France",
trigger_iso="FRA",
trigger_date=date(2025, 6, 1),
cascade_events=[
CascadeNode(id="E1", description="Metro flooded",
domain="infrastructure/transport",
severity="high", mechanism="Drainage overwhelmed"),
],
)
class StubPredictor:
"""Predictor stub that records each predict() call for assertions."""
def __init__(self, chain, reference_event_ids=None, similarity_scores=None):
self._chain = chain
self._refs = reference_event_ids or ["2023-0866-FRA"]
self._sims = similarity_scores or [0.81]
self.calls: list[dict] = []
def predict(self, **kwargs):
self.calls.append(kwargs)
return PredictionResult(
predicted_chain=self._chain,
confidence_scores={"E1": 0.8},
reference_event_ids=self._refs,
similarity_scores=self._sims,
)
def _base_config(tmp_path, cascade_index: str | None = None) -> dict:
cfg = {
"prompts": {"judge_cascade": "prompts/judge_cascade.txt"},
"knowledge": {"judge": "knowledge/expert_judge.md"},
"evaluation": {
"judge_model": None,
"judge_temperature": 0.0,
"max_articles_per_event": 6,
"max_chars_per_article": 4000,
"output_dir": str(tmp_path / "evaluation"),
},
}
if cascade_index is not None:
cfg["paths"] = {"cascade_index": cascade_index}
return cfg
JUDGE_RESPONSE_OK = json.dumps({
"node_evaluations": [{
"node_id": "E1",
"evidence_level": "verified",
"evidence_quote": "'Metro line closed after flood' (https://lemonde.fr/x)",
"reasoning": "Article reports the metro closure directly.",
"timing_alignment": "match",
}],
"missed_cascades": [{
"description": "Power outage",
"domain": "infrastructure/power",
"evidence_quote": "'25,000 homes without power' (https://afp.com/y)",
}],
"summary": "Captured transport impact, missed power outage.",
})
def test_evaluate_event_returns_structured_result(
tmp_path, fake_llm, write_article, articles_dir
):
write_article(event_id="2025-0001-FRA", url="https://lemonde.fr/x",
title="Flood hits Paris", date="2025-06-01",
text="Metro line 4 was closed after floodwater filled tunnels.",
hash_suffix="aa")
evaluator = Evaluator(
llm_client=fake_llm([JUDGE_RESPONSE_OK]),
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
result = evaluator.evaluate_event(_test_event())
assert isinstance(result, EventEvaluation)
assert result.event_id == "2025-0001-FRA"
assert len(result.node_evaluations) == 1
assert result.node_evaluations[0].evidence_level == "verified"
assert len(result.missed_cascades) == 1
assert result.missed_cascades[0].domain == "infrastructure/power"
assert result.retrieval_info == [{"event_id": "2023-0866-FRA",
"similarity": 0.81}]
assert result.predicted_chain.cascade_events[0].id == "E1"
assert result.news_sources_used == ["https://lemonde.fr/x"]
def test_evaluate_event_writes_cache_file(
tmp_path, fake_llm, write_article, articles_dir
):
write_article(event_id="2025-0001-FRA", url="https://x", title="t",
date="2025-06-01", text="Metro flooded", hash_suffix="aa")
evaluator = Evaluator(
llm_client=fake_llm([JUDGE_RESPONSE_OK]),
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
evaluator.evaluate_event(_test_event(), write_cache=True)
cache_file = tmp_path / "evaluation" / "2025-0001-FRA.json"
assert cache_file.exists()
loaded = EventEvaluation.model_validate_json(cache_file.read_text())
assert loaded.event_id == "2025-0001-FRA"
# Cache carries the current fingerprint, which is non-empty for a
# successful judge run.
assert loaded.judge_fingerprint != ""
def test_evaluate_event_no_articles_skips_judge(
tmp_path, fake_llm, articles_dir
):
"""When no articles exist, judge is NOT called; a placeholder record is returned."""
client = fake_llm([]) # empty — would fail if judge is called
evaluator = Evaluator(
llm_client=client,
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir, # empty
config=_base_config(tmp_path),
)
result = evaluator.evaluate_event(_test_event())
assert client.calls == []
assert result.summary.startswith("No news articles")
assert result.node_evaluations == []
assert result.news_sources_used == []
assert evaluator.last_cache_status == "skip_no_news"
def test_evaluate_event_malformed_judge_json_captures_error(
tmp_path, fake_llm, write_article, articles_dir
):
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
evaluator = Evaluator(
llm_client=fake_llm(["not actually json {{{"]),
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
result = evaluator.evaluate_event(_test_event())
assert result.node_evaluations == []
assert "malformed" in result.summary.lower()
def test_judge_fingerprint_changes_with_content():
a = judge_fingerprint("prompt v1", "knowledge v1")
b = judge_fingerprint("prompt v2", "knowledge v1")
c = judge_fingerprint("prompt v1", "knowledge v2")
assert a != b
assert a != c
# Deterministic
assert a == judge_fingerprint("prompt v1", "knowledge v1")
def test_evaluate_event_invalid_evidence_level_captures_error(
tmp_path, fake_llm, write_article, articles_dir
):
"""Judge returns valid JSON but with an invalid evidence_level enum value."""
bad_response = json.dumps({
"node_evaluations": [{
"node_id": "E1",
"evidence_level": "maybe", # not a valid EvidenceLevel
"evidence_quote": "q",
"reasoning": "r",
"timing_alignment": "match",
}],
"missed_cascades": [],
"summary": "should be swallowed",
})
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
evaluator = Evaluator(
llm_client=fake_llm([bad_response]),
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
result = evaluator.evaluate_event(_test_event())
assert result.node_evaluations == []
assert "malformed" in result.summary.lower()
# ---------------------------------------------------------------------------
# v2 alignment coverage: severity derivation, richer description, cache
# invalidation, and retrieval_info enrichment.
# ---------------------------------------------------------------------------
def test_severity_derivation_covers_four_buckets():
"""`_infer_severity` is the v2 shared mapping; confirm all four tiers."""
assert _infer_severity(_test_event(total_deaths=60)) == "critical"
assert _infer_severity(_test_event(total_affected=150_000)) == "critical"
assert _infer_severity(_test_event(total_deaths=11)) == "high"
assert _infer_severity(_test_event(total_affected=20_000)) == "high"
assert _infer_severity(_test_event(total_deaths=1)) == "medium"
assert _infer_severity(_test_event(total_affected=2_000)) == "medium"
assert _infer_severity(_test_event()) == "low"
def test_predictor_receives_derived_severity_and_rich_description(
tmp_path, fake_llm, write_article, articles_dir
):
"""Predictor must get v2-derived severity (not hardcoded 'medium') and a
description that carries the EM-DAT numeric fields."""
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
stub = StubPredictor(_predicted_chain())
evaluator = Evaluator(
llm_client=fake_llm([JUDGE_RESPONSE_OK]),
predictor=stub,
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
event = _test_event(total_deaths=25, total_affected=50_000,
total_damage_k_usd=1200.0, origin="Heavy rains")
evaluator.evaluate_event(event)
assert len(stub.calls) == 1
call = stub.calls[0]
assert call["severity"] == "high" # 25 deaths + 50k affected → high
desc = call["description"]
assert "Deaths: 25" in desc
assert "Affected: 50000 people" in desc
assert "Heavy rains" in desc
def test_build_description_tolerates_missing_fields():
desc = _build_description(_test_event())
# Location, iso, and date should always land in the string.
assert "Paris" in desc
assert "FRA" in desc
assert "2025-06-01" in desc
def test_cache_hit_skips_predict_and_judge(
tmp_path, fake_llm, write_article, articles_dir
):
"""When a cached EventEvaluation has a matching fingerprint, neither the
predictor nor the judge LLM should be invoked."""
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
# First pass: populate cache with current fingerprint.
first_llm = fake_llm([JUDGE_RESPONSE_OK])
first_stub = StubPredictor(_predicted_chain())
ev = Evaluator(
llm_client=first_llm,
predictor=first_stub,
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
ev.evaluate_event(_test_event(), write_cache=True)
assert ev.last_cache_status == "rejudge"
assert len(first_stub.calls) == 1
# Second pass: new client/stub. Cache hit should skip everything.
second_llm = fake_llm([]) # would error if called
second_stub = StubPredictor(_predicted_chain())
ev2 = Evaluator(
llm_client=second_llm,
predictor=second_stub,
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
result = ev2.evaluate_event(_test_event(), write_cache=True)
assert ev2.last_cache_status == "hit"
assert second_llm.calls == []
assert second_stub.calls == []
assert result.event_id == "2025-0001-FRA"
assert result.judge_fingerprint == ev2.judge_fingerprint_value
def test_fingerprint_mismatch_forces_rejudge(
tmp_path, fake_llm, write_article, articles_dir
):
"""A cache file with a stale fingerprint must be overwritten by a new
predict+judge run."""
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
# Seed the cache with a bogus fingerprint.
out_dir = tmp_path / "evaluation"
out_dir.mkdir()
stale = EventEvaluation(
event_id="2025-0001-FRA",
node_evaluations=[],
missed_cascades=[],
summary="stale",
news_sources_used=[],
retrieval_info=[],
predicted_chain=_predicted_chain(),
evaluated_at=date(2025, 1, 1),
judge_fingerprint="stale0000fingerprint",
)
(out_dir / "2025-0001-FRA.json").write_text(stale.model_dump_json())
llm = fake_llm([JUDGE_RESPONSE_OK])
ev = Evaluator(
llm_client=llm,
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
result = ev.evaluate_event(_test_event(), write_cache=True)
assert ev.last_cache_status == "rejudge"
assert len(llm.calls) == 1
assert result.summary != "stale"
assert result.judge_fingerprint == ev.judge_fingerprint_value
def test_force_rejudge_bypasses_cache(
tmp_path, fake_llm, write_article, articles_dir
):
"""`force_rejudge=True` must ignore a matching-fingerprint cache."""
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
# First, populate cache.
ev = Evaluator(
llm_client=fake_llm([JUDGE_RESPONSE_OK]),
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
ev.evaluate_event(_test_event(), write_cache=True)
# Second, force.
llm2 = fake_llm([JUDGE_RESPONSE_OK])
stub2 = StubPredictor(_predicted_chain())
ev2 = Evaluator(
llm_client=llm2, predictor=stub2,
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
ev2.evaluate_event(_test_event(), write_cache=True, force_rejudge=True)
assert ev2.last_cache_status == "rejudge"
assert len(llm2.calls) == 1
assert len(stub2.calls) == 1
def test_retrieval_info_enriched_from_chain_index(
tmp_path, fake_llm, write_article, articles_dir
):
"""retrieval_info should carry country/date/num_cascade_nodes/top_domains
when a cascade index is available."""
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
index_path = tmp_path / "cascade_chains_index.json"
index_path.write_text(json.dumps([
{
"event_id": "2023-0866-FRA",
"country": "France",
"date": "2023-11-02",
"num_cascade_nodes": 24,
"domains": [
"infrastructure/transport",
"infrastructure/power",
"social/evacuation",
"economy/agriculture",
],
},
{
"event_id": "2024-0100-DEU",
"country": "Germany",
"date": "2024-05-30",
"num_cascade_nodes": 18,
"domains": ["infrastructure/water"],
},
]))
ev = Evaluator(
llm_client=fake_llm([JUDGE_RESPONSE_OK]),
predictor=StubPredictor(
_predicted_chain(),
reference_event_ids=["2023-0866-FRA", "2024-0100-DEU"],
similarity_scores=[0.81, 0.62],
),
articles_dir=articles_dir,
config=_base_config(tmp_path, cascade_index=str(index_path)),
)
result = ev.evaluate_event(_test_event())
assert len(result.retrieval_info) == 2
first = result.retrieval_info[0]
assert first["event_id"] == "2023-0866-FRA"
assert first["similarity"] == 0.81
assert first["country"] == "France"
assert first["date"] == "2023-11-02"
assert first["num_cascade_nodes"] == 24
# top_domains capped at 3.
assert first["top_domains"] == [
"infrastructure/transport",
"infrastructure/power",
"social/evacuation",
]
assert result.retrieval_info[1]["country"] == "Germany"
def test_retrieval_info_falls_back_when_index_missing(
tmp_path, fake_llm, write_article, articles_dir
):
"""With no cascade_index configured, retrieval_info stays minimal
(just event_id + similarity)."""
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
ev = Evaluator(
llm_client=fake_llm([JUDGE_RESPONSE_OK]),
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
result = ev.evaluate_event(_test_event())
assert result.retrieval_info == [
{"event_id": "2023-0866-FRA", "similarity": 0.81}
]
def test_empty_fingerprint_cache_does_not_short_circuit(
tmp_path, fake_llm, write_article, articles_dir
):
"""A cache file written when there were no articles has judge_fingerprint=''
and must not count as a fingerprint match — otherwise empty cache entries
would pin forever."""
out_dir = tmp_path / "evaluation"
out_dir.mkdir()
empty = EventEvaluation(
event_id="2025-0001-FRA",
node_evaluations=[],
missed_cascades=[],
summary="No news articles available",
news_sources_used=[],
retrieval_info=[],
predicted_chain=_predicted_chain(),
evaluated_at=date(2025, 1, 1),
judge_fingerprint="",
)
(out_dir / "2025-0001-FRA.json").write_text(empty.model_dump_json())
# Now add articles — re-run should NOT early-return.
write_article(event_id="2025-0001-FRA", url="u", title="t",
date="2025-06-01", text="x", hash_suffix="aa")
llm = fake_llm([JUDGE_RESPONSE_OK])
ev = Evaluator(
llm_client=llm,
predictor=StubPredictor(_predicted_chain()),
articles_dir=articles_dir,
config=_base_config(tmp_path),
)
ev.evaluate_event(_test_event(), write_cache=True)
assert ev.last_cache_status == "rejudge"
assert len(llm.calls) == 1