Skip to content

feat: Add RNTuple row extension and field addition via update mode - #1687

Open
Yokubas wants to merge 75 commits into
scikit-hep:mainfrom
Yokubas:Yokubas/rntuple-update-pr
Open

feat: Add RNTuple row extension and field addition via update mode#1687
Yokubas wants to merge 75 commits into
scikit-hep:mainfrom
Yokubas:Yokubas/rntuple-update-pr

Conversation

@Yokubas

@Yokubas Yokubas commented Jul 21, 2026

Copy link
Copy Markdown

Summary

Implements in-place modification of existing RNTuples:

  • f["name"].extend({"x": array1, "y": array2}) — append new rows to existing RNTuple
  • f["name"].add_fields({"z": np.int32, "w": np.float32, ...}) — add one or more new fields back-filled with zeros
  • f["name"].add_fields({"particle.phi": np.float32, "particle.eta": np.float64, ...}) — add subfields to existing untyped structs
  • f["name"].extend({"x": array1, "z": array2}, accept_new_fields=True) — auto-add new fields and extend

How it works

  • Reads existing RNTuple metadata (anchor, header, footer, page lists)
  • Reconstructs a writable NTuple object from the existing file
  • For row extension: writes new pages and adds new cluster group to footer
  • For field addition: uses RNTuple's deferred column mechanism — new fields are added to the footer's extension records with first_element_index = num_entries, marking where new data starts. Old cluster groups are left completely untouched. The reader automatically zero-pads entries before first_element_index. Subsequent extend calls write new cluster groups that include the new column.
  • Only footer and new data are written — existing data never touched
  • Anchor updated in-place since it's always the same size

Tests

32 tests in tests/test_1687_rntuple_update.py covering:

  • Basic extend and add_fields
  • Multiple extends in separate sessions
  • Multiple fields added in a single call
  • Sequential add_fields calls holding the same object
  • add_fields + extend in same session with same object
  • Variable length arrays
  • Mixed types (scalar + jagged)
  • Empty ntuples
  • Multiple ntuples in same file
  • Subfields and deeply nested subfields with correct parent resolution
  • Validation: nonexistent parent, typed parent, collection parent, wrong field types
  • accept_new_fields kwarg behavior
  • ROOT verification reading actual column values via RNTupleReader

Blocking issues addressed (from review)

  • Silent data corruption on ROOT-written files → column record comparison raises clear ValueError with encoding mismatch instead of writing garbage data
  • Wrong element_offset for jagged fields → per-column element counts recovered from existing page lists instead of assuming all columns have num_entries elements
  • Multi-cluster groups in add_fields → now fully supported — writes one page per new field per cluster sized to that cluster's entry span
  • Stale in-memory state after add_fields → field records and column counts updated directly in memory; footer/page lists/akform reloaded from file
  • Subfield parent resolved by bare name → walks full dotted path using parent-id chain, correctly handles both uproot and ROOT parent-id conventions

Known limitations

  • ROOT-written RNTuples cannot be extended (split encoding not yet supported)
  • Only scalar numeric types supported in add_fields
  • Multi-cluster RNTuples not yet supported for add_fields
  • File-like objects not supported (requires file path for re-reading metadata)

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.71154% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.14%. Comparing base (5388b85) to head (1994214).

Files with missing lines Patch % Lines
src/uproot/writing/writable.py 94.68% 5 Missing and 6 partials ⚠️

❌ Your patch check has failed because the patch coverage (94.71%) is below the target coverage (98.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
Files with missing lines Coverage Δ
src/uproot/writing/_cascade.py 86.85% <100.00%> (ø)
src/uproot/writing/writable.py 83.29% <94.68%> (+3.45%) ⬆️

... and 2 files with indirect coverage changes

@ariostas ariostas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @Yokubas, this is fantastic progress! I left a few comments.

Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py
Comment thread tests/test_1687_rntuple_update.py
Comment thread tests/test_rntuple_update.py Outdated
Comment thread tests/test_1687_rntuple_update.py
Comment thread src/uproot/writing/writable.py Outdated
Comment on lines +2505 to +2510
if len(ple.pagelinklist) > 1:
raise ValueError(
f"add_fields does not yet support RNTuples with multiple clusters per cluster group "
f"(cluster group {cg_idx} has {len(ple.pagelinklist)} clusters). "
f"This is a known limitation that will be fixed in a future version."
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should implement this since I think it should be pretty straightforward. But let me know if it's not so easy and we can leave it for later.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented multi-cluster support. Instead of raising an error, add_fields now loops over all clusters in each cluster group and writes one zero-filled page per new field per cluster, sized to that cluster's entry span. Tested with 3 cluster groups and it works correctly. Note that multiple clusters per cluster group (ROOT behavior) can't be easily tested since ROOT-written files have split encoding which we reject — but the loop handles that case correctly too.

key = self._file.root_directory._cascading.data.get_key(
self._path[-1], 1
)
reloaded = self._file.root_directory._load_existing_ntuple(key)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's better for add_fields to update the necessary things instead of reloading the entire ntuple

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduced the file reload — _existing_field_records and _column_counts are now updated directly in memory. Still need to read _existing_footer, _existing_page_list_envelopes, and _header._akform from file because the writable footer uses cluster_group_record_frames while the next add_fields call needs cluster_group_records from the read-only footer. Could avoid this by adding a cluster_group_records property to the writable footer that mirrors cluster_group_record_frames — would that be the preferred approach?

Comment thread src/uproot/writing/writable.py Outdated
)
footer.extension_column_record_frames.append(new_col)

new_data = numpy.zeros(num_entries, dtype=numpy.dtype(ak_primitive))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I missed this part on my first review. We actually, don't want to explicitly backfill the new fields with zeros. This is taken care by attaching a deferred column to the new field. So the existing cluster groups stay as they were, with no new columns for the new fields, and new cluster groups will start to contain the new column. So you'll have to modify NTuple_Column_Description so that it can also take a first-element index (see https://github.com/root-project/root/blob/master/tree/ntuple/doc/BinaryFormatSpecification.md#column-description).

So now it makes sense why there were some issues with cluster groups with multiple clusters. This way, since you're leaving old cluster groups intact and Uproot (currently) only writes cluster groups with a single cluster then you don't have to worry about multiple clusters in a cluster group.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the deferred column approach — NTuple_Column_Description now accepts first_element_index, and when it's > 0 the DEFERRED flag is set and the value is serialized. add_fields no longer backfills zeros — old cluster groups stay completely untouched, and the reader pads zeros automatically via the deferred column mechanism. Also fixed the reload path to preserve first_element_index when copying extension column records. 32 tests still pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants