Add a generator-expression bytecode disassembler - #725
Conversation
`_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>
|
Test failure is spurious and unrelated. The tests for this PR are part of the "Test / core" step, which passed for all Python versions in the build matrix. This is ready for review. |
`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>
|
Per ChatGPT:
|
| cases. However, the semantic behavior of the reconstructed AST should | ||
| match the original comprehension. | ||
| """ | ||
| assert inspect.isgenerator(genexpr), "Input must be a generator expression" |
There was a problem hiding this comment.
This should raise a ValueError instead of asserting.
There was a problem hiding this comment.
Done in ccf7733. disassemble now raises ValueError for a non-generator, and also for a generator that has already been started — that check had been left to an assert further in, in _ensure_ast_genexpr. The docstring's Raises: section is updated, and test_error_handling covers all three cases.
|
I used some LLM time to look at this, but I can't provide meaningful review given its size. |
I think reviewing the implementation details wouldn't be so useful anyway. This code has a very slim interface (just |
|
A few more ChatGPT-discovered issues. The first one looks like it could lead to surprises depending on how exactly the disassembled generator is converted to a
|
|
I'm assuming we're ok with changing the semantics of generators with stateful predicates. We might make a documentation note about that though. |
|
Lambda default values are not preserved: |
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.
|
Thanks — all six are fixed in ccf7733. Each has a test that I confirmed fails against the pre-fix module. 1. Dict displays reconstructed in reverse order. 2. Tuples beginning with 3. Captured closure variables. Substituting the value, per your second suggestion. The bindings come from the generator's frame (an unstarted generator has its cells copied in already) or from The distinction you asked for is on Every closure test evaluates the reconstruction in a namespace binding the same name to something else, so a lookup that leaked out to globals fails the test — including the conflicting-global case you asked for, and both shadowing directions (a nested comprehension target and a lambda parameter that reuse a captured name). One case I had to rule out rather than support: a captured iterator. 4. 5. Lambda default values. Good catch — 6. Stateful predicates. Agreed, and documented. The module docstring has a new section on what reconstruction does and does not recover, covering all three ways the reconstruction can part company with the generator it came from: a stateful filter or element expression is re-run and can answer differently the second time; the outermost iterable is a snapshot of unconsumed elements rather than the expression that produced it; and a free variable is recovered by value. Tests: 663 passed, 2 xfailed on 3.12, 3.13 and 3.14 locally — so the two version paths the PR description flagged as CI-only are covered here too. |
* 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>
* 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>
Adds
effectful/internals/disassembly.py, a standalone bytecode disassembler for generator expressions, plus its test suite.What it does
The single public entry point is
disassemble(gen). Given a generator object, it symbolically interprets the bytecode of the underlying code object and reconstructs anastnode for the comprehension that produced it — its targets, its iterables, itsifguards, and the element expression — including lambdas and comprehensions nested inside the element expression.This lets a comprehension's source syntax be recovered from a code object at runtime, which is what the follow-up PR uses to give
Monoida comprehension syntax.No coupling
disassembly.pyimports nothing fromeffectful— only stdlib (ast,dis,types,inspect, ...). This PR adds two new files and changes no existing library code, so it is safe to land ahead of anything that consumes it. No packaging changes are needed:effectful/internals/is already a package.Version support
Bytecode layout is version-specific, so the module dispatches on
sys.version_info.minorthrough aPythonVersionenum covering 3.12, 3.13 and 3.14, and raisesNotImplementedErroron an unrecognised version rather than guessing that the previous release's opcodes still mean what they used to.Locally I could only exercise the 3.12 path (the dev interpreter here is 3.12.11); the 3.13 and 3.14 paths need CI to cover.
Tests
tests/test_internals_disassembler.py: 620 passed, 2 xfailed on 3.12.The two xfails are known limitations of reconstructing from bytecode, not regressions:
test_conditional_expression_as_iterable[<genexpr>3]— an empty list literal is indistinguishable from the start of a list comprehension.test_conditional_expression_as_iterable[<genexpr>6]— two conditional iterables in one comprehension leave paths that do not pairwise merge.The full suite (
effectful/ tests/, excluding the LLM handler tests) is unchanged and green: 18968 passed, 2 skipped, 2080 xfailed.ruff checkandruff format --diffare clean.mypyreports one pre-existing error ineffectful/handlers/jax/monoid.py:382, which is present onstaging-weightedand unrelated to this PR.Split out of #724 for review. Part of a stack: based on #729 (the
_jax_argsfix), and #727 (
eb-comprehension) builds on this. This PR's own diff is justthe two new files.
🤖 Generated with Claude Code