Skip to content

fix(rntuple): preserve optional records when writing and reading (#1713) - #1779

Open
sumitsaraswat362 wants to merge 1 commit into
scikit-hep:mainfrom
sumitsaraswat362:fix-1713-rntuple-optional-records
Open

sumitsaraswat362 wants to merge 1 commit into
scikit-hep:mainfrom
sumitsaraswat362:fix-1713-rntuple-optional-records

Conversation

@sumitsaraswat362

Copy link
Copy Markdown
Contributor

Resolves #1713.

Description

When writing an optional record such as ?{a: float64}, _cascadentuple._cpp_typename generated std::optional<UntypedRecord>. The subsequent check if not subcall and "UntypedRecord" in typename: typename = "" erased the entire string to "", stripping the std::optional marker from the collection field description on disk. As a consequence, reading the RNTuple fell back to ListOffsetForm, silently converting None into empty lists [].

This PR narrows the blank out in _cascadentuple._cpp_typename:

  • When typename.startswith("std::optional"), "UntypedRecord" is removed from the template argument so std::optional<> survives on the collection field record.
  • Untyped records (RecordForm) with empty type names remain unaffected and retain empty type_name for add_fields compatibility.
  • On read, self._all_fields[cfid].record.type_name.startswith("std::optional") successfully matches, reconstructing ak.forms.IndexedOptionForm(idx_type, inner) where inner is the child RecordForm.

Tests

Added tests/test_1713_rntuple_optional_records.py covering:

  • test_optional_record_round_trip: Full round trip, single field reading (nt["o"]), dotted subfield access (nt["o.a"]), and filtering.
  • test_optional_record_with_multiple_fields: Multiple fields under an optional record.
  • test_optional_record_extend_and_dask: extend() after update() and uproot.dask computation.

@github-actions github-actions Bot added the type/fix PR title type: fix (set automatically) label Oct 26, 2026
@sumitsaraswat362
sumitsaraswat362 marked this pull request as draft October 26, 2026 04:45
@sumitsaraswat362
sumitsaraswat362 force-pushed the fix-1713-rntuple-optional-records branch from d0a3d20 to 4bb2ce2 Compare October 28, 2026 07:07
@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.33%. Comparing base (d358adf) to head (89a2b7c).

Additional details and impacted files
Files with missing lines Coverage Δ
src/uproot/models/RNTuple.py 91.52% <100.00%> (ø)
src/uproot/writing/_cascadentuple.py 90.79% <100.00%> (+0.12%) ⬆️

... and 3 files with indirect coverage changes

@sumitsaraswat362
sumitsaraswat362 marked this pull request as ready for review October 28, 2026 07:14
@sumitsaraswat362
sumitsaraswat362 marked this pull request as draft October 28, 2026 13:33
@sumitsaraswat362
sumitsaraswat362 marked this pull request as ready for review October 29, 2026 15:27

@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.

🤖 AI text below 🤖

Thanks for working on this! The uproot round trip works, but ROOT can't open the files this writes; details inline.

Comment thread src/uproot/writing/_cascadentuple.py Outdated
raise AssertionError(msg)
if not subcall and "UntypedRecord" in typename:
typename = "" # empty types for anything that contains UntypedRecord
if typename.startswith("std::optional"):

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.

🤖 AI text below 🤖

With this change, files with an optional record can no longer be opened by ROOT. Writing ak.Array([{"o": {"a": 1.0}}, {"o": None}]) (the case in the new test) records the field types as ['std::optional<>', '', 'double'], and ROOT 6.40.02 fails on it:

RException: no type name specified for field '_0'

The same data written from main (types ['', '', 'double']) opens in ROOT as a collection.

Comment thread src/uproot/writing/_cascadentuple.py Outdated
if not subcall and "UntypedRecord" in typename:
typename = "" # empty types for anything that contains UntypedRecord
if typename.startswith("std::optional"):
typename = typename.replace("UntypedRecord", "")

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.

🤖 AI text below 🤖

replace("UntypedRecord", "") removes the marker at every nesting level, not only where the optional wraps the record, so nested types get malformed C++ names written to disk:

  • ak.Array([{"o": (1.0, {"x": 2})}, {"o": None}]) -> std::optional<std::tuple<double,>>
  • ak.Array([{"o": [{"a": 1.0}]}, {"o": None}]) -> std::optional<std::vector<>>

uproot reads these back only because the reader checks startswith("std::optional"), but ROOT rejects them and users would see them in RField.typename.

Comment thread src/uproot/writing/_cascadentuple.py Outdated
@@ -114,7 +114,10 @@ def _cpp_typename(akform, subcall=False):
msg = "This should not have happened. Please report this issue to the Uproot developers."
raise AssertionError(msg)
if not subcall and "UntypedRecord" in typename:

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.

🤖 AI text below 🤖

Neither option here gives a file that both ROOT and uproot read correctly: an empty type name makes uproot read None back as [] (#1713), while std::optional<...> around an untyped record makes the file unreadable by ROOT. I think this needs to be decided in the IndexedOptionForm branch rather than by post-processing the string, e.g. keep the ROOT-compatible type name and record the optional-ness in a way the reader can detect, or reject/warn on optional untyped records.

More generally, the "UntypedRecord" placeholder plus substring checks means every wrapper (std::array, std::variant, tuples, ...) needs its own patch-up. Having subcalls report "untyped" explicitly (e.g. returning None or a flag) and handling it in each container branch would avoid the global string replacement.

import uproot


def test_optional_record_round_trip(tmp_path):

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.

🤖 AI text below 🤖

These tests only round-trip through uproot, so they pass even though ROOT can't open the file. Could you add a ROOT read-back check guarded by pytest.importorskip("ROOT"), like tests/test_0705_rntuple_writing_metadata.py does? It would have caught the issue above.



def test_optional_record_extend_and_dask(tmp_path):
pytest.importorskip("dask_awkward")

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.

🤖 AI text below 🤖

Because importorskip("dask_awkward") is at the top, the update()/extend() round trip is skipped entirely when dask_awkward isn't installed. Could the extend check go in its own test, with the dask part separate?

It would also be good to cover the nested cases (optional tuple, optional list of records) and assert the on-disk type names.

@sumitsaraswat362
sumitsaraswat362 force-pushed the fix-1713-rntuple-optional-records branch from 4bb2ce2 to 89a2b7c Compare October 30, 2026 01:29
@sumitsaraswat362

Copy link
Copy Markdown
Contributor Author

Thanks for catching the ROOT interoperability issue and pointing out the malformed template names in nested containers!

I have refactored this to address all the points:

  1. Replaced the UntypedRecord string placeholder with explicit propagation:
    _cpp_typename now reports untyped inner components directly by returning None on subcalls. Container branches (std::vector, std::tuple, std::array, std::optional, std::variant) check for None from subcalls and propagate None upwards rather than trying to construct invalid template strings like std::optional<>, std::vector<>, or std::tuple<double,>. At the top level, any untyped field receives "".

  2. Recorded optional status out-of-band for untyped fields:
    When writing an IndexedOptionForm with untyped content, type_name remains "" for ROOT compatibility, while type_alias is set to "std::optional". The reader checks record.type_name.startswith("std::optional") or record.type_alias == "std::optional", so uproot cleanly distinguishes optional records from lists of records while ROOT can open the file as an untyped collection without throwing RException: no type name specified for field '_0'.

  3. Split and expanded test coverage:

    • Separated test_optional_record_extend from test_optional_record_dask so update/extend tests execute without requiring dask_awkward.
    • Added test_optional_record_nested_tuple and test_optional_record_nested_list to test nested cases and explicitly verify on-disk typename and type_alias.
    • Added test_optional_record_ROOT guarded by pytest.importorskip("ROOT") and ROOT version checks.

This branch has not been deployed

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

Labels

type/fix PR title type: fix (set automatically)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RNTuple: optional records lose their optionality, turning None into []

2 participants