Calling a function value that was stored in a container (a record field, a tuple, a list, or a Result payload) panics the compiler during monotype lowering whenever the lambda's numeric types are a concrete non-default primitive (e.g. I64 or F64). The same code works if the numbers are left to default to Dec, or if the function value is first rebound to a plain local before calling it.
thread 3123713 panic: postcheck invariant violated: instantiation unified two different primitive types
This is a Debug-build panic via Common.invariant (src/postcheck/common.zig:112); in release builds the same path is unreachable, so release builds reach undefined behavior instead of a clean panic.
Steps to reproduce
-
Build the compiler at current main (reproduced at 04802b59f7):
-
Save this as repro.roc:
module []
expect {
r = { op: |x| x * 11.I64 }
(r.op)(4.I64) == 44
}
(The module [] header prints an unrelated deprecation warning; it keeps the repro self-contained.)
-
Run:
./zig-out/bin/roc test repro.roc
Expected: the expect runs (and passes). Actual: compiler panic before anything executes.
Stack trace
thread 3123713 panic: postcheck invariant violated: instantiation unified two different primitive types
src/postcheck/common.zig:112:24 invariant
src/postcheck/monotype/solve.zig:712:66 unifyConcrete
src/postcheck/monotype/solve.zig:662:47 unifyRoots
src/postcheck/monotype/solve.zig:617:32 unify
src/postcheck/monotype/lower.zig:16762:33 instantiateTargetCallNodeFromMonoArgs
src/postcheck/monotype/lower.zig:16746:71 instantiateTargetCallTypeFromMonoArgs
src/postcheck/monotype/lower.zig:20263:68 methodTargetMonoTypeFromPlan
src/postcheck/monotype/lower.zig:19376:50 lowerDispatchExprAtType
src/postcheck/monotype/lower.zig:18677:77 lowerExprAtType
src/postcheck/monotype/lower.zig:24438:85 lowerBindingContinuation
src/postcheck/monotype/lower.zig:9044:53 lowerLambdaArgsAndBody
src/postcheck/monotype/lower.zig:8984:56 lowerLambdaTemplate
src/postcheck/monotype/lower.zig:9109:44 lowerNestedLambdaTemplate
src/postcheck/monotype/lower.zig:9092:67 lowerNestedFunction
src/postcheck/monotype/lower.zig:3162:60 lowerNestedFnRequest
src/postcheck/monotype/lower.zig:3110:45 lowerNestedFnFromContext
src/postcheck/monotype/lower.zig:19258:95 lowerLambdaExpr
src/postcheck/monotype/lower.zig:9419:52 lowerExprWithType
src/postcheck/monotype/lower.zig:18712:59 lowerExprAtType
src/postcheck/monotype/lower.zig:19139:41 lowerRecordExpr
src/postcheck/monotype/lower.zig:9392:64 lowerExprWithType
src/postcheck/monotype/lower.zig:9214:42 lowerExpr
src/postcheck/monotype/lower.zig:26775:41 lowerPatternStatement
src/postcheck/monotype/lower.zig:26667:58 lowerStatement
src/postcheck/monotype/lower.zig:25310:75 lowerBlockStatements
src/postcheck/monotype/lower.zig:25272:52 lowerBlock
src/postcheck/monotype/lower.zig:9451:50 lowerExprWithType
src/postcheck/monotype/lower.zig:18721:51 lowerExprAtType
src/postcheck/monotype/lower.zig:9241:40 lowerComptimeRootExprAtType
Per the trace, the panic fires while lowering the lambda nested inside the container literal (lowerRecordExpr → lowerNestedFunction → lambda body): the static-dispatch lowering of the * inside the lambda body instantiates the method target's type and unifies a formal against an argument monotype, and the two sides carry different concrete primitives. Given the works/panics matrix below, this looks like the numeric default (Dec) leaking into the method-target instantiation context used for lambdas that are lowered as children of container literals, where it then collides with the lambda's actual concrete primitive (I64, F64, ...).
roc check on the same file reports 0 errors — the type checker is fine with the program; only the monotype lowering stage panics.
What panics vs. what works
All of these were verified individually with roc test on the build above. Captures are irrelevant (both capturing and capture-free variants behave identically); the discriminator is the concrete number type inside the lambda.
Panics — function value read out of a container and called, with .I64 (or .F64) typed numbers:
# record field
r = { op: |x| x * 11.I64 }
(r.op)(4.I64)
# tuple destructure
pair = (|x| x + 1.I64, |x| x * 2.I64)
match pair { (add, mul) => add(1.I64) + mul(3.I64) }
# list destructure
fs = [|x| x + 1.I64]
match fs { [f] => f(1.I64), _ => ... }
# Result payload
fs = [|x| x + 1.I64]
match List.first(fs) { Ok(f) => f(1.I64), Err(_) => ... }
Works — same shapes, numbers left to the Dec default:
fs = [|x| x + 1, |x| x + 2]
match List.first(fs) { Ok(f) => f(41), Err(_) => ... } # passes
Works — same .I64 lambda in a container, but rebound to a local before the call:
r = { op: |x| x * 11.I64 }
op = r.op
op(4.I64) # passes
Works — .I64 closures that never pass through a container: called directly, passed as function arguments, or returned from calls.
Note the existing eval-corpus case "top-level list constant containing callable values" (src/eval/test/eval_tests.zig) is the untyped-literal variant, which is why this never surfaced in the test suite: with a .I64 inside the lambdas, that same corpus case's shape panics too.
Where this is tracked in-repo
The Lambda Mono differential harness's generated sweep corpus keeps four repros as expected failures, flagged known_panic in src/eval/test/lambda_mono_generated_corpus.zig. Once this is fixed, those flags can be removed and the cases will run as normal differential checks:
zig build run-test-lambda-mono-differential -- generated-only --filter "closure"
Calling a function value that was stored in a container (a record field, a tuple, a list, or a
Resultpayload) panics the compiler during monotype lowering whenever the lambda's numeric types are a concrete non-default primitive (e.g.I64orF64). The same code works if the numbers are left to default toDec, or if the function value is first rebound to a plain local before calling it.This is a Debug-build panic via
Common.invariant(src/postcheck/common.zig:112); in release builds the same path isunreachable, so release builds reach undefined behavior instead of a clean panic.Steps to reproduce
Build the compiler at current
main(reproduced at04802b59f7):Save this as
repro.roc:(The
module []header prints an unrelated deprecation warning; it keeps the repro self-contained.)Run:
Expected: the expect runs (and passes). Actual: compiler panic before anything executes.
Stack trace
Per the trace, the panic fires while lowering the lambda nested inside the container literal (
lowerRecordExpr→lowerNestedFunction→ lambda body): the static-dispatch lowering of the*inside the lambda body instantiates the method target's type and unifies a formal against an argument monotype, and the two sides carry different concrete primitives. Given the works/panics matrix below, this looks like the numeric default (Dec) leaking into the method-target instantiation context used for lambdas that are lowered as children of container literals, where it then collides with the lambda's actual concrete primitive (I64,F64, ...).roc checkon the same file reports 0 errors — the type checker is fine with the program; only the monotype lowering stage panics.What panics vs. what works
All of these were verified individually with
roc teston the build above. Captures are irrelevant (both capturing and capture-free variants behave identically); the discriminator is the concrete number type inside the lambda.Panics — function value read out of a container and called, with
.I64(or.F64) typed numbers:Works — same shapes, numbers left to the
Decdefault:Works — same
.I64lambda in a container, but rebound to a local before the call:Works —
.I64closures that never pass through a container: called directly, passed as function arguments, or returned from calls.Note the existing eval-corpus case "top-level list constant containing callable values" (
src/eval/test/eval_tests.zig) is the untyped-literal variant, which is why this never surfaced in the test suite: with a.I64inside the lambdas, that same corpus case's shape panics too.Where this is tracked in-repo
The Lambda Mono differential harness's generated sweep corpus keeps four repros as expected failures, flagged
known_panicinsrc/eval/test/lambda_mono_generated_corpus.zig. Once this is fixed, those flags can be removed and the cases will run as normal differential checks: