File size: 10,749 Bytes
12f3701
 
 
 
 
d07f416
 
 
 
12f3701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e35b73e
 
 
12f3701
 
e35b73e
12f3701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07f416
12f3701
d07f416
12f3701
 
 
 
 
 
 
 
d07f416
12f3701
 
 
d07f416
 
 
12f3701
d07f416
 
 
 
12f3701
d07f416
12f3701
 
 
 
 
 
d07f416
12f3701
d07f416
 
12f3701
 
 
d07f416
 
 
12f3701
 
 
 
d07f416
12f3701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07f416
12f3701
d07f416
12f3701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
#!/usr/bin/env python3
"""
Evaluate baseline ``trans_query.txt`` outputs against ``datasets/query_translation_eval.csv``.

Metrics (per row):
  - exact_match: normalized equality vs **reference-XML-adapted** gold (same identifier space
    as ``artifacts/baselines/claude/*.xml`` and verifyta on those models)
  - exact_match_dataset_csv: same vs raw ``ground_query`` from CSV
  - token_precision / recall / f1: vs adapted gold; ``*_dataset_csv`` vs CSV gold
  - semantic_correct (optional --verifyta): ``verdict(pred) == verdict(gold)`` on the same XML
  - csv_answer_agree (optional): whether ``verdict(pred)`` matches CSV ``answer`` (T/F)

Gold model layout:
  Pass ``--gold-models-dir D`` so that ``D/1.xml`` ... ``D/10.xml`` exist (``spec_id`` in CSV).

Usage:
  python scripts/eval_baseline_trans_query.py \\
    --csv datasets/query_translation_eval.csv \\
    --pred artifacts/baselines/gpt/trans_query.txt \\
    --gold-models-dir artifacts/_gold_modelbuild_runs_20260501_182558

  python scripts/eval_baseline_trans_query.py ... --verifyta --json-out artifacts/eval_gpt.json
"""

from __future__ import annotations

import argparse
import csv
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

from frame.evaluation.natural2ctl_query_normalize import normalize_natural2ctl_gold_query
from frame.evaluation.reference_xml_gold_adapt import adapt_csv_gold_to_reference_xml
from frame.pipeline.nl_uppaal_query import parse_verifyta_text_verdict

try:
    from frame.pipeline.model_checking_pipeline import ModelCheckingPipeline
except ImportError:
    ModelCheckingPipeline = None  # type: ignore[misc, assignment]


def normalize_query(s: str) -> str:
    return normalize_natural2ctl_gold_query(re.sub(r"\s+", " ", s.strip()))


_TOK_RE = re.compile(
    r"""
    (A\s*\[\]|E\s*<>|A\s*<>|E\s*\[\])
    |(-->)
    |(&&|\|\||<=|>=|==|!=)
    |([(){}\[\],])
    |(\bforall\b|\bexists\b|\bimply\b|\bnot\b|\bdeadlock\b)
    |([A-Za-z_]\w*)
    |(\d+)
    """,
    re.VERBOSE | re.IGNORECASE,
)


def token_f1(pred: str, gold: str) -> tuple[float, float, float]:
    p = normalize_query(pred)
    g = normalize_query(gold)
    pt = [t for t in _TOK_RE.findall(p) for t in t if t]
    gt = [t for t in _TOK_RE.findall(g) for t in t if t]
    pc = Counter(pt)
    gc = Counter(gt)
    inter = sum((pc & gc).values())
    p_tot = sum(pc.values())
    g_tot = sum(gc.values())
    if p_tot == 0 and g_tot == 0:
        return 1.0, 1.0, 1.0
    if p_tot == 0 or g_tot == 0:
        return 0.0, 0.0, 0.0
    prec = inter / p_tot
    rec = inter / g_tot
    if prec + rec == 0:
        return 0.0, 0.0, 0.0
    return prec, rec, 2 * prec * rec / (prec + rec)


def _strip_inline_comment(line: str) -> str:
    if "//" in line:
        line = line.split("//", 1)[0]
    return line.strip()


def _strip_backticks(s: str) -> str:
    s = s.strip()
    if s.startswith("`") and s.endswith("`") and len(s) >= 2:
        return s[1:-1].strip()
    return s


def parse_trans_query_file(path: Path) -> list[str]:
    """
    Parse baseline text: numbered lines ``1. ...`` / ``2) ...``, optional ``Spec N`` headers,
    or one formula per non-empty line (Grok-style).
    """
    text = path.read_text(encoding="utf-8", errors="replace")
    raw_lines = text.splitlines()
    lines: list[str] = []
    for ln in raw_lines:
        ln = _strip_inline_comment(ln).strip()
        if not ln or re.match(r"^Spec\s+\d+\s*$", ln, re.IGNORECASE):
            continue
        lines.append(ln)

    numbered: list[tuple[int, str]] = []
    for ln in lines:
        m = re.match(r"^\s*(\d+)\s*[\.)]\s*(.+)$", ln)
        if m:
            numbered.append((int(m.group(1)), _strip_backticks(m.group(2))))

    # Prefer numbered format when it dominates (GPT / Claude exports).
    if len(numbered) >= max(3, len(lines) // 2):
        by_id: dict[int, str] = {}
        for idx, q in numbered:
            by_id[idx] = q
        order = sorted(by_id.keys())
        return [by_id[i] for i in order]

    out: list[str] = []
    for ln in lines:
        m = re.match(r"^\s*(\d+)\s*[\.)]\s*(.+)$", ln)
        if m:
            out.append(_strip_backticks(m.group(2)))
        else:
            out.append(_strip_backticks(ln))
    return out


def _csv_answer_to_verdict(ans: str) -> str | None:
    t = (ans or "").strip().upper()
    if t == "T":
        return "satisfied"
    if t == "F":
        return "not_satisfied"
    return None


def load_eval_csv(path: Path) -> list[dict[str, str]]:
    rows: list[dict[str, str]] = []
    with path.open(encoding="utf-8", newline="") as f:
        r = csv.DictReader(f)
        for row in r:
            if not row.get("spec_id"):
                continue
            rows.append(row)
    return rows


def resolve_model_path(spec_id: str, models_dir: Path | None) -> Path | None:
    if models_dir is None:
        return None
    p = models_dir / f"{int(spec_id)}.xml"
    return p if p.is_file() else None


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--csv", type=Path, default=ROOT / "datasets" / "query_translation_eval.csv")
    ap.add_argument("--pred", type=Path, required=True, help="Baseline trans_query.txt")
    ap.add_argument(
        "--gold-models-dir",
        type=Path,
        default=None,
        help="Directory with 1.xml .. 10.xml (must match spec_id in CSV for --verifyta)",
    )
    ap.add_argument("--verifyta", action="store_true", help="Run verifyta (needs pyuppaal + verifyta)")
    ap.add_argument("--json-out", type=Path, default=None)
    args = ap.parse_args()

    rows = load_eval_csv(args.csv.resolve())
    preds = parse_trans_query_file(args.pred.resolve())

    if len(preds) != len(rows):
        print(
            f"Count mismatch: CSV has {len(rows)} rows, parsed {len(preds)} predictions from {args.pred}",
            file=sys.stderr,
        )
        return 1

    if args.verifyta and args.gold_models_dir is None:
        print("--verifyta requires --gold-models-dir", file=sys.stderr)
        return 1
    if args.verifyta and ModelCheckingPipeline is None:
        print("Could not import ModelCheckingPipeline", file=sys.stderr)
        return 1

    checkers: dict[str, Any] = {}
    if args.verifyta and args.gold_models_dir:
        for r in rows:
            sid = str(int(r["spec_id"]))
            if sid in checkers:
                continue
            xm = resolve_model_path(sid, args.gold_models_dir.resolve())
            if xm is None:
                print(f"Missing model for spec_id={sid}: {args.gold_models_dir / (sid + '.xml')}", file=sys.stderr)
                return 1
            checkers[sid] = ModelCheckingPipeline(str(xm))

    per_row: list[dict[str, Any]] = []
    tot_exact = 0
    tot_exact_csv = 0
    tot_f1 = 0.0
    tot_f1_csv = 0.0
    sem_ok = 0
    sem_att = 0
    csv_agree = 0
    csv_att = 0
    gold_parse_fail = 0

    for i, row in enumerate(rows):
        gold_q = (row.get("ground_query") or "").strip()
        gold_ref = adapt_csv_gold_to_reference_xml(gold_q)
        nl = (row.get("nl_query") or "").strip()
        spec_id = str(int(row["spec_id"]))
        pred = preds[i]
        ex_ref = normalize_query(pred) == normalize_query(gold_ref)
        ex_csv = normalize_query(pred) == normalize_query(gold_q)
        if ex_ref:
            tot_exact += 1
        if ex_csv:
            tot_exact_csv += 1
        prec, rec, f1 = token_f1(pred, gold_ref)
        prec_c, rec_c, f1_c = token_f1(pred, gold_q)
        tot_f1 += f1
        tot_f1_csv += f1_c

        rec_out: dict[str, Any] = {
            "index": i + 1,
            "spec_id": spec_id,
            "nl_query": nl,
            "ground_query": gold_q,
            "ground_query_adapted_ref_xml": gold_ref,
            "pred_query": pred,
            "exact_match": ex_ref,
            "exact_match_dataset_csv": ex_csv,
            "token_precision": prec,
            "token_recall": rec,
            "token_f1": f1,
            "token_precision_dataset_csv": prec_c,
            "token_recall_dataset_csv": rec_c,
            "token_f1_dataset_csv": f1_c,
        }

        if args.verifyta:
            ch = checkers[spec_id]
            g_res, _gt, g_err = ch.verify(gold_ref)
            p_res, _pt, p_err = ch.verify(pred)
            g_ver = parse_verifyta_text_verdict(g_res, errors=g_err)
            p_ver = parse_verifyta_text_verdict(p_res, errors=p_err)
            sem = (
                g_ver == p_ver
                and g_ver in ("satisfied", "not_satisfied")
                and p_ver in ("satisfied", "not_satisfied")
            )
            if g_ver not in ("satisfied", "not_satisfied"):
                gold_parse_fail += 1
            if g_ver in ("satisfied", "not_satisfied") and p_ver in ("satisfied", "not_satisfied"):
                sem_att += 1
                if sem:
                    sem_ok += 1

            exp = _csv_answer_to_verdict(row.get("answer") or "")
            rec_out["gold_verdict"] = g_ver
            rec_out["pred_verdict"] = p_ver
            rec_out["semantic_correct"] = sem
            rec_out["gold_errors"] = (g_err or [])[:2]
            rec_out["pred_errors"] = (p_err or [])[:2]
            if exp is not None and p_ver in ("satisfied", "not_satisfied"):
                csv_att += 1
                if p_ver == exp:
                    csv_agree += 1
                rec_out["csv_answer_agree"] = p_ver == exp

        per_row.append(rec_out)

    n = len(rows)
    summary = {
        "csv": str(args.csv),
        "pred": str(args.pred),
        "gold_models_dir": str(args.gold_models_dir) if args.gold_models_dir else None,
        "n": n,
        "exact_match_rate": tot_exact / n,
        "exact_match_rate_dataset_csv": tot_exact_csv / n,
        "mean_token_f1": tot_f1 / n,
        "mean_token_f1_dataset_csv": tot_f1_csv / n,
        "verifyta": bool(args.verifyta),
    }
    if args.verifyta:
        summary["semantic_correct_rate"] = sem_ok / sem_att if sem_att else None
        summary["semantic_compared"] = sem_att
        summary["csv_answer_agree_rate"] = csv_agree / csv_att if csv_att else None
        summary["gold_verdict_fail_count"] = gold_parse_fail

    print(json.dumps(summary, indent=2))
    if args.json_out:
        args.json_out.parent.mkdir(parents=True, exist_ok=True)
        out_obj = {"summary": summary, "rows": per_row}
        args.json_out.write_text(json.dumps(out_obj, indent=2, ensure_ascii=False), encoding="utf-8")
        print(f"Wrote {args.json_out}", file=sys.stderr)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())