|
12 | 12 |
|
13 | 13 | from pathlib import Path |
14 | 14 |
|
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 |
16 | 16 |
|
17 | 17 |
|
18 | 18 | class ProjectDataNotFound(FileNotFoundError): |
@@ -75,29 +75,124 @@ def read_project_file(rel_path: str, project: str | None = None): |
75 | 75 | return deferred_read_parquet(str(path)) |
76 | 76 |
|
77 | 77 |
|
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. |
90 | 186 |
|
91 | 187 | Args: |
92 | 188 | path: Absolute path to the CSV file. |
93 | 189 | 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. |
95 | 191 | """ |
96 | 192 | import xorq.api as xo |
97 | | - import xorq.vendor.ibis as ibis |
98 | 193 |
|
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") |
101 | 196 |
|
102 | 197 |
|
103 | 198 | def _reconstructing_source_digest(proj: str, rel_path: str) -> str | None: |
|
0 commit comments