cascade_risk / tests /test_iterative_predictor.py
Lucasoppem's picture
Sync from GitHub main (part 2)
36f9d47 verified
Raw
History Blame Contribute Delete
23.8 kB
"""Tests for the BFS CascadePredictor (v0.2 issue #6).
These tests use:
* a deterministic ``FakeEmbedder`` (8-dim one-hot keyed by ``tag:N``
substring), reused from the EdgeRetriever test fixture style β€” both
ingestion and retrieval are patched onto the same class so cosine
similarity is exact;
* a scripted ``FakeLLMClient`` that returns a queued JSON response per
``call`` and which detects which of the two prompts is being asked by
inspecting whether ``"BFS anchor rules"`` (only present in the
iterative prompt) is in the template;
* a tmp ChromaDB seeded with a tiny edge corpus per test, plus on-disk
``cascade_edges/{event_id}.json`` so ``EdgeRetriever._load_edge`` can
rehydrate full ``CascadeEdge`` objects.
No real LLM is invoked.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
import pytest
from src.llm.client import LLMClient
from src.models.schemas import CascadeEdge, PredictionResult
from src.rag.ingestion import (
add_cascade_edge,
get_edge_chroma_client,
get_or_create_edge_collection,
)
# ----------------------------------------------------------------------
# Test doubles
# ----------------------------------------------------------------------
_TAG_RE = re.compile(r"tag:(\d+)")
class FakeEmbedder:
"""Deterministic 8-dim one-hot embedder keyed by ``tag:N`` substrings."""
DIM = 8
def __init__(self, *_args, **_kwargs):
self.calls: list[str] = []
def embed_text(self, text: str) -> list[float]:
self.calls.append(text)
m = _TAG_RE.search(text)
idx = int(m.group(1)) % self.DIM if m else 0
v = [0.0] * self.DIM
v[idx] = 1.0
return v
def embed_texts(self, texts: list[str]) -> list[list[float]]:
return [self.embed_text(t) for t in texts]
class TwoPromptLLM(LLMClient):
"""Routes responses to the matching prompt template by content sniffing.
The initial prompt's ``cascade_events`` shape and the iterative prompt's
``layer`` shape are different enough that callers want them dispatched
independently. The simplest reliable cue is the unique
``"BFS anchor rules"`` heading inside ``predict_iterative.txt``.
"""
def __init__(self, initial: list[str], iterative: list[str]):
self.initial = list(initial)
self.iterative = list(iterative)
self.calls: list[dict] = []
def call(
self,
prompt_template: str,
variables: dict,
expert_knowledge: str = "",
*,
seed: int | None = None,
) -> str:
self.calls.append({"variables": variables, "knowledge": expert_knowledge})
if "BFS anchor rules" in prompt_template:
if not self.iterative:
# Returning empty layer with explicit saturation keeps tests
# deterministic when they over-step: BFS is supposed to stop.
return json.dumps({"layer": [], "stop_reason": "saturation"})
return self.iterative.pop(0)
if not self.initial:
raise RuntimeError("TwoPromptLLM: out of initial responses")
return self.initial.pop(0)
# ----------------------------------------------------------------------
# Edge corpus helpers
# ----------------------------------------------------------------------
def _edge(
edge_id: str,
*,
source_event_id: str,
is_first_level: bool,
parent_text: str,
parent_domain: str | None = None,
child_description: str = "child desc",
child_domain: str = "infrastructure/power",
child_severity: str = "high",
delta: float = 6.0,
) -> CascadeEdge:
return CascadeEdge(
edge_id=edge_id,
source_event_id=source_event_id,
is_first_level=is_first_level,
parent_text=parent_text,
parent_domain=parent_domain,
parent_severity="medium",
child_description=child_description,
child_domain=child_domain,
child_severity=child_severity,
child_mechanism="m",
time_offset_hours_delta=delta,
trigger_country="Italy",
trigger_iso="ITA",
trigger_severity="medium",
trigger_summary="Flood in Italy on 2023-04-30",
)
def _write_edge_files(edges_dir: Path, edges: list[CascadeEdge]) -> None:
edges_dir.mkdir(parents=True, exist_ok=True)
grouped: dict[str, list[CascadeEdge]] = {}
for e in edges:
grouped.setdefault(e.source_event_id, []).append(e)
for event_id, group in grouped.items():
(edges_dir / f"{event_id}.json").write_text(
json.dumps([e.model_dump(mode="json") for e in group])
)
def _make_config(
tmp_path: Path,
*,
similarity_threshold: float = 0.5,
time_window_hours: float = 336,
max_layers: int = 8,
max_total_nodes: int = 200,
initial_top_k: int = 8,
iterative_top_k_per_frontier: int = 5,
) -> dict:
return {
"embedding": {
"backend": "sentence-transformers",
"model": "all-MiniLM-L6-v2",
"encoding_mode": "trigger_only",
},
"rag": {
"edge_collection_name": "cascade_edges",
"edge_vectordb_dir": str(tmp_path / "vectordb_v2"),
"initial_top_k": initial_top_k,
"iterative_top_k_per_frontier": iterative_top_k_per_frontier,
"similarity_threshold": similarity_threshold,
"time_window_hours": time_window_hours,
"max_layers": max_layers,
"max_total_nodes": max_total_nodes,
},
"paths": {
"cascade_edges_dir": str(tmp_path / "cascade_edges"),
},
"data": {
"train_years": [2023, 2024],
},
"prompts": {
"predict_initial": "prompts/predict_initial.txt",
"predict_iterative": "prompts/predict_iterative.txt",
},
"knowledge": {
"predict": "knowledge/expert_predict.md",
},
}
@pytest.fixture
def setup_predictor(tmp_path, monkeypatch):
"""Build a CascadePredictor wired to a tmp ChromaDB + FakeEmbedder.
Returns ``setup(edges, llm, **cfg_overrides) -> (config, predictor)``.
"""
monkeypatch.setattr("src.rag.ingestion.Embedder", FakeEmbedder)
monkeypatch.setattr("src.rag.edge_retriever.Embedder", FakeEmbedder)
# Import here so the monkeypatch is in effect before the module reads
# the original Embedder reference.
from src.rag.predictor import CascadePredictor
def _setup(edges: list[CascadeEdge], llm: LLMClient, **cfg_overrides):
config = _make_config(tmp_path, **cfg_overrides)
edges_dir = Path(config["paths"]["cascade_edges_dir"])
_write_edge_files(edges_dir, edges)
client = get_edge_chroma_client(config)
collection = get_or_create_edge_collection(client, config)
embedder = FakeEmbedder()
for e in edges:
add_cascade_edge(e, collection, embedder)
predictor = CascadePredictor(llm_client=llm, config=config)
return config, predictor
return _setup
# ----------------------------------------------------------------------
# Canned LLM payload helpers
# ----------------------------------------------------------------------
def _initial_payload(nodes: list[dict], stop_reason: str | None = None) -> str:
return json.dumps(
{
"event_id": "PRED-2025-05-01-SVN",
"trigger_summary": "test",
"trigger_country": "Slovenia",
"trigger_iso": "SVN",
"trigger_date": "2025-05-01",
"trigger_severity": "medium",
"cascade_events": nodes,
"stop_reason": stop_reason,
}
)
def _iterative_payload(nodes: list[dict], stop_reason: str | None = None) -> str:
return json.dumps({"layer": nodes, "stop_reason": stop_reason})
def _node(
nid: str,
*,
parent_ids: list[str] | None = None,
domain: str = "infrastructure/power",
severity: str = "high",
time: float = 6.0,
description: str | None = None,
) -> dict:
return {
"id": nid,
"description": description or f"node {nid}",
"domain": domain,
"severity": severity,
"time_offset_hours": time,
"mechanism": "m",
"parent_ids": list(parent_ids or []),
}
def _predict(predictor) -> PredictionResult:
return predictor.predict(
country="Slovenia",
iso="SVN",
location="Ljubljana",
event_date="2025-05-01",
severity="medium",
description="Heavy floods in Ljubljana basin tag:0",
)
# ----------------------------------------------------------------------
# 1. Time window STOP
# ----------------------------------------------------------------------
def test_time_window_stops_bfs_when_layer_exceeds_window(setup_predictor):
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="layer1 desc tag:0",
parent_domain="infrastructure/power",
),
]
llm = TwoPromptLLM(
initial=[_initial_payload([_node("E1", time=6)])],
iterative=[
# Layer-2 node placed past the 336h window β€” should stop BFS.
_iterative_payload([_node("E2", parent_ids=["E1"], time=400)]),
],
)
_config, predictor = setup_predictor(edges, llm)
result = _predict(predictor)
stop_reasons = [t.get("stop_reason") for t in result.trace]
assert "time_window_exhausted" in stop_reasons
# E2 is still emitted into the DAG (it just doesn't get extended).
ids = [n.id for n in result.predicted_chain.cascade_events]
assert "E1" in ids and "E2" in ids
# ----------------------------------------------------------------------
# 2. Similarity STOP
# ----------------------------------------------------------------------
def test_similarity_below_threshold_stops_bfs(setup_predictor):
# Layer-1 frontier embeds tag:5; only edges with tag:5 in parent_text
# would match. Our corpus has none β†’ max similarity β‰ˆ 0 < 0.5.
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="parent tag:1", # frontier asks tag:5 β€” no match
parent_domain="infrastructure/power",
),
]
llm = TwoPromptLLM(
initial=[
_initial_payload(
[_node("E1", description="frontier carries tag:5", time=6)]
)
],
iterative=[
_iterative_payload([_node("E2", parent_ids=["E1"], time=12)]),
],
)
_config, predictor = setup_predictor(edges, llm)
result = _predict(predictor)
stop_reasons = [t.get("stop_reason") for t in result.trace]
assert "similarity_below_threshold" in stop_reasons
# _llm_iterative was never reached β€” still 1 LLM call total.
assert len(llm.calls) == 1
# Only E1 made it; E2 was never generated because BFS bailed first.
ids = [n.id for n in result.predicted_chain.cascade_events]
assert ids == ["E1"]
# ----------------------------------------------------------------------
# 3. Model STOP (saturation)
# ----------------------------------------------------------------------
def test_model_saturation_stops_bfs(setup_predictor):
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="parent tag:0",
parent_domain="infrastructure/power",
),
]
llm = TwoPromptLLM(
initial=[_initial_payload([_node("E1", description="tag:0", time=6)])],
iterative=[_iterative_payload([], stop_reason="saturation")],
)
_config, predictor = setup_predictor(edges, llm)
result = _predict(predictor)
stop_reasons = [t.get("stop_reason") for t in result.trace]
assert "saturation" in stop_reasons
ids = [n.id for n in result.predicted_chain.cascade_events]
assert ids == ["E1"]
# ----------------------------------------------------------------------
# 4. Safety net: max_layers
# ----------------------------------------------------------------------
def test_safety_max_layers_stops_runaway_bfs(setup_predictor):
edges = [
_edge(
f"EV1::p{i}->c{i}",
source_event_id="2023-EV1-ITA",
is_first_level=(i == 0),
parent_text=f"parent tag:0",
parent_domain=None if i == 0 else "infrastructure/power",
)
for i in range(3)
]
# Each iterative call returns a single fresh node anchored to the most
# recent frontier β€” never stops until the safety net kicks in.
# Each iterative call returns one fresh node anchored to the previous
# frontier (E1 for the first iteration, then L1, L2, ...). With
# max_layers=3 the safety net must trip even though the LLM never stops.
iterative_responses = [
_iterative_payload(
[
_node(
f"L{i}",
parent_ids=[f"L{i-1}"] if i > 1 else ["E1"],
time=6 + i,
)
]
)
for i in range(1, 12)
]
llm = TwoPromptLLM(
initial=[_initial_payload([_node("E1", description="tag:0", time=6)])],
iterative=iterative_responses,
)
_config, predictor = setup_predictor(edges, llm, max_layers=3)
result = _predict(predictor)
stop_reasons = [t.get("stop_reason") for t in result.trace]
assert any(r and r.startswith("safety:max_layers") for r in stop_reasons)
# ----------------------------------------------------------------------
# 5. Safety net: max_total_nodes
# ----------------------------------------------------------------------
def test_safety_max_total_nodes_stops_bloated_bfs(setup_predictor):
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="parent tag:0",
parent_domain="infrastructure/power",
),
]
# Two iterative layers each emit 8 nodes; with max_total_nodes=10 the
# third iteration must be blocked by the safety net.
layer1 = [_node(f"E{i}", description="tag:0", time=6) for i in range(8)]
layer2 = [_node(f"F{i}", parent_ids=["E0"], time=12) for i in range(8)]
layer3 = [_node(f"G{i}", parent_ids=["F0"], time=18) for i in range(8)]
llm = TwoPromptLLM(
initial=[_initial_payload(layer1)],
iterative=[_iterative_payload(layer2), _iterative_payload(layer3)],
)
_config, predictor = setup_predictor(edges, llm, max_total_nodes=10)
result = _predict(predictor)
stop_reasons = [t.get("stop_reason") for t in result.trace]
assert any(r and r.startswith("safety:max_total_nodes") for r in stop_reasons)
assert len(result.predicted_chain.cascade_events) >= 10
# ----------------------------------------------------------------------
# 6. parent_ids anchor validation
# ----------------------------------------------------------------------
def test_validate_parent_anchors_drops_orphans_and_keeps_valid(setup_predictor, caplog):
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="parent tag:0",
parent_domain="infrastructure/power",
),
]
llm = TwoPromptLLM(
initial=[_initial_payload([_node("E1", description="tag:0", time=6)])],
iterative=[
_iterative_payload(
[
_node("E2", parent_ids=["E1"], time=12),
_node("E99", parent_ids=["X999"], time=12), # orphan
]
),
# Second iteration to exercise the loop further; saturate so we
# don't depend on a third response.
_iterative_payload([], stop_reason="saturation"),
],
)
with caplog.at_level(logging.WARNING):
_config, predictor = setup_predictor(edges, llm)
result = _predict(predictor)
ids = [n.id for n in result.predicted_chain.cascade_events]
assert "E2" in ids
assert "E99" not in ids
assert any("unknown_parent" in rec.message for rec in caplog.records)
# ----------------------------------------------------------------------
# 7. Flat-DAG warning on accumulated DAG
# ----------------------------------------------------------------------
def test_flat_dag_warning_when_no_inter_node_edges(setup_predictor, caplog):
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
]
# Layer 0 emits 3 sibling nodes, all parent_ids=[] (correct for layer 0).
# Iterative call returns immediate saturation so DAG stays "flat" with
# zero inter-node edges β€” that is the v0.1 star-DAG failure mode.
llm = TwoPromptLLM(
initial=[
_initial_payload(
[_node("E1"), _node("E2"), _node("E3")]
)
],
iterative=[_iterative_payload([], stop_reason="saturation")],
)
with caplog.at_level(logging.WARNING):
_config, predictor = setup_predictor(edges, llm)
result = _predict(predictor)
assert len(result.predicted_chain.cascade_events) == 3
assert all(not n.parent_ids for n in result.predicted_chain.cascade_events)
assert any("Flat DAG" in rec.message for rec in caplog.records)
# ----------------------------------------------------------------------
# 8. predict_stream chunk shape contract
# ----------------------------------------------------------------------
def test_predict_stream_emits_per_layer_then_final_chunk(setup_predictor):
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="node E1 tag:0",
parent_domain="infrastructure/power",
),
]
llm = TwoPromptLLM(
initial=[
_initial_payload(
[_node("E1", description="node E1 tag:0", time=6)]
)
],
iterative=[_iterative_payload([], stop_reason="saturation")],
)
_config, predictor = setup_predictor(edges, llm)
chunks = list(
predictor.predict_stream(
country="Slovenia",
iso="SVN",
location="Ljubljana",
event_date="2025-05-01",
severity="medium",
description="Heavy floods in Ljubljana basin tag:0",
)
)
# At least one per-layer chunk + exactly one final chunk.
assert len(chunks) >= 2
final = chunks[-1]
assert final["is_final"] is True
assert isinstance(final["result"], PredictionResult)
# Per-layer chunks share the contract documented on predict_stream.
expected_keys = {
"layer", "trace_record", "produced", "evidence_ids",
"stop_reason", "partial_dag", "is_final",
}
for chunk in chunks[:-1]:
assert chunk["is_final"] is False
assert expected_keys.issubset(chunk.keys())
assert isinstance(chunk["produced"], list)
assert isinstance(chunk["evidence_ids"], list)
assert isinstance(chunk["partial_dag"], list)
# trace_record is the same dict appended to result.trace
assert chunk["trace_record"]["layer"] == chunk["layer"]
# The accumulated DAG in the final chunk's result mirrors the last
# per-layer chunk's partial_dag β€” predict() returns this same object.
assert [n.id for n in final["result"].predicted_chain.cascade_events] == [
n.id for n in chunks[-2]["partial_dag"]
]
assert final["result"].predicted_chain.cascade_events[0].id == "E1"
# ----------------------------------------------------------------------
# 9. dump_full_trace flag β€” issue #12 diagnostic capture
# ----------------------------------------------------------------------
def test_dump_full_trace_flag_attaches_edges_and_llm_call(setup_predictor):
"""Flag on β†’ trace records carry retrieved_edges + llm_call.
Flag off (default) β†’ neither key is present."""
edges = [
_edge(
"EV1::TRIGGER->E1",
source_event_id="2023-EV1-ITA",
is_first_level=True,
parent_text="trigger tag:0",
child_description="downstream cascade child",
),
_edge(
"EV1::E1->E2",
source_event_id="2023-EV1-ITA",
is_first_level=False,
parent_text="node E1 tag:0",
parent_domain="infrastructure/power",
),
]
# ---- Flag OFF (baseline) ----
llm_off = TwoPromptLLM(
initial=[_initial_payload([_node("E1", description="node E1 tag:0", time=6)])],
iterative=[_iterative_payload([], stop_reason="saturation")],
)
_cfg_off, predictor_off = setup_predictor(edges, llm_off)
assert predictor_off.dump_full_trace is False
result_off = _predict(predictor_off)
for record in result_off.trace:
assert "retrieved_edges" not in record
assert "llm_call" not in record
# ---- Flag ON (diagnostic) ----
llm_on = TwoPromptLLM(
initial=[_initial_payload([_node("E1", description="node E1 tag:0", time=6)])],
iterative=[_iterative_payload([], stop_reason="saturation")],
)
_cfg_on, predictor_on = setup_predictor(edges, llm_on)
predictor_on.dump_full_trace = True
result_on = _predict(predictor_on)
layer0 = next(t for t in result_on.trace if t["layer"] == 0)
assert "retrieved_edges" in layer0
assert "llm_call" in layer0
# Layer-0 retrieval is keyed by 'trigger' (the seed query bucket).
assert "trigger" in layer0["retrieved_edges"]
seed_payload = layer0["retrieved_edges"]["trigger"]
assert seed_payload, "expected at least one retrieved seed edge"
first_edge = seed_payload[0]
# Serialized edge round-trips through Pydantic; identity-bearing fields
# must survive into the dump.
assert first_edge["edge"]["edge_id"] == "EV1::TRIGGER->E1"
assert "similarity" in first_edge
# Layer-0 ran the predict_initial LLM call β†’ llm_call is populated.
assert layer0["llm_call"] is not None
assert layer0["llm_call"]["prompt_template_path"] == "prompts/predict_initial.txt"
assert "raw_response" in layer0["llm_call"]
assert "variables" in layer0["llm_call"]
# Variables must include the seed_edges injected by _format_seed_edges.
assert "seed_edges" in layer0["llm_call"]["variables"]