Files
tai/taicpp/tools/parity_diff.py
T

603 lines
25 KiB
Python
Raw Normal View History

#!/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())