Skip to content

Add gradual-typing elaboration to Laurel resolution#1424

Open
julesmt wants to merge 8 commits into
reviewed-kbd-will-merge-to-mainfrom
julesmt/feature/native-types/laurel
Open

Add gradual-typing elaboration to Laurel resolution#1424
julesmt wants to merge 8 commits into
reviewed-kbd-will-merge-to-mainfrom
julesmt/feature/native-types/laurel

Conversation

@julesmt

@julesmt julesmt commented Jun 27, 2026

Copy link
Copy Markdown
Member

Adds an optional GradualConfig so resolution keeps native operators on native operands and routes Any operands through prelude operators, inserting box/unbox/toBool casts. 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.

julesmt added 8 commits June 25, 2026 12:22
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.
@julesmt

julesmt commented Jun 27, 2026

Copy link
Copy Markdown
Member Author

↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)

Review (COMMENTED):

Great PR ! This goes really helps moving forward to lower Dyn type into Laurel
I wrote a few design considerations that we can discuss and asked for some modifications

/-- 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

sure, like a canonical HighType.Dyn constructor in LaurelAST.lean?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

maybe another PR no ?

Comment thread Strata/Languages/Laurel/Resolution.lean Outdated
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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() ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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 })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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 ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

good catch

Comment thread Strata/Languages/Laurel/Resolution.lean Outdated
pure true
| _ => pure false

/-- A candidate lowering: `some` if it applies, else `none`. -/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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 ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

sure

"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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

I'll ask kiro to check if you have something similar to extract a helper !

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

at least I didn't find anything close, nor did Kiro, could you look ?


/-- Substring containment. -/
private def containsSubstr (haystack needle : String) : Bool :=
(haystack.splitOn needle).length > 1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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 ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

true

-- 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @leo-leesco on 2026-06-26 (original)

see comment https://github.com/strata-org/Strata/pull/1422/changes#r3483561857

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↪ Imported from #1422 — originally by @julesmt on 2026-06-26 (original)

yeah let's discuss it monday

must already be in scope; `none` ⇒ no elaboration. -/
structure GradualConfig where
/-- The dynamic type whose values are boxed (`TCore "Any"` for Python). -/
dynamic : HighType

@keyboardDrummer keyboardDrummer Jun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Any types 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 Any type.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants