Derive a Cast's refinements from its term instead of overwriting the target - #78
Open
dpmills wants to merge 1 commit into
Open
Derive a Cast's refinements from its term instead of overwriting the target#78dpmills wants to merge 1 commit into
Cast's refinements from its term instead of overwriting the target#78dpmills wants to merge 1 commit into
Conversation
Contributor
Author
|
This change is part of the following stack:
Change managed by git-spice. |
This was referenced Aug 11, 2026
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 11, 2026 22:34
3b73c4e to
92f97be
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 12, 2026 00:09
92f97be to
a46740f
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 12, 2026 00:38
a46740f to
193bc2f
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 12, 2026 21:00
193bc2f to
9da26b7
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 12, 2026 22:36
9da26b7 to
cedc80e
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 12, 2026 22:45
cedc80e to
7bd7b54
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 13, 2026 05:39
7bd7b54 to
0148d4f
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 13, 2026 06:01
0148d4f to
b92238a
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 19, 2026 23:06
a31b7d6 to
66bc8ba
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 19, 2026 23:34
66bc8ba to
ceac235
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 20, 2026 00:12
ceac235 to
5ebd593
Compare
This was referenced Aug 20, 2026
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 20, 2026 20:51
5ebd593 to
8d0aae4
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 20, 2026 23:24
8d0aae4 to
9c08283
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 04:29
9c08283 to
6240195
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 04:39
6240195 to
403338f
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 04:55
403338f to
0097a8a
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 05:47
0097a8a to
30d78a7
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 18:41
30d78a7 to
f3a91be
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 19:42
f3a91be to
d6eed5e
Compare
dpmills
force-pushed
the
dmills/canonical-discharge
branch
from
August 21, 2026 20:45
d6eed5e to
b28bfca
Compare
…e `target`
**A rebuild pass resolves a cast's bases; it never decides its refinements.** Four passes decided them, each by stamping a type derived from the surrounding term onto a cast slot, and each producing two copies of one refinement that render alike and refuse to dedup.
`coalesce_node` overwrote a `Cast`'s `target` wholesale with the occurrence's coalesced view (`expr.ty`). Which refinements an occurrence's variable accumulates depends on the route bounds took through the graph, so two copies of one embedded cast — a comprehension source cloned into a filter predicate has its own variable per copy — coalesce carrying different refinement sets: one bare, one decorated with a sibling layer's filter. Refinement equality is deliberately cast-target-aware, so the copies refused to dedup, and which copy a position ended up holding depended on bound arrival order.
The overwrite becomes `canonical_cast_ty`: the coalesced view contributes its *shape and bases*; the refinements are determined by the *term*. A cast is an assertion — `cast(value, {D | p} ⇒ V)` asserts exactly `p` on top of whatever its value already established — so its `target` keeps its **born refinements** (fixed when lowering wrote the cast; inference resolves bases, never rewrites refinements), and the node's *type* is the value's own domain refinements joined with them (the walk is bottom-up, so the value's type is already canonical by induction). Target and type stay distinct: the post-inference check recomputes a cast's type as value-refinements ∪ target-refinements, and a target that also carried the value's refinements would double-book them — any divergence between the value's copy and the target's copy of one refinement surfaces as a duplicated refinement in the recomputation.
The other three sites, each the same rule applied later in the pipeline:
- `simplify`'s collapse-a-chain-to-one-element rewrite stamped the chain's interface type wholesale onto a surviving `Cast`. The interface type is derived from neighbour types, which are route-dependent where inference left route-dependent types in eq-blind slots (a lambda param annotation inside a still-pointful predicate), so the stamp desynced the cast's recorded type from its term-determined refinements. The rewrite now restores the canonical type at construction.
- `sync_cast_targets` (at the tails of `lambda_elim::run`, `planning::run`, and inlining) copied that route-dependent `expr.ty` into the cast's `target`, promoting a benign, eq-invisible divergence into the one slot `eq_refinement_predicate` deliberately compares. The pass is retired; `ccl_utils::canonicalize_cast_types` replaces it — `target` = born refinements on the rebuilt view's bases, `expr.ty` = value-refinements ∪ born, and a chain headed by a cast follows its head's domain when the two differ only in refinements on one base (the post-pass check's reconcile is exact on domain refinements, so the recorded chain type must track its head). `canonical_cast_ty` moves to `ccl_utils` and is shared with coalesce.
- `compile_refinement_predicates` compiled a cast target's refinements against the target's bare base. A target holds only the cast's *born* refinements, so its sole refinement compiled with a bare `__elem` stamp — failing the checker's argument edge once the predicate function's domain carries the value's refinements. Target refinements are assertions on the cast's **value**, so they now compile against the value's domain, matching `emit_cast`, which types target predicates with `__elem` bound at the value's domain.
**The desync is reachable and measured: 14 cast sites, in both physical refinement orders.** Instrumenting `canonical_cast_ty` to report when the term-determined set differs from the view the old code installed wholesale counts 14 over the corpus, forward and reversed alike — a nested filter is enough (`[a for a in [b for b in [1, 2, 3, 4] if b < 3] if a < 3]`, already in the suite). At each one the target had picked up the *value's* refinement in addition to its own born one: view `{[0,3] | b < 3, cast(…) ▷ a < 3}` against the term-determined `{cast(…) ▷ a < 3}`.
No inferred type changes, because the double-booking is currently absorbed rather than observed. The post-inference check recomputes a cast's type as value-refinements ∪ target-refinements, and the target's extra copy of the value's refinement is `eq` to the value's own, so the set deduplicates it — the representation change below this commit is what makes the defect latent, and under the old nested-layer representation the same 14 sites produced a duplicated layer.
The failure mode is the copies not being `eq`. Refinement equality is deliberately cast-target-aware, so two vintages of one refinement do not dedup, and then the recomputation disagrees with the recorded type while printing the same. Instrumenting `RefinementSet::insert` for a member that renders alike without being `eq` counts zero over the corpus in either order at this commit and below it, so that surface is currently unreached — a refinement's binding is its index rather than its spelling once the telescope coordinate is beneath the stack. The 14 sites are the reachable half, and removing the double-booking is what keeps the unreachable half unreachable.
`a_cast_target_does_not_carry_its_value_s_refinements` pins it: the nested filter above, compiled, with every cast asserted to carry no refinement its value already establishes. It fails on the parent commit, naming the doubled predicate.
Review guide: `ccl_utils::canonical_cast_ty` is the rule, and the four call sites are the change. `predicates.rs` also gains `compile_refinements`, extracting the per-refinement compile loop so a cast target and an ordinary refined domain share it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A rebuild pass resolves a cast's bases; it never decides its refinements. Four passes decided them, each by stamping a type derived from the surrounding term onto a cast slot, and each producing two copies of one refinement that render alike and refuse to dedup.
coalesce_nodeoverwrote aCast'stargetwholesale with the occurrence's coalesced view (expr.ty). Which refinements an occurrence's variable accumulates depends on the route bounds took through the graph, so two copies of one embedded cast — a comprehension source cloned into a filter predicate has its own variable per copy — coalesce carrying different refinement sets: one bare, one decorated with a sibling layer's filter. Refinement equality is deliberately cast-target-aware, so the copies refused to dedup, and which copy a position ended up holding depended on bound arrival order.The overwrite becomes
canonical_cast_ty: the coalesced view contributes its shape and bases; the refinements are determined by the term. A cast is an assertion —cast(value, {D | p} ⇒ V)asserts exactlypon top of whatever its value already established — so itstargetkeeps its born refinements (fixed when lowering wrote the cast; inference resolves bases, never rewrites refinements), and the node's type is the value's own domain refinements joined with them (the walk is bottom-up, so the value's type is already canonical by induction). Target and type stay distinct: the post-inference check recomputes a cast's type as value-refinements ∪ target-refinements, and a target that also carried the value's refinements would double-book them — any divergence between the value's copy and the target's copy of one refinement surfaces as a duplicated refinement in the recomputation.The other three sites, each the same rule applied later in the pipeline:
simplify's collapse-a-chain-to-one-element rewrite stamped the chain's interface type wholesale onto a survivingCast. The interface type is derived from neighbour types, which are route-dependent where inference left route-dependent types in eq-blind slots (a lambda param annotation inside a still-pointful predicate), so the stamp desynced the cast's recorded type from its term-determined refinements. The rewrite now restores the canonical type at construction.sync_cast_targets(at the tails oflambda_elim::run,planning::run, and inlining) copied that route-dependentexpr.tyinto the cast'starget, promoting a benign, eq-invisible divergence into the one sloteq_refinement_predicatedeliberately compares. The pass is retired;ccl_utils::canonicalize_cast_typesreplaces it —target= born refinements on the rebuilt view's bases,expr.ty= value-refinements ∪ born, and a chain headed by a cast follows its head's domain when the two differ only in refinements on one base (the post-pass check's reconcile is exact on domain refinements, so the recorded chain type must track its head).canonical_cast_tymoves toccl_utilsand is shared with coalesce.compile_refinement_predicatescompiled a cast target's refinements against the target's bare base. A target holds only the cast's born refinements, so its sole refinement compiled with a bare__elemstamp — failing the checker's argument edge once the predicate function's domain carries the value's refinements. Target refinements are assertions on the cast's value, so they now compile against the value's domain, matchingemit_cast, which types target predicates with__elembound at the value's domain.The desync is reachable and measured: 14 cast sites, in both physical refinement orders. Instrumenting
canonical_cast_tyto report when the term-determined set differs from the view the old code installed wholesale counts 14 over the corpus, forward and reversed alike — a nested filter is enough ([a for a in [b for b in [1, 2, 3, 4] if b < 3] if a < 3], already in the suite). At each one the target had picked up the value's refinement in addition to its own born one: view{[0,3] | b < 3, cast(…) ▷ a < 3}against the term-determined{cast(…) ▷ a < 3}.No inferred type changes, because the double-booking is currently absorbed rather than observed. The post-inference check recomputes a cast's type as value-refinements ∪ target-refinements, and the target's extra copy of the value's refinement is
eqto the value's own, so the set deduplicates it — the representation change below this commit is what makes the defect latent, and under the old nested-layer representation the same 14 sites produced a duplicated layer.The failure mode is the copies not being
eq. Refinement equality is deliberately cast-target-aware, so two vintages of one refinement do not dedup, and then the recomputation disagrees with the recorded type while printing the same. InstrumentingRefinementSet::insertfor a member that renders alike without beingeqcounts zero over the corpus in either order at this commit and below it, so that surface is currently unreached — a refinement's binding is its index rather than its spelling once the telescope coordinate is beneath the stack. The 14 sites are the reachable half, and removing the double-booking is what keeps the unreachable half unreachable.a_cast_target_does_not_carry_its_value_s_refinementspins it: the nested filter above, compiled, with every cast asserted to carry no refinement its value already establishes. It fails on the parent commit, naming the doubled predicate.Review guide:
ccl_utils::canonical_cast_tyis the rule, and the four call sites are the change.predicates.rsalso gainscompile_refinements, extracting the per-refinement compile loop so a cast target and an ordinary refined domain share it.