Add gradual-typing elaboration to Laurel resolution#1424
Conversation
Synth.primitiveOp inlined two match arms -- one for the arithmetic operators, one for the boolean / comparison / equality / concatenation families. Extract them into standalone nativeArith and nativeOther helpers (with an isArithmeticOp predicate and a joinAll LUB fold), and have Synth.primitiveOp resolve its operands once and dispatch to them. Pure refactor, no behavior change. Gives the native typing rules a single home so a later commit can reuse them as the fallback for gradual elaboration.
For datatype constructors, getCallInfo returned an empty parameter-type list, so constructor arguments were never checked against their declared types. Return ctor.args.map (.type) so a constructor call is type-checked like any other call. No behavior change for existing programs (their arguments already matched); this lets a later commit coerce constructor arguments under gradual typing.
Add an optional GradualConfig (a frontend's dynamic type, its boxing constructors, checked downcast accessors, per-operator prelude functions, and which procedures to elaborate). A gradualActive flag, set only inside a frontend's own procedures, gates a single dispatch in Synth.primitiveOp: when active, route operands through synthArith / synthOther, which keep a native operator when the operands are native and switch to the Any-prelude operator (inserting box / unbox / Any_to_bool coercions at boundaries), falling back to the native nativeArith / nativeOther rules; when inactive the native rules apply unchanged. Inert until a frontend supplies a config.
Add gradualConfig to LaurelTranslateOptions and pass it from LaurelCompilationPipeline into resolve by named argument. Defaults to none, so pipelines that supply no config are unaffected.
filterPrelude runs before elaboration, so the prelude calls it may insert are not yet referenced by the user program. Add extraSeeds (retained unconditionally) and a generic opSeed (Operation -> Option String) so an operator's prelude function is retained only when that operator appears. Inert without seeds.
Addresses PR #1422 review (comment on gradualActive vs gradual.isSome). The reviewer's instinct was right that storing a derived boolean is a smell, but gradualActive cannot simply be gradual.isSome: it must also account for GradualConfig.shouldElaborate, which keeps elaboration out of hand-written prelude bodies and out of top-level (non-procedure) contexts. Replace the manually save/restored gradualActive : Bool with a meaningful currentProc : Option String (none = not inside a procedure body, mirroring answerType/instanceTypeName), and derive activeGradual as gradual.filter (currentProc.elim false shouldElaborate). Centralize the per-procedure save/restore in a withProc combinator, used by resolveProcedure and resolveInstanceProcedure. Behavior is unchanged.
Addresses PR #1422 review (join, line ~763). - Doc: join/joinAll are consistency merges, not a least upper bound (they do not walk the inheritance tree), so a none result means "cannot merge structurally", not "inconsistent". - Fix: join only erased .Unknown, but the dynamic type is .TCore (e.g. Any), which isConsistent treats as top while join did not. At the if-branch merge this made the inferred type order-dependent: 'if c then (e:Any) else (e:int)' inferred Any, but the swapped branches inferred int. Add .TCore absorbing arms so join agrees with isConsistent (dynamic wins), plus a symmetric-branch regression test. The operator path is unaffected (elaborateDynamicOp intercepts dynamic operands before join; nativeArith gates on isNumeric).
Addresses PR #1422 review (comment on the OpAlt docstring). Explain that an OpAlt is one candidate in the priority list firstElab walks (none = try next, some = commit), and add a concrete a + b walkthrough under synthArith: Any + Int commits via elaborateDynamicOp, String + String falls to strConcatAlt, and Int + Int falls through to the native Add. Doc-only.
|
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original) Review (COMMENTED): Great PR ! This goes really helps moving forward to lower |
| /-- The dynamic type whose values are boxed (`TCore "Any"` for Python). -/ | ||
| dynamic : HighType | ||
| /-- Whether a type is the dynamic type (may have several spellings). -/ | ||
| isDynamic : HighType → Bool |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
Design consideration : instead of threading this Dyn type in the resolution, can we imagine referencing it throughout the program?
In other words, do we expect Dyn to be source-language dependent ? This looks like a dependency inversion to me, if we were to add a new type to express the Dyn type, we could probably defer translating the source-language Dyn to Laurel's Dyn to the corresponding frontend
| gradual : Option GradualConfig := none | ||
| /-- Whether gradual elaboration is active for the procedure being resolved. | ||
| Set per-procedure by `resolveProcedure` so only user code is elaborated. -/ | ||
| gradualActive : Bool := false |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
is there a benefit to having two fields as opposed to destructing gradual to Some _ | None ?
In other words, do we gain anything from not defining gradualActive = gradual.isSome() ?
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)
I don't think so, at least without some refactoring.
So gradual tells us if gradual typing enabled globally and gradualActive tells us if the current procedure be elaborated. The latter is gradual.isSome && shouldElaborate(currentProc), so they are not equivalent. However, I agree it's not super elegant, let me see if we have something neeter.
| (source : Option FileRange) : Option HighTypeMd := | ||
| argTypes.foldl | ||
| (fun acc t => match acc with | some l => join ctx l t | none => none) | ||
| (some { val := .Unknown, source := source }) |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
just a warning : this is not yet an actual LUB (please update doc) as it does not walk any inheritance tree
join simply erases any Unknown if tried to merge with another static type. Can you look at join and see if it needs updating using your GradualConfig ?
| pure true | ||
| | _ => pure false | ||
|
|
||
| /-- A candidate lowering: `some` if it applies, else `none`. -/ |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
can you expand this docstring (eg providing an example) or provide an example in response to this thread ?
| "multi-output call cannot be used as a value here; it returns multiple values. Unpack it into separate variables first" | ||
| modify fun s => { s with errors := s.errors.push diag } | ||
| pure true | ||
| | _ => pure false |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
I might be wrong, but I seem to remember the typechecker was already making the same kind of check ; if not we should move this helper function with the rest of the typechecker
|
|
||
| /-- Substring containment. -/ | ||
| private def containsSubstr (haystack needle : String) : Bool := | ||
| (haystack.splitOn needle).length > 1 |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
[out of scope of this PR] : considering we have a similar helper function used to match annotations in tests, maybe we could factor both of them out ?
| -- and return `true` so the caller short-circuits to the operator's natural | ||
| -- result type, suppressing the per-family check (and its cascading error) | ||
| -- on that operand. | ||
| let reportMultiValued (a : StmtExprMd) (aTy : HighTypeMd) : ResolveM Bool := do |
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
see comment https://github.com/strata-org/Strata/pull/1422/changes#r3483561857
There was a problem hiding this comment.
↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)
maybe we can discuss the location of this ? If it makes sense to move it, that's perfectly okay
| must already be in scope; `none` ⇒ no elaboration. -/ | ||
| structure GradualConfig where | ||
| /-- The dynamic type whose values are boxed (`TCore "Any"` for Python). -/ | ||
| dynamic : HighType |
There was a problem hiding this comment.
I had a different design in mind to support performant Python that I would hoping would be more powerful, and so would enable supporting different use-cases as well, related to nullable reference types and exceptions.
The design I had in mind for performant Python is:
- Do not insert any
Anytypes in PythonToLaurel, except for possibly as type annotations in library functions. Leave local variables without a type annotation. - Configure Laurel with a set of implicit value conversions from specific types to the
Anytype.
Input Laurel from Python:
procedure foo(b) {
var x := 3;
var y := x + x;
var z := if b then y else "hello";
var a := z + z
};
After Laurel type inference:
procedure foo(b: bool) {
var x: int := 3;
var y: int := x +int x;
var z: Any := Any..int(y) if b else Any..string("hello");
var a := z +Any z;
};
The + operator in Laurel would be overloaded, meaning there are several definitions with different signatures, and type inference would decide which one to pick.
In the first implementation I would say that the implicit conversions related to Any would be one directional, only going from specific types to Any but not the other way around: once you're in Any land you're not getting out. We could enable going both ways but this would change the verification output and we should be careful to keep the verification and runtime behavior in sync, especially if we view not verifying everything as Any as purely a verification performance optimization.
@leo-leesco @ssomayyajula does that match with your thoughts?
What I like about the above system is that I think it would also resolve issues other than Python's dynamic value. For example, Java has nullable reference variables, and they need to be wrapped in an Option in Laurel.
Input Laurel from Java:
procedure foo() {
var o := new Object();
var p := o;
p := null;
p.toString();
}
After Laurel type inference:
procedure foo() {
var o: Object := new Object();
var p: Object := Some(o);
o := None();
Option..fromSome(p).toString(); // precondition adds an implicit assertion
}
Java programs would have the option to use annotations to specify that a particular dereference can throw, which would not generate an assertion but will possibly throw a NullPointerException, which will then propagate up the call-chain until caught.
Java program:
void foo() {
var p: Object := null;
JVerify.allowNullPointerException();
p.toString();
}Before type inference:
void foo() {
var p: Object := null;
dereferenceOrThrow(p).toString();
}
procedure derefenceOrThrow<T>(nullable: Option<T>): T throws NullPointerException
.. ensures clauses..
After type inference:
void foo() throws NullPointerException // throws has been added, ie: procedure type has been lifted
{
var p: Option<Object> := null; // local variable type has been lifted!
dereferenceOrThrow(p).toString();
}
| operations on `dynamic` operands to the named prelude operators and | ||
| materializes the implicit box/unbox/`toBool` casts at boundaries. All names | ||
| must already be in scope; `none` ⇒ no elaboration. -/ | ||
| structure GradualConfig where |
There was a problem hiding this comment.
Can we try a GradualConfig that is just coercions: List Coercion with
structure Coercion where
from : HighType
to : HighType
cast: StmtExprMd -> StmtExprMd
and then fill this with coercions from all the constructors of PythonAny to PythonAny?
We might need to remove some (or all?) of the PythonAny boxing that is happening in PythonToLaurel, so Resolution can ensure to only insert it where necessary.
Adds an optional
GradualConfigso resolution keeps native operators on native operands and routesAnyoperands through prelude operators, inserting box/unbox/toBoolcasts. Inert without a config; strict typing unchanged. Foundation for the stacked native typed-Python PR.Continuation of #1422. That PR was auto-closed by GitHub when its head branch was renamed to
julesmt/feature/native-types/laurel(GitHub closes any open PR whose head branch is renamed). Same commits, same base.