Skip to content

Commit bbc077c

Browse files
michaltakacDimensionLab OSS Sync
authored andcommitted
feat(arrayroute): verify materialized fixtures independently of file inventory
GitOrigin-RevId: 8860e4c0dd9a4a51a75c5b56271e51fb6ff9b419
1 parent b4d785a commit bbc077c

4 files changed

Lines changed: 321 additions & 0 deletions

File tree

‎packages/arrayroute/README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,24 @@ are synthetic scientific buffers, not solver output. OS caches are uncontrolled,
150150
byte estimates are not transport measurements, and engine thread budgets must be matched
151151
before using the results for a controlled comparison. See the report's method metadata.
152152

153+
The generator saves `materialization.json` before counting physical files. For very
154+
large fixtures, numerical verification can proceed independently of that inventory:
155+
156+
```sh
157+
python -m arrayroute.benchmark_materialized --root /tmp/arrayroute-pilot \
158+
--output /tmp/arrayroute-independent-scan --engine tensorstore
159+
```
160+
161+
This command is specific to the synthetic generator above. It validates the completed
162+
write progress and array shape, then regenerates each frame from its seed and checks
163+
the stored bytes. It retains per-frame expected/actual hashes, read times, generation
164+
times and hashing times separately, with a constant-state traversal permutation.
165+
`--limit` creates an explicitly partial pilot; `--deadline` stops between frames at
166+
the supplied Unix timestamp. A partial or failed scan never reports full verification.
167+
An existing output directory is refused. Keep the source immutable and suspend competing
168+
I/O for comparative measurements; the command does not stop other processes or control
169+
the OS cache. Its memory policy covers two frame arrays, not native decoder scratch.
170+
153171
To scan a **completed** fixture with multiple independent reader processes:
154172

155173
```sh

‎packages/arrayroute/src/arrayroute/benchmark.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def write_frame(array, t, value):
9797
"concurrent_layout_writers": 3,
9898
"layouts": {},
9999
}
100+
# Numeric verification must not wait for a multi-million-object inventory.
101+
(root / "materialization.json").write_text(json.dumps(receipt, indent=2))
100102
for name, array in arrays.items():
101103
file_count, file_bytes, allocated_bytes = 0, 0, 0
102104
for directory, _, names in os.walk(root / f"{name}.zarr"):
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Verify the synthetic layout benchmark without waiting for file inventory.
3+
4+
Regenerate each frame from its seed. This is deliberately specific to the fixture
5+
in ``arrayroute.benchmark``; it is not a verifier for arbitrary scientific data.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import hashlib
12+
import importlib.metadata
13+
import json
14+
import math
15+
import platform
16+
import time
17+
from pathlib import Path
18+
19+
import numpy as np
20+
21+
from .backends import TensorStoreBackend, ZarrBackend, ZarristaBackend
22+
from .permutation import Permutation
23+
from .reader import ArrayReader, Backend
24+
25+
26+
def reference_frame(frame: int, edge: int) -> np.ndarray:
27+
rng = np.random.default_rng(83491 + frame)
28+
value = rng.standard_normal((edge, edge, edge, 3), dtype=np.float32)
29+
x = np.arange(edge, dtype=np.float32) / edge
30+
value *= np.float32(0.03)
31+
value += np.sin(x[:, None, None, None] * 12 + frame * 0.1)
32+
return value
33+
34+
35+
def digest(value: np.ndarray) -> str:
36+
return hashlib.sha256(memoryview(np.ascontiguousarray(value)).cast("B")).hexdigest()
37+
38+
39+
def open_backend(path: Path, engine: str) -> Backend:
40+
if engine == "zarr-python":
41+
import zarr
42+
43+
return ZarrBackend(zarr.open_array(path, mode="r"))
44+
if engine == "zarrista":
45+
import zarrista
46+
47+
return ZarristaBackend(zarrista.Array.open(zarrista.store.FilesystemStore(str(path))))
48+
if engine == "tensorstore":
49+
import tensorstore as ts
50+
51+
return TensorStoreBackend(
52+
ts.open(
53+
{"driver": "zarr3", "kvstore": {"driver": "file", "path": str(path) + "/"}},
54+
read=True,
55+
context=ts.Context(
56+
{
57+
"cache_pool": {"total_bytes_limit": 0},
58+
"data_copy_concurrency": {"limit": 8},
59+
"file_io_concurrency": {"limit": 8},
60+
}
61+
),
62+
).result()
63+
)
64+
raise ValueError("Unknown engine")
65+
66+
67+
def scan(
68+
root: Path,
69+
output: Path,
70+
*,
71+
engine: str,
72+
layout: str = "sharded",
73+
deadline: float | None = None,
74+
limit: int | None = None,
75+
) -> dict:
76+
if layout not in {"frame", "brick", "sharded"}:
77+
raise ValueError("Unknown layout")
78+
if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or limit < 1):
79+
raise ValueError("limit must be a positive integer")
80+
if deadline is not None and not math.isfinite(deadline):
81+
raise ValueError("deadline must be finite")
82+
progress_bytes = (root / "progress.json").read_bytes()
83+
progress = json.loads(progress_bytes)
84+
backend = open_backend(root / f"{layout}.zarr", engine)
85+
shape = backend.spec.shape
86+
if (
87+
len(shape) != 5
88+
or shape[0] < 4
89+
or shape[1] < 32
90+
or shape[1] % 32
91+
or shape[1:4] != (shape[1],) * 3
92+
or shape[4] != 3
93+
or backend.dtype != np.dtype("float32")
94+
or progress.get("frames_written") != shape[0]
95+
or progress.get("frames_total") != shape[0]
96+
or progress.get("logical_bytes_per_layout") != math.prod(shape) * 4
97+
):
98+
raise ValueError("Fixture is incomplete or does not match the synthetic generator")
99+
output.mkdir(parents=True, exist_ok=False)
100+
(output / "source-progress.json").write_bytes(progress_bytes)
101+
versions = {}
102+
for package in ("numpy", "zarr", "zarrista", "tensorstore", "arrayroute"):
103+
try:
104+
versions[package] = importlib.metadata.version(package)
105+
except importlib.metadata.PackageNotFoundError:
106+
pass
107+
report = {
108+
"engine": engine,
109+
"layout": layout,
110+
"shape": shape,
111+
"generator": "arrayroute-broadband-83491-v1",
112+
"order": {"algorithm": Permutation.algorithm, "seed": 991, "epoch": 0},
113+
"state": "running",
114+
"complete": False,
115+
"frames_verified": 0,
116+
"verified_logical_bytes": 0,
117+
"read_seconds": 0.0,
118+
"reference_seconds": 0.0,
119+
"hash_seconds": 0.0,
120+
"started_at": time.time(),
121+
"deadline": deadline,
122+
"limit": limit,
123+
"versions": versions,
124+
"platform": platform.platform(),
125+
"cache_state": "OS uncontrolled; one traversal in seeded permuted order",
126+
"memory_policy": "One read frame and one regenerated reference; no dataset-sized index",
127+
"buffer_bytes": 256 << 20,
128+
}
129+
reader = ArrayReader(backend, buffer_bytes=256 << 20)
130+
started = time.perf_counter()
131+
132+
def save() -> None:
133+
report["elapsed_seconds"] = time.perf_counter() - started
134+
temporary = output / "report.tmp"
135+
temporary.write_text(json.dumps(report, indent=2) + "\n")
136+
temporary.replace(output / "report.json")
137+
138+
save()
139+
try:
140+
with (output / "frames.jsonl").open("x") as log:
141+
stop = min(shape[0], limit) if limit is not None else shape[0]
142+
for frame in Permutation(shape[0], seed=991).indices(stop=stop):
143+
if deadline is not None and time.time() >= deadline:
144+
report["state"] = "deadline"
145+
break
146+
start = time.perf_counter()
147+
value = reader.read((frame,) + (slice(None),) * 4)
148+
read_s = time.perf_counter() - start
149+
start = time.perf_counter()
150+
expected = reference_frame(frame, shape[1])
151+
reference_s = time.perf_counter() - start
152+
start = time.perf_counter()
153+
expected_hash, actual_hash = digest(expected), digest(value)
154+
hash_s = time.perf_counter() - start
155+
valid = value.shape == expected.shape and value.dtype == expected.dtype
156+
valid = valid and actual_hash == expected_hash
157+
log.write(json.dumps({
158+
"frame": frame, "bytes": value.nbytes, "read_s": read_s,
159+
"reference_s": reference_s, "hash_s": hash_s,
160+
"expected_sha256": expected_hash, "actual_sha256": actual_hash,
161+
"verified": valid,
162+
}) + "\n")
163+
log.flush()
164+
if not valid:
165+
raise ValueError(f"Frame {frame} differs from independently regenerated values")
166+
report["frames_verified"] += 1
167+
report["verified_logical_bytes"] += value.nbytes
168+
report["read_seconds"] += read_s
169+
report["reference_seconds"] += reference_s
170+
report["hash_seconds"] += hash_s
171+
del value, expected
172+
if report["frames_verified"] % 32 == 0:
173+
save()
174+
else:
175+
report["complete"] = report["frames_verified"] == shape[0]
176+
report["state"] = "complete" if report["complete"] else "limited"
177+
except BaseException as exc:
178+
report["state"] = "failed"
179+
report["error"] = f"{type(exc).__name__}: {exc}"
180+
raise
181+
finally:
182+
report["finished_at"] = time.time()
183+
seconds = report["read_seconds"]
184+
report["read_GB_s"] = report["verified_logical_bytes"] / seconds / 1e9 if seconds else None
185+
save()
186+
return report
187+
188+
189+
def main() -> None:
190+
parser = argparse.ArgumentParser(description=__doc__)
191+
parser.add_argument("--root", type=Path, required=True)
192+
parser.add_argument("--output", type=Path, required=True)
193+
parser.add_argument(
194+
"--engine", choices=["zarr-python", "zarrista", "tensorstore"], required=True
195+
)
196+
parser.add_argument("--layout", choices=["frame", "brick", "sharded"], default="sharded")
197+
parser.add_argument(
198+
"--deadline", type=float, help="Unix seconds; stop before starting a new frame"
199+
)
200+
parser.add_argument(
201+
"--limit", type=int, help="Pilot only; never marks an incomplete scan complete"
202+
)
203+
args = parser.parse_args()
204+
report = scan(**vars(args))
205+
print(json.dumps(report, indent=2))
206+
if report["state"] == "deadline":
207+
raise SystemExit(2)
208+
209+
210+
if __name__ == "__main__":
211+
main()
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
import json
3+
import time
4+
5+
import numpy as np
6+
import pytest
7+
import zarr
8+
9+
from arrayroute.benchmark import create_fixture
10+
from arrayroute.benchmark_materialized import digest, reference_frame, scan
11+
from arrayroute.permutation import Permutation
12+
13+
14+
@pytest.fixture
15+
def fixture(tmp_path):
16+
root = tmp_path / "data"
17+
create_fixture(root, frames=4, edge=32)
18+
# The expensive physical inventory is not a precondition for numeric verification.
19+
(root / "fixture.json").unlink()
20+
return root
21+
22+
23+
@pytest.mark.parametrize("engine", ["zarr-python", "tensorstore", "zarrista"])
24+
@pytest.mark.parametrize("layout", ["frame", "brick", "sharded"])
25+
def test_complete_scan_independently_verifies_every_frame(fixture, tmp_path, engine, layout):
26+
pytest.importorskip("zarr" if engine == "zarr-python" else engine)
27+
out = tmp_path / "report"
28+
report = scan(fixture, out, engine=engine, layout=layout)
29+
assert report["complete"] and report["state"] == "complete"
30+
assert report["frames_verified"] == 4
31+
assert report["verified_logical_bytes"] == 4 * 32**3 * 3 * 4
32+
lines = [json.loads(line) for line in (out / "frames.jsonl").read_text().splitlines()]
33+
assert sorted(line["frame"] for line in lines) == list(range(4))
34+
assert all(line["expected_sha256"] == line["actual_sha256"] for line in lines)
35+
with pytest.raises(FileExistsError):
36+
scan(fixture, out, engine=engine, layout=layout)
37+
38+
39+
def test_regenerator_matches_previously_verified_cloud_frame():
40+
assert digest(reference_frame(0, 128)) == (
41+
"e57096032394db1696cfe8484c0f2a25d13b129d791f186740b2732b949d2b53"
42+
)
43+
44+
45+
def test_corruption_is_recorded_and_never_marked_complete(fixture, tmp_path):
46+
array = zarr.open_array(fixture / "sharded.zarr", mode="r+")
47+
frame = Permutation(4, seed=991)[0]
48+
array[frame] = np.zeros((32, 32, 32, 3), dtype=np.float32)
49+
output = tmp_path / "corrupt"
50+
with pytest.raises(ValueError, match="differs"):
51+
scan(fixture, output, engine="zarr-python")
52+
report = json.loads((output / "report.json").read_text())
53+
assert not report["complete"] and report["state"] == "failed"
54+
assert report["frames_verified"] == 0
55+
assert json.loads((output / "frames.jsonl").read_text())["verified"] is False
56+
57+
58+
def test_deadline_and_pilot_are_partial(fixture, tmp_path):
59+
report = scan(fixture, tmp_path / "expired", engine="zarr-python", deadline=time.time() - 1)
60+
assert not report["complete"] and report["state"] == "deadline"
61+
assert report["frames_verified"] == 0
62+
report = scan(fixture, tmp_path / "pilot", engine="zarr-python", limit=2)
63+
assert not report["complete"] and report["state"] == "limited"
64+
assert report["frames_verified"] == 2
65+
66+
67+
def test_incomplete_materialization_refused(fixture, tmp_path):
68+
progress = fixture / "progress.json"
69+
info = json.loads(progress.read_text())
70+
info["frames_written"] -= 1
71+
progress.write_text(json.dumps(info))
72+
with pytest.raises(ValueError, match="incomplete"):
73+
scan(fixture, tmp_path / "report", engine="zarr-python")
74+
assert not (tmp_path / "report").exists()
75+
76+
77+
def test_materialization_receipt_precedes_inventory(tmp_path, monkeypatch):
78+
import arrayroute.benchmark as benchmark
79+
80+
def broken_inventory(*args, **kwargs):
81+
raise OSError("inventory failed")
82+
83+
monkeypatch.setattr(benchmark.os, "walk", broken_inventory)
84+
root = tmp_path / "fixture"
85+
with pytest.raises(OSError, match="inventory failed"):
86+
create_fixture(root, frames=4, edge=32)
87+
receipt = json.loads((root / "materialization.json").read_text())
88+
assert len(receipt["frame_sha256"]) == 4
89+
assert not (root / "fixture.json").exists()
90+
assert scan(root, tmp_path / "verified", engine="zarr-python")["complete"]

0 commit comments

Comments
 (0)