Skip to content

fix: proper comparison in ak.almost_equal between record array fields#3962

Merged
ikrommyd merged 4 commits into
scikit-hep:mainfrom
ikrommyd:array-equal-records-fix
Apr 13, 2026
Merged

fix: proper comparison in ak.almost_equal between record array fields#3962
ikrommyd merged 4 commits into
scikit-hep:mainfrom
ikrommyd:array-equal-records-fix

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Apr 9, 2026

Copy link
Copy Markdown
Member

Yet another ak.almost_equal problem regarding comparing record arrays this time.
Comparing tuple vs non-tuple record arrays and the number of names of fields within them was not done right.

@ikrommyd

ikrommyd commented Apr 9, 2026

Copy link
Copy Markdown
Member Author

@TaiSakuma I'm surprised hypothesis-awkward did not find this in CI yet. I magically found it my running the property-based tests in parallel threads 🤣.

@ikrommyd ikrommyd changed the title fix: proper comparison in ak.almost_equal between record arrays fix: proper comparison in ak.almost_equal between record array fields Apr 9, 2026
@ikrommyd
ikrommyd requested review from TaiSakuma, ariostas and ianna April 9, 2026 09:32
@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown

The documentation preview is ready to be viewed at http://preview.awkward-array.org.s3-website.us-east-1.amazonaws.com/PR3962

@codecov

codecov Bot commented Apr 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.28%. Comparing base (f44402c) to head (062bcb8).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
Files with missing lines Coverage Δ
src/awkward/operations/ak_almost_equal.py 96.36% <ø> (-1.82%) ⬇️

... and 6 files with indirect coverage changes

@TaiSakuma

Copy link
Copy Markdown
Member

@ikrommyd The existing property-based tests on ak.array_equal() wouldn't find this.

To find this with hypothesis-awkward, we will need tests like:

@given(a=st_ak.constructors.arrays())
def test(a: ak.Array) -> None:
    b = change_field_name(a)
    assert not ak.array_equal(a, b, equal_nan=True)

Which tests are "the property-based tests in parallel threads"? Are they in the repo?

@ikrommyd

ikrommyd commented Apr 9, 2026

Copy link
Copy Markdown
Member Author

Yes we do run the whole CI with 4 parallel threads but the error hasn't been triggered in CI so far.

- name: Test non-kernels (Python) in multiple threads
if: matrix.python-version == '3.14t'
run: |
python -m pip install 'pytest-run-parallel>=0.8.2'
python -m pytest -vv -rs tests --parallel-threads 4 --ignore=tests/test_3364_virtualarray.py --ignore=tests/test_3451_virtualarray_with_jax.py --ignore=tests/test_3475_virtualarray_unknown_length.py

I randomly got it locally by doing pytest tests/properties --parallel-threads 4 (you need pytest-run-parallel installed)

@ikrommyd

ikrommyd commented Apr 9, 2026

Copy link
Copy Markdown
Member Author

For reference, this is what I got on main

❯ pytest tests/properties --parallel-threads 4
============================================================================================================================= test session starts =============================================================================================================================
platform darwin -- Python 3.14.4, pytest-9.0.2, pluggy-1.6.0
Matplotlib: 3.10.8
Freetype: 2.6.1
rootdir: /Users/iason/Dropbox/work/pyhep_dev/awkward
configfile: pyproject.toml
plugins: anyio-4.12.1, mock-3.15.1, mpl-0.18.0, doctestplus-1.6.0, xdist-3.8.0, run-parallel-0.8.2, timeout-2.4.0, hypothesis-6.151.12, asyncio-1.3.0, zarr-3.1.5, rerunfailures-16.1, cov-7.0.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 3 items
Collected 3 items to run in parallel

tests/properties/operations/test_array_equal.py ·e                                                                                                                                                                                                                      [ 66%]
tests/properties/operations/test_to_from_buffers.py ·                                                                                                                                                                                                                   [100%]

=================================================================================================================================== ERRORS ====================================================================================================================================
_______________________________________________________________________________________________________________________ ERROR at call of test_symmetry ________________________________________________________________________________________________________________________

    @settings(max_examples=200)
>   @given(

        a1=st_ak.constructors.arrays(),
        a2=st_ak.constructors.arrays(),
    )

f          = <function given.<locals>.run_test_as_given.<locals>.wrapped_test at 0x116adb7f0>

tests/properties/operations/test_array_equal.py:19:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/properties/operations/test_array_equal.py:25: in test_symmetry
    result_12 = ak.array_equal(a1, a2, equal_nan=True)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        a1         = <Array [] type='0 * {"": unknown}'>
        a2         = <Array [] type='0 * {A: unknown}'>
src/awkward/_dispatch.py:41: in dispatch
    with OperationErrorContext(name, args, kwargs):
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        args       = (<Array [] type='0 * {"": unknown}'>, <Array [] type='0 * {A: unknown}'>)
        array_like = <Array [] type='0 * {A: unknown}'>
        array_likes = (<Array [] type='0 * {"": unknown}'>, <Array [] type='0 * {A: unknown}'>)
        dispatch   = <function array_equal at 0x112d4c510>
        func       = <function array_equal at 0x112d4c460>
        gen_or_result = <generator object array_equal at 0x11733fb60>
        kwargs     = {'equal_nan': True}
        name       = 'ak.array_equal'
src/awkward/_errors.py:80: in __exit__
    raise self.decorate_exception(exception_type, exception_value)
        exception_type = <class 'awkward.errors.FieldNotFoundError'>
        exception_value = FieldNotFoundError("no field '' in record with 1 fields")
        self       = <awkward._errors.OperationErrorContext object at 0x1408ad310>
        traceback  = <traceback object at 0x142f31480>
src/awkward/_dispatch.py:67: in dispatch
    next(gen_or_result)
        args       = (<Array [] type='0 * {"": unknown}'>, <Array [] type='0 * {A: unknown}'>)
        array_like = <Array [] type='0 * {A: unknown}'>
        array_likes = (<Array [] type='0 * {"": unknown}'>, <Array [] type='0 * {A: unknown}'>)
        dispatch   = <function array_equal at 0x112d4c510>
        func       = <function array_equal at 0x112d4c460>
        gen_or_result = <generator object array_equal at 0x11733fb60>
        kwargs     = {'equal_nan': True}
        name       = 'ak.array_equal'
src/awkward/operations/ak_array_equal.py:46: in array_equal
    return ak.operations.ak_almost_equal._impl(
        a1         = <Array [] type='0 * {"": unknown}'>
        a2         = <Array [] type='0 * {A: unknown}'>
        check_named_axis = True
        check_parameters = True
        check_regular = True
        dtype_exact = True
        equal_nan  = True
        same_content_types = True
src/awkward/operations/ak_almost_equal.py:316: in _impl
    return visitor(left_layout, right_layout)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        atol       = 0.0
        backend    = <awkward._backends.numpy.NumpyBackend object at 0x111e0d160>
        check_named_axis = True
        check_parameters = True
        check_regular = True
        dtype_exact = True
        equal_nan  = True
        exact_eq   = True
        is_approx_dtype = <function _impl.<locals>.is_approx_dtype at 0x12482e4b0>
        layouts    = [<RecordArray is_tuple='false' len='0'>
    <content index='0' field=''>
        <EmptyArray len='0'/>
    </content>
...s_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>]
        left       = <Array [] type='0 * {"": unknown}'>
        left_behavior = None
        left_layout = <RecordArray is_tuple='false' len='0'>
    <content index='0' field=''>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
        packed_list_content = <function _impl.<locals>.packed_list_content at 0x12482db10>
        right      = <Array [] type='0 * {A: unknown}'>
        right_behavior = None
        right_layout = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
        rtol       = 0.0
        same_content_types = True
        visitor    = <function _impl.<locals>.visitor at 0x12482ecf0>
src/awkward/operations/ak_almost_equal.py:308: in visitor
    and all(visitor(left.content(f), right.content(f)) for f in left.fields)
        ^^^
        atol       = 0.0
        backend    = <awkward._backends.numpy.NumpyBackend object at 0x111e0d160>
        check_parameters = True
        check_regular = True
        dtype_exact = True
        equal_nan  = True
        exact_eq   = True
        is_approx_dtype = <function _impl.<locals>.is_approx_dtype at 0x12482e4b0>
        left       = <RecordArray is_tuple='false' len='0'>
    <content index='0' field=''>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
        left_behavior = None
        packed_list_content = <function _impl.<locals>.packed_list_content at 0x12482db10>
        right      = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
        right_behavior = None
        rtol       = 0.0
        same_content_types = True
        visitor    = <function _impl.<locals>.visitor at 0x12482ecf0>
src/awkward/operations/ak_almost_equal.py:308: in <genexpr>
    and all(visitor(left.content(f), right.content(f)) for f in left.fields)
                                     ^^^^^^^^^^^^^^^^
        .0         = <list_iterator object at 0x1273d84f0>
        f          = ''
        left       = <RecordArray is_tuple='false' len='0'>
    <content index='0' field=''>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
        right      = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
        visitor    = <function _impl.<locals>.visitor at 0x12482ecf0>
src/awkward/contents/recordarray.py:439: in content
    out = super().content(index_or_field)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        __class__  = <class 'awkward.contents.recordarray.RecordArray'>
        index_or_field = ''
        self       = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
src/awkward/_meta/recordmeta.py:140: in content
    index = self.field_to_index(index_or_field)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        index_or_field = ''
        self       = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>, field = ''

    def field_to_index(self, field: str) -> int:
        if self._fields is None:
            try:
                i = int(field)
            except ValueError:
                pass
            else:
                if 0 <= i < len(self._contents):
                    return i
        else:
            try:
                i = self._fields.index(field)
            except ValueError:
                pass
            else:
                return i
>       raise FieldNotFoundError(
            f"no field {field!r} in record with {len(self._contents)} fields"
        )
E       awkward.errors.FieldNotFoundError: no field '' in record with 1 fields
E
E       This error occurred while calling
E
E           ak.array_equal(
E               <Array [] type='0 * {"": unknown}'>
E               <Array [] type='0 * {A: unknown}'>
E               equal_nan = True
E           )
E       Falsifying example: test_symmetry(
E           a1=<Array [] type='0 * {"": unknown}'>,
E           a2=<Array [] type='0 * {A: unknown}'>,
E       )
E       Explanation:
E           These lines were always and only run by failing examples:
E               /Users/iason/Dropbox/work/pyhep_dev/awkward/src/awkward/_errors.py:33
E               /Users/iason/Dropbox/work/pyhep_dev/awkward/src/awkward/_errors.py:49
E               /Users/iason/Dropbox/work/pyhep_dev/awkward/src/awkward/_errors.py:73
E               /Users/iason/Dropbox/work/pyhep_dev/awkward/src/awkward/_errors.py:78
E               /Users/iason/Dropbox/work/pyhep_dev/awkward/src/awkward/_errors.py:87
E               (and 76 more with settings.verbosity >= verbose)

field      = ''
self       = <RecordArray is_tuple='false' len='0'>
    <content index='0' field='A'>
        <EmptyArray len='0'/>
    </content>
</RecordArray>

src/awkward/_meta/recordmeta.py:121: FieldNotFoundError
************************************************************************************************************************* pytest-run-parallel report **************************************************************************************************************************
All tests were run in parallel! 🎉
=========================================================================================================================== short test summary info ===========================================================================================================================
PARALLEL FAILED tests/properties/operations/test_array_equal.py::test_symmetry - awkward.errors.FieldNotFoundError: no field '' in record with 1 fields
==================================================================================================================== 2 passed, 1 error in 76.71s (0:01:16) ====================================================================================================================

@TaiSakuma

Copy link
Copy Markdown
Member

@ikrommyd I see. It is not the assertion that failed. ak.array_equal() raised an exception.

I think that max_examples is too small in test_array_equal.py to find this often.

@ikrommyd

ikrommyd commented Apr 9, 2026

Copy link
Copy Markdown
Member Author

Yeah but as we keep increasing the property based tests, if we also increase max examples, it will take too long.

@TaiSakuma

Copy link
Copy Markdown
Member

Yes. We can keep max_examples small for PRs and add a separate scheduled workflow (e.g., nightly) with a large max_examples.

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

Looks good to me. Thank you, @ikrommyd!

@ikrommyd
ikrommyd merged commit 80c3e39 into scikit-hep:main Apr 13, 2026
41 checks passed
@ikrommyd
ikrommyd deleted the array-equal-records-fix branch April 13, 2026 13:31

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

Is converting index to field intentional/desired? The tuple RecordArray does not have fields.

@ikrommyd

ikrommyd commented Apr 13, 2026

Copy link
Copy Markdown
Member Author

Is converting index to field intentional/desired? The tuple RecordArray does not have fields.

I don't undertand the question. The tuple record array does internally "have" fields that are just integers ("0", "1"....). The are not saved in ._fields but that's how awkward keeps track of which "field" is which. By numbering them. And you can actually see them with .fields. What conversion are you referring to? In order for two record arrays to be equal they need to have the same number of fields, tuple or not tuple.

@ikrommyd

Copy link
Copy Markdown
Member Author

These were the errors of the tests without this PR

FAILED test.py::test_tuple_different_arity - AssertionError: assert True is False
FAILED test.py::test_record_different_field_names - awkward.errors.FieldNotFoundError: no field 'x' in record with 1 fields

A) different number of fields would not be compared properly in the tuple case
B) different field names would raise an error and not return False.
This is all that was fixed here.

@ianna

ianna commented Apr 13, 2026

Copy link
Copy Markdown
Member

Is converting index to field intentional/desired? The tuple RecordArray does not have fields.

I don't undertand the question. The tuple record array does internally "have" fields that are just integers ("0", "1"....). The are not saved in ._fields but that's how awkward keeps track of which "field" is which. By numbering them. And you can actually see them with .fields. What conversion are you referring to?

<RecordArray is_tuple='true' len='2'>
    <content index='0'>
        <NumpyArray dtype='float64' len='2'>[1. 3.]</NumpyArray>
    </content>
    <content index='1'>
        <NumpyArray dtype='int64' len='2'>[2 4]</NumpyArray>
    </content>
</RecordArray>

vs

<RecordArray is_tuple='false' len='1'>
    <content index='0' field='x'>
        <NumpyArray dtype='int64' len='1'>[2]</NumpyArray>
    </content>
    <content index='1' field='y'>
        <NumpyArray dtype='int64' len='1'>[4]</NumpyArray>
    </content>
</RecordArray>

@ikrommyd

ikrommyd commented Apr 13, 2026

Copy link
Copy Markdown
Member Author

Yeah and what about those two. Tuple vs non-tuple the equality check should return False even if each column is the same because manually named fields have a meaning to the user while how awkward internally labels fields is an arbitrary choice of awkward. A manual "0", "1", "2" labeling means the user explicitly chose to name those fields like that. That fact that it randomly coincides with how awkward internally chooses to label things in the tuple case is not relevant.

In [4]: a1.layout
Out[4]:
<RecordArray is_tuple='true' len='2'>
    <content index='0'>
        <NumpyArray dtype='int64' len='2'>[1 2]</NumpyArray>
    </content>
</RecordArray>

In [5]: a2.layout
Out[5]:
<RecordArray is_tuple='false' len='2'>
    <content index='0' field='0'>
        <NumpyArray dtype='int64' len='2'>[1 2]</NumpyArray>
    </content>
</RecordArray>

In [6]: ak.array_equal(a1, a2)
Out[6]: False

@ianna

ianna commented Apr 13, 2026

Copy link
Copy Markdown
Member

Yeah and what about those two. Tuple vs non-tuple the equality check should return False.

Is converting index to field intentional/desired? The tuple RecordArray does not have fields.

@ikrommyd

ikrommyd commented Apr 13, 2026

Copy link
Copy Markdown
Member Author

Yeah and what about those two. Tuple vs non-tuple the equality check should return False.

Is converting index to field intentional/desired? The tuple RecordArray does not have fields.

I still don't understand, there is no conversion added here in this PR. That's just what awkward does since forever. Are we talking about policy here or a specific change?

TaiSakuma added a commit that referenced this pull request Jun 18, 2026
Hypothesis settings profiles replace the hardcoded per-test
max_examples: "default" (200) for PR CI and local runs, "nightly"
(10,000) selected via the HYPOTHESIS_PROFILE environment variable.

A new scheduled workflow runs tests/properties nightly at the large
profile, plus a second pass under pytest-run-parallel (4 threads),
which is how the bug behind #3962 was originally found. A README
badge surfaces nightly failures; the stale Build Test badge (dead
since the rename in #3969) now points at ci.yml.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

4 participants