cascade_risk / scripts /v05_d6_audit.py
Lucasoppem's picture
Sync from GitHub main (part 2)
36f9d47 verified
Raw
History Blame Contribute Delete
13.1 kB
"""v0.5 issue B+C — D6 hallucination re-audit (issue #70 Task 10).
Adapted from scripts/v04_retrieval_audit.py. Measures whether the v0.5
predictor (with structural filter from Task 2/3, templating disabled per
Task 9 B-only decision) hallucinates specific numbers / place names that
aren't grounded in either:
(a) the new event's description / location / country (legitimate context),
(b) any retrieved evidence's parent_text or child_description as recorded
in the per-event BFS trace (legitimate historical anchor).
A predicted node is flagged "hallucination" if its description contains at
least one specific number or known place name that fails BOTH (a) and (b)
groundedness checks.
Note: the BFS trace is not seed-specific (temperature=0 → deterministic
prediction, all three seeds produce identical node lists and trace paths).
The per-seed GoldEvaluation caches are processed individually to confirm
this, but hallucination counts should be identical across seeds.
Reads:
- data/evaluation/gold/{event_id}_seed{seed}.json (GoldEvaluation caches)
- data/evaluation/diagnostics/{event_id}_bfs_full.json (BFS trace)
- data/splits/test_events.json (FloodEvent descriptions for check (a))
- knowledge/place_names.txt (vocabulary for LOC detection)
Writes:
- /tmp/v05_d6_audit.csv (per-node detail)
- Stdout summary (pipe to tee /tmp/v05_d6_audit.log)
Usage:
PYTHONPATH=. python scripts/v05_d6_audit.py | tee /tmp/v05_d6_audit.log
"""
from __future__ import annotations
import csv
import json
import re
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EVAL_DIR = ROOT / "data/evaluation/gold"
TRACE_DIR = ROOT / "data/evaluation/diagnostics"
TEST_EVENTS_PATH = ROOT / "data/splits/test_events.json"
OUT_CSV = Path("/tmp/v05_d6_audit.csv")
_OUTLIER_EVENT_IDS = {"2025-0632-ROU"}
# Same number regex as evidence_templating._NUMBER_RE — kept inline so the
# audit does NOT accidentally import a changed definition from the module.
_NUMBER_RE = re.compile(r"\b\d[\d,.]*[KkMm]?(?=[^0-9KkMm.,]|$)")
# ---------------------------------------------------------------------------
# Place-name loading (mirrors evidence_templating.load_place_names)
# ---------------------------------------------------------------------------
def load_place_names(path: str | Path = "knowledge/place_names.txt") -> set[str]:
"""Read lowercase place-name set; tokens < 3 chars excluded."""
p = Path(path)
if not p.exists():
return set()
return {
line.strip().lower()
for line in p.read_text(encoding="utf-8").splitlines()
if line.strip() and len(line.strip()) >= 3
}
# ---------------------------------------------------------------------------
# Token extraction helpers
# ---------------------------------------------------------------------------
def _extract_numbers(text: str) -> set[str]:
"""Return all distinct number tokens in text."""
if not text:
return set()
return {m.group(0) for m in _NUMBER_RE.finditer(text)}
def _extract_places(text: str, place_names: set[str]) -> set[str]:
"""Return all place-name tokens (lowercase) found in text."""
if not text:
return set()
found: set[str] = set()
text_lower = text.lower()
for p in place_names:
if re.search(r"\b" + re.escape(p) + r"\b", text_lower):
found.add(p)
return found
# ---------------------------------------------------------------------------
# Hallucination predicate
# ---------------------------------------------------------------------------
def _is_hallucination(
pred_description: str,
grounded_text: str,
place_names: set[str],
) -> tuple[bool, int, int, int]:
"""Return (is_halluc, ungrounded_num_count, ungrounded_place_count, grounded_match_count).
A node is a hallucination if it contains at least one number or place name
that does not appear in the grounded_text corpus.
"""
pred_nums = _extract_numbers(pred_description)
pred_places = _extract_places(pred_description, place_names)
grounded_nums = _extract_numbers(grounded_text)
grounded_places = _extract_places(grounded_text, place_names)
ungrounded_nums = pred_nums - grounded_nums
ungrounded_places = pred_places - grounded_places
grounded_match = len(pred_nums & grounded_nums) + len(pred_places & grounded_places)
is_halluc = bool(ungrounded_nums) or bool(ungrounded_places)
return is_halluc, len(ungrounded_nums), len(ungrounded_places), grounded_match
# ---------------------------------------------------------------------------
# BFS trace retrieval-text extraction
# ---------------------------------------------------------------------------
def _trace_path_for(event_id: str) -> Path | None:
"""Resolve the BFS trace path for an event, preferring multi-seed dumps.
Multi-seed eval writes one trace per seed (filename suffix
`_seed{seed}_bfs_full.json`); single-seed legacy eval writes a single
seedless trace (`_bfs_full.json`). Since BFS is deterministic at
temperature=0, any of the per-seed traces is fine — pick the first
sorted seed for stability. Return None if no trace exists.
"""
seed_traces = sorted(TRACE_DIR.glob(f"{event_id}_seed*_bfs_full.json"))
if seed_traces:
return seed_traces[0]
legacy = TRACE_DIR / f"{event_id}_bfs_full.json"
return legacy if legacy.exists() else None
def _build_retrieval_corpus(event_id: str) -> str:
"""Collect all parent_text + child_description strings from the BFS trace
for an event. Returns a single concatenated string for substring matching.
Falls back to empty string if the trace file is missing.
"""
trace_path = _trace_path_for(event_id)
if trace_path is None:
return ""
try:
trace_data = json.loads(trace_path.read_text())
except Exception:
return ""
parts: list[str] = []
for layer in trace_data.get("trace", []):
edges_block = layer.get("retrieved_edges", {})
# edges_block is dict[frontier_id, list[edge_record]]
if isinstance(edges_block, dict):
edge_records = [e for edges in edges_block.values() for e in (edges or [])]
else:
edge_records = list(edges_block or [])
for rec in edge_records:
# The document field contains the BFS query text (parent-side)
if rec.get("document"):
parts.append(rec["document"])
# The edge sub-object holds the raw parent_text + child_description
edge_obj = rec.get("edge") or {}
if edge_obj.get("parent_text"):
parts.append(edge_obj["parent_text"])
if edge_obj.get("child_description"):
parts.append(edge_obj["child_description"])
return " ".join(parts)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
place_names = load_place_names(ROOT / "knowledge/place_names.txt")
print(f"Loaded {len(place_names)} place names from knowledge/place_names.txt")
# Build event_id → FloodEvent fields map for check (a)
test_events_raw: list[dict] = json.loads(TEST_EVENTS_PATH.read_text())
test_events: dict[str, dict] = {e["event_id"]: e for e in test_events_raw}
# Walk eval cache files
files = sorted(EVAL_DIR.glob("*_seed*.json"))
print(f"Found {len(files)} eval cache files")
# Staleness guard: trace older than ANY of its event's caches means the
# last eval run forgot --dump-bfs-full. Abort rather than silently audit
# against stale evidence (the grounding corpus would not match the
# prediction the audit is judging).
stale: list[tuple[str, float, float]] = []
missing: list[str] = []
seen_events: set[str] = set()
for f in files:
stem = f.stem
if "_seed" not in stem:
continue
event_id = stem.rsplit("_seed", 1)[0]
if event_id in _OUTLIER_EVENT_IDS or event_id in seen_events:
continue
seen_events.add(event_id)
trace_path = _trace_path_for(event_id)
if trace_path is None:
missing.append(event_id)
continue
cache_mtime = f.stat().st_mtime
trace_mtime = trace_path.stat().st_mtime
if trace_mtime < cache_mtime:
stale.append((event_id, trace_mtime, cache_mtime))
if stale or missing:
print("ERROR: D6 audit cannot run — BFS trace inputs are stale or missing.")
for event_id, tm, cm in stale:
print(f" STALE {event_id}: trace {tm:.0f} < cache {cm:.0f}")
for event_id in missing:
print(f" MISSING {event_id}: no trace file")
print()
print("Rerun the eval with --dump-bfs-full to refresh traces:")
print(" PYTHONPATH=. python scripts/05_evaluate.py --force --dump-bfs-full")
raise SystemExit(1)
# retrieval corpus is per-event (not per-seed — deterministic prediction)
retrieval_corpus_cache: dict[str, str] = {}
# per_event[event_id] = list of (is_halluc, num_count, place_count, seed)
per_event: dict[str, list[tuple[bool, int, int, int]]] = defaultdict(list)
csv_rows: list[dict] = []
for f in files:
stem = f.stem
if "_seed" not in stem:
continue
event_id, seed_part = stem.rsplit("_seed", 1)
if event_id in _OUTLIER_EVENT_IDS:
continue
try:
seed = int(seed_part)
except ValueError:
continue
try:
gold_data = json.loads(f.read_text())
except Exception as exc:
print(f" skip {f.name}: {exc}")
continue
event = test_events.get(event_id)
if event is None:
print(f" skip {f.name}: event not in test_events.json")
continue
# ---- Grounding corpus (a): new event context ----
event_context = " ".join(filter(None, [
event.get("description") or "",
event.get("location") or "",
event.get("country") or "",
]))
# ---- Grounding corpus (b): retrieved evidence texts ----
if event_id not in retrieval_corpus_cache:
retrieval_corpus_cache[event_id] = _build_retrieval_corpus(event_id)
retrieval_corpus = retrieval_corpus_cache[event_id]
# Combined grounded text
grounded_text = event_context + " " + retrieval_corpus
# ---- Walk predicted nodes ----
predicted_chain = gold_data.get("predicted_chain", {})
cascade_events = predicted_chain.get("cascade_events", [])
for node in cascade_events:
desc = node.get("description") or ""
is_halluc, n_nums, n_places, n_grounded = _is_hallucination(
desc, grounded_text, place_names,
)
per_event[event_id].append((is_halluc, n_nums, n_places, seed))
csv_rows.append({
"event_id": event_id,
"seed": seed,
"node_id": node.get("id", ""),
"domain": node.get("domain", ""),
"is_hallucination": int(is_halluc),
"ungrounded_nums": n_nums,
"ungrounded_places": n_places,
"grounded_match": n_grounded,
"description": desc[:200],
})
# ---- Write CSV ----
if csv_rows:
with OUT_CSV.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=list(csv_rows[0].keys()))
writer.writeheader()
writer.writerows(csv_rows)
print(f"\nWrote {len(csv_rows)} per-node rows to {OUT_CSV}")
else:
print("\nNo CSV rows to write.")
# ---- Per-event aggregate ----
print("\n=== Per-event hallucination rate ===")
header = f"{'event_id':<22} {'pred_nodes (×seeds)':>20} {'halluc_nodes':>13} {'rate':>7}"
print(header)
print("-" * len(header))
rates: list[float] = []
for eid in sorted(per_event):
rows = per_event[eid]
total = len(rows)
halluc = sum(1 for r in rows if r[0])
rate = halluc / total if total else 0.0
rates.append(rate)
print(f"{eid:<22} {total:>20} {halluc:>13} {rate:>7.3f}")
print("\n=== Macro aggregate ===")
if rates:
macro_rate = sum(rates) / len(rates)
print(f"Macro hallucination rate: {macro_rate:.3f} (across {len(rates)} events)")
print(f"v0.4 D6 baseline: 0.795")
delta = macro_rate - 0.795
delta_str = f"{delta:+.3f}"
print(f"Delta vs v0.4: {delta_str}")
print()
if macro_rate < 0.50:
print("Target < 0.50: MET")
else:
print("Target < 0.50: NOT MET (templating is OFF; filter-only improvement modest)")
else:
print("No events to aggregate.")
if __name__ == "__main__":
main()