diff --git a/binder/filespec.ipynb b/binder/filespec.ipynb index 9e5e20ec1..a52dbc883 100644 --- a/binder/filespec.ipynb +++ b/binder/filespec.ipynb @@ -15,7 +15,8 @@ "\n", "The FileSpec system provides:\n", "- **Type-safe data structures** with automatic validation\n", - "- **Automatic format detection** for ROOT and Parquet files\n", + "- **Automatic format detection** for ROOT (TTree and RNTuple) and Parquet files\n", + "- **Switchable execution backends** (`dask`, `iterative`, `futures`) for preprocessing\n", "- **Seamless integration** with existing Coffea functions\n", "- **JSON serialization/deserialization** for data persistence\n", "- **Automatic promotion** between optional and concrete specifications\n", @@ -37,7 +38,14 @@ "cell_type": "code", "execution_count": 1, "id": "f6c75a47", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:31:42.715172Z", + "iopub.status.busy": "2026-07-02T23:31:42.714932Z", + "iopub.status.idle": "2026-07-02T23:32:07.577593Z", + "shell.execute_reply": "2026-07-02T23:32:07.577053Z" + } + }, "outputs": [ { "name": "stdout", @@ -69,6 +77,9 @@ " \n", " # Dataset manipulation functions\n", " preprocess,\n", + " preprocess_parquet,\n", + " preprocess_rntuple,\n", + " FuturesBackend,\n", " apply_to_fileset,\n", " max_chunks,\n", " max_chunks_per_file,\n", @@ -109,7 +120,14 @@ "cell_type": "code", "execution_count": 2, "id": "2ecaf55b", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.579034Z", + "iopub.status.busy": "2026-07-02T23:32:07.578839Z", + "iopub.status.idle": "2026-07-02T23:32:07.586602Z", + "shell.execute_reply": "2026-07-02T23:32:07.586149Z" + } + }, "outputs": [ { "name": "stdout", @@ -212,7 +230,14 @@ "cell_type": "code", "execution_count": 3, "id": "d59e690c", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.587784Z", + "iopub.status.busy": "2026-07-02T23:32:07.587711Z", + "iopub.status.idle": "2026-07-02T23:32:07.592336Z", + "shell.execute_reply": "2026-07-02T23:32:07.591877Z" + } + }, "outputs": [ { "name": "stdout", @@ -314,7 +339,14 @@ "cell_type": "code", "execution_count": 4, "id": "85d98abf", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.593469Z", + "iopub.status.busy": "2026-07-02T23:32:07.593381Z", + "iopub.status.idle": "2026-07-02T23:32:07.599317Z", + "shell.execute_reply": "2026-07-02T23:32:07.598796Z" + } + }, "outputs": [ { "name": "stdout", @@ -465,7 +497,14 @@ "cell_type": "code", "execution_count": 5, "id": "38ee2f96", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.600473Z", + "iopub.status.busy": "2026-07-02T23:32:07.600391Z", + "iopub.status.idle": "2026-07-02T23:32:07.605639Z", + "shell.execute_reply": "2026-07-02T23:32:07.605231Z" + } + }, "outputs": [ { "name": "stdout", @@ -519,26 +558,26 @@ "
3 validation errors for CoffeaROOTFileSpec\n", "steps\n", " Field required [type=missing, input_value={'object_path': 'Events'}, input_type=dict]\n", - " For further information visit https://errors.pydantic.dev/2.11/v/missing\n", + " For further information visit https://errors.pydantic.dev/2.12/v/missing\n", "num_entries\n", " Field required [type=missing, input_value={'object_path': 'Events'}, input_type=dict]\n", - " For further information visit https://errors.pydantic.dev/2.11/v/missing\n", + " For further information visit https://errors.pydantic.dev/2.12/v/missing\n", "uuid\n", " Field required [type=missing, input_value={'object_path': 'Events'}, input_type=dict]\n", - " For further information visit https://errors.pydantic.dev/2.11/v/missing\n", + " For further information visit https://errors.pydantic.dev/2.12/v/missing\n", "\n" ], "text/plain": [ "\u001b[1;36m3\u001b[0m validation errors for CoffeaROOTFileSpec\n", "steps\n", " Field required \u001b[1m[\u001b[0m\u001b[33mtype\u001b[0m=\u001b[35mmissing\u001b[0m, \u001b[33minput_value\u001b[0m=\u001b[1m{\u001b[0m\u001b[32m'object_path'\u001b[0m: \u001b[32m'Events'\u001b[0m\u001b[1m}\u001b[0m, \u001b[33minput_type\u001b[0m=\u001b[35mdict\u001b[0m\u001b[1m]\u001b[0m\n", - " For further information visit \u001b[4;94mhttps://errors.pydantic.dev/2.11/v/missing\u001b[0m\n", + " For further information visit \u001b[4;94mhttps://errors.pydantic.dev/2.12/v/missing\u001b[0m\n", "num_entries\n", " Field required \u001b[1m[\u001b[0m\u001b[33mtype\u001b[0m=\u001b[35mmissing\u001b[0m, \u001b[33minput_value\u001b[0m=\u001b[1m{\u001b[0m\u001b[32m'object_path'\u001b[0m: \u001b[32m'Events'\u001b[0m\u001b[1m}\u001b[0m, \u001b[33minput_type\u001b[0m=\u001b[35mdict\u001b[0m\u001b[1m]\u001b[0m\n", - " For further information visit \u001b[4;94mhttps://errors.pydantic.dev/2.11/v/missing\u001b[0m\n", + " For further information visit \u001b[4;94mhttps://errors.pydantic.dev/2.12/v/missing\u001b[0m\n", "uuid\n", " Field required \u001b[1m[\u001b[0m\u001b[33mtype\u001b[0m=\u001b[35mmissing\u001b[0m, \u001b[33minput_value\u001b[0m=\u001b[1m{\u001b[0m\u001b[32m'object_path'\u001b[0m: \u001b[32m'Events'\u001b[0m\u001b[1m}\u001b[0m, \u001b[33minput_type\u001b[0m=\u001b[35mdict\u001b[0m\u001b[1m]\u001b[0m\n", - " For further information visit \u001b[4;94mhttps://errors.pydantic.dev/2.11/v/missing\u001b[0m\n" + " For further information visit \u001b[4;94mhttps://errors.pydantic.dev/2.12/v/missing\u001b[0m\n" ] }, "metadata": {}, @@ -575,7 +614,14 @@ "cell_type": "code", "execution_count": 6, "id": "694daed3", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.606724Z", + "iopub.status.busy": "2026-07-02T23:32:07.606660Z", + "iopub.status.idle": "2026-07-02T23:32:07.611162Z", + "shell.execute_reply": "2026-07-02T23:32:07.610766Z" + } + }, "outputs": [ { "name": "stdout", @@ -699,7 +745,14 @@ "cell_type": "code", "execution_count": 7, "id": "3cfd0d6a", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.612312Z", + "iopub.status.busy": "2026-07-02T23:32:07.612256Z", + "iopub.status.idle": "2026-07-02T23:32:07.621771Z", + "shell.execute_reply": "2026-07-02T23:32:07.621231Z" + } + }, "outputs": [ { "name": "stdout", @@ -968,7 +1021,14 @@ "cell_type": "code", "execution_count": 8, "id": "b2db2dcd", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.622914Z", + "iopub.status.busy": "2026-07-02T23:32:07.622852Z", + "iopub.status.idle": "2026-07-02T23:32:07.627539Z", + "shell.execute_reply": "2026-07-02T23:32:07.627196Z" + } + }, "outputs": [ { "name": "stdout", @@ -1107,7 +1167,14 @@ "cell_type": "code", "execution_count": 9, "id": "b31c13f5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.628785Z", + "iopub.status.busy": "2026-07-02T23:32:07.628726Z", + "iopub.status.idle": "2026-07-02T23:32:07.633925Z", + "shell.execute_reply": "2026-07-02T23:32:07.633595Z" + } + }, "outputs": [ { "name": "stdout", @@ -1243,7 +1310,14 @@ "cell_type": "code", "execution_count": 10, "id": "76aa5992", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.635068Z", + "iopub.status.busy": "2026-07-02T23:32:07.635001Z", + "iopub.status.idle": "2026-07-02T23:32:07.640202Z", + "shell.execute_reply": "2026-07-02T23:32:07.639734Z" + } + }, "outputs": [ { "name": "stdout", @@ -1257,7 +1331,7 @@ "data": { "text/html": [ "
DatasetSpec(\n", - " files=InputFiles(\n", + " files=PreprocessedFiles(\n", " root={\n", " 'processed_data_1.root': CoffeaROOTFileSpec(\n", " object_path='Events',\n", @@ -1290,7 +1364,7 @@ ], "text/plain": [ "\u001b[1;35mDatasetSpec\u001b[0m\u001b[1m(\u001b[0m\n", - " \u001b[33mfiles\u001b[0m=\u001b[1;35mInputFiles\u001b[0m\u001b[1m(\u001b[0m\n", + " \u001b[33mfiles\u001b[0m=\u001b[1;35mPreprocessedFiles\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mroot\u001b[0m=\u001b[1m{\u001b[0m\n", " \u001b[32m'processed_data_1.root'\u001b[0m: \u001b[1;35mCoffeaROOTFileSpec\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mobject_path\u001b[0m=\u001b[32m'Events'\u001b[0m,\n", @@ -1373,7 +1447,14 @@ "cell_type": "code", "execution_count": 11, "id": "6af14880", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.641291Z", + "iopub.status.busy": "2026-07-02T23:32:07.641225Z", + "iopub.status.idle": "2026-07-02T23:32:07.643911Z", + "shell.execute_reply": "2026-07-02T23:32:07.643466Z" + } + }, "outputs": [ { "name": "stdout", @@ -1382,7 +1463,7 @@ "=== Mixed Format Datasets ===\n", "Validation error for mixed format dataset: 1 validation error for DatasetSpec\n", " Value error, format: format must be one of {'root', 'parquet'} [type=value_error, input_value={'files': {'data.root': C...selected_entries=2000)}}, input_type=dict]\n", - " For further information visit https://errors.pydantic.dev/2.11/v/value_error\n" + " For further information visit https://errors.pydantic.dev/2.12/v/value_error\n" ] } ], @@ -1421,7 +1502,14 @@ "cell_type": "code", "execution_count": 12, "id": "98564dea", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.644963Z", + "iopub.status.busy": "2026-07-02T23:32:07.644901Z", + "iopub.status.idle": "2026-07-02T23:32:07.650382Z", + "shell.execute_reply": "2026-07-02T23:32:07.650042Z" + } + }, "outputs": [ { "name": "stdout", @@ -1589,7 +1677,14 @@ "cell_type": "code", "execution_count": 13, "id": "7109b611", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.651575Z", + "iopub.status.busy": "2026-07-02T23:32:07.651517Z", + "iopub.status.idle": "2026-07-02T23:32:07.660381Z", + "shell.execute_reply": "2026-07-02T23:32:07.659975Z" + } + }, "outputs": [ { "name": "stdout", @@ -1848,7 +1943,14 @@ "cell_type": "code", "execution_count": 14, "id": "376a6765", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.661499Z", + "iopub.status.busy": "2026-07-02T23:32:07.661424Z", + "iopub.status.idle": "2026-07-02T23:32:07.664025Z", + "shell.execute_reply": "2026-07-02T23:32:07.663682Z" + } + }, "outputs": [ { "name": "stdout", @@ -1894,7 +1996,14 @@ "cell_type": "code", "execution_count": 15, "id": "fc3b6e90", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.665153Z", + "iopub.status.busy": "2026-07-02T23:32:07.665075Z", + "iopub.status.idle": "2026-07-02T23:32:07.667908Z", + "shell.execute_reply": "2026-07-02T23:32:07.667560Z" + } + }, "outputs": [ { "name": "stdout", @@ -1964,7 +2073,14 @@ "cell_type": "code", "execution_count": 16, "id": "8d360bf8", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:07.669063Z", + "iopub.status.busy": "2026-07-02T23:32:07.668989Z", + "iopub.status.idle": "2026-07-02T23:32:09.782645Z", + "shell.execute_reply": "2026-07-02T23:32:09.782076Z" + } + }, "outputs": [ { "name": "stdout", @@ -2100,7 +2216,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "Preprocessing the fileset...\n", + "Preprocessing the fileset...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "Fileset after preprocessing (excluding compressed_form string):\n" ] }, @@ -2329,6 +2451,279 @@ "#rich.print({dname: {k: v for k, v in dataset_updated[dname].files.items() if k not in dataset_runnable[dname].files} for dname in dataset_updated})\n" ] }, + { + "cell_type": "markdown", + "id": "c6525825", + "metadata": {}, + "source": [ + "### 5.2 Switchable execution backends\n", + "\n", + "`preprocess` (and the format-specific `preprocess_root` / `preprocess_rntuple` / `preprocess_parquet`) accept a `backend=` argument that selects how the per-file work is executed:\n", + "\n", + "- `\"dask\"` (default): builds a dask-awkward task graph, which scales out to a cluster via the `scheduler` argument.\n", + "- `\"iterative\"`: immediate, synchronous, single-process execution. **No dask required.**\n", + "- `\"futures\"`: a `concurrent.futures` pool — threads by default (preprocessing is I/O-bound). Pass a `FuturesBackend(workers=N)` or `FuturesBackend(use_processes=True)` instance for finer control. **No dask required.**\n", + "\n", + "The dask-free backends produce results identical to the dask backend for both ROOT and Parquet inputs. TTree/RNTuple form extraction uses uproot's own (non-dask) form builder, so `save_form=True` also works without dask." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "2e8b6aa5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:09.784246Z", + "iopub.status.busy": "2026-07-02T23:32:09.784065Z", + "iopub.status.idle": "2026-07-02T23:32:10.556751Z", + "shell.execute_reply": "2026-07-02T23:32:10.556198Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "iterative and futures backends agree: True\n" + ] + }, + { + "data": { + "text/html": [ + "{\n", + " 'ZJets': {\n", + " 'files': {\n", + " 'https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy.root': {\n", + " 'object_path': 'Events',\n", + " 'steps': [[0, 20], [20, 40]],\n", + " 'num_entries': 40,\n", + " 'format': 'root',\n", + " 'lfn': None,\n", + " 'pfn': None,\n", + " 'uuid': 'a9490124-3648-11ea-89e9-f5b55c90beef',\n", + " 'num_selected_entries': 40\n", + " }\n", + " },\n", + " 'metadata': {},\n", + " 'format': 'root',\n", + " 'did': None\n", + " }\n", + "}\n", + "\n" + ], + "text/plain": [ + "\u001b[1m{\u001b[0m\n", + " \u001b[32m'ZJets'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'files'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy.root'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'object_path'\u001b[0m: \u001b[32m'Events'\u001b[0m,\n", + " \u001b[32m'steps'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m, \u001b[1;36m20\u001b[0m\u001b[1m]\u001b[0m, \u001b[1m[\u001b[0m\u001b[1;36m20\u001b[0m, \u001b[1;36m40\u001b[0m\u001b[1m]\u001b[0m\u001b[1m]\u001b[0m,\n", + " \u001b[32m'num_entries'\u001b[0m: \u001b[1;36m40\u001b[0m,\n", + " \u001b[32m'format'\u001b[0m: \u001b[32m'root'\u001b[0m,\n", + " \u001b[32m'lfn'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'pfn'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'uuid'\u001b[0m: \u001b[32m'a9490124-3648-11ea-89e9-f5b55c90beef'\u001b[0m,\n", + " \u001b[32m'num_selected_entries'\u001b[0m: \u001b[1;36m40\u001b[0m\n", + " \u001b[1m}\u001b[0m\n", + " \u001b[1m}\u001b[0m,\n", + " \u001b[32m'metadata'\u001b[0m: \u001b[1m{\u001b[0m\u001b[1m}\u001b[0m,\n", + " \u001b[32m'format'\u001b[0m: \u001b[32m'root'\u001b[0m,\n", + " \u001b[32m'did'\u001b[0m: \u001b[3;35mNone\u001b[0m\n", + " \u001b[1m}\u001b[0m\n", + "\u001b[1m}\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# 5.2 The same preprocessing, run with different backends\n", + "root_fileset = DataGroupSpec({\n", + " \"ZJets\": {\"files\": [\"https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy.root:Events\"]},\n", + "})\n", + "\n", + "available_iterative, _ = preprocess(root_fileset, step_size=20, save_form=True, backend=\"iterative\")\n", + "available_futures, _ = preprocess(root_fileset, step_size=20, save_form=True, backend=FuturesBackend(workers=2))\n", + "\n", + "print(\"iterative and futures backends agree:\", available_iterative == available_futures)\n", + "rich.print({k: v.model_dump(exclude=\"compressed_form\") for k, v in available_iterative.items()})" + ] + }, + { + "cell_type": "markdown", + "id": "b2071c3f", + "metadata": {}, + "source": [ + "### 5.3 Preprocessing Parquet datasets\n", + "\n", + "The file format is detected automatically from the filename, so `preprocess` handles Parquet datasets transparently; `preprocess_parquet` is the format-specific entry point. Unlike ROOT, Parquet files do not take an `object_path`, and steps can optionally follow the Parquet row groups with `use_row_groups=True`." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "a60ff4e0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:10.558360Z", + "iopub.status.busy": "2026-07-02T23:32:10.558268Z", + "iopub.status.idle": "2026-07-02T23:32:12.047819Z", + "shell.execute_reply": "2026-07-02T23:32:12.047180Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Detected format: parquet\n" + ] + }, + { + "data": { + "text/html": [ + "{\n", + " 'ZJets': {\n", + " 'files': {\n", + " 'https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy.parquet': {\n", + " 'object_path': None,\n", + " 'steps': [[0, 40]],\n", + " 'num_entries': 40,\n", + " 'format': 'parquet',\n", + " 'lfn': None,\n", + " 'pfn': None,\n", + " 'uuid': '24be1d49dc0e1cc4ae4a0ee765e3d17c27662d075bf923aeaade5fbec7e29fca',\n", + " 'num_selected_entries': 40\n", + " }\n", + " },\n", + " 'metadata': {},\n", + " 'format': 'parquet',\n", + " 'did': None\n", + " }\n", + "}\n", + "\n" + ], + "text/plain": [ + "\u001b[1m{\u001b[0m\n", + " \u001b[32m'ZJets'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'files'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy.parquet'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'object_path'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'steps'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m, \u001b[1;36m40\u001b[0m\u001b[1m]\u001b[0m\u001b[1m]\u001b[0m,\n", + " \u001b[32m'num_entries'\u001b[0m: \u001b[1;36m40\u001b[0m,\n", + " \u001b[32m'format'\u001b[0m: \u001b[32m'parquet'\u001b[0m,\n", + " \u001b[32m'lfn'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'pfn'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'uuid'\u001b[0m: \u001b[32m'24be1d49dc0e1cc4ae4a0ee765e3d17c27662d075bf923aeaade5fbec7e29fca'\u001b[0m,\n", + " \u001b[32m'num_selected_entries'\u001b[0m: \u001b[1;36m40\u001b[0m\n", + " \u001b[1m}\u001b[0m\n", + " \u001b[1m}\u001b[0m,\n", + " \u001b[32m'metadata'\u001b[0m: \u001b[1m{\u001b[0m\u001b[1m}\u001b[0m,\n", + " \u001b[32m'format'\u001b[0m: \u001b[32m'parquet'\u001b[0m,\n", + " \u001b[32m'did'\u001b[0m: \u001b[3;35mNone\u001b[0m\n", + " \u001b[1m}\u001b[0m\n", + "\u001b[1m}\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# 5.3 Preprocessing a Parquet dataset (note: parquet files have no object_path)\n", + "parquet_fileset = DataGroupSpec({\n", + " \"ZJets\": {\"files\": [\"https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy.parquet\"]},\n", + "})\n", + "\n", + "parquet_available, _ = preprocess_parquet(parquet_fileset, step_size=20000, save_form=True, backend=\"iterative\")\n", + "print(\"Detected format:\", parquet_available[\"ZJets\"].format)\n", + "rich.print({k: v.model_dump(exclude=\"compressed_form\") for k, v in parquet_available.items()})" + ] + }, + { + "cell_type": "markdown", + "id": "86c1f41a", + "metadata": {}, + "source": [ + "### 5.4 Preprocessing RNTuple datasets\n", + "\n", + "RNTuple-backed ROOT files are auto-detected: `preprocess` and `preprocess_root` transparently handle both TTrees and RNTuples. Use `preprocess_rntuple` when you want to *assert* that every object is an RNTuple — it raises a `ValueError` if it encounters a TTree." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "c54655a5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:12.049879Z", + "iopub.status.busy": "2026-07-02T23:32:12.049784Z", + "iopub.status.idle": "2026-07-02T23:32:13.451399Z", + "shell.execute_reply": "2026-07-02T23:32:13.450909Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "{\n", + " 'ZJets': {\n", + " 'files': {\n", + " 'https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy_rntuple.root': {\n", + " 'object_path': 'Events',\n", + " 'steps': [[0, 20], [20, 40]],\n", + " 'num_entries': 40,\n", + " 'format': 'root',\n", + " 'lfn': None,\n", + " 'pfn': None,\n", + " 'uuid': '5aa41eba-a9f5-11f0-ba63-0f00a8c0beef',\n", + " 'num_selected_entries': 40\n", + " }\n", + " },\n", + " 'metadata': {},\n", + " 'format': 'root',\n", + " 'did': None\n", + " }\n", + "}\n", + "\n" + ], + "text/plain": [ + "\u001b[1m{\u001b[0m\n", + " \u001b[32m'ZJets'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'files'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy_rntuple.root'\u001b[0m: \u001b[1m{\u001b[0m\n", + " \u001b[32m'object_path'\u001b[0m: \u001b[32m'Events'\u001b[0m,\n", + " \u001b[32m'steps'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m, \u001b[1;36m20\u001b[0m\u001b[1m]\u001b[0m, \u001b[1m[\u001b[0m\u001b[1;36m20\u001b[0m, \u001b[1;36m40\u001b[0m\u001b[1m]\u001b[0m\u001b[1m]\u001b[0m,\n", + " \u001b[32m'num_entries'\u001b[0m: \u001b[1;36m40\u001b[0m,\n", + " \u001b[32m'format'\u001b[0m: \u001b[32m'root'\u001b[0m,\n", + " \u001b[32m'lfn'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'pfn'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n", + " \u001b[32m'uuid'\u001b[0m: \u001b[32m'5aa41eba-a9f5-11f0-ba63-0f00a8c0beef'\u001b[0m,\n", + " \u001b[32m'num_selected_entries'\u001b[0m: \u001b[1;36m40\u001b[0m\n", + " \u001b[1m}\u001b[0m\n", + " \u001b[1m}\u001b[0m,\n", + " \u001b[32m'metadata'\u001b[0m: \u001b[1m{\u001b[0m\u001b[1m}\u001b[0m,\n", + " \u001b[32m'format'\u001b[0m: \u001b[32m'root'\u001b[0m,\n", + " \u001b[32m'did'\u001b[0m: \u001b[3;35mNone\u001b[0m\n", + " \u001b[1m}\u001b[0m\n", + "\u001b[1m}\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# 5.4 Preprocessing an RNTuple dataset (auto-detected; preprocess_rntuple enforces RNTuple-only)\n", + "rntuple_fileset = DataGroupSpec({\n", + " \"ZJets\": {\"files\": [\"https://raw.githubusercontent.com/scikit-hep/coffea/master/tests/samples/nano_dy_rntuple.root:Events\"]},\n", + "})\n", + "\n", + "rntuple_available, _ = preprocess_rntuple(rntuple_fileset, step_size=20, save_form=True, backend=\"iterative\")\n", + "rich.print({k: v.model_dump(exclude=\"compressed_form\") for k, v in rntuple_available.items()})" + ] + }, { "cell_type": "markdown", "id": "6b974346", @@ -2341,21 +2736,46 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 20, "id": "39a43fa8", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:13.453038Z", + "iopub.status.busy": "2026-07-02T23:32:13.452910Z", + "iopub.status.idle": "2026-07-02T23:32:21.747719Z", + "shell.execute_reply": "2026-07-02T23:32:21.747218Z" + } + }, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "/Users/nmangane/scikit-hep-dev-4/coffea/src/coffea/nanoevents/schemas/nanoaod.py:264: RuntimeWarning: Missing cross-reference index for LowPtElectron_electronIdx => Electron\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:297: RuntimeWarning: Missing cross-reference index for LowPtElectron_electronIdx => Electron\n", + " warnings.warn(\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:297: RuntimeWarning: Missing cross-reference index for LowPtElectron_genPartIdx => GenPart\n", + " warnings.warn(\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:297: RuntimeWarning: Missing cross-reference index for LowPtElectron_photonIdx => Photon\n", " warnings.warn(\n", - "/Users/nmangane/scikit-hep-dev-4/coffea/src/coffea/nanoevents/schemas/nanoaod.py:264: RuntimeWarning: Missing cross-reference index for LowPtElectron_genPartIdx => GenPart\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:297: RuntimeWarning: Missing cross-reference index for FatJet_genJetAK8Idx => GenJetAK8\n", " warnings.warn(\n", - "/Users/nmangane/scikit-hep-dev-4/coffea/src/coffea/nanoevents/schemas/nanoaod.py:264: RuntimeWarning: Missing cross-reference index for LowPtElectron_photonIdx => Photon\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:336: RuntimeWarning: Branch Photon_mass already exists but its values will be replaced with 0.0\n", " warnings.warn(\n", - "/Users/nmangane/scikit-hep-dev-4/coffea/src/coffea/nanoevents/schemas/nanoaod.py:264: RuntimeWarning: Missing cross-reference index for FatJet_genJetAK8Idx => GenJetAK8\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:336: RuntimeWarning: Branch Photon_charge already exists but its values will be replaced with 0.0\n", + " warnings.warn(\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:297: RuntimeWarning: Missing cross-reference index for LowPtElectron_electronIdx => Electron\n", + " warnings.warn(\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:297: RuntimeWarning: Missing cross-reference index for LowPtElectron_photonIdx => Photon\n", + " warnings.warn(\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:336: RuntimeWarning: Branch Photon_mass already exists but its values will be replaced with 0.0\n", + " warnings.warn(\n", + "/Users/nmangane/servicex_claude/coffea/src/coffea/nanoevents/schemas/nanoaod.py:336: RuntimeWarning: Branch Photon_charge already exists but its values will be replaced with 0.0\n", " warnings.warn(\n" ] }, @@ -2453,9 +2873,16 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 21, "id": "e7919395", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.749318Z", + "iopub.status.busy": "2026-07-02T23:32:21.749215Z", + "iopub.status.idle": "2026-07-02T23:32:21.753528Z", + "shell.execute_reply": "2026-07-02T23:32:21.753161Z" + } + }, "outputs": [ { "name": "stdout", @@ -2509,9 +2936,16 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 22, "id": "d860f60a", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.754954Z", + "iopub.status.busy": "2026-07-02T23:32:21.754878Z", + "iopub.status.idle": "2026-07-02T23:32:21.758814Z", + "shell.execute_reply": "2026-07-02T23:32:21.758442Z" + } + }, "outputs": [ { "name": "stdout", @@ -2554,9 +2988,16 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 23, "id": "01044461", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.760066Z", + "iopub.status.busy": "2026-07-02T23:32:21.759987Z", + "iopub.status.idle": "2026-07-02T23:32:21.763465Z", + "shell.execute_reply": "2026-07-02T23:32:21.762990Z" + } + }, "outputs": [ { "name": "stdout", @@ -2590,9 +3031,16 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 24, "id": "ee931c3e", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.764558Z", + "iopub.status.busy": "2026-07-02T23:32:21.764499Z", + "iopub.status.idle": "2026-07-02T23:32:21.767574Z", + "shell.execute_reply": "2026-07-02T23:32:21.767030Z" + } + }, "outputs": [ { "name": "stdout", @@ -2655,9 +3103,16 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 25, "id": "bc8ca29a", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.768739Z", + "iopub.status.busy": "2026-07-02T23:32:21.768668Z", + "iopub.status.idle": "2026-07-02T23:32:21.775571Z", + "shell.execute_reply": "2026-07-02T23:32:21.775079Z" + } + }, "outputs": [ { "name": "stdout", @@ -2753,9 +3208,16 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 26, "id": "a6237760", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.776638Z", + "iopub.status.busy": "2026-07-02T23:32:21.776576Z", + "iopub.status.idle": "2026-07-02T23:32:21.782356Z", + "shell.execute_reply": "2026-07-02T23:32:21.781926Z" + } + }, "outputs": [ { "name": "stdout", @@ -2811,9 +3273,16 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 27, "id": "60f065ea", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.783482Z", + "iopub.status.busy": "2026-07-02T23:32:21.783408Z", + "iopub.status.idle": "2026-07-02T23:32:21.786933Z", + "shell.execute_reply": "2026-07-02T23:32:21.786436Z" + } + }, "outputs": [ { "name": "stdout", @@ -2873,9 +3342,16 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 28, "id": "f22002f4", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.788092Z", + "iopub.status.busy": "2026-07-02T23:32:21.788023Z", + "iopub.status.idle": "2026-07-02T23:32:21.795655Z", + "shell.execute_reply": "2026-07-02T23:32:21.795224Z" + } + }, "outputs": [ { "name": "stdout", @@ -2893,7 +3369,7 @@ "DataGroupSpec(\n", " root={\n", " 'higgs_m125': DatasetSpec(\n", - " files=InputFiles(\n", + " files=PreprocessedFiles(\n", " root={\n", " 'higgs_m125_part0.root': CoffeaROOTFileSpec(\n", " object_path='Events',\n", @@ -2913,7 +3389,7 @@ " did=None\n", " ),\n", " 'higgs_m200': DatasetSpec(\n", - " files=InputFiles(\n", + " files=PreprocessedFiles(\n", " root={\n", " 'higgs_m200_part0.root': CoffeaROOTFileSpec(\n", " object_path='Events',\n", @@ -2940,7 +3416,7 @@ "\u001b[1;35mDataGroupSpec\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mroot\u001b[0m=\u001b[1m{\u001b[0m\n", " \u001b[32m'higgs_m125'\u001b[0m: \u001b[1;35mDatasetSpec\u001b[0m\u001b[1m(\u001b[0m\n", - " \u001b[33mfiles\u001b[0m=\u001b[1;35mInputFiles\u001b[0m\u001b[1m(\u001b[0m\n", + " \u001b[33mfiles\u001b[0m=\u001b[1;35mPreprocessedFiles\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mroot\u001b[0m=\u001b[1m{\u001b[0m\n", " \u001b[32m'higgs_m125_part0.root'\u001b[0m: \u001b[1;35mCoffeaROOTFileSpec\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mobject_path\u001b[0m=\u001b[32m'Events'\u001b[0m,\n", @@ -2960,7 +3436,7 @@ " \u001b[33mdid\u001b[0m=\u001b[3;35mNone\u001b[0m\n", " \u001b[1m)\u001b[0m,\n", " \u001b[32m'higgs_m200'\u001b[0m: \u001b[1;35mDatasetSpec\u001b[0m\u001b[1m(\u001b[0m\n", - " \u001b[33mfiles\u001b[0m=\u001b[1;35mInputFiles\u001b[0m\u001b[1m(\u001b[0m\n", + " \u001b[33mfiles\u001b[0m=\u001b[1;35mPreprocessedFiles\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mroot\u001b[0m=\u001b[1m{\u001b[0m\n", " \u001b[32m'higgs_m200_part0.root'\u001b[0m: \u001b[1;35mCoffeaROOTFileSpec\u001b[0m\u001b[1m(\u001b[0m\n", " \u001b[33mobject_path\u001b[0m=\u001b[32m'Events'\u001b[0m,\n", @@ -3051,9 +3527,16 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 29, "id": "b5c82c85", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.796764Z", + "iopub.status.busy": "2026-07-02T23:32:21.796704Z", + "iopub.status.idle": "2026-07-02T23:32:21.799170Z", + "shell.execute_reply": "2026-07-02T23:32:21.798738Z" + } + }, "outputs": [ { "name": "stdout", @@ -3115,9 +3598,16 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 30, "id": "f6f4842c", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.800305Z", + "iopub.status.busy": "2026-07-02T23:32:21.800246Z", + "iopub.status.idle": "2026-07-02T23:32:21.827349Z", + "shell.execute_reply": "2026-07-02T23:32:21.826827Z" + } + }, "outputs": [ { "name": "stdout", @@ -4096,9 +4586,16 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 31, "id": "aeb145d5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-02T23:32:21.828792Z", + "iopub.status.busy": "2026-07-02T23:32:21.828705Z", + "iopub.status.idle": "2026-07-02T23:32:21.834941Z", + "shell.execute_reply": "2026-07-02T23:32:21.834371Z" + } + }, "outputs": [ { "name": "stdout", @@ -4250,7 +4747,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.0" + "version": "3.13.14" } }, "nbformat": 4, diff --git a/binder/processing.ipynb b/binder/processing.ipynb index 1cdc6b8b6..29720ce86 100644 --- a/binder/processing.ipynb +++ b/binder/processing.ipynb @@ -1563,7 +1563,9 @@ "metadata": {}, "source": [ "### Preprocessing\n", - "There are dataset discovery tools inside of coffea to help construct such datasets. Those will not be demonstrated here. For now, we'll take the above `initial_fileset` and preprocess it." + "There are dataset discovery tools inside of coffea to help construct such datasets. Those will not be demonstrated here. For now, we'll take the above `initial_fileset` and preprocess it.\n", + "\n", + "> **New:** `preprocess` accepts a `backend=` argument — `\"dask\"` (default), or the dask-free `\"iterative\"` / `\"futures\"` backends — and handles Parquet and RNTuple inputs (auto-detected, or via the `preprocess_parquet` / `preprocess_rntuple` helpers). See the [filespec notebook](filespec.ipynb) for a focused walkthrough." ] }, { diff --git a/src/coffea/dataset_tools/__init__.py b/src/coffea/dataset_tools/__init__.py index 0808e87b2..7992ad221 100644 --- a/src/coffea/dataset_tools/__init__.py +++ b/src/coffea/dataset_tools/__init__.py @@ -26,14 +26,23 @@ preprocess, preprocess_legacy, preprocess_parquet, + preprocess_rntuple, preprocess_root, ) +from coffea.dataset_tools.preprocess_backends import ( + DaskBackend, + FuturesBackend, + IterativeBackend, + PreprocessBackend, + PreprocessJob, +) from coffea.dataset_tools.splitting import hash_fileset, split_fileset __all__ = [ "preprocess", "preprocess_legacy", "preprocess_parquet", + "preprocess_rntuple", "preprocess_root", "split_fileset", "hash_fileset", @@ -58,4 +67,9 @@ "DatasetSpec", "DataGroupSpec", "ModelFactory", + "PreprocessBackend", + "PreprocessJob", + "DaskBackend", + "FuturesBackend", + "IterativeBackend", ] diff --git a/src/coffea/dataset_tools/filespec.py b/src/coffea/dataset_tools/filespec.py index 6f455a748..537e0750b 100644 --- a/src/coffea/dataset_tools/filespec.py +++ b/src/coffea/dataset_tools/filespec.py @@ -33,6 +33,21 @@ class GenericFileSpec(BaseModel): format: str | None = None lfn: str | None = None pfn: str | None = None + # Per-file user metadata, e.g. filled by preprocess(metadata_extractor=...). Must be + # JSON-serializable. Carried by the pydantic models only; legacy dict conversions drop it. + metadata: dict[str, Any] | None = None + # Experimental: hex bitset over the owning dataset's union-form top-level fields + # (bit i set = field i is present in this file); meaningful only relative to that + # dataset's saved form field order. Subject to change; do not rely on the encoding. + experimental_field_bitset: str | None = None + + def __eq__(self, other: Any) -> bool: + # experimental fields do not participate in equality: two specs describing the + # same file compare equal regardless of experimental annotations + if self.__class__ is not other.__class__: + return NotImplemented + excluded = {"experimental_field_bitset"} + return self.model_dump(exclude=excluded) == other.model_dump(exclude=excluded) def __add__(self, other: GenericFileSpec) -> GenericFileSpec: if not isinstance(other, GenericFileSpec): @@ -60,6 +75,21 @@ def __add__(self, other: GenericFileSpec) -> GenericFileSpec: new_spec["num_entries"] = self.num_entries else: new_spec["num_entries"] = max(self.num_entries, other.num_entries) + if self.metadata is None: + new_spec["metadata"] = other.metadata + elif other.metadata is not None: + # merge per-file metadata dictionaries, with other taking precedence + merged_meta = dict(self.metadata) + merged_meta.update(other.metadata) + new_spec["metadata"] = merged_meta + if self.experimental_field_bitset is None: + new_spec["experimental_field_bitset"] = other.experimental_field_bitset + elif other.experimental_field_bitset not in ( + None, + self.experimental_field_bitset, + ): + # disagreeing bitsets cannot be reconciled without the owning forms + new_spec["experimental_field_bitset"] = None return type(self)(**new_spec) def __sub__(self, other: GenericFileSpec) -> GenericFileSpec: @@ -466,6 +496,22 @@ def __eq__(self, other: Any) -> bool: return self.form == other.form def __add__(self, other: DatasetSpec) -> DatasetSpec: + return self.union_with(other) + + def union_with(self, other: DatasetSpec, sort_fields: bool = False) -> DatasetSpec: + """Merge two DatasetSpecs, computing the union of their saved forms. + + Files merge like ``+`` on the file collections and metadata merges with ``other`` + taking precedence. When both operands carry a saved form, the result's form is + their union (every field appearing in either form), and per-file experimental + field bitsets are remapped to the union field order. When neither operand has a + form the result has none. Adding a form-bearing spec to a form-less one raises a + ValueError, since the union form could not describe the form-less operand's files. + ``sort_fields=True`` sorts record fields recursively so the serialized union form + is byte-stable regardless of operand order. + + ``__add__`` delegates here with default options. + """ if not isinstance(other, DatasetSpec): raise TypeError( f"Can only add DatasetSpec to DatasetSpec, got {type(other)}" @@ -475,20 +521,156 @@ def __add__(self, other: DatasetSpec) -> DatasetSpec: raise ValueError( f"Cannot add DatasetSpec with different dids: {self.did} and {other.did}" ) + if (self.compressed_form is None) != (other.compressed_form is None): + raise ValueError( + "Cannot add a DatasetSpec with a saved form to one without: the union " + "form could not describe the files of the form-less operand. Preprocess " + "it with save_form=True (or clear the other form) first." + ) new_spec = self.model_dump() - new_spec["files"] = self.files + other.files + merged_files = self.files + other.files + new_spec["files"] = merged_files.model_dump() # merge metadata dictionaries, with other taking precedence new_metadata = copy.deepcopy(self.metadata) new_metadata.update(other.metadata) new_spec["metadata"] = new_metadata # format will be re-evaluated in post validation new_spec["format"] = None - # compressed_form is not merged, set to None - new_spec["compressed_form"] = None # did is not merged, set to None new_spec["did"] = self.did if self.did is not None else other.did + + if self.compressed_form is None and other.compressed_form is None: + new_spec["compressed_form"] = None + else: + import awkward + + from coffea.dataset_tools.forms import ( + decode_field_bitset, + encode_field_bitset, + sort_form_fields, + union_form_jsonstr, + ) + from coffea.util import compress_form + + self_form = self.form + other_form = other.form + if list(self_form.fields) == list(other_form.fields) and ( + self_form == other_form + ): + union_form = sort_form_fields(self_form) if sort_fields else self_form + union_jsonstr = union_form.to_json() + else: + union_jsonstr = union_form_jsonstr( + [self_form, other_form], sort_fields=sort_fields + ) + union_form = awkward.forms.from_json(union_jsonstr) + new_spec["compressed_form"] = compress_form(union_jsonstr) + + # remap per-file field bitsets from each operand's field order to the union's + union_fields = list(union_form.fields) + + def _decoded(files, form): + fields = list(form.fields) + return { + fname: ( + decode_field_bitset(fs.experimental_field_bitset, fields) + if fs.experimental_field_bitset is not None + else None + ) + for fname, fs in files.items() + } + + fields_self = _decoded(self.files, self_form) + fields_other = _decoded(other.files, other_form) + for fname, spec_dict in new_spec["files"].items(): + present_self = fields_self.get(fname) + present_other = fields_other.get(fname) + if present_self is None: + present = present_other + elif present_other is None or present_self == present_other: + present = present_self + else: + # the same file reports different field sets; unknowable which is right + present = None + spec_dict["experimental_field_bitset"] = ( + encode_field_bitset(present, union_fields) + if present is not None + else None + ) return type(self)(**new_spec) + def canonicalize_form(self) -> Self: + """Return a copy whose saved form has recursively sorted record fields. + + Sorting makes the serialized form (and anything hashed from it) independent of the + union/merge history that produced it; per-file experimental field bitsets are + remapped to the sorted field order. A spec without a saved form is returned as an + unmodified copy. + """ + spec = self.model_dump() + if self.compressed_form is not None: + from coffea.dataset_tools.forms import ( + decode_field_bitset, + encode_field_bitset, + sort_form_fields, + ) + from coffea.util import compress_form + + old_form = self.form + old_fields = list(old_form.fields) + sorted_form = sort_form_fields(old_form) + new_fields = list(sorted_form.fields) + spec["compressed_form"] = compress_form(sorted_form.to_json()) + for fname, spec_dict in spec["files"].items(): + bitset = spec_dict.get("experimental_field_bitset") + if bitset is not None: + spec_dict["experimental_field_bitset"] = encode_field_bitset( + decode_field_bitset(bitset, old_fields), new_fields + ) + return type(self)(**spec) + + def _prune_form_for_files(self, spec: dict) -> dict: + """Prune the saved union form in a dumped ``spec`` to the fields its files carry. + + Pruning applies only when every remaining file has an experimental field bitset + (otherwise the field content of some file is unknown and the form is kept as a + superset). Bitsets are remapped to the pruned field order. + """ + if self.compressed_form is None or not spec["files"]: + return spec + bitsets = { + fname: file_spec.get("experimental_field_bitset") + for fname, file_spec in spec["files"].items() + } + if any(bitset is None for bitset in bitsets.values()): + return spec + + from coffea.dataset_tools.forms import ( + decode_field_bitset, + encode_field_bitset, + prune_form_fields, + ) + from coffea.util import compress_form + + form = self.form + old_fields = list(form.fields) + keep_fields = set() + present_by_file = {} + for fname, bitset in bitsets.items(): + present = decode_field_bitset(bitset, old_fields) + present_by_file[fname] = present + keep_fields |= present + if keep_fields == set(old_fields): + return spec + pruned_form = prune_form_fields(form, keep_fields) + new_fields = list(pruned_form.fields) + spec["compressed_form"] = compress_form(pruned_form.to_json()) + for fname, file_spec in spec["files"].items(): + file_spec["experimental_field_bitset"] = encode_field_bitset( + present_by_file[fname], new_fields + ) + return spec + def __sub__(self, other: DatasetSpec) -> DatasetSpec: if not isinstance(other, DatasetSpec): raise TypeError( @@ -699,9 +881,14 @@ def limit_steps(self, max_steps: int | slice, per_file: bool = False) -> Self: return type(self)(**spec) def limit_files(self, max_files: int | slice | None) -> Self: - """Limit the number of files.""" + """Limit the number of files. + + When every remaining file carries an experimental field bitset, the saved union + form is pruned to the fields those files carry; otherwise it is kept as a superset. + """ spec = self.model_dump() - spec["files"] = self.files.limit_files(max_files) + spec["files"] = self.files.limit_files(max_files).model_dump() + spec = self._prune_form_for_files(spec) return type(self)(**spec) def filter_files( @@ -709,11 +896,16 @@ def filter_files( filter_name: str | None = None, filter_callable: Callable[[FileSpecUnion], bool] | None = None, ) -> Self: - """Filter files by a regex pattern on the file names(filter_name) or callable applied to Filespecs (filter_callable).""" + """Filter files by a regex pattern on the file names(filter_name) or callable applied to Filespecs (filter_callable). + + When every remaining file carries an experimental field bitset, the saved union + form is pruned to the fields those files carry; otherwise it is kept as a superset. + """ spec = self.model_dump() spec["files"] = self.files.filter_files( filter_name=filter_name, filter_callable=filter_callable - ) + ).model_dump() + spec = self._prune_form_for_files(spec) return type(self)(**spec) @@ -937,7 +1129,8 @@ def filespec_to_dict( raise TypeError( f"{cls.__name__}.filespec_to_dict expects a Coffea(Parquet)FileSpec(Optional), got {type(input)} instead: {input}" ) - return input.model_dump() + # the legacy dict format does not carry per-file metadata or experimental fields + return input.model_dump(exclude={"metadata", "experimental_field_bitset"}) @classmethod def dict_to_datasetspec(cls, input: dict[str, Any], verbose=False) -> DatasetSpec: @@ -953,6 +1146,11 @@ def datasetspec_to_dict( input, DatasetSpec ), f"{cls.__name__}.datasetspec_to_dict expects a DatasetSpec, got {type(input)} instead: {input}" if coerce_filespec_to_dict: - return input.model_dump() + # the legacy dict format does not carry per-file metadata or experimental fields + return input.model_dump( + exclude={ + "files": {"__all__": {"metadata", "experimental_field_bitset"}} + } + ) else: return dict(input) diff --git a/src/coffea/dataset_tools/forms.py b/src/coffea/dataset_tools/forms.py new file mode 100644 index 000000000..f88150296 --- /dev/null +++ b/src/coffea/dataset_tools/forms.py @@ -0,0 +1,147 @@ +"""Helpers for combining awkward forms across files and datasets. + +A dataset's saved form is the union of its files' forms: NanoAOD-style inputs routinely have +per-file field differences (e.g. ``HLT_*`` trigger bits or ``GenModel`` points present in only +some files), so the dataset form must cover every field that appears anywhere while remaining +readable for files that lack some of them. This module is import-light (awkward only) so both +:mod:`coffea.dataset_tools.preprocess` and :mod:`coffea.dataset_tools.filespec` can use it. +""" + +from __future__ import annotations + +import awkward + +__all__ = [ + "union_form_jsonstr", + "sort_form_fields", + "prune_form_fields", + "encode_field_bitset", + "decode_field_bitset", +] + + +def union_form_jsonstr(forms: list, sort_fields: bool = False) -> str | None: + """Compute the union form (as a JSON string) over a list of awkward forms. + + The input list is consumed. Returns None if the list is empty. Mirrors the merging of + flat-tuple-like schemas used when building a dataset's union form across files. Fields + appear in merge order by default; ``sort_fields=True`` recursively sorts record fields so + the serialized form is byte-stable regardless of merge order (awkward form equality is + field-order-insensitive either way). + """ + union_array = None + while len(forms): + new_array = awkward.Array(forms.pop().length_zero_array()) + if union_array is None: + union_array = new_array + else: + union_array = awkward.to_packed( + awkward.merge_union_of_records( + awkward.concatenate([union_array, new_array]), axis=0 + ) + ) + union_array.layout.parameters.update(new_array.layout.parameters) + if union_array is None: + return None + + union_form = union_array.layout.form + for icontent, content in enumerate(union_form.contents): + if isinstance(content, awkward.forms.IndexedOptionForm): + if ( + not isinstance(content.content, awkward.forms.NumpyForm) + or content.content.primitive != "bool" + ): + raise ValueError( + "IndexedOptionArrays can only contain NumpyArrays of " + "bools in mergers of flat-tuple-like schemas!" + ) + parameters = ( + content.content.parameters.copy() + if content.content.parameters is not None + else {} + ) + # re-create IndexOptionForm with parameters of lower level array + union_form.contents[icontent] = awkward.forms.IndexedOptionForm( + content.index, + content.content, + parameters=parameters, + form_key=content.form_key, + ) + if sort_fields: + union_form = sort_form_fields(union_form) + return union_form.to_json() + + +def _sort_record_nodes(node) -> None: + if isinstance(node, dict): + if ( + node.get("class") == "RecordArray" + and isinstance(node.get("fields"), list) + and isinstance(node.get("contents"), list) + ): + pairs = sorted( + zip(node["fields"], node["contents"]), key=lambda pair: pair[0] + ) + node["fields"] = [field for field, _ in pairs] + node["contents"] = [content for _, content in pairs] + for value in node.values(): + _sort_record_nodes(value) + elif isinstance(node, list): + for value in node: + _sort_record_nodes(value) + + +def sort_form_fields(form: awkward.forms.Form) -> awkward.forms.Form: + """Return a copy of ``form`` with record fields recursively sorted by name. + + Tuple-like records (no field names) are left untouched. Sorting canonicalizes the + serialized byte order of forms whose field order depends on union/merge history. + """ + form_dict = form.to_dict(verbose=True) + _sort_record_nodes(form_dict) + return awkward.forms.from_dict(form_dict) + + +def prune_form_fields( + form: awkward.forms.Form, keep_fields: set[str] +) -> awkward.forms.Form: + """Return a copy of ``form`` with only the top-level record fields in ``keep_fields``. + + Only the outermost record is pruned: dataset union forms merge file forms at the top + level, so that is the level at which fields can disappear when files are removed. + """ + form_dict = form.to_dict(verbose=True) + if not ( + isinstance(form_dict.get("fields"), list) + and isinstance(form_dict.get("contents"), list) + ): + return form + pairs = [ + (field, content) + for field, content in zip(form_dict["fields"], form_dict["contents"]) + if field in keep_fields + ] + form_dict["fields"] = [field for field, _ in pairs] + form_dict["contents"] = [content for _, content in pairs] + return awkward.forms.from_dict(form_dict) + + +def encode_field_bitset(present_fields, union_fields: list[str]) -> str: + """Encode which of ``union_fields`` a file carries as a compact hex bitset string. + + Bit ``i`` (value ``1 << i``) corresponds to ``union_fields[i]``; a set bit means the + field is present in the file. Fields outside ``union_fields`` are ignored. The encoding + is only meaningful relative to the field order of the owning dataset's saved form. + """ + present = set(present_fields) + bits = 0 + for index, field in enumerate(union_fields): + if field in present: + bits |= 1 << index + return format(bits, "x") + + +def decode_field_bitset(bitset: str, union_fields: list[str]) -> set[str]: + """Decode a hex bitset string (see :func:`encode_field_bitset`) into a set of field names.""" + bits = int(bitset, 16) + return {field for index, field in enumerate(union_fields) if (bits >> index) & 1} diff --git a/src/coffea/dataset_tools/mutable_steps.py b/src/coffea/dataset_tools/mutable_steps.py new file mode 100644 index 000000000..e00d93ca5 --- /dev/null +++ b/src/coffea/dataset_tools/mutable_steps.py @@ -0,0 +1,257 @@ +"""Prototype for mutable (resizable) steps: generator-driven step iteration where the +consumer may renegotiate the step size mid-stream. + +Stored ``steps`` on a file spec are a static tiling chosen at preprocess time. Execution +backends with runtime resource information (e.g. a resource-monitoring scheduler that sees a +worker near memory exhaustion) can do better by shrinking or growing subsequent chunks while +a file is being processed. This module provides that as a ``send``-channel generator +protocol: iterating yields ``[start, stop]`` steps, and ``generator.send(new_size)`` requests +that subsequent steps be *at most* ``new_size`` entries, with the remainder of the current +region re-tiled as evenly as possible:: + + n = ceil(remaining / new_size) + actual = ceil(remaining / n) + +so a resize triggered by resource exhaustion can never produce a larger-than-requested step, +and the remaining steps stay near-uniform. The same channel shape is used by +``Computable.gen_steps`` in the ``coffea.compute`` protocol, so consumers written against +this prototype translate directly. + +Everything here is a prototype: APIs may change, and nothing is re-exported from +``coffea.dataset_tools``. +""" + +from __future__ import annotations + +import math +import time +from collections.abc import Callable, Generator +from dataclasses import dataclass, field + +__all__ = [ + "resizable_steps", + "coverage_regions", + "iter_file_steps", + "iter_dataset_steps", + "remaining_regions", + "completed_spec", + "WallTimeStepPolicy", + "run_adaptive_steps", +] + + +def _validate_size(size: int) -> None: + if size is None or size < 1: + raise ValueError(f"step size must be a positive integer (>= 1), got {size!r}.") + + +def resizable_steps( + start: int, stop: int, step_size: int +) -> Generator[list[int], int | None, None]: + """Yield ``[begin, end]`` steps tiling ``[start, stop)``, honoring resize requests. + + Steps are at most the current target size and as even as possible: each iteration + re-tiles the remaining entries as ``ceil(remaining / ceil(remaining / size))``, which is + stable while the size is unchanged. Sending a positive integer sets the target size for + all subsequent steps; sending ``None`` (plain iteration) keeps the current size. + """ + _validate_size(step_size) + current = step_size + pos = start + while pos < stop: + remaining = stop - pos + n_steps = math.ceil(remaining / current) + actual = math.ceil(remaining / n_steps) + end = min(pos + actual, stop) + sent = yield [pos, end] + pos = end + if sent is not None: + _validate_size(sent) + current = sent + + +def coverage_regions(filespec) -> list[list[int]]: + """The contiguous entry regions a file spec covers, as ``[begin, end]`` pairs. + + Adjacent steps (``stop == next start``) merge into one region. A spec without steps + covers ``[0, num_entries)``; a spec with neither steps nor num_entries cannot be tiled + and raises a ValueError. + """ + if filespec.steps is not None: + regions: list[list[int]] = [] + for begin, end in filespec.steps: + if regions and regions[-1][1] == begin: + regions[-1][1] = end + else: + regions.append([begin, end]) + return regions + if filespec.num_entries is not None: + return [[0, filespec.num_entries]] if filespec.num_entries > 0 else [] + raise ValueError( + "Cannot iterate steps for a file spec with neither steps nor num_entries; " + "preprocess the file first." + ) + + +def iter_file_steps( + filespec, step_size: int | None = None +) -> Generator[list[int], int | None, None]: + """Yield resizable ``[start, stop]`` steps over a file spec's covered regions. + + ``step_size`` is the initial target size (default: one step per contiguous region). + ``send(new_size)`` re-tiles from the next step onward, carrying across regions. + """ + current = step_size + for begin, end in coverage_regions(filespec): + size = current if current is not None else end - begin + gen = resizable_steps(begin, end, size) + try: + step = next(gen) + while True: + sent = yield step + if sent is not None: + current = sent + step = gen.send(sent) + except StopIteration: + continue + + +def iter_dataset_steps( + dataset, step_size: int | None = None +) -> Generator[tuple[str, list[int]], int | None, None]: + """Yield resizable ``(filename, [start, stop])`` steps over every file of a DatasetSpec. + + A resize request applies from the next step onward and carries across file boundaries, + so a consumer that shrinks the step size mid-file keeps the smaller size for later files + until it requests otherwise. + """ + current = step_size + for fname, filespec in dataset.files.items(): + gen = iter_file_steps(filespec, current) + try: + step = next(gen) + while True: + sent = yield (fname, step) + if sent is not None: + current = sent + step = gen.send(sent) + except StopIteration: + continue + + +def remaining_regions(filespec, completed: list[list[int]]) -> list[list[int]]: + """The parts of a file spec's coverage not contained in ``completed`` ranges. + + ``completed`` are arbitrary ``[start, stop]`` pairs (they need not align with the spec's + stored steps -- resized steps generally do not). Returns ``[begin, end]`` regions suitable + for resuming iteration via :func:`resizable_steps`. + """ + merged: list[list[int]] = [] + for begin, end in sorted([list(pair) for pair in completed]): + if merged and begin <= merged[-1][1]: + merged[-1][1] = max(merged[-1][1], end) + else: + merged.append([begin, end]) + remaining: list[list[int]] = [] + for begin, end in coverage_regions(filespec): + pos = begin + for done_begin, done_end in merged: + if done_end <= pos or done_begin >= end: + continue + if done_begin > pos: + remaining.append([pos, done_begin]) + pos = max(pos, done_end) + if pos >= end: + break + if pos < end: + remaining.append([pos, end]) + return remaining + + +def completed_spec(filespec, completed: list[list[int]]): + """A copy of ``filespec`` whose steps are the ``completed`` ranges. + + Useful for persisting progress: the result round-trips like any spec, and adding it to a + spec built from later completions accumulates coverage via the ordinary step arithmetic. + Returns ``None`` when nothing has completed. + """ + if not completed: + return None + spec = filespec.model_dump() + spec["steps"] = sorted([list(pair) for pair in completed]) + return type(filespec)(**spec) + + +@dataclass +class WallTimeStepPolicy: + """Toy resize policy targeting a fixed wall time per step. + + After each step, the next target size is ``current * target_seconds / elapsed``, clamped + to ``[min_step_size, max_step_size]`` and damped by ``max_growth`` per adjustment so a + single fast outlier does not balloon the step size. + """ + + target_seconds: float + min_step_size: int = 1 + max_step_size: int | None = None + max_growth: float = 2.0 + + def propose(self, current_size: int, elapsed_seconds: float) -> int | None: + """The new target size, or None to keep the current one.""" + if elapsed_seconds <= 0.0: + scaled = current_size * self.max_growth + else: + scaled = current_size * self.target_seconds / elapsed_seconds + scaled = min(scaled, current_size * self.max_growth) + new_size = max(self.min_step_size, int(scaled)) + if self.max_step_size is not None: + new_size = min(new_size, self.max_step_size) + return None if new_size == current_size else new_size + + +@dataclass +class AdaptiveRun: + """Result of :func:`run_adaptive_steps`.""" + + results: list + completed: dict[str, list[list[int]]] + step_sizes: list[int] = field(default_factory=list) + + +def run_adaptive_steps( + dataset, + work: Callable[[str, list[int]], object], + step_size: int, + policy: WallTimeStepPolicy, + clock: Callable[[], float] = time.monotonic, +) -> AdaptiveRun: + """Toy driver: process a DatasetSpec step by step, resizing steps from measured wall time. + + ``work(filename, [start, stop])`` is called for each step; its wall time (measured with + ``clock``) feeds ``policy.propose``, and any proposed size is sent into the step + generator, re-tiling the remainder. This is the single-consumer analogue of what a + resource-monitoring scheduler does with worker feedback; ``clock`` is injectable so the + control loop is testable without real waiting. + """ + _validate_size(step_size) + gen = iter_dataset_steps(dataset, step_size) + run = AdaptiveRun(results=[], completed={}) + current = step_size + try: + item = next(gen) + while True: + fname, step = item + begin = clock() + run.results.append(work(fname, step)) + elapsed = clock() - begin + run.completed.setdefault(fname, []).append(step) + run.step_sizes.append(step[1] - step[0]) + proposed = policy.propose(current, elapsed) + if proposed is not None: + current = proposed + item = gen.send(proposed) + else: + item = next(gen) + except StopIteration: + pass + return run diff --git a/src/coffea/dataset_tools/preprocess.py b/src/coffea/dataset_tools/preprocess.py index 4439eea1e..b9cae8c82 100644 --- a/src/coffea/dataset_tools/preprocess.py +++ b/src/coffea/dataset_tools/preprocess.py @@ -2,6 +2,7 @@ import copy import hashlib +import json import math import warnings from collections.abc import Callable @@ -16,11 +17,31 @@ import uproot from uproot._util import no_filter +try: + # Private uproot helper that builds a TTree's awkward form without dask. It is exactly what + # uproot.dask() uses internally, so the result is byte-identical to + # uproot.dask(tree).layout.form. Guarded so a uproot release that moves or renames it + # degrades gracefully to the dask-based path below. + from uproot._dask import _get_ttree_form as _uproot_get_ttree_form +except Exception: # pragma: no cover - depends on uproot internals + _uproot_get_ttree_form = None + from coffea.dataset_tools.filespec import ( DataGroupSpec, DatasetSpec, ModelFactory, ) +from coffea.dataset_tools.forms import ( + encode_field_bitset, +) +from coffea.dataset_tools.forms import union_form_jsonstr as _union_form_jsonstr +from coffea.dataset_tools.preprocess_backends import ( + DaskBackend, + PreprocessBackend, + PreprocessJob, + print_dask_backend_fallback_hint, + resolve_backend, +) from coffea.util import ( _import_dask, _import_dask_awkward, @@ -30,6 +51,179 @@ ) +def _validate_step_size(step_size: int | None) -> None: + """Reject a non-positive ``step_size`` at the public entrypoints. + + ``step_size`` is the number of entries per step; ``None`` means "one step per file". A value + < 1 is meaningless and would otherwise surface as a bare ``ZeroDivisionError`` from + ``_even_steps`` deep inside a worker (or dask graph). + """ + if step_size is not None and step_size < 1: + raise ValueError( + f"step_size must be a positive integer (>= 1) or None, got {step_size!r}." + ) + + +def _even_steps(num_entries: int, target_step_size: int) -> numpy.ndarray: + """Split ``num_entries`` into as-even-as-possible ``[start, stop]`` steps of ~target size.""" + n_steps_target = max(round(num_entries / target_step_size), 1) + actual_step_size = math.ceil(num_entries / n_steps_target) + return numpy.array( + [ + [i * actual_step_size, min((i + 1) * actual_step_size, num_entries)] + for i in range(n_steps_target) + ], + dtype="int64", + ) + + +def _aligned_steps( + boundaries, + target_step_size: int, + step_size_safety_factor: float, + file_label: str, + mode_label: str, +) -> numpy.ndarray: + """Build ``[start, stop]`` steps that snap to natural boundaries (TTree clusters, RNTuple + cluster summaries, or parquet row groups). + + ``boundaries`` is the increasing sequence of absolute entry offsets at which a step is + allowed to end, with the final element equal to ``num_entries``. Steps accumulate + boundaries until at least ``target_step_size`` entries are covered. ``mode_label`` is the + name of the user-facing option (``align_clusters`` or ``use_row_groups``) used in the + over-size warning. + """ + out = [0] + for c in boundaries: + if c >= out[-1] + target_step_size: + out.append(c) + if boundaries[-1] != out[-1]: + out.append(boundaries[-1]) + out = numpy.array(out, dtype="int64") + out = numpy.stack((out[:-1], out[1:]), axis=1) + + step_mask = out[:, 1] - out[:, 0] > (1 + step_size_safety_factor) * target_step_size + if numpy.any(step_mask): + warnings.warn( + f"In file {file_label}, steps: {out[step_mask]} with {mode_label}=True are " + f"{step_size_safety_factor*100:.0f}% larger than target " + f"step size: {target_step_size}!" + ) + return out + + +def _rntuple_cluster_boundaries(rntuple, num_entries: int) -> list[int]: + """Absolute entry offsets at RNTuple cluster boundaries, terminating at ``num_entries``.""" + boundaries = [cluster.num_first_entry for cluster in rntuple.cluster_summaries] + boundaries.append(num_entries) + return boundaries + + +def _serialize_user_metadata(user_meta, filename) -> str | None: + """Validate and JSON-encode a metadata_extractor result for transport in the worker record.""" + if user_meta is None: + return None + if not isinstance(user_meta, dict): + raise ValueError( + f"metadata_extractor must return a dict, got {type(user_meta).__name__} " + f"for file {filename!r}." + ) + try: + return json.dumps(user_meta) + except TypeError as err: + raise ValueError( + f"metadata_extractor result for file {filename!r} is not JSON-serializable: {err}" + ) from err + + +_FORM_AK_ADD_DOC = {"__doc__": "title", "typename": "typename"} + + +def _form_json_with_null_keys(form) -> str: + """Serialize ``form`` to JSON with every ``form_key`` set to ``None``. + + ``uproot.dask`` exposes its *meta* (typetracer) form, whose form keys are null, whereas + ``_get_ttree_form`` carries RNTuple column keys (``"column-N"``). Nulling the keys makes the + serialized form byte-identical to ``uproot.dask(...).layout.form.to_json()`` and is a no-op + for TTree forms (already keyless). The keys are stripped on the ``to_dict`` representation + and serialized directly, matching ``Form.to_json`` (``json.dumps`` of the verbose dict) + without rebuilding a ``Form`` tree in between. + """ + form_dict = form.to_dict(verbose=True) + + def _strip(node): + if isinstance(node, dict): + if "form_key" in node: + node["form_key"] = None + for value in node.values(): + _strip(value) + elif isinstance(node, list): + for value in node: + _strip(value) + + _strip(form_dict) + return json.dumps(form_dict) + + +def _awkward_form_json(tree, is_rntuple: bool) -> str: + """Build a TTree or RNTuple awkward form JSON without importing dask. + + Reuses uproot's own form builder (``uproot._dask._get_ttree_form``, which handles both TTree + branches and RNTuple fields) and nulls the resulting form keys, producing output + byte-identical to ``uproot.dask(...).layout.form.to_json()``. This is what lets the + ``iterative``/``futures`` backends extract forms in a dask-free environment for both formats. + """ + # RNTuples filter on fields and use full field paths; TTrees filter on branches. + filter_kwarg = "filter_field" if is_rntuple else "filter_branch" + common_keys = tree.keys( + recursive=True, + filter_name=no_filter, + filter_typename=no_filter, + full_paths=is_rntuple, + ignore_duplicates=True, + **{filter_kwarg: partial(_is_interpretable, emit_warning=False)}, + ) + base_form = _uproot_get_ttree_form(awkward, tree, common_keys, _FORM_AK_ADD_DOC) + return _form_json_with_null_keys(base_form) + + +def _dask_form_json(uproot_target, is_rntuple: bool, uproot_options: dict = {}) -> str: + """Build a form JSON via ``uproot.dask`` (requires dask). + + Fallback used only when uproot's internal form builder (``_get_ttree_form``) is unavailable + (an unexpected uproot version); the dask-free :func:`_awkward_form_json` is preferred for both + TTree and RNTuple. ``uproot_target`` is an already-open TTree object, or a + ``{file: object_path}`` mapping (required for RNTuples, which cannot build a form from an + already-open object via ``uproot.dask``). ``uproot_options`` is forwarded to the file open + when ``uproot_target`` is a mapping. + """ + if is_rntuple: + form_dask = uproot.dask( + uproot_target, + open_files=False, + full_paths=True, + ak_add_doc=_FORM_AK_ADD_DOC, + filter_name=no_filter, + filter_typename=no_filter, + filter_branch=partial(_is_interpretable, emit_warning=False), + **uproot_options, + ) + else: + form_dask = uproot.dask( + uproot_target, + ak_add_doc=_FORM_AK_ADD_DOC, + filter_name=no_filter, + filter_typename=no_filter, + filter_branch=partial(_is_interpretable, emit_warning=False), + ) + form_str = form_dask.layout.form.to_json() + # the function cache needs to be popped if present to prevent memory growth + dask = _import_dask() + if getattr(dask.base, "function_cache", None): + dask.base.function_cache.popitem() + return form_str + + def get_steps( normed_files: awkward.Array | dask_awkward.Array, step_size: int | None = None, @@ -41,6 +235,8 @@ def get_steps( step_size_safety_factor: float = 0.5, uproot_options: dict = {}, legacy_form_key: bool = True, + require_rntuple: bool = False, + metadata_extractor: Callable | None = None, ) -> awkward.Array | dask_awkward.Array: """ Given a list of normalized file and object paths (defined in uproot), determine the steps for each file according to the supplied processing options. @@ -69,12 +265,22 @@ def get_steps( legacy_form_key : bool, default True Use "form" for the compressed form key in the output for backwards compatibility. Set to False to use "compressed_form" instead. + require_rntuple : bool, default False + If True, require every object to be an RNTuple and raise a ValueError otherwise. + If False, TTree and RNTuple objects are auto-detected and handled transparently. + metadata_extractor : Callable or None, default None + User function called once per file with the open uproot file handle (the + ReadOnlyDirectory, not the tree); must return a JSON-serializable dict, which is + stored as that file's metadata. Runs inside the per-file error handling, so an + extraction failure participates in skip_bad_files/file_exceptions. Must be + picklable to run under process pools or distributed schedulers. Returns ------- array : awkward.Array or dask_awkward.Array The normalized file descriptions, appended with the calculated steps for those files. """ + _validate_step_size(step_size) nf_backend = awkward.backend(normed_files) lz_or_nf = awkward.typetracer.length_zero_if_typetracer(normed_files) output_form_key = "form" if legacy_form_key else "compressed_form" @@ -84,6 +290,18 @@ def get_steps( try: the_file = uproot.open({arg.file: None}, **uproot_options) tree = the_file[arg.object_path] + is_rntuple = isinstance(tree, uproot.behaviors.RNTuple.HasFields) + if require_rntuple and not is_rntuple: + # raised inside this block so skip_bad_files/file_exceptions can skip the file + raise ValueError( + f"require_rntuple=True but {arg.object_path!r} in {arg.file!r} is a " + f"{type(tree).__name__}, not an RNTuple." + ) + # run the user extractor on the open file handle, inside this block so a file + # whose extraction fails participates in skip_bad_files/file_exceptions + user_meta = ( + metadata_extractor(the_file) if metadata_extractor is not None else None + ) except file_exceptions as e: if skip_bad_files: array.append(None) @@ -91,22 +309,25 @@ def get_steps( else: raise e + user_meta_json = _serialize_user_metadata(user_meta, arg.file) + num_entries = tree.num_entries form_json = None form_hash = None if save_form: - form_str = uproot.dask( - tree, - ak_add_doc={"__doc__": "title", "typename": "typename"}, - filter_name=no_filter, - filter_typename=no_filter, - filter_branch=partial(_is_interpretable, emit_warning=False), - ).layout.form.to_json() - # the function cache needs to be popped if present to prevent memory growth - dask = _import_dask() - if hasattr(dask.base, "function_cache"): - dask.base.function_cache.popitem() + if _uproot_get_ttree_form is not None: + # dask-free form extraction (TTree and RNTuple); byte-identical to uproot.dask + form_str = _awkward_form_json(tree, is_rntuple) + elif is_rntuple: + # uproot without the private form builder: fall back to the dask-based path + form_str = _dask_form_json( + {arg.file: arg.object_path}, + is_rntuple=True, + uproot_options=uproot_options, + ) + else: + form_str = _dask_form_json(tree, is_rntuple=False) form_hash = hashlib.md5(form_str.encode("utf-8")).hexdigest() form_json = compress_form(form_str) @@ -128,46 +349,26 @@ def get_steps( "uuid": file_uuid, output_form_key: form_json, "form_hash_md5": form_hash, + "user_metadata_json": user_meta_json, } ) continue if out_uuid != file_uuid or recalculate_steps: if align_clusters: - clusters = tree.common_entry_offsets() - out = [0] - for c in clusters: - if c >= out[-1] + target_step_size: - out.append(c) - if clusters[-1] != out[-1]: - out.append(clusters[-1]) - out = numpy.array(out, dtype="int64") - out = numpy.stack((out[:-1], out[1:]), axis=1) - - step_mask = ( - out[:, 1] - out[:, 0] - > (1 + step_size_safety_factor) * target_step_size + if is_rntuple: + boundaries = _rntuple_cluster_boundaries(tree, num_entries) + else: + boundaries = tree.common_entry_offsets() + out = _aligned_steps( + boundaries, + target_step_size, + step_size_safety_factor, + arg.file, + "align_clusters", ) - if numpy.any(step_mask): - warnings.warn( - f"In file {arg.file}, steps: {out[step_mask]} with align_clusters=True are " - f"{step_size_safety_factor*100:.0f}% larger than target " - f"step size: {target_step_size}!" - ) - else: - n_steps_target = max(round(num_entries / target_step_size), 1) - actual_step_size = math.ceil(num_entries / n_steps_target) - out = numpy.array( - [ - [ - i * actual_step_size, - min((i + 1) * actual_step_size, num_entries), - ] - for i in range(n_steps_target) - ], - dtype="int64", - ) + out = _even_steps(num_entries, target_step_size) out_uuid = file_uuid out_steps = out.tolist() @@ -184,6 +385,7 @@ def get_steps( "uuid": out_uuid, output_form_key: form_json, "form_hash_md5": form_hash, + "user_metadata_json": user_meta_json, } ) @@ -198,6 +400,7 @@ def get_steps( "uuid": "junk", output_form_key: "junk", "form_hash_md5": "junk", + "user_metadata_json": "junk", }, None, ] @@ -305,6 +508,7 @@ def preprocess_legacy( out_updated : dict The original set of datasets including files that were not accessible, updated to include the result of preprocessing where available. """ + _validate_step_size(step_size) dask = _import_dask() dask_awkward = _import_dask_awkward() @@ -413,7 +617,7 @@ def preprocess_legacy( ): # skip trivially filled or empty files form = awkward.forms.from_json(decompress_form(formstr)) - if num_entries >= 0 and set(form.fields) != _trivial_file_fields: + if set(form.fields) != _trivial_file_fields: dataset_forms.append(form) else: warnings.warn( @@ -425,46 +629,7 @@ def preprocess_legacy( ", by default, removes empty files each dataset in a fileset." ) - union_array = None - union_form_jsonstr = None - while len(dataset_forms): - new_array = awkward.Array(dataset_forms.pop().length_zero_array()) - if union_array is None: - union_array = new_array - else: - union_array = awkward.to_packed( - awkward.merge_union_of_records( - awkward.concatenate([union_array, new_array]), axis=0 - ) - ) - union_array.layout.parameters.update(new_array.layout.parameters) - if union_array is not None: - union_form = union_array.layout.form - - for icontent, content in enumerate(union_form.contents): - if isinstance(content, awkward.forms.IndexedOptionForm): - if ( - not isinstance(content.content, awkward.forms.NumpyForm) - or content.content.primitive != "bool" - ): - raise ValueError( - "IndexedOptionArrays can only contain NumpyArrays of " - "bools in mergers of flat-tuple-like schemas!" - ) - parameters = ( - content.content.parameters.copy() - if content.content.parameters is not None - else {} - ) - # re-create IndexOptionForm with parameters of lower level array - union_form.contents[icontent] = awkward.forms.IndexedOptionForm( - content.index, - content.content, - parameters=parameters, - form_key=content.form_key, - ) - - union_form_jsonstr = union_form.to_json() + union_form_jsonstr = _union_form_jsonstr(dataset_forms) files_available = { item["file"]: { @@ -547,6 +712,7 @@ def get_parquet_form_uuid_steps( save_form: bool = False, step_size_safety_factor: float = 0.5, parquet_options: dict = {}, + metadata_extractor: Callable | None = None, ) -> awkward.Array | dask_awkward.Array: """ Given a list of normalized file and object paths, determine the form, steps, uuid for each file according to the supplied processing options. @@ -567,16 +733,23 @@ def get_parquet_form_uuid_steps( file_exceptions : Exception | Warning | tuple[Exception | Warning], default (OSError,) What exceptions to catch when skipping bad files. save_form : bool, default False - Extract the form of the parquet file so we can skip opening files later. + Extract the form from the parquet metadata so we can skip opening files later. step_size_safety_factor : float, default 0.5 When using use_row_groups, if a resulting step is larger than step_size by this factor warn the user that the resulting steps may be highly irregular. + metadata_extractor : Callable or None, default None + User function called once per file with the parquet metadata mapping returned by + ``awkward.metadata_from_parquet``; must return a JSON-serializable dict, which is + stored as that file's metadata. Runs inside the per-file error handling, so an + extraction failure participates in skip_bad_files/file_exceptions. Must be + picklable to run under process pools or distributed schedulers. Returns ------- array : awkward.Array | dask_awkward.Array The normalized file descriptions, appended with the calculated steps for those files. """ + _validate_step_size(step_size) nf_backend = awkward.backend(normed_files) lz_or_nf = awkward.typetracer.length_zero_if_typetracer(normed_files) @@ -584,6 +757,11 @@ def get_parquet_form_uuid_steps( for arg in lz_or_nf: try: the_file = awkward.metadata_from_parquet(arg.file, **parquet_options) + # run the user extractor on the parquet metadata, inside this block so a file + # whose extraction fails participates in skip_bad_files/file_exceptions + user_meta = ( + metadata_extractor(the_file) if metadata_extractor is not None else None + ) except file_exceptions as e: if skip_bad_files: array.append(None) @@ -591,13 +769,18 @@ def get_parquet_form_uuid_steps( else: raise e + user_meta_json = _serialize_user_metadata(user_meta, arg.file) + num_entries = the_file["num_rows"] form_json = None form_hash = None if save_form: + # parquet metadata already carries the form; reading it builds no dask graph, + # so (unlike the TTree/RNTuple path) there is no function cache to pop here. form = the_file["form"] form_str = form.to_json() + form_hash = hashlib.md5(form_str.encode("utf-8")).hexdigest() form_json = compress_form(form_str) @@ -605,9 +788,8 @@ def get_parquet_form_uuid_steps( file_uuid = the_file.get("uuid", None) - out_uuid = arg.uuid - out_steps = arg.steps - + # A 0-row file gets a single trivial step: it has no row groups to align to and no + # entries to split into steps. if num_entries == 0: array.append( { @@ -618,47 +800,27 @@ def get_parquet_form_uuid_steps( "uuid": file_uuid, "compressed_form": form_json, "form_hash_md5": form_hash, + "user_metadata_json": user_meta_json, } ) continue + out_uuid = arg.uuid + out_steps = arg.steps + if out_uuid != file_uuid or recalculate_steps: if use_row_groups: - row_group_entries = the_file["col_counts"] - out = [0] - this_offset = 0 - for c in row_group_entries: - this_offset += c - if this_offset >= out[-1] + target_step_size: - out.append(this_offset) - if this_offset != out[-1]: - out.append(this_offset) - out = numpy.array(out, dtype="int64") - out = numpy.stack((out[:-1], out[1:]), axis=1) - - step_mask = ( - out[:, 1] - out[:, 0] - > (1 + step_size_safety_factor) * target_step_size + # cumulative row counts give the absolute offset at each row-group boundary + boundaries = numpy.cumsum(the_file["col_counts"]).tolist() + out = _aligned_steps( + boundaries, + target_step_size, + step_size_safety_factor, + arg.file, + "use_row_groups", ) - if numpy.any(step_mask): - warnings.warn( - f"In file {arg.file}, steps: {out[step_mask]} with use_row_groups=True are " - f"{step_size_safety_factor*100:.0f}% larger than target " - f"step size: {target_step_size}!" - ) else: - n_steps_target = max(round(num_entries / target_step_size), 1) - actual_step_size = math.ceil(num_entries / n_steps_target) - out = numpy.array( - [ - [ - i * actual_step_size, - min((i + 1) * actual_step_size, num_entries), - ] - for i in range(n_steps_target) - ], - dtype="int64", - ) + out = _even_steps(num_entries, target_step_size) out_uuid = file_uuid out_steps = out.tolist() @@ -675,6 +837,7 @@ def get_parquet_form_uuid_steps( "uuid": out_uuid, "compressed_form": form_json, "form_hash_md5": form_hash, + "user_metadata_json": user_meta_json, } ) @@ -689,6 +852,7 @@ def get_parquet_form_uuid_steps( "uuid": "junk", "compressed_form": "junk", "form_hash_md5": "junk", + "user_metadata_json": "junk", }, None, ] @@ -718,10 +882,17 @@ def preprocess_root( uproot_options: dict = {}, step_size_safety_factor: float = 0.5, allow_empty_datasets: bool = False, + backend: str | PreprocessBackend = "dask", + require_rntuple: bool = False, + metadata_extractor: Callable | None = None, + metadata_reducer: Callable | None = None, ) -> tuple[DataGroupSpec, DataGroupSpec]: """ Given a list of normalized file and object paths (defined in uproot), determine the steps for each file according to the supplied processing options. + Both TTree and RNTuple objects are auto-detected and handled; pass ``require_rntuple=True`` + (or use the :func:`preprocess_rntuple` alias) to require that every object is an RNTuple. + Parameters ---------- datagroupspec : DataGroupSpec @@ -753,6 +924,24 @@ def preprocess_root( allow_empty_datasets : bool, default False When a dataset query comes back completely empty, this is normally considered a processing error. Toggle this argument to True to change this to warnings and allow incomplete returned filesets. + backend : str or PreprocessBackend, default "dask" + Execution backend for preprocessing: "dask" (default), "iterative" (immediate, + synchronous, dask-free), "futures" (dask-free concurrent.futures thread pool), or a + PreprocessBackend instance. The ``scheduler`` argument only affects the dask backend. + require_rntuple : bool, default False + If True, require every dataset to be ROOT-format and every object to be an RNTuple. + A parquet dataset raises a ValueError; a TTree object raises a ValueError inside the + worker, subject to ``skip_bad_files``/``file_exceptions`` like any other per-file + error. + metadata_extractor : Callable or None, default None + User function called once per file with the open uproot file handle; must return a + JSON-serializable dict, stored as that file's ``metadata`` on its file spec. Runs + inside the per-file error handling (participates in skip_bad_files/file_exceptions) + and must be picklable. + metadata_reducer : Callable or None, default None + User function called once per dataset with ``{filename: extracted_dict}`` for the + available files; must return a dict, merged into the dataset's ``metadata`` (reducer + output takes precedence over existing keys) on both returned filesets. Returns ------- out_available : DataGroupSpec @@ -773,9 +962,31 @@ def preprocess_root( filetype_options=uproot_options, step_size_safety_factor=step_size_safety_factor, allow_empty_datasets=allow_empty_datasets, + backend=backend, + require_rntuple=require_rntuple, + metadata_extractor=metadata_extractor, + metadata_reducer=metadata_reducer, ) +def preprocess_rntuple( + datagroupspec: DataGroupSpec, + **kwargs, +) -> tuple[DataGroupSpec, DataGroupSpec]: + """ + Preprocess datasets of ROOT files containing RNTuples, determining the steps for each file. + + Alias for :func:`preprocess_root` with ``require_rntuple=True``: every dataset must be + ROOT-format (parquet datasets raise a ValueError) and every object must be an RNTuple + (a TTree raises a ValueError inside the worker, subject to ``skip_bad_files``/ + ``file_exceptions``). ``preprocess`` and ``preprocess_root`` already auto-detect and handle + RNTuples transparently, so this function is for RNTuple-only workflows that want the + stricter contract. All other arguments are forwarded to :func:`preprocess_root`. + """ + kwargs["require_rntuple"] = True + return preprocess_root(datagroupspec, **kwargs) + + def preprocess_parquet( datagroupspec: DataGroupSpec, step_size: None | int = None, @@ -789,6 +1000,9 @@ def preprocess_parquet( parquet_options: dict = {}, step_size_safety_factor: float = 0.5, allow_empty_datasets: bool = False, + backend: str | PreprocessBackend = "dask", + metadata_extractor: Callable | None = None, + metadata_reducer: Callable | None = None, ) -> tuple[DataGroupSpec, DataGroupSpec]: """ Given a list of normalized files, determine the form, steps, and add the metadata for each file according to the supplied processing options. @@ -820,6 +1034,19 @@ def preprocess_parquet( allow_empty_datasets : bool, default False When a dataset query comes back completely empty, this is normally considered a processing error. Toggle this argument to True to change this to warnings and allow incomplete returned filesets. + backend : str or PreprocessBackend, default "dask" + Execution backend for preprocessing: "dask" (default), "iterative" (immediate, + synchronous, dask-free), "futures" (dask-free concurrent.futures thread pool), or a + PreprocessBackend instance. The ``scheduler`` argument only affects the dask backend. + metadata_extractor : Callable or None, default None + User function called once per file with the parquet metadata mapping from + ``awkward.metadata_from_parquet``; must return a JSON-serializable dict, stored as + that file's ``metadata`` on its file spec. Runs inside the per-file error handling + (participates in skip_bad_files/file_exceptions) and must be picklable. + metadata_reducer : Callable or None, default None + User function called once per dataset with ``{filename: extracted_dict}`` for the + available files; must return a dict, merged into the dataset's ``metadata`` (reducer + output takes precedence over existing keys) on both returned filesets. Returns ------- out_available : DataGroupSpec @@ -840,6 +1067,9 @@ def preprocess_parquet( filetype_options=parquet_options, step_size_safety_factor=step_size_safety_factor, allow_empty_datasets=allow_empty_datasets, + backend=backend, + metadata_extractor=metadata_extractor, + metadata_reducer=metadata_reducer, ) @@ -856,6 +1086,10 @@ def _preprocess_pydantic( filetype_options: dict = {}, step_size_safety_factor: float = 0.5, allow_empty_datasets: bool = False, + require_rntuple: bool = False, + backend: str | PreprocessBackend = "dask", + metadata_extractor: Callable | None = None, + metadata_reducer: Callable | None = None, ) -> tuple[DataGroupSpec, DataGroupSpec]: """ Internal function to preprocess either ROOT or parquet DatasetSpecs in a DataGroupSpec. @@ -890,6 +1124,11 @@ def _preprocess_pydantic( Warn if aligned steps exceed target by this factor. allow_empty_datasets : bool, default False If True, warn instead of raising when a dataset has no accessible files. + backend : str or PreprocessBackend, default "dask" + Execution backend for the per-dataset map-reduce. One of "dask" (default), + "iterative" (immediate, synchronous, dask-free), "futures" (a dask-free + concurrent.futures thread pool), or a PreprocessBackend instance for full control. + ``scheduler`` only affects the dask backend. Returns ------- @@ -909,15 +1148,18 @@ def _preprocess_pydantic( raise ValueError( f"_preprocess_pydantic expects a DataGroupSpec, got {type(datagroupspec)}" ) - - dask = _import_dask() - dask_awkward = _import_dask_awkward() + _validate_step_size(step_size) + if len(datagroupspec) == 0: + return DataGroupSpec({}), DataGroupSpec({}) out_updated = datagroupspec.model_dump() out_available = datagroupspec.model_dump() + # Build one map-reduce job per dataset. The map worker (get_steps / + # get_parquet_form_uuid_steps) and the concatenating reduce are backend-agnostic; only the + # execution strategy (dask graph vs. futures vs. synchronous) is selected via `backend`. all_ak_norm_files = {} - files_to_preprocess = {} + jobs = {} for name, info in datagroupspec.items(): norm_files = _normalize_pydantic_file_info(info) fields = ["file", "object_path", "steps", "num_entries", "uuid"] @@ -927,26 +1169,9 @@ def _preprocess_pydantic( ) all_ak_norm_files[name] = ak_norm_files - dak_norm_files = dask_awkward.from_awkward( - ak_norm_files, math.ceil(len(ak_norm_files) / files_per_batch) - ) - - concat_fn = partial( - awkward.concatenate, - axis=0, - ) - - split_every = 8 - - files_trl_label = f"preprocess-{name}" - files_trl_token = dask.base.tokenize(dak_norm_files, concat_fn, split_every) - files_trl_name = f"{files_trl_label}-{files_trl_token}" - files_trl_tree_node_name = f"{files_trl_label}-tree-node-{files_trl_token}" - if info.format == "root": - files_part = dask_awkward.map_partitions( + map_fn = partial( get_steps, - dak_norm_files, step_size=step_size, align_clusters=use_alignment_boundaries, recalculate_steps=recalculate_steps, @@ -956,12 +1181,17 @@ def _preprocess_pydantic( step_size_safety_factor=step_size_safety_factor, legacy_form_key=False, # in the pydantic preprocess function, the output form key is always "compressed_form", "form" is a method to extract the uncompressed form uproot_options=filetype_options, - meta=dask_awkward.lib.core.empty_typetracer(), + require_rntuple=require_rntuple, + metadata_extractor=metadata_extractor, ) elif info.format == "parquet": - files_part = dask_awkward.map_partitions( + if require_rntuple: + raise ValueError( + f"require_rntuple=True but dataset {name!r} is parquet-format; " + "only ROOT files can contain RNTuples." + ) + map_fn = partial( get_parquet_form_uuid_steps, - dak_norm_files, step_size=step_size, use_row_groups=use_alignment_boundaries, recalculate_steps=recalculate_steps, @@ -970,36 +1200,29 @@ def _preprocess_pydantic( save_form=save_form, step_size_safety_factor=step_size_safety_factor, parquet_options=filetype_options, - meta=dask_awkward.lib.core.empty_typetracer(), + metadata_extractor=metadata_extractor, ) else: raise ValueError( f"Dataset {name} has unsupported format {info.format}, supported formats are 'root' and 'parquet'." ) - files_trl = dask_awkward.layers.layers.AwkwardTreeReductionLayer( - name=files_trl_name, - name_input=files_part.name, - npartitions_input=files_part.npartitions, - concat_func=concat_fn, - tree_node_func=lambda x: x, - finalize_func=lambda x: x, - split_every=split_every, - tree_node_name=files_trl_tree_node_name, - ) - - files_graph = dask.highlevelgraph.HighLevelGraph.from_collections( - files_trl_name, files_trl, dependencies=[files_part] - ) - - files_to_preprocess[name] = dask_awkward.lib.core.new_array_object( - files_graph, - files_trl_name, - meta=dask_awkward.lib.core.empty_typetracer(), - npartitions=len(files_trl.output_partitions), + jobs[name] = PreprocessJob( + array=ak_norm_files, map_fn=map_fn, files_per_batch=files_per_batch ) - (all_processed_files,) = dask.compute(files_to_preprocess, scheduler=scheduler) + backend_obj = resolve_backend(backend, scheduler) + # Only submit() imports dask (for the dask backend); a ModuleNotFoundError here means the + # dask stack itself is missing, so we can point the user at the dask-free backends. Worker + # ImportErrors (e.g. a missing codec) surface later in result() and must NOT trigger the + # dask hint, so result() is called outside this guard. + try: + preprocess_task = backend_obj.submit(jobs) + except ModuleNotFoundError: + if isinstance(backend_obj, DaskBackend): + print_dask_backend_fallback_hint() + raise + all_processed_files = preprocess_task.result() for name, processed_files in all_processed_files.items(): @@ -1019,7 +1242,14 @@ def _preprocess_pydantic( continue processed_files_without_forms = processed_files[ - ["file", "object_path", "steps", "num_entries", "uuid"] + [ + "file", + "object_path", + "steps", + "num_entries", + "uuid", + "user_metadata_json", + ] ] compressed_forms = processed_files[ @@ -1031,17 +1261,18 @@ def _preprocess_pydantic( ) dataset_forms = [] + fields_by_hash = {} unique_forms = compressed_forms[unique_forms_idx] - for thefile, formstr, num_entries in zip( - unique_forms.file, unique_forms.compressed_form, unique_forms.num_entries - ): + for item in unique_forms.to_list(): + form = awkward.forms.from_json(decompress_form(item["compressed_form"])) + fields_by_hash[item["form_hash_md5"]] = set(form.fields) # skip trivially filled or empty files - form = awkward.forms.from_json(decompress_form(formstr)) - if num_entries >= 0 and set(form.fields) != _trivial_file_fields: + if set(form.fields) != _trivial_file_fields: dataset_forms.append(form) else: warnings.warn( - f"{thefile} has fields {form.fields} and num_entries={num_entries} " + f"{item['file']} has fields {form.fields} and " + f"num_entries={item['num_entries']} " "and has been skipped during form-union determination. You will need " "to skip this file when processing. You can either manually remove it " "or, if it is an empty file, dynamically remove it with the function " @@ -1049,72 +1280,87 @@ def _preprocess_pydantic( ", by default, removes empty files each dataset in a fileset." ) - union_array = None - union_form_jsonstr = None - while len(dataset_forms): - new_array = awkward.Array(dataset_forms.pop().length_zero_array()) - if union_array is None: - union_array = new_array - else: - union_array = awkward.to_packed( - awkward.merge_union_of_records( - awkward.concatenate([union_array, new_array]), axis=0 - ) - ) - union_array.layout.parameters.update(new_array.layout.parameters) - if union_array is not None: - union_form = union_array.layout.form - - for icontent, content in enumerate(union_form.contents): - if isinstance(content, awkward.forms.IndexedOptionForm): - if ( - not isinstance(content.content, awkward.forms.NumpyForm) - or content.content.primitive != "bool" - ): - raise ValueError( - "IndexedOptionArrays can only contain NumpyArrays of " - "bools in mergers of flat-tuple-like schemas!" - ) - parameters = ( - content.content.parameters.copy() - if content.content.parameters is not None - else {} - ) - # re-create IndexOptionForm with parameters of lower level array - union_form.contents[icontent] = awkward.forms.IndexedOptionForm( - content.index, - content.content, - parameters=parameters, - form_key=content.form_key, - ) + union_form_jsonstr = _union_form_jsonstr(dataset_forms) - union_form_jsonstr = union_form.to_json() + # Per-file experimental field bitsets: which top-level union-form fields each file + # carries, encoded against the union field order. These enable offline pruning of the + # union form when files are filtered out and per-file branch-set comparisons. + bitset_by_file = {} + if union_form_jsonstr is not None: + union_fields = awkward.forms.from_json(union_form_jsonstr).fields + for item in compressed_forms[["file", "form_hash_md5"]].to_list(): + file_fields = fields_by_hash.get(item["form_hash_md5"]) + if file_fields is not None: + bitset_by_file[item["file"]] = encode_field_bitset( + file_fields, union_fields + ) - files_available = { + # Index successfully-processed files by filename. Skipped/bad files were dropped as + # None by the worker and are simply absent here. + available_by_file = { item["file"]: { "object_path": item["object_path"], "steps": item["steps"], "num_entries": item["num_entries"], "uuid": item["uuid"], + "experimental_field_bitset": bitset_by_file.get(item["file"]), + "metadata": ( + json.loads(item["user_metadata_json"]) + if item["user_metadata_json"] is not None + else None + ), } for item in awkward.drop_none(processed_files_without_forms).to_list() } + # Assemble both outputs by filename in the original input order. Matching by key keeps + # correctness independent of the order in which the backend reduced/concatenated the + # per-file results -- each processed record carries its filename. + orig_items = all_ak_norm_files[name].to_list() + + files_available = { + item["file"]: available_by_file[item["file"]] + for item in orig_items + if item["file"] in available_by_file + } + files_out = {} - for proc_item, orig_item in zip( - processed_files_without_forms.to_list(), all_ak_norm_files[name].to_list() - ): - item = orig_item if proc_item is None else proc_item - files_out[item["file"]] = { - "object_path": item["object_path"], - "steps": item["steps"], - "num_entries": item["num_entries"], - "uuid": item["uuid"], - } + for orig_item in orig_items: + filename = orig_item["file"] + # processed info when available, else fall back to the original input info + files_out[filename] = available_by_file.get( + filename, + { + "object_path": orig_item["object_path"], + "steps": orig_item["steps"], + "num_entries": orig_item["num_entries"], + "uuid": orig_item["uuid"], + }, + ) out_updated[name]["files"] = files_out out_available[name]["files"] = files_available + # Reduce per-file extracted metadata into the dataset-level metadata (e.g. summing + # per-file sums-of-weights); the reducer output takes precedence over existing keys. + if metadata_reducer is not None: + per_file_meta = { + fname: info["metadata"] + for fname, info in files_available.items() + if info.get("metadata") is not None + } + if per_file_meta: + reduced = metadata_reducer(per_file_meta) + if not isinstance(reduced, dict): + raise ValueError( + f"metadata_reducer must return a dict, got {type(reduced).__name__} " + f"for dataset {name!r}." + ) + for out in (out_updated, out_available): + merged = dict(out[name].get("metadata") or {}) + merged.update(reduced) + out[name]["metadata"] = merged + compressed_union_form = ( compress_form(union_form_jsonstr) if union_form_jsonstr else None ) @@ -1141,8 +1387,16 @@ def _advertise_datagroupspec() -> None: def _datagroupspec_to_dict(datagroupspec: DataGroupSpec) -> dict: - """Convert a DataGroupSpec back to a plain (JSON-serializable) dict fileset.""" - return datagroupspec.model_dump() + """Convert a DataGroupSpec back to a plain (JSON-serializable) dict fileset. + + The legacy dict format does not carry per-file metadata or experimental fields + (dataset-level metadata is kept). + """ + return datagroupspec.model_dump( + exclude={ + "__all__": {"files": {"__all__": {"metadata", "experimental_field_bitset"}}} + } + ) def preprocess( @@ -1161,6 +1415,9 @@ def preprocess( preprocess_legacy_root: bool = False, use_row_groups: bool = False, parquet_options: dict = {}, + backend: str | PreprocessBackend = "dask", + metadata_extractor: Callable | None = None, + metadata_reducer: Callable | None = None, ) -> tuple[DataGroupSpec, DataGroupSpec] | tuple[dict, dict]: """ Given a list of normalized file and object paths (defined in uproot), determine the steps for each file according to the supplied processing options. @@ -1208,6 +1465,24 @@ def preprocess( Calculate steps according to the row_groups in the parquet files (only applies to DataGroupSpec datasets with parquet files). parquet_options : dict, default {} Options to pass to get_parquet_form_uuid_steps for opening parquet files (only applies to DataGroupSpec datasets with parquet files). + backend : str or PreprocessBackend, default "dask" + Execution backend for preprocessing: "dask" (default), "iterative" (immediate, + synchronous, dask-free), "futures" (dask-free concurrent.futures thread pool), or a + PreprocessBackend instance. The ``scheduler`` argument only affects the dask backend. + Ignored when ``preprocess_legacy_root=True`` (the legacy path is always dask-based). + metadata_extractor : Callable or None, default None + User function called once per file with the open file handle (uproot + ReadOnlyDirectory for ROOT files, ``awkward.metadata_from_parquet`` mapping for + parquet); must return a JSON-serializable dict, stored as that file's ``metadata`` + on its file spec (pydantic output only; the legacy dict output does not carry + per-file metadata). Runs inside the per-file error handling and must be picklable. + Not supported with ``preprocess_legacy_root=True``. + metadata_reducer : Callable or None, default None + User function called once per dataset with ``{filename: extracted_dict}`` for the + available files; must return a dict, merged into the dataset's ``metadata`` + (reducer output takes precedence over existing keys) on both returned filesets. + The reduced dataset-level metadata survives dict output as well. Not supported + with ``preprocess_legacy_root=True``. Returns ------- out_available : DataGroupSpec | dict @@ -1217,6 +1492,14 @@ def preprocess( """ input_is_dict = not isinstance(fileset, DataGroupSpec) + if preprocess_legacy_root and ( + metadata_extractor is not None or metadata_reducer is not None + ): + raise ValueError( + "metadata_extractor/metadata_reducer are not supported with " + "preprocess_legacy_root=True; use the pydantic preprocessing path." + ) + if preprocess_legacy_root: # use the legacy root TTree preprocessing function if requested; # the legacy path historically defaulted to save_form=False @@ -1269,6 +1552,10 @@ def preprocess( "Entries assigned via item assignment are not validated; rebuild or re-validate the " "DataGroupSpec (e.g. DataGroupSpec.model_validate(...)) so every entry is a DatasetSpec." ) + # Resolve the backend once so warnings (e.g. an ignored scheduler) are emitted a single + # time even for mixed root+parquet filesets, which dispatch to two sub-calls below. + backend = resolve_backend(backend, scheduler) + scheduler = None # split datasetspecs into uproot and parquet files, keeping track of original order original_order = list(datasetspecs.keys()) formats = [dss.format for dss in datasetspecs.values()] @@ -1291,6 +1578,9 @@ def preprocess( uproot_options=uproot_options, step_size_safety_factor=step_size_safety_factor, allow_empty_datasets=allow_empty_datasets, + backend=backend, + metadata_extractor=metadata_extractor, + metadata_reducer=metadata_reducer, ) out_available_parquet, out_updated_parquet = preprocess_parquet( datasetspecs.filter_datasets( @@ -1307,6 +1597,9 @@ def preprocess( parquet_options=parquet_options, step_size_safety_factor=step_size_safety_factor, allow_empty_datasets=allow_empty_datasets, + backend=backend, + metadata_extractor=metadata_extractor, + metadata_reducer=metadata_reducer, ) # recombine outputs in original order, skipping datasets removed due to allow_empty_datasets. # The sub-results are already-validated DatasetSpec instances, so use model_construct to diff --git a/src/coffea/dataset_tools/preprocess_backends.py b/src/coffea/dataset_tools/preprocess_backends.py new file mode 100644 index 000000000..45a09282a --- /dev/null +++ b/src/coffea/dataset_tools/preprocess_backends.py @@ -0,0 +1,452 @@ +"""Switchable execution backends for pydantic preprocessing. + +Preprocessing a :class:`~coffea.dataset_tools.filespec.DataGroupSpec` is, per dataset, an +ordered map-reduce: a worker (``get_steps`` / ``get_parquet_form_uuid_steps``) is mapped over +batches of normalized file records and the resulting awkward arrays are concatenated. This +module factors that map-reduce out from :func:`coffea.dataset_tools.preprocess._preprocess_pydantic` +behind a small backend interface so the same worker can run on: + +- :class:`DaskBackend` -- the default: ``dask_awkward.map_partitions`` + an + ``AwkwardTreeReductionLayer`` + ``dask.compute``. This backend builds and executes a dask + task graph to orchestrate the map-reduce. +- :class:`IterativeBackend` -- immediate (synchronous, single process) execution, mirroring + coffea's ``IterativeExecutor``. +- :class:`FuturesBackend` -- a :mod:`concurrent.futures` pool, threads by default (preprocessing + is IO-bound) with an opt-in process pool (mirrors ``FuturesExecutor``). + +The iterative and futures backends do not build a dask *task graph* for orchestration, and they +are dask-free for parquet input and for ROOT input of either flavor. ROOT form extraction +(``save_form=True``) uses uproot's own non-dask form builder for both TTree and RNTuple, producing +a form byte-identical to the ``uproot.dask`` form; it only falls back to ``uproot.dask`` (and thus +dask) if that internal uproot helper is unavailable on an unexpected uproot version. + +A ``Backend`` turns a "computable" into a future-like ``Task`` (``result`` / ``partial_result`` +/ ``wait``) whose ``result()`` blocks. Here the computable is a mapping +``{dataset_name: PreprocessJob}`` and the result is ``{dataset_name: awkward.Array}``. + +Ordering is not load-bearing for correctness: the reduction concatenates per-batch results, and +the concrete backends happen to preserve submission order, but the downstream post-processing +assembles its outputs *by filename* (see ``_preprocess_pydantic``), so a backend is free to +reduce in any order. +""" + +from __future__ import annotations + +import math +import warnings +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable, Mapping +from concurrent.futures import Executor, Future, ProcessPoolExecutor, ThreadPoolExecutor +from concurrent.futures import wait as futures_wait +from dataclasses import dataclass, replace +from functools import partial +from typing import Protocol, runtime_checkable + +import awkward + +from coffea.util import _import_dask, _import_dask_awkward, coffea_console + +__all__ = [ + "PreprocessJob", + "PreprocessTask", + "PreprocessBackend", + "IterativeBackend", + "FuturesBackend", + "DaskBackend", + "ordered_concat", + "resolve_backend", +] + + +def ordered_concat(parts: Iterable[awkward.Array | None]) -> awkward.Array | None: + """Concatenate mapped outputs, preserving input order. + + ``None`` parts are dropped. Returns ``None`` if nothing remains. This is the reduction step + for preprocessing. Order is preserved for tidiness/determinism, but is not required for + correctness: the downstream consumer assembles its outputs by filename, so the reduce may run + in any order. + """ + parts = [part for part in parts if part is not None] + if len(parts) == 0: + return None + if len(parts) == 1: + return parts[0] + return awkward.concatenate(parts, axis=0) + + +def _iter_batches(array: awkward.Array, files_per_batch: int): + """Yield contiguous slices of ``array`` of at most ``files_per_batch`` rows, in order. + + An empty array yields itself once so the worker still produces a correctly-typed empty + result (matching the single-partition dask behaviour). + """ + n = len(array) + if n == 0: + yield array + return + step = max(1, files_per_batch) + for start in range(0, n, step): + yield array[start : start + step] + + +@dataclass +class PreprocessJob: + """One dataset's preprocessing work: map ``map_fn`` over batches of ``array`` and concat. + + Parameters + ---------- + array : awkward.Array + Normalized file records (fields ``file``, ``object_path``, ``steps``, + ``num_entries``, ``uuid``) for a single dataset. + map_fn : Callable[[awkward.Array], awkward.Array] + The per-batch worker, e.g. ``functools.partial(get_steps, **options)``. Must be a + top-level (picklable) callable so it can run under a process pool. + files_per_batch : int, default 1 + Number of files handled per unit of work. Larger values mean fewer, heavier tasks. + """ + + array: awkward.Array + map_fn: Callable[[awkward.Array], awkward.Array] + files_per_batch: int = 1 + + +@runtime_checkable +class PreprocessTask(Protocol): + """Future-like handle to a submitted preprocessing computable.""" + + def result(self) -> dict[str, awkward.Array]: + """Block until done and return ``{dataset_name: concatenated awkward.Array}``.""" + + def partial_result(self) -> dict[str, awkward.Array]: + """Return results for the work finished so far, without blocking on the rest. + + Datasets/batches still running are omitted; a dataset appears only once at least one of + its batches has completed. Intended for resumable/progress use. Backends that cannot + produce a cheap partial (e.g. a fused dask graph) may return the full :meth:`result`. + """ + + def wait(self) -> None: + """Block until the computation has finished, without returning the result.""" + + +class PreprocessBackend(ABC): + """Base class for preprocessing execution backends.""" + + @abstractmethod + def submit(self, jobs: Mapping[str, PreprocessJob]) -> PreprocessTask: + """Start executing ``jobs`` and return a non-blocking :class:`PreprocessTask`.""" + + def compute(self, jobs: Mapping[str, PreprocessJob]) -> dict[str, awkward.Array]: + """Convenience blocking call: ``submit(jobs).result()``.""" + return self.submit(jobs).result() + + +@dataclass +class _CompletedTask: + """A :class:`PreprocessTask` whose work is already done (used by the iterative backend).""" + + _results: dict[str, awkward.Array] + + def result(self) -> dict[str, awkward.Array]: + return self._results + + def partial_result(self) -> dict[str, awkward.Array]: + # work already finished in submit(), so the partial result is the full result + return self._results + + def wait(self) -> None: + return None + + +@dataclass +class IterativeBackend(PreprocessBackend): + """Execute preprocessing immediately -- synchronously, in the current thread. + + Named to mirror coffea's ``IterativeExecutor``; the execution is "immediate" in that each + batch is mapped and reduced inline with no deferral or task graph. No dask required. + """ + + def submit(self, jobs: Mapping[str, PreprocessJob]) -> PreprocessTask: + results: dict[str, awkward.Array] = {} + for name, job in jobs.items(): + parts = [ + job.map_fn(batch) + for batch in _iter_batches(job.array, job.files_per_batch) + ] + results[name] = ordered_concat(parts) + return _CompletedTask(results) + + +class _FuturesTask: + """A :class:`PreprocessTask` backed by :mod:`concurrent.futures` futures. + + Futures are kept grouped and ordered per dataset so results reassemble in the original file + order regardless of completion order. A pool created by the backend is shut down once the + result has been gathered; an externally-supplied pool is left open. + """ + + def __init__( + self, + name_to_futures: dict[str, list[Future]], + pool: Executor, + owns_pool: bool, + ): + self._name_to_futures = name_to_futures + self._pool = pool + self._owns_pool = owns_pool + + def _all_futures(self) -> list[Future]: + return [fut for futs in self._name_to_futures.values() for fut in futs] + + def wait(self) -> None: + futures_wait(self._all_futures()) + + def result(self) -> dict[str, awkward.Array]: + try: + results: dict[str, awkward.Array] = {} + for name, futs in self._name_to_futures.items(): + # .result() re-raises worker exceptions here; ordering follows submission + parts = [fut.result() for fut in futs] + results[name] = ordered_concat(parts) + except BaseException: + # fail fast: cancel batches that have not started so a fatal error does not wait + # for the rest of the fileset to be processed + for fut in self._all_futures(): + fut.cancel() + if self._owns_pool: + self._pool.shutdown(wait=False, cancel_futures=True) + raise + if self._owns_pool: + self._pool.shutdown() + return results + + def partial_result(self) -> dict[str, awkward.Array]: + # Gather only the batches that have completed successfully, per dataset, without blocking. + # The owned pool is not shut down here; unfinished work may still be running. + results: dict[str, awkward.Array] = {} + for name, futs in self._name_to_futures.items(): + done_parts = [ + fut.result() + for fut in futs + if fut.done() and not fut.cancelled() and fut.exception() is None + ] + merged = ordered_concat(done_parts) + if merged is not None: + results[name] = merged + return results + + def __del__(self): + # Safety net: an owned pool is normally shut down in result(); if the caller only + # wait()s or drops the task without calling result(), avoid leaking the pool. + # Executor.shutdown is idempotent, so a double call after result() is harmless. + if getattr(self, "_owns_pool", False): + self._pool.shutdown(wait=False) + + +@dataclass +class FuturesBackend(PreprocessBackend): + """Execute preprocessing over a :mod:`concurrent.futures` pool. No dask required. + + Batches from every dataset are submitted to a single shared pool for good cross-dataset + load balancing, then reassembled per dataset in order. + + Parameters + ---------- + workers : int or None, default None + Number of workers when this backend creates the pool. ``None`` uses the executor's + default sizing (for :class:`~concurrent.futures.ThreadPoolExecutor`, + ``min(32, os.cpu_count() + 4)``; for + :class:`~concurrent.futures.ProcessPoolExecutor`, ``os.cpu_count()``). + use_processes : bool, default False + Create a :class:`~concurrent.futures.ProcessPoolExecutor` instead of the default + :class:`~concurrent.futures.ThreadPoolExecutor`. Threads are preferred because + preprocessing is IO-bound (opening files / reading metadata) and avoids pickling + the awkward arrays; use processes when form extraction is CPU-heavy. + pool : concurrent.futures.Executor or Callable, optional + An existing executor instance to reuse (left open by this backend), or a callable + (e.g. an ``Executor`` subclass) invoked as ``pool(max_workers=workers)``. Overrides + ``use_processes`` when given. + """ + + workers: int | None = None + use_processes: bool = False + pool: Executor | Callable[..., Executor] | None = None + + def _make_pool(self) -> tuple[Executor, bool]: + if isinstance(self.pool, Executor): + return self.pool, False + if self.pool is not None: + return self.pool(max_workers=self.workers), True + if self.use_processes: + return ProcessPoolExecutor(max_workers=self.workers), True + return ThreadPoolExecutor(max_workers=self.workers), True + + def submit(self, jobs: Mapping[str, PreprocessJob]) -> PreprocessTask: + pool, owns_pool = self._make_pool() + try: + name_to_futures: dict[str, list[Future]] = {} + for name, job in jobs.items(): + name_to_futures[name] = [ + pool.submit(job.map_fn, batch) + for batch in _iter_batches(job.array, job.files_per_batch) + ] + except BaseException: + if owns_pool: + pool.shutdown() + raise + return _FuturesTask(name_to_futures, pool, owns_pool) + + +class _DaskTask: + """A :class:`PreprocessTask` wrapping unmaterialized dask-awkward collections.""" + + def __init__(self, collections: dict, scheduler): + self._collections = collections + self._scheduler = scheduler + self._computed: dict[str, awkward.Array] | None = None + + def wait(self) -> None: + self.result() + + def result(self) -> dict[str, awkward.Array]: + if self._computed is None: + dask = _import_dask() + (self._computed,) = dask.compute( + self._collections, scheduler=self._scheduler + ) + return self._computed + + def partial_result(self) -> dict[str, awkward.Array]: + # A fused dask graph has no cheap partial: fall back to the full (blocking) result. + return self.result() + + +@dataclass +class DaskBackend(PreprocessBackend): + """Execute preprocessing as a dask-awkward task graph (the default). + + Per dataset this builds ``from_awkward`` -> ``map_partitions`` -> ``AwkwardTreeReductionLayer`` + and lets a single ``dask.compute`` materialize all datasets together. + + Parameters + ---------- + scheduler : None or Callable or str, default None + Passed through to ``dask.compute``. + split_every : int, default 8 + Fan-in of the tree reduction that concatenates per-batch results. + """ + + scheduler: None | Callable | str = None + split_every: int = 8 + + def _build_collection(self, name: str, job: PreprocessJob, dask, dask_awkward): + ak_norm_files = job.array + # guard files_per_batch<1 so the dask backend matches the iterative/futures batching + # (which clamp via max(1, files_per_batch)) instead of dividing by zero + files_per_batch = max(1, job.files_per_batch) + dak_norm_files = dask_awkward.from_awkward( + ak_norm_files, math.ceil(len(ak_norm_files) / files_per_batch) + ) + + concat_fn = partial(awkward.concatenate, axis=0) + split_every = self.split_every + + files_trl_label = f"preprocess-{name}" + files_trl_token = dask.base.tokenize(dak_norm_files, concat_fn, split_every) + files_trl_name = f"{files_trl_label}-{files_trl_token}" + files_trl_tree_node_name = f"{files_trl_label}-tree-node-{files_trl_token}" + + files_part = dask_awkward.map_partitions( + job.map_fn, + dak_norm_files, + meta=dask_awkward.lib.core.empty_typetracer(), + ) + + files_trl = dask_awkward.layers.layers.AwkwardTreeReductionLayer( + name=files_trl_name, + name_input=files_part.name, + npartitions_input=files_part.npartitions, + concat_func=concat_fn, + tree_node_func=lambda x: x, + finalize_func=lambda x: x, + split_every=split_every, + tree_node_name=files_trl_tree_node_name, + ) + + files_graph = dask.highlevelgraph.HighLevelGraph.from_collections( + files_trl_name, files_trl, dependencies=[files_part] + ) + + return dask_awkward.lib.core.new_array_object( + files_graph, + files_trl_name, + meta=dask_awkward.lib.core.empty_typetracer(), + npartitions=len(files_trl.output_partitions), + ) + + def submit(self, jobs: Mapping[str, PreprocessJob]) -> PreprocessTask: + dask = _import_dask() + dask_awkward = _import_dask_awkward() + collections = { + name: self._build_collection(name, job, dask, dask_awkward) + for name, job in jobs.items() + } + return _DaskTask(collections, self.scheduler) + + +def resolve_backend( + backend: str | PreprocessBackend | None, + scheduler: None | Callable | str = None, +) -> PreprocessBackend: + """Turn a ``backend`` selector into a :class:`PreprocessBackend` instance. + + ``backend`` may be an existing :class:`PreprocessBackend`, or one of the strings ``"dask"`` + (default), ``"iterative"``, or ``"futures"``. ``scheduler`` is forwarded to a + default-constructed :class:`DaskBackend`, and is injected into a passed :class:`DaskBackend` + instance whose own ``scheduler`` is unset (a copy is returned; the instance is not mutated). + If ``scheduler`` is set while a non-dask backend is selected, or the :class:`DaskBackend` + instance already carries its own scheduler, a warning is issued because the argument has no + effect there. + """ + if isinstance(backend, PreprocessBackend): + if scheduler is not None: + if isinstance(backend, DaskBackend) and backend.scheduler is None: + return replace(backend, scheduler=scheduler) + warnings.warn( + "The 'scheduler' argument is ignored when a PreprocessBackend instance " + "carries its own execution configuration; set it on the instance, e.g. " + "DaskBackend(scheduler=...).", + stacklevel=2, + ) + return backend + + if backend is None or backend == "dask": + return DaskBackend(scheduler=scheduler) + + if scheduler is not None: + warnings.warn( + "The 'scheduler' argument only affects the dask backend and is ignored here.", + stacklevel=2, + ) + if backend == "iterative": + return IterativeBackend() + if backend == "futures": + return FuturesBackend() + raise ValueError( + f"Unknown preprocessing backend {backend!r}; expected 'dask', 'iterative', " + "'futures', or a PreprocessBackend instance." + ) + + +def print_dask_backend_fallback_hint() -> None: + """Print (via ``coffea_console``) a hint that dask-free backends exist. + + Called when the default dask backend fails because dask / dask-awkward are not importable. + """ + coffea_console.print( + "[bold red]The dask preprocessing backend is unavailable because dask / dask-awkward " + "could not be imported.[/bold red]\n" + "Preprocessing can run without dask for parquet and ROOT input: pass " + "[bold]backend='iterative'[/bold] (single process) or [bold]backend='futures'[/bold] " + "(thread pool) to preprocess() and the format-specific preprocess_* functions." + ) diff --git a/tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400.parquet b/tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400.parquet new file mode 100644 index 000000000..e33c44d69 Binary files /dev/null and b/tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400.parquet differ diff --git a/tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400.root b/tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400.root new file mode 100644 index 000000000..add9bee5e Binary files /dev/null and b/tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400.root differ diff --git a/tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400.parquet b/tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400.parquet new file mode 100644 index 000000000..e80c61d65 Binary files /dev/null and b/tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400.parquet differ diff --git a/tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400.root b/tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400.root new file mode 100644 index 000000000..8d40d7a71 Binary files /dev/null and b/tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400.root differ diff --git a/tests/test_dataset_tools.py b/tests/test_dataset_tools.py index 77c785d05..924fb5686 100644 --- a/tests/test_dataset_tools.py +++ b/tests/test_dataset_tools.py @@ -1,11 +1,13 @@ import contextlib import copy import json +from functools import partial from pathlib import Path import awkward import pytest import uproot +from uproot._util import no_filter from uproot.exceptions import KeyInFileError from coffea.dataset_tools import ( @@ -17,16 +19,19 @@ max_chunks_per_file, max_files, preprocess, + preprocess_rntuple, + preprocess_root, slice_chunks, slice_files, split_fileset, ) from coffea.dataset_tools.filespec import ( DataGroupSpec, + PreprocessedFiles, ) from coffea.nanoevents import BaseSchema, NanoAODSchema from coffea.processor.test_items import NanoEventsProcessor, NanoTestProcessor -from coffea.util import decompress_form +from coffea.util import _is_interpretable, decompress_form dask_awkward = pytest.importorskip("dask_awkward") @@ -749,6 +754,58 @@ def test_preprocess_calculate_form(dask_client, preprocess_legacy_root): ) +def test_preprocess_rntuple(): + """RNTuple .root files are preprocessed via preprocess_rntuple, auto-detected by + preprocess_root, and preprocess_rntuple rejects TTree files. + + Uses the synchronous scheduler (RNTuple preprocessing does not require a distributed + client and this keeps the test fast and deterministic). + """ + rntuple_file = "tests/samples/nano_dy_rntuple.root" + rntuple_fileset = DataGroupSpec({"ZJets": {"files": {rntuple_file: "Events"}}}) + + available, _updated = preprocess_rntuple( + rntuple_fileset, step_size=15, save_form=True, scheduler="synchronous" + ) + + dataset = available["ZJets"] + assert isinstance(dataset.files, PreprocessedFiles) + spec = dataset.files[rntuple_file] + assert spec.num_entries == 40 + assert spec.steps == [[0, 14], [14, 28], [28, 40]] + + # the stored union form matches the form uproot produces under the same interpretable-branch + # filter that preprocessing applies (which, for RNTuples, drops _collection* subfields) + raw_form = uproot.dask( + {rntuple_file: "Events"}, + open_files=False, + ak_add_doc={"__doc__": "title", "typename": "typename"}, + filter_name=no_filter, + filter_typename=no_filter, + filter_branch=partial(_is_interpretable, emit_warning=False), + ).layout.form.to_json() + assert decompress_form(dataset.compressed_form) == raw_form + + # preprocess_root auto-detects the RNTuple and yields the same steps + available_auto, _ = preprocess_root( + DataGroupSpec({"ZJets": {"files": {rntuple_file: "Events"}}}), + step_size=15, + save_form=False, + scheduler="synchronous", + ) + auto_spec = available_auto["ZJets"].files[rntuple_file] + assert auto_spec.steps == [[0, 14], [14, 28], [28, 40]] + + # preprocess_rntuple must reject a TTree file + ttree_fileset = DataGroupSpec( + {"ZJets": {"files": {"tests/samples/nano_dy.root": "Events"}}} + ) + with pytest.raises(ValueError, match="require_rntuple"): + preprocess_rntuple( + ttree_fileset, step_size=15, save_form=False, scheduler="synchronous" + ) + + @pytest.mark.dask_client def test_preprocess_failed_file(dask_client): with dask_client.as_current() as _, pytest.raises(FileNotFoundError): @@ -950,6 +1007,22 @@ def test_filter_files(the_fileset): assert filtered_files == target +def test_filter_files_returns_preprocessed_files(): + """Filtering a preprocessed DataGroupSpec keeps each dataset's files as a + PreprocessedFiles (all surviving files are concrete), and empty files are removed. + """ + filtered = filter_files(DataGroupSpec(_updated_result)) + assert isinstance(filtered, DataGroupSpec) + for name, dataset in filtered.items(): + assert isinstance( + dataset.files, PreprocessedFiles + ), f"{name} files should be PreprocessedFiles, got {type(dataset.files).__name__}" + # the empty file present in _updated_result must have been filtered out + assert all( + spec.num_entries and spec.num_entries > 0 for spec in dataset.files.values() + ) + + @pytest.mark.parametrize( "the_fileset", [_updated_result, DataGroupSpec(_updated_result)] ) diff --git a/tests/test_dataset_tools_backends.py b/tests/test_dataset_tools_backends.py new file mode 100644 index 000000000..42a55dfc5 --- /dev/null +++ b/tests/test_dataset_tools_backends.py @@ -0,0 +1,464 @@ +"""Tests for the switchable preprocessing backends in coffea.dataset_tools.preprocess_backends.""" + +import shutil + +import awkward +import pytest + +from coffea.dataset_tools import ( + DaskBackend, + DataGroupSpec, + FuturesBackend, + IterativeBackend, + PreprocessBackend, + PreprocessJob, + preprocess, +) +from coffea.dataset_tools.preprocess_backends import ( + _iter_batches, + ordered_concat, + resolve_backend, +) + + +def _multi_file_fileset(tmp_path): + """A single dataset with two identical-form ROOT files (so form union is trivial).""" + src = "tests/samples/nano_dy.root" + copy = tmp_path / "nano_dy_copy.root" + shutil.copy(src, copy) + return DataGroupSpec( + { + "ZJets": {"files": {src: "Events", str(copy): "Events"}}, + "ZJets2": {"files": {src: "Events"}}, + } + ) + + +# -------------------------------------------------------------------------------------- +# ordered_concat / _iter_batches unit tests +# -------------------------------------------------------------------------------------- + + +def test_ordered_concat_preserves_order(): + a = awkward.Array([1, 2]) + b = awkward.Array([3, 4]) + c = awkward.Array([5]) + assert ordered_concat([a, b, c]).to_list() == [1, 2, 3, 4, 5] + + +def test_ordered_concat_drops_none_and_handles_empty(): + a = awkward.Array([1, 2]) + assert ordered_concat([None, a, None]).to_list() == [1, 2] + # single element is returned as-is + assert ordered_concat([a]) is a + # nothing to concatenate + assert ordered_concat([]) is None + assert ordered_concat([None, None]) is None + + +def test_iter_batches_slices_in_order(): + arr = awkward.Array([{"x": i} for i in range(5)]) + batches = list(_iter_batches(arr, files_per_batch=2)) + assert [b.x.to_list() for b in batches] == [[0, 1], [2, 3], [4]] + + +def test_iter_batches_empty_yields_once(): + arr = awkward.Array([{"x": 1}])[0:0] + batches = list(_iter_batches(arr, files_per_batch=1)) + assert len(batches) == 1 + assert len(batches[0]) == 0 + + +# -------------------------------------------------------------------------------------- +# resolve_backend +# -------------------------------------------------------------------------------------- + + +def test_resolve_backend_strings(): + assert isinstance(resolve_backend("dask"), DaskBackend) + assert isinstance(resolve_backend(None), DaskBackend) + assert isinstance(resolve_backend("iterative"), IterativeBackend) + assert isinstance(resolve_backend("futures"), FuturesBackend) + + +def test_resolve_backend_instance_passthrough(): + inst = IterativeBackend() + assert resolve_backend(inst) is inst + + +def test_resolve_backend_unknown_raises(): + with pytest.raises(ValueError, match="Unknown preprocessing backend"): + resolve_backend("nonsense") + + +def test_resolve_backend_scheduler_warns_for_non_dask_string(): + with pytest.warns(UserWarning, match="only affects the dask backend"): + resolve_backend("iterative", scheduler="synchronous") + + +def test_resolve_backend_scheduler_injected_into_dask_instance(recwarn): + """A DaskBackend instance with no scheduler of its own receives the scheduler argument + (as a copy; the original instance is not mutated), without warning.""" + inst = DaskBackend(split_every=4) + resolved = resolve_backend(inst, scheduler="synchronous") + assert resolved.scheduler == "synchronous" + assert resolved.split_every == 4 + assert inst.scheduler is None + assert not any("ignored" in str(w.message) for w in recwarn) + + +def test_resolve_backend_scheduler_warns_for_instance(): + # scheduler cannot be injected into a non-dask instance or a DaskBackend that already + # carries its own scheduler, so it warns instead of silently dropping the argument + with pytest.warns(UserWarning, match="ignored when a PreprocessBackend instance"): + resolve_backend(FuturesBackend(), scheduler="synchronous") + with pytest.warns(UserWarning, match="ignored when a PreprocessBackend instance"): + inst = DaskBackend(scheduler="threads") + assert resolve_backend(inst, scheduler="synchronous") is inst + + +def test_resolve_backend_scheduler_no_warn_for_dask_string(recwarn): + resolve_backend("dask", scheduler="synchronous") + assert not any("ignored" in str(w.message) for w in recwarn) + + +# -------------------------------------------------------------------------------------- +# Backend equivalence (dask-free backends do not import dask) +# -------------------------------------------------------------------------------------- + + +def test_iterative_and_threads_agree(tmp_path): + dgs = _multi_file_fileset(tmp_path) + a_iter, u_iter = preprocess(dgs, step_size=7, save_form=True, backend="iterative") + a_thr, u_thr = preprocess( + dgs, step_size=7, save_form=True, backend=FuturesBackend(workers=2) + ) + # DatasetSpec.__eq__ compares decoded forms (ignoring non-deterministic compressed bytes) + assert a_iter == a_thr + assert u_iter == u_thr + # order of files within a dataset is preserved by the ordered reduce + src = "tests/samples/nano_dy.root" + assert list(a_iter["ZJets"].files)[0] == src + + +def test_iterative_preserves_steps_and_form(tmp_path): + dgs = _multi_file_fileset(tmp_path) + available, _ = preprocess(dgs, step_size=7, save_form=True, backend="iterative") + ds = available["ZJets"] + assert len(ds.files) == 2 + for fs in ds.files.values(): + assert fs.num_entries == 40 + assert fs.uuid is not None + assert fs.steps[0][0] == 0 + assert ds.compressed_form is not None + + +def test_dask_matches_dask_free(tmp_path): + pytest.importorskip("dask") + pytest.importorskip("dask_awkward") + dgs = _multi_file_fileset(tmp_path) + a_dask, u_dask = preprocess( + dgs, step_size=7, save_form=True, backend="dask", scheduler="synchronous" + ) + a_iter, u_iter = preprocess(dgs, step_size=7, save_form=True, backend="iterative") + assert a_dask == a_iter + assert u_dask == u_iter + + +# -------------------------------------------------------------------------------------- +# dask-unavailable fallback hint +# -------------------------------------------------------------------------------------- + + +def test_dask_import_failure_prints_hint(tmp_path, monkeypatch): + """When the default dask backend can't import dask, a hint about the dask-free + backends is emitted before the ModuleNotFoundError propagates.""" + import importlib + + backends_mod = importlib.import_module("coffea.dataset_tools.preprocess_backends") + # the package re-exports the `preprocess` function, shadowing the submodule attribute, + # so fetch the module object explicitly rather than via attribute access + preprocess_mod = importlib.import_module("coffea.dataset_tools.preprocess") + + def _boom(): + raise ModuleNotFoundError("no dask here") + + monkeypatch.setattr(backends_mod, "_import_dask", _boom) + + called = {"hint": False} + orig_hint = preprocess_mod.print_dask_backend_fallback_hint + + def _hint(): + called["hint"] = True + orig_hint() + + monkeypatch.setattr(preprocess_mod, "print_dask_backend_fallback_hint", _hint) + + dgs = _multi_file_fileset(tmp_path) + with pytest.raises(ModuleNotFoundError): + preprocess(dgs, step_size=7, save_form=True, backend="dask") + assert called["hint"] is True + + +def test_worker_modulenotfound_does_not_trigger_dask_hint(tmp_path, monkeypatch): + """A ModuleNotFoundError raised by a worker during compute (surfaced in Task.result()) + must NOT print the 'dask could not be imported' hint -- only a failure to import dask in + submit() should.""" + import importlib + + preprocess_mod = importlib.import_module("coffea.dataset_tools.preprocess") + + class _RaisingTask: + def result(self): + raise ModuleNotFoundError("missing xrootd inside a worker") + + class _FakeDaskBackend(DaskBackend): + def submit(self, jobs): # submit succeeds; the error is deferred to result() + return _RaisingTask() + + called = {"hint": False} + monkeypatch.setattr( + preprocess_mod, + "print_dask_backend_fallback_hint", + lambda: called.__setitem__("hint", True), + ) + monkeypatch.setattr( + preprocess_mod, "resolve_backend", lambda b, s=None: _FakeDaskBackend() + ) + + dgs = _multi_file_fileset(tmp_path) + with pytest.raises(ModuleNotFoundError, match="missing xrootd"): + preprocess(dgs, step_size=7, save_form=True, backend="dask") + assert called["hint"] is False + + +def test_empty_parquet_file_does_not_crash(tmp_path): + """A 0-row parquet file must yield steps=[[0, 0]] instead of a ZeroDivisionError, + mirroring the ROOT get_steps num_entries==0 guard.""" + from coffea.dataset_tools import preprocess_parquet + + path = tmp_path / "empty.parquet" + awkward.to_parquet(awkward.Array([{"x": 1.0, "y": 2}])[0:0], str(path)) + dgs = DataGroupSpec({"E": {"files": {str(path): None}}}) + + # recalculate_steps=True forces the step-computation branch + _available, updated = preprocess_parquet( + dgs, recalculate_steps=True, save_form=True, backend="iterative" + ) + fs = next(iter(updated["E"].files.values())) + assert fs.num_entries == 0 + assert fs.steps == [[0, 0]] + + +def test_fallback_hint_mentions_backends(capsys): + from coffea.dataset_tools.preprocess_backends import ( + print_dask_backend_fallback_hint, + ) + + print_dask_backend_fallback_hint() + out = capsys.readouterr().out + assert "iterative" in out + assert "futures" in out + + +@pytest.mark.parametrize( + "sample, is_rntuple", + [ + ("tests/samples/nano_dy.root", False), + ("tests/samples/nano_dy_rntuple.root", True), + ], +) +def test_awkward_form_json_matches_uproot_dask(sample, is_rntuple): + """The dask-free form builder must stay byte-identical to uproot.dask's form for both TTree + and RNTuple, otherwise the iterative/futures backends would store forms incompatible with the + dask read path used at analysis time.""" + pytest.importorskip("dask") + pytest.importorskip("dask_awkward") + from functools import partial + + import uproot + from uproot._util import no_filter + + from coffea.dataset_tools.preprocess import _FORM_AK_ADD_DOC, _awkward_form_json + from coffea.util import _is_interpretable + + tree = uproot.open({sample: None})["Events"] + filt = partial(_is_interpretable, emit_warning=False) + if is_rntuple: + # RNTuples cannot build a form from an already-open object via uproot.dask; pass the spec + dask_form = uproot.dask( + {sample: "Events"}, + open_files=False, + full_paths=True, + ak_add_doc=_FORM_AK_ADD_DOC, + filter_name=no_filter, + filter_typename=no_filter, + filter_branch=filt, + ).layout.form.to_json() + else: + dask_form = uproot.dask( + tree, + ak_add_doc=_FORM_AK_ADD_DOC, + filter_name=no_filter, + filter_typename=no_filter, + filter_branch=filt, + ).layout.form.to_json() + assert _awkward_form_json(tree, is_rntuple) == dask_form + + +def test_step_size_zero_or_negative_raises(tmp_path): + """step_size < 1 is rejected with a clear ValueError at the entrypoint rather than a bare + ZeroDivisionError from deep inside a worker.""" + dgs = _multi_file_fileset(tmp_path) + for bad in (0, -5): + with pytest.raises(ValueError, match="step_size must be a positive integer"): + preprocess(dgs, step_size=bad, save_form=False, backend="iterative") + # None and >= 1 are accepted (smoke: no raise) + preprocess(dgs, step_size=1, save_form=False, backend="iterative") + + +def test_step_size_zero_raises_in_legacy_path(): + """The legacy path validates step_size too, before importing dask or opening files.""" + from coffea.dataset_tools import preprocess_legacy + + with pytest.raises(ValueError, match="step_size must be a positive integer"): + preprocess_legacy( + {"ds": {"files": {"nonexistent.root": "Events"}}}, step_size=0 + ) + + +def test_backends_are_preprocessbackend_instances(): + assert isinstance(DaskBackend(), PreprocessBackend) + assert isinstance(IterativeBackend(), PreprocessBackend) + assert isinstance(FuturesBackend(), PreprocessBackend) + + +def test_skipped_bad_file_assembled_by_filename(tmp_path): + """A skipped bad file is absent from `available` but retained in `updated` with its original + input info, assembled by filename in the original input order (no positional zip). + """ + good = "tests/samples/nano_dy.root" + bad = str(tmp_path / "does_not_exist.root") + dgs = DataGroupSpec({"ZJets": {"files": {bad: "Events", good: "Events"}}}) + available, updated = preprocess( + dgs, step_size=20, save_form=True, skip_bad_files=True, backend="iterative" + ) + # updated keeps both files, in the original input order; available drops the bad one + assert list(updated["ZJets"].files) == [bad, good] + assert bad not in available["ZJets"].files + assert good in available["ZJets"].files + + +def test_partial_result_equals_result_when_complete(): + """partial_result() agrees with result() once all work has finished, for the eager and + futures backends.""" + arr = awkward.Array([{"x": i} for i in range(4)]) + jobs = { + "d": PreprocessJob(array=arr, map_fn=lambda batch: batch, files_per_batch=1) + } + for backend in (IterativeBackend(), FuturesBackend(workers=2)): + task = backend.submit(jobs) + task.wait() + partial = task.partial_result() + result = task.result() + assert result["d"].to_list() == arr.to_list() + assert partial["d"].to_list() == result["d"].to_list() + + +def test_futures_default_workers_uses_executor_default(): + """FuturesBackend defaults workers to None, deferring pool sizing to the executor + (parallel by default), including via the string selector.""" + assert FuturesBackend().workers is None + assert resolve_backend("futures").workers is None + + +def test_futures_string_backend_matches_iterative(tmp_path): + dgs = _multi_file_fileset(tmp_path) + a_fut, u_fut = preprocess(dgs, step_size=7, save_form=True, backend="futures") + a_iter, u_iter = preprocess(dgs, step_size=7, save_form=True, backend="iterative") + assert a_fut == a_iter + assert u_fut == u_iter + + +def test_futures_result_failure_cancels_pending(): + """When a batch fails, result() re-raises after cancelling batches that have not started, + so a fatal error does not wait for the rest of the fileset to be processed.""" + import threading + from concurrent.futures import Future, ThreadPoolExecutor + + from coffea.dataset_tools.preprocess_backends import _FuturesTask + + release = threading.Event() + ran = threading.Event() + pool = ThreadPoolExecutor(max_workers=1) + try: + pool.submit(release.wait) # occupies the only worker + pending = pool.submit(ran.set) # queued behind the blocker + + failed = Future() + failed.set_exception(ValueError("boom")) + + task = _FuturesTask({"d": [failed, pending]}, pool, owns_pool=True) + with pytest.raises(ValueError, match="boom"): + task.result() + assert pending.cancelled() + assert not ran.is_set() + finally: + release.set() + + +def test_preprocess_rntuple_skips_ttree_file_when_requested(): + """With require_rntuple, a TTree object raises a ValueError that participates in + skip_bad_files/file_exceptions like any other per-file error.""" + from coffea.dataset_tools import preprocess_rntuple + + rnt = "tests/samples/nano_dy_rntuple.root" + ttree = "tests/samples/nano_dy.root" + dgs = DataGroupSpec({"D": {"files": {rnt: "Events", ttree: "Events"}}}) + + with pytest.raises(ValueError, match="not an RNTuple"): + preprocess_rntuple(dgs, save_form=False, backend="iterative") + + available, updated = preprocess_rntuple( + dgs, + save_form=False, + backend="iterative", + skip_bad_files=True, + file_exceptions=(OSError, ValueError), + ) + assert list(available["D"].files) == [rnt] + assert list(updated["D"].files) == [rnt, ttree] + + +def test_preprocess_rntuple_rejects_parquet(tmp_path): + """require_rntuple rejects parquet-format datasets with a clear error.""" + from coffea.dataset_tools import preprocess_rntuple + + path = tmp_path / "d.parquet" + awkward.to_parquet(awkward.Array([{"x": 1.0}]), str(path)) + dgs = DataGroupSpec({"P": {"files": {str(path): None}}}) + with pytest.raises(ValueError, match="parquet-format"): + preprocess_rntuple(dgs, save_form=False, backend="iterative") + + +def test_workers_validate_step_size(): + """get_steps and get_parquet_form_uuid_steps reject step_size < 1 directly, including + negative values (which would otherwise silently produce a single step per file).""" + from coffea.dataset_tools.preprocess import get_parquet_form_uuid_steps, get_steps + + normed = awkward.Array( + [ + { + "file": "tests/samples/nano_dy.root", + "object_path": "Events", + "steps": None, + "num_entries": None, + "uuid": None, + } + ] + ) + for bad in (0, -5): + with pytest.raises(ValueError, match="step_size must be a positive integer"): + get_steps(normed, step_size=bad) + with pytest.raises(ValueError, match="step_size must be a positive integer"): + get_parquet_form_uuid_steps(normed, step_size=bad) diff --git a/tests/test_dataset_tools_forms.py b/tests/test_dataset_tools_forms.py new file mode 100644 index 000000000..6ebe9b487 --- /dev/null +++ b/tests/test_dataset_tools_forms.py @@ -0,0 +1,454 @@ +"""Tests for union-form generation via DatasetSpec addition and the forms helpers.""" + +import awkward +import numpy +import pytest +import uproot + +from coffea.dataset_tools import DataGroupSpec, preprocess +from coffea.dataset_tools.forms import ( + decode_field_bitset, + encode_field_bitset, + prune_form_fields, + sort_form_fields, + union_form_jsonstr, +) + +_DY = "tests/samples/nano_dy.root" + +# Two CMS NanoAOD files carrying disjoint subsets of GenModel_TChiZH_* model-point flags +# (the GenModel case #1478 targets): file A has GenModel_TChiZH_700_1, file B does not. +_GENMODEL_A = "tests/samples/nano_genmodel_with20_700_1_with0_1100_200_with0_950_400" +_GENMODEL_B = "tests/samples/nano_genmodel_without_700_1_with0_1100_200_with20_950_400" + + +def _record_form(**fields): + return awkward.Array([fields]).layout.form + + +@pytest.fixture +def hlt_files(tmp_path): + """Two small TTree files sharing a flat branch but with disjoint HLT-style bool branches + (the per-file field variation the union form exists to cover).""" + file_a = str(tmp_path / "hlt_a.root") + file_b = str(tmp_path / "hlt_b.root") + with uproot.recreate(file_a) as f: + f["Events"] = { + "x": numpy.arange(10, dtype="f8"), + "HLT_a": numpy.ones(10, dtype=bool), + } + with uproot.recreate(file_b) as f: + f["Events"] = { + "x": numpy.arange(10, dtype="f8"), + "HLT_b": numpy.zeros(10, dtype=bool), + } + return file_a, file_b + + +def _preprocessed(files, name="D"): + dgs = DataGroupSpec({name: {"files": files}}) + available, _ = preprocess(dgs, save_form=True, backend="iterative") + return available[name] + + +# -------------------------------------------------------------------------------------- +# forms helpers +# -------------------------------------------------------------------------------------- + + +def test_union_form_jsonstr_first_seen_and_sorted(): + fa = _record_form(x=1.0, HLT_a=True) + fb = _record_form(x=1.0, HLT_b=False) + ab = union_form_jsonstr([fa, fb]) + ba = union_form_jsonstr([fb, fa]) + # merge order perturbs the byte-level field order but not form equality + assert ab != ba + assert awkward.forms.from_json(ab) == awkward.forms.from_json(ba) + # sort_fields canonicalizes the bytes independent of merge order + fa2 = _record_form(x=1.0, HLT_a=True) + fb2 = _record_form(x=1.0, HLT_b=False) + ab_sorted = union_form_jsonstr([fa2, fb2], sort_fields=True) + ba_sorted = union_form_jsonstr([fb2, fa2], sort_fields=True) + assert ab_sorted == ba_sorted + assert awkward.forms.from_json(ab_sorted).fields == sorted( + awkward.forms.from_json(ab).fields + ) + + +def test_union_form_jsonstr_empty_returns_none(): + assert union_form_jsonstr([]) is None + + +def test_sort_form_fields_recursive_and_equal(): + form = _record_form(b=1.0, a={"y": 1, "x": 2.0}) + sorted_form = sort_form_fields(form) + assert sorted_form.fields == ["a", "b"] + assert sorted_form.contents[0].fields == ["x", "y"] + assert sorted_form == form + + +def test_prune_form_fields_top_level(): + form = _record_form(x=1.0, y=2, z=True) + pruned = prune_form_fields(form, {"x", "z"}) + assert pruned.fields == ["x", "z"] + + +def test_field_bitset_roundtrip(): + union_fields = ["a", "b", "c", "d"] + present = {"a", "c"} + bitset = encode_field_bitset(present, union_fields) + assert decode_field_bitset(bitset, union_fields) == present + # fields outside the union are ignored on encode + assert encode_field_bitset({"a", "c", "zz"}, union_fields) == bitset + + +# -------------------------------------------------------------------------------------- +# DatasetSpec.__add__ / union_with +# -------------------------------------------------------------------------------------- + + +def test_add_unions_forms_matches_joint_preprocess(hlt_files): + """Adding two separately preprocessed DatasetSpecs yields the same union form as + preprocessing all files together, without re-opening any file.""" + file_a, file_b = hlt_files + ds_a = _preprocessed({file_a: "Events"}) + ds_b = _preprocessed({file_b: "Events"}) + joint = _preprocessed({file_a: "Events", file_b: "Events"}) + + combined = ds_a + ds_b + assert combined.compressed_form is not None + assert combined.form == joint.form + assert set(combined.form.fields) == {"x", "HLT_a", "HLT_b"} + # per-file bitsets decode to each file's own field set + union_fields = list(combined.form.fields) + assert decode_field_bitset( + combined.files[file_a].experimental_field_bitset, union_fields + ) == {"x", "HLT_a"} + assert decode_field_bitset( + combined.files[file_b].experimental_field_bitset, union_fields + ) == {"x", "HLT_b"} + + +def test_add_equal_forms_short_circuits(tmp_path): + import shutil + + copy = str(tmp_path / "nano_dy_copy.root") + shutil.copy(_DY, copy) + ds_a = _preprocessed({_DY: "Events"}) + ds_b = _preprocessed({copy: "Events"}) + combined = ds_a + ds_b + assert combined.form == ds_a.form + assert list(combined.form.fields) == list(ds_a.form.fields) + + +def test_add_one_sided_form_raises(hlt_files): + file_a, file_b = hlt_files + with_form = _preprocessed({file_a: "Events"}) + without_form = DataGroupSpec({"D": {"files": {file_b: "Events"}}})["D"] + with pytest.raises(ValueError, match="saved form"): + with_form + without_form + with pytest.raises(ValueError, match="saved form"): + without_form + with_form + + +def test_add_no_forms_stays_none(hlt_files): + file_a, file_b = hlt_files + ds_a = DataGroupSpec({"D": {"files": {file_a: "Events"}}})["D"] + ds_b = DataGroupSpec({"D": {"files": {file_b: "Events"}}})["D"] + assert (ds_a + ds_b).compressed_form is None + + +def test_union_with_sort_fields_is_order_independent(hlt_files): + from coffea.util import decompress_form + + file_a, file_b = hlt_files + ds_a = _preprocessed({file_a: "Events"}) + ds_b = _preprocessed({file_b: "Events"}) + ab = ds_a.union_with(ds_b, sort_fields=True) + ba = ds_b.union_with(ds_a, sort_fields=True) + assert decompress_form(ab.compressed_form) == decompress_form(ba.compressed_form) + assert list(ab.form.fields) == sorted(ab.form.fields) + + +def test_datagroupspec_add_unions_same_name_dataset(hlt_files): + file_a, file_b = hlt_files + group_a = DataGroupSpec({"D": _preprocessed({file_a: "Events"}).model_dump()}) + group_b = DataGroupSpec({"D": _preprocessed({file_b: "Events"}).model_dump()}) + combined = group_a + group_b + assert combined["D"].compressed_form is not None + assert set(combined["D"].form.fields) == {"x", "HLT_a", "HLT_b"} + + +# -------------------------------------------------------------------------------------- +# canonicalize_form / filter pruning +# -------------------------------------------------------------------------------------- + + +def test_canonicalize_form_sorts_and_remaps_bitsets(hlt_files): + file_a, file_b = hlt_files + ds = _preprocessed({file_a: "Events", file_b: "Events"}) + canonical = ds.canonicalize_form() + assert list(canonical.form.fields) == sorted(ds.form.fields) + assert canonical.form == ds.form + for fname in ds.files: + before = decode_field_bitset( + ds.files[fname].experimental_field_bitset, list(ds.form.fields) + ) + after = decode_field_bitset( + canonical.files[fname].experimental_field_bitset, + list(canonical.form.fields), + ) + assert before == after + + +def test_canonicalize_form_without_form_is_copy(): + ds = DataGroupSpec({"D": {"files": {_DY: "Events"}}})["D"] + assert ds.canonicalize_form() == ds + + +def test_filter_files_prunes_union_form(hlt_files): + file_a, file_b = hlt_files + ds = _preprocessed({file_a: "Events", file_b: "Events"}) + + filtered = ds.filter_files(filter_name=".*hlt_a.root") + assert list(filtered.files) == [file_a] + # the union form shrinks to exactly the fields the remaining file carries + assert set(filtered.form.fields) == {"x", "HLT_a"} + # the remaining file's bitset covers the whole pruned form + assert decode_field_bitset( + filtered.files[file_a].experimental_field_bitset, list(filtered.form.fields) + ) == set(filtered.form.fields) + + +def test_filter_files_without_bitsets_keeps_superset(hlt_files): + file_a, file_b = hlt_files + ds = _preprocessed({file_a: "Events", file_b: "Events"}) + spec = ds.model_dump() + for file_spec in spec["files"].values(): + file_spec["experimental_field_bitset"] = None + ds_nobits = type(ds)(**spec) + + filtered = ds_nobits.filter_files(filter_name=".*hlt_a.root") + assert filtered.form == ds.form + + +def test_limit_files_prunes_union_form(hlt_files): + file_a, file_b = hlt_files + ds = _preprocessed({file_a: "Events", file_b: "Events"}) + limited = ds.limit_files(1) + remaining = next(iter(limited.files)) + remaining_fields = decode_field_bitset( + limited.files[remaining].experimental_field_bitset, list(limited.form.fields) + ) + assert remaining_fields == set(limited.form.fields) + assert len(limited.form.fields) < len(ds.form.fields) + + +def test_preprocess_populates_bitsets(hlt_files): + file_a, file_b = hlt_files + ds = _preprocessed({file_a: "Events", file_b: "Events"}) + union_fields = list(ds.form.fields) + all_fields = set() + for fname, fs in ds.files.items(): + assert fs.experimental_field_bitset is not None + present = decode_field_bitset(fs.experimental_field_bitset, union_fields) + assert present <= set(union_fields) + assert len(present) > 0 + all_fields |= present + # the union of the per-file field sets is the full union form + assert all_fields == set(union_fields) + + +def test_bitset_roundtrips_through_json(hlt_files): + file_a, file_b = hlt_files + ds = _preprocessed({file_a: "Events", file_b: "Events"}) + from coffea.dataset_tools.filespec import DatasetSpec + + restored = DatasetSpec.model_validate_json(ds.model_dump_json()) + assert restored == ds + for fname in ds.files: + assert ( + restored.files[fname].experimental_field_bitset + == ds.files[fname].experimental_field_bitset + ) + + +# -------------------------------------------------------------------------------------- +# GenModel: real CMS NanoAOD files with disjoint model-point (GenModel) branch subsets +# -------------------------------------------------------------------------------------- + +_GM_700 = "GenModel_TChiZH_700_1" + + +@pytest.mark.parametrize( + "ext, object_path", + [(".root", "Events"), (".parquet", None)], +) +def test_genmodel_union_makes_absent_fields_optional(ext, object_path): + """Adding two DatasetSpecs whose files carry disjoint GenModel model-point flags unions + their forms: the result is a superset of both, and a flag present in only one file becomes + an option type so it stays readable (as None) for the file that lacks it -- the GenModel + behavior #1478 targets, for both ROOT and parquet inputs.""" + files_a = {_GENMODEL_A + ext: object_path} + files_b = {_GENMODEL_B + ext: object_path} + da, _ = preprocess( + DataGroupSpec({"genmodel": {"files": files_a}}), + save_form=True, + backend="iterative", + ) + db, _ = preprocess( + DataGroupSpec({"genmodel": {"files": files_b}}), + save_form=True, + backend="iterative", + ) + fields_a = set(da["genmodel"].form.fields) + fields_b = set(db["genmodel"].form.fields) + # the fixtures differ in their GenModel subset, and only file A has GenModel_TChiZH_700_1 + assert fields_a != fields_b + assert _GM_700 in fields_a and _GM_700 not in fields_b + + combined = da["genmodel"] + db["genmodel"] + union = combined.form + assert set(union.fields) == fields_a | fields_b + + # a flag present in only one file is an IndexedOptionArray(bool) in the union + only_in_a = sorted(fields_a - fields_b) + content = union.contents[union.fields.index(only_in_a[0])] + assert isinstance(content, awkward.forms.IndexedOptionForm) + assert isinstance(content.content, awkward.forms.NumpyForm) + assert content.content.primitive == "bool" + # GenModel_TChiZH_700_1 (present only in A) is optional in the union + assert isinstance( + union.contents[union.fields.index(_GM_700)], awkward.forms.IndexedOptionForm + ) + + +def test_genmodel_matches_joint_preprocess(): + """The union built by adding two separately-preprocessed GenModel DatasetSpecs equals the + form built by preprocessing both files together in one dataset.""" + da, _ = preprocess( + DataGroupSpec({"genmodel": {"files": {_GENMODEL_A + ".root": "Events"}}}), + save_form=True, + backend="iterative", + ) + db, _ = preprocess( + DataGroupSpec({"genmodel": {"files": {_GENMODEL_B + ".root": "Events"}}}), + save_form=True, + backend="iterative", + ) + joint, _ = preprocess( + DataGroupSpec( + { + "genmodel": { + "files": { + _GENMODEL_A + ".root": "Events", + _GENMODEL_B + ".root": "Events", + } + } + } + ), + save_form=True, + backend="iterative", + ) + assert (da["genmodel"] + db["genmodel"]).form == joint["genmodel"].form + + +def test_genmodel_bitsets_and_filter_prune(): + """Each GenModel file's experimental bitset decodes to its own branch set, and filtering the + combined dataset back to one file prunes the union form to that file's branches.""" + combined, _ = preprocess( + DataGroupSpec( + { + "genmodel": { + "files": { + _GENMODEL_A + ".root": "Events", + _GENMODEL_B + ".root": "Events", + } + } + } + ), + save_form=True, + backend="iterative", + ) + ds = combined["genmodel"] + union_fields = list(ds.form.fields) + present_a = decode_field_bitset( + ds.files[_GENMODEL_A + ".root"].experimental_field_bitset, union_fields + ) + assert _GM_700 in present_a + + only_a = ds.filter_files(filter_name=".*with20_700_1.*") + assert list(only_a.files) == [_GENMODEL_A + ".root"] + # the pruned form is exactly file A's fields, and still carries GenModel_TChiZH_700_1 + assert set(only_a.form.fields) == present_a + assert _GM_700 in only_a.form.fields + + +def _genmodel_events(files, form, mode="dask"): + from coffea.nanoevents import NanoAODSchema, NanoEventsFactory + + NanoAODSchema.warn_missing_crossrefs = False + return NanoEventsFactory.from_root( + files, schemaclass=NanoAODSchema, known_base_form=form, mode=mode + ).events() + + +def _genmodel_form(files): + out, _ = preprocess( + DataGroupSpec({"g": {"files": files}}), save_form=True, backend="iterative" + ) + return out["g"].form + + +def test_genmodel_union_form_preserves_masked_events(): + """Events passing a GenModel model-point mask survive the union form unchanged. File A's + GenModel_TChiZH_700_1 selection is the same read with A's own saved form or the dataset union + form, and for each model point the combined (A+B) count equals the per-file sum and the known + per-file truth (700_1: 20 from A only; 950_400: 20 from B only; 1100_200: 0). Uses the dask + read path, where the union form injects the option-typed flag physically absent from a file. + """ + pytest.importorskip("dask") + pytest.importorskip("dask_awkward") + + a_root = _GENMODEL_A + ".root" + b_root = _GENMODEL_B + ".root" + form_a = _genmodel_form({a_root: "Events"}) + union = _genmodel_form({a_root: "Events", b_root: "Events"}) + + def selected(files, form, point): + events = _genmodel_events(files, form) + mask = awkward.fill_none(events.GenModel[point], False) + return int(awkward.count_nonzero(mask).compute()) + + # the precise regression: applying the union form does not falsify A's True flags + assert selected({a_root: "Events"}, form_a, "TChiZH_700_1") == 20 + assert selected({a_root: "Events"}, union, "TChiZH_700_1") == 20 + + for point, truth in [ + ("TChiZH_700_1", 20), + ("TChiZH_1100_200", 0), + ("TChiZH_950_400", 20), + ]: + a = selected({a_root: "Events"}, union, point) + b = selected({b_root: "Events"}, union, point) + together = selected({a_root: "Events", b_root: "Events"}, union, point) + assert a + b == together == truth, point + + +def test_genmodel_union_flag_requires_fill_none_for_masking(): + """A model-point flag absent from a file is injected as None by the union form, so selecting + events with fill_none(flag, False) keeps only the True rows (20, all from file A), while the + raw option-type mask keeps the None rows too and over-selects every event (40).""" + pytest.importorskip("dask") + pytest.importorskip("dask_awkward") + + a_root = _GENMODEL_A + ".root" + b_root = _GENMODEL_B + ".root" + union = _genmodel_form({a_root: "Events", b_root: "Events"}) + events = _genmodel_events({a_root: "Events", b_root: "Events"}, union) + flag = events.GenModel.TChiZH_700_1 + + n_fill = int(awkward.num(events[awkward.fill_none(flag, False)], axis=0).compute()) + n_raw = int(awkward.num(events[flag], axis=0).compute()) + assert n_fill == 20 + assert n_raw == 40 diff --git a/tests/test_dataset_tools_metadata.py b/tests/test_dataset_tools_metadata.py new file mode 100644 index 000000000..776b67748 --- /dev/null +++ b/tests/test_dataset_tools_metadata.py @@ -0,0 +1,231 @@ +"""Tests for user-supplied metadata extraction during pydantic preprocessing.""" + +import awkward +import pytest + +from coffea.dataset_tools import DataGroupSpec, preprocess + +_DY = "tests/samples/nano_dy.root" + + +def _nbranches(file_handle): + """Extractor: number of branches/fields in the Events object of an open ROOT file.""" + return {"nbranches": len(file_handle["Events"].keys())} + + +def _sumw_style(file_handle): + """Extractor shaped like the ATLAS CutBookkeepers sum-of-weights pattern.""" + return {"sumw": float(file_handle["Events"].num_entries)} + + +def _sum_sumw(per_file): + """Reducer: dataset-level sum of the per-file sumw values.""" + return {"sumw_dataset": sum(meta["sumw"] for meta in per_file.values())} + + +def _parquet_rows(parquet_metadata): + return {"rows": int(parquet_metadata["num_rows"])} + + +def _returns_non_dict(file_handle): + return 42 + + +def _returns_unserializable(file_handle): + return {"handle": object()} + + +def _raises_oserror(file_handle): + raise OSError("broken metadata source") + + +@pytest.fixture +def two_files(tmp_path): + import shutil + + copy = str(tmp_path / "nano_dy_copy.root") + shutil.copy(_DY, copy) + return _DY, copy + + +def test_extractor_fills_file_metadata(two_files): + file_a, file_b = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events", file_b: "Events"}}}) + available, updated = preprocess( + dgs, save_form=False, backend="iterative", metadata_extractor=_nbranches + ) + for out in (available, updated): + for fs in out["D"].files.values(): + assert fs.metadata is not None + assert fs.metadata["nbranches"] > 0 + + +def test_reducer_fills_dataset_metadata(two_files): + file_a, file_b = two_files + dgs = DataGroupSpec( + {"D": {"files": {file_a: "Events", file_b: "Events"}, "metadata": {"xs": 1.5}}} + ) + available, updated = preprocess( + dgs, + save_form=False, + backend="iterative", + metadata_extractor=_sumw_style, + metadata_reducer=_sum_sumw, + ) + for out in (available, updated): + # both files have 40 entries; existing dataset metadata is preserved + assert out["D"].metadata["sumw_dataset"] == 80.0 + assert out["D"].metadata["xs"] == 1.5 + for fs in out["D"].files.values(): + assert fs.metadata == {"sumw": 40.0} + + +def test_extractor_parquet(tmp_path): + path = str(tmp_path / "d.parquet") + awkward.to_parquet(awkward.Array([{"x": 1.0}, {"x": 2.0}]), path) + dgs = DataGroupSpec({"P": {"files": {path: None}}}) + available, _ = preprocess( + dgs, save_form=False, backend="iterative", metadata_extractor=_parquet_rows + ) + assert available["P"].files[path].metadata == {"rows": 2} + + +def test_extractor_backend_equivalence(two_files): + file_a, file_b = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events", file_b: "Events"}}}) + kwargs = dict( + save_form=True, + metadata_extractor=_sumw_style, + metadata_reducer=_sum_sumw, + ) + a_iter, u_iter = preprocess(dgs, backend="iterative", **kwargs) + a_fut, u_fut = preprocess(dgs, backend="futures", **kwargs) + assert a_iter == a_fut + assert u_iter == u_fut + assert a_iter["D"].metadata["sumw_dataset"] == 80.0 + + +def test_extractor_dask_backend(two_files): + pytest.importorskip("dask") + pytest.importorskip("dask_awkward") + file_a, file_b = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events", file_b: "Events"}}}) + kwargs = dict( + save_form=True, + metadata_extractor=_sumw_style, + metadata_reducer=_sum_sumw, + ) + a_dask, u_dask = preprocess(dgs, backend="dask", scheduler="synchronous", **kwargs) + a_iter, u_iter = preprocess(dgs, backend="iterative", **kwargs) + assert a_dask == a_iter + assert u_dask == u_iter + + +def test_extractor_failure_participates_in_skip_bad_files(two_files, tmp_path): + file_a, file_b = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events", file_b: "Events"}}}) + with pytest.raises(OSError, match="broken metadata source"): + preprocess( + dgs, + save_form=False, + backend="iterative", + metadata_extractor=_raises_oserror, + ) + # a failing extractor is a per-file failure: skip_bad_files drops the files + available, updated = preprocess( + dgs, + save_form=False, + backend="iterative", + skip_bad_files=True, + allow_empty_datasets=True, + metadata_extractor=_raises_oserror, + ) + assert "D" not in available or len(available["D"].files) == 0 + + +def test_extractor_non_dict_raises(two_files): + file_a, _ = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events"}}}) + with pytest.raises(ValueError, match="must return a dict"): + preprocess( + dgs, + save_form=False, + backend="iterative", + metadata_extractor=_returns_non_dict, + ) + + +def test_extractor_unserializable_raises(two_files): + file_a, _ = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events"}}}) + with pytest.raises(ValueError, match="not JSON-serializable"): + preprocess( + dgs, + save_form=False, + backend="iterative", + metadata_extractor=_returns_unserializable, + ) + + +def test_extractor_rejected_on_legacy_path(two_files): + file_a, _ = two_files + with pytest.raises(ValueError, match="not supported"): + preprocess( + {"D": {"files": {file_a: "Events"}}}, + preprocess_legacy_root=True, + metadata_extractor=_nbranches, + ) + + +def test_file_metadata_survives_json_roundtrip(two_files): + file_a, file_b = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events", file_b: "Events"}}}) + available, _ = preprocess( + dgs, save_form=True, backend="iterative", metadata_extractor=_nbranches + ) + restored = DataGroupSpec.model_validate_json(available.model_dump_json()) + assert restored == available + for fname, fs in available["D"].files.items(): + assert restored["D"].files[fname].metadata == fs.metadata + + +def test_file_metadata_excluded_from_legacy_dict_output(two_files): + file_a, file_b = two_files + available, _ = preprocess( + {"D": {"files": {file_a: "Events", file_b: "Events"}}}, + save_form=False, + backend="iterative", + metadata_extractor=_sumw_style, + metadata_reducer=_sum_sumw, + ) + # dict-in/dict-out: per-file metadata is not part of the legacy format, but the + # reduced dataset-level metadata is + for file_info in available["D"]["files"].values(): + assert "metadata" not in file_info + assert available["D"]["metadata"]["sumw_dataset"] == 80.0 + + +def test_filespec_metadata_add_merges(two_files): + file_a, _ = two_files + dgs = DataGroupSpec({"D": {"files": {file_a: "Events"}}}) + available, _ = preprocess( + dgs, + save_form=False, + step_size=20, + backend="iterative", + metadata_extractor=_nbranches, + ) + fs = available["D"].files[file_a] + assert len(fs.steps) == 2 + merged = fs.limit_steps(slice(0, 1)) + fs.limit_steps(slice(1, 2)) + assert merged.metadata == fs.metadata + + +def test_extractor_with_rntuple(tmp_path): + """The extractor receives the open file for RNTuple inputs too.""" + rnt = "tests/samples/nano_dy_rntuple.root" + dgs = DataGroupSpec({"D": {"files": {rnt: "Events"}}}) + available, _ = preprocess( + dgs, save_form=False, backend="iterative", metadata_extractor=_sumw_style + ) + assert available["D"].files[rnt].metadata == {"sumw": 40.0} diff --git a/tests/test_dataset_tools_mutable_steps.py b/tests/test_dataset_tools_mutable_steps.py new file mode 100644 index 000000000..9dd207340 --- /dev/null +++ b/tests/test_dataset_tools_mutable_steps.py @@ -0,0 +1,218 @@ +"""Tests for the mutable (resizable) steps prototype.""" + +import shutil + +import pytest + +from coffea.dataset_tools import DataGroupSpec, preprocess +from coffea.dataset_tools.mutable_steps import ( + WallTimeStepPolicy, + completed_spec, + coverage_regions, + iter_dataset_steps, + iter_file_steps, + remaining_regions, + resizable_steps, + run_adaptive_steps, +) + +_DY = "tests/samples/nano_dy.root" + + +def _contiguous(steps, begin, end): + """Steps tile [begin, end) exactly, in order, with no gaps or overlaps.""" + assert steps[0][0] == begin + assert steps[-1][1] == end + for previous, current in zip(steps, steps[1:]): + assert previous[1] == current[0] + + +@pytest.fixture +def dataset(tmp_path): + copy = str(tmp_path / "nano_dy_copy.root") + shutil.copy(_DY, copy) + dgs = DataGroupSpec({"D": {"files": {_DY: "Events", copy: "Events"}}}) + available, _ = preprocess(dgs, save_form=False, backend="iterative") + return available["D"] + + +# -------------------------------------------------------------------------------------- +# resizable_steps +# -------------------------------------------------------------------------------------- + + +def test_resizable_steps_even_tiling(): + # 100 entries at target 30: 4 steps of 25 (as even as possible, at most the target) + steps = list(resizable_steps(0, 100, 30)) + assert steps == [[0, 25], [25, 50], [50, 75], [75, 100]] + + +def test_resizable_steps_shrink_retiles_remainder(): + gen = resizable_steps(0, 100, 30) + steps = [next(gen)] + # request at most 10 for the remaining 75 entries + step = gen.send(10) + while True: + steps.append(step) + assert step[1] - step[0] <= 10 + try: + step = next(gen) + except StopIteration: + break + _contiguous(steps, 0, 100) + + +def test_resizable_steps_grow_is_at_most_requested(): + gen = resizable_steps(0, 100, 25) + next(gen) + # remaining 75 at target 50: 2 steps of at most 38 = ceil(75/2) + step = gen.send(50) + assert step == [25, 63] + + +def test_resizable_steps_invalid_sizes_raise(): + with pytest.raises(ValueError, match="positive integer"): + next(resizable_steps(0, 10, 0)) + gen = resizable_steps(0, 10, 5) + next(gen) + with pytest.raises(ValueError, match="positive integer"): + gen.send(-1) + + +# -------------------------------------------------------------------------------------- +# coverage_regions / iter_file_steps +# -------------------------------------------------------------------------------------- + + +def test_coverage_regions_merges_adjacent(dataset): + fs = next(iter(dataset.files.values())) + spec = fs.model_dump() + spec["steps"] = [[0, 20], [20, 40]] + merged = type(fs)(**spec) + assert coverage_regions(merged) == [[0, 40]] + + spec["steps"] = [[0, 10], [20, 30]] + disjoint = type(fs)(**spec) + assert coverage_regions(disjoint) == [[0, 10], [20, 30]] + + +def test_coverage_regions_without_steps_uses_num_entries(dataset): + fs = next(iter(dataset.files.values())) + spec = fs.model_dump() + spec["steps"] = None + from coffea.dataset_tools.filespec import CoffeaROOTFileSpecOptional + + nosteps = CoffeaROOTFileSpecOptional(**spec) + assert coverage_regions(nosteps) == [[0, fs.num_entries]] + + spec["num_entries"] = None + unknown = CoffeaROOTFileSpecOptional(**spec) + with pytest.raises(ValueError, match="neither steps nor num_entries"): + coverage_regions(unknown) + + +def test_iter_file_steps_respects_disjoint_regions(dataset): + fs = next(iter(dataset.files.values())) + spec = fs.model_dump() + spec["steps"] = [[0, 10], [20, 30]] + disjoint = type(fs)(**spec) + steps = list(iter_file_steps(disjoint, 5)) + assert steps == [[0, 5], [5, 10], [20, 25], [25, 30]] + + +def test_iter_dataset_steps_resize_carries_across_files(dataset): + gen = iter_dataset_steps(dataset, 40) + fname_first, step = next(gen) + assert step == [0, 40] + # shrink after the first file's single step: the second file re-tiles at 10 + seen = [] + try: + item = gen.send(10) + while True: + seen.append(item) + item = next(gen) + except StopIteration: + pass + fnames = {fname for fname, _ in seen} + assert fnames == set(dataset.files) - {fname_first} or fnames == set(dataset.files) + for _, step in seen: + assert step[1] - step[0] <= 10 + second_file_steps = [step for fname, step in seen if fname != fname_first] + _contiguous(second_file_steps, 0, 40) + + +# -------------------------------------------------------------------------------------- +# resumption helpers +# -------------------------------------------------------------------------------------- + + +def test_remaining_regions_interval_subtraction(dataset): + fs = next(iter(dataset.files.values())) + # completed ranges need not align with stored steps + assert remaining_regions(fs, [[0, 25]]) == [[25, 40]] + assert remaining_regions(fs, [[10, 15], [15, 20]]) == [[0, 10], [20, 40]] + assert remaining_regions(fs, [[0, 40]]) == [] + assert remaining_regions(fs, []) == coverage_regions(fs) + + +def test_completed_spec_roundtrip_and_accumulation(dataset): + fs = next(iter(dataset.files.values())) + first = completed_spec(fs, [[0, 15]]) + second = completed_spec(fs, [[15, 40]]) + assert completed_spec(fs, []) is None + merged = first + second + assert merged.steps == [[0, 15], [15, 40]] + assert remaining_regions(fs, merged.steps) == [] + + +# -------------------------------------------------------------------------------------- +# toy adaptive driver +# -------------------------------------------------------------------------------------- + + +class _FakeClock: + """Deterministic clock advanced by the fake work function.""" + + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +def test_run_adaptive_steps_converges_to_target(dataset): + clock = _FakeClock() + per_entry_seconds = 0.01 + + def work(fname, step): + clock.now += (step[1] - step[0]) * per_entry_seconds + return (fname, tuple(step)) + + # equilibrium size: target 0.1 s at 0.01 s/entry -> 10 entries per step + policy = WallTimeStepPolicy(target_seconds=0.1) + run = run_adaptive_steps(dataset, work, step_size=40, policy=policy, clock=clock) + + # the first (full-file) step triggers a shrink; later steps sit at the equilibrium + assert run.step_sizes[0] == 40 + assert set(run.step_sizes[1:]) == {10} + # every file is fully covered exactly once + assert set(run.completed) == set(dataset.files) + for fname, steps in run.completed.items(): + _contiguous(steps, 0, dataset.files[fname].num_entries) + assert len(run.results) == len(run.step_sizes) + + +def test_run_adaptive_steps_growth_is_damped(dataset): + clock = _FakeClock() + + def instant_work(fname, step): + return None + + policy = WallTimeStepPolicy(target_seconds=1.0, max_step_size=64, max_growth=2.0) + run = run_adaptive_steps( + dataset, instant_work, step_size=4, policy=policy, clock=clock + ) + # growth per adjustment is bounded by max_growth and capped at max_step_size + for previous, current in zip(run.step_sizes, run.step_sizes[1:]): + assert current <= max(previous * 2, 1) or current <= 64 + assert max(run.step_sizes) <= 64