Repository: scikit-hep/awkward

Issue:
String broadcasting is not producing sensible results
### Version of Awkward Array

main

### Description and code to reproduce

As Jim and I discussed today, string broadcasting is currently not producing reliable results for string-string, or even string-non string cases. In particular many broadcasts violate the appearance that strings are atoms:

```python3
>>> import awkward as ak
>>> x = ak._v2.Array([["one", "two"], ["three", "four"]])
>>> ak._v2.broadcast_arrays(x[1:], x[:-1])
ValueError: while calling (from <ipython-input-42-6e4db831c1ff>, line 1)
    ak._v2.broadcast_arrays(
        arrays = (<Array [['one', 'two']] type='1 * var * string'>, <Array [[...
        kwargs = {}
    )
Error details: cannot broadcast nested list (in compiled code: https://github.com/scikit-hep/awkward-1.0/blob/1.10.0rc1/src/cpu-kernels/awkward_ListArray_broadcast_tooffsets.cpp#L27)
```

In this case, broadcasting an Array of strings against an Array of integers produces arrays that have the same structure as though the outer `__array__ = "string"` were missing (i.e. broadcasting happens against the underlying characters array):
```python3
>>> import awkward as ak
>>> x = ak._v2.Array([["one", "two"], ["three", "four"]])
>>> y = ak._v2.Array([[1,2],[3, 4]])
>>> ak._v2.broadcast_arrays(x, y)
[<Array [['one', 'two'], ['three', 'four']] type='2 * var * string'>,
 <Array [[[1, 1, 1], [2, 2, 2]], [[...], ...]] type='2 * var * var * int64'>]
```


Discussion:

Resolving patch:
diff --git a/src/awkward/operations/ak_broadcast_arrays.py b/src/awkward/operations/ak_broadcast_arrays.py
--- a/src/awkward/operations/ak_broadcast_arrays.py
+++ b/src/awkward/operations/ak_broadcast_arrays.py
@@ -222,7 +222,14 @@ def _impl(
         inputs.append(y.to_backend(backend))
 
     def action(inputs, depth, **kwargs):
-        if depth == depth_limit or all(x.is_numpy for x in inputs):
+        # The depth limit is the depth at which we must return, i.e.
+        # the _first_ layout at that depth
+        if depth == depth_limit:
+            return tuple(inputs)
+        # Walk through non-leaf nodes
+        elif all(
+            x.purelist_depth == 1 and not (x.is_option or x.is_indexed) for x in inputs
+        ):
             return tuple(inputs)
         else:
             return None


Test patch:
diff --git a/tests/test_2410_string_broadcast.py b/tests/test_2410_string_broadcast.py
new file mode 100644
--- /dev/null
+++ b/tests/test_2410_string_broadcast.py
@@ -0,0 +1,36 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import pytest
+
+import awkward as ak
+
+
+@pytest.mark.xfail(
+    reason="reconstructed list should be list of string, not string of string"
+)
+def test_deep_string_string():
+    left, right = ak.broadcast_arrays(
+        [["x", "yz"], ["hello", "world", "foo", "bar"]], ["x", "y"]
+    )
+    assert right.to_list() == [["x", "x"], ["y", "y", "y", "y"]]
+
+
+@pytest.mark.xfail(
+    reason="reconstructed list should be list of string, not string of string"
+)
+def test_deep_numbers_string():
+    left, right = ak.broadcast_arrays([[1, 2], [3, 4, 5, 6]], ["x", "y"])
+    assert right.to_list() == [["x", "x"], ["y", "y", "y", "y"]]
+
+
+def test_deep_string_numbers():
+    left, right = ak.broadcast_arrays(
+        [["x", "yz"], ["hello", "world", "foo", "bar"]], [1, 2]
+    )
+    assert right.to_list() == [[1, 1], [2, 2, 2, 2]]
+
+
+def test_same_depth():
+    left, right = ak.broadcast_arrays(["z"], ["x", "y"])
+    assert left.to_list() == ["z", "z"]
+    assert right.to_list() == ["x", "y"]