Viewing files at commit e60fa60d3a02Commit detailsView latest

suites/merge/mergesuite/runner.py 375 lines · 18.1 KB · python
1
"""Run scenarios: git merge-ort oracle vs Oak's merge engine, classify, apply merge laws."""
2
from __future__ import annotations
3
4
import concurrent.futures
5
import hashlib
6
import itertools
7
import json
8
import os
9
import re
10
import fnmatch
11
from pathlib import Path
12
import queue
13
import shutil
14
import subprocess
15
import time
16
import traceback
17
from typing import Callable, Dict, List, Optional
18
19
from . import classify as cl
20
from .gitops import Git, git_conflicts, prepare_git_home
21
from .oak_adapter import (LocalOakServer, PATH_DESCRIPTION, PATH_EXERCISED, oak_env,
22
                          run_pull_sync)
23
24
SCHEMA_VERSION = 1
25
26
27
def file_sha256(path: str) -> Optional[str]:
28
    try:
29
        return hashlib.sha256(Path(path).read_bytes()).hexdigest()
30
    except OSError:
31
        return None
32
33
34
def tree_digest(tree: Optional[dict]) -> Optional[str]:
35
    if tree is None:
36
        return None
37
    return hashlib.sha256(json.dumps(sorted(tree.items())).encode()).hexdigest()
38
39
40
def tool_version(binary: str, *args: str) -> Optional[str]:
41
    try:
42
        return subprocess.run([binary, *args], capture_output=True, text=True, timeout=30).stdout.strip() or None
43
    except OSError:
44
        return None
45
46
47
def git_oracle(git: Git, direction: dict) -> dict:
48
    merge = git.merge_tree(direction["oracle"]["ours"], direction["oracle"]["theirs"])
49
    return {"clean": merge["clean"], "tree": merge["tree"], "conflicts": git_conflicts(merge),
50
            "messages": [m for m in merge["messages"] if m["type"].startswith("CONFLICT")][:20]}
51
52
53
def text_evidence(git: Git, trees: dict, oak_tree: dict, oak_conf: set, case_dir: Path,
54
                  base: dict, ours: dict, theirs: dict) -> Dict[str, dict]:
55
    """Bytes for paths both sides changed that Oak text-merged (needed by the no-lost-change law)."""
56
    out = {}
57
    for path, value in oak_tree.items():
58
        if path in oak_conf:
59
            continue
60
        b, o, t = base.get(path), ours.get(path), theirs.get(path)
61
        if o is None or t is None or o == t or o == b or t == b or value in (b, o, t):
62
            continue
63
64
        def blob(commit):
65
            p = git.run("cat-file", "-p", f"{commit}:{path}", check=False)
66
            return p.stdout if p.returncode == 0 else b""
67
        merged_path = case_dir / "ours" / path
68
        if merged_path.is_symlink():
69
            merged = os.fsencode(os.readlink(merged_path))  # symlink "content" is its target
70
        else:
71
            merged = merged_path.read_bytes() if merged_path.is_file() else b""
72
        out[path] = {"base": blob(trees["base"]) if b else b"", "ours": blob(trees["ours"]),
73
                     "theirs": blob(trees["theirs"]), "merged": merged}
74
    return out
75
76
77
class Suite:
78
    def __init__(self, workdir: Path, oak_bin: str, git_bin: str, jobs: int = 4,
79
                 keep_equal: bool = False, run_id: Optional[str] = None):
80
        self.workdir, self.oak_bin, self.git_bin = workdir, oak_bin, git_bin
81
        self.jobs, self.keep_equal = max(1, jobs), keep_equal
82
        self.run_id = run_id or time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
83
        self.git_home = workdir / "git-home"
84
        prepare_git_home(self.git_home, git_bin)
85
        self.homes: "queue.Queue[Path]" = queue.Queue()
86
        for i in range(self.jobs):
87
            home = workdir / "oak-homes" / f"h{i}"
88
            home.mkdir(parents=True, exist_ok=True)
89
            prepare_git_home(home, git_bin)
90
            self.homes.put(home)
91
        self.meta = {
92
            "oak": {"bin": oak_bin, "sha256": file_sha256(shutil.which(oak_bin) or oak_bin),
93
                    "version": tool_version(oak_bin, "--version"),
94
                    "path_exercised": PATH_EXERCISED, "path_description": PATH_DESCRIPTION},
95
            "git": {"bin": git_bin, "sha256": file_sha256(git_bin), "version": tool_version(git_bin, "--version"),
96
                    "role": "reference oracle (merge-ort via merge-tree --write-tree) and fixture builder"},
97
        }
98
        self._keys = itertools.count(1)
99
        self.bugs = load_known_bugs()
100
101
    def run(self, scenarios: List[dict], emit: Callable[[dict], None]) -> List[dict]:
102
        server_env = oak_env(self.workdir / "oak-homes" / "server", self.git_bin)
103
        (self.workdir / "oak-homes" / "server").mkdir(parents=True, exist_ok=True)
104
        server = LocalOakServer(self.workdir, self.oak_bin, server_env)
105
        rows: List[dict] = []
106
        try:
107
            with concurrent.futures.ThreadPoolExecutor(self.jobs) as pool:
108
                futures = [pool.submit(self._scenario, s, server) for s in scenarios]
109
                for fut in concurrent.futures.as_completed(futures):
110
                    for row in fut.result():
111
                        rows.append(row)
112
                        emit(row)
113
        finally:
114
            server.close()
115
        return rows
116
117
    def _next_key(self) -> str:
118
        return f"suite/m{next(self._keys):05d}"
119
120
    def _scenario(self, scenario: dict, server: LocalOakServer) -> List[dict]:
121
        home = self.homes.get()
122
        try:
123
            return self._scenario_rows(scenario, server, home)
124
        finally:
125
            self.homes.put(home)
126
127
    def _scenario_rows(self, scenario: dict, server: LocalOakServer, home: Path) -> List[dict]:
128
        git = Git(scenario["repo"], self.git_bin, self.git_home)
129
        safe = scenario["id"].replace("/", "__")
130
        scen_dir = self.workdir / "cases" / safe
131
        rows, results = [], {}
132
        bare = scen_dir / "src.git"
133
        for direction in scenario["directions"]:
134
            start = time.monotonic()
135
            case_dir = scen_dir / direction["name"]
136
            row = self._base_row(scenario, direction)
137
            oak_result, git_result, harness = None, None, None
138
            base, ours, theirs = None, {}, {}
139
            try:
140
                if scenario.get("unsupported") is None:
141
                    git_result = git_oracle(git, direction)
142
                    trees = direction["trees"]
143
                    unique_base = trees.get("base") and len(direction.get("merge_bases") or [1]) == 1
144
                    base = git.tree(trees["base"]) if unique_base else None
145
                    ours, theirs = git.tree(trees["ours"]), git.tree(trees["theirs"])
146
                    if not bare.exists():
147
                        self._make_bare(git, bare, scenario)
148
                    if case_dir.exists():
149
                        shutil.rmtree(case_dir)
150
                    case_dir.mkdir(parents=True)
151
                    env = oak_env(home, self.git_bin)
152
                    oak_result = run_pull_sync(server, self.oak_bin, env, Git(bare, self.git_bin, self.git_home),
153
                                               bare, case_dir, self._next_key(), direction["plan"])
154
            except Exception as exc:  # instrument failure, never an Oak verdict
155
                harness = f"{type(exc).__name__}: {exc}"
156
                row["traceback"] = traceback.format_exc()[-2000:]
157
            texts = None
158
            if oak_result and oak_result.get("tree") is not None and git_result:
159
                texts = text_evidence(git, direction["trees"], oak_result["tree"],
160
                                      set(oak_result.get("conflicts") or {}), case_dir, base or {}, ours, theirs)
161
            verdict = cl.classify(git_result, oak_result, base, ours, theirs,
162
                                  unsupported=scenario.get("unsupported"), harness_error=harness,
163
                                  expect=direction.get("expect"), texts=texts)
164
            row.update(self._result_fields(git_result, oak_result, verdict))
165
            row["known_bug"] = match_known_bug(row, self.bugs)
166
            if direction.get("intent_note"):
167
                row["intent_note"] = direction["intent_note"]
168
            row["elapsed_ms"] = round((time.monotonic() - start) * 1000)
169
            row["case_dir"] = str(case_dir) if case_dir.exists() else None
170
            if verdict["class"] == "equal-to-git" and not self.keep_equal and case_dir.exists():
171
                shutil.rmtree(case_dir, ignore_errors=True)
172
                row["case_dir"] = None
173
            results[direction["name"]] = oak_result
174
            rows.append(row)
175
        if len(rows) == 2:
176
            sym = cl.symmetry(*(results[d["name"]] for d in scenario["directions"]))
177
            for row in rows:
178
                row["laws"]["symmetric"] = None if sym is None else sym["holds"]
179
                if sym and not sym["holds"]:
180
                    row.setdefault("evidence", {})["symmetry"] = sym
181
        if not self.keep_equal and all(r["class"] == "equal-to-git" for r in rows):
182
            shutil.rmtree(scen_dir, ignore_errors=True)
183
        return rows
184
185
    def _make_bare(self, git: Git, bare: Path, scenario: dict) -> None:
186
        """Bare mirror holding every plan commit under a ref (builder commits are unreferenced;
187
        `oak clone <x.git>` imports from the mirror's HEAD, which the adapter points per step)."""
188
        wanted = set()
189
        for d in scenario["directions"]:
190
            plan = d["plan"]
191
            wanted.update(x for x in (plan.get("main"), plan.get("fork"), plan.get("final_stage")) if x)
192
            for step in plan["steps"]:
193
                wanted.update(x for x in (step.get("tree"), step.get("stage")) if x)
194
        ns = "refs/mergesuite/" + hashlib.sha256(scenario["id"].encode()).hexdigest()[:16]
195
        for i, commit in enumerate(sorted(wanted)):
196
            git.run("update-ref", f"{ns}/keep{i}", commit)
197
        bare.parent.mkdir(parents=True, exist_ok=True)
198
        Git(bare.parent, self.git_bin, self.git_home).run("init", "-q", "--bare", str(bare))
199
        Git(bare, self.git_bin, self.git_home).run("fetch", "-q", str(scenario["repo"]),
200
                                                   f"+{ns}/*:refs/mergesuite/*")
201
202
    def _base_row(self, scenario: dict, direction: dict) -> dict:
203
        return {
204
            "merge_suite_schema_version": SCHEMA_VERSION,
205
            "evidence_class": "diagnostic_merge_correctness",
206
            "run_id": self.run_id,
207
            "scenario": scenario["id"], "scenario_version": scenario["version"],
208
            "direction": direction["name"], "origin": scenario["origin"], "upstream": scenario["upstream"],
209
            "notes": scenario.get("notes") or None,
210
            "oak": self.meta["oak"], "git": self.meta["git"],
211
            "laws": {"no_lost_change": None, "idempotent": None, "symmetric": None,
212
                     "false_conflict": None, "missed_conflict": None, "prediction_agrees": None},
213
            "repro": ("python3 suites/merge/run_merge_suite.py --workdir <dir> --only "
214
                      f"'{scenario['id']}'"),
215
        }
216
217
    def _result_fields(self, git_result: Optional[dict], oak_result: Optional[dict], verdict: dict) -> dict:
218
        out = {"class": verdict["class"], "reasons": verdict.get("reasons", [])}
219
        if verdict.get("notes"):
220
            out["class_notes"] = verdict["notes"]
221
        evidence = dict(verdict.get("evidence") or {})
222
        laws = {"no_lost_change": None, "idempotent": None, "symmetric": None,
223
                "false_conflict": None, "missed_conflict": None, "prediction_agrees": None}
224
        if git_result:
225
            out["git_result"] = {"clean": git_result["clean"], "conflicts": git_result["conflicts"],
226
                                 "tree_digest": tree_digest(git_result["tree"]),
227
                                 "conflict_messages": git_result.get("messages", [])}
228
        if oak_result:
229
            out["oak_result"] = {
230
                "path_exercised": oak_result.get("path_exercised"),
231
                "clean": oak_result.get("clean"), "conflicts": oak_result.get("conflicts"),
232
                "tree_digest": tree_digest(oak_result.get("tree")),
233
                "pull_returncode": oak_result.get("pull_returncode"),
234
                "pull_receipt": oak_result.get("pull_receipt"),
235
                "prediction": oak_result.get("prediction"),
236
                "idempotence": oak_result.get("idempotence"),
237
                "worktree_matches_head": oak_result.get("worktree_matches_head"),
238
                "result_source": oak_result.get("result_source"),
239
                "conflict_worktree_stale_paths": oak_result.get("conflict_worktree_stale_paths") or None,
240
                "error": oak_result.get("error"), "error_kind": oak_result.get("error_kind"),
241
                "shim_log": oak_result.get("shim_log") or None,
242
            }
243
            if verdict["class"] not in ("equal-to-git",):
244
                out["oak_steps"] = [{k: s[k] for k in ("label", "argv", "returncode", "ms")}
245
                                    | {"stderr_tail": s["stderr"][-300:]} for s in oak_result.get("steps", [])]
246
            if oak_result.get("clean") is not None and git_result:
247
                law = evidence.get("law_no_lost_change")
248
                laws["no_lost_change"] = None if law is None else not any(
249
                    law.get(k) for k in ("lost", "mode_lost", "markers", "markers_no_final_newline",
250
                                         "markers_symlink", "fabricated"))
251
                laws["idempotent"] = cl.idempotence_holds(oak_result)
252
                oak_conf = set(oak_result.get("conflicts") or {})
253
                git_conf = set(git_result["conflicts"])
254
                laws["false_conflict"] = bool(oak_conf - git_conf)
255
                laws["missed_conflict"] = bool(git_conf - oak_conf)
256
                pred = oak_result.get("prediction") or {}
257
                if pred.get("available"):
258
                    laws["prediction_agrees"] = (bool(pred.get("clean")) == bool(oak_result["clean"])
259
                                                 and set(pred.get("conflict_files") or []) == oak_conf)
260
            elif oak_result.get("error_kind") == "oak":
261
                # The real pull failed: a prediction that promised a merge (clean or a conflict
262
                # set) disagrees with reality; count it rather than dropping the row.
263
                pred = oak_result.get("prediction") or {}
264
                if pred.get("available"):
265
                    laws["prediction_agrees"] = False
266
                pf = oak_result.get("post_failure") or {}
267
                if pf.get("hazard_head_moved_worktree_stale") or pf.get("hazard_worktree_dirty_head_unmoved"):
268
                    out["post_failure_hazard"] = True
269
            if oak_result.get("post_failure"):
270
                out["oak_result"]["post_failure"] = oak_result["post_failure"]
271
        out["laws"] = laws
272
        cl.validate_class(out["class"])
273
        if evidence:
274
            out["evidence"] = evidence
275
        return out
276
277
278
KNOWN_BUGS = Path(__file__).resolve().parents[1] / "known_bugs.json"
279
280
281
def load_known_bugs(path: Path = KNOWN_BUGS) -> List[dict]:
282
    return json.loads(path.read_text())["bugs"] if path.exists() else []
283
284
285
def row_id(row: dict) -> str:
286
    return f"{row['scenario']}|{row['direction']}"
287
288
289
def _bug_covers(bug: dict, reason: str) -> bool:
290
    return any(re.search(p, reason) for p in bug.get("reason_patterns") or [])
291
292
293
def match_known_bug(row: dict, bugs: List[dict]) -> Optional[str]:
294
    """The known bug that explains an oak-wrong row, or None.
295
296
    A row maps to a bug only when EVERY reason on the row is covered by that one
297
    bug's reason patterns (and its optional scenario glob matches). A row with an
298
    extra, unexplained reason stays unmapped, so a new bug cannot hide behind a
299
    known one."""
300
    if row["class"] != "oak-wrong" or not row.get("reasons"):
301
        return None
302
    for bug in bugs:
303
        glob = bug.get("scenario_glob")
304
        if glob and not fnmatch.fnmatch(row["scenario"], glob):
305
            continue
306
        if all(_bug_covers(bug, reason) for reason in row["reasons"]):
307
            return bug["id"]
308
    return None
309
310
311
def missed_conflict_kind(row: dict) -> str:
312
    kinds = set()
313
    for types in ((row.get("git_result") or {}).get("conflicts") or {}).values():
314
        kinds.update(types)
315
    if any("rename" in k for k in kinds):
316
        return "rename-or-directory-rename"
317
    return "/".join(sorted(kinds)) or "unknown"
318
319
320
def summarize(rows: List[dict], bugs: Optional[List[dict]] = None) -> dict:
321
    bugs = load_known_bugs() if bugs is None else bugs
322
    mapped: Dict[str, List[str]] = {b["id"]: [] for b in bugs}
323
    unmapped: List[str] = []
324
    for r in rows:
325
        bug = match_known_bug(r, bugs)
326
        r["known_bug"] = bug
327
        if bug:
328
            mapped[bug].append(row_id(r))
329
        elif r["class"] == "oak-wrong":
330
            unmapped.append(row_id(r))
331
332
    def bug_report(bug: dict) -> dict:
333
        got, want = set(mapped[bug["id"]]), set(bug.get("expected_rows") or [])
334
        new, vanished = sorted(got - want), sorted(want - got)
335
        if not got:
336
            status = "NOT reproduced (fixed?)"
337
        elif vanished or new:
338
            status = "changed"
339
        else:
340
            status = "reproduced as pinned"
341
        return {"family": bug.get("family"), "severity": bug.get("severity"), "rows": len(got),
342
                "expected_rows": len(want), "new_rows": new, "vanished_rows": vanished, "status": status}
343
    missed_by_class: Dict[str, int] = {}
344
    missed_by_kind: Dict[str, int] = {}
345
    for r in rows:
346
        if r["laws"].get("missed_conflict"):
347
            missed_by_class[r["class"]] = missed_by_class.get(r["class"], 0) + 1
348
            k = missed_conflict_kind(r)
349
            missed_by_kind[k] = missed_by_kind.get(k, 0) + 1
350
    base = _summarize(rows)
351
    base.update({
352
        "known_bugs": {b["id"]: bug_report(b) for b in bugs},
353
        "oak_wrong_unmapped": len(unmapped),
354
        "oak_wrong_unmapped_rows": unmapped,
355
        "missed_conflict_by_class": missed_by_class,
356
        "missed_conflict_by_git_kind": missed_by_kind,
357
        "post_failure_hazard_rows": sum(1 for r in rows if r.get("post_failure_hazard")),
358
    })
359
    return base
360
361
362
def _summarize(rows: List[dict]) -> dict:
363
    counts = {c: 0 for c in cl.CLASSES}
364
    by_origin: Dict[str, Dict[str, int]] = {}
365
    for r in rows:
366
        counts[r["class"]] += 1
367
        by_origin.setdefault(r["origin"], {c: 0 for c in cl.CLASSES})[r["class"]] += 1
368
369
    def rate(key):
370
        vals = [r["laws"][key] for r in rows if r["laws"].get(key) is not None]
371
        return {"true": sum(1 for v in vals if v), "measured": len(vals)}
372
    return {"cases": len(rows), "scenarios": len({r["scenario"] for r in rows}), "counts": counts,
373
            "by_origin": by_origin,
374
            "laws": {k: rate(k) for k in ("no_lost_change", "idempotent", "symmetric", "false_conflict",
375
                                          "missed_conflict", "prediction_agrees")}}