Skip to content

Resolve inferred generics through the generics they're inferred from - #265

Merged
Rigidity merged 4 commits into
mainfrom
rigidity/issue-146-transitive-inference
Nov 14, 2026
Merged

Rigidity merged 4 commits into
mainfrom
rigidity/issue-146-transitive-inference

Conversation

@Rigidity

@Rigidity Rigidity commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #146

Problem

Passing a generic function to a parameter that relates two of the callee's generics failed:

fn map<A, B>(f: fn(a: A) -> B, x: A) -> B { f(x) }
fn id<U>(x: U) -> U { x }
fn main(n: Int) -> Int { map(id, n) }

The candidates collected were A: [Int] and B: [A]. resolve_inference mapped each generic on its own, to {A: Int, B: A}, and substitute_with_mappings doesn't apply mappings transitively. That left fn(a: Int) -> A and the return type A. I confirmed this by logging the mappings on main.

Change

  • resolve_inference resolves a candidate that references another generic being inferred through what that generic is inferred as, resolving the generics it depends on first.
    • Generics that depend on each other (a cycle such as A: [B, Int], B: [A]) are resolved together. A candidate that's exactly another generic in the cycle is replaced by that generic's candidates of the same kind (lower, upper or held).
    • A generic that's only bounded by others in its cycle goes through the ones that were resolved, then the type expected of the call, and otherwise keeps its candidates as on main. So an unconstrained A: B, B: A is still rejected. This is done in rounds, so that one resolved this way is passed on to the others in turn (a cycle of three bounded at one generic, or one where only one generic has an expected type). Each round reads the same mappings, so the order of the generics doesn't matter.
    • A candidate that references a cycle member inside another type would be an infinite type, so it's left as is.
  • New Inference::set_in_scope. The compiler marks the callee's generics that are in scope at the call, which happens in a recursive call to the function itself. These aren't resolved through, because a candidate A there can be the caller's own rigid A. Main already relies on this for something like swap(n - 1, y, x) inside swap<A, B>, which gives {A: B, B: A}.
  • New resolve_inference_with_fallback. The call's final resolution uses the type expected of the call for generics that nothing else constrains, so generics inferred from them resolve through it, e.g. let g: fn(a: Int) -> Int = compose(identity). Before, the fallback was merged in after resolution.
    • The per-argument resolution inside the argument loop is unchanged. Lambda ordering (has_uninferred_parameters) must only see what the arguments inferred, and including the fallback there changed generic_inference_lambda_order.

Inference still only proposes types. The final check of each argument against the substituted parameter type is unchanged, so the change makes more valid programs compile and rejects nothing new.

Tests

  • tests/types/generic_inference_transitive.rue covers:
    • the issue's case and the reversed argument order (x before f)
    • generics declared out of dependency order (map<B, A>)
    • a chain of three generics
    • a cycle between generics with a concrete type
    • a cycle resolved through the other generic
    • a cycle resolved from the expected type, and one where only one generic has an expected type
    • a cycle of three resolved through each other in turn, and a cycle of four
    • resolution through the expected type
    • two recursive calls: one guards the existing {A: B, B: A} behaviour, and one fails if the in-scope marking is removed
  • tests/types/generic_inference_transitive_errors.rue covers calls that must still be rejected:
    • a wrong expected type
    • a function that can't take the value
    • a type inferred through A that a later function can't take
    • an unconstrained cycle
  • Unit tests in rue-types cover lower, upper, held and compound candidates, compound candidates in cycles being left as is, cycles in both generic orders, unconstrained cycles, generics in scope, and fallback types.

No existing snapshots changed.

Provisional decisions, for you to confirm

  • Transitive resolution, not dropping other generics as candidates. Dropping them would leave B uninferred in let r = map(id, 5), and it would break the recursive swap case.
  • Generics in scope at the call aren't resolved through. This keeps recursive calls behaving as on main, so a recursive call that passes a generic function, such as map(id, x) inside map, keeps main's behaviour. The fuller fix would rename the callee's generics in recursive calls so both meanings can coexist. That's a larger change in function_call.rs.
  • A generic bounded only by others in its cycle resolves through the resolved ones, then through the expected type. This accepts valid programs that main rejected, like the cycle tests.
  • Edges between generics are only followed forward. A lower candidate B of A gives A the lower candidates of B. The reverse relation, A appearing in B's upper list, isn't propagated. So feed(identity, inc) with A ≤ B ≤ Int still can't infer A, as on main (Infer generics bounded from above through another generic #285).
  • The expected-type fallback is transitive only in the final resolution, not in the per-argument expected types inside the argument loop. This is to keep the lambda order unchanged.

Follow-ups, not in this PR

- Resolve generics through the type expected of a call when that's all
  that gives a generic a type
- Test a recursive call that depends on generics in scope not being
  resolved through, and upper bound candidates that are other generics
- Compute which generics each one depends on once, and drop a buffer
  that wasn't needed
- Resolve a generic that's only bounded by others in a cycle through the
  ones that were resolved, or the type expected of the call
- Test generics declared out of order, and a type inferred through
  another generic that a later argument can't take
- Resolve generics that are only bounded by their cycle in rounds, so one resolved through a partner or the expected type is passed on to the others
- Test a cycle of three resolved in turn, a cycle of four, and a cycle where only one generic has an expected type
- Test held candidates resolved through other generics, and compound candidates in cycles left as is
- Say what the swapped recursive call does and doesn't cover
@Rigidity
Rigidity force-pushed the rigidity/issue-146-transitive-inference branch from 8dba60b to a3c4ba6 Compare November 14, 2026 04:36
@Rigidity
Rigidity merged commit dab9475 into main Nov 14, 2026
2 checks passed
@Rigidity
Rigidity deleted the rigidity/issue-146-transitive-inference branch November 14, 2026 04:46
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.

Inference doesn't resolve generics that are inferred from other generics

1 participant