Skip to content

fix(beam): preserve function-value identity for PhysicalEquality#4762

Merged
dbrattli merged 4 commits into
mainfrom
fix/beam-function-physical-equality
Jul 10, 2026
Merged

fix(beam): preserve function-value identity for PhysicalEquality#4762
dbrattli merged 4 commits into
mainfrom
fix/beam-function-physical-equality

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

LanguagePrimitives.PhysicalEquality (and ReferenceEquals, and the <>/reference-identity patterns built on them) returned false for 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:

  • Re-curried form (e.g. a function stored in a list/record): a nested 1-arity adapter fun(A0) -> fun(A1) -> F(A0, A1) end end, from a Curry node.
  • Uncurried form (compared/passed to an uncurried slot): a single N-arity fun 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 the Curry node in Fable2Beam.fs. Builds the re-curried adapter but captures the underlying F inside a {fable_curry_adapter, F, N} marker as the outermost fun's only captured variable. Applies F with erlang: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 the EtaAdapterDelegate pattern in Fable2Beam.fs. Same idea for the uncurried adapter, tagged {fable_eta_adapter, F, N}.
  • fable_utils:fun_ref_eq/2 — peels either marker via erlang:fun_info(F, env) before =:=, normalising every representation of a value to the same F.
  • Beam/Replacements.fs — route PhysicalEquality/ReferenceEquals through fun_ref_eq when 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 collapsed fun x -> g x onto g; the marker approach fixes that false positive.)

Adapter cancellation (mirrors the Python target's curry.py memoization). If make_eta/make_curry receives 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-local WeakKeyDictionary this survives a fun crossing process boundaries. The arity in the marker guards the cancellation (a mismatch wraps instead, avoiding badarity).

Known limitations (documented in Beam/FABLE-BEAM.md)

  • Arity cap 2..7 (matching make_eta). Functions with 8+ curried args fall back to the default lowering and are not identity-preserved.
  • Generic call sites: routing to fun_ref_eq is gated on the operand being a LambdaType/DelegateType at the call site. A value typed as a bare generic 'T (Fable does not monomorphise) falls back to plain =:=, so wrapping PhysicalEquality in a generic helper does not get the function-aware path.

Tests

New tests in tests/Beam/ComparisonTests.fs (pass on .NET first, then Beam):

  • A curried 2-arg function stored in a list is found by reference identity; a distinct-but-identical function is not found.
  • 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.
  • Identity, not structural equality: two distinct closures with identical bodies stay unequal, and a hand-written eta expansion (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

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>
@dbrattli dbrattli force-pushed the fix/beam-function-physical-equality branch from cd3769f to 9e93f4b Compare July 10, 2026 20:55
dbrattli and others added 3 commits July 10, 2026 23:17
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>
@dbrattli dbrattli merged commit 727673f into main Jul 10, 2026
52 of 53 checks passed
@dbrattli dbrattli deleted the fix/beam-function-physical-equality branch July 10, 2026 23:01
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.

1 participant