Skip to content

Commit 685ad1d

Browse files
paddymulclaude
andcommitted
fix: make tallyman_read_csv order-stable via polars, not ibis.row_number
original_row_order is now produced by an order-preserving polars read (scan_csv -> with_row_index) materialised to a stat-addressed parquet, then read back via deferred_read_parquet with a top-level order_by. A bare datafusion ROW_NUMBER() OVER () numbers rows in the nondeterministic arrival order of a parallel CSV scan (any file over the ~10MB repartition threshold), so it never pinned file order: the snapshot digest false-drifted run-to-run on the motivating large-CSV case while the 3-row test stayed green. The intermediate parquet is keyed on the CSV stat signature (idempotent; skips the sink if present), written atomically (tmp + rename), with pinned polars write settings so the bytes are reproducible within an environment. original_row_order is cast to int64 and placed last to preserve the documented schema shape; unmapped ibis types raise rather than silently inferring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59247b9 commit 685ad1d

1 file changed

Lines changed: 112 additions & 17 deletions

File tree

src/tallyman_xorq/io.py

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from pathlib import Path
1414

15-
from tallyman_core import data_dir, entry_dir, get_alias, resolve_project
15+
from tallyman_core import artifacts_dir, data_dir, entry_dir, get_alias, resolve_project
1616

1717

1818
class ProjectDataNotFound(FileNotFoundError):
@@ -75,29 +75,124 @@ def read_project_file(rel_path: str, project: str | None = None):
7575
return deferred_read_parquet(str(path))
7676

7777

78-
def tallyman_read_csv(path: str, schema=None, **kwargs):
79-
"""Read a CSV into a xorq expression with ``original_row_order`` injected.
80-
81-
Use this instead of ``xo.deferred_read_csv`` for all CSV ingests. It adds
82-
a 0-based ``original_row_order`` column (the source file's row sequence)
83-
which serves as the canonical total order for snapshot baking — making
84-
the entry's result digest byte-stable across builds.
85-
86-
Because ``original_row_order`` requires a ``RowNumber`` window op, entries
87-
built with this function are classified as snapshot-worthy and bake a
88-
sorted parquet snapshot on first build. Subsequent reads are fast
89-
(parquet scan, not CSV re-parse).
78+
# Pinned polars parquet-write settings for the ordered-CSV intermediate. Held
79+
# constant so the bytes — and therefore the snapshot digest — are reproducible
80+
# run-to-run within an environment. A polars/library upgrade may change the
81+
# bytes; rebuild-is-fine here per the project's single-user rule.
82+
_CSV_PARQUET_WRITE = {
83+
"compression": "zstd",
84+
"compression_level": 3,
85+
"row_group_size": 122880,
86+
"statistics": True,
87+
}
88+
89+
# ibis primitive -> polars dtype, for reading a CSV with an explicit schema.
90+
# Covers the types the MCP documents for tallyman_read_csv; an unmapped type
91+
# raises rather than silently inferring, so a schema mistake fails loudly.
92+
_IBIS_TO_POLARS = {
93+
"int8": "Int8", "int16": "Int16", "int32": "Int32", "int64": "Int64",
94+
"uint8": "UInt8", "uint16": "UInt16", "uint32": "UInt32", "uint64": "UInt64",
95+
"float32": "Float32", "float64": "Float64",
96+
"string": "String", "bool": "Boolean", "boolean": "Boolean",
97+
"date": "Date",
98+
}
99+
100+
101+
def _polars_overrides(schema):
102+
"""Translate an ibis schema to polars ``schema_overrides`` dtypes."""
103+
import polars as pl
104+
105+
overrides = {}
106+
for name, dtype in zip(schema.names, schema.types):
107+
key = str(dtype)
108+
if key.startswith("timestamp"):
109+
overrides[name] = pl.Datetime
110+
continue
111+
pl_name = _IBIS_TO_POLARS.get(key)
112+
if pl_name is None:
113+
raise ValueError(
114+
f"tallyman_read_csv: column {name!r} has ibis type {key!r}, which has no "
115+
f"polars mapping. Supported: {sorted(_IBIS_TO_POLARS)} (+ timestamp*)."
116+
)
117+
overrides[name] = getattr(pl, pl_name)
118+
return overrides
119+
120+
121+
def _ordered_csv_parquet(path: str, schema) -> Path:
122+
"""Materialise *path* to a row-order-stable parquet, keyed on the CSV's stat
123+
signature, and return its path (idempotent — skips the write if it exists).
124+
125+
polars ``scan_csv -> with_row_index -> sink_parquet`` preserves source file
126+
order (unlike datafusion's parallel scan, whose row order is nondeterministic
127+
above the repartition threshold), so ``original_row_order`` is the true 0..N-1
128+
file sequence and the written bytes are reproducible. See
129+
``plans/adr-result-digest-canonical-ordering.md``.
130+
"""
131+
import hashlib
132+
133+
import polars as pl
134+
135+
src = Path(path)
136+
st = src.stat()
137+
schema_sig = repr(sorted(zip(schema.names, [str(t) for t in schema.types]))) if schema is not None else "none"
138+
key = hashlib.md5( # noqa: S324 — naming key, not a security boundary
139+
f"{src.resolve()}|{st.st_mtime_ns}|{st.st_size}|{schema_sig}".encode()
140+
).hexdigest()
141+
142+
out_dir = artifacts_dir(resolve_project(None)) / "csv_ordered"
143+
out_dir.mkdir(parents=True, exist_ok=True)
144+
target = out_dir / f"{key}.parquet"
145+
if target.exists():
146+
return target
147+
148+
lf = pl.scan_csv(
149+
str(src),
150+
**({"schema_overrides": _polars_overrides(schema), "infer_schema_length": 0} if schema is not None else {}),
151+
).with_row_index("original_row_order")
152+
if schema is not None:
153+
cols = list(schema.names)
154+
else:
155+
cols = [c for c in lf.collect_schema().names() if c != "original_row_order"]
156+
# original_row_order last (matches the prior mutate-appends-a-column shape)
157+
# and cast to int64 (with_row_index yields uint32) for the documented schema.
158+
lf = lf.select([*cols, pl.col("original_row_order").cast(pl.Int64)])
159+
160+
tmp = target.with_suffix(".parquet.tmp")
161+
lf.sink_parquet(str(tmp), **_CSV_PARQUET_WRITE)
162+
tmp.replace(target) # atomic — a crash mid-write never leaves a partial at `target`
163+
return target
164+
165+
166+
def tallyman_read_csv(path: str, schema=None):
167+
"""Read a CSV into a xorq expression with a stable ``original_row_order``.
168+
169+
Use this instead of ``xo.deferred_read_csv`` for all CSV ingests. It adds a
170+
0-based ``original_row_order`` column holding the source file's true row
171+
sequence, which serves as the canonical total order for snapshot baking —
172+
making the entry's result digest byte-stable across builds.
173+
174+
The ordering is produced by an order-preserving polars read
175+
(``scan_csv -> with_row_index``) materialised to a stat-addressed parquet,
176+
*not* by ``ibis.row_number()``: a bare datafusion ``ROW_NUMBER() OVER ()``
177+
numbers rows in the nondeterministic arrival order of a parallel CSV scan
178+
(any file over datafusion's ~10 MB repartition threshold), so it would not
179+
pin file order at all. See ``plans/adr-result-digest-canonical-ordering.md``.
180+
181+
The returned expression is a ``deferred_read_parquet`` of that stable file
182+
with a top-level ``order_by("original_row_order")``, so it is classified
183+
snapshot-worthy and bakes a canonically-ordered parquet snapshot on build;
184+
the trailing sort also re-imposes the canonical order even if the parquet
185+
scan itself reparallelises.
90186
91187
Args:
92188
path: Absolute path to the CSV file.
93189
schema: Optional ibis schema for the columns (same as deferred_read_csv).
94-
**kwargs: Forwarded to ``xo.deferred_read_csv``.
190+
When omitted, polars infers types.
95191
"""
96192
import xorq.api as xo
97-
import xorq.vendor.ibis as ibis
98193

99-
t = xo.deferred_read_csv(path, schema=schema, **kwargs)
100-
return t.mutate(original_row_order=ibis.row_number()).order_by("original_row_order")
194+
parquet_path = _ordered_csv_parquet(path, schema)
195+
return xo.deferred_read_parquet(str(parquet_path)).order_by("original_row_order")
101196

102197

103198
def _reconstructing_source_digest(proj: str, rel_path: str) -> str | None:

0 commit comments

Comments
 (0)