| |
| """ |
| QA4PC ablation: does the formal model layer improve yes/no/maybe accuracy? |
| |
| Conditions |
| ---------- |
| direct policy + scenario + question → LLM → yes/no/maybe |
| formal policy → SpecificationAnalyzerAgent blueprint → |
| blueprint + policy + scenario → LLM → yes/no/maybe |
| |
| The 133 unique policy trees are processed once and their blueprints cached on |
| disk (``artifacts/qa4pc_cache/blueprints/``), so re-runs with different sample |
| sizes don't re-hit the API. |
| |
| Usage |
| ----- |
| python scripts/eval/eval_qa4pc_ablation.py |
| python scripts/eval/eval_qa4pc_ablation.py --n 50 --seed 99 |
| python scripts/eval/eval_qa4pc_ablation.py --build-cache-only |
| python scripts/eval/eval_qa4pc_ablation.py --conditions direct # skip formal |
| python scripts/eval/eval_qa4pc_ablation.py --json-out artifacts/qa4pc_ablation.json |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import re |
| import sys |
| import time |
| from collections import Counter |
| from pathlib import Path |
| from typing import Any |
|
|
| ROOT = Path(__file__).resolve().parents[2] |
| sys.path.insert(0, str(ROOT / "src")) |
|
|
| from dotenv import load_dotenv |
| load_dotenv(ROOT / ".env", override=True) |
|
|
| os.environ.setdefault("LLM_BACKEND_FALLBACK", "openai") |
| os.environ.setdefault("RAG_FALLBACK_MODEL_NAME", "gpt-4o-mini") |
|
|
| from frame.rag_component.llm import LLM |
| from frame.timed_automata.nl2formalmodel.specification_analyzer import SpecificationAnalyzerAgent |
|
|
| |
| |
| |
|
|
| CACHE_DIR = ROOT / "artifacts" / "qa4pc_cache" / "blueprints" |
|
|
|
|
| |
| |
| |
|
|
| def load_qa4pc_test() -> list[dict]: |
| """Download QA4PC test_entailment split and return as list of dicts.""" |
| try: |
| from huggingface_hub import hf_hub_download |
| except ImportError: |
| print("[error] huggingface_hub not installed; run: pip install huggingface_hub") |
| sys.exit(1) |
|
|
| path = hf_hub_download( |
| "Marzipan/QA4PC", |
| "test_entailment_qa4pc.json", |
| repo_type="dataset", |
| ) |
| with open(path, encoding="utf-8") as f: |
| return json.load(f) |
|
|
|
|
| def sample_items(items: list[dict], n: int, seed: int) -> list[dict]: |
| import random |
| rng = random.Random(seed) |
| if n >= len(items): |
| return list(items) |
| return rng.sample(items, n) |
|
|
|
|
| |
| |
| |
|
|
| _ANSWER_SYSTEM = ( |
| "You are a policy compliance evaluator. " |
| "Given a policy excerpt, a user scenario, and a question, " |
| "decide whether the answer is **yes**, **no**, or **maybe** " |
| "(maybe = cannot be determined from the policy alone). " |
| "Reply with a single word: yes, no, or maybe. No explanation." |
| ) |
|
|
| _DIRECT_TEMPLATE = """\ |
| ## Policy |
| {policy} |
| |
| ## User scenario |
| {scenario} |
| |
| ## Question |
| {question} |
| |
| Reply with exactly one word: yes, no, or maybe.""" |
|
|
| _FORMAL_TEMPLATE = """\ |
| ## Policy |
| {policy} |
| |
| ## Formal model of the policy (structured blueprint) |
| {blueprint} |
| |
| ## User scenario |
| {scenario} |
| |
| ## Question |
| {question} |
| |
| Reply with exactly one word: yes, no, or maybe.""" |
|
|
|
|
| def parse_ynm(raw: str) -> str | None: |
| """Extract yes / no / maybe from LLM output.""" |
| s = (raw or "").strip().lower() |
| for word in re.split(r"[\s.,;:!?]+", s): |
| if word in ("yes", "no", "maybe"): |
| return word |
| return None |
|
|
|
|
| |
| |
| |
|
|
| def blueprint_path(tree_id: str) -> Path: |
| return CACHE_DIR / f"{tree_id}.json" |
|
|
|
|
| def load_blueprint_cache(tree_id: str) -> dict | None: |
| p = blueprint_path(tree_id) |
| if p.exists(): |
| with open(p, encoding="utf-8") as f: |
| return json.load(f) |
| return None |
|
|
|
|
| def save_blueprint_cache(tree_id: str, data: dict) -> None: |
| CACHE_DIR.mkdir(parents=True, exist_ok=True) |
| with open(blueprint_path(tree_id), "w", encoding="utf-8") as f: |
| json.dump(data, f, ensure_ascii=False, indent=2) |
|
|
|
|
| def build_blueprints( |
| items: list[dict], |
| *, |
| model_name: str, |
| sleep_s: float, |
| verbose: bool, |
| ) -> dict[str, dict]: |
| """Build (or load from cache) the formal blueprint for every unique tree.""" |
| trees: dict[str, str] = {} |
| for item in items: |
| tid = item["tree_id"] |
| if tid not in trees: |
| trees[tid] = item["policy"] |
|
|
| analyzer = SpecificationAnalyzerAgent(model_name=model_name) |
| blueprints: dict[str, dict] = {} |
|
|
| for i, (tid, policy) in enumerate(trees.items()): |
| cached = load_blueprint_cache(tid) |
| if cached is not None: |
| blueprints[tid] = cached |
| if verbose: |
| print(f" [cache] {tid[:12]}…") |
| continue |
|
|
| if verbose: |
| print(f" [build {i+1}/{len(trees)}] {tid[:12]}…") |
|
|
| try: |
| result = analyzer.analyze(policy) |
| data = { |
| "prose": result.prose, |
| "blueprint": result.blueprint, |
| } |
| except Exception as exc: |
| print(f" [warn] Analyzer failed for {tid[:12]}: {exc}") |
| data = {"prose": "", "blueprint": {}} |
|
|
| save_blueprint_cache(tid, data) |
| blueprints[tid] = data |
|
|
| if sleep_s > 0: |
| time.sleep(sleep_s) |
|
|
| return blueprints |
|
|
|
|
| def _fmt_blueprint(bp: dict) -> str: |
| """Compact text representation of the §8 blueprint JSON.""" |
| if not bp: |
| return "(no structured blueprint available)" |
| return json.dumps(bp, ensure_ascii=False, indent=2) |
|
|
|
|
| |
| |
| |
|
|
| def run_condition( |
| items: list[dict], |
| *, |
| condition: str, |
| blueprints: dict[str, dict], |
| model_name: str, |
| sleep_s: float, |
| verbose: bool, |
| ) -> list[dict]: |
| llm = LLM(_ANSWER_SYSTEM, model_name=model_name) |
| results: list[dict] = [] |
|
|
| for idx, item in enumerate(items): |
| policy = item["policy"] |
| scenario = item["scenario"] |
| question = item["question"] |
| gt = item["answer"] |
|
|
| if condition == "direct": |
| prompt = _DIRECT_TEMPLATE.format( |
| policy=policy, |
| scenario=scenario, |
| question=question, |
| ) |
| else: |
| bp_data = blueprints.get(item["tree_id"], {}) |
| bp_json = _fmt_blueprint(bp_data.get("blueprint", {})) |
| prompt = _FORMAL_TEMPLATE.format( |
| policy=policy, |
| blueprint=bp_json, |
| scenario=scenario, |
| question=question, |
| ) |
|
|
| raw = llm.generate(user_prompt=prompt) |
| pred = parse_ynm(raw) |
| correct = (pred == gt) if pred else False |
|
|
| row = { |
| "tree_id": item["tree_id"], |
| "utterance_id": item.get("utterance_id", ""), |
| "gt": gt, |
| "pred": pred or "?", |
| "raw": raw.strip()[:120], |
| "correct": correct, |
| } |
| results.append(row) |
|
|
| if verbose: |
| mark = "✓" if correct else "✗" |
| print(f" [{idx+1:3d}/{len(items)}] {mark} gt={gt:<5} pred={pred}") |
|
|
| if sleep_s > 0: |
| time.sleep(sleep_s) |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def compute_metrics(results: list[dict]) -> dict: |
| total = len(results) |
| if total == 0: |
| return {} |
| correct = sum(1 for r in results if r["correct"]) |
| acc = correct / total |
|
|
| |
| by_gt: dict[str, list[bool]] = {} |
| for r in results: |
| gt = r["gt"] |
| by_gt.setdefault(gt, []).append(r["correct"]) |
| per_label = {lbl: sum(hits) / len(hits) for lbl, hits in by_gt.items()} |
|
|
| |
| gt_dist = Counter(r["gt"] for r in results) |
| pred_dist = Counter(r["pred"] for r in results) |
|
|
| return { |
| "n": total, |
| "accuracy": round(acc, 4), |
| "correct": correct, |
| "per_label_accuracy": {k: round(v, 4) for k, v in sorted(per_label.items())}, |
| "gt_distribution": dict(gt_dist), |
| "pred_distribution": dict(pred_dist), |
| } |
|
|
|
|
| def print_report(condition: str, metrics: dict) -> None: |
| print(f"\n{'─'*50}") |
| print(f"Condition: {condition.upper()}") |
| print(f" n={metrics['n']} accuracy={metrics['accuracy']:.1%} correct={metrics['correct']}") |
| print(f" Per-label: {metrics['per_label_accuracy']}") |
| print(f" GT dist: {metrics['gt_distribution']}") |
| print(f" Pred dist: {metrics['pred_distribution']}") |
|
|
|
|
| |
| |
| |
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser(description="QA4PC ablation: direct vs. formal layer") |
| ap.add_argument("--n", type=int, default=100, help="items to evaluate (default: 100)") |
| ap.add_argument("--seed", type=int, default=42) |
| ap.add_argument( |
| "--conditions", |
| nargs="+", |
| choices=["direct", "formal"], |
| default=["direct", "formal"], |
| ) |
| ap.add_argument("--model", default="gpt-4o-mini", help="LLM model for answering (default: gpt-4o-mini)") |
| ap.add_argument("--analyzer-model", default="gpt-4.1", help="LLM model for Analyzer agent") |
| ap.add_argument("--sleep", type=float, default=0.5, help="seconds between API calls") |
| ap.add_argument("--build-cache-only", action="store_true", help="only build blueprint cache, no eval") |
| ap.add_argument("--json-out", type=Path, default=None) |
| ap.add_argument("-v", "--verbose", action="store_true") |
| args = ap.parse_args() |
|
|
| print("Loading QA4PC test_entailment split…") |
| all_items = load_qa4pc_test() |
| print(f" Loaded {len(all_items)} items ({len({x['tree_id'] for x in all_items})} unique trees)") |
|
|
| items = sample_items(all_items, args.n, args.seed) |
| print(f" Sampled {len(items)} items (seed={args.seed})") |
|
|
| blueprints: dict[str, dict] = {} |
| if "formal" in args.conditions or args.build_cache_only: |
| print(f"\nBuilding/loading formal blueprints (model={args.analyzer_model})…") |
| blueprints = build_blueprints( |
| items, |
| model_name=args.analyzer_model, |
| sleep_s=args.sleep, |
| verbose=args.verbose, |
| ) |
| cached_count = sum( |
| 1 for item in items |
| if blueprint_path(item["tree_id"]).exists() |
| ) |
| print(f" Done — {cached_count}/{len({x['tree_id'] for x in items})} trees cached") |
|
|
| if args.build_cache_only: |
| print("--build-cache-only: stopping after cache build.") |
| return |
|
|
| all_results: dict[str, Any] = {"conditions": {}} |
|
|
| for cond in args.conditions: |
| print(f"\nRunning condition: {cond.upper()} (model={args.model})…") |
| results = run_condition( |
| items, |
| condition=cond, |
| blueprints=blueprints, |
| model_name=args.model, |
| sleep_s=args.sleep, |
| verbose=args.verbose, |
| ) |
| metrics = compute_metrics(results) |
| all_results["conditions"][cond] = {"metrics": metrics, "rows": results} |
| print_report(cond, metrics) |
|
|
| |
| if len(args.conditions) > 1: |
| print(f"\n{'═'*50}") |
| print("Summary") |
| for cond in args.conditions: |
| m = all_results["conditions"][cond]["metrics"] |
| print(f" {cond:<8} acc={m['accuracy']:.1%} ({m['correct']}/{m['n']})") |
| conds = args.conditions |
| if len(conds) == 2: |
| a0 = all_results["conditions"][conds[0]]["metrics"]["accuracy"] |
| a1 = all_results["conditions"][conds[1]]["metrics"]["accuracy"] |
| delta = a1 - a0 |
| print(f"\n Δ ({conds[1]} − {conds[0]}) = {delta:+.1%}") |
|
|
| if args.json_out: |
| args.json_out.parent.mkdir(parents=True, exist_ok=True) |
| all_results["config"] = vars(args) |
| with open(args.json_out, "w", encoding="utf-8") as f: |
| json.dump(all_results, f, ensure_ascii=False, indent=2, default=str) |
| print(f"\nResults saved → {args.json_out}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|