Viewing files at commit 2e081b95a118Commit detailsView latest

Browse files
scripts/oakbench/output_semantics.py 385 lines · 17.9 KB · python
1
"""Information content of VCS output: bytes saved vs information lost.
2
3
Compact output is only a win if the agent can still act on it. These oracles
4
score a status/diff output against the fixture's ground-truth changed file
5
list: information_recall is the fraction of actually-changed paths an agent
6
(or a script) could recover from the bytes it was shown. A subject that emits
7
half the bytes but names half the files has not saved anything — it has moved
8
the cost to a follow-up command.
9
10
The second oracle is pipe compatibility: scripts and agents pipe diff output
11
into grep/patch/apply tooling that expects unified-diff structure. A diff
12
output without recognizable file headers and hunk markers silently breaks that
13
ecosystem; the probe records the structure so a format change is a visible
14
metric regression, not a downstream surprise.
15
"""
16
17
from __future__ import annotations
18
19
import re
20
from typing import Any, Iterable
21
22
STATUS_LINE = re.compile(r"^(?:(?!  )[ MADRCU?!]{2}|[MADRCU?!])\s+(.+)$")
23
UNIFIED_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@", re.MULTILINE)
24
UNIFIED_OLD_FILE = re.compile(r"^--- ", re.MULTILINE)
25
UNIFIED_NEW_FILE = re.compile(r"^\+\+\+ ", re.MULTILINE)
26
GIT_DIFF_HEADER = re.compile(r"^diff --git ", re.MULTILINE)
27
BINARY_NOTICE = re.compile(r"^Binary files? .* differ", re.MULTILINE | re.IGNORECASE)
28
29
30
OUTPUT_ORACLE_VERSION = "exact-paths-v2"
31
PATCH_ORACLE_VERSION = "apply-tree-v1"
32
33
34
def _decode_path(raw: str) -> str | None:
35
    """Decode Git C-quoted paths without stripping meaningful path whitespace."""
36
    import os
37
    if not raw.startswith('"'):
38
        return raw if raw and raw != "/dev/null" else None
39
    if len(raw) < 2 or not raw.endswith('"'):
40
        return None
41
    data = bytearray()
42
    text = raw[1:-1]
43
    escapes = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13,
44
               '"': 34, "\\": 92}
45
    i = 0
46
    while i < len(text):
47
        if text[i] != "\\":
48
            data.extend(os.fsencode(text[i])); i += 1; continue
49
        i += 1
50
        if i == len(text):
51
            return None
52
        if text[i] in escapes:
53
            data.append(escapes[text[i]]); i += 1
54
        elif text[i] in "01234567":
55
            digits = text[i:i + 3]
56
            if len(digits) != 3 or any(c not in "01234567" for c in digits):
57
                return None
58
            value = int(digits, 8)
59
            if value > 255:
60
                return None
61
            data.append(value); i += 3
62
        else:
63
            return None
64
    return os.fsdecode(bytes(data))
65
66
67
def _diff_path(raw: str) -> str | None:
68
    path = _decode_path(raw.split("\t", 1)[0])
69
    return path[2:] if path and path.startswith(("a/", "b/")) else path
70
71
72
def _split_unquoted(raw: str, separator: str) -> list[str]:
73
    pieces = []
74
    start = i = 0
75
    quoted = False
76
    while i < len(raw):
77
        if raw[i] == "\\" and quoted:
78
            i += 2
79
            continue
80
        if raw[i] == '"':
81
            quoted = not quoted
82
        if not quoted and raw.startswith(separator, i):
83
            pieces.append(raw[start:i])
84
            i += len(separator)
85
            start = i
86
        else:
87
            i += 1
88
    pieces.append(raw[start:])
89
    return pieces
90
91
92
def reported_paths(output_text: str, output_format: str = "auto") -> list[str]:
93
    """Parse metadata identities; never count a path substring or diff-body text.
94
95
    Explicit formats avoid interpreting a file name as another output grammar.
96
    Auto exists for historical callers and recognizes metadata, never bare names.
97
    Renames report both endpoints. Diffstat abbreviations remain literal unmatched
98
    identities: they cannot recover a full path that the output did not contain.
99
    """
100
    import json
101
    paths: set[str] = set()
102
    def add(path):
103
        if path is not None and path != "":
104
            paths.add(path)
105
    if output_format == "oak-json":
106
        try:
107
            payload = json.loads(output_text)
108
            changes = payload["changes"]
109
            if not isinstance(changes, list):
110
                return []
111
            for change in changes:
112
                if isinstance(change, dict) and isinstance(change.get("path"), str):
113
                    add(change["path"])
114
        except (ValueError, KeyError, TypeError):
115
            return []
116
        return sorted(paths)
117
    if output_format == "porcelain-z":
118
        records = iter(output_text.split("\0"))
119
        for record in records:
120
            if (len(record) >= 4 and record[2] == " " and record[:2] != "  "
121
                    and all(c in " MADRCU?!T" for c in record[:2])):
122
                add(record[3:])
123
                if "R" in record[:2] or "C" in record[:2]:
124
                    add(next(records, None))
125
        return sorted(paths)
126
    if output_format == "name-only-z":
127
        return sorted(set(output_text.rstrip("\0").split("\0"))) if output_text else []
128
    if output_format == "name-only":
129
        return sorted({p for line in output_text.split("\n") if (p := _decode_path(line)) is not None})
130
    if output_format not in {"auto", "status", "diff", "stat"}:
131
        raise ValueError(f"unknown output format: {output_format}")
132
    in_hunk = False
133
    for line in output_text.split("\n"):
134
        if output_format in {"auto", "diff"} and line.startswith(("diff --git ", "diff --oak ")):
135
            in_hunk = False
136
            raw = line[len("diff --git "):]
137
            # C-quoted endpoints can contain escaped quotes. Unquoted endpoints
138
            # with spaces are resolved by subsequent ---/+++ headers; identical
139
            # old/new names also have a unique equal-endpoint split.
140
            parts = re.findall(r'"(?:[^"\\]|\\.)*"|[^ ]+', raw)
141
            if len(parts) == 2:
142
                for part in parts:
143
                    add(_diff_path(part))
144
            elif raw.startswith("a/"):
145
                for match in re.finditer(" b/", raw):
146
                    old, new = raw[:match.start()], raw[match.start() + 1:]
147
                    if old[2:] == new[2:]:
148
                        add(old[2:]); break
149
            continue
150
        if output_format in {"auto", "diff"} and line.startswith("@@ "):
151
            in_hunk = True
152
            continue
153
        if in_hunk:
154
            continue
155
        if output_format in {"auto", "diff"} and line.startswith(("--- ", "+++ ")):
156
            add(_diff_path(line[4:])); continue
157
        if output_format in {"auto", "diff"} and line.startswith(("rename from ", "rename to ")):
158
            add(_decode_path(line.split(" ", 2)[2])); continue
159
        if output_format in {"auto", "diff"} and line.startswith("Binary files ") and line.endswith(" differ"):
160
            raw = line[len("Binary files "):-len(" differ")]
161
            parts = _split_unquoted(raw, " and ")
162
            if len(parts) == 2:
163
                left, right = parts
164
                add(_diff_path(left)); add(_diff_path(right))
165
            elif raw.startswith("a/"):
166
                for match in re.finditer(" and b/", raw):
167
                    left, right = raw[:match.start()], raw[match.start() + 5:]
168
                    if left[2:] == right[2:]:
169
                        add(left[2:]); break
170
            continue
171
        if output_format in {"auto", "status"}:
172
            payload = None
173
            codes = " MADRCU?!T"
174
            if len(line) > 3 and line[0] in codes and line[1] in codes and line[2] == " " and line[:2] != "  ":
175
                payload = line[3:]
176
                rename = "R" in line[:2] or "C" in line[:2]
177
            elif len(line) > 2 and line[0] in codes.strip() and line[1] == " ":
178
                payload = line[2:]
179
                rename = line[0] in "RC"
180
            if payload is not None:
181
                parts = _split_unquoted(payload, " -> ") if rename else [payload]
182
                for part in parts if len(parts) <= 2 else []:
183
                    add(_decode_path(part))
184
                continue
185
        if output_format in {"auto", "stat"} and " | " in line:
186
            raw = line.split(" | ", 1)[0]
187
            # Git diffstat has one formatting space, not arbitrary whitespace.
188
            raw = (raw[1:] if raw.startswith(" ") else raw).rstrip(" ")
189
            brace = re.fullmatch(r"(.*)\{(.*?) => (.*?)\}(.*)", raw)
190
            if brace:
191
                prefix, old, new, suffix = brace.groups()
192
                add(_decode_path(prefix + old + suffix)); add(_decode_path(prefix + new + suffix))
193
            elif " => " in raw:
194
                for part in raw.split(" => "):
195
                    add(_decode_path(part))
196
            else:
197
                add(_decode_path(raw))
198
        elif output_format in {"auto", "stat"}:
199
            oak_stat = re.fullmatch(r"[MADRCUT] (.+)  (?:\+\d+ -\d+|bin(?:ary)?)", line)
200
            if oak_stat:
201
                add(_decode_path(oak_stat.group(1)))
202
    return sorted(paths)
203
204
205
def recovered_paths(output_text: str, changed_paths: Iterable[str], output_format: str = "auto") -> list[str]:
206
    return sorted(set(changed_paths) & set(reported_paths(output_text, output_format)))
207
208
209
def information_recall_fields(
210
    output_text: str,
211
    changed_paths: list[str],
212
    output_bytes: int,
213
    output_truncated: bool,
214
    output_format: str = "auto",
215
) -> dict[str, Any]:
216
    """Recall and byte-efficiency fields for one status/diff output.
217
218
    information_recall is null (unmeasured) when there is no ground truth to
219
    score against; bytes_per_changed_file_named is null when nothing was
220
    recovered — never zero (ADR-0002).
221
    """
222
    expected_paths = set(changed_paths)
223
    recovered = recovered_paths(output_text, changed_paths, output_format)
224
    reported = reported_paths(output_text, output_format)
225
    false_positives = [path for path in reported if path not in expected_paths]
226
    true_reported = [path for path in reported if path in expected_paths]
227
    expected = len(expected_paths)
228
    recall = round(len(recovered) / expected, 4) if expected else None
229
    precision = round(len(true_reported) / len(reported), 4) if expected and reported else None
230
    bytes_per_named = round(output_bytes / len(recovered), 1) if recovered else None
231
    return {
232
        "output_oracle_version": OUTPUT_ORACLE_VERSION,
233
        "output_format": output_format,
234
        "changed_files_expected": expected,
235
        "changed_files_recovered": len(recovered),
236
        "changed_files_reported": len(reported),
237
        "changed_files_false_positive": len(false_positives),
238
        "information_recall": recall,
239
        "information_precision": precision,
240
        "bytes_per_changed_file_named": bytes_per_named,
241
        "probe_output_truncated": output_truncated,
242
        "information_recall_source": "exact_metadata_path_set",
243
        "information_precision_source": "exact_metadata_path_set",
244
    }
245
246
247
def unified_diff_compat_fields(output_text: str) -> dict[str, Any]:
248
    """Structure fields for piped-diff compatibility.
249
250
    Historical structural indicator only: headers/hunks or a binary notice.
251
    This does NOT prove a patch applies. Use patch_application_fields for that.
252
    """
253
    hunk_headers = len(UNIFIED_HUNK_HEADER.findall(output_text))
254
    old_headers = len(UNIFIED_OLD_FILE.findall(output_text))
255
    new_headers = len(UNIFIED_NEW_FILE.findall(output_text))
256
    git_headers = len(GIT_DIFF_HEADER.findall(output_text))
257
    binary_notices = len(BINARY_NOTICE.findall(output_text))
258
    has_text_structure = hunk_headers > 0 and old_headers > 0 and new_headers > 0
259
    return {
260
        "unified_hunk_header_count": hunk_headers,
261
        "unified_file_header_count": min(old_headers, new_headers),
262
        "git_diff_header_count": git_headers,
263
        "binary_notice_count": binary_notices,
264
        "pipe_compatible_unified": bool(has_text_structure or binary_notices > 0),
265
    }
266
267
268
from functools import lru_cache
269
270
271
@lru_cache(maxsize=16)
272
def _patch_instrument_metadata(binary: str, stat_key: tuple) -> dict[str, str]:
273
    """Cache successful identity by file identity; failures are never cached."""
274
    import hashlib
275
    from pathlib import Path
276
    import subprocess
277
    from oakbench.environment import base_env
278
    version = subprocess.run([binary, "--version"], capture_output=True,
279
                             env=base_env(), timeout=15, check=True)
280
    version_text = version.stdout.decode("utf-8", "replace").strip()
281
    if not version_text:
282
        raise OSError("Git instrument returned an empty version")
283
    return {"patch_instrument_binary": binary,
284
            "patch_instrument_sha256": hashlib.sha256(Path(binary).read_bytes()).hexdigest(),
285
            "patch_instrument_version": version_text}
286
287
288
def manifest_sha256(manifest: dict) -> str:
289
    """Canonical exact-tree fingerprint shared by patch and mutation evidence."""
290
    import hashlib
291
    import json
292
    return hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":"),
293
                                     ensure_ascii=True).encode()).hexdigest()
294
295
296
def finalize_patch_evidence(fields: dict, expected_manifest: dict, actual_manifest: dict,
297
                            *, application_required: bool) -> dict:
298
    """Classify measured patch/tree defects without mixing skip and failure."""
299
    result = dict(fields)
300
    if actual_manifest != expected_manifest:
301
        result.pop("patch_skip_reason", None)
302
        result.pop("patch_instrument_error", None)
303
        result.update(patch_apply_ok=False, patch_failure_reason="diff_mutated_subject_tree",
304
                      patch_failure_source="subject_tree_manifest_comparison",
305
                      patch_subject_tree_after_sha256=manifest_sha256(actual_manifest))
306
    if result.get("patch_apply_ok") is False and (application_required
307
            or result.get("patch_failure_source") == "subject_tree_manifest_comparison"):
308
        result.update(returncode=1, integrity_ok=False,
309
                      integrity_failure_reason=result.get("patch_failure_reason"))
310
    return result
311
312
313
def patch_application_fields(patch: bytes, pristine, expected_manifest: dict,
314
                             workdir, git_binary: str = "git",
315
                             output_truncated: bool = False,
316
                             pristine_excluded_root_paths: tuple[str, ...] = ()) -> dict[str, Any]:
317
    """Apply actual bytes in a disposable copy and compare independent full tree.
318
319
    The Git binary is an external untimed correctness instrument, not a subject
320
    cost. No shell, fuzzy matching, --recount, --unsafe-paths or whitespace repair.
321
    The caller supplies a pristine tree and expected manifest independently of
322
    the patch. Non-applicable binary notices fail instead of counting as patches.
323
    """
324
    import hashlib
325
    from pathlib import Path
326
    import shutil
327
    import subprocess
328
    import tempfile
329
    from oakbench.conformance import tree_manifest
330
    from oakbench.environment import base_env
331
    fields = {"patch_oracle_version": PATCH_ORACLE_VERSION,
332
              "patch_apply_ok": None, "patch_apply_source": "git_apply_exact_tree",
333
              "patch_apply_returncode": None, "patch_tree_matches": None,
334
              "patch_input_sha256": hashlib.sha256(patch).hexdigest(),
335
              "patch_expected_tree_sha256": manifest_sha256(expected_manifest)}
336
    if output_truncated:
337
        return {**fields, "patch_apply_ok": False, "patch_failure_reason": "patch_output_truncated"}
338
    binary = shutil.which(git_binary)
339
    if binary is None:
340
        return {**fields, "patch_skip_reason": "git_apply_instrument_unavailable"}
341
    binary = str(Path(binary).resolve())
342
    try:
343
        identity = Path(binary).stat()
344
        fields.update(_patch_instrument_metadata(binary, (identity.st_dev, identity.st_ino,
345
                      identity.st_size, identity.st_mtime_ns, identity.st_ctime_ns)))
346
    except (OSError, subprocess.SubprocessError) as exc:
347
        reason = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "error"
348
        return {**fields, "patch_skip_reason": "git_apply_instrument_" + reason,
349
                "patch_instrument_error": str(exc)}
350
    try:
351
        with tempfile.TemporaryDirectory(prefix="patch-oracle-", dir=workdir) as tmp:
352
            root = Path(tmp)
353
            tree = root / "tree"
354
            excluded = {".git", ".oak", *pristine_excluded_root_paths}
355
            shutil.copytree(pristine, tree, symlinks=True,
356
                            ignore=lambda directory, names: excluded.intersection(names)
357
                            if Path(directory) == Path(pristine) else set())
358
            fields["patch_pristine_excluded_root_paths"] = sorted(excluded)
359
            env = base_env(home=root / "home")
360
            env["GIT_CEILING_DIRECTORIES"] = str(root)
361
            process = subprocess.run([binary, "apply", "--whitespace=nowarn", "-"],
362
                                     input=patch, cwd=tree, env=env,
363
                                     capture_output=True, timeout=30)
364
            if process.returncode < 0:
365
                return {**fields, "patch_skip_reason": "git_apply_instrument_signal",
366
                        "patch_instrument_returncode": process.returncode}
367
            fields["patch_apply_returncode"] = process.returncode
368
            actual = tree_manifest(tree)
369
            fields["patch_tree_matches"] = actual == expected_manifest
370
            fields["patch_apply_ok"] = process.returncode == 0 and fields["patch_tree_matches"]
371
            if not fields["patch_apply_ok"]:
372
                fields["patch_failure_reason"] = (
373
                    "patch_application_failed" if process.returncode else "patch_result_tree_mismatch")
374
                fields["patch_stderr"] = process.stderr.decode("utf-8", "replace")[-2000:]
375
                fields["patch_tree_missing"] = sorted(expected_manifest.keys() - actual.keys())[:20]
376
                fields["patch_tree_extra"] = sorted(actual.keys() - expected_manifest.keys())[:20]
377
                fields["patch_tree_changed"] = sorted(
378
                    path for path in expected_manifest.keys() & actual.keys()
379
                    if expected_manifest[path] != actual[path])[:20]
380
    except (OSError, subprocess.SubprocessError) as exc:
381
        reason = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "error"
382
        fields.update(patch_apply_ok=None, patch_tree_matches=None, patch_apply_returncode=None,
383
                      patch_skip_reason="git_apply_instrument_" + reason,
384
                      patch_instrument_error=str(exc))
385
    return fields