Skip to content

types: alpha-rename imported schemes into the consumer's id space - #27

Merged
simontreanor merged 1 commit into
mainfrom
fix/imported-scheme-tyvar-collision
Jul 30, 2026
Merged

types: alpha-rename imported schemes into the consumer's id space#27
simontreanor merged 1 commit into
mainfrom
fix/imported-scheme-tyvar-collision

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Fixes #26.

Imported schemes were seeded into the consumer''s env carrying the exporting module''s raw variable ids, while every module allocates its own ids from the same counter (RESERVED_VARS). instantiate survives that on its own — it refreshes the quantified vars before substituting — but env_free_vars applies the local substitution to every env scheme, so a single id collision silently rewrote the imported scheme and leaked local variables into the "free in the environment" set. generalize then refused to quantify them.

The consequences compound:

  • the importing module''s own let f a b = (a, b) goes monomorphic, so a second use at another type is rejected;
  • that binding exports un-quantified, so consumers one module further along share a single type variable across every use site.

Both were observed in a real project: an exported refill came out as (List ''a) -> (List ''a) -> (List ''a, List ''a) with nothing quantified, and the imported scheme whose collision caused it had been rewritten from int -> List a -> List a into int -> List ((List ''a) -> List ''a) -> List ((List ''a) -> List ''a).

Fix

Infer::refresh_scheme alpha-renames an imported scheme into the consumer''s variable space — type, unit, num and effect ids, carrying ord_vars across — applied to imported values and constructors as they are seeded, in sorted order so the ids do not depend on HashMap iteration. Free ids are renamed as well as quantified ones: a scheme should arrive closed, but a free id is exactly the case that must not alias a local variable.

Tests

Two regression tests in tests/project.rs, both verified to fail on main:

  • an_importing_module_keeps_its_own_functions_polymorphic — the direct case; Main does not even use the import.
  • an_under_generalized_export_does_not_cascade_to_a_consumer — the contagious case, three modules deep.

cargo test green (all suites), cargo clippy --all-targets clean, cargo fmt applied. INTERNALS.md updated: the old soundness note claimed closed schemes were sufficient, which is where the reasoning went wrong.

Every module's inference allocates variable ids from its own counter starting
at RESERVED_VARS, so a scheme transplanted from a dependency arrives holding
ids the importing module will hand out again. `instantiate` survives that on
its own, but `env_free_vars` applies the *local* substitution to every env
scheme -- so one id collision silently rewrote the imported scheme and leaked
local variables into the "free in the environment" set, which blocked
generalization of the importing module's own bindings. Those then exported
un-quantified, and consumers got one shared type variable across every use
site: a spurious "expected int, found string" at a call two modules away,
appearing and disappearing as unrelated definitions shifted the id arithmetic.

Refresh every id once, at the boundary, for imported values and constructors
alike, in sorted order so the ids do not depend on HashMap iteration.

Fixes #26
@simontreanor
simontreanor merged commit b99a3a4 into main Jul 30, 2026
11 checks passed
@simontreanor
simontreanor deleted the fix/imported-scheme-tyvar-collision 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.

Imported schemes reuse the exporting module's type-variable ids, silently breaking generalization

1 participant