Repository: scipy/scipy

Issue:
BUG: `sparse.hstack` returns incorrect result when the stack would result in indices too large for `np.int32`
### Describe your issue.

Over in scikit-learn we ran into this bug in the pursuit of [this PR](https://github.com/scikit-learn/scikit-learn/pull/23731). Essentially when using `sparse.hstack` on a collection of sparse (csr) matrices whose `indices` arrays contain values no greater than the maximum for `np.int32` the operation produces incorrect results.

I believe the problem is within `sparse._construct._stack_along_minor_axis`. In particular its `dtype` resolution for `indices` and `indptr` misses this edge case (and perhaps some others).

### Reproducing Code Example

```python
from scipy import sparse 
import numpy as np

data = [1.0]
row = [0]

max_int32 = np.iinfo(np.int32).max
ind_1 = max_int32
ind_2 = 2
assert ind_1 + ind_2 - 1 > max_int32 #condition of failure
assert max(ind_1 - 1, ind_2 - 1) < max_int32 #condition of failure

col_1 = [ind_1 - 1] 
col_2 = [ind_2 - 1]
X_1 = sparse.csr_matrix((data, (row, col_1)))
X_2 = sparse.csr_matrix((data, (row, col_2)))
Z = sparse.hstack([X_1, X_2], format="csr")

print(Z.indices) # [65534 -2147450882]
assert Z.indices.max() == ind_1 + ind_2 - 1
```


### Error message

```shell
N/A
```


### SciPy/NumPy/Python version information

1.10.0.dev0+0.df3fe4e 1.24.0.dev0+449.g353fea031 sys.version_info(major=3, minor=9, micro=12, releaselevel='final', serial=0)


Discussion:
I thing the data size is too large, unless you're using a very memory-constrained computer. Make sure the rows and columns are either np.int32 or np.int64 to avoid error.
> I thing the data size is too large, unless you're using a very memory-constrained computer. Make sure the rows and columns are either np.int32 or np.int64 to avoid error.

I don't think that's the issue. Regardless of the `dtype` choices for the components of the sparse matrices, internally `hstack` (more precisely `_stack_along_minor_axis`) recasts it by its calculated choice of `dtype` which is what I think is done incorrectly. For reference:

```python

from scipy import sparse 
import numpy as np

data = [1.0]
row = np.array([0], dtype=np.int64)

max_int32 = np.iinfo(np.int32).max
ind_1 = max_int32
ind_2 = 2
assert ind_1 + ind_2 - 1 > max_int32 #condition of failure
assert max(ind_1 - 1, ind_2 - 1) < max_int32 #condition of failure

col_1 = np.array([ind_1 - 1], dtype=np.int64)
col_2 = np.array([ind_2 - 1], dtype=np.int64)
X_1 = sparse.csr_matrix((data, (row, col_1)))
X_2 = sparse.csr_matrix((data, (row, col_2)))
Z = sparse.hstack([X_1, X_2], format="csr")

print(Z.indices) # [65534 -2147450882]
assert Z.indices.max() == ind_1 + ind_2 - 1 #fails
```

Resolving patch:
diff --git a/scipy/sparse/_construct.py b/scipy/sparse/_construct.py
--- a/scipy/sparse/_construct.py
+++ b/scipy/sparse/_construct.py
@@ -465,8 +465,16 @@ def _stack_along_minor_axis(blocks, axis):
     # Do the stacking
     indptr_list = [b.indptr for b in blocks]
     data_cat = np.concatenate([b.data for b in blocks])
-    idx_dtype = get_index_dtype(arrays=indptr_list,
-                                maxval=max(data_cat.size, constant_dim))
+
+    # Need to check if any indices/indptr, would be too large post-
+    # concatenation for np.int32:
+    # - The max value of indices is the output array's stacking-axis length - 1
+    # - The max value in indptr is the number of non-zero entries. This is
+    #   exceedingly unlikely to require int64, but is checked out of an
+    #   abundance of caution.
+    sum_dim = sum(b.shape[axis] for b in blocks)
+    nnz = sum(len(b.indices) for b in blocks)
+    idx_dtype = get_index_dtype(maxval=max(sum_dim - 1, nnz))
     stack_dim_cat = np.array([b.shape[axis] for b in blocks], dtype=idx_dtype)
     if data_cat.size > 0:
         indptr_cat = np.concatenate(indptr_list).astype(idx_dtype)
@@ -483,7 +491,6 @@ def _stack_along_minor_axis(blocks, axis):
         indices = np.empty(0, dtype=idx_dtype)
         data = np.empty(0, dtype=data_cat.dtype)
 
-    sum_dim = stack_dim_cat.sum()
     if axis == 0:
         return csc_matrix((data, indices, indptr),
                           shape=(sum_dim, constant_dim))


Test patch:
diff --git a/scipy/sparse/tests/test_csr.py b/scipy/sparse/tests/test_csr.py
--- a/scipy/sparse/tests/test_csr.py
+++ b/scipy/sparse/tests/test_csr.py
@@ -1,7 +1,6 @@
 import numpy as np
 from numpy.testing import assert_array_almost_equal, assert_
-from scipy.sparse import csr_matrix
-
+from scipy.sparse import csr_matrix, hstack
 import pytest
 
 
@@ -113,3 +112,58 @@ def test_csr_bool_indexing():
     assert (slice_list2 == slice_array2).all()
     assert (slice_list3 == slice_array3).all()
 
+
+def test_csr_hstack_int64():
+    """
+    Tests if hstack properly promotes to indices and indptr arrays to np.int64
+    when using np.int32 during concatenation would result in either array
+    overflowing.
+    """
+    max_int32 = np.iinfo(np.int32).max
+
+    # First case: indices would overflow with int32
+    data = [1.0]
+    row = [0]
+
+    max_indices_1 = max_int32 - 1
+    max_indices_2 = 3
+
+    # Individual indices arrays are representable with int32
+    col_1 = [max_indices_1 - 1]
+    col_2 = [max_indices_2 - 1]
+
+    X_1 = csr_matrix((data, (row, col_1)))
+    X_2 = csr_matrix((data, (row, col_2)))
+
+    assert max(max_indices_1 - 1, max_indices_2 - 1) < max_int32
+    assert X_1.indices.dtype == X_1.indptr.dtype == np.int32
+    assert X_2.indices.dtype == X_2.indptr.dtype == np.int32
+
+    # ... but when concatenating their CSR matrices, the resulting indices
+    # array can't be represented with int32 and must be promoted to int64.
+    X_hs = hstack([X_1, X_2], format="csr")
+
+    assert X_hs.indices.max() == max_indices_1 + max_indices_2 - 1
+    assert max_indices_1 + max_indices_2 - 1 > max_int32
+    assert X_hs.indices.dtype == X_hs.indptr.dtype == np.int64
+
+    # Even if the matrices are empty, we must account for their size
+    # contribution so that we may safely set the final elements.
+    X_1_empty = csr_matrix(X_1.shape)
+    X_2_empty = csr_matrix(X_2.shape)
+    X_hs_empty = hstack([X_1_empty, X_2_empty], format="csr")
+
+    assert X_hs_empty.shape == X_hs.shape
+    assert X_hs_empty.indices.dtype == np.int64
+
+    # Should be just small enough to stay in int32 after stack. Note that
+    # we theoretically could support indices.max() == max_int32, but due to an
+    # edge-case in the underlying sparsetools code
+    # (namely the `coo_tocsr` routine),
+    # we require that max(X_hs_32.shape) < max_int32 as well.
+    # Hence we can only support max_int32 - 1.
+    col_3 = [max_int32 - max_indices_1 - 1]
+    X_3 = csr_matrix((data, (row, col_3)))
+    X_hs_32 = hstack([X_1, X_3], format="csr")
+    assert X_hs_32.indices.dtype == np.int32
+    assert X_hs_32.indices.max() == max_int32 - 1