feat(dataset_tools): union forms via DatasetSpec addition (#1478) - #1599
Draft
NJManganelli wants to merge 12 commits into
Draft
feat(dataset_tools): union forms via DatasetSpec addition (#1478)#1599NJManganelli wants to merge 12 commits into
NJManganelli wants to merge 12 commits into
Conversation
…etection
- get_steps now auto-detects TTree vs RNTuple per file: RNTuple num_entries,
cluster boundaries (from cluster_summaries), and form (via uproot.dask with the
{path: object_path} spec, since the opened RNTuple object is not accepted) are
handled alongside the existing TTree path. preprocess()/preprocess_root handle
RNTuples transparently.
- Add preprocess_rntuple(), mirroring preprocess_parquet/preprocess_root, which
requires every object to be an RNTuple (require_rntuple=True) and raises
otherwise; exported from coffea.dataset_tools.
- Extract shared helpers _even_steps, _aligned_steps, _rntuple_cluster_boundaries,
and _union_form_jsonstr; use them in get_steps and get_parquet_form_uuid_steps
and de-duplicate the union-form logic across the legacy and pydantic paths.
- Short-circuit an empty per-format DataGroupSpec in _preprocess_pydantic so a
single-format fileset no longer issues a wasted (empty) dask.compute.
- Remove the spurious dask function-cache pop in the parquet path (no graph is
built there) and guard the TTree/RNTuple pop against an empty cache; drop the
always-true 'num_entries >= 0' guard; correct file_exceptions/parquet docstrings.
- Add end-to-end test_preprocess_rntuple and a filter_files->PreprocessedFiles test.
Assisted-by: Claude Opus 4.8
…ree forms Factor the per-dataset preprocessing map-reduce out of _preprocess_pydantic behind a small backend interface (new module dataset_tools/backends.py) shaped to align with the coffea.compute refactor (PR scikit-hep#1470): PreprocessJob (computable) -> Backend.submit() -> future-like Task.result(), with an ordered concat reduce (preprocessing zips results against original file order). Backends: - DaskBackend: the historical from_awkward/map_partitions/AwkwardTreeReductionLayer graph; the only backend that imports dask for orchestration. - IterativeBackend: immediate (synchronous, single process), mirroring IterativeExecutor. Dask-free. - FuturesBackend: concurrent.futures pool, threads by default with a use_processes opt-in. Dask-free. preprocess()/preprocess_root/_rntuple/_parquet gain backend= (default "dask", so existing behavior is unchanged). On a dask import failure the default path prints a coffea_console hint pointing at the dask-free backends. The Runner is not rewired; that is left to the compute refactor. TTree form extraction is now dask-free: it uses uproot's own non-dask form builder (uproot._dask._get_ttree_form, guarded with a fallback), byte-identical to uproot.dask(tree).layout.form, which also drops the function_cache workaround for TTrees. RNTuple form extraction still uses uproot.dask (documented). Also fixes found while self-reviewing this change: - get_parquet_form_uuid_steps: add the num_entries==0 guard ROOT already had, so a 0-row parquet yields steps=[[0,0]] instead of ZeroDivisionError. - Scope the dask fallback hint to submit() import failure so a worker's own ModuleNotFoundError during compute is no longer misreported as "dask missing". - FuturesBackend: shut down an owned pool via __del__ if the task is only wait()-ed or dropped without result(). - resolve_backend: warn when scheduler is passed with a pre-built backend instance (previously dropped silently for a DaskBackend instance). - DaskBackend: clamp files_per_batch with max(1, ...) for parity with the other backends. Tests: tests/test_dataset_tools_backends.py; dask/iterative/futures verified to produce equal DataGroupSpec, and the non-dask TTree form verified byte-identical to the dask form. Assisted-by: Claude Opus 4.8
An explicit step_size=0 (or negative) previously surfaced as a bare ZeroDivisionError from _even_steps deep inside a worker/dask graph. Add a _validate_step_size guard, called at the start of _preprocess_pydantic (covers preprocess/preprocess_root/_rntuple/_parquet) and preprocess_legacy, raising a clear ValueError before any file I/O. None and >= 1 are unaffected. Assisted-by: Claude Opus 4.8
Extend the dask-free form path from TTree to RNTuple by routing both through uproot's own form builder (uproot._dask._get_ttree_form, which already handles RNTuple HasFields sub-branches) and nulling the resulting form keys. The RNTuple delay-open dask path builds base_form the same way; the only delta was that uproot.dask exposes the keyless meta form while _get_ttree_form carries RNTuple "column-N" keys, so _null_form_keys closes the gap (a no-op for already-keyless TTree forms). _ttree_form_json is replaced by the format-aware _awkward_form_json (filter_field + full_paths for RNTuple, filter_branch for TTree). uproot.dask is now only a fallback for the unlikely case that the private uproot helper is unavailable. Net result: iterative/futures backends are fully dask-free for parquet and both ROOT flavors. Verified byte-identical to uproot.dask(...).layout.form for TTree and RNTuple samples (parametrized test), and dask/iterative/futures produce equal DataGroupSpec end-to-end for an RNTuple dataset. Assisted-by: Claude Opus 4.8
… rename backends module Pre-pay the coffea.compute (PR scikit-hep#1470) migration on two axes: (a) Order-relaxation. _preprocess_pydantic previously zipped the concatenated per-file results positionally against the original input to recover skipped/ bad files (emitted as None by the worker), which made the reduce order load-bearing. Assemble both `available` and `updated` filesets by filename instead, in the original input order, so a skipped file is identified by its absence from the results rather than by position. Correctness no longer depends on the order in which a backend reduces/concatenates, which is what lets preprocessing mesh with an order-agnostic compute reduce. The shared get_steps worker (also used by the legacy path) is untouched. (b) Task.partial_result(). Add partial_result() to the PreprocessTask protocol and all three tasks, mirroring compute.Task: the eager task returns its full result, the futures task gathers only completed batches without blocking (and without shutting the pool), and the dask task falls back to the full result (a fused graph has no cheap partial). Also rename backends.py -> preprocess_backends.py (clearer alongside a future coffea.compute.backends) and soften the now-obsolete "ordering is load-bearing" docs. Tests: filename-based assembly of a skipped bad file; partial_result == result when complete. Full preprocess suite + backend suite green; pre-commit clean. Assisted-by: Claude Opus 4.8
…ocessing filespec.ipynb: extend the "Integration with Preprocessing" section with three new subsections (executed against the on-master samples, real outputs attached): - 5.2 Switchable execution backends (backend="dask"/"iterative"/"futures", FuturesBackend instances), showing the dask-free backends agree with dask. - 5.3 Preprocessing Parquet datasets (preprocess_parquet, format auto-detection, no object_path, use_row_groups). - 5.4 Preprocessing RNTuple datasets (auto-detection; preprocess_rntuple's RNTuple-only contract). Imports and the overview are updated accordingly; later cells' execution counts are renumbered to stay monotonic. The remaining cells' committed outputs are untouched (the apply_to_fileset/dask-histogram compute path can't be re-run in the current env due to a known awkward/dask-histogram version-compat baseline, unrelated to preprocessing). processing.ipynb: add a short note to the Preprocessing section pointing at the new backend= argument and Parquet/RNTuple support, linking to filespec.ipynb. (docs/source/notebooks/*.ipynb are symlinks into binder/, so they update too.) Assisted-by: Claude Opus 4.8
Re-execute the entire notebook end-to-end (nbconvert --execute) now that the environment is unblocked: apply_to_fileset -> dask.compute runs cleanly on the advanced scikit-hep stack (uproot 5.7.4, awkward 2.10.0, dask-awkward 2026.2.1, dask-histogram 2026.2.0), which previously crashed with the awkward/dask-awkward scalar-meta incompatibility. Every cell (including the Integration-with- apply_to_fileset section and the 5.2/5.3/5.4 backends/parquet/rntuple demos) now carries real, consistently-numbered outputs from a single fresh run -- replacing the earlier partial-output render. 0 error outputs. Assisted-by: Claude Opus 4.8
…bsent test_awkward_form_json_matches_uproot_dask compares the dask-free form builder against uproot.dask output, so it requires dask and dask-awkward; guard it with pytest.importorskip like the other dask-dependent tests in this module so the no-dask CI job skips it. Assisted-by: Claude Fable 5
- require_rntuple raises inside the per-file error handling so skip_bad_files/file_exceptions can skip TTree files, and rejects parquet-format datasets with a clear error - preprocess_root gains require_rntuple=; preprocess_rntuple becomes a thin alias instead of a duplicated 13-parameter signature - get_steps/get_parquet_form_uuid_steps validate step_size directly (negative values previously produced a silent single step per file) - drop the unreachable duplicate zero-row guard in get_parquet_form_uuid_steps - forward uproot_options to the RNTuple uproot.dask form fallback - futures backend: fail fast on batch failure (cancel pending batches), and default pool sizing to the executor default instead of 1 worker - resolve_backend injects scheduler into a DaskBackend instance that has none; preprocess resolves the backend once so mixed filesets do not double-warn - serialize the null-key form dict directly (skips a Form round-trip) and reuse a single to_list in the by-filename assembly - comment-convention cleanups (no PR/review references, present-tense descriptions) Assisted-by: Claude Fable 5
…field bitsets Adding two DatasetSpecs that both carry saved forms now computes the union form (fields appearing in either operand) instead of discarding it, so separately preprocessed specs combine without re-opening files. Adding a form-bearing spec to a form-less one raises, since the union could not describe the form-less files. - new dataset_tools/forms.py hosts the union helper (moved from preprocess.py) plus sort_form_fields, prune_form_fields, and hex field-bitset encode/decode - DatasetSpec.union_with(other, sort_fields=...) is the explicit form; sort_fields=True canonicalizes the serialized field order for byte-stable output independent of operand order (form equality is field-order-insensitive either way) - DatasetSpec.canonicalize_form() re-serializes the saved form with recursively sorted fields and remaps bitsets - experimental: file specs carry experimental_field_bitset, a hex bitset over the dataset union form's top-level fields, populated during pydantic preprocessing; filter_files/limit_files prune the union form to the surviving files' fields when every remaining file has a bitset - experimental fields are excluded from file-spec equality and from the legacy dict conversions Assisted-by: Claude Fable 5
…parquet) Add two real CMS NanoAOD files (and their parquet conversions) carrying disjoint subsets of GenModel_TChiZH_* model-point flags -- file A has GenModel_TChiZH_700_1, file B does not. Adding the two separately preprocessed DatasetSpecs unions their forms into a superset in which a flag present in only one file becomes an IndexedOptionArray(bool), so it stays readable (as None) for the file that lacks it. This is the GenModel case scikit-hep#1478 targets and exercises the option-type union path for both ROOT and parquet, alongside the existing synthetic HLT unit tests. Assisted-by: Claude Fable 5
This was referenced Jul 20, 2026
Add materialization regressions over the union read path (dask mode, where the union form injects an option-typed flag physically absent from a file). Reading file A through the dataset union form yields the same GenModel_TChiZH_700_1 selection as reading A through its own form (20), so events passing a model-point mask are not falsified when the flag is made optional by unioning with a file that lacks it. For each model point the combined A+B count equals the per-file sum and the per-file truth (700_1: 20 from A; 950_400: 20 from B; 1100_200: 0). A second test pins that selecting on the raw option-type mask keeps the injected None rows and over-selects (40), so fill_none(flag, False) is required (20). Assisted-by: Claude Fable 5
NJManganelli
force-pushed
the
feat/union-form-addition
branch
from
July 25, 2026 16:45
4df37f4 to
4b087e3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Note
Targets
scikit-hep:masterbut builds on #1579 (RNTuple + switchable backends). Until #1579 merges, this PR's diff also shows #1579's commits; once it lands, GitHub collapses this to just the union-form changes. Review after #1579. Two further PRs stack on this one (metadata extraction, mutable-steps).Implements the union-form-on-addition feature requested in #1478.
What this adds
When two
DatasetSpecs that both carry a saved form are added, the result's form is now the union of their forms (every field appearing in either), instead of the form being discarded. This gives a second route to a dataset union form — combining already-preprocessed specs — without reopening any file. Adding a form-bearing spec to a form-less one raises, since the union could not describe the form-less operand's files.DatasetSpec.__add__delegates toDatasetSpec.union_with(other, sort_fields=False);sort_fields=Truerecursively sorts record fields so the serialized form is byte-stable regardless of add order (awkwardFormequality is field-order-insensitive either way).DatasetSpec.canonicalize_form()re-serializes an existing saved form with sorted fields (stable hashing/reproducibility).coffea/dataset_tools/forms.pyhosts the union helper (moved out ofpreprocess.py, which now imports it) plussort_form_fields,prune_form_fields, and hex field-bitset encode/decode.experimental_field_bitset, a compact hex bitset over the dataset union form's top-level fields (bit i set ⇒ field i present in that file), populated during pydantic preprocessing.filter_files/limit_filesprune the union form back to the surviving files' fields when every remaining file has a bitset (kept as a superset otherwise). Excluded from file-spec equality and from the legacy dict conversions, and prefixedexperimental_deliberately — the encoding may change.The union merges flat-tuple-like differences: the HLT-trigger-bit /
GenModelmodel-point case #1478 targets, where a field present in only some files becomes an option type (IndexedOptionArray(bool)) so it stays readable (asNone) for the files that lack it.Try it (against the bundled GenModel test files)
Two real CMS NanoAOD files with disjoint
GenModel_TChiZH_*model-point flag subsets are added in this PR (file AhasGenModel_TChiZH_700_1;file Bdoes not). Highest-level API only —DataGroupSpec→preprocess→ spec addition:Tests
tests/test_dataset_tools_forms.pycovers the helpers (union order/associativity, sort, prune, bitset round-trip),__add__/union_with(matches joint preprocessing, equal-form short-circuit, one-sided/no-form cases, order independence),canonicalize_form, filter/limit pruning (including the no-bitset superset fallback and JSON round-trip), and a GenModel regression on the bundled files. At the form level (ROOT and parquet): the union is a superset of both, a flag present in only one file becomesIndexedOptionArray(bool), bitsets decode to each file's branch set, and filtering prunes the form. At the value level (dask read path, where the union form injects the option-typed flag absent from a file): reading file A through the union form gives the sameGenModel_TChiZH_700_1selection as reading A through its own form (20 events — the union does not falsify events passing a model-point mask), and for each model point the combined A+B count equals the per-file sum and the known truth (700_1: 20 from A; 950_400: 20 from B; 1100_200: 0). A companion test pins that selecting on the raw option-type mask keeps the injectedNonerows and over-selects (40 vs 20), sofill_none(flag, False)is required.Important
Before converting from draft to review:
tests/test_dataset_tools_forms.py, alongside the synthetic HLT unit tests. (done —nano_genmodel_*ROOT + parquet fixtures)Developed with Claude Fable 5. Prototype — API may change.