Skip to content

fix(#816): Expression shape/len/iter protocol + concatenate/stack - #820

Merged
jkitchin merged 3 commits into
mainfrom
claude/discopt-issue-816-hq28dp
Jul 21, 2026
Merged

fix(#816): Expression shape/len/iter protocol + concatenate/stack#820
jkitchin merged 3 commits into
mainfrom
claude/discopt-issue-816-hq28dp

Conversation

@jkitchin

@jkitchin jkitchin commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #816.

Two behaviors on modeling Expression objects.

Bug (the urgent part). Variable supported __getitem__ but not __len__/__iter__, and out-of-range indexing returned an expression instead of raising. Python then fell back to the legacy sequence-iteration protocol (x[0], x[1], … until IndexError), which never terminated — list(x) / sum(x) / np.array(x) wedged the process on any accidental iteration.

  • Expression gains __len__ (leading-axis length) and __iter__ (yields expr[0], expr[1], …); both raise TypeError for scalar or shape-unknown expressions instead of hanging. __iter__ is what actually kills the hang (Python no longer uses the sequence protocol).
  • Out-of-range integer indexing via the [] operator on a shaped (ndim ≥ 1) expression now raises IndexError (numpy semantics) — for a plain int or a full-arity all-int tuple that is out of range (x[99], X[i, j]). The guard lives on Expression.__getitem__; direct IndexExpression(base, idx) construction stays conservative (never raises), because the expression-graph internals (GAMS import, NL export resolver, signomial/sign-domain matching) build out-of-range / non-integer / too-many-index nodes as lazy forms resolved downstream (test_sign_domain_checks: IndexExpression(v, 10) "must be conservative, not raise"). Scalar-base indexing (x[0] on shape ()), slices, wrong-arity tuples, and non-integer keys stay lenient.

Enhancement. .shape now propagates through indexing/slicing, element-wise BinaryOp/UnaryOp, and element-wise FunctionCall (sqrt, exp, …), exposed as a read-only Expression.shape property that raises AttributeError when the shape is not statically known — so getattr(expr, "shape", default) / hasattr keep their prior behaviour for shape-unknown nodes (reductions, matmul, custom). dm.concatenate / dm.stack assemble a vector of scalar expressions from pieces (the method-of-lines "boundary, interior, boundary" pattern) as a numpy object ndarray — no new DAG node type, each element is an ordinary scalar expression the solver already handles.

The issue's shared momentum_residual now reuses verbatim across np.sqrt and dm.sqrt (a regression test pins this). Request #3 (np.sqrt(expr) via __array_ufunc__) is intentionally not in scope: it would require replacing the load-bearing __array_ufunc__ = None (which defers A @ x to __rmatmul__) with a handler that re-owns all ufunc dispatch including matmul — a correctness-sensitive marshaling change that belongs in its own bound-neutral issue.

Second commit (chore(types), unrelated to #816, requested in review) clears 31 pre-existing mypy errors in the solver stack (TypeAlias on VectorLike; -np.inffloat("-inf") for -> float returns; int()/bool() wraps). mypy python/discopt/ now reports 0 errors.

Correctness

No solver-math change: this is modeling-DSL metadata and Python-side iteration. The out-of-bounds guard only makes a provably invalid user-facing index (x[99]) fail loudly instead of building a silently-invalid node; it never rejects a valid model, and internal lazy IndexExpression construction is untouched (stays conservative). .shape inference is exact (numpy broadcast rules / the probe mirrors the compiler's base[index]) and never raises, so the M8 build-time shape guard fires only on genuinely incompatible shapes. getattr(con.body, "shape", ()) and similar consumers are unaffected (AttributeError preserves the default). End-to-end solves with shaped/sliced/sqrt expressions verified (objective correct).

  • Cannot produce a false certificate (N/A — no solver-math change)
  • No validation, fallback, or safety guard was weakened to make a check pass

Tests run (state the result — numbers, not adjectives)

  • pytest -m smoke → 817 passed, 15 skipped, 2 xpassed
  • Full Python fast CI lane green on ef889ae (an earlier revision failed 7 tests — GAMS/export/signomial/sign-domain — because the guard first sat in IndexExpression.__init__ and broke internal lazy construction; moving it to __getitem__ fixed all 7)
  • pytest -m slow python/tests/test_adversarial_recent_fixes.py → 9 passed, 1 flaky (test_large_dense_jacobian_no_crash trips its wall-clock deadline under load; passes standalone in 27s, scalar-only, unrelated to this change)
  • cargo test -p discopt-core → N/A (no Rust touched)
  • ruff check / ruff format --check clean; mypy python/discopt/ → 0 errors

Regression test

python/tests/test_expression_shape_iter_816.py (17 cases): OOB IndexError 1-D/2-D via [], scalar-indexing leniency, len/iter, no-hang builtins (list/sum/np.array), .shape propagation, concatenate/stack, and the issue's shared momentum-residual reused verbatim across numpy and discopt backends. Each fails before the change (hang / silent no-op / AttributeError) and passes after.

  • Added a regression test that fails before / passes after

Generated by Claude Code

claude added 3 commits July 21, 2026 01:13
Bug (the urgent part): ``Variable`` supported ``__getitem__`` but not
``__len__``/``__iter__``, and out-of-range indexing returned an
expression instead of raising. Python then fell back to the legacy
sequence-iteration protocol (``x[0]``, ``x[1]``, … until ``IndexError``),
which never terminated — ``list(x)``/``sum(x)``/``np.array(x)`` hung a
process on any accidental iteration.

- Out-of-range integer indexing on a *shaped* (ndim >= 1) expression now
  raises ``IndexError`` at build time (numpy semantics), via a
  zero-allocation broadcast probe that mirrors the JAX compiler's
  ``base[index]``. Scalar-base indexing (``x[0]`` on shape ``()``) keeps
  its pre-existing leniency — #816 is about array indexing, and the LLM
  model builder relies on scalar indexing.
- ``Expression`` gains ``__len__`` (leading-axis length) and ``__iter__``
  (yields ``expr[0]``, ``expr[1]``, …); both raise ``TypeError`` for
  scalar or shape-unknown expressions instead of hanging.

Enhancement: ``.shape`` now propagates through indexing/slicing,
element-wise ``BinaryOp``/``UnaryOp`` and element-wise ``FunctionCall``
(``sqrt``, ``exp``, …). Exposed as a read-only ``Expression.shape``
property that raises ``AttributeError`` when the shape is not statically
known, so ``getattr(expr, "shape", default)`` / ``hasattr`` keep their
prior behaviour for shape-unknown nodes (reductions, matmul, custom).

``dm.concatenate`` / ``dm.stack`` assemble a vector of scalar
expressions from pieces (method-of-lines "boundary, interior, boundary"
pattern) as a numpy object ndarray — no new DAG node type, each element
is an ordinary scalar expression the solver already handles.

Adds python/tests/test_expression_shape_iter_816.py (17 cases: OOB
IndexError 1-D/2-D, len/iter, no-hang builtins, scalar-indexing
leniency, shape propagation, concatenate/stack, and the issue's shared
momentum-residual reused verbatim across numpy and discopt backends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbM6bykXYYunrv3fygbzh5
All 31 are latent under ``warn_return_any``; unrelated to any behaviour.

- mip_nlp_rootsearch: ``VectorLike`` module alias annotated with
  ``TypeAlias`` so it is valid in annotation position (12 valid-type).
- mccormick_nlp / solver: ``return -np.inf`` -> ``return float("-inf")``
  where the enclosing function is declared ``-> float`` (``np.inf`` types
  as ``Any`` in the installed numpy stubs); value is identical (15
  no-any-return).
- mcbox / ellipsoidal_arith: wrap ``.shape[0]`` / ``.size`` in ``int()``
  for the ``-> int`` properties (2).
- sparsity / solver: wrap numpy comparisons in ``bool()`` for the
  ``-> bool`` returns (2).

``mypy python/discopt/`` now reports 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbM6bykXYYunrv3fygbzh5
…pression lenient)

CI surfaced a documented internal contract the eager guard broke: the
expression-graph machinery (GAMS import, NL export resolver, signomial /
sign-domain pattern matching) constructs ``IndexExpression(base, idx)``
with out-of-range, non-integer, and too-many-index values as lazy nodes
it resolves conservatively later — e.g. test_sign_domain_checks:
``bad = IndexExpression(v, 10)  # must be conservative, not raise``.

Fix by layering:
- ``IndexExpression.__init__`` / ``_index_result_shape`` are now purely
  best-effort shape inference and NEVER raise; any index numpy cannot
  resolve yields shape-unknown.
- The out-of-bounds IndexError moves to ``Expression.__getitem__`` (the
  user-facing ``[]`` operator) and fires only for a plain integer or a
  full-arity all-integer tuple that is out of range — the ``x[99]`` /
  ``X[i, j]`` typo the issue names. Slices, ellipsis, wrong-arity tuples,
  and non-integer keys stay lenient (the lazy internal forms).

Fixes the 7 CI failures (test_export_cli_roundtrip, test_gams ×4,
test_relax_compiler_convexity_units ×2) while preserving the issue's
user-facing guard; regression suite unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbM6bykXYYunrv3fygbzh5
@jkitchin
jkitchin merged commit 5cba2f0 into main Jul 21, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expression objects lack shape/iteration protocol, blocking array-style model code

2 participants