Viewing files at commit 2735d7f5558eCommit detailsView latest

Browse files
scripts/oakbench/output_semantics.py 362 lines · 16.8 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 patch_application_fields(patch: bytes, pristine, expected_manifest: dict,
289
                             workdir, git_binary: str = "git",
290
                             output_truncated: bool = False,
291
                             pristine_excluded_root_paths: tuple[str, ...] = ()) -> dict[str, Any]:
292
    """Apply actual bytes in a disposable copy and compare independent full tree.
293
294
    The Git binary is an external untimed correctness instrument, not a subject
295
    cost. No shell, fuzzy matching, --recount, --unsafe-paths or whitespace repair.
296
    The caller supplies a pristine tree and expected manifest independently of
297
    the patch. Non-applicable binary notices fail instead of counting as patches.
298
    """
299
    import hashlib
300
    import json
301
    from pathlib import Path
302
    import shutil
303
    import subprocess
304
    import tempfile
305
    from oakbench.conformance import tree_manifest
306
    from oakbench.environment import base_env
307
    fields = {"patch_oracle_version": PATCH_ORACLE_VERSION,
308
              "patch_apply_ok": None, "patch_apply_source": "git_apply_exact_tree",
309
              "patch_apply_returncode": None, "patch_tree_matches": None,
310
              "patch_input_sha256": hashlib.sha256(patch).hexdigest(),
311
              "patch_expected_tree_sha256": hashlib.sha256(json.dumps(expected_manifest,
312
                  sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()}
313
    if output_truncated:
314
        return {**fields, "patch_apply_ok": False, "patch_failure_reason": "patch_output_truncated"}
315
    binary = shutil.which(git_binary)
316
    if binary is None:
317
        return {**fields, "patch_skip_reason": "git_apply_instrument_unavailable"}
318
    binary = str(Path(binary).resolve())
319
    try:
320
        identity = Path(binary).stat()
321
        fields.update(_patch_instrument_metadata(binary, (identity.st_dev, identity.st_ino,
322
                      identity.st_size, identity.st_mtime_ns, identity.st_ctime_ns)))
323
    except (OSError, subprocess.SubprocessError) as exc:
324
        reason = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "error"
325
        return {**fields, "patch_skip_reason": "git_apply_instrument_" + reason,
326
                "patch_instrument_error": str(exc)}
327
    try:
328
        with tempfile.TemporaryDirectory(prefix="patch-oracle-", dir=workdir) as tmp:
329
            root = Path(tmp)
330
            tree = root / "tree"
331
            excluded = {".git", ".oak", *pristine_excluded_root_paths}
332
            shutil.copytree(pristine, tree, symlinks=True,
333
                            ignore=lambda directory, names: excluded.intersection(names)
334
                            if Path(directory) == Path(pristine) else set())
335
            fields["patch_pristine_excluded_root_paths"] = sorted(excluded)
336
            env = base_env(home=root / "home")
337
            env["GIT_CEILING_DIRECTORIES"] = str(root)
338
            process = subprocess.run([binary, "apply", "--whitespace=nowarn", "-"],
339
                                     input=patch, cwd=tree, env=env,
340
                                     capture_output=True, timeout=30)
341
            if process.returncode < 0:
342
                return {**fields, "patch_skip_reason": "git_apply_instrument_signal",
343
                        "patch_instrument_returncode": process.returncode}
344
            fields["patch_apply_returncode"] = process.returncode
345
            actual = tree_manifest(tree)
346
            fields["patch_tree_matches"] = actual == expected_manifest
347
            fields["patch_apply_ok"] = process.returncode == 0 and fields["patch_tree_matches"]
348
            if not fields["patch_apply_ok"]:
349
                fields["patch_failure_reason"] = (
350
                    "patch_application_failed" if process.returncode else "patch_result_tree_mismatch")
351
                fields["patch_stderr"] = process.stderr.decode("utf-8", "replace")[-2000:]
352
                fields["patch_tree_missing"] = sorted(expected_manifest.keys() - actual.keys())[:20]
353
                fields["patch_tree_extra"] = sorted(actual.keys() - expected_manifest.keys())[:20]
354
                fields["patch_tree_changed"] = sorted(
355
                    path for path in expected_manifest.keys() & actual.keys()
356
                    if expected_manifest[path] != actual[path])[:20]
357
    except (OSError, subprocess.SubprocessError) as exc:
358
        reason = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "error"
359
        fields.update(patch_apply_ok=None, patch_tree_matches=None, patch_apply_returncode=None,
360
                      patch_skip_reason="git_apply_instrument_" + reason,
361
                      patch_instrument_error=str(exc))
362
    return fields