Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """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() | |