Skip to content

Commit 4e99eb1

Browse files
committed
test(compiler): compare large stage captures without loading them whole
Captures embedded every repeated subtree in full, so their size grew quadratically with sources per file and the harness held and parsed both engines' captures in memory; dense 3k ran out of memory. The capture writer now stores every container or string of at least 1 KiB once under the SHA-256 of its compact JSON, with invocation ids and native store suffixes masked, and refers to it as {"__shared__": digest}. Sets and unordered maps sort by the same masked form, so ordering never depends on noise the comparison ignores, and authored mappings using a reserved marker key are encoded as pairs. Each shared node and the root sit on their own line, so the harness indexes a capture by digest, treats equal digests as equal without reading them, and parses only the nodes on a differing path while still reporting the logical JSON pointer and resolved previews. Captures stay on disk, one directory per compared side, instead of in memory.
1 parent 45bf571 commit 4e99eb1

29 files changed

Lines changed: 747 additions & 111 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Compare two stage captures on disk without loading either one whole."""
2+
3+
from __future__ import annotations
4+
5+
import itertools
6+
import json
7+
from collections.abc import Iterator
8+
from pathlib import Path
9+
10+
from scripts.compiler_differential._helpers.comparing.compare import (
11+
first_json_difference,
12+
preview,
13+
)
14+
from scripts.compiler_differential._helpers.comparing.normalize import normalize_artifact_text
15+
from scripts.compiler_differential.classes.capture_file import CaptureFile
16+
from scripts.compiler_differential.constants import MISSING_VALUE
17+
from scripts.compiler_differential.models import Divergence
18+
19+
20+
def first_capture_difference(*, left: Path, right: Path) -> Divergence | None:
21+
"""Return the first logical JSON pointer, or line for invalid JSON, where captures differ."""
22+
23+
try:
24+
left_capture: CaptureFile = CaptureFile(left)
25+
right_capture: CaptureFile = CaptureFile(right)
26+
return first_json_difference(
27+
left=left_capture.root,
28+
right=right_capture.root,
29+
resolve=(left_capture.resolve, right_capture.resolve),
30+
)
31+
except (json.JSONDecodeError, UnicodeDecodeError):
32+
return _first_line_difference(left=left, right=right)
33+
34+
35+
def _first_line_difference(*, left: Path, right: Path) -> Divergence | None:
36+
with left.open("rb") as left_stream, right.open("rb") as right_stream:
37+
lines: Iterator[tuple[bytes | None, bytes | None]] = itertools.zip_longest(
38+
left_stream, right_stream
39+
)
40+
for number, (left_line, right_line) in enumerate(lines, start=1):
41+
left_text: str = _line_text(left_line)
42+
right_text: str = _line_text(right_line)
43+
if left_text != right_text:
44+
return Divergence(
45+
location=f"line {number}", left=preview(left_text), right=preview(right_text)
46+
)
47+
return None
48+
49+
50+
def _line_text(line: bytes | None) -> str:
51+
if line is None:
52+
return MISSING_VALUE
53+
return normalize_artifact_text(line.decode("utf-8", "surrogateescape").rstrip("\n"))

‎scripts/compiler_differential/_helpers/comparing/compare.py‎

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,50 @@
33
from __future__ import annotations
44

55
import json
6+
from collections.abc import Callable
67
from typing import cast
78

89
from scripts.compiler_differential.constants import MISSING_VALUE, VALUE_PREVIEW_CHARACTERS
910
from scripts.compiler_differential.models import Divergence
11+
from scripts.compiler_differential.types import NodeResolvers
12+
from sqlbuild.compiler.frontier.constants import STAGE_CAPTURE_SHARED_MARKER
1013

1114

12-
def first_json_difference(*, left: object, right: object, pointer: str = "") -> Divergence | None:
13-
"""Return the first JSON pointer whose value, type, or object key order differs."""
15+
def first_json_difference(
16+
*, left: object, right: object, pointer: str = "", resolve: NodeResolvers | None = None
17+
) -> Divergence | None:
18+
"""Return the first JSON pointer whose value, type, or key order differs, via `resolve`."""
1419

20+
if resolve is not None:
21+
left_digest: str | None = shared_digest(left)
22+
if left_digest is not None and left_digest == shared_digest(right):
23+
return None
24+
left, right = resolve[0](left), resolve[1](right)
1525
if type(left) is not type(right):
1626
return _divergence(location=pointer or "/", left=left, right=right)
1727
left_object: dict[str, object] | None = as_json_object(left)
1828
right_object: dict[str, object] | None = as_json_object(right)
1929
if left_object is not None and right_object is not None:
20-
return _first_object_difference(left=left_object, right=right_object, pointer=pointer)
30+
return _first_object_difference(
31+
left=left_object, right=right_object, pointer=pointer, resolve=resolve
32+
)
2133
if isinstance(left, list) and isinstance(right, list):
2234
for index, (left_item, right_item) in enumerate(zip(left, right, strict=False)):
2335
found: Divergence | None = first_json_difference(
24-
left=left_item, right=right_item, pointer=f"{pointer}/{index}"
36+
left=left_item, right=right_item, pointer=f"{pointer}/{index}", resolve=resolve
2537
)
2638
if found is not None:
2739
return found
2840
if len(left) != len(right):
2941
index: int = min(len(left), len(right))
3042
return _divergence(
3143
location=f"{pointer}/{index}",
32-
left=left[index] if index < len(left) else MISSING_VALUE,
33-
right=right[index] if index < len(right) else MISSING_VALUE,
44+
left=_shown(value=left[index], resolve=resolve, side=0)
45+
if index < len(left)
46+
else MISSING_VALUE,
47+
right=_shown(value=right[index], resolve=resolve, side=1)
48+
if index < len(right)
49+
else MISSING_VALUE,
3450
)
3551
return None
3652
if left != right:
@@ -50,20 +66,19 @@ def first_text_difference(*, left: str, right: str) -> Divergence | None:
5066
right_line: str = right_lines[index] if index < len(right_lines) else MISSING_VALUE
5167
if left_line != right_line:
5268
return Divergence(
53-
location=f"line {index + 1}", left=_preview(left_line), right=_preview(right_line)
69+
location=f"line {index + 1}", left=preview(left_line), right=preview(right_line)
5470
)
5571
return Divergence(location="end of file", left="<bytes differ>", right="<bytes differ>")
5672

5773

58-
def first_document_difference(*, left: str, right: str) -> Divergence | None:
59-
"""Compare two documents as JSON when both parse, otherwise line by line."""
74+
def shared_digest(value: object) -> str | None:
75+
"""Return the digest a stage-capture `{"__shared__": digest}` reference names."""
6076

61-
try:
62-
left_payload: object = json.loads(left)
63-
right_payload: object = json.loads(right)
64-
except json.JSONDecodeError:
65-
return first_text_difference(left=left, right=right)
66-
return first_json_difference(left=left_payload, right=right_payload)
77+
mapping: dict[str, object] | None = as_json_object(value)
78+
if mapping is None or len(mapping) != 1:
79+
return None
80+
digest: object = mapping.get(STAGE_CAPTURE_SHARED_MARKER)
81+
return digest if isinstance(digest, str) else None
6782

6883

6984
def as_json_object(value: object) -> dict[str, object] | None:
@@ -79,35 +94,70 @@ def json_pointer_token(key: str) -> str:
7994

8095

8196
def _first_object_difference(
82-
*, left: dict[str, object], right: dict[str, object], pointer: str
97+
*,
98+
left: dict[str, object],
99+
right: dict[str, object],
100+
pointer: str,
101+
resolve: NodeResolvers | None,
83102
) -> Divergence | None:
84103
for key in (*left, *(key for key in right if key not in left)):
85104
location: str = f"{pointer}/{json_pointer_token(key)}"
86105
if key not in left or key not in right:
87106
return _divergence(
88107
location=location,
89-
left=left.get(key, MISSING_VALUE),
90-
right=right.get(key, MISSING_VALUE),
108+
left=_shown(value=left.get(key, MISSING_VALUE), resolve=resolve, side=0),
109+
right=_shown(value=right.get(key, MISSING_VALUE), resolve=resolve, side=1),
91110
)
92111
found: Divergence | None = first_json_difference(
93-
left=left[key], right=right[key], pointer=location
112+
left=left[key], right=right[key], pointer=location, resolve=resolve
94113
)
95114
if found is not None:
96115
return found
97116
if list(left) != list(right):
98117
return Divergence(
99118
location=f"{pointer or '/'} (key order)",
100-
left=_preview(list(left)),
101-
right=_preview(list(right)),
119+
left=preview(list(left)),
120+
right=preview(list(right)),
102121
)
103122
return None
104123

105124

125+
def _shown(*, value: object, resolve: NodeResolvers | None, side: int) -> object:
126+
if resolve is None:
127+
return value
128+
return _previewed(value=value, resolve=resolve[side], remaining=VALUE_PREVIEW_CHARACTERS)[0]
129+
130+
131+
def _previewed(
132+
*, value: object, resolve: Callable[[object], object], remaining: int
133+
) -> tuple[object, int]:
134+
if remaining <= 0:
135+
return value, remaining
136+
resolved: object = resolve(value)
137+
mapping: dict[str, object] | None = as_json_object(resolved)
138+
if mapping is not None:
139+
shown: dict[str, object] = {}
140+
for key, item in mapping.items():
141+
shown[key], remaining = _previewed(
142+
value=item, resolve=resolve, remaining=remaining - len(key)
143+
)
144+
return shown, remaining
145+
if isinstance(resolved, list):
146+
items: list[object] = []
147+
for item in resolved:
148+
shown_item, remaining = _previewed(value=item, resolve=resolve, remaining=remaining)
149+
items.append(shown_item)
150+
return items, remaining
151+
return resolved, remaining - len(json.dumps(resolved, ensure_ascii=False))
152+
153+
106154
def _divergence(*, location: str, left: object, right: object) -> Divergence:
107-
return Divergence(location=location, left=_preview(left), right=_preview(right))
155+
return Divergence(location=location, left=preview(left), right=preview(right))
156+
108157

158+
def preview(value: object) -> str:
159+
"""Render a value for a report, shortened to the preview width."""
109160

110-
def _preview(value: object) -> str:
111161
rendered: str = (
112162
value
113163
if isinstance(value, str) and value == MISSING_VALUE

‎scripts/compiler_differential/_helpers/comparing/comparison.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
import json
66
from collections.abc import Callable
7+
from pathlib import Path
78

9+
from scripts.compiler_differential._helpers.comparing.capture import first_capture_difference
810
from scripts.compiler_differential._helpers.comparing.compare import (
911
as_json_object,
10-
first_document_difference,
1112
first_json_difference,
1213
first_text_difference,
1314
)
@@ -237,12 +238,12 @@ def _loads(text: str | None) -> object:
237238

238239

239240
def _capture_differences(
240-
*, left: dict[str, dict[str, str]], right: dict[str, dict[str, str]]
241+
*, left: dict[str, dict[str, Path]], right: dict[str, dict[str, Path]]
241242
) -> list[tuple[str, Divergence | None]]:
242243
differences: list[tuple[str, Divergence | None]] = []
243244
for command in sorted(set(left) | set(right)):
244-
left_files: dict[str, str] = left.get(command, {})
245-
right_files: dict[str, str] = right.get(command, {})
245+
left_files: dict[str, Path] = left.get(command, {})
246+
right_files: dict[str, Path] = right.get(command, {})
246247
for name in sorted(set(left_files) | set(right_files)):
247248
artifact: str = f"stage capture {command}/{name}"
248249
if name not in left_files or name not in right_files:
@@ -258,10 +259,7 @@ def _capture_differences(
258259
differences.append(
259260
(
260261
artifact,
261-
first_document_difference(
262-
left=normalize_artifact_text(left_files[name]),
263-
right=normalize_artifact_text(right_files[name]),
264-
),
262+
first_capture_difference(left=left_files[name], right=right_files[name]),
265263
)
266264
)
267265
return differences

‎scripts/compiler_differential/_helpers/comparing/normalize.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,16 @@
66
from scripts.compiler_differential.constants import (
77
ELAPSED_TIME_MASK,
88
ELAPSED_TIME_PATTERN,
9-
ENGINE_NAMESPACE_PATTERN,
10-
INVOCATION_ID_MASK,
11-
INVOCATION_ID_PATTERN,
129
MANIFEST_VOLATILE_METADATA,
1310
STRIPPED_REPORT_FIELDS,
1411
)
12+
from sqlbuild.compiler.frontier.classes.stage_capture_encoder import mask_capture_noise
1513

1614

1715
def normalize_artifact_text(text: str) -> str:
1816
"""Mask invocation ids and engine store suffixes, which differ by design."""
1917

20-
return ENGINE_NAMESPACE_PATTERN.sub("", INVOCATION_ID_PATTERN.sub(INVOCATION_ID_MASK, text))
18+
return mask_capture_noise(text)
2119

2220

2321
def normalize_stderr(text: str) -> str:

‎scripts/compiler_differential/_helpers/comparing/project_comparison.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
from scripts.compiler_differential._helpers.coverage.discovery import project_discovery_kinds
1414
from scripts.compiler_differential._helpers.coverage.render import project_render_kinds
1515
from scripts.compiler_differential._helpers.running.execution import run_engine
16-
from scripts.compiler_differential.constants import ERROR_SEVERITY, WARNING_SEVERITY
16+
from scripts.compiler_differential.constants import (
17+
ERROR_SEVERITY,
18+
LEFT_SIDE,
19+
RIGHT_SIDE,
20+
WARNING_SEVERITY,
21+
)
1722
from scripts.compiler_differential.models import (
1823
CommandOutcome,
1924
CorpusProject,
@@ -42,13 +47,15 @@ def compare_project(*, project: CorpusProject, options: DifferentialOptions) ->
4247
source_dir=source_dir,
4348
case_dir=case_dir,
4449
engine=left_engine,
50+
side=LEFT_SIDE,
4551
options=options,
4652
)
4753
right: EngineRun = run_engine(
4854
project=project,
4955
source_dir=source_dir,
5056
case_dir=case_dir,
5157
engine=right_engine,
58+
side=RIGHT_SIDE,
5259
options=options,
5360
)
5461
counted: bool = project.seed_coverage and options.stage_captures

‎scripts/compiler_differential/_helpers/corpus/emitted_codes.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DIAGNOSTIC_CODE_PATTERN,
1818
ERROR_SEVERITY,
1919
EXPECT_SUCCESS,
20+
LEFT_SIDE,
2021
RENDER_CODE_SCAN_EXCLUDED,
2122
RENDER_RAISED_CODES,
2223
WARNING_SEVERITY,
@@ -139,6 +140,7 @@ def _emitted_codes(*, project: CorpusProject, options: DifferentialOptions) -> E
139140
source_dir=source_dir,
140141
case_dir=case_dir,
141142
engine=options.engines[0],
143+
side=LEFT_SIDE,
142144
options=options,
143145
)
144146
return EmittedCodes(

‎scripts/compiler_differential/_helpers/coverage/capture_values.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from __future__ import annotations
44

5-
import json
65
import re
76
from collections.abc import Iterator
87

98
from scripts.compiler_differential._helpers.comparing.compare import as_json_object
9+
from scripts.compiler_differential.classes.capture_file import expand_capture_text
1010
from scripts.compiler_differential.constants import (
1111
CALLABLE_MARKER,
1212
COLLECTION_CAPTURE_MARKERS,
@@ -87,7 +87,7 @@ def capture_problems(
8787
) -> tuple[str, ...]:
8888
"""Return why a capture is incomplete or not canonical; empty when it is sound."""
8989

90-
capture: dict[str, object] = as_json_object(json.loads(capture_text)) or {}
90+
capture: dict[str, object] = as_json_object(expand_capture_text(capture_text)) or {}
9191
problems: list[str] = []
9292
captured: tuple[str, ...] = tuple(key for key in capture if key != TYPE_MARKER)
9393
if captured != fields:

‎scripts/compiler_differential/_helpers/coverage/discovery.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
named_values,
1818
records,
1919
)
20+
from scripts.compiler_differential.classes.capture_file import expand_capture_text
2021
from scripts.compiler_differential.constants import (
2122
CALLABLE_CAPTURE_FIELDS,
2223
CRLF_BYTES,
@@ -59,7 +60,7 @@ def discovery_collection_kinds() -> tuple[str, ...]:
5960

6061

6162
def project_discovery_kinds(
62-
*, captures: dict[str, dict[str, str]], source_dir: Path
63+
*, captures: dict[str, dict[str, Path]], source_dir: Path
6364
) -> frozenset[str]:
6465
"""Return the kinds of every discovery capture plus CRLF, which only the bytes show."""
6566

@@ -75,7 +76,7 @@ def project_discovery_kinds(
7576
def discovered_relative_paths(capture_text: str) -> frozenset[str]:
7677
"""Return the project-relative path of every file a discovery capture says it read."""
7778

78-
capture: object = json.loads(capture_text)
79+
capture: object = expand_capture_text(capture_text)
7980
return frozenset(
8081
str(path)
8182
for _, value in named_values(value=capture, names=RELATIVE_PATH_FIELDS)
@@ -94,17 +95,17 @@ def authored_byte_kinds(*, source_dir: Path, relative_paths: frozenset[str]) ->
9495
)
9596

9697

97-
def _discovery_capture_texts(captures: dict[str, dict[str, str]]) -> Iterator[str]:
98+
def _discovery_capture_texts(captures: dict[str, dict[str, Path]]) -> Iterator[str]:
9899
for files in captures.values():
99-
for name, text in files.items():
100+
for name, path in files.items():
100101
if name.endswith(DISCOVERY_STAGE_CAPTURE_SUFFIX):
101-
yield text
102+
yield path.read_text(encoding="utf-8")
102103

103104

104105
def discovered_input_kinds(capture_text: str) -> frozenset[str]:
105106
"""Return the input kinds one canonical discovery capture proves were discovered."""
106107

107-
capture: dict[str, object] = as_json_object(json.loads(capture_text)) or {}
108+
capture: dict[str, object] = as_json_object(expand_capture_text(capture_text)) or {}
108109
kinds: set[str] = {
109110
kind for kind in discovery_collection_kinds() if has_content(capture.get(kind))
110111
}

0 commit comments

Comments
 (0)