Viewing files at commit 2f5107c20e85Commit detailsView latest

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