From 2c093a92cb447720f67aa2e18d3b5c1f67d2641b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 3 Apr 2026 11:00:17 +0200 Subject: [PATCH 1/7] Add juliacall backend --- .github/workflows/ci.yml | 59 ++++++ pyproject.toml | 7 +- src/jumpy/__pycache__/backend.cpython-311.pyc | Bin 0 -> 6617 bytes src/jumpy/__pycache__/model.cpython-311.pyc | Bin 11860 -> 11618 bytes src/jumpy/backend.py | 142 ++++++++++++++ src/jumpy/bridge_juliacall.py | 177 ++++++++++++++++++ src/jumpy/model.py | 36 ++-- tests/test_solve.py | 130 +++++++++++++ 8 files changed, 527 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/jumpy/__pycache__/backend.cpython-311.pyc create mode 100644 src/jumpy/backend.py create mode 100644 src/jumpy/bridge_juliacall.py create mode 100644 tests/test_solve.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad37434 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-expressions: + name: Python expression tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run expression tests + run: python tests/test_expressions.py + + test-solve: + name: End-to-end solve (juliacall + HiGHS) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + julia-version: ["1.11", "1.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.julia-version }} + + - uses: julia-actions/cache@v2 + + - name: Install juliacall + run: pip install juliacall + + - name: Preinstall Julia packages + run: | + julia -e ' + using Pkg + Pkg.add(["MathOptInterface", "HiGHS"]) + using MathOptInterface + using HiGHS + ' + + - name: Run solve tests + run: python tests/test_solve.py diff --git a/pyproject.toml b/pyproject.toml index ac2f484..0538ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,10 @@ version = "0.1.0" description = "A Python interface to JuMP's MathOptInterface via GeneratorOptInterface" requires-python = ">=3.10" license = "MIT" -dependencies = [ - "numpy", -] +dependencies = [] + +[project.optional-dependencies] +juliacall = ["juliacall>=0.9.23"] [tool.hatch.build.targets.wheel] packages = ["src/jumpy"] diff --git a/src/jumpy/__pycache__/backend.cpython-311.pyc b/src/jumpy/__pycache__/backend.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e86124f6bbf0e0276a0b715d7767532750feeb78 GIT binary patch literal 6617 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 literal 0 HcmV?d00001 diff --git a/src/jumpy/__pycache__/model.cpython-311.pyc b/src/jumpy/__pycache__/model.cpython-311.pyc index f41eba2f40e8c6fc02241e96882323fc934f1077..d4dcace60c343ddec5ce9d03e6daf835057cd716 100644 GIT binary patch delta 3439 zcmZ`*Z)_V$72ol{I8N-OiIdn)oUY?G$zBuZl9H%|_UNVQL3>=IVc;q1+cxBy@a$R&Y9mKtQWXl$8JpsYpn7QqX}w_XXaY zwYP5Qt-ZgQdGF2Jd2il(GxyK@H5>U}I2=;&`QzsAjFY>sM`CQ?`pD{j#)D>eA)1XA zda^xC;r%>()tBwXHiB&*AK<&Njp;s*@+pOPT~&DZ^*<-W@j4-jgdQXuN=SY$4*T@h zn_fkE3t#8S_Dfz2dGVVb_IPr#Sm2JL$rg1{3WAc)O1MVa1dXJ6C}1;eG#@-5_TcM zq7|r(NmjLxE3N4ODWndGgZ?B-h>h0xmgT?4SeJON3iY``U0!Q+W_mulc?feh`ge(|Qem&dMAUQDljH-FW0+ z7L%WE+6y3OG=B~BGUReEi#zS3L(+Ge&sl9d3If>xEd);^XaR8{)RR6+W74zJODfR_ zPKdei^BdBAG+e^heSA)jeefOZys79)mT@KP;VkRrqg>4Fk<9$1oV7GSMzF7zo-^U zT;DJ=A(#6stXQ+|h$ht6KC2En)(okmTwl&t&B~ZsEUCxIMAW5oooi|d8e)0V9C}QT z^L510s-;`1qYS0S=2W9-Rj_B(m6`dPVXmvjazky#RkQ%)9xVu+*w{h0jy&u*b4>F% ziSij_HJ1UPV5>!5KvPXOHw+S@_}*0h)u{}PfB*E|)b#z-G-Zjn&^dKP2J$U096}j* z2Eq0f^93C~LF11TNY23q8(zlVhasy1>J@dNRMeqs>u;TY4_g%PbtQ*SkQ`;d7IIc8 z^|;FAY?u`j-TSZ#mEbES(K(e-f1xnSj<-YnpG_wjJ%0QmZero>av-ysCj_=x;3UQ$!K3H70QN*+N|^W4Zzkk&Osj5-GwxC zqVyxR=cJ7VT$zxTEmQV^UKw?fG5cUnr;V6<%)Bbeinz0=a1E0 zPXs_{JS4gvVBWx<$5hXErBl(B;#J%{$4}Evz)A!?)H<4@j!vp!@z>0U2_QMX9 z^ME;1(ZPZ>Bw$F*V&mkH6StzG9%nP+wwlDa|C2h+-WT1ekr~=E&1-uM4o{J$z*^p{ z>K0t2=`tcOk%Y(gTY3>4O-DZzL^-v;cZ0fg_G-*#WjCaLlA7#M&`67}N}I}^Q+F!w zoZiF(TKqS4ba>O9rVxT1TQ5g+-bnGn^9V|7;|h zOsg>UD3u%&MC|q#t3?bM z{U|^QY`;PKl3TOuRb!vuKNdW?Ok2 zy2PS3HBV_M^)H~8WRB6j!m*0HfzFlJ(srWm;FF}5gpY~W_I>lL9bT;#%IlfNMW}-& z&>0Rff+RtTfObTllkyTMzrDlg4}%8_C0;f4XW#}3qs@x-zTji;CLSo6-#_WU9T11d Zqxk;(lNUTp+g6^8|B&>5egs!~{eJ_3==lHu delta 3662 zcmZ`*U2Gf25xzbCm?9-gwkV1eMd@hCk|xMhiN&9FU z{6n;#cEG<&bIa1R65YQc(F1q>7HfMo1Tb$u~@<_#3_p!3Z#?w`A4PYoXzS*-O6Sw9hb_5v)4;3)u3DeJ`)YQE`1bH zZn?kT_E9JTzm{9@+AFiS3@kacedF;+5;t0>Q)hs%Ma1+?zF}yb=GmB5V%hAA{7NP5{`2Ajrl)VteiXd8x*=JRdms=}Pu&B5n9Zo5T3y9VT_9U9U5`5mETHcA8CFH>XUYGc<_D@U;k8ZixSAoQ)0BY`{TF}^6uzw0cjGS%^ zGYq@Oe$!&g4JB<9HP*3x@_PRfl2=2AAxH0r`U&H| z4ZR0x{ASnfsbLiUHiFm{VOLO*$5ymU$ICEeqBS5@Fj@F)I8JW!h4527B8mo4%>cqd z1TR8j`$711d0Zs>IV4;LfM^y8CJ$xn!c@>$)#Q$H*qHMkiZPcg>YEiu|4S zF#lupwZ7+oVk?quMe{&#>K4!TB*+7Pvu7CgxYhHG$TKhsN%uTTHT#Q`SU-7>Ct{tz zcp`RY5*gVdK(ek^hqj{^2ho+4cH6zi{IZs_^cC%W{vZ~P;WD4dd5kIe$Y(F{ce>&{ zss!C@#B%Xpw#Ru-Z~K^v+iXEmFnQ0CbD*kb%>tkzdh^ ziz&Y&^#btDmGio)m^#%?JM{6Ca!~`5LClnMmZ|VHZ|rM|#Wc-S7MM{`7V@g4sEnzr zrXw_wQZ6&KWGc3xsuwNA_KTty`Mcd=e$KnU>aZV6DbG-9D)UUIi`o}zj;EAqHJ>+? zS^e4BOAh-9T#{90MN{k$_ENTVqo}Na5e??BrIW6jKWjFuQ9F>$fwJr}ZZZo(RXmK& zgK6tNs`-VjvQ)LyW>?jGM#&QAT_YVE{f8iI4gsu7J7Fo@vzdJ>`(fA6qpqP_{*R-F ze>V4F^wgv1DJbi~TkwjqUUO4DXQ9Kk*Wwx12*yFpUs4(5t)(%OSwMiWo`j#B$9C@U z|0;9jIG;8Mv-N8ooqnHrP>cKKL=SYPFL+oSx|p`VlUJUSd36*cI4Alht+nn(De60o(2j*DL% z9v3EoMSf|O7WEoSHM@u)E&_P0C4IZ=`CgppH#o|L<#yFjp(%k@g9yV?XO~t%#v%)u zAljPEfklh?5CnC(iKDswy3LLIXE?ddy^7TKy>1j&*)($D`M|Css0b9G<|{Fyq%mt% z)JJfrW!$VcLf%lVSN1N(3b1WT7G!ecq2wO)?mLt>Nm8!d6a@+En5b4c zqfpZG8f_HYC6!g7m)GZ+%2t&XT~%_{YDqKewfTkfGv`k$-zt>yT0w&{Y7}#JVYiEQ zLr%w9Ti1L|x0V!9=5i@9dBvz#cBuLqQ|8s&Yj_qE4NFWvz list[float]: + """Solve the model and return the solution vector.""" + ... + + +class JuliacBackend(Backend): + """ + Default backend: calls a precompiled Julia shared library via ctypes. + + The library is built with juliac from: + MOI + GenOpt + Bridges + HiGHS + + No Julia installation required. + """ + + def __init__(self): + self._lib = None + + def _load_lib(self): + if self._lib is not None: + return + import ctypes + import importlib.resources + # TODO: resolve platform-specific library path + # For now, search standard locations + import os + lib_names = [ + "libjumpy_backend.so", + "libjumpy_backend.dylib", + "jumpy_backend.dll", + ] + for name in lib_names: + for search_dir in [os.path.dirname(__file__), os.getcwd(), "/usr/local/lib"]: + path = os.path.join(search_dir, name) + if os.path.exists(path): + self._lib = ctypes.CDLL(path) + return + raise FileNotFoundError( + "Could not find the compiled JuMPy backend library.\n" + "The juliac-compiled shared library (libjumpy_backend.so) is not installed.\n" + "Either:\n" + " 1. Install the pre-built wheel: pip install jumpy\n" + " 2. Use the juliacall backend: jp.Model(backend='juliacall')\n" + ) + + def optimize(self, model: Model) -> list[float]: + self._load_lib() + data = model._serialize() + # TODO: implement ctypes calls to the compiled library + raise NotImplementedError( + "juliac backend not yet compiled. " + "Use jp.Model(backend='juliacall') for now." + ) + + +class JuliaCallBackend(Backend): + """ + Optional backend: calls Julia directly through juliacall. + + Requires `pip install jumpy[juliacall]`. Julia is installed lazily + by juliacall on first use if not already present. + + This backend has full flexibility — it can use any solver or MOI + feature, not just what's compiled into the juliac library. + """ + + def __init__(self): + self._jl = None + + def _init_julia(self): + if self._jl is not None: + return + try: + from juliacall import Main as jl + except ImportError: + raise ImportError( + "juliacall is not installed.\n" + "Install it with: pip install jumpy[juliacall]\n" + "This will also install Julia automatically if needed." + ) from None + # Install and load Julia packages on first use + jl.seval("using Pkg") + for pkg in ["MathOptInterface", "HiGHS"]: + jl.seval(f""" + if !haskey(Pkg.project().dependencies, "{pkg}") + Pkg.add("{pkg}") + end + """) + jl.seval(""" + using MathOptInterface + const MOI = MathOptInterface + using HiGHS + """) + # TODO: load GenOpt once it's registered / available + self._jl = jl + + def optimize(self, model: Model) -> list[float]: + self._init_julia() + jl = self._jl + return self._build_and_solve(jl, model) + + def _build_and_solve(self, jl, model: Model) -> list[float]: + from jumpy.bridge_juliacall import build_moi_model + return build_moi_model(jl, model) + + +_BACKENDS = { + "juliac": JuliacBackend, + "juliacall": JuliaCallBackend, +} + + +def get_backend(name: str) -> Backend: + cls = _BACKENDS.get(name) + if cls is None: + raise ValueError( + f"Unknown backend '{name}'. Choose from: {list(_BACKENDS.keys())}" + ) + return cls() diff --git a/src/jumpy/bridge_juliacall.py b/src/jumpy/bridge_juliacall.py new file mode 100644 index 0000000..a64c8dc --- /dev/null +++ b/src/jumpy/bridge_juliacall.py @@ -0,0 +1,177 @@ +""" +Bridge between JuMPy's Python expression graph and MathOptInterface via juliacall. + +Translates Python Expr nodes into Julia MOI function calls. +This module is only imported when backend="juliacall" is used. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from jumpy.model import Model + +from jumpy.expressions import ( + BinaryOp, + Constant, + Constraint, + Func, + IndexedParameter, + IndexedVariable, + UnaryOp, + Variable, +) +from jumpy.iterators import Iterator + + +def build_moi_model(jl, model: Model) -> list[float]: + """ + Build an MOI model in Julia from a JuMPy Model and solve it. + + For constraint groups, this constructs the GenOpt IteratedFunction + representation so that expansion happens in Julia. + + For now (without GenOpt in juliacall), we fall back to expanding + constraint groups in Python and adding them individually. This is + slower but functionally correct — it lets users validate models + before the juliac backend is ready. + """ + jl.seval(""" + function _jumpy_create_optimizer() + optimizer = MOI.instantiate( + MOI.OptimizerWithAttributes(HiGHS.Optimizer, "output_flag" => false), + with_bridge_type = Float64, + ) + return optimizer + end + """) + optimizer = jl._jumpy_create_optimizer() + + # Add variables + jl_vars = [] + for block in model._var_blocks: + for var in block.vector: + jl_var = jl.MOI.add_variable(optimizer) + jl_vars.append(jl_var) + if block.lower is not None: + jl.MOI.add_constraint( + optimizer, jl_var, + jl.MOI.GreaterThan(block.lower), + ) + if block.upper is not None: + jl.MOI.add_constraint( + optimizer, jl_var, + jl.MOI.LessThan(block.upper), + ) + + # Add constraint groups (expanded in Python for juliacall fallback) + for group in model._constraint_groups: + _add_constraint_group_expanded(jl, optimizer, jl_vars, group) + + # Add individual constraints + for con in model._individual_constraints: + _add_constraint(jl, optimizer, jl_vars, con) + + # Set objective + if model._objective is not None: + sense = ( + jl.MOI.MIN_SENSE + if model._objective.sense == "min" + else jl.MOI.MAX_SENSE + ) + jl.MOI.set(optimizer, jl.MOI.ObjectiveSense(), sense) + obj_func = _expr_to_moi(jl, jl_vars, model._objective.expr, {}) + jl.MOI.set(optimizer, jl.MOI.ObjectiveFunction(jl.typeof(obj_func)), obj_func) + + # Optimize + jl.MOI.optimize_b(optimizer) + + # Extract solution + solution = [] + for jl_var in jl_vars: + val = float(jl.MOI.get(optimizer, jl.MOI.VariablePrimal(), jl_var)) + solution.append(val) + + return solution + + +def _add_constraint_group_expanded(jl, optimizer, jl_vars, group): + """Expand a constraint group in Python and add each constraint to MOI.""" + from itertools import product + + ranges = [range(it.length) for it in group.iterators] + for indices in product(*ranges): + env = {} + for it, idx in zip(group.iterators, indices): + env[it.id] = it.values[idx] + con = group.template + _add_constraint_with_env(jl, optimizer, jl_vars, con, env) + + +def _add_constraint_with_env(jl, optimizer, jl_vars, con, env): + """Add a single constraint, resolving iterator references from env.""" + lhs_func = _expr_to_moi(jl, jl_vars, con.lhs, env) + rhs_func = _expr_to_moi(jl, jl_vars, con.rhs, env) + + # Normalize: lhs - rhs in set + if con.sense == "<=": + set_ = jl.MOI.LessThan(0.0) + elif con.sense == ">=": + set_ = jl.MOI.GreaterThan(0.0) + elif con.sense == "==": + set_ = jl.MOI.EqualTo(0.0) + else: + raise ValueError(f"Unknown constraint sense: {con.sense}") + + func = jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[lhs_func, rhs_func]) + jl.MOI.add_constraint(optimizer, func, set_) + + +def _expr_to_moi(jl, jl_vars, expr, env): + """Convert a Python Expr to a Julia MOI function, resolving iterators from env.""" + match expr: + case Constant(value=v): + return v + case Variable(index=idx): + return jl_vars[idx] + case BinaryOp(op=op, left=left, right=right): + l = _expr_to_moi(jl, jl_vars, left, env) + r = _expr_to_moi(jl, jl_vars, right, env) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), jl.Any[l, r]) + case UnaryOp(op="-", arg=arg): + a = _expr_to_moi(jl, jl_vars, arg, env) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[a]) + case Func(name=name, arg=arg): + a = _expr_to_moi(jl, jl_vars, arg, env) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), jl.Any[a]) + case Iterator() as it: + return float(env[it.id]) + case IndexedVariable() as iv: + idx_val = _eval_index(iv.index_expr, env) + return jl_vars[iv.variable_vector._variables[0].index + int(idx_val)] + case IndexedParameter() as ip: + idx_val = _eval_index(ip.index_expr, env) + return ip.parameter.values[int(idx_val)] + case _: + raise TypeError(f"Cannot convert {type(expr).__name__} to MOI") + + +def _eval_index(expr, env) -> float: + """Evaluate an index expression with concrete iterator values from env.""" + match expr: + case Constant(value=v): + return v + case Iterator() as it: + return float(env[it.id]) + case BinaryOp(op=op, left=left, right=right): + l = _eval_index(left, env) + r = _eval_index(right, env) + match op: + case "+": return l + r + case "-": return l - r + case "*": return l * r + case "/": return l / r + case "^": return l ** r + case _: + raise TypeError(f"Cannot evaluate index expression: {type(expr).__name__}") diff --git a/src/jumpy/model.py b/src/jumpy/model.py index 1024fea..e7dce37 100644 --- a/src/jumpy/model.py +++ b/src/jumpy/model.py @@ -20,6 +20,7 @@ ) from jumpy.iterators import Iterator from jumpy.serialize import serialize_constraint, serialize_expr +from jumpy.backend import Backend, get_backend def minimize(expr: Expr) -> Objective: @@ -101,7 +102,15 @@ class Model: m.optimize() """ - def __init__(self): + def __init__(self, backend: str = "juliac"): + """ + Create a new model. + + Args: + backend: "juliac" (default, no Julia needed) or "juliacall" + (uses juliacall, installs Julia lazily if needed). + """ + self._backend: Backend = get_backend(backend) self._var_blocks: list[VariableBlock] = [] self._constraint_groups: list[ConstraintGroup] = [] self._individual_constraints: list[Constraint] = [] @@ -191,17 +200,12 @@ def objective(self, obj: Objective) -> None: def optimize(self) -> None: """ - Serialize the model and send it to the compiled Julia library for solving. - - The Julia side: - 1. Reconstructs MOI.ScalarNonlinearFunction trees from flat arrays - 2. Wraps constraint groups in GeneratorOptInterface.IteratedFunction - 3. Adds bridges - 4. Calls HiGHS - 5. Returns the solution vector + Solve the model using the selected backend. + + - juliac backend: serializes to flat arrays, calls compiled shared library + - juliacall backend: builds MOI model directly in Julia via juliacall """ - model_data = self._serialize() - self._solution = _call_julia_solver(model_data) + self._solution = self._backend.optimize(self) def _serialize(self) -> dict: """Serialize the entire model for the Julia C ABI.""" @@ -263,13 +267,3 @@ def value(self, var: Variable) -> float: return self._solution[var.index] -def _call_julia_solver(model_data: dict) -> list[float]: - """ - Call the compiled Julia shared library via ctypes. - - TODO: Implement once the Julia library is compiled with juliac. - """ - raise NotImplementedError( - "Julia solver backend not yet available. " - "Compile the Julia library with juliac and place it on the library path." - ) diff --git a/tests/test_solve.py b/tests/test_solve.py new file mode 100644 index 0000000..d35f178 --- /dev/null +++ b/tests/test_solve.py @@ -0,0 +1,130 @@ +""" +End-to-end tests that solve models with HiGHS via the juliacall backend. + +These require Julia + juliacall to be installed: + pip install juliacall +""" + +import sys +sys.path.insert(0, "src") + +import pytest +from jumpy import Model, Iterator, Parameter, minimize, maximize, sin, exp + + +def _model(): + return Model(backend="juliacall") + + +def test_simple_lp(): + """min x + y s.t. x + y >= 10, x >= 0, y >= 0""" + m = _model() + x = m.variable(lower=0, name="x") + y = m.variable(lower=0, name="y") + + m.constraint(x + y >= 10) + m.objective = minimize(x + y) + m.optimize() + + assert abs(m.value(x) + m.value(y) - 10.0) < 1e-6 + + +def test_constraint_group_lp(): + """ + min sum(x) s.t. x[i] >= 1 for i in 0..9, x[i] >= 0 + Optimal: all x[i] = 1, obj = 10 + """ + m = _model() + x = m.variables(10, lower=0, name="x") + + i = Iterator(range(10)) + m.constraint_group([i], x[i] >= 1) + + m.objective = minimize(sum(x)) + m.optimize() + + total = sum(m.value(v) for v in x) + assert abs(total - 10.0) < 1e-6 + + +def test_constraint_group_consecutive(): + """ + min x[0] s.t. x[i] + x[i+1] >= 2 for i in 0..8, x[i] >= 0 + """ + m = _model() + x = m.variables(10, lower=0, name="x") + + i = Iterator(range(9)) + m.constraint_group([i], x[i] + x[i + 1] >= 2) + + m.objective = minimize(x[0] + x[1] + x[2]) + m.optimize() + + for k in range(9): + assert m.value(x[k]) + m.value(x[k + 1]) >= 2.0 - 1e-6 + + +def test_parameter_in_constraint_group(): + """ + min sum(x) s.t. x[i] >= demand[i], x[i] >= 0 + demand = [1, 2, 3, 4, 5] + Optimal: x[i] = demand[i], obj = 15 + """ + m = _model() + x = m.variables(5, lower=0, name="x") + demand = Parameter([1.0, 2.0, 3.0, 4.0, 5.0], name="demand") + + i = Iterator(range(5)) + m.constraint_group([i], x[i] >= demand[i]) + + m.objective = minimize(sum(x)) + m.optimize() + + total = sum(m.value(v) for v in x) + assert abs(total - 15.0) < 1e-6 + + +def test_multidim_constraint_group(): + """ + 2D indexing: x[3*i + j] >= 1 for i in 0..2, j in 0..2 + 9 variables, all >= 1 + """ + m = _model() + x = m.variables(9, lower=0, name="x") + + i = Iterator(range(3)) + j = Iterator(range(3)) + m.constraint_group([i, j], x[3 * i + j] >= 1) + + m.objective = minimize(sum(x)) + m.optimize() + + total = sum(m.value(v) for v in x) + assert abs(total - 9.0) < 1e-6 + + +def test_maximize(): + """max x s.t. x <= 42, x >= 0""" + m = _model() + x = m.variable(lower=0, upper=42, name="x") + + m.objective = maximize(x) + m.optimize() + + assert abs(m.value(x) - 42.0) < 1e-6 + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + passed = failed = 0 + for test in tests: + try: + test() + passed += 1 + print(f" PASS {test.__name__}") + except Exception as e: + failed += 1 + import traceback + print(f" FAIL {test.__name__}: {e}") + traceback.print_exc() + print(f"\n{passed} passed, {failed} failed") From 6b55a561cc4867243494e863861be408a602d026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 3 Apr 2026 11:08:21 +0200 Subject: [PATCH 2/7] Fix --- tests/test_solve.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_solve.py b/tests/test_solve.py index d35f178..cd0f1de 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -8,7 +8,6 @@ import sys sys.path.insert(0, "src") -import pytest from jumpy import Model, Iterator, Parameter, minimize, maximize, sin, exp From cf2378a5a146651a9f69df37b97b7683038d0b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 3 Apr 2026 11:33:33 +0200 Subject: [PATCH 3/7] use import --- .gitignore | 5 +++++ src/jumpy/backend.py | 10 ++++------ 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b5ea51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ diff --git a/src/jumpy/backend.py b/src/jumpy/backend.py index fc52041..dfff732 100644 --- a/src/jumpy/backend.py +++ b/src/jumpy/backend.py @@ -103,17 +103,15 @@ def _init_julia(self): ) from None # Install and load Julia packages on first use jl.seval("using Pkg") - for pkg in ["MathOptInterface", "HiGHS"]: + for pkg in ["MathOptInterface", "HiGHS", "GenOpt"]: jl.seval(f""" if !haskey(Pkg.project().dependencies, "{pkg}") Pkg.add("{pkg}") end """) - jl.seval(""" - using MathOptInterface - const MOI = MathOptInterface - using HiGHS - """) + jl.seval("import MathOptInterface as MOI") + jl.seval("import GenOpt") + jl.seval("import HiGHS") # TODO: load GenOpt once it's registered / available self._jl = jl From 5a4cbb305fde95d726cca8b090ed2b3ae2ee1ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 3 Apr 2026 11:49:07 +0200 Subject: [PATCH 4/7] Remove pycache --- src/jumpy/__pycache__/__init__.cpython-311.pyc | Bin 1183 -> 0 bytes src/jumpy/__pycache__/backend.cpython-311.pyc | Bin 6617 -> 0 bytes .../__pycache__/expressions.cpython-311.pyc | Bin 17257 -> 0 bytes src/jumpy/__pycache__/iterators.cpython-311.pyc | Bin 2004 -> 0 bytes src/jumpy/__pycache__/model.cpython-311.pyc | Bin 11618 -> 0 bytes src/jumpy/__pycache__/serialize.cpython-311.pyc | Bin 5941 -> 0 bytes 6 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/jumpy/__pycache__/__init__.cpython-311.pyc delete mode 100644 src/jumpy/__pycache__/backend.cpython-311.pyc delete mode 100644 src/jumpy/__pycache__/expressions.cpython-311.pyc delete mode 100644 src/jumpy/__pycache__/iterators.cpython-311.pyc delete mode 100644 src/jumpy/__pycache__/model.cpython-311.pyc delete mode 100644 src/jumpy/__pycache__/serialize.cpython-311.pyc diff --git a/src/jumpy/__pycache__/__init__.cpython-311.pyc b/src/jumpy/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 6dd6b2d1c3e32c42f4fa01dd63ccec1d712dd9eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1183 zcmcJN&u`N(6vv$=ZPRpZ_oLh3fXIOZ5vUPYKu8Sjr&Km+(vW}qTP1lIqQH)Aa)^fK6MG5H z^LG$@Kq0~?G{RD3@DeU@6Pw(^7BAy6uiy%=;wrbX&1<-3=zS*abFjnfxXu^w0&n03 zZ{j9z;l*OzikA2?UM}Ktw8GoC%~$a%U&CvB9j_Z`h{DQ81K$d(hsd)}X3k#TJ(xar zpSuUsY?Q<a|ek4saR%$Kn*$#5*Jy3r7M$Y!s21a`KH_N*~&g5f{|WCRr4x3qJ1bz z*)OyRi8EC@AcAlT{ZQ5>RHdw~Jnk!}*ig8r+J0{gpRoz`D$0~BR%Snu$^>_1M#)fF z^0mm6NqW*Vl)VdDx^teT%H1TSk+M098D}RHB62*BWX^+RLWQ>?mLXjMX9x*PqpVTU zsA||6H4S~>#FEB}Mq6W5qpq=_(a>mWv;e9yP6J5mrFUCZgCI=$L71@@H=CD zX8e#&{Nf<{QuO^o&<_IWY!(Eabb4Vw1^GOp&%|9&FyN8j0W;Guj9IB{STm&2tQn;l o`oCx+W6L;2n>Qb4=-%n|afY6p{%g#vb{&ej!uzVe#T}^n2gFB5H~;_u diff --git a/src/jumpy/__pycache__/backend.cpython-311.pyc b/src/jumpy/__pycache__/backend.cpython-311.pyc deleted file mode 100644 index e86124f6bbf0e0276a0b715d7767532750feeb78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6617 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 diff --git a/src/jumpy/__pycache__/expressions.cpython-311.pyc b/src/jumpy/__pycache__/expressions.cpython-311.pyc deleted file mode 100644 index 56290c6f986df90bd5479e5429e4d443ec1f03d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17257 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 EKfk`rSO5S3 diff --git a/src/jumpy/__pycache__/iterators.cpython-311.pyc b/src/jumpy/__pycache__/iterators.cpython-311.pyc deleted file mode 100644 index ecaf8397da0fc8f0e3b413df8e018a1c50cfda74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2004 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 lqDnogjDz;gpkzFv(kG2qz2Ec6;DfE z+ob}v2wJs(Q6Bv^wPkf)`jfW&IzF+di+kvlBa!GEb|I^a#|{n30g;mw7jVH_4P#sl3H5E zo=JKJNb}8&;e#3F?4`kI=2KJV5t0#sL%tOiVKJ|%)P)k5n&CVn9b(Qm8C?y zhUW_P`t+*d8LO@0R<6sTLf%6+J6Q*Hj&<-*<#0XX)x2lWGaNoq0fZQw) zOzD}ZI`0JwGQ6;113R91;lRd;jSCwLWfA8t+q28|8tmy^wtts>m$W0)W!OP&!wxDN zc2L)_gQ|uD)ims&;*{Y)w-IDv1WeBmc`2o+Oqe(GGk&j@I{5+FQ<~r5MgK#A^9W(rj%+1Ac%>5b4f8R5-?Ocm6b(;ZpVGv zM_GX@xr9;;({jX41kjpvL1Wk#@{0=E0Yu*&>|f4hMc%l=t3|R=-u;xf zV-MaQivs5H)%yiU^DKMJ%GaqRJDv62JD~NtbKSM>-ecxKovv|awWSU(I1knaKPHd5 zuZGGLTm`mZdv)V9VYq(?qh`raotNaGI{o4fY+%sIvxi0)MyjPrqXQO{VSchko;~F~ zRG&d|HpuJUQ{E$WswR0m>+pj2CwgE{d10*z^*JOLm3Zh5fY!M^>>IArG|F4xR@$sL zKekFt594kD8BSVM3{#ciJt~6_Wbm!j*1*bs3r}*_t+7(`_ByZjTNvQs2Fhk>*8Q*D z;RAL0Ce8R}4o&a;&F_}%&105nTJOGS!nN%%RtNevpC7_EIHO>l~bKeI;T6T}?$s`l;(S@W0u~ z%cz&?a!8!|v-+sOG+Sgvu-^TH`RElp{E3bDrr^+X(v%uIG(n8m4O?#2a9}=cIB7B* zv5^??fmDPnMQm{VKz>BfSD!Hia|?jkQj+1Kj1+PXK=)h6XeK7q4n85$MKJj=T=HF2 z&Ps-Ne)M``YX z+@uIz`k?ReY%x4YeC_7cn89HSlU*I3BYu3vy?byTjO8iNkKdD)@VrK%!p8EwP6l%5P7m8!WF^kasX#ckI@u5!+X&uoL z->dVznpKg$>$!gj>4$2y7CgpeWmX6diQy)W2D4i(D@4*f8iP5 z{!lH>ZTFS?piJRySMH5vu5)? z8h(0kXYk3<((!Zp@pD?onr!#1)uJJod4{|lOtc{zrOiJ;md;7aka#c>-@OJkE4yq2kYC($KZhx zogdMxTJeIF!0r_e4E3%5sOCRbt0mfh)^V^H-inqw-q1VV*t}5=b`;;!g1u0`J9M;| zEgkCB5A~Kig{`#S8LK$$(Y6W$&BH+PZmH!IIM?5TtM%Ihk*C8uo&WOav59ZG_1JW& zXGZUt(K>!v;%9Y!R^w;)kg^q|0}oc(m_YDZ+hOg<_*agd`7gLHIelOZMo>#_*Yvh) z8h?$PhFyfWxh!!(uj&4UQH>_%x1m$Ne-)S&7@Gz9wG{h4L?yVHVc@J5dM|^h`#DBl zS^yt7nnkj0;T_F>|IhSJGiO~w1kwDkLa)6eLKeR)!Ph9baN)(W`T?p3qIFi0t9P_n zdXubvnZcOxhcM!XM})WG`vHE+!OsX$Axwz?9KLudTnJyr=F+9`Iu&3F&JF=YgSdpO zBMyk4%W%mh%L=^&5i|qbwQj+~jC5RGHtWhoW0Vo99VPG)1-=ncRS4z>OYm1NL-XoQ zsx|aMNAb+oxl(I*WAeL}&f7ZsV_wy8`6 zvp;ein{H?#9y2=Q9m@>S2sJUI*rS-ei0LvF!&UGqC&Y{#HQb4WluITOaL^A?5Ef2dkZvesm_Tujzx9-Hg7QbON k|0L86uHy2s!3y&O&5 Date: Fri, 3 Apr 2026 12:35:27 +0200 Subject: [PATCH 5/7] Use genopt --- src/jumpy/bridge_juliacall.py | 290 +++++++++++++++++++++++----------- 1 file changed, 197 insertions(+), 93 deletions(-) diff --git a/src/jumpy/bridge_juliacall.py b/src/jumpy/bridge_juliacall.py index a64c8dc..018f7d9 100644 --- a/src/jumpy/bridge_juliacall.py +++ b/src/jumpy/bridge_juliacall.py @@ -1,8 +1,12 @@ """ Bridge between JuMPy's Python expression graph and MathOptInterface via juliacall. -Translates Python Expr nodes into Julia MOI function calls. +Translates Python Expr nodes into Julia MOI + GenOpt types. This module is only imported when backend="juliacall" is used. + +The Python side does NO iteration over constraints or variables. +It builds one expression template per constraint group, hands it to GenOpt +as a FunctionGenerator, and lets Julia handle all expansion. """ from __future__ import annotations @@ -15,7 +19,6 @@ from jumpy.expressions import ( BinaryOp, Constant, - Constraint, Func, IndexedParameter, IndexedVariable, @@ -25,53 +28,90 @@ from jumpy.iterators import Iterator -def build_moi_model(jl, model: Model) -> list[float]: - """ - Build an MOI model in Julia from a JuMPy Model and solve it. +_HELPERS_DEFINED = False - For constraint groups, this constructs the GenOpt IteratedFunction - representation so that expansion happens in Julia. - For now (without GenOpt in juliacall), we fall back to expanding - constraint groups in Python and adding them individually. This is - slower but functionally correct — it lets users validate models - before the juliac backend is ready. - """ +def _define_helpers(jl): + """Define Julia helper functions once.""" + global _HELPERS_DEFINED + if _HELPERS_DEFINED: + return jl.seval(""" + function _jumpy_add_variables!(optimizer, count, lower, upper) + vars = MOI.add_variables(optimizer, count) + if !isnothing(lower) + for v in vars + MOI.add_constraint(optimizer, v, MOI.GreaterThan(lower)) + end + end + if !isnothing(upper) + for v in vars + MOI.add_constraint(optimizer, v, MOI.LessThan(upper)) + end + end + return vars + end + + function _jumpy_add_constraint_group!(optimizer, func, sense) + n = prod(length.(func.iterators)) + if sense == "<=" + set = MOI.Nonpositives(n) + elseif sense == ">=" + set = MOI.Nonnegatives(n) + elseif sense == "==" + set = MOI.Zeros(n) + else + error("Unknown sense: $sense") + end + MOI.add_constraint(optimizer, func, set) + end + + function _jumpy_make_generator(func, iterators) + return GenOpt.FunctionGenerator{typeof(func)}(func, iterators) + end + function _jumpy_create_optimizer() - optimizer = MOI.instantiate( + return MOI.instantiate( MOI.OptimizerWithAttributes(HiGHS.Optimizer, "output_flag" => false), with_bridge_type = Float64, ) - return optimizer + end + + function _jumpy_get_solution(optimizer, vars) + return [MOI.get(optimizer, MOI.VariablePrimal(), v) for v in vars] end """) + _HELPERS_DEFINED = True + + +def build_moi_model(jl, model: Model) -> list[float]: + """ + Build an MOI model in Julia from a JuMPy Model and solve it. + + Constraint groups are passed as GenOpt.FunctionGenerator objects + so that expansion happens entirely in Julia. + """ + _define_helpers(jl) + optimizer = jl._jumpy_create_optimizer() - # Add variables - jl_vars = [] + # Add variables — one bulk call per block + all_jl_vars = [] for block in model._var_blocks: - for var in block.vector: - jl_var = jl.MOI.add_variable(optimizer) - jl_vars.append(jl_var) - if block.lower is not None: - jl.MOI.add_constraint( - optimizer, jl_var, - jl.MOI.GreaterThan(block.lower), - ) - if block.upper is not None: - jl.MOI.add_constraint( - optimizer, jl_var, - jl.MOI.LessThan(block.upper), - ) - - # Add constraint groups (expanded in Python for juliacall fallback) + lower = block.lower if block.lower is not None else jl.nothing + upper = block.upper if block.upper is not None else jl.nothing + block_vars = jl._jumpy_add_variables_b( + optimizer, block.count, lower, upper, + ) + all_jl_vars.append(block_vars) + + # Add constraint groups via GenOpt for group in model._constraint_groups: - _add_constraint_group_expanded(jl, optimizer, jl_vars, group) + _add_constraint_group(jl, optimizer, all_jl_vars, model, group) # Add individual constraints for con in model._individual_constraints: - _add_constraint(jl, optimizer, jl_vars, con) + _add_individual_constraint(jl, optimizer, all_jl_vars, con) # Set objective if model._objective is not None: @@ -81,97 +121,161 @@ def build_moi_model(jl, model: Model) -> list[float]: else jl.MOI.MAX_SENSE ) jl.MOI.set(optimizer, jl.MOI.ObjectiveSense(), sense) - obj_func = _expr_to_moi(jl, jl_vars, model._objective.expr, {}) - jl.MOI.set(optimizer, jl.MOI.ObjectiveFunction(jl.typeof(obj_func)), obj_func) + obj_func = _expr_to_moi(jl, all_jl_vars, model._objective.expr) + obj_type = jl.typeof(obj_func) + jl.MOI.set(optimizer, jl.MOI.ObjectiveFunction(obj_type), obj_func) - # Optimize + # Optimize and extract solution jl.MOI.optimize_b(optimizer) - # Extract solution - solution = [] - for jl_var in jl_vars: - val = float(jl.MOI.get(optimizer, jl.MOI.VariablePrimal(), jl_var)) - solution.append(val) + # Flatten all variable blocks into one solution vector + all_vars_flat = jl.seval("vcat")(*(v for v in all_jl_vars)) + jl_solution = jl._jumpy_get_solution(optimizer, all_vars_flat) + return [float(jl_solution[i]) for i in range(1, len(jl_solution) + 1)] - return solution +def _add_constraint_group(jl, optimizer, all_jl_vars, model, group): + """ + Add a constraint group as a single GenOpt.FunctionGenerator. -def _add_constraint_group_expanded(jl, optimizer, jl_vars, group): - """Expand a constraint group in Python and add each constraint to MOI.""" - from itertools import product + Python builds the template expression and iterator list, then hands + them to GenOpt. No Python-side iteration over constraint instances. + """ + # Build GenOpt iterators + genopt_iterators = jl.seval("GenOpt.Iterator[]") + iter_id_map = {} + for idx, it in enumerate(group.iterators): + jl_values = jl.seval("collect")(it.values) + jl_it = jl.GenOpt.Iterator(jl_values) + jl.push_b(genopt_iterators, jl_it) + iter_id_map[it.id] = idx + 1 # 1-based - ranges = [range(it.length) for it in group.iterators] - for indices in product(*ranges): - env = {} - for it, idx in zip(group.iterators, indices): - env[it.id] = it.values[idx] - con = group.template - _add_constraint_with_env(jl, optimizer, jl_vars, con, env) + # Normalize: lhs - rhs in {Nonpositives, Nonnegatives, Zeros} + normalized = group.template.lhs - group.template.rhs + # Build MOI.ScalarNonlinearFunction template with GenOpt placeholders + template_func = _expr_to_moi_template( + jl, all_jl_vars, model, normalized, genopt_iterators, iter_id_map, + ) -def _add_constraint_with_env(jl, optimizer, jl_vars, con, env): - """Add a single constraint, resolving iterator references from env.""" - lhs_func = _expr_to_moi(jl, jl_vars, con.lhs, env) - rhs_func = _expr_to_moi(jl, jl_vars, con.rhs, env) + # Wrap in FunctionGenerator and add constraint — all in Julia + func_gen = jl._jumpy_make_generator(template_func, genopt_iterators) + jl._jumpy_add_constraint_group_b(optimizer, func_gen, group.template.sense) - # Normalize: lhs - rhs in set - if con.sense == "<=": - set_ = jl.MOI.LessThan(0.0) - elif con.sense == ">=": - set_ = jl.MOI.GreaterThan(0.0) - elif con.sense == "==": - set_ = jl.MOI.EqualTo(0.0) - else: - raise ValueError(f"Unknown constraint sense: {con.sense}") - func = jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[lhs_func, rhs_func]) - jl.MOI.add_constraint(optimizer, func, set_) +def _get_jl_var(jl, all_jl_vars, var_index, model): + """Get the Julia MOI.VariableIndex for a Python Variable by its index.""" + offset = 0 + for block_idx, block in enumerate(model._var_blocks): + if var_index < offset + block.count: + local_idx = var_index - offset + return all_jl_vars[block_idx][local_idx + 1] # 1-based + offset += block.count + raise IndexError(f"Variable index {var_index} out of range") + + +def _get_contiguous(jl, all_jl_vars, variable_vector, model): + """Get a GenOpt.ContiguousArrayOfVariables for a VariableVector.""" + start = variable_vector._variables[0].index + count = len(variable_vector) + return jl.seval( + f"GenOpt.ContiguousArrayOfVariables({start}, ({count},))" + ) -def _expr_to_moi(jl, jl_vars, expr, env): - """Convert a Python Expr to a Julia MOI function, resolving iterators from env.""" +def _expr_to_moi_template(jl, all_jl_vars, model, expr, genopt_iterators, iter_id_map): + """ + Convert a Python Expr into an MOI.ScalarNonlinearFunction template + with GenOpt.IteratorIndex and ContiguousArrayOfVariables placeholders. + """ match expr: case Constant(value=v): return v case Variable(index=idx): - return jl_vars[idx] + return _get_jl_var(jl, all_jl_vars, idx, model) case BinaryOp(op=op, left=left, right=right): - l = _expr_to_moi(jl, jl_vars, left, env) - r = _expr_to_moi(jl, jl_vars, right, env) + l = _expr_to_moi_template(jl, all_jl_vars, model, left, genopt_iterators, iter_id_map) + r = _expr_to_moi_template(jl, all_jl_vars, model, right, genopt_iterators, iter_id_map) return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), jl.Any[l, r]) case UnaryOp(op="-", arg=arg): - a = _expr_to_moi(jl, jl_vars, arg, env) + a = _expr_to_moi_template(jl, all_jl_vars, model, arg, genopt_iterators, iter_id_map) return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[a]) case Func(name=name, arg=arg): - a = _expr_to_moi(jl, jl_vars, arg, env) + a = _expr_to_moi_template(jl, all_jl_vars, model, arg, genopt_iterators, iter_id_map) return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), jl.Any[a]) case Iterator() as it: - return float(env[it.id]) + jl_idx = iter_id_map[it.id] + return jl.GenOpt.IteratorIndex(jl_idx) case IndexedVariable() as iv: - idx_val = _eval_index(iv.index_expr, env) - return jl_vars[iv.variable_vector._variables[0].index + int(idx_val)] + contiguous = _get_contiguous(jl, all_jl_vars, iv.variable_vector, model) + index_expr = _expr_to_moi_template( + jl, all_jl_vars, model, iv.index_expr, genopt_iterators, iter_id_map, + ) + # 0-based Python → 1-based Julia + index_1based = jl.MOI.ScalarNonlinearFunction( + jl.Symbol("+"), jl.Any[index_expr, 1], + ) + return jl.MOI.ScalarNonlinearFunction( + jl.Symbol("getindex"), jl.Any[contiguous, index_1based], + ) case IndexedParameter() as ip: - idx_val = _eval_index(ip.index_expr, env) - return ip.parameter.values[int(idx_val)] + jl_values = jl.seval("collect")(ip.parameter.values) + index_expr = _expr_to_moi_template( + jl, all_jl_vars, model, ip.index_expr, genopt_iterators, iter_id_map, + ) + index_1based = jl.MOI.ScalarNonlinearFunction( + jl.Symbol("+"), jl.Any[index_expr, 1], + ) + return jl.MOI.ScalarNonlinearFunction( + jl.Symbol("getindex"), jl.Any[jl_values, index_1based], + ) case _: - raise TypeError(f"Cannot convert {type(expr).__name__} to MOI") + raise TypeError(f"Cannot convert {type(expr).__name__} to MOI template") -def _eval_index(expr, env) -> float: - """Evaluate an index expression with concrete iterator values from env.""" +def _expr_to_moi(jl, all_jl_vars, expr): + """Convert a Python Expr to a concrete Julia MOI function (no iterators).""" match expr: case Constant(value=v): return v - case Iterator() as it: - return float(env[it.id]) + case Variable(index=idx): + # For objectives/individual constraints, find the variable + offset = 0 + for block_idx, block_vars in enumerate(all_jl_vars): + block_size = int(jl.length(block_vars)) + if idx < offset + block_size: + return block_vars[idx - offset + 1] # 1-based + offset += block_size + raise IndexError(f"Variable index {idx} out of range") case BinaryOp(op=op, left=left, right=right): - l = _eval_index(left, env) - r = _eval_index(right, env) - match op: - case "+": return l + r - case "-": return l - r - case "*": return l * r - case "/": return l / r - case "^": return l ** r + l = _expr_to_moi(jl, all_jl_vars, left) + r = _expr_to_moi(jl, all_jl_vars, right) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), jl.Any[l, r]) + case UnaryOp(op="-", arg=arg): + a = _expr_to_moi(jl, all_jl_vars, arg) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[a]) + case Func(name=name, arg=arg): + a = _expr_to_moi(jl, all_jl_vars, arg) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), jl.Any[a]) case _: - raise TypeError(f"Cannot evaluate index expression: {type(expr).__name__}") + raise TypeError(f"Cannot convert {type(expr).__name__} to MOI") + + +def _add_individual_constraint(jl, optimizer, all_jl_vars, con): + """Add a single non-grouped constraint.""" + lhs_func = _expr_to_moi(jl, all_jl_vars, con.lhs) + rhs_func = _expr_to_moi(jl, all_jl_vars, con.rhs) + + if con.sense == "<=": + set_ = jl.MOI.LessThan(0.0) + elif con.sense == ">=": + set_ = jl.MOI.GreaterThan(0.0) + elif con.sense == "==": + set_ = jl.MOI.EqualTo(0.0) + else: + raise ValueError(f"Unknown constraint sense: {con.sense}") + + func = jl.MOI.ScalarNonlinearFunction( + jl.Symbol("-"), jl.Any[lhs_func, rhs_func], + ) + jl.MOI.add_constraint(optimizer, func, set_) From 7bd574c7c8a472e3f2c3255b544eaca6530d0992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 3 Apr 2026 16:24:15 +0200 Subject: [PATCH 6/7] Fix --- src/jumpy/bridge_juliacall.py | 4 ++-- tests/test_expressions.py | 2 ++ tests/test_solve.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/jumpy/bridge_juliacall.py b/src/jumpy/bridge_juliacall.py index 018f7d9..b33ce32 100644 --- a/src/jumpy/bridge_juliacall.py +++ b/src/jumpy/bridge_juliacall.py @@ -98,8 +98,8 @@ def build_moi_model(jl, model: Model) -> list[float]: # Add variables — one bulk call per block all_jl_vars = [] for block in model._var_blocks: - lower = block.lower if block.lower is not None else jl.nothing - upper = block.upper if block.upper is not None else jl.nothing + lower = float(block.lower) if block.lower is not None else jl.nothing + upper = float(block.upper) if block.upper is not None else jl.nothing block_vars = jl._jumpy_add_variables_b( optimizer, block.count, lower, upper, ) diff --git a/tests/test_expressions.py b/tests/test_expressions.py index a70b87c..65e255d 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -267,3 +267,5 @@ def test_serialize_full_model(): print(f"FAIL {test.__name__}: {e}") traceback.print_exc() print(f"\n{passed} passed, {failed} failed") + import sys + sys.exit(1 if failed else 0) diff --git a/tests/test_solve.py b/tests/test_solve.py index cd0f1de..effc976 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -127,3 +127,4 @@ def test_maximize(): print(f" FAIL {test.__name__}: {e}") traceback.print_exc() print(f"\n{passed} passed, {failed} failed") + sys.exit(1 if failed else 0) From 65dbed8437699a4cd003958e5752b92550727dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Sun, 5 Apr 2026 11:45:00 +0200 Subject: [PATCH 7/7] Fixes --- src/jumpy/bridge_juliacall.py | 178 +++++++++++++++++++++++++++------- 1 file changed, 144 insertions(+), 34 deletions(-) diff --git a/src/jumpy/bridge_juliacall.py b/src/jumpy/bridge_juliacall.py index b33ce32..2126c8a 100644 --- a/src/jumpy/bridge_juliacall.py +++ b/src/jumpy/bridge_juliacall.py @@ -29,13 +29,31 @@ _HELPERS_DEFINED = False +_any_vec = None def _define_helpers(jl): """Define Julia helper functions once.""" - global _HELPERS_DEFINED + global _HELPERS_DEFINED, _any_vec if _HELPERS_DEFINED: return + # jl.Any[...] is broken in PythonCall with Julia 1.12+ + _any_vec = jl.seval("(args...) -> Any[args...]") + jl.seval(""" + function _jumpy_scalar_affine(terms, constant) + return MOI.ScalarAffineFunction(terms, constant) + end + + function _jumpy_affine_term(coef, var) + return MOI.ScalarAffineTerm(coef, var) + end + + function _jumpy_set_objective!(optimizer, sense, func) + MOI.set(optimizer, MOI.ObjectiveSense(), sense) + F = typeof(func) + MOI.set(optimizer, MOI.ObjectiveFunction{F}(), func) + end + """) jl.seval(""" function _jumpy_add_variables!(optimizer, count, lower, upper) vars = MOI.add_variables(optimizer, count) @@ -66,15 +84,17 @@ def _define_helpers(jl): MOI.add_constraint(optimizer, func, set) end - function _jumpy_make_generator(func, iterators) - return GenOpt.FunctionGenerator{typeof(func)}(func, iterators) + function _jumpy_make_generator(func, iterators, target_type) + return GenOpt.FunctionGenerator{target_type}(func, iterators) end function _jumpy_create_optimizer() - return MOI.instantiate( + optimizer = MOI.instantiate( MOI.OptimizerWithAttributes(HiGHS.Optimizer, "output_flag" => false), with_bridge_type = Float64, ) + MOI.Bridges.add_bridge(optimizer, GenOpt.FunctionGeneratorBridge{Float64}) + return optimizer end function _jumpy_get_solution(optimizer, vars) @@ -120,10 +140,8 @@ def build_moi_model(jl, model: Model) -> list[float]: if model._objective.sense == "min" else jl.MOI.MAX_SENSE ) - jl.MOI.set(optimizer, jl.MOI.ObjectiveSense(), sense) obj_func = _expr_to_moi(jl, all_jl_vars, model._objective.expr) - obj_type = jl.typeof(obj_func) - jl.MOI.set(optimizer, jl.MOI.ObjectiveFunction(obj_type), obj_func) + jl._jumpy_set_objective_b(optimizer, sense, obj_func) # Optimize and extract solution jl.MOI.optimize_b(optimizer) @@ -131,7 +149,24 @@ def build_moi_model(jl, model: Model) -> list[float]: # Flatten all variable blocks into one solution vector all_vars_flat = jl.seval("vcat")(*(v for v in all_jl_vars)) jl_solution = jl._jumpy_get_solution(optimizer, all_vars_flat) - return [float(jl_solution[i]) for i in range(1, len(jl_solution) + 1)] + return [float(jl_solution[i]) for i in range(len(jl_solution))] + + +def _is_linear_template(expr) -> bool: + """Check if a template expression is linear (no nonlinear functions).""" + match expr: + case Constant() | Variable() | Iterator() | IndexedVariable() | IndexedParameter(): + return True + case BinaryOp(op=op, left=left, right=right): + if op in ("+", "-", "*"): + return _is_linear_template(left) and _is_linear_template(right) + return False + case UnaryOp(op="-", arg=arg): + return _is_linear_template(arg) + case Func(): + return False + case _: + return False def _add_constraint_group(jl, optimizer, all_jl_vars, model, group): @@ -158,8 +193,14 @@ def _add_constraint_group(jl, optimizer, all_jl_vars, model, group): jl, all_jl_vars, model, normalized, genopt_iterators, iter_id_map, ) + # Determine target function type: affine if template is linear, else nonlinear + if _is_linear_template(group.template.lhs) and _is_linear_template(group.template.rhs): + target_type = jl.seval("MOI.ScalarAffineFunction{Float64}") + else: + target_type = jl.seval("MOI.ScalarNonlinearFunction") + # Wrap in FunctionGenerator and add constraint — all in Julia - func_gen = jl._jumpy_make_generator(template_func, genopt_iterators) + func_gen = jl._jumpy_make_generator(template_func, genopt_iterators, target_type) jl._jumpy_add_constraint_group_b(optimizer, func_gen, group.template.sense) @@ -169,7 +210,7 @@ def _get_jl_var(jl, all_jl_vars, var_index, model): for block_idx, block in enumerate(model._var_blocks): if var_index < offset + block.count: local_idx = var_index - offset - return all_jl_vars[block_idx][local_idx + 1] # 1-based + return all_jl_vars[block_idx][local_idx] # PythonCall uses 0-based indexing offset += block.count raise IndexError(f"Variable index {var_index} out of range") @@ -196,13 +237,13 @@ def _expr_to_moi_template(jl, all_jl_vars, model, expr, genopt_iterators, iter_i case BinaryOp(op=op, left=left, right=right): l = _expr_to_moi_template(jl, all_jl_vars, model, left, genopt_iterators, iter_id_map) r = _expr_to_moi_template(jl, all_jl_vars, model, right, genopt_iterators, iter_id_map) - return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), jl.Any[l, r]) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), _any_vec(l, r)) case UnaryOp(op="-", arg=arg): a = _expr_to_moi_template(jl, all_jl_vars, model, arg, genopt_iterators, iter_id_map) - return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[a]) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), _any_vec(a)) case Func(name=name, arg=arg): a = _expr_to_moi_template(jl, all_jl_vars, model, arg, genopt_iterators, iter_id_map) - return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), jl.Any[a]) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), _any_vec(a)) case Iterator() as it: jl_idx = iter_id_map[it.id] return jl.GenOpt.IteratorIndex(jl_idx) @@ -213,10 +254,10 @@ def _expr_to_moi_template(jl, all_jl_vars, model, expr, genopt_iterators, iter_i ) # 0-based Python → 1-based Julia index_1based = jl.MOI.ScalarNonlinearFunction( - jl.Symbol("+"), jl.Any[index_expr, 1], + jl.Symbol("+"), _any_vec(index_expr, 1), ) return jl.MOI.ScalarNonlinearFunction( - jl.Symbol("getindex"), jl.Any[contiguous, index_1based], + jl.Symbol("getindex"), _any_vec(contiguous, index_1based), ) case IndexedParameter() as ip: jl_values = jl.seval("collect")(ip.parameter.values) @@ -224,47 +265,119 @@ def _expr_to_moi_template(jl, all_jl_vars, model, expr, genopt_iterators, iter_i jl, all_jl_vars, model, ip.index_expr, genopt_iterators, iter_id_map, ) index_1based = jl.MOI.ScalarNonlinearFunction( - jl.Symbol("+"), jl.Any[index_expr, 1], + jl.Symbol("+"), _any_vec(index_expr, 1), ) return jl.MOI.ScalarNonlinearFunction( - jl.Symbol("getindex"), jl.Any[jl_values, index_1based], + jl.Symbol("getindex"), _any_vec(jl_values, index_1based), ) case _: raise TypeError(f"Cannot convert {type(expr).__name__} to MOI template") +def _get_jl_var_by_index(jl, all_jl_vars, idx): + """Get Julia MOI.VariableIndex for a Python variable by global index.""" + offset = 0 + for block_idx, block_vars in enumerate(all_jl_vars): + block_size = int(jl.length(block_vars)) + if idx < offset + block_size: + return block_vars[idx - offset] # PythonCall uses 0-based indexing + offset += block_size + raise IndexError(f"Variable index {idx} out of range") + + +def _collect_linear_terms(expr, terms, sign=1.0): + """ + Try to decompose expr into linear terms: list of (coef, var_index) + constant. + Returns (success, constant). + """ + match expr: + case Constant(value=v): + return True, v * sign + case Variable(index=idx): + terms.append((sign, idx)) + return True, 0.0 + case BinaryOp(op="+", left=left, right=right): + terms_before = len(terms) + ok_l, const_l = _collect_linear_terms(left, terms, sign) + if not ok_l: + del terms[terms_before:] + return False, 0.0 + ok_r, const_r = _collect_linear_terms(right, terms, sign) + if not ok_r: + del terms[terms_before:] + return False, 0.0 + return True, const_l + const_r + case BinaryOp(op="-", left=left, right=right): + terms_before = len(terms) + ok_l, const_l = _collect_linear_terms(left, terms, sign) + if not ok_l: + del terms[terms_before:] + return False, 0.0 + ok_r, const_r = _collect_linear_terms(right, terms, -sign) + if not ok_r: + del terms[terms_before:] + return False, 0.0 + return True, const_l + const_r + case BinaryOp(op="*", left=Constant(value=v), right=right): + return _collect_linear_terms(right, terms, sign * v) + case BinaryOp(op="*", left=left, right=Constant(value=v)): + return _collect_linear_terms(left, terms, sign * v) + case UnaryOp(op="-", arg=arg): + return _collect_linear_terms(arg, terms, -sign) + case _: + return False, 0.0 + + +def _expr_to_moi_linear(jl, all_jl_vars, expr): + """ + Try to convert expr to ScalarAffineFunction. Returns None if nonlinear. + """ + terms = [] + ok, constant = _collect_linear_terms(expr, terms) + if not ok: + return None + + jl_terms = jl.seval("MOI.ScalarAffineTerm{Float64}[]") + for coef, var_idx in terms: + jl_var = _get_jl_var_by_index(jl, all_jl_vars, var_idx) + jl.push_b(jl_terms, jl._jumpy_affine_term(float(coef), jl_var)) + + return jl._jumpy_scalar_affine(jl_terms, float(constant)) + + def _expr_to_moi(jl, all_jl_vars, expr): """Convert a Python Expr to a concrete Julia MOI function (no iterators).""" + # Try linear first + linear = _expr_to_moi_linear(jl, all_jl_vars, expr) + if linear is not None: + return linear + match expr: case Constant(value=v): return v case Variable(index=idx): - # For objectives/individual constraints, find the variable - offset = 0 - for block_idx, block_vars in enumerate(all_jl_vars): - block_size = int(jl.length(block_vars)) - if idx < offset + block_size: - return block_vars[idx - offset + 1] # 1-based - offset += block_size - raise IndexError(f"Variable index {idx} out of range") + return _get_jl_var_by_index(jl, all_jl_vars, idx) case BinaryOp(op=op, left=left, right=right): l = _expr_to_moi(jl, all_jl_vars, left) r = _expr_to_moi(jl, all_jl_vars, right) - return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), jl.Any[l, r]) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(op), _any_vec(l, r)) case UnaryOp(op="-", arg=arg): a = _expr_to_moi(jl, all_jl_vars, arg) - return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), jl.Any[a]) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol("-"), _any_vec(a)) case Func(name=name, arg=arg): a = _expr_to_moi(jl, all_jl_vars, arg) - return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), jl.Any[a]) + return jl.MOI.ScalarNonlinearFunction(jl.Symbol(name), _any_vec(a)) case _: raise TypeError(f"Cannot convert {type(expr).__name__} to MOI") def _add_individual_constraint(jl, optimizer, all_jl_vars, con): """Add a single non-grouped constraint.""" - lhs_func = _expr_to_moi(jl, all_jl_vars, con.lhs) - rhs_func = _expr_to_moi(jl, all_jl_vars, con.rhs) + # Normalize: lhs - rhs in {set} + from jumpy.expressions import BinaryOp as _BinaryOp, Constant as _Constant + normalized = con.lhs - con.rhs + + func = _expr_to_moi(jl, all_jl_vars, normalized) if con.sense == "<=": set_ = jl.MOI.LessThan(0.0) @@ -275,7 +388,4 @@ def _add_individual_constraint(jl, optimizer, all_jl_vars, con): else: raise ValueError(f"Unknown constraint sense: {con.sense}") - func = jl.MOI.ScalarNonlinearFunction( - jl.Symbol("-"), jl.Any[lhs_func, rhs_func], - ) - jl.MOI.add_constraint(optimizer, func, set_) + jl.MOI.Utilities.normalize_and_add_constraint(optimizer, func, set_)