Skip to content

types: solve the recursive effect equation exactly instead of widening - #28

Merged
simontreanor merged 1 commit into
mainfrom
fix/effect-recursion-arity
Jul 30, 2026
Merged

types: solve the recursive effect equation exactly instead of widening#28
simontreanor merged 1 commit into
mainfrom
fix/effect-recursion-arity

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Fixes #24.

A recursive function of arity >= 2 that performed any effect was rejected outright:

error: effect mismatch: cannot unify ''a -> int -> int with ''a -> int ->{io} int

Arity 1 was fine at any effect, and arity >= 2 was fine while pure — it was the combination that failed, which is the shape of every game loop, REPL, menu and server loop. With no while in the language, recursion is the only way to write a loop at all.

Cause

Not the monomorphic-recursion assumption. Each application in a body performs a fresh latent effect variable into cur_eff, so a recursive call of arity n contributes n of them, and the binding''s own type puts the accumulated body effect on the innermost arrow with the currying arrows pure. unify recurses into the codomain before unifying an arrow''s own effect, so the first equation solved is the innermost one — and it mentions the outer arrows'' variables:

{e6}  ~  {io, e4, e6}

Neither side is a bare variable absent from the other, so it fell through to the conservative widening, which closes every variable involved to the joined label set. That stamped io onto e4 — the outer currying arrow, which only builds a closure and which the binding''s type says is pure. The outer equation was then {io} ~ {}: two closed effects that disagree.

Fix

Effects are an idempotent union, so X = S ∪ X has the exact solution X := S. Solve that case directly — bind the variable to the other side with its own occurrence removed — before falling back to widening, leaving every other variable in the equation open. e4 then stays open and the outer arrow binds it to pure; e6 resolves to {io}, giving ''a -> int ->{io} int.

The effect is not lost, which is the thing worth checking about a fix in this area: fully applying the loop still performs io (a let pure caller is still rejected) and partial application is still pure.

Tests

tests/typecheck.rs — arity-2 recursion with io checks and infers ''a -> int ->{io} int; the effect still leaks out of a full application; partial application is still pure; mutual recursion at arity 2 with io checks. tests/compile.rs — an e2e that the accepted loop actually runs and counts down. All four typecheck tests verified to fail on main.

cargo test green, cargo clippy --all-targets clean, cargo fmt applied. INTERNALS.md gains a short section documenting unify_eff''s solving order, since the case that was missing is not obvious from the code.

A recursive function of arity >= 2 that performed any effect was rejected:
"cannot unify 'a -> int -> int with 'a -> int ->{io} int".

Each application in a body performs a fresh latent effect variable into
cur_eff, so a recursive call of arity n contributes n of them, and the
binding's own type puts the accumulated body effect on the innermost arrow
with the currying arrows pure. Since `unify` recurses into the codomain before
unifying an arrow's own effect, the first equation solved is the innermost
one -- and it mentions the outer arrows' variables: `e6 ~ {io, e4, e6}`.
Neither side was a bare variable absent from the other, so it fell through to
the conservative widening, which closes *every* variable involved to the
joined labels. That stamped `io` onto `e4`, the outer currying arrow, which
the binding's type says is pure -- two closed effects disagreeing.

Effects are an idempotent union, so `X = S u X` has the exact solution
`X := S`. Solve that case directly, before the widening fallback, leaving
every other variable in the equation open. The widening stays for genuinely
multi-variable equations.

The effect is not lost: fully applying the loop still performs `io` (a `let
pure` caller is still rejected) and partial application is still pure.

Fixes #24
@simontreanor
simontreanor merged commit 68923ca into main Jul 30, 2026
11 checks passed
@simontreanor
simontreanor deleted the fix/effect-recursion-arity branch July 30, 2026 15:07
@simontreanor simontreanor mentioned this pull request Jul 31, 2026
simontreanor added a commit that referenced this pull request Jul 31, 2026
Two user-visible additions since 0.2.0 -- the `input` prelude builtin
(`string ->{io} string`, #19) and caller-supplied `extern` keyword slots
(`kw = ...`, #21) -- plus five fixes, four of them from the first program
written in Pyfun in anger:

* imported schemes reused the exporting module's type-variable ids, silently
  blocking generalization in the importing module and cascading to its
  consumers (#26 / #27)
* a recursive function of arity >= 2 could not perform any effect -- the shape
  of every loop in a language with no `while` (#24 / #28)
* a module that only matched an imported `Option` emitted `None_` without
  importing it (#25 / #29)
* user identifiers collided with Python keywords, builtins the emitter calls,
  and modules an `extern` imports (#23 / #30)
* the local-binder module-alias shadowing residual (#18)
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.

A recursive function of arity >= 2 cannot perform any effect

1 participant