Repository: scikit-hep/awkward

Issue:
`ak.run_lengths` does not handle leading/trailing empty sublists
### Version of Awkward Array

main

### Description and code to reproduce

This also exists in v1.

The following code raises an `Exception`:
```python
import awkward as ak
import numpy as np


layout = ak.contents.ListOffsetArray(
    ak.index.Index(np.array([0, 2, 4, 4], dtype=np.int64)),
    ak.contents.NumpyArray(
        np.arange(4)
    )
)
ak.run_lengths(layout)
```

Also, we should use `regularize_numpyarray` to handle `NumpyArray`, which currently just raises an Exception.


Discussion:

Resolving patch:
diff --git a/src/awkward/operations/ak_run_lengths.py b/src/awkward/operations/ak_run_lengths.py
--- a/src/awkward/operations/ak_run_lengths.py
+++ b/src/awkward/operations/ak_run_lengths.py
@@ -105,8 +105,21 @@ def lengths_of(data, offsets):
 
             if isinstance(diffs, ak.highlevel.Array):
                 diffs = nplike.asarray(diffs)
+            # Do we have list boundaries to consider?
             if offsets is not None:
-                diffs[offsets[1:-1] - 1] = True
+                # When checking to see whether one element equals its following neighbour
+                # we also want to break runs at list boundaries. The comparison for offset `i`
+                # occurs at `i-1` in `diffs`. Consider this example with an empty sublist:
+                #                                data = [1 1 2 2 2 3 4 4 5  ]
+                #                             offsets = [0           6   9 9]
+                #                        (data) diffs = [  0 1 0 0 1 1 0 1  ]
+                #                        diffs index  = [  0 1 2 3 4 5 6 7  ]
+                #                                      boundary diff ^
+                # To consider only the interior boundaries, we ignore the start and end
+                # offset values. These can be repeated with empty sublists, so we mask them out.
+                is_interior = nplike.logical_and(0 < offsets, offsets < len(data) - 1)
+                interior_offsets = offsets[is_interior]
+                diffs[interior_offsets - 1] = True
             positions = nplike.index_nplike.nonzero(diffs)[0]
             full_positions = nplike.index_nplike.empty(len(positions) + 2, np.int64)
             full_positions[0] = 0
@@ -155,6 +168,8 @@ def action(layout, **kwargs):
                 layout.content.parameter("__array__") == "string"
                 or layout.content.parameter("__array__") == "bytestring"
             ):
+                # We also want to trim the _upper_ bound of content,
+                # so we manually convert the list type to zero-based
                 listoffsetarray = layout.toListOffsetArray64(False)
                 offsets = nplike.index_nplike.asarray(listoffsetarray.offsets)
                 content = listoffsetarray.content[offsets[0] : offsets[-1]]


Test patch:
diff --git a/tests/test_1794-run-lengths-empty-sublist.py b/tests/test_1794-run-lengths-empty-sublist.py
new file mode 100644
--- /dev/null
+++ b/tests/test_1794-run-lengths-empty-sublist.py
@@ -0,0 +1,30 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import numpy as np  # noqa: F401
+import pytest  # noqa: F401
+
+import awkward as ak  # noqa: F401
+
+
+def test_trailing_sublist():
+    layout = ak.contents.ListOffsetArray(
+        ak.index.Index(np.array([0, 6, 9, 9], dtype=np.int64)),
+        ak.contents.NumpyArray(np.array([1, 1, 2, 2, 2, 3, 4, 4, 5])),
+    )
+    assert ak.run_lengths(layout).tolist() == [[2, 3, 1], [2, 1], []]
+
+
+def test_leading_sublist():
+    layout = ak.contents.ListOffsetArray(
+        ak.index.Index(np.array([0, 0, 6, 9], dtype=np.int64)),
+        ak.contents.NumpyArray(np.array([1, 1, 2, 2, 2, 3, 4, 4, 5])),
+    )
+    assert ak.run_lengths(layout).tolist() == [[], [2, 3, 1], [2, 1]]
+
+
+def test_leading_trailing_sublist():
+    layout = ak.contents.ListOffsetArray(
+        ak.index.Index(np.array([0, 0, 6, 9, 9], dtype=np.int64)),
+        ak.contents.NumpyArray(np.array([1, 1, 2, 2, 2, 3, 4, 4, 5])),
+    )
+    assert ak.run_lengths(layout).tolist() == [[], [2, 3, 1], [2, 1], []]