Skip to content

Add generator-comprehension syntax for Monoid - #727

Open
eb8680 wants to merge 15 commits into
staging-weightedfrom
eb-comprehension
Open

Add generator-comprehension syntax for Monoid#727
eb8680 wants to merge 15 commits into
staging-weightedfrom
eb-comprehension

Conversation

@eb8680

@eb8680 eb8680 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Gives Monoid a comprehension syntax, so a loop nest can be written the way you'd write it in Python and still be reduced symbolically.

Sum(f(x) * g(x, y) for x in xs for y in ys(x))

desugars to

Sum.reduce(f(x()) * g(x(), y()), {x: xs, y: ys(x())})

where x and y are fresh Operations standing for "an element of xs" and "an element of ys(x())".

How it works

Monoid.__call__ hands the generator object to effectful/internals/comprehension.py, which recovers the comprehension's syntax with the disassembler from the base PR and replays it against the monoid.

The interesting part is the typing, not the rewriting. Each loop target becomes an Operation whose return type is the element type of its stream, and that type has to be known before the target can be applied to anything in the body. Streams may also depend on earlier targets, as ys(x()) does, so element types are inferred one generator at a time, left to right: evaluate a stream, infer its element type, mint the target operation, bind it, then move on.

Some of what a comprehension may contain is syntax rather than operations. and, or, not and conditional expressions all ask their operands for a concrete bool, which a Term cannot supply, so they are rewritten into ite, which yields one of its arms outright when the condition is concrete and a term when it is not. Comparisons are left alone: numeric terms already implement == and < symbolically.

Changes outside the two new files

Three small, independent hunks, each reviewable on its own:

  1. Monoid.__call__ (effectful/ops/monoid.py) — the entry point, with a doctest. This is the only part of the diff that is the feature itself.

  2. ReduceDisequalityMask (effectful/ops/monoid.py) — an independent bugfix. It used or to pick the first non-None of two _neq_to_plus results, which asks a Term for its truthiness. Replaced with an explicit is None check, which is what the following if ret is not None already expected.

  3. _jax_args (effectful/handlers/jax/monoid.py)moved to Don't route all-scalar monoid ops through jax #729, which
    this branch is now stacked behind. These tests depend on it: jax.typing.ArrayLike
    is a union that includes bool/int/float/complex, and the JAX handlers extend
    the global EvaluateIntp at import time, so once anything imported
    effectful.handlers.jax.monoid a scalar comprehension body was silently narrowed to
    a float32 array. Six tests here (including two doctests) failed in a full-suite run
    but passed in isolation, purely on import order.

Nothing else from effectful/ops/monoid.py in #724 is included here — no ReduceGroundCartesianProduct, no inversion rewrite, no ReducePartial.unrolled. Those are separate branches.

Tests

tests/test_internals_comprehension.py: 734 passed, 5 xfailed.

The 5 xfails come from #724 as written and are not waiting on any sibling PR: a generator expression used as an inner stream (for y in (z for z in range(x))) raises NotImplementedError, marked strict=True. A list comprehension in the same position (for y in [z for z in range(2)]) does work and is tested.

Full suite (effectful/ tests/, excluding the LLM handler tests): 19704 passed, 2 skipped, 2085 xfailed, no failures. Doctests are on repo-wide via --doctest-modules, and the new Monoid.__call__ doctest passes both standalone and in a full run. ruff check and ruff format --diff are clean. mypy reports one pre-existing error in effectful/handlers/jax/monoid.py, present on staging-weighted and unrelated.

Split out of #724 for review. Stacked on #725, which is stacked on #729.
Review #729#725#727 in that order.

🤖 Generated with Claude Code

@eb8680
eb8680 marked this pull request as ready for review July 28, 2026 15:25
@eb8680
eb8680 marked this pull request as draft July 28, 2026 15:26
@eb8680 eb8680 added the blocked label Jul 28, 2026
@eb8680
eb8680 force-pushed the eb-comprehension branch 2 times, most recently from 4de7e7e to ad39d08 Compare July 28, 2026 15:37
Base automatically changed from eb-disassembly to staging-weighted July 28, 2026 19:54
@jfeser
jfeser changed the base branch from staging-weighted to master July 28, 2026 19:55
@jfeser
jfeser changed the base branch from master to staging-weighted July 28, 2026 19:55
@eb8680
eb8680 force-pushed the eb-comprehension branch from ad39d08 to 854fbc6 Compare July 29, 2026 00:05
@eb8680 eb8680 removed the blocked label Jul 29, 2026
@eb8680
eb8680 marked this pull request as ready for review July 29, 2026 00:07
jfeser and others added 14 commits July 30, 2026 11:43
* add monoid module

* clean up

* fix doctest

* fix

* wip

* remove incorrect rule

* add disjoint set tests and fix bug

* lint

* drop jax monoid defs

* drop incorrect comment

* add assert

* reduce nondeterminism and add assertions

* fix inconsistent stream numbering and missing constant factors
* Add monoid module (#653)

* add monoid module

* clean up

* fix doctest

* fix

* wip

* remove incorrect rule

* add disjoint set tests and fix bug

* lint

* drop jax monoid defs

* drop incorrect comment

* add assert

* reduce nondeterminism and add assertions

* fix inconsistent stream numbering and missing constant factors

* wip

* cleanup

* fix rule

* wip

* fix bug

* cleanup

* lin
* Add monoid module (#653)

* add monoid module

* clean up

* fix doctest

* fix

* wip

* remove incorrect rule

* add disjoint set tests and fix bug

* lint

* drop jax monoid defs

* drop incorrect comment

* add assert

* reduce nondeterminism and add assertions

* fix inconsistent stream numbering and missing constant factors

* wip

* cleanup

* fix rule

* wip

* fix bug

* cleanup

* lin

* wip

* fix tests

* format

* lint

* wip
* Add monoid module (#653)

* add monoid module

* clean up

* fix doctest

* fix

* wip

* remove incorrect rule

* add disjoint set tests and fix bug

* lint

* drop jax monoid defs

* drop incorrect comment

* add assert

* reduce nondeterminism and add assertions

* fix inconsistent stream numbering and missing constant factors

* wip

* cleanup

* wip

* fix rule

* wip

* fix bug

* cleanup

* lin

* wip

* fix tests

* format

* lint

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* drop runtime typed dict lifting

* wip

* format

* reorganize

* stop using string dicts to avoid unification issue

* wip

* wip

* wip

* wip

* wip

* use check_rewrite in jax tests

* lint

* fix bugs
* Add monoid module (#653)

* add monoid module

* clean up

* fix doctest

* fix

* wip

* remove incorrect rule

* add disjoint set tests and fix bug

* lint

* drop jax monoid defs

* drop incorrect comment

* add assert

* reduce nondeterminism and add assertions

* fix inconsistent stream numbering and missing constant factors

* wip

* cleanup

* wip

* wip

* fix rule

* wip

* fix bug

* cleanup

* lin

* wip

* fix tests

* format

* lint

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* drop runtime typed dict lifting

* wip

* format

* reorganize

* stop using string dicts to avoid unification issue

* wip

* wip

* wip

* wip

* wip

* use check_rewrite in jax tests

* lint

* wip

* fix bugs

* comment on not implemented cases

* format

* simplify

* lint

* add matmul test
* more precise stream type

* add tests for weighted rules

* add reduction rule for weighted streams and tests

* add test to demo expectation

* add numpyro monoid module

* add quadrature

* add tests

* wip

* refactor tests

* wip

* test composition of lifting and weighting

* drop numpyro changes

* drop unused ops

* lint

* make weighted a Monoid method

* fix typing of jax arrays

* change weighted typing to take callable

* fix test

* fix test

* resolve type aliases before dispatching

* wip

* wip

* remove typeof_full

* wip

* wip

* wip

* format

* refactor test harness

* drop unused test
* more agressive factorization that hoists shared streams

* reduce nesting

* comment

* replace with simpler push-based rule

* format

* drop unused disjoint set

* remove unused

* push multiple streams instead of one at a time
* more precise stream type

* add tests for weighted rules

* add reduction rule for weighted streams and tests

* add test to demo expectation

* add numpyro monoid module

* add quadrature

* add tests

* wip

* refactor tests

* wip

* test composition of lifting and weighting

* drop numpyro changes

* drop unused ops

* lint

* make weighted a Monoid method

* fix typing of jax arrays

* change weighted typing to take callable

* fix test

* fix test

* resolve type aliases before dispatching

* wip

* wip

* remove typeof_full

* wip

* wip

* wip

* format

* refactor test harness

* fix behavior of delta terms

* add baseline einsum

* rework einsum to work on shapes instead of concrete tensors

* add einsum benchmark

* wip

* wip

* finish sum/product contraction

* allow bind_dims to bind nonexistent named dimensions

* wip

* add custom partial eval for reductions

* working benchmarks

* fix infinite loop

* eliminate identity indexing when possible

* wip

* handle getitem where dimensions are created

* treat any index with bare ops and slice(None) as canonical

* simplify range op and add reduction rules

* wip

* remove old benchmark code

* another try at removing identity gathers

* refactor

* fix test

* lint

* clean up comment

* fix some test failures

* drop sketchy bind_dims rule

* drop more type-incompatible plus rules

* format

* fix reduction issue

* drop dimension creating behavior from bind_dims

* lint

* simplify comment

* drop partition

* fix docstring

* handle negative dimension indexing

* fix creation of empty tensors

* fully restore previous behavior for missing named dims

* reduce any arraylike or named tensor

* require at least one jax array to reduce

* fix typing test

* drop typing test

* drop einsum parser in favor of opt_einsum

* more agressive factorization that hoists shared streams

* reduce nesting

* comment

* replace with simpler push-based rule

* format

* drop unused disjoint set

* remove unused

* push multiple streams instead of one at a time

* drop contraction ordering handler

* fold BindDimsBindDims into default behavior

* handle Sum.reduce instead of Monoid.reduce

* wip

* wip

* hacks

* extract contraction heuristic

* lint

* fix test

* use a named dimension einsum for contractions

* lint

* drop custom arange op

* wip

* simplify by targetting delta rules

* wip

* fixes

* fixes

* lint

* drop unused

* pick up constants but not rest of module

* lint
* wip

* wip

* drop syntactic tests

* wip

* wip

* wip

* wip

* wip

* add cartesian product tests

* wip

* wip

* revise reducesplit to leave shared streams

* wip

* allow ReduceEqualityMaskRange to look through plus

* add mask hoisting

* wip

* wip

* wip

* unsupplied parameters stay bound in deffn

* wip

* wip

* wip

* allow factorization over masks

* don't do leave-one-out for factors with no output dims

* plated einsum tests pass

* format

* fix some tests

* fix tests

* more fixes and documentation

* passing plated einsum tests

* format

* simplify and generalize plusdistr

* normalize both order and duplicates in plus

* drop unused test

* push masks instead of hoisting

* stop emitting extra masks

* wip

* add missing code

* wip

* revert to simple ReduceSplit, handle plus bodies in cprod elim

* replace ReduceFactorization with new combined Factor

* wip

* give delta mapping semantics

* fix tests

* fix tests

* add ReduceEqualityMaskRange tests

* wip

* wip

* refactor einsum term generation

* wip

* wip

* use where as a hoistable conditional primitive

* drop binddimswhere

* fix tests and clean up

* avoid expensive traversal when looking up signatures

* fix tests

* remove ReduceCartesianWeightedStream

cartesian products changed type, making these rules invalid

* wip

* wip

* fix tests remove outdated

* lint

* drop test

* fix notebook

* drop 3.14 in CI

* drop 3.14

* wip

* restrict litellm

* reset ci scripts

* reset

* drop _ArrayTerm

* introduce ite op and move where handlers to ops/monoid.py

* wip

* remove SplitDisjointProduct

* wip

* move ReduceDependentRangeMask to ops/monoid.py

* move ContractLongestArrayStream to ops/monoid.py

* start generalizing ReduceDistributeCartesianProduct

* wip

* drop unused SumOfProductsIntp

* wip

* drop unused

* simplify

* more work

* lint

* update comment

* add tests to ReduceUnfactor

* drop unnecessary check

* fix bug

* fix tests

* format

* lint

* drop unused code

* fix tests

* replace Union.delta with a dict building op

* drop comment

* replace Union.delta with as_dict

* lint

* revert

* fix flipped mask

* require simple ranges in scan rule

* clean up collection types

* enforce simple range requirement in dependent range elim

* simplify typing in _EinsumBuilder

* lint

* add helper function _conjuncts

* extract jax-independent behavior

* fix test
`_jax_args` admitted `jax.typing.ArrayLike`, a union that includes `bool`,
`int`, `float` and `complex`, so the jax `Monoid.plus` handlers claimed
pure-Python scalar arithmetic. They extend `EvaluateIntp` after the scalar
implementations and so take precedence, silently narrowing a Python float
to a `float32` array and leaving downstream rules treating a scalar body as
array-valued. Require at least one genuine array.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Don't route all-scalar monoid ops through jax

`_jax_args` admitted `jax.typing.ArrayLike`, a union that includes `bool`,
`int`, `float` and `complex`, so the jax `Monoid.plus` handlers claimed
pure-Python scalar arithmetic. They extend `EvaluateIntp` after the scalar
implementations and so take precedence, silently narrowing a Python float
to a `float32` array and leaving downstream rules treating a scalar body as
array-valued. Require at least one genuine array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add a generator-expression bytecode disassembler

`effectful/internals/disassembly.py` symbolically interprets the bytecode
of a generator expression (and of lambdas and comprehensions nested inside
it) back into an `ast` node, so a comprehension's source syntax can be
recovered from the code object at runtime. Supports CPython 3.12 and 3.13.

Standalone: imports nothing from `effectful` and touches no existing code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review comments on the generator-expression disassembler

Six fixes, each with tests that fail without them:

- `handle_build_map` read the key/value pairs of a dict display from the
  top of the stack down, reversing source order: a later duplicate key
  lost to an earlier one, and side effects ran backwards.

- `_ensure_ast_tuple` treated any tuple whose first element was the
  string "dict_item" as an internal marker and dropped that element.
  Nothing produced such a marker; user data holding that string was
  silently corrupted. The special case is gone.

- A free variable was reconstructed as a bare `ast.Name`, so evaluating
  the result resolved it against the evaluating namespace instead of the
  captured cell. The captured value is now written into the tree, for
  the generator itself, for lambdas reached as live objects, and for
  lambdas and comprehensions nested inside. A cell the comprehension
  creates -- a target captured by a nested lambda -- still stands as a
  name, since the reconstruction binds it too. A capture with no AST
  spelling, including an iterator, raises `TypeError` rather than
  reconstructing to a name that would answer differently.

- `_ensure_ast_iterator_adaptor` ignored the strictness a `zip` pickles
  as reduction state, so a strict zip silently truncated ragged input
  where the original raised.

- A lambda reached as a live object lost its default values, which live
  on the function rather than in its code object, leaving parameters
  with no way to be filled.

- `disassemble` asserted on its input; it now raises `ValueError`, and
  checks the generator has not been started rather than leaving that to
  an assert further in.

Also documents what reconstruction does and does not recover: evaluating
the result re-runs every expression in it, so a stateful filter answers
against state as it then stands.

663 passed, 2 xfailed on 3.12, 3.13 and 3.14.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Sum(f(x) * g(x, y) for x in xs for y in ys(x))` now desugars to
`Sum.reduce(f(x()) * g(x(), y()), {x: xs, y: ys(x())})` with fresh
Operations standing for an element of each stream.
`effectful/internals/comprehension.py` recovers the comprehension's syntax
via the bytecode disassembler and rebuilds it as a reduction.

Also fixes `ReduceDisequalityMask`, which used `or` to pick the first
non-None of two `_neq_to_plus` results and so asked a `Term` for its
truthiness.

The `_jax_args` fix these tests depend on is no longer duplicated here; it
comes from the base branch `eb-jax-scalar-plus`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jfeser
jfeser force-pushed the eb-comprehension branch from 854fbc6 to 3ffb1dd Compare July 30, 2026 18:00
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.

2 participants