@@ -18,8 +18,13 @@ class ProjectDataNotFound(FileNotFoundError):
1818 pass
1919
2020
21- def project_path (rel_path : str , project : str | None = None ) -> Path :
22- """Resolve `rel_path` against the project's data dir. Raises if absent."""
21+ def project_path (rel_path : str , project : str | None = None , must_exist : bool = True ) -> Path :
22+ """Resolve `rel_path` against the project's data dir. Raises if absent.
23+
24+ Pass ``must_exist=False`` for digest-pinned reconstruction: it serves the frozen
25+ ``.cas`` clone (``recon_cas_path``) and must not require the live source to still
26+ be present. The traversal guard still applies regardless.
27+ """
2328 proj = resolve_project (project )
2429 base = data_dir (proj )
2530 candidate = (base / rel_path ).resolve ()
@@ -28,7 +33,7 @@ def project_path(rel_path: str, project: str | None = None) -> Path:
2833 candidate .relative_to (base .resolve ())
2934 except ValueError as exc :
3035 raise ProjectDataNotFound (f"{ rel_path !r} resolves outside { base } (got { candidate } )" ) from exc
31- if not candidate .is_file ():
36+ if must_exist and not candidate .is_file ():
3237 raise ProjectDataNotFound (f"{ candidate } not found" )
3338 return candidate
3439
@@ -52,7 +57,6 @@ def read_project_file(rel_path: str, project: str | None = None):
5257 from tallyman_xorq import source_identity as si
5358
5459 proj = resolve_project (project )
55- path = project_path (rel_path , proj )
5660 if si .mode () == "cas" :
5761 recorded = _reconstructing_source_digest (proj , rel_path )
5862 if recorded is not None :
@@ -64,8 +68,14 @@ def read_project_file(rel_path: str, project: str | None = None):
6468 # source collector is independent of tracked_expr_from_alias's _RECONSTRUCTING-gated
6569 # parent capture, so both fire) so a child build reconstructing this entry
6670 # records the frozen digest and gc_cas keeps the clone alive.
71+ #
72+ # must_exist=False: recon_cas_path serves the frozen clone without the live
73+ # source, so a moved/deleted source must not defeat the pinned read. This
74+ # block MUST run before the existence-requiring project_path below.
75+ recon_path = project_path (rel_path , proj , must_exist = False )
6776 si .note_source (rel_path , recorded )
68- return deferred_read_parquet (str (si .recon_cas_path (proj , path , recorded )))
77+ return deferred_read_parquet (str (si .recon_cas_path (proj , recon_path , recorded )))
78+ path = project_path (rel_path , proj )
6979 if si .mode () != "off" :
7080 digest = si .digest_for (proj , path )
7181 si .note_source (rel_path , digest )
@@ -155,6 +165,13 @@ def _polars_overrides(schema):
155165_REST = "&rest" # wildcard closure: the remaining tail columns (ADR D3)
156166_BEGIN = "&begin" # wildcard closure: the leading head columns (specced, not yet implemented)
157167
168+ # scan_csv options tallyman_read_csv owns and must not accept as pass-through
169+ # **kwargs: every internal scan_csv call hardcodes infer_schema_length (the
170+ # escalation ladder) and schema_overrides (derived from schema=), so forwarding
171+ # either collides ("multiple values for keyword argument") or silently bypasses
172+ # the schema system. Rejected up front with a message steering to schema=.
173+ _RESERVED_SCAN_KWARGS = ("infer_schema_length" , "schema_overrides" )
174+
158175
159176def _spec_sig (spec ) -> str :
160177 """A stable string for the cache key, covering every schema-spec shape.
@@ -189,19 +206,39 @@ def _spec_pairs(spec):
189206 return list (spec .items ())
190207
191208
192- def _normalize_schema (spec , header : list [str ]) -> dict :
209+ def _normalize_schema (spec , header : list [str ], * , reserved : tuple [ str , ...] = () ) -> dict :
193210 """Resolve a schema spec against the CSV *header* into a polars parse plan.
194211
212+ *header* is the CSV's full header. *reserved* names tallyman-managed columns
213+ (currently ``original_row_order``) that the caller must not spec: they are
214+ excluded from the columns the spec has to cover, but kept in the diagnostics
215+ so an error message matches the file the user is looking at rather than a
216+ silently reserved-stripped subset. Threading ``reserved`` in — rather than
217+ pre-stripping the header at the call site — keeps the totality check, the
218+ diagnostics, and positional binding all reading one consistent header.
219+
195220 Returns ``{overrides, rename, out_names, all_pinned}``:
196221 - ``overrides`` — ``{header_name: polars_dtype}`` for pinned columns, keyed by the
197222 name polars reads (positional renames are applied *after* the read).
198223 - ``rename`` — ``{header_name: output_name}`` where they differ (positional only).
199224 - ``out_names`` — output column names in column order (the final ``select``).
200225 - ``all_pinned`` — True if no column is inferred (then ``infer_schema_length=0``).
201226
202- Raises the actionable ValueError on a by-name miss, a non-total spec, or an
203- over-long positional spec — these are the #143 error-contract surfaces.
227+ Raises the actionable ValueError on a by-name miss, a non-total spec, an
228+ over-long positional spec, a by-name spec that names a reserved column, or a
229+ positional spec whose reserved column is not the trailing column — these are
230+ the #143 error-contract surfaces.
204231 """
232+ reserved = tuple (r for r in reserved if r in header )
233+ speccable = [h for h in header if h not in reserved ]
234+ reserved_hint = ""
235+ if reserved :
236+ names = ", " .join (repr (r ) for r in reserved )
237+ reserved_hint = (
238+ f" (the file also has tallyman's reserved column { names } , which tallyman "
239+ "manages automatically — leave it out of the schema.)"
240+ )
241+
205242 overrides : dict = {}
206243 rename : dict = {}
207244 inferred = False
@@ -219,20 +256,31 @@ def _normalize_schema(spec, header: list[str]) -> dict:
219256 cells = cells [:- 1 ]
220257 if any (n in (_REST , _BEGIN ) for n , _ in cells ):
221258 raise ValueError ("tallyman_read_csv: '&rest' must be the last cell and '&begin' the first." )
222- if len (cells ) > len (header ):
259+ # Positional cells bind by physical column position. Excluding a reserved column
260+ # that is not the trailing column would silently shift every later cell onto the
261+ # wrong data column, so require the reserved column(s) to be a trailing suffix
262+ # (header[:len(speccable)] == speccable); otherwise steer to a by-name schema.
263+ if reserved and header [: len (speccable )] != speccable :
264+ raise ValueError (
265+ f"tallyman_read_csv: a positional schema cannot bind { list (header )} — its reserved "
266+ f"column { list (reserved )} is not the last column, and positional cells bind by physical "
267+ "column position, so excluding it would silently rebind later cells onto the wrong data "
268+ "column. Use a by-name schema (a dict or ibis schema keyed on the header names) instead."
269+ )
270+ if len (cells ) > len (speccable ):
223271 raise ValueError (
224272 f"tallyman_read_csv: positional schema has { len (cells )} columns but the CSV header has "
225- f"{ len (header )} { list (header )} ."
273+ f"{ len (speccable )} { list (speccable )} .{ reserved_hint } "
226274 )
227- if rest_dtype is None and len (cells ) != len (header ):
228- uncovered = list (header [len (cells ):])
275+ if rest_dtype is None and len (cells ) != len (speccable ):
276+ uncovered = list (speccable [len (cells ):])
229277 raise ValueError (
230278 f"tallyman_read_csv: schema is not total — it leaves column(s) { uncovered } unspecified. "
231279 'Name every column, or close the spec with ("&rest", "infer").'
232280 )
233281 out_names = []
234282 for i , (out_name , dt ) in enumerate (cells ):
235- orig = header [i ]
283+ orig = speccable [i ]
236284 out_names .append (out_name )
237285 if out_name != orig :
238286 rename [orig ] = out_name
@@ -241,7 +289,7 @@ def _normalize_schema(spec, header: list[str]) -> dict:
241289 inferred = True
242290 else :
243291 overrides [orig ] = pl_dt
244- for orig in header [len (cells ):]: # the &rest tail keeps its header name
292+ for orig in speccable [len (cells ):]: # the &rest tail keeps its header name
245293 out_names .append (orig )
246294 pl_dt = _resolve_spec_dtype (rest_dtype )
247295 if pl_dt is None :
@@ -260,14 +308,20 @@ def _normalize_schema(spec, header: list[str]) -> dict:
260308 rest_dtype = dt
261309 continue
262310 named [str (name )] = dt
263- missing = [n for n in named if n not in header ]
311+ named_reserved = [n for n in named if n in reserved ]
312+ if named_reserved :
313+ raise ValueError (
314+ f"tallyman_read_csv: schema names tallyman's reserved column { sorted (named_reserved )} , which "
315+ "tallyman manages automatically — remove it from the schema (it is added and ordered for you)."
316+ )
317+ missing = [n for n in named if n not in speccable ]
264318 if missing :
265319 raise ValueError (
266- f"tallyman_read_csv: schema column(s) { sorted (missing )} are not in the CSV header { list (header )} . "
320+ f"tallyman_read_csv: schema column(s) { sorted (missing )} are not in the CSV header { list (speccable )} . "
267321 "Bind by name only when the names match the header; to rename by position pass a tuple-of-tuples "
268- 'schema, e.g. schema=(("Date", "date"), ("&rest", "infer")).'
322+ 'schema, e.g. schema=(("Date", "date"), ("&rest", "infer")).' + reserved_hint
269323 )
270- uncovered = [h for h in header if h not in named ]
324+ uncovered = [h for h in speccable if h not in named ]
271325 if rest_dtype is None and uncovered :
272326 raise ValueError (
273327 f"tallyman_read_csv: schema is not total — it leaves column(s) { uncovered } unspecified. "
@@ -285,7 +339,7 @@ def _normalize_schema(spec, header: list[str]) -> dict:
285339 inferred = True
286340 else :
287341 overrides [name ] = pl_dt
288- return {"overrides" : overrides , "rename" : {}, "out_names" : list (header ), "all_pinned" : not inferred }
342+ return {"overrides" : overrides , "rename" : {}, "out_names" : list (speccable ), "all_pinned" : not inferred }
289343
290344
291345_ESCALATED_INFER = 10_000 # the middle rung of the #143 infer ladder; whole-file (None) is the terminal
@@ -317,12 +371,18 @@ def _polars_to_ibis(dt) -> str:
317371 return "string" # safe fallback — an unmapped column still reads as string
318372
319373
320- def _suggest_schema_dsl (src : Path , scan_kwargs : dict ) -> str :
321- """Whole-file-infer *src* and render it as the paste-ready tuple-of-tuples DSL."""
374+ def _suggest_schema_dsl (src : Path , scan_kwargs : dict , reserved : tuple [str , ...] = ()) -> str :
375+ """Whole-file-infer *src* and render it as the paste-ready tuple-of-tuples DSL.
376+
377+ *reserved* columns (tallyman-managed, e.g. ``original_row_order``) are dropped:
378+ they must not appear in the suggestion because the caller cannot spec them —
379+ a suggestion carrying one is un-pasteable (the totality check excludes the
380+ reserved column, so pasting it back over-counts the columns and raises).
381+ """
322382 import polars as pl
323383
324384 inferred = pl .scan_csv (str (src ), infer_schema_length = None , ** scan_kwargs ).collect_schema ()
325- pairs = tuple ((name , _polars_to_ibis (dt )) for name , dt in inferred .items ())
385+ pairs = tuple ((name , _polars_to_ibis (dt )) for name , dt in inferred .items () if name not in reserved )
326386 return repr (pairs )
327387
328388
@@ -380,13 +440,28 @@ def _materialize_ordered(src: Path, schema, scan_kwargs: dict, tmp_path: Path) -
380440 # check; infer_schema_length=0 reads just the header line, no type sampling.
381441 header = list (pl .scan_csv (str (src ), infer_schema_length = 0 , ** scan_kwargs ).collect_schema ().names ())
382442 has_row_order = "original_row_order" in header
443+ # original_row_order is tallyman's reserved index — _ordered re-appends it, and
444+ # _validate_existing_row_order (below) vets a pre-existing one. It is not the caller's
445+ # to spec, so it is threaded as `reserved` through every header reader (the schema
446+ # plan, the diagnostics, and the suggested-schema hint) rather than stripped ad hoc.
447+ reserved = ("original_row_order" ,) if has_row_order else ()
383448 if has_row_order :
384449 _validate_existing_row_order (src , scan_kwargs ) # reuse it, or raise — never with_row_index over it
385450
386451 plan = None
387452 explicit_only = False
388453 if schema is not None :
389- plan = _normalize_schema (schema , header )
454+ plan = _normalize_schema (schema , header , reserved = reserved )
455+ # A spec may not PRODUCE the reserved name either: renaming a data column onto
456+ # 'original_row_order' collides with the index _ordered re-appends (a raw polars
457+ # DuplicateError at sink). Reject the reserved output name up front with the same
458+ # guidance as the pre-existing-column clash.
459+ if "original_row_order" in plan ["out_names" ]:
460+ raise ValueError (
461+ "tallyman_read_csv: 'original_row_order' is reserved for tallyman's canonical "
462+ "row index and cannot be a schema output column name. Rename that column to "
463+ "something other than 'original_row_order' in the schema."
464+ )
390465 explicit_only = plan ["all_pinned" ]
391466
392467 def _ordered (infer_len ):
@@ -417,7 +492,7 @@ def _ordered(infer_len):
417492 last_exc = exc
418493 raise ValueError (
419494 f"tallyman_read_csv: the schema does not parse { src .name } : { last_exc } . "
420- f"Suggested schema (whole-file inference): schema={ _suggest_schema_dsl (src , scan_kwargs )} "
495+ f"Suggested schema (whole-file inference): schema={ _suggest_schema_dsl (src , scan_kwargs , reserved )} "
421496 )
422497
423498
@@ -533,10 +608,21 @@ def tallyman_read_csv(path: str, schema=None, **kwargs):
533608 **kwargs: Forwarded to ``polars.scan_csv`` — reader options such as
534609 ``separator``, ``skip_rows``, ``null_values``, ``quote_char``,
535610 ``has_header``, ``encoding``. They participate in the intermediate's
536- cache key, so changing one re-ingests.
611+ cache key, so changing one re-ingests. ``infer_schema_length`` and
612+ ``schema_overrides`` are managed internally (see ``_RESERVED_SCAN_KWARGS``)
613+ and rejected — they would collide with the values every internal
614+ ``scan_csv`` call already sets.
537615 """
538616 import xorq .api as xo
539617
618+ reserved = [k for k in _RESERVED_SCAN_KWARGS if k in kwargs ]
619+ if reserved :
620+ raise ValueError (
621+ f"tallyman_read_csv: { reserved } is managed internally, not a pass-through "
622+ "polars.scan_csv reader option. Type inference escalates automatically "
623+ "(100 -> 10k -> whole-file); to pin column types pass schema= (an ibis schema, "
624+ "plain dict, or tuple-of-tuples), never schema_overrides."
625+ )
540626 parquet_path = _ordered_csv_parquet (path , schema , kwargs )
541627 return xo .deferred_read_parquet (str (parquet_path )).order_by ("original_row_order" )
542628
0 commit comments