| 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": None if output_truncated else len(recovered),
|
| 236 | "changed_files_reported": None if output_truncated else len(reported),
|
| 237 | "changed_files_false_positive": None if output_truncated else len(false_positives),
|
| 238 | "information_recall": None if output_truncated else recall,
|
| 239 | "information_precision": None if output_truncated else precision,
|
| 240 | "bytes_per_changed_file_named": None if output_truncated else 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_expected_tree_sha256=manifest_sha256(expected_manifest),
|
| 306 | patch_subject_tree_after_sha256=manifest_sha256(actual_manifest))
|
| 307 | if result.get("patch_apply_ok") is False and (application_required
|
| 308 | or result.get("patch_failure_source") == "subject_tree_manifest_comparison"):
|
| 309 | result.update(returncode=1, integrity_ok=False,
|
| 310 | integrity_failure_reason=result.get("patch_failure_reason"))
|
| 311 | return result
|
| 312 |
|
| 313 |
|
| 314 | def patch_application_fields(patch: bytes, pristine, expected_manifest: dict,
|
| 315 | workdir, git_binary: str = "git",
|
| 316 | output_truncated: bool = False,
|
| 317 | pristine_excluded_root_paths: tuple[str, ...] = ()) -> dict[str, Any]:
|
| 318 | """Apply actual bytes in a disposable copy and compare independent full tree.
|
| 319 |
|
| 320 | The Git binary is an external untimed correctness instrument, not a subject
|
| 321 | cost. No shell, fuzzy matching, --recount, --unsafe-paths or whitespace repair.
|
| 322 | The caller supplies a pristine tree and expected manifest independently of
|
| 323 | the patch. Non-applicable binary notices fail instead of counting as patches.
|
| 324 | """
|
| 325 | import hashlib
|
| 326 | from pathlib import Path
|
| 327 | import shutil
|
| 328 | import subprocess
|
| 329 | import tempfile
|
| 330 | from oakbench.conformance import tree_manifest
|
| 331 | from oakbench.environment import base_env
|
| 332 | fields = {"patch_oracle_version": PATCH_ORACLE_VERSION,
|
| 333 | "patch_apply_ok": None, "patch_apply_source": "git_apply_exact_tree",
|
| 334 | "patch_apply_returncode": None, "patch_tree_matches": None,
|
| 335 | "patch_input_sha256": hashlib.sha256(patch).hexdigest(),
|
| 336 | "patch_expected_tree_sha256": manifest_sha256(expected_manifest)}
|
| 337 | if output_truncated:
|
| 338 | return {**fields, "patch_apply_ok": False, "patch_failure_reason": "patch_output_truncated"}
|
| 339 | binary = shutil.which(git_binary)
|
| 340 | if binary is None:
|
| 341 | return {**fields, "patch_skip_reason": "git_apply_instrument_unavailable"}
|
| 342 | binary = str(Path(binary).resolve())
|
| 343 | try:
|
| 344 | identity = Path(binary).stat()
|
| 345 | fields.update(_patch_instrument_metadata(binary, (identity.st_dev, identity.st_ino,
|
| 346 | identity.st_size, identity.st_mtime_ns, identity.st_ctime_ns)))
|
| 347 | except (OSError, subprocess.SubprocessError) as exc:
|
| 348 | reason = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "error"
|
| 349 | return {**fields, "patch_skip_reason": "git_apply_instrument_" + reason,
|
| 350 | "patch_instrument_error": str(exc)}
|
| 351 | try:
|
| 352 | with tempfile.TemporaryDirectory(prefix="patch-oracle-", dir=workdir) as tmp:
|
| 353 | root = Path(tmp)
|
| 354 | tree = root / "tree"
|
| 355 | excluded = {".git", ".oak", *pristine_excluded_root_paths}
|
| 356 | shutil.copytree(pristine, tree, symlinks=True,
|
| 357 | ignore=lambda directory, names: excluded.intersection(names)
|
| 358 | if Path(directory) == Path(pristine) else set())
|
| 359 | fields["patch_pristine_excluded_root_paths"] = sorted(excluded)
|
| 360 | env = base_env(home=root / "home")
|
| 361 | env["GIT_CEILING_DIRECTORIES"] = str(root)
|
| 362 | process = subprocess.run([binary, "apply", "--whitespace=nowarn", "-"],
|
| 363 | input=patch, cwd=tree, env=env,
|
| 364 | capture_output=True, timeout=30)
|
| 365 | if process.returncode < 0:
|
| 366 | return {**fields, "patch_skip_reason": "git_apply_instrument_signal",
|
| 367 | "patch_instrument_returncode": process.returncode}
|
| 368 | fields["patch_apply_returncode"] = process.returncode
|
| 369 | actual = tree_manifest(tree)
|
| 370 | fields["patch_tree_matches"] = actual == expected_manifest
|
| 371 | fields["patch_apply_ok"] = process.returncode == 0 and fields["patch_tree_matches"]
|
| 372 | if not fields["patch_apply_ok"]:
|
| 373 | fields["patch_failure_reason"] = (
|
| 374 | "patch_application_failed" if process.returncode else "patch_result_tree_mismatch")
|
| 375 | fields["patch_stderr"] = process.stderr.decode("utf-8", "replace")[-2000:]
|
| 376 | fields["patch_tree_missing"] = sorted(expected_manifest.keys() - actual.keys())[:20]
|
| 377 | fields["patch_tree_extra"] = sorted(actual.keys() - expected_manifest.keys())[:20]
|
| 378 | fields["patch_tree_changed"] = sorted(
|
| 379 | path for path in expected_manifest.keys() & actual.keys()
|
| 380 | if expected_manifest[path] != actual[path])[:20]
|
| 381 | except (OSError, subprocess.SubprocessError) as exc:
|
| 382 | reason = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "error"
|
| 383 | fields.update(patch_apply_ok=None, patch_tree_matches=None, patch_apply_returncode=None,
|
| 384 | patch_skip_reason="git_apply_instrument_" + reason,
|
| 385 | patch_instrument_error=str(exc))
|
| 386 | return fields
|