Repository: scipy/scipy

Issue:
BUG: scipy.optimize.linprog does not fulfill integer constraints yet reports success
### Describe your issue.

Hi,

[According to the docs](https://github.com/scipy/scipy/blob/b4114a651601d439144d91ef9381e81a1749e6bc/scipy/optimize/_linprog.py#L340), integrality constraints for linear programming are supplied as a one-dimensions array containing an integer. However, apparently they are not always fulfilled, even when terminated successfully.

Thanks for the help.

### Reproducing Code Example

```python
import numpy as np
import scipy.optimize as spo

np.random.seed(10)
A = np.random.rand(10,10)
b, c = np.random.rand(10), np.random.rand(10)
spo.linprog(-c, A, b, integrality=np.array([1,], dtype="int"), method="highs")
```


### Result

```shell

           con: array([], dtype=float64)
 crossover_nit: -1
         eqlin:  marginals: array([], dtype=float64)
  residual: array([], dtype=float64)
           fun: -0.03742097937820781
       ineqlin:  marginals: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
  residual: array([0.57099249, 0.8151632 , 0.04062182, 0.46254971, 0.76819817,
       0.68370398, 0.57205104, 0.        , 0.327102  , 0.54974553])
         lower:  marginals: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
  residual: array([ 0.        ,  0.        ,  0.        ,  0.        ,  0.        ,
        0.        ,  0.        ,  0.        ,  0.04224411, -0.        ])
       message: 'Optimization terminated successfully. (HiGHS Status 7: Optimal)'
           nit: -1
         slack: array([0.57099249, 0.8151632 , 0.04062182, 0.46254971, 0.76819817,
       0.68370398, 0.57205104, 0.        , 0.327102  , 0.54974553])
        status: 0
       success: True
         upper:  marginals: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
  residual: array([inf, inf, inf, inf, inf, inf, inf, inf, inf, inf])
             x: array([ 0.        ,  0.        ,  0.        ,  0.        ,  0.        ,
        0.        ,  0.        ,  0.        ,  0.04224411, -0.        ])
```


### SciPy/NumPy/Python version information

1.9.1 1.22.4 sys.version_info(major=3, minor=9, micro=2, releaselevel='final', serial=0)


Discussion:
Each element of `integrality` is an integer that specifies the type of constraint on the corresponding decision variable. So ideally, `len(integrality)` should be the number of the decision variables. In your example, there are 10 decision variables, but only one element in `integrality`.

HiGHS doesn't complain about `integrality` not having enough elements, and `linprog` doesn't broadcast. Apparently, in this case, HiGHS only treats the first variable as though it has an integrality constraint.

There are two options to get around it for now:

Provide an `integrality` array with the right number of elements:
```python3
spo.linprog(-c, A, b, integrality=np.array([1,]*10, dtype="int"), method="highs")
```
or use `milp`, which is a more substantial wrapper that does nice things like broadcasting:
```python3
spo.milp(-c, constraints=(A, -np.inf, b), integrality=1)
 #        message: Optimization terminated successfully. (HiGHS Status 7: Optimal)
 #        success: True
 #         status: 0
 #            fun: 0.0
 #              x: [ 0.000e+00  0.000e+00  0.000e+00  0.000e+00  0.000e+00
 #                   0.000e+00  0.000e+00  0.000e+00  0.000e+00  0.000e+00]
 # mip_node_count: 0
 # mip_dual_bound: 0.0
 #        mip_gap: 0.0
```

What would you suggest - probably a doc enhancement to clarify that you need as many elements in `integrality` as you have decision variables? What would you suggest that `linprog` do if this condition is not met - broadcast like `milp`?
Many thanks for the quick help.

> What would you suggest - probably a doc enhancement to clarify that you need as many elements in integrality as you have decision variables?
> What would you suggest that linprog do if this condition is not met - broadcast like milp?

**In terms of functionality**, I find that supplying only partial information as a user is somewhat unintuitive. I.e., I would expect `integrality=np.array([1])` to apply to all decision variables or raise an error, requiring `len(integrality) == len(c)`.
The return type could also be an integer (not the closest float).
If the highs solver behaves differently and this could confuse users, I would suggest a PR in the highs repo.

**In terms of docs**, surely all behaviors should be listed and alternative ergonomics through `milp` can be mentioned.

I am happy to create a PR once we are clear on the updates :)
> I would expect `integrality=np.array([1])` to apply to all decision variables

I agree; I think it should broadcast `1` to an array with as many elements as the bounds. Please use `np.broadcast_to`, which will raise a reasonable error message if `integrality` cannot be broadcast to the correct shape.

> The return type could also be an integer (not the closest float).

Let's not change this. In the general case (mixed integer constraints) it needs to be floats. I would prefer not to change the return type in the special case that all decision variables have integrality constraints.

PR would be welcome. Thanks! The relevant files are `_linprog.py`, `_linprog_highs.py`, and `_linprog_doc.py`.

Resolving patch:
diff --git a/scipy/optimize/_linprog.py b/scipy/optimize/_linprog.py
--- a/scipy/optimize/_linprog.py
+++ b/scipy/optimize/_linprog.py
@@ -337,7 +337,7 @@ def linprog(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
         'revised simplex' method, and can only be used if `x0` represents a
         basic feasible solution.
 
-    integrality : 1-D array, optional
+    integrality : 1-D array or int, optional
         Indicates the type of integrality constraint on each decision variable.
 
         ``0`` : Continuous variable; no integrality constraint.
@@ -351,8 +351,14 @@ def linprog(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
         ``3`` : Semi-integer variable; decision variable must be an integer
         within `bounds` or take value ``0``.
 
-        By default, all variables are continuous. This argument is currently
-        used only by the ``'highs'`` method and ignored otherwise.
+        By default, all variables are continuous.
+
+        For mixed integrality constraints, supply an array of shape `c.shape`.
+        To infer a constraint on each decision variable from shorter inputs,
+        the argument will be broadcasted to `c.shape` using `np.broadcast_to`.
+
+        This argument is currently used only by the ``'highs'`` method and
+        ignored otherwise.
 
     Returns
     -------
@@ -613,6 +619,8 @@ def linprog(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
         warning_message = ("Only `method='highs'` supports integer "
                            "constraints. Ignoring `integrality`.")
         warn(warning_message, OptimizeWarning)
+    elif np.any(integrality):
+        integrality = np.broadcast_to(integrality, c.shape)
 
     lp = _LPProblem(c, A_ub, b_ub, A_eq, b_eq, bounds, x0, integrality)
     lp, solver_options = _parse_linprog(lp, options, meth)
diff --git a/scipy/optimize/_linprog_doc.py b/scipy/optimize/_linprog_doc.py
--- a/scipy/optimize/_linprog_doc.py
+++ b/scipy/optimize/_linprog_doc.py
@@ -80,7 +80,7 @@ def _linprog_highs_doc(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
         :ref:`'revised simplex' <optimize.linprog-revised_simplex>`, and
         :ref:`'simplex' <optimize.linprog-simplex>` (legacy)
         are also available.
-    integrality : 1-D array, optional
+    integrality : 1-D array or int, optional
         Indicates the type of integrality constraint on each decision variable.
 
         ``0`` : Continuous variable; no integrality constraint.
@@ -96,6 +96,13 @@ def _linprog_highs_doc(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
 
         By default, all variables are continuous.
 
+        For mixed integrality constraints, supply an array of shape `c.shape`.
+        To infer a constraint on each decision variable from shorter inputs,
+        the argument will be broadcasted to `c.shape` using `np.broadcast_to`.
+
+        This argument is currently used only by the ``'highs'`` method and
+        ignored otherwise.
+
     Options
     -------
     maxiter : int
diff --git a/scipy/optimize/_linprog_util.py b/scipy/optimize/_linprog_util.py
--- a/scipy/optimize/_linprog_util.py
+++ b/scipy/optimize/_linprog_util.py
@@ -51,6 +51,28 @@
         the optimization algorithm. This argument is currently used only by the
         'revised simplex' method, and can only be used if `x0` represents a
         basic feasible solution.
+    integrality : 1-D array or int, optional
+        Indicates the type of integrality constraint on each decision variable.
+
+        ``0`` : Continuous variable; no integrality constraint.
+
+        ``1`` : Integer variable; decision variable must be an integer
+        within `bounds`.
+
+        ``2`` : Semi-continuous variable; decision variable must be within
+        `bounds` or take value ``0``.
+
+        ``3`` : Semi-integer variable; decision variable must be an integer
+        within `bounds` or take value ``0``.
+
+        By default, all variables are continuous.
+
+        For mixed integrality constraints, supply an array of shape `c.shape`.
+        To infer a constraint on each decision variable from shorter inputs,
+        the argument will be broadcasted to `c.shape` using `np.broadcast_to`.
+
+        This argument is currently used only by the ``'highs'`` method and
+        ignored otherwise.
 
     Notes
     -----


Test patch: