Skip to content

fix(pool): dispatch-drive the flatten walk, fix vacuous MomentLeaf guard (#352) - #363

Closed
seabbs-bot wants to merge 4 commits into
mainfrom
fix/352-codec-inference-hardening
Closed

fix(pool): dispatch-drive the flatten walk, fix vacuous MomentLeaf guard (#352)#363
seabbs-bot wants to merge 4 commits into
mainfrom
fix/352-codec-inference-hardening

Conversation

@seabbs-bot

@seabbs-bot seabbs-bot commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

What changed

Two follow-ups from the S3 inference fix, per #352.

  1. _leaf_flatten_grouped/_leaf_flatten_walk_grouped (src/composers/Pool.jl) derived their walk order by calling leaf_param_names(leaf) at runtime and recursing over the result as a plain Tuple, relying on the whole call constant-folding so pname stayed a Core.Const at each recursion step. Converted to the Val{names}-driven dispatch recursion its duals (_leaf_extract in introspection.jl, _hyper_flatten_walk just below in the same file) already use: names is now taken from entry's own NamedTuple{names} type parameter (its keys are leaf_param_names(leaf) by construction — _leaf_entry_grouped built it that way) rather than a fresh runtime call.

  2. The MomentLeaf testitem (test/composers/codec_gen.jl, S3) carried @test isconcretetype(typeof(nt)). typeof of any runtime value is always concrete, so this assertion could never fail regardless of whether the call that produced nt inferred concretely. Replaced with @inferred unflatten(...), matching the S2 parity item, and added @inferred to the flatten call too (it had no inference assertion at all) — inference is now asserted in both codec directions.

Why

For every built-in leaf, param_names returns a literal tuple, so _leaf_flatten_walk_grouped's constant-folding gamble happened to pay off — @inferred passed on every existing pooling/S2 fixture, which is why nothing caught this. It only breaks for a leaf whose native parameter names come from anything the compiler can't fold to a compile-time constant (a Ref read, a field behind a branch — a realistic third-party Distributions.jl leaf that just implements the plain contract, not anything CD-specific). I reproduced this with such a leaf under a pool(...) group: @code_warntype flatten(tree, nt) showed Body::ANY before the fix, Body::Vector{Float64} after. Once pname's value stops being const, specs[pname] (indexing a heterogeneous NamedTuple by a non-literal Symbol) can't resolve which field it needs, and the whole walk — and flatten after it — silently widens. No error, just a boxed, Union/Any-typed result on what the Pool.jl comments already call the per-gradient hot path.

This is a pure robustness fix. The flat layout, order and values for every built-in-leaf tree are unchanged — confirmed byte-for-byte on the existing S2 parity fixtures and every pooling.jl test.

Breaking surface

None. No public API, export, or docstring changed. Internal function signatures changed (_leaf_flatten_grouped's entry parameter is now NamedTuple{names} instead of bare NamedTuple) but these are underscore-prefixed internals with no external callers.

Scope note

unflatten's dual (_leaf_entry_grouped/_leaf_walk_grouped) has the same runtime-name-walk shape and, on inspection, the same theoretical exposure (confirmed with the same adversarial leaf — @code_warntype unflatten(...) showed an abstract NamedTuple return before any change here). #352 names only the flatten-direction walker, so I left it alone and flagged it as a follow-up rather than expanding scope — will file a tracking issue.

Gates (this branch, Julia 1.12.6, local — CI is currently blocked on #359's [sources]/compat fix, unrelated to this PR; I unblocked instantiate locally with the same compat bump #359 makes, without committing it)

  • task test-fast (Pkg.test(test_args=["skip_quality"])): 1697/1697 pass, cold, twice in a row.
  • task test-quality (Pkg.test(test_args=["quality_only"])): 239/239 pass (Aqua, ExplicitImports, doctest, formatting, JET, extension ambiguities all included).
  • task format check: clean, no changes needed after the commits.
  • Full docs build not run — no export, docstring, or @example touched.
  • AD gradient suite (task test-ad) not run — outside this item's stated gates; the fix only affects flatten's return-type inference, not values, and reconstruct/update (the path AD differentiates through) are untouched.

Commits

Red/green TDD, as requested:

  1. test(pool): pin flatten-walk inference gap for non-foldable leaf names — adds the adversarial-leaf regression test; confirmed it fails against unfixed Pool.jl (return type Vector{Float64} does not match inferred return type Any).
  2. fix(pool): drive flatten-walk recursion off Val{names}, not a runtime Tuple — the fix; confirmed green.
  3. test(codec): assert @inferred on both codec directions in the S3 leaf test — the MomentLeaf hardening (unrelated to the Pool.jl bug — MomentLeaf's param_names is a literal tuple, so this was never exposed to the gap; pure test strengthening).

Orthogonal to #359/#346/#362/#343 — only touches src/composers/Pool.jl and two test files, no conflict expected.

This was opened by a bot. Please ping @seabbs for any questions.

Closes #352.

#352)

_leaf_flatten_grouped/_leaf_flatten_walk_grouped (Pool.jl) call
leaf_param_names(leaf) at runtime and recurse over the result as a
plain Tuple. Every built-in leaf's param_names is a literal tuple, so
this happens to constant-fold and @inferred passes today -- which is
why the existing pooling and S2 codec-parity tests never caught the
gap.

Add a leaf whose param_names comes from a Ref read (not effect-free,
so it can't fold even though its TYPE still infers concretely) inside
a pooled tree. @inferred flatten fails: "return type Vector{Float64}
does not match inferred return type Any". Red, on purpose -- the fix
lands next.
… Tuple (#352)

_leaf_flatten_grouped/_leaf_flatten_walk_grouped derived their walk
order by calling leaf_param_names(leaf) at runtime and recursing over
the result as a plain Tuple, hoping the whole call constant-folded so
`pname` stayed a Core.Const at each step. It does, for every built-in
leaf (param_names returns a literal tuple), which is why nothing
caught this. A leaf whose native names come from anything less
trivially foldable (a Ref, a field behind a branch -- a realistic
third-party Distributions.jl leaf) breaks the fold: `specs[pname]`
can no longer resolve which field of the heterogeneous spec
NamedTuple it needs, and the walk -- and `flatten` after it --
silently widens to Union/Any. Same class of bug _hyper_flatten_walk
was already fixed for one level down (see its comment); this was the
one remaining flatten-direction walker still on the old pattern.

Fix: derive `names` from `entry`'s own NamedTuple{names} type
parameter (entry's keys ARE leaf_param_names(leaf) by construction --
_leaf_entry_grouped built it that way) and dispatch the whole
recursion on Val{names}/Val{speckeys}/Val{pool_names}/Val{materialize},
mirroring _leaf_extract (introspection.jl) and _hyper_flatten_walk
(this file). `pname` is then part of the TYPE at every step, so it is
a Core.Const regardless of whether leaf_param_names itself folds.

Measured: @inferred flatten(tree, nt) on a pooled tree over the
adversarial leaf now infers Vector{Float64} (was: Any). Every
existing pooling/codec test still passes unchanged -- this is a pure
robustness fix, not a behaviour change; the built-in-leaf flat
layout, order and values are untouched (confirmed byte-for-byte on
the S2 parity fixtures).

unflatten's dual (_leaf_entry_grouped/_leaf_walk_grouped) has the
same shape and the same theoretical exposure but is out of this
item's scope (#352 names only the flatten-direction walker); flagged
as a follow-up.
… test (#352)

`isconcretetype(typeof(nt))` can never fail -- `typeof` of any runtime
value is always concrete, whether or not the CALL that produced it
inferred concretely. Replace it with `@inferred unflatten(...)`,
matching the S2 parity item just above, and add `@inferred` to the
`flatten` call too (it had no inference assertion at all). Both pass
unchanged: MomentLeaf's param_names is a literal tuple, so this item
was never exposed to the runtime-name-walk gap the Pool.jl fix
addresses; this is pure test hardening, not a bug fix.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Try this Pull Request!

Option 1: Julia Package Manager

Open Julia and type:

import Pkg
Pkg.activate(temp=true)
Pkg.add(url="https://github.com/EpiAware/ComposedDistributions.jl", rev="fix/352-codec-inference-hardening")
using ComposedDistributions

Option 2: Local Checkout

If you have the repo locally:

git checkout fix/352-codec-inference-hardening
julia --project=. -e "using Pkg; Pkg.instantiate()"

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/composers/Pool.jl 87.50% 1 Missing ⚠️
Flag Coverage Δ
ad-enzyme-forward 0.00% <0.00%> (ø)
ad-enzyme-reverse 13.64% <0.00%> (+0.01%) ⬆️
ad-forwarddiff 12.07% <0.00%> (+0.01%) ⬆️
ad-mooncake-forward 0.00% <0.00%> (ø)
ad-mooncake-reverse 12.26% <0.00%> (+0.01%) ⬆️
ad-reversediff 12.07% <0.00%> (+0.01%) ⬆️
unit 86.87% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/composers/Pool.jl 88.83% <87.50%> (+1.11%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Documentation preview is ready!

View the docs for this PR at: https://EpiAware.github.io/ComposedDistributions.jl/previews/PR363/

This preview will be updated automatically when you push new commits.

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark comparison vs base

Minimum time per call. Buckets are PR time as a % of base, so lower is faster (🟢 faster, ⚪ within 5%, 🔴 slower). Counts of benchmarks per bucket:

Group 🟢 <50% 🟢 50–75% 🟢 75–95% ⚪ 95–105% 🔴 105–125% 🔴 125–150% 🔴 >150%
Evaluation · · · · · · 17
ForwardDiff · · · · · · 9
ReverseDiff (tape) · · · · · · 9
Mooncake reverse · · · · · · 9
Enzyme reverse · · · · · · 9
Evaluation — 17 benchmarks (by time change)
Benchmark base PR time memory
Composition / Sequential / rand 650.0 ns 1.22 μs 🔴 1.88× ⚪ 1.0×
Composition / Nested / rand 2.31 μs 4.33 μs 🔴 1.87× ⚪ 1.0×
Composition / Parallel / rand 651.0 ns 1.21 μs 🔴 1.86× ⚪ 1.0×
Composition / Parallel / logpdf 60.0 ns 110.0 ns 🔴 1.83× ⚪ 1.0×
Composition / Sequential / logpdf 60.0 ns 110.0 ns 🔴 1.83× ⚪ 1.0×
Composition / Sequential / construct 741.0 ns 1.35 μs 🔴 1.82× ⚪ 1.0×
Composition / Nested / logpdf 911.0 ns 1.6 μs 🔴 1.76× ⚪ 1.0×
Composition / Parallel / construct 771.0 ns 1.35 μs 🔴 1.75× ⚪ 1.0×
Composition / Compete / logccdf 200.0 ns 350.0 ns 🔴 1.75×
Composition / Resolve / logpdf 80.0 ns 140.0 ns 🔴 1.75×
Composition / Nested / compose 1.55 μs 2.6 μs 🔴 1.68× ⚪ 1.0×
Composition / Resolve / rand 280.0 ns 460.0 ns 🔴 1.64× ⚪ 1.0×
Composition / Compete / rand 300.0 ns 480.0 ns 🔴 1.6× ⚪ 1.0×
Composition / Choose / construct 20.0 ns 30.0 ns 🔴 1.5×
Composition / Choose / logpdf 40.0 ns 60.0 ns 🔴 1.5×
Composition / Compete / construct 20.0 ns 30.0 ns 🔴 1.5×
Composition / Resolve / construct 20.0 ns 30.0 ns 🔴 1.5×
AD gradients — 36 benchmarks (by time change)
Benchmark base PR time memory
AD gradients / Censored leaf marginal logpdf / ForwardDiff 6.25 μs 14.7 μs 🔴 2.35× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / ReverseDiff (tape) 7.01 μs 13.97 μs 🔴 1.99× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / Mooncake reverse 80.45 μs 159.61 μs 🔴 1.98× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / Enzyme reverse 9.37 μs 18.38 μs 🔴 1.96× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / Mooncake reverse 150.34 μs 292.08 μs 🔴 1.94× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / ReverseDiff (tape) 13.01 μs 25.1 μs 🔴 1.93× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / ReverseDiff (tape) 25.52 μs 48.48 μs 🔴 1.9× ⚪ 1.0×
AD gradients / Censored leaf marginal logpdf / Mooncake reverse 174.28 μs 329.58 μs 🔴 1.89× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / ReverseDiff (tape) 6.56 μs 12.35 μs 🔴 1.88× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / ReverseDiff (tape) 14.6 μs 27.36 μs 🔴 1.87× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / ReverseDiff (tape) 33.47 μs 62.2 μs 🔴 1.86× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / ReverseDiff (tape) 28.94 μs 53.34 μs 🔴 1.84× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / Mooncake reverse 9.24 μs 16.98 μs 🔴 1.84× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / Enzyme reverse 11.64 μs 21.13 μs 🔴 1.82× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / Enzyme reverse 11.77 μs 21.35 μs 🔴 1.81× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / Enzyme reverse 54.91 μs 99.61 μs 🔴 1.81× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / ForwardDiff 3.64 μs 6.59 μs 🔴 1.81× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / ForwardDiff 2.18 μs 3.95 μs 🔴 1.81× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / Mooncake reverse 28.17 μs 50.89 μs 🔴 1.81× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / ForwardDiff 540.0 ns 971.0 ns 🔴 1.8× ⚪ 1.0×
AD gradients / Censored leaf marginal logpdf / Enzyme reverse 67.03 μs 120.15 μs 🔴 1.79× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / Mooncake reverse 11.84 μs 21.17 μs 🔴 1.79× ⚪ 1.0×
AD gradients / Censored leaf marginal logpdf / ReverseDiff (tape) 15.51 μs 27.58 μs 🔴 1.78× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / Mooncake reverse 48.76 μs 86.68 μs 🔴 1.78× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / ForwardDiff 390.0 ns 691.0 ns 🔴 1.77× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / Mooncake reverse 49.43 μs 87.12 μs 🔴 1.76× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / ReverseDiff (tape) 6.61 μs 11.54 μs 🔴 1.75× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / ForwardDiff 3.86 μs 6.73 μs 🔴 1.75× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / ForwardDiff 4.44 μs 7.72 μs 🔴 1.74× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / Enzyme reverse 4.58 μs 7.89 μs 🔴 1.72× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / Mooncake reverse 54.45 μs 92.18 μs 🔴 1.69× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / Enzyme reverse 1.16 μs 1.91 μs 🔴 1.65× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / ForwardDiff 1.06 μs 1.73 μs 🔴 1.63× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / Enzyme reverse 1.95 μs 3.15 μs 🔴 1.61× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / Enzyme reverse 6.13 μs 9.8 μs 🔴 1.6× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / ForwardDiff 7.09 μs 10.89 μs 🔴 1.54× ⚪ 1.0×

@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Superseded by #381, which folds this and seven other PRs into one branch off main so the 0.2.0 contract reviews as a single diff. Its gates: test-fast 2379/2379, test-quality 255/255, full docs build clean.

This branch is preserved and the PR body is kept for its rationale — the detail here is the record of why, which #381 summarises rather than repeats. Closing so it does not compete for review.

This comment was posted by a bot. Please ping @seabbs for any questions.

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.

Codec inference hardening: dispatch-drive the remaining flatten walk, fix the vacuous MomentLeaf guard

2 participants