603 lines
25 KiB
Python
603 lines
25 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Differential harness: run each case against tai.py and detz, compare.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
tools/parity_diff.py [--detz PATH] [--py PATH] [--filter TEXT] [--no-llm]
|
||
|
|
[--cases DIR] [--jobs N] [--list]
|
||
|
|
|
||
|
|
Every case runs twice, once per side, in its own throwaway TAI_HOME and cwd
|
||
|
|
(never ~/.tai), with the real binaries and a real vault. Compared channels:
|
||
|
|
stdout, stderr, exit code, vault state (sqlite dump of every *.db in the home),
|
||
|
|
and declared files. Volatile data is normalized ONLY by rules declared in the
|
||
|
|
case itself (each with a `why`); there is no blanket scrubbing. A rule that
|
||
|
|
matched nothing on either side is reported as unused.
|
||
|
|
|
||
|
|
LLM cases (`"llm": true`) make a real model call on both sides (tests/README.md
|
||
|
|
rule, no mocks). Their outputs are not equal-compared; both sides must each
|
||
|
|
satisfy the case's `checks`. They are probabilistic: `attempts` / `min_pass`
|
||
|
|
report a ratio, and the result carries it.
|
||
|
|
|
||
|
|
Writes build/parity/diff-results.json and diff-report.md. Exit 1 if any case
|
||
|
|
that is not marked expected_fail failed, or an expected_fail case passed.
|
||
|
|
See docs/parity.md for the case format.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import concurrent.futures
|
||
|
|
import difflib
|
||
|
|
import glob as globmod
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import shutil
|
||
|
|
import signal
|
||
|
|
import sqlite3
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
|
||
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
|
ROOT = os.path.dirname(HERE)
|
||
|
|
REPO = os.path.dirname(ROOT)
|
||
|
|
OUT = os.path.join(ROOT, "build", "parity")
|
||
|
|
CASES_DIR = os.path.join(ROOT, "tests", "diff", "cases")
|
||
|
|
|
||
|
|
# Hard caps (nothing unbounded).
|
||
|
|
MAX_JOBS = 6
|
||
|
|
MAX_STEPS = 8
|
||
|
|
MAX_STEP_TIMEOUT = 300
|
||
|
|
MAX_DIFF_LINES = 40
|
||
|
|
MAX_CAPTURE = 2_000_000
|
||
|
|
MAX_ATTEMPTS = 5
|
||
|
|
BASE_ENV = {"COLUMNS": "80", "LINES": "24", "TAI_PASSPHRASE": "", "LC_ALL": "C.UTF-8", "TERM": "dumb"}
|
||
|
|
VAULT_SKIP_PREFIXES = ("sqlite_", "fts_docs_") # sqlite internals and fts shadow tables
|
||
|
|
CHANNELS = ("stdout", "stderr", "exit", "vault", "files")
|
||
|
|
|
||
|
|
|
||
|
|
class CaseError(Exception):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def sha256_file(path):
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
with open(path, "rb") as fh:
|
||
|
|
for block in iter(lambda: fh.read(1 << 20), b""):
|
||
|
|
digest.update(block)
|
||
|
|
return digest.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
class Rule:
|
||
|
|
"""One declared normalization: regex -> replacement on named channels."""
|
||
|
|
|
||
|
|
def __init__(self, spec, case_id):
|
||
|
|
for key in ("on", "pattern", "replace", "why"):
|
||
|
|
if key not in spec:
|
||
|
|
raise CaseError("%s: normalize rule missing %r" % (case_id, key))
|
||
|
|
if not str(spec["why"]).strip():
|
||
|
|
raise CaseError("%s: normalize rule needs a non-empty why" % case_id)
|
||
|
|
self.on = list(spec["on"])
|
||
|
|
for ch in self.on:
|
||
|
|
if ch not in CHANNELS:
|
||
|
|
raise CaseError("%s: normalize rule on unknown channel %r" % (case_id, ch))
|
||
|
|
self.regex = re.compile(spec["pattern"], re.M | (re.S if spec.get("dotall") else 0))
|
||
|
|
self.replace = spec["replace"]
|
||
|
|
self.why = spec["why"]
|
||
|
|
self.hits = {"ref": 0, "cpp": 0}
|
||
|
|
|
||
|
|
def apply(self, channel, text, side):
|
||
|
|
if channel not in self.on:
|
||
|
|
return text
|
||
|
|
text, count = self.regex.subn(self.replace, text)
|
||
|
|
self.hits[side] += count
|
||
|
|
return text
|
||
|
|
|
||
|
|
|
||
|
|
class Step:
|
||
|
|
def __init__(self, spec, case_id):
|
||
|
|
self.argv = [str(a) for a in spec.get("argv", [])]
|
||
|
|
self.stdin = spec.get("stdin", "")
|
||
|
|
self.env = {k: str(v) for k, v in spec.get("env", {}).items()}
|
||
|
|
self.files = dict(spec.get("files", {}))
|
||
|
|
self.home_files = dict(spec.get("home_files", {}))
|
||
|
|
self.mode = spec.get("mode", "pipe")
|
||
|
|
self.timeout = min(float(spec.get("timeout", 60)), MAX_STEP_TIMEOUT)
|
||
|
|
if self.mode not in ("pipe", "pty"):
|
||
|
|
raise CaseError("%s: unknown step mode %r" % (case_id, self.mode))
|
||
|
|
|
||
|
|
|
||
|
|
class Case:
|
||
|
|
def __init__(self, spec, source):
|
||
|
|
self.id = spec["id"]
|
||
|
|
self.source = source
|
||
|
|
self.doc = spec.get("doc", "")
|
||
|
|
self.symbols = list(spec.get("symbols", []))
|
||
|
|
self.tests = list(spec.get("tests", []))
|
||
|
|
self.llm = bool(spec.get("llm", False))
|
||
|
|
self.expected_fail = spec.get("expected_fail")
|
||
|
|
self.steps = [Step(s, self.id) for s in spec["steps"]]
|
||
|
|
if not self.steps or len(self.steps) > MAX_STEPS:
|
||
|
|
raise CaseError("%s: 1..%d steps required" % (self.id, MAX_STEPS))
|
||
|
|
compare = spec.get("compare", {})
|
||
|
|
self.compare = {
|
||
|
|
"stdout": compare.get("stdout", True),
|
||
|
|
"stderr": compare.get("stderr", True),
|
||
|
|
"exit": compare.get("exit", True),
|
||
|
|
"vault": compare.get("vault", False),
|
||
|
|
"files": compare.get("files", []),
|
||
|
|
}
|
||
|
|
self.vault_ignore = list(compare.get("vault_ignore_tables", []))
|
||
|
|
if self.vault_ignore and not str(compare.get("vault_ignore_why", "")).strip():
|
||
|
|
raise CaseError("%s: vault_ignore_tables needs vault_ignore_why" % self.id)
|
||
|
|
self.rule_specs = list(spec.get("normalize", []))
|
||
|
|
for rs in self.rule_specs:
|
||
|
|
Rule(rs, self.id) # validate at load
|
||
|
|
self.checks = list(spec.get("checks", []))
|
||
|
|
# Symbols named by the reference's own output (e.g. every tool /tools lists), claimed only
|
||
|
|
# when the case passes, i.e. when detz printed the same thing.
|
||
|
|
self.symbols_from_stdout = list(spec.get("symbols_from_stdout", []))
|
||
|
|
self.attempts = min(int(spec.get("attempts", 1)), MAX_ATTEMPTS)
|
||
|
|
self.min_pass = int(spec.get("min_pass", 1))
|
||
|
|
if self.llm and not self.checks:
|
||
|
|
raise CaseError("%s: llm case needs checks (outputs are not equal-compared)" % self.id)
|
||
|
|
|
||
|
|
def rules(self):
|
||
|
|
return [Rule(rs, self.id) for rs in self.rule_specs]
|
||
|
|
|
||
|
|
|
||
|
|
def load_cases(directory):
|
||
|
|
cases, seen = [], {}
|
||
|
|
for path in sorted(globmod.glob(os.path.join(directory, "*.json"))):
|
||
|
|
with open(path, encoding="utf-8") as fh:
|
||
|
|
doc = json.load(fh)
|
||
|
|
for spec in doc["cases"]:
|
||
|
|
case = Case(spec, os.path.basename(path))
|
||
|
|
if case.id in seen:
|
||
|
|
raise CaseError("duplicate case id %s (%s, %s)" % (case.id, seen[case.id], path))
|
||
|
|
seen[case.id] = path
|
||
|
|
cases.append(case)
|
||
|
|
return cases
|
||
|
|
|
||
|
|
|
||
|
|
class Side:
|
||
|
|
"""One implementation under test."""
|
||
|
|
|
||
|
|
def __init__(self, name, command):
|
||
|
|
self.name = name
|
||
|
|
self.command = list(command)
|
||
|
|
|
||
|
|
|
||
|
|
class Capture:
|
||
|
|
"""Everything observed from running one case once on one side."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.steps = [] # list of dict(argv, rc, stdout, stderr, timed_out)
|
||
|
|
self.vault = ""
|
||
|
|
self.files = ""
|
||
|
|
self.sql = None # open read-only vault path for checks
|
||
|
|
self.home = None
|
||
|
|
self.cwd = None
|
||
|
|
|
||
|
|
|
||
|
|
class Runner:
|
||
|
|
def __init__(self, work_root):
|
||
|
|
self.work_root = work_root
|
||
|
|
|
||
|
|
def run_step(self, side, step, home, cwd):
|
||
|
|
for rel, content in step.files.items():
|
||
|
|
self._write(os.path.join(cwd, rel), content)
|
||
|
|
for rel, content in step.home_files.items():
|
||
|
|
self._write(os.path.join(home, rel), content)
|
||
|
|
# Hermetic environment: inherited TAI_*, CAP_*, TMUX* leak the caller's session into the
|
||
|
|
# run (parent shell session, pane resolution), so they are dropped. Declared per step env wins.
|
||
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith(("TAI_", "CAP_", "TMUX"))}
|
||
|
|
env.update(BASE_ENV)
|
||
|
|
user_home = os.path.join(os.path.dirname(home), "user")
|
||
|
|
os.makedirs(user_home, exist_ok=True)
|
||
|
|
env.update({"TAI_HOME": home, "HOME": user_home})
|
||
|
|
env.update(step.env)
|
||
|
|
cmd = side.command + step.argv
|
||
|
|
start = time.monotonic()
|
||
|
|
if step.mode == "pty":
|
||
|
|
return self._run_pty(cmd, step, env, cwd, start)
|
||
|
|
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||
|
|
cwd=cwd, env=env, start_new_session=True)
|
||
|
|
timed_out = False
|
||
|
|
try:
|
||
|
|
out, err = proc.communicate(step.stdin.encode("utf-8"), timeout=step.timeout)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
timed_out = True
|
||
|
|
self._kill(proc)
|
||
|
|
out, err = proc.communicate()
|
||
|
|
return {"argv": step.argv, "rc": proc.returncode, "timed_out": timed_out,
|
||
|
|
"stdout": out[:MAX_CAPTURE].decode("utf-8", "replace"),
|
||
|
|
"stderr": err[:MAX_CAPTURE].decode("utf-8", "replace"),
|
||
|
|
"secs": round(time.monotonic() - start, 2)}
|
||
|
|
|
||
|
|
def _run_pty(self, cmd, step, env, cwd, start):
|
||
|
|
import asyncio
|
||
|
|
sys.path.insert(0, REPO)
|
||
|
|
import tai_test # the one base harness (tests/README.md); pty merges stderr into stdout
|
||
|
|
|
||
|
|
async def go():
|
||
|
|
proc = tai_test.ManagedProcess(cmd, mode="pty", cwd=cwd, env=env)
|
||
|
|
await proc.start()
|
||
|
|
timed_out = False
|
||
|
|
text = ""
|
||
|
|
try:
|
||
|
|
for line in step.stdin.splitlines():
|
||
|
|
await proc.send_line(line)
|
||
|
|
text = await proc.drain_until_eof(timeout=step.timeout)
|
||
|
|
except tai_test.TestTimeout:
|
||
|
|
timed_out = True
|
||
|
|
text = "".join(l.text for l in proc.transcript if l.stream == "stdout")
|
||
|
|
rc = await proc.terminate() if timed_out else await proc.wait(5)
|
||
|
|
await proc.terminate()
|
||
|
|
return text, rc, timed_out
|
||
|
|
text, rc, timed_out = asyncio.run(go())
|
||
|
|
return {"argv": step.argv, "rc": rc, "timed_out": timed_out, "stdout": tai_test.strip_ansi(text)[:MAX_CAPTURE],
|
||
|
|
"stderr": "", "secs": round(time.monotonic() - start, 2)}
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _write(path, content):
|
||
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
|
|
with open(path, "w", encoding="utf-8") as fh:
|
||
|
|
fh.write(content)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _kill(proc):
|
||
|
|
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||
|
|
try:
|
||
|
|
os.killpg(proc.pid, sig)
|
||
|
|
except (ProcessLookupError, PermissionError):
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
proc.wait(3)
|
||
|
|
return
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
continue
|
||
|
|
|
||
|
|
def capture(self, case, side, tag):
|
||
|
|
base = os.path.join(self.work_root, case.id.replace("/", "_"), "%s-%s" % (side.name, tag))
|
||
|
|
shutil.rmtree(base, ignore_errors=True)
|
||
|
|
home, cwd = os.path.join(base, "home"), os.path.join(base, "cwd")
|
||
|
|
os.makedirs(home)
|
||
|
|
os.makedirs(cwd)
|
||
|
|
cap = Capture()
|
||
|
|
cap.home, cap.cwd = home, cwd
|
||
|
|
for step in case.steps:
|
||
|
|
cap.steps.append(self.run_step(side, step, home, cwd))
|
||
|
|
if case.compare["vault"]:
|
||
|
|
cap.vault = VaultDump(home, case.vault_ignore).text()
|
||
|
|
if case.compare["files"]:
|
||
|
|
cap.files = FilesDump(home, cwd, case.compare["files"]).text()
|
||
|
|
return cap
|
||
|
|
|
||
|
|
|
||
|
|
class VaultDump:
|
||
|
|
"""Deterministic text dump of every sqlite db under the home (read-only copy)."""
|
||
|
|
|
||
|
|
def __init__(self, home, ignore):
|
||
|
|
self.home = home
|
||
|
|
self.ignore = set(ignore)
|
||
|
|
|
||
|
|
def text(self):
|
||
|
|
lines = []
|
||
|
|
for base, _dirs, names in sorted(os.walk(self.home)):
|
||
|
|
for name in sorted(names):
|
||
|
|
if name.endswith(".db"):
|
||
|
|
path = os.path.join(base, name)
|
||
|
|
lines.append("# db %s" % os.path.relpath(path, self.home))
|
||
|
|
lines.extend(self._dump(path))
|
||
|
|
return "\n".join(lines) + ("\n" if lines else "")
|
||
|
|
|
||
|
|
def _dump(self, path):
|
||
|
|
out = []
|
||
|
|
tmp = tempfile.mkdtemp()
|
||
|
|
try:
|
||
|
|
copy = os.path.join(tmp, "v.db")
|
||
|
|
shutil.copy(path, copy)
|
||
|
|
for suffix in ("-wal", "-shm"):
|
||
|
|
if os.path.exists(path + suffix):
|
||
|
|
shutil.copy(path + suffix, copy + suffix)
|
||
|
|
db = sqlite3.connect(copy)
|
||
|
|
db.text_factory = lambda b: b.decode("utf-8", "replace")
|
||
|
|
tables = [r for r in db.execute("SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name")]
|
||
|
|
for name, sql in tables:
|
||
|
|
if name.startswith(VAULT_SKIP_PREFIXES) or name in self.ignore:
|
||
|
|
continue
|
||
|
|
out.append("## table %s :: %s" % (name, " ".join((sql or "").split())))
|
||
|
|
rows = [json.dumps(list(r), ensure_ascii=False, default=repr) for r in db.execute('SELECT * FROM "%s"' % name)]
|
||
|
|
out.extend(" " + r for r in sorted(rows))
|
||
|
|
idx = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
||
|
|
out.append("## indexes %s" % ",".join(idx))
|
||
|
|
db.close()
|
||
|
|
except sqlite3.Error as exc:
|
||
|
|
out.append("## unreadable: %s" % exc)
|
||
|
|
finally:
|
||
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
class FilesDump:
|
||
|
|
"""Path + content hash listing for declared globs, rooted at home or cwd."""
|
||
|
|
|
||
|
|
def __init__(self, home, cwd, specs):
|
||
|
|
self.roots = {"home": home, "cwd": cwd}
|
||
|
|
self.specs = specs
|
||
|
|
|
||
|
|
def text(self):
|
||
|
|
lines = []
|
||
|
|
for spec in self.specs:
|
||
|
|
root = self.roots[spec["root"]]
|
||
|
|
for path in sorted(globmod.glob(os.path.join(root, spec["glob"]), recursive=True)):
|
||
|
|
if os.path.isfile(path):
|
||
|
|
rel = "%s:%s" % (spec["root"], os.path.relpath(path, root))
|
||
|
|
with open(path, "rb") as fh:
|
||
|
|
data = fh.read(MAX_CAPTURE)
|
||
|
|
try:
|
||
|
|
body = data.decode("utf-8")
|
||
|
|
lines.append("%s\t%s" % (rel, json.dumps(body, ensure_ascii=False)))
|
||
|
|
except UnicodeDecodeError:
|
||
|
|
lines.append("%s\tsha256:%s" % (rel, hashlib.sha256(data).hexdigest()))
|
||
|
|
return "\n".join(sorted(lines)) + ("\n" if lines else "")
|
||
|
|
|
||
|
|
|
||
|
|
class Normalizer:
|
||
|
|
def __init__(self, case):
|
||
|
|
self.rules = case.rules()
|
||
|
|
|
||
|
|
def apply(self, channel, text, side):
|
||
|
|
for rule in self.rules:
|
||
|
|
text = rule.apply(channel, text, side)
|
||
|
|
return text
|
||
|
|
|
||
|
|
def unused(self):
|
||
|
|
return [r.why for r in self.rules if r.hits["ref"] == 0 and r.hits["cpp"] == 0]
|
||
|
|
|
||
|
|
|
||
|
|
class Checker:
|
||
|
|
"""Evaluates `checks` of an llm case against one side's capture."""
|
||
|
|
|
||
|
|
def __init__(self, case):
|
||
|
|
self.case = case
|
||
|
|
|
||
|
|
def evaluate(self, cap, side):
|
||
|
|
fails = []
|
||
|
|
for chk in self.case.checks:
|
||
|
|
if chk.get("side", "both") not in ("both", side):
|
||
|
|
continue
|
||
|
|
kind = chk["kind"]
|
||
|
|
if kind == "exit":
|
||
|
|
got = cap.steps[chk["step"]]["rc"]
|
||
|
|
if got != chk["value"]:
|
||
|
|
fails.append("exit of step %d is %r, want %r" % (chk["step"], got, chk["value"]))
|
||
|
|
elif kind in ("stdout_regex", "stderr_regex"):
|
||
|
|
text = cap.steps[chk["step"]]["stdout" if kind == "stdout_regex" else "stderr"]
|
||
|
|
if not re.search(chk["pattern"], text, re.S | re.I):
|
||
|
|
fails.append("%s step %d: /%s/ not found" % (kind, chk["step"], chk["pattern"]))
|
||
|
|
elif kind == "vault_sql":
|
||
|
|
got = self._sql(cap, chk["sql"])
|
||
|
|
if not self._cmp(got, chk["op"], chk["value"]):
|
||
|
|
fails.append("vault_sql %r gave %r, want %s %r" % (chk["sql"], got, chk["op"], chk["value"]))
|
||
|
|
else:
|
||
|
|
fails.append("unknown check kind %r" % kind)
|
||
|
|
return fails
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _cmp(got, op, want):
|
||
|
|
try:
|
||
|
|
return {"==": got == want, ">=": got >= want, "<=": got <= want, ">": got > want, "<": got < want}[op]
|
||
|
|
except TypeError:
|
||
|
|
return False
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _sql(cap, sql):
|
||
|
|
path = os.path.join(cap.home, "memory.db")
|
||
|
|
if not os.path.exists(path):
|
||
|
|
return None
|
||
|
|
tmp = tempfile.mkdtemp()
|
||
|
|
try:
|
||
|
|
copy = os.path.join(tmp, "v.db")
|
||
|
|
shutil.copy(path, copy)
|
||
|
|
for suffix in ("-wal", "-shm"):
|
||
|
|
if os.path.exists(path + suffix):
|
||
|
|
shutil.copy(path + suffix, copy + suffix)
|
||
|
|
db = sqlite3.connect(copy)
|
||
|
|
row = db.execute(sql).fetchone()
|
||
|
|
db.close()
|
||
|
|
return row[0] if row else None
|
||
|
|
except sqlite3.Error as exc:
|
||
|
|
return "sqlerror: %s" % exc
|
||
|
|
finally:
|
||
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
|
|
||
|
|
|
||
|
|
class Verdict:
|
||
|
|
def __init__(self, case):
|
||
|
|
self.case = case
|
||
|
|
self.status = "fail" # pass | fail | xfail | xpass | skipped
|
||
|
|
self.problems = [] # human lines, capped
|
||
|
|
self.ratio = None # llm: "ok/attempts" per side
|
||
|
|
self.unused_rules = []
|
||
|
|
self.extra_symbols = []
|
||
|
|
self.secs = 0.0
|
||
|
|
|
||
|
|
def as_dict(self):
|
||
|
|
return {"id": self.case.id, "status": self.status, "llm": self.case.llm, "symbols": self.case.symbols + self.extra_symbols,
|
||
|
|
"tests": self.case.tests, "expected_fail": self.case.expected_fail, "problems": self.problems,
|
||
|
|
"ratio": self.ratio, "unused_rules": self.unused_rules, "secs": round(self.secs, 1),
|
||
|
|
"source": self.case.source}
|
||
|
|
|
||
|
|
|
||
|
|
class CaseExecutor:
|
||
|
|
def __init__(self, runner, ref, cpp):
|
||
|
|
self.runner, self.ref, self.cpp = runner, ref, cpp
|
||
|
|
|
||
|
|
def execute(self, case):
|
||
|
|
start = time.monotonic()
|
||
|
|
verdict = Verdict(case)
|
||
|
|
try:
|
||
|
|
if case.llm:
|
||
|
|
self._llm(case, verdict)
|
||
|
|
else:
|
||
|
|
self._equal(case, verdict)
|
||
|
|
except Exception as exc: # harness fault is a failure, loudly
|
||
|
|
verdict.problems.append("harness error: %r" % exc)
|
||
|
|
passed = not verdict.problems
|
||
|
|
if case.expected_fail:
|
||
|
|
verdict.status = "xpass" if passed else "xfail"
|
||
|
|
else:
|
||
|
|
verdict.status = "pass" if passed else "fail"
|
||
|
|
verdict.secs = time.monotonic() - start
|
||
|
|
return verdict
|
||
|
|
|
||
|
|
def _equal(self, case, verdict):
|
||
|
|
norm = Normalizer(case)
|
||
|
|
caps = {"ref": self.runner.capture(case, self.ref, "a"), "cpp": self.runner.capture(case, self.cpp, "a")}
|
||
|
|
for i, step in enumerate(case.steps):
|
||
|
|
for side, cap in caps.items():
|
||
|
|
if cap.steps[i]["timed_out"]:
|
||
|
|
verdict.problems.append("step %d timed out on %s (%.0fs)" % (i, side, step.timeout))
|
||
|
|
views = {}
|
||
|
|
for side, cap in caps.items():
|
||
|
|
view = {"exit": "\n".join(str(s["rc"]) for s in cap.steps) + "\n"}
|
||
|
|
view["stdout"] = "".join("--- step %d\n%s" % (i, s["stdout"]) for i, s in enumerate(cap.steps))
|
||
|
|
view["stderr"] = "".join("--- step %d\n%s" % (i, s["stderr"]) for i, s in enumerate(cap.steps))
|
||
|
|
view["vault"] = cap.vault
|
||
|
|
view["files"] = cap.files
|
||
|
|
views[side] = {ch: norm.apply(ch, txt, side) for ch, txt in view.items()}
|
||
|
|
for ch in CHANNELS:
|
||
|
|
if not case.compare[ch]:
|
||
|
|
continue
|
||
|
|
if views["ref"][ch] != views["cpp"][ch]:
|
||
|
|
verdict.problems.extend(self._diff(ch, views["ref"][ch], views["cpp"][ch]))
|
||
|
|
verdict.unused_rules = norm.unused()
|
||
|
|
for spec in case.symbols_from_stdout:
|
||
|
|
text = views["ref"]["stdout"].split("--- step %d\n" % spec["step"], 1)[-1]
|
||
|
|
names = re.findall(spec["pattern"], text, re.M)
|
||
|
|
if not names:
|
||
|
|
verdict.problems.append("symbols_from_stdout matched nothing for %s" % spec["prefix"])
|
||
|
|
verdict.extra_symbols += ["%s%s#%s" % (spec["prefix"], n, spec["facet"]) for n in names]
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _diff(channel, ref, cpp):
|
||
|
|
lines = list(difflib.unified_diff(ref.splitlines(), cpp.splitlines(), "tai.py:" + channel, "detz:" + channel, lineterm="", n=1))
|
||
|
|
out = ["%s differs (%d diff lines)" % (channel, len(lines))]
|
||
|
|
out.extend(lines[:MAX_DIFF_LINES])
|
||
|
|
if len(lines) > MAX_DIFF_LINES:
|
||
|
|
out.append("... %d more diff lines" % (len(lines) - MAX_DIFF_LINES))
|
||
|
|
return out
|
||
|
|
|
||
|
|
def _llm(self, case, verdict):
|
||
|
|
checker = Checker(case)
|
||
|
|
ratios = {}
|
||
|
|
for name, side in (("ref", self.ref), ("cpp", self.cpp)):
|
||
|
|
ok, last = 0, []
|
||
|
|
for attempt in range(case.attempts):
|
||
|
|
cap = self.runner.capture(case, side, "t%d" % attempt)
|
||
|
|
fails = checker.evaluate(cap, name)
|
||
|
|
for i, s in enumerate(cap.steps):
|
||
|
|
if s["timed_out"]:
|
||
|
|
fails.append("step %d timed out" % i)
|
||
|
|
if fails:
|
||
|
|
last = fails
|
||
|
|
else:
|
||
|
|
ok += 1
|
||
|
|
ratios[name] = "%d/%d" % (ok, case.attempts)
|
||
|
|
if ok < case.min_pass:
|
||
|
|
verdict.problems.append("%s: %d/%d attempts passed, need %d" % (name, ok, case.attempts, case.min_pass))
|
||
|
|
verdict.problems.extend(" " + f for f in last[:MAX_DIFF_LINES])
|
||
|
|
verdict.ratio = ratios
|
||
|
|
|
||
|
|
|
||
|
|
class Report:
|
||
|
|
def __init__(self, verdicts, meta):
|
||
|
|
self.verdicts, self.meta = verdicts, meta
|
||
|
|
|
||
|
|
def write(self):
|
||
|
|
os.makedirs(OUT, exist_ok=True)
|
||
|
|
suffix = "-partial" if self.meta["filtered"] else "" # a filtered run never feeds the ledger
|
||
|
|
with open(os.path.join(OUT, "diff-results%s.json" % suffix), "w") as fh:
|
||
|
|
json.dump({"meta": self.meta, "results": [v.as_dict() for v in self.verdicts]}, fh, indent=1)
|
||
|
|
counts = {}
|
||
|
|
for v in self.verdicts:
|
||
|
|
counts[v.status] = counts.get(v.status, 0) + 1
|
||
|
|
lines = ["# Differential report (generated)", "",
|
||
|
|
"tai.py sha256 `%s`, detz sha256 `%s`" % (self.meta["tai_sha"][:16], self.meta["detz_sha"][:16]), "",
|
||
|
|
"Counts: " + ", ".join("%s=%d" % kv for kv in sorted(counts.items())), "",
|
||
|
|
"| case | status | llm | secs | note |", "|---|---|---|---|---|"]
|
||
|
|
for v in sorted(self.verdicts, key=lambda x: x.case.id):
|
||
|
|
note = (v.case.expected_fail or "") if v.status in ("xfail", "xpass") else ""
|
||
|
|
if v.ratio:
|
||
|
|
note += " ratio %s" % json.dumps(v.ratio)
|
||
|
|
lines.append("| %s | %s | %s | %.1f | %s |" % (v.case.id, v.status, "yes" if v.case.llm else "", v.secs, note.strip()))
|
||
|
|
lines += ["", "## Details of non-passing cases", ""]
|
||
|
|
for v in sorted(self.verdicts, key=lambda x: x.case.id):
|
||
|
|
if v.status in ("pass", "skipped"):
|
||
|
|
continue
|
||
|
|
lines += ["### %s (%s)" % (v.case.id, v.status), ""]
|
||
|
|
if v.case.expected_fail:
|
||
|
|
lines += ["expected_fail: " + v.case.expected_fail, ""]
|
||
|
|
lines += ["```"] + v.problems[:MAX_DIFF_LINES * 3] + ["```", ""]
|
||
|
|
unused = [(v.case.id, w) for v in self.verdicts for w in v.unused_rules]
|
||
|
|
if unused:
|
||
|
|
lines += ["## Unused normalization rules (suspect)", ""] + ["- %s: %s" % u for u in unused] + [""]
|
||
|
|
with open(os.path.join(OUT, "diff-report%s.md" % suffix), "w") as fh:
|
||
|
|
fh.write("\n".join(lines))
|
||
|
|
return counts
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||
|
|
ap.add_argument("--detz", default=os.environ.get("DETZ_BIN", os.path.join(ROOT, "build", "detz")))
|
||
|
|
ap.add_argument("--py", default=os.environ.get("TAI_PY", os.path.join(REPO, "tai.py")))
|
||
|
|
ap.add_argument("--cases", default=CASES_DIR)
|
||
|
|
ap.add_argument("--filter", default=None)
|
||
|
|
ap.add_argument("--no-llm", action="store_true")
|
||
|
|
ap.add_argument("--jobs", type=int, default=4)
|
||
|
|
ap.add_argument("--list", action="store_true")
|
||
|
|
args = ap.parse_args()
|
||
|
|
cases = load_cases(args.cases)
|
||
|
|
if args.filter:
|
||
|
|
cases = [c for c in cases if args.filter in c.id]
|
||
|
|
if args.list:
|
||
|
|
for c in cases:
|
||
|
|
print(c.id, "llm" if c.llm else "", c.expected_fail or "")
|
||
|
|
return 0
|
||
|
|
if not os.path.isfile(args.py):
|
||
|
|
print("tai.py not found: %s" % args.py, file=sys.stderr)
|
||
|
|
return 2
|
||
|
|
detz_ok = os.path.isfile(args.detz) and os.access(args.detz, os.X_OK)
|
||
|
|
if not detz_ok:
|
||
|
|
print("detz binary missing: %s (every case will fail; build it with `make`)" % args.detz, file=sys.stderr)
|
||
|
|
work = os.path.join(OUT, "work")
|
||
|
|
shutil.rmtree(work, ignore_errors=True)
|
||
|
|
runner = Runner(work)
|
||
|
|
ref = Side("ref", [sys.executable, args.py])
|
||
|
|
cpp = Side("cpp", [args.detz] if detz_ok else ["/nonexistent/detz"])
|
||
|
|
executor = CaseExecutor(runner, ref, cpp)
|
||
|
|
todo, verdicts = [], []
|
||
|
|
for c in cases:
|
||
|
|
if c.llm and args.no_llm:
|
||
|
|
v = Verdict(c)
|
||
|
|
v.status = "skipped"
|
||
|
|
verdicts.append(v)
|
||
|
|
else:
|
||
|
|
todo.append(c)
|
||
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, min(args.jobs, MAX_JOBS))) as pool:
|
||
|
|
for v in pool.map(executor.execute, todo):
|
||
|
|
print("%-7s %s" % (v.status.upper(), v.case.id), flush=True)
|
||
|
|
verdicts.append(v)
|
||
|
|
meta = {"tai_sha": sha256_file(args.py), "detz_sha": sha256_file(args.detz) if detz_ok else "missing",
|
||
|
|
"detz_path": args.detz, "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "filtered": bool(args.filter),
|
||
|
|
"no_llm": args.no_llm}
|
||
|
|
counts = Report(verdicts, meta).write()
|
||
|
|
print("counts:", counts, "(report dir: %s)" % OUT)
|
||
|
|
bad = counts.get("fail", 0) + counts.get("xpass", 0)
|
||
|
|
return 1 if bad else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|