Skip to content

Commit 082f5f2

Browse files
committed
fix(claims): verify query values and schemas
1 parent f459509 commit 082f5f2

6 files changed

Lines changed: 169 additions & 24 deletions

File tree

‎src/format_bench/claims/tsfile.py‎

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,41 @@
77
import pyarrow as pa
88
import pyarrow.parquet as pq
99

10+
from format_bench.fair import FairOperation, result_evidence
1011
from format_bench.runner import stats_ms
1112

1213

1314
TABLE_NAME = "sensors"
1415

1516

16-
def _measure(function: Callable[[], int], warmups: int, iterations: int) -> dict:
17+
def _normalize_result(name: str, value: Any) -> Any:
18+
if value is None:
19+
return None
20+
if isinstance(value, bytes):
21+
value = value.decode("utf-8")
22+
if name in {"temperature", "pressure"}:
23+
return float(value)
24+
if name == "sequence":
25+
return int(value)
26+
if name == "active":
27+
return bool(value)
28+
return str(value)
29+
30+
31+
def _measure(function: Callable[[], pa.Table], warmups: int, iterations: int) -> dict:
32+
result = pa.table({})
1733
for _ in range(warmups):
18-
function()
19-
samples, result = [], 0
34+
result = function()
35+
samples = []
2036
for _ in range(iterations):
2137
started = time.perf_counter_ns()
2238
result = function()
2339
samples.append((time.perf_counter_ns() - started) / 1_000_000)
24-
return {"timing": stats_ms(samples), "result": result}
40+
return {
41+
"timing": stats_ms(samples),
42+
"result": result.num_rows,
43+
"evidence": result_evidence(result, FairOperation.READ_ALL),
44+
}
2545

2646

2747
def _schema() -> tuple[Any, list[str], list[Any]]:
@@ -181,26 +201,50 @@ def run_tsfile_claim(
181201
end = min(points_per_device, start + max(1, points_per_device // 10))
182202
device = f"device-{device_index:03d}"
183203

184-
def ts_query() -> int:
185-
rows = 0
204+
def ts_query() -> pa.Table:
205+
rows = []
186206
with TsFileReader(str(tsfile_path)) as reader:
187207
with reader.query_table(
188208
TABLE_NAME, columns, start, end - 1, tag_eq("device", device)
189209
) as result:
190210
while result.next():
191-
rows += 1
192-
return rows
211+
rows.append(
212+
{
213+
name: _normalize_result(
214+
name, result.get_value_by_name(name)
215+
)
216+
for name in columns
217+
}
218+
)
219+
return pa.Table.from_pylist(
220+
rows,
221+
schema=pa.schema(
222+
[
223+
pa.field("device", pa.string()),
224+
pa.field("site", pa.string()),
225+
pa.field("temperature", pa.float64()),
226+
pa.field("pressure", pa.float64()),
227+
pa.field("active", pa.bool_()),
228+
pa.field("sequence", pa.int64()),
229+
]
230+
),
231+
)
193232

194-
def parquet_query() -> int:
233+
def parquet_query() -> pa.Table:
195234
return pq.read_table(
196235
parquet_path,
197236
columns=columns,
198237
filters=[("device", "=", device), ("timestamp", ">=", start), ("timestamp", "<", end)],
199-
).num_rows
238+
)
200239

201240
ts_result = _measure(ts_query, warmups, iterations)
202241
parquet_result = _measure(parquet_query, warmups, iterations)
203-
status = "MEASURED" if ts_result["result"] == parquet_result["result"] else "FAILED"
242+
status = (
243+
"MEASURED"
244+
if ts_result["result"] == parquet_result["result"]
245+
and ts_result["evidence"] == parquet_result["evidence"]
246+
else "FAILED"
247+
)
204248
return {
205249
"status": status,
206250
"rows": devices * points_per_device,
@@ -213,4 +257,8 @@ def parquet_query() -> int:
213257
},
214258
"bytes": {"tsfile": tsfile_path.stat().st_size, "parquet": parquet_path.stat().st_size},
215259
"timing": {"tsfile": ts_result, "parquet": parquet_result},
260+
"evidence": {
261+
"tsfile": ts_result["evidence"],
262+
"parquet": parquet_result["evidence"],
263+
},
216264
}

‎src/format_bench/claims/vortex.py‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,31 @@
1010
import vortex
1111
import vortex.expr as ve
1212

13+
from format_bench.fair import FairOperation, result_evidence
1314
from format_bench.runner import stats_ms
1415

1516

1617
ROW_GROUP_SIZE = 4096
1718
PROJECTION = ["full_name", "repo_stars"]
1819

1920

20-
def _measure(function: Callable[[], int], warmups: int, iterations: int) -> dict:
21+
def _measure(function: Callable[[], pa.Table], warmups: int, iterations: int) -> dict:
22+
result = pa.table({})
2123
for _ in range(warmups):
22-
function()
23-
samples, result = [], 0
24+
result = function()
25+
samples = []
2426
for _ in range(iterations):
2527
started = time.perf_counter_ns()
2628
result = function()
2729
samples.append((time.perf_counter_ns() - started) / 1_000_000)
28-
return {"timing": stats_ms(samples), "result": result}
30+
return {
31+
"timing": stats_ms(samples),
32+
"result": result.num_rows,
33+
"evidence": result_evidence(result, FairOperation.READ_ALL),
34+
}
2935

3036

31-
def _parquet_random_take(path: Path, indices: list[int]) -> int:
37+
def _parquet_random_take(path: Path, indices: list[int]) -> pa.Table:
3238
parquet = pq.ParquetFile(path)
3339
groups = sorted({index // ROW_GROUP_SIZE for index in indices})
3440
table = parquet.read_row_groups(groups, columns=PROJECTION)
@@ -37,7 +43,7 @@ def _parquet_random_take(path: Path, indices: list[int]) -> int:
3743
offsets[group] = offset
3844
offset += parquet.metadata.row_group(group).num_rows
3945
local = [offsets[index // ROW_GROUP_SIZE] + index % ROW_GROUP_SIZE for index in indices]
40-
return table.take(pa.array(local, type=pa.int64())).num_rows
46+
return table.take(pa.array(local, type=pa.int64()))
4147

4248

4349
def _write_compact(table: pa.Table, path: Path) -> None:
@@ -51,13 +57,12 @@ def _vortex_scan(
5157
*,
5258
expr=None,
5359
indices=None,
54-
) -> int:
60+
) -> pa.Table:
5561
source = vortex.open(str(path))
5662
return (
5763
source.scan(PROJECTION, expr=expr, indices=indices)
5864
.read_all()
5965
.to_arrow_table()
60-
.num_rows
6166
)
6267

6368

@@ -78,21 +83,21 @@ def _variant(
7883
vortex_indices = vortex.array(pa.array(indices, type=pa.uint64()))
7984
operations = {
8085
"full_projection": (
81-
lambda: pq.read_table(parquet_path, columns=PROJECTION).num_rows,
86+
lambda: pq.read_table(parquet_path, columns=PROJECTION),
8287
lambda: _vortex_scan(vortex_path),
8388
),
8489
"filter_popular": (
8590
lambda: pq.read_table(
8691
parquet_path, columns=PROJECTION, filters=[("repo_stars", ">", 100000)]
87-
).num_rows,
92+
),
8893
lambda: _vortex_scan(
8994
vortex_path, expr=ve.column("repo_stars") > 100000
9095
),
9196
),
9297
"filter_none": (
9398
lambda: pq.read_table(
9499
parquet_path, columns=PROJECTION, filters=[("repo_stars", ">", 99_999_999)]
95-
).num_rows,
100+
),
96101
lambda: _vortex_scan(
97102
vortex_path, expr=ve.column("repo_stars") > 99_999_999
98103
),
@@ -106,7 +111,10 @@ def _variant(
106111
for operation, (parquet_fn, vortex_fn) in operations.items():
107112
parquet_result = _measure(parquet_fn, warmups, iterations)
108113
vortex_result = _measure(vortex_fn, warmups, iterations)
109-
if parquet_result["result"] != vortex_result["result"]:
114+
if (
115+
parquet_result["result"] != vortex_result["result"]
116+
or parquet_result["evidence"] != vortex_result["evidence"]
117+
):
110118
raise ValueError(f"stress result mismatch for {name}/{operation}")
111119
measured[operation] = {"parquet": parquet_result, "vortex": vortex_result}
112120
return {

‎src/format_bench/fair.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ class ResultEvidence(TypedDict):
3939
normalized_hash: str
4040

4141

42+
def _evidence_type(field: pa.Field) -> str:
43+
# Arrow string_view is a storage-level view of the same UTF-8 logical type.
44+
return "string" if pa.types.is_string_view(field.type) else str(field.type)
45+
46+
4247
def operations_for(manifest: Mapping[str, object] | None = None) -> tuple[str, ...]:
4348
if manifest is not None and "workloads" in manifest:
4449
workloads = load_workloads(manifest)
@@ -131,7 +136,11 @@ def result_evidence(
131136
"rows": table.num_rows,
132137
"columns": table.column_names,
133138
"schema": [
134-
{"name": field.name, "type": str(field.type), "nullable": field.nullable}
139+
{
140+
"name": field.name,
141+
"type": _evidence_type(field),
142+
"nullable": field.nullable,
143+
}
135144
for field in table.schema
136145
],
137146
"row_order": "ORDER_SENSITIVE" if order_sensitive else "ORDER_INSENSITIVE",

‎tests/test_fair.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ def test_result_evidence_includes_type_and_nullability() -> None:
3434
)
3535

3636

37+
def test_result_evidence_distinguishes_same_rows_with_different_values() -> None:
38+
original = pa.table({"value": [1, 2]})
39+
changed = pa.table({"value": [1, 3]})
40+
41+
assert original.num_rows == changed.num_rows
42+
assert result_evidence(original, FairOperation.READ_ALL) != result_evidence(
43+
changed, FairOperation.READ_ALL
44+
)
45+
46+
47+
def test_result_evidence_distinguishes_same_rows_with_different_schema() -> None:
48+
original = pa.table({"value": pa.array([1, 2], type=pa.int64())})
49+
changed = pa.table({"value": pa.array([1, 2], type=pa.float64())})
50+
51+
assert original.num_rows == changed.num_rows
52+
assert result_evidence(original, FairOperation.READ_ALL) != result_evidence(
53+
changed, FairOperation.READ_ALL
54+
)
55+
56+
3757
def test_result_evidence_remains_order_insensitive() -> None:
3858
table = pa.table({"value": [1, 2, 3]})
3959
reversed_table = table.take(pa.array([2, 1, 0]))

‎tests/test_tsfile_claim.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import sys
12
from pathlib import Path
3+
from types import ModuleType
24

5+
import pyarrow as pa
36
import pytest
47

8+
import format_bench.claims.tsfile as tsfile_claim
59
from format_bench.claims import run_tsfile_claim
10+
from format_bench.fair import FairOperation, result_evidence
611

712

813
def test_tsfile_claim_matches_parquet_results(tmp_path: Path) -> None:
@@ -15,10 +20,44 @@ def test_tsfile_claim_matches_parquet_results(tmp_path: Path) -> None:
1520
assert result["rows"] == 20
1621
assert result["timing"]["tsfile"]["result"] == 1
1722
assert result["timing"]["parquet"]["result"] == 1
23+
assert result["evidence"]["tsfile"] == result["evidence"]["parquet"]
24+
assert result["evidence"]["tsfile"]["rows"] == 1
25+
assert result["evidence"]["tsfile"]["normalized_hash"]
1826
assert result["bytes"]["tsfile"] > 0
1927
assert result["writer_settings"]["tsfile"]["default_compression_type_"] == "LZ4"
2028
assert result["writer_settings"]["tsfile"]["time_encoding_type_"] == "TS_2DIFF"
2129
assert result["writer_settings"]["parquet"] == {
2230
"compression": "zstd",
2331
"row_group_size": 10,
2432
}
33+
34+
35+
def test_tsfile_claim_rejects_equal_counts_with_different_values(
36+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
37+
) -> None:
38+
fake_tsfile = ModuleType("tsfile")
39+
fake_tsfile.TsFileReader = object
40+
fake_tsfile.tag_eq = lambda *_: None
41+
monkeypatch.setitem(sys.modules, "tsfile", fake_tsfile)
42+
43+
def fake_write(tsfile_path: Path, parquet_path: Path, *_: int):
44+
tsfile_path.write_bytes(b"tsfile")
45+
parquet_path.write_bytes(b"parquet")
46+
return {"tsfile": 0.0, "parquet": 0.0}, {}
47+
48+
original = result_evidence(pa.table({"value": [1]}), FairOperation.READ_ALL)
49+
changed = result_evidence(pa.table({"value": [2]}), FairOperation.READ_ALL)
50+
measurements = iter(
51+
[
52+
{"timing": {}, "result": 1, "evidence": original},
53+
{"timing": {}, "result": 1, "evidence": changed},
54+
]
55+
)
56+
monkeypatch.setattr(tsfile_claim, "_write_datasets", fake_write)
57+
monkeypatch.setattr(tsfile_claim, "_measure", lambda *_: next(measurements))
58+
59+
result = tsfile_claim.run_tsfile_claim(
60+
tmp_path, devices=1, points_per_device=1, warmups=0, iterations=1
61+
)
62+
63+
assert result["status"] == "FAILED"

‎tests/test_vortex_adapter.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import json
22
from pathlib import Path
33

4+
import pyarrow as pa
45
import pytest
56

67
import format_bench.claims.vortex as vortex_claim
78
from format_bench.canonical import canonical_hash, query_counts, read_csv
89
from format_bench.claims import run_vortex_stress
10+
from format_bench.fair import FairOperation, result_evidence
911
from format_bench.formats import VortexAdapter
1012

1113

@@ -48,4 +50,23 @@ def tracked_open(path: str):
4850
for variant in ("sorted", "unsorted"):
4951
for operation in result[variant]["operations"].values():
5052
assert operation["parquet"]["result"] == operation["vortex"]["result"]
53+
assert operation["parquet"]["evidence"] == operation["vortex"]["evidence"]
5154
assert open_count == 8
55+
56+
57+
def test_vortex_stress_rejects_equal_counts_with_different_values(
58+
tmp_path: Path, fixture_contract, monkeypatch: pytest.MonkeyPatch
59+
) -> None:
60+
_, table = fixture_contract
61+
original = result_evidence(pa.table({"value": [1]}), FairOperation.READ_ALL)
62+
changed = result_evidence(pa.table({"value": [2]}), FairOperation.READ_ALL)
63+
measurements = iter(
64+
[
65+
{"result": 1, "evidence": original},
66+
{"result": 1, "evidence": changed},
67+
]
68+
)
69+
monkeypatch.setattr(vortex_claim, "_measure", lambda *_: next(measurements))
70+
71+
with pytest.raises(ValueError, match="stress result mismatch"):
72+
run_vortex_stress(table, tmp_path, rows=8, warmups=0, iterations=1)

0 commit comments

Comments
 (0)