fix(beam): preserve function-value identity for PhysicalEquality#4762
Merged
Conversation
Fable-BEAM represents the same F# function value with different Erlang funs at different sites: sometimes uncurried (a single N-arity fun) and sometimes re-curried into a nested 1-arity adapter to satisfy a curried-typed slot. Erlang compares funs by closure identity, so these two representations are never `=:=` even though they denote one value, making LanguagePrimitives.PhysicalEquality wrongly return false (e.g. removing a stored callback by reference identity never matched). Make PhysicalEquality/ReferenceEquals currying-agnostic via a new fable_utils:fun_ref_eq/2 that unwraps curry adapters (the only 1-arity fun capturing exactly one function) to the underlying fun before comparing. This does not degrade into structural equality: genuinely distinct closures stay unequal, and non-function values keep plain `=:=`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cd3769f to
9e93f4b
Compare
The prior PhysicalEquality fix unwrapped 1-arity curry adapters at runtime,
but missed the common asymmetry where a curried function value (e.g. sourced
from a tuple deconstruction) is normalised to an uncurried arity by a fresh
inline adapter `fun(B0, B1) -> (F(B0))(B1) end` at BOTH the store site and the
compare site. Those are two distinct closures that fun_ref_eq cannot reconcile,
so removing a stored callback by reference identity still failed (Parchment's
Remove was a no-op for tuple-sourced sinks).
Detect this eta/uncurry adapter shape in the Beam Delegate lowering and build
it through a new fable_utils:make_eta(F, N), which captures F behind a
`{fable_eta_adapter, F}` marker. fun_ref_eq recovers F from the marker, so all
adapters over the same F compare reference-equal. Identity, not structural
equality, is preserved: genuinely distinct closures stay unequal.
Adds a regression test mirroring the Parchment tuple-sourced sink removal via
a class, which the earlier lambda-literal tests did not exercise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the Curry node (arity 2..7) through fable_utils:make_curry, which
captures the underlying function behind a {fable_curry_adapter, F} marker,
mirroring the existing make_eta path. fun_ref_eq now peels both markers via
erlang:fun_info before =:=, replacing the earlier 1-arity shape heuristic
that wrongly collapsed a hand-written eta expansion (fun x -> g x) onto g.
make_curry applies F with erlang:apply (all args at once), matching the
default multi-arg Apply lowering exactly, so it is behaviour-identical bar
the marker. This is precise reference identity: only compiler-generated
adapters carry a marker, so eta expansions and partial applications stay
distinct, matching .NET.
Adds a regression test and a FABLE-BEAM.md decision note. Beam suite:
2500 passed, 0 failed (.NET 2494, 0 failed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cheaply
make_eta and make_curry now detect when their input is already the opposite,
same-arity adapter and return the original underlying fun instead of stacking
a second wrapper (uncurry ∘ curry = curry ∘ uncurry = id). Round-tripped
values are then the same fun object, so native =:= holds with no unwrap and
no per-call wrapper cost, and adapters never accumulate. Mirrors the Python
target's curry.py memoization, but the marker travels inside the closure env
so it survives a fun crossing process boundaries (unlike a process-local map).
The marker now carries the arity ({tag, F, N}) to guard cancellation: a
mismatched arity wraps instead of cancelling, avoiding a badarity. fun_ref_eq
still covers the non-round-trip case (stored one way, compared another).
Verified: round-trips return the same object (native =:=), behaviour
preserved, arity guard prevents unsafe cancellation. Beam suite 2500 passed,
0 failed (.NET 2494, 0 failed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Problem
LanguagePrimitives.PhysicalEquality(andReferenceEquals, and the<>/reference-identity patterns built on them) returnedfalsefor the same F# function value on the Beam target.Fable-BEAM represents one logical function value with different Erlang funs depending on the argument site:
fun(A0) -> fun(A1) -> F(A0, A1) end end, from aCurrynode.fun(A, B) -> F(A, B) end.Erlang compares funs by closure identity, so these representations are never
=:=even though they denote one value. Real-world symptom: removing a stored callback by reference identity (e.g.sinks |> List.filter (fun s -> not (PhysicalEquality s sink))) never matched, so nothing was removed.Fix
Make the two adapter kinds identity-recoverable by tagging them, rather than changing the underlying function representation (which would ripple through the hand-written curried runtime libs).
fable_utils:make_curry/2— routed from theCurrynode inFable2Beam.fs. Builds the re-curried adapter but captures the underlyingFinside a{fable_curry_adapter, F, N}marker as the outermost fun's only captured variable. AppliesFwitherlang:apply(all args at once), matching the default multi-arg lowering exactly, so it is behaviour-identical bar the marker.fable_utils:make_eta/2— routed from theEtaAdapterDelegatepattern inFable2Beam.fs. Same idea for the uncurried adapter, tagged{fable_eta_adapter, F, N}.fable_utils:fun_ref_eq/2— peels either marker viaerlang:fun_info(F, env)before=:=, normalising every representation of a value to the sameF.Beam/Replacements.fs— routePhysicalEquality/ReferenceEqualsthroughfun_ref_eqwhen either operand is a function type; keep plain=:=otherwise.Precise reference identity, not structural equality. Only Fable's own compiler-generated adapters carry a marker, so a hand-written eta expansion (
fun x -> g x) or a partial application is left intact and stays distinct — matching .NET. (An earlier iteration used a shape heuristic — unwrap any 1-arity fun capturing a single function — which wrongly collapsedfun x -> g xontog; the marker approach fixes that false positive.)Adapter cancellation (mirrors the Python target's
curry.pymemoization). Ifmake_eta/make_curryreceives a value that is already the opposite, same-arity adapter, it returns the original underlying fun instead of stacking a second wrapper (uncurry ∘ curry = curry ∘ uncurry = id). A round-tripped value is then the same fun object — native=:=holds with no unwrap and no per-call wrapper cost — and adapters never accumulate. The marker lives in the closure env, so unlike Python's process-localWeakKeyDictionarythis survives a fun crossing process boundaries. The arity in the marker guards the cancellation (a mismatch wraps instead, avoidingbadarity).Known limitations (documented in
Beam/FABLE-BEAM.md)make_eta). Functions with 8+ curried args fall back to the default lowering and are not identity-preserved.fun_ref_eqis gated on the operand being aLambdaType/DelegateTypeat the call site. A value typed as a bare generic'T(Fable does not monomorphise) falls back to plain=:=, so wrappingPhysicalEqualityin a generic helper does not get the function-aware path.Tests
New tests in
tests/Beam/ComparisonTests.fs(pass on .NET first, then Beam):PhysicalEquality-based removal actually removes the stored element and is a no-op for a non-stored one (the sink-removal pattern), including a tuple-sourced sink stored/removed via a class.fun x -> g x) stays distinct from the original (regression guard for the former shape heuristic).Full Beam suite: 2500 passed, 0 failed (.NET 2494, 0 failed).
Note: changelog files are auto-generated from commit history per
AGENTS.md, so none is edited here.🤖 Generated with Claude Code