types: solve the recursive effect equation exactly instead of widening - #28
Merged
Conversation
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
Merged
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)
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.
Fixes #24.
A recursive function of arity >= 2 that performed any effect was rejected outright:
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
whilein 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.unifyrecurses 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: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
ioontoe4— 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 ∪ Xhas the exact solutionX := 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.e4then stays open and the outer arrow binds it to pure;e6resolves 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(alet purecaller is still rejected) and partial application is still pure.Tests
tests/typecheck.rs— arity-2 recursion withiochecks 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 withiochecks.tests/compile.rs— an e2e that the accepted loop actually runs and counts down. All four typecheck tests verified to fail onmain.cargo testgreen,cargo clippy --all-targetsclean,cargo fmtapplied.INTERNALS.mdgains a short section documentingunify_eff''s solving order, since the case that was missing is not obvious from the code.