Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """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", | |
| }, | |
| } | |
| 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"] | |