Skip to content

feat: restore the Elm constraint tree, add representation coverage and fix type-system crashes - #44

Merged
MicroProofs merged 24 commits into
mainfrom
fixing-issues
Oct 24, 2026
Merged

MicroProofs merged 24 commits into
mainfrom
fixing-issues

Conversation

@MicroProofs

@MicroProofs MicroProofs commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This branch holds the type-system audit work. It restores Elm's constraint tree in place of direct inference, fixes four type-system crashes and a crash in long diagnostics, fixes four wrong results in type checking and code generation, adds the representation coverage rule for impls that are split by element representation, and replaces the separate search budgets with one compile fuel counter. Twenty-four commits, seventeen changesets.

Behavior changes:

  • same : Eq 'a => list 'a -> list 'a -> bool compiles. When the representation of a rigid variable selects the impl, the annotation must state the trait at that variable. A missing one is a missing-constraint error such as Eq 'a, not a missing impl for the container.
  • A phantom type parameter that no use fixes no longer stops nash test or nash build with "superclass resolution requires a ground type".
  • Three contradictory representation contexts are now errors at the context. Each one crashed the compiler before: (Big ('f 'a), Little ('f 'a)), Little 'a inside Big 'a => ..., and an impl context that contradicts a superclass of the impl's trait.
  • A trait constraint on an infinite type no longer crashes the compiler or gives a false impl-resolution-limit error. The occurs check reports the infinite type.
  • type alias Xs = List Int works with List.length and other functions over 'f 'a.
  • When an annotated body fails to type check, its callers are still checked against the annotation, and values passed to it are no longer reported as ambiguous.
  • An annotated definition publishes its declared type, so importers find the impls of an alias in its annotation.
  • nash check, build, test and docs have no search limit. Programs that the fixed work and depth limits rejected now compile, such as an impl context that rewraps its argument used at 200 nested types. On main, depths 127 and up failed. A looping impl now runs until stopped.
  • The language server gives each module 10 million compilation steps, so a looping program reports COMPILATION STEP LIMIT (nash::type::out_of_fuel) after about a second and a half instead of freezing the editor. This one error replaces nash::type::impl_resolution_limit, nash::names::impl_pattern_limit, and the code generation specialization, evidence and recursion limits.
  • nash check no longer crashes with "Doc::text must not contain newlines" when a missing-constraint or missing-impl predicate is wider than 80 columns. When such a sentence does not fit the line, the predicate moves to its own line, indented by 4.
  • A tuple or record type wider than the line puts one entry on each line, as Elm does.
  • The record after "Available fields:" lines up under its opening brace.

Versioning: minor bumps for nash-ast, nash-can, nash-cli, nash-codegen, nash-constrain, nash-driver, nash-language-server, nash-report and nash-solve; patch for nash-docs. See .sampo/changesets/.

Elm constraint tree (refactor(types): restore the Elm constraint tree)

  • nash-constrain generates Elm's constraint tree from the canonical AST again, and nash-solve walks it. The direct-inference files are deleted.
  • The generator and the walker are the files that 0ed0c75a ("refactor(types): infer directly from AST") deleted. The features that six later commits added to direct inference are ported into them: per-node type records for code generation (SaveType), module tests, overloaded Bool and Unit literals, assert, fail, todo, trace and comptime, native pair patterns, and the regions that diagnostics point at.
  • No existing snapshot changes. The published annotation type is the one behavior change. Its regression test is annotated_alias_result_keeps_its_impl_for_importers_after_a_wider_call.
  • refactor(constrain): carry the tests scope on record field constraints removes the Nash-only TestScope constraint. Record and Field constraints carry whether they come from a module test, so the solver keeps no state for it. The constraint language is now Elm's plus SaveType. Field visibility does not change. test_imports_grant_field_visibility_to_every_field_form_in_tests covers the accessor, update and record-pattern forms in both scopes.

Crash fixes

Each fix has a test for the audit case that crashed.

  • fix(traits): reject contradictory representation contexts on type applications groups representation predicates by type, not only by plain variable.
  • fix(solve): reject representation givens that contradict an enclosing annotation checks a local annotation together with the enclosing annotations, which share its type variables as in Elm.
  • fix(traits): check every impl context through its superclasses runs the superclass check in check_impl. An impl with no methods used to skip it.
  • fix(solve): do not select impls for infinite types makes the constraint wait for the occurs check of its binding, as in Elm. occurs::reaches_cycle visits each variable once.

Representation coverage (feat(traits): select representation-split impls by coverage)

  • nash_ast::head::cover_impls first checks that a given states the trait at each rigid variable that selects the impl. It then selects an impl for every admitted representation of those variables.
  • The evidence is the new Evidence::Specialize. Code generation selects the impl for each use and keeps the selecting types in the specialization key.
  • Superclass proofs in nash-can do not use the rule.

Open types in superclass evidence

  • fix(solve): resolve superclass evidence for open phantom types. nash_solve::evidence::resolve keeps open type variables. An open variable equals only itself. Resolution reports NonGround only when impl selection, a representation test, a reflexive Lift or the requirements of a type need the variable.
  • The new nash_ast::head::Open gives impl selection that view. nash_codegen::evidence::Error::NonGround is removed.
  • The driver test phantom_type_through_a_superclass failed before the fix. The solver test open_type_that_selects_a_superclass_impl_is_a_type_error shows that type checking rejects the case where the open variable would select the impl.
  • fix(solve): let a selected impl prove an open type that another impl cannot decide keeps impl selection under SelectPolicy::Exact, so an impl that matches whatever the variable is wins over a candidate that only the variable could decide. An undecided reflexive Lift goes on to impl search. The driver test phantom_pair_through_a_superclass failed before this commit with "cannot resolve superclass: NonGround".

Solver diagnostics

  • fix(solve): unify type applications with transparent aliases by their bodies
  • fix(solve): keep a failed annotated body's annotation for its callers
  • fix(solve): do not report types stranded by a failed call as ambiguous

Long diagnostics

  • fix(report): break tuple and record entries as Elm's D.cat does adds Doc::pcat, Elm's D.cat. Tuple and record entries were joined with no break point, so a wide type broke only inside its last entries and drifted to the right. source_pipeline_tuple_wider_than_the_page shows the new layout.
  • fix(report): build long trait help lines from document parts fixes the crash. The help line rendered the predicate to a string and placed it in one piece of text, which cannot hold a line break. Each sentence with a predicate is now laid out like the rest of the report, and a sentence that does not fit moves its predicate to its own line. Sentences that fit render as before. Three existing snapshots of 81 to 97 columns now wrap.
  • fix(report): align the record after Available fields under its brace changes six snapshots, in indentation only.

Compile fuel, shared trait search and indexes

  • refactor: replace search budgets with one compile fuel counter. Trait search, overlap checks, superclass entailment, evidence and code generation each had their own work or depth budget, and they counted work differently. On this branch before this commit, a nested impl C 'a => C (wrap 'a) used at depth 178 to 255 passed nash check and failed nash build with no location. nash_ast::fuel is now the one step counter. The driver installs it around each module's compile (Unlimited for the command line, Limited(10_000_000) for the language server), and every search loop calls tick(). The constants WORK_LIMIT, DEPTH_LIMIT, MAX_SPECIALIZATIONS and MAX_TYPE_DEPTH, the 4096-step normalization limit, and every budget parameter are deleted. Superclass cycle detection and the polymorphic recursion error stay. deeply_nested_impl_context_checks_and_builds_without_a_depth_limit checks and builds the depth-200 case, and fails before this commit. a_looping_program_reports_running_out_of_fuel_instead_of_hanging covers the language server.
  • The earlier commits refactor(traits): keep the trait search limits in one place, feat: raise the trait search and specialization depth limits to 256 and test(codegen): build the deep type from the depth limit are superseded by the fuel commit. Their changesets are removed.
  • refactor(traits): share impl selection between inference, entailment, and evidence adds select_impl with SelectPolicy::Defer and SelectPolicy::Exact. A second matching impl stops the compiler as a coherence bug.
  • perf(solve): index kind contracts, recovery edges, and scheme records replaces scans of every earlier record with indexes that follow union-find merges. refactor(solve): remove the step 03 scan oracles removes the debug-build checks that compared each index with its old scan, and the two randomized tests that drove them.

Build and examples

  • build: optimize the compiler crates in the dev profile
  • fix(examples): move test-only imports into tests blocks. nash build strips tests blocks, so imports that only tests use were reported as unused.

Not in this PR

  • The Plan 11 document commits and the macro work, including audit steps 01, 10 and 11. They stay on plan-11.

Test plan

  • cargo fmt --all -- --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo nextest run --workspace (3,936 tests)
  • nash build and nash test in examples/vesting (10 tests pass)
  • nash build and nash test in examples/order. The property compare is antisymmetric fails because invert returns its argument unchanged. The example has had that since ltmkrkqv.
  • nash check gives a diagnostic for each of the 13 audit reproductions, with no crash.
  • nash check and nash build succeed at nesting depths 177, 178 and 200 of impl C 'a => C (wrap 'a).

nash build strips tests blocks before canonicalization, so module-level
imports used only by tests were reported as unused.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
… bodies

Signed-off-by: microproofs <kwhitemsg@gmail.com>
Signed-off-by: microproofs <kwhitemsg@gmail.com>
Signed-off-by: microproofs <kwhitemsg@gmail.com>
Signed-off-by: microproofs <kwhitemsg@gmail.com>
Signed-off-by: microproofs <kwhitemsg@gmail.com>
Signed-off-by: microproofs <kwhitemsg@gmail.com>
Signed-off-by: microproofs <kwhitemsg@gmail.com>
Kind checks, recovery closure, stranded lookups, scheme lookups, and the
per-scope grouping in finish scanned every earlier record. Each is now an
index that follows union-find merges through the new UnionFind::merges log.
Debug builds keep an ORACLE that recomputes the old scan and asserts the
same result on every call; step 13 removes them after the constraint tree
and coverage changes land.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
… and evidence

Inference, superclass entailment, and evidence each had their own impl
selection loop. They now call nash_ast::head::select_impl with a
SelectPolicy: Defer (inference) lets any undecided candidate defer the
selection; Exact (rigid or ground arguments) treats it as not proven.

Every candidate is matched under both policies. A second match panics as
an impl coherence violation instead of keeping the first or the last:
canonicalization already rejects overlapping impls.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
…lications

check_representation_consistency only grouped predicates on plain type
variables, so a context such as (Big ('f 'a), Little ('f 'a)) passed
canonicalization. In inference, the givens then proved an empty
representation set, which in_class read as proof of every class: both
disjoint blanket impls matched and select_impl stopped the compiler.

Group predicates by location-free type identity (nash_ast::same_type) and
report the existing contradictory-representation error with the subject
type. in_class now asserts the proven set is non-empty.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
nash-constrain again turns the canonical AST into Elm's constraint tree and
nash-solve walks it, in place of direct inference. The generator and walker
are the pre-removal files (recursive, Elm order) plus the features added
since: SaveType node metadata, module tests (TestScope), overloaded Bool and
Unit literals, assert/fail/todo/trace/comptime, native pair patterns,
diagnostic regions, alias identity of annotated functions, fix A, gap B,
and the step 03/04 machinery. Let branches without definitions record no
schemes (decision N4).

Types convert once per constraint, as in Elm. Definition types convert on
their own, so an annotated definition publishes its declared type: a call
with more arguments in the same module no longer replaces an alias in its
annotation, and importers find the impls of that alias. Direct inference
lost the alias there (regression test added). No existing snapshot changes.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
… annotation

A local annotation shares the enclosing annotation's type variables, as
Elm's rtv does (Constrain/Expression.hs constrainDef). Canonicalization
checks each context alone, so 'Little 'a' inside 'Big 'a => ...' passed,
and the joined given frames reached impl selection with an empty proven
set, where an assert stopped the compiler.

enter_givens now groups the representation givens of all active frames
and the new frame by subject, as in_class compares them, and reports a
contradiction with the existing contradictory-representation error at
the local definition. The subject is poisoned, so obligations on it are
dropped and the frame's second entry (ambiguity loop) does not report
again. check_ambiguity shares the grouping. A requirement implied by
another one (Storable next to Big) is left out of the report.

The in_class assert stays: contradictory givens reaching impl selection
are a compiler bug. Its comment now names both checks.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
check_annotation rejects a context that admits no representation once its
superclasses are added (Foo 'a with trait Foo ('a : Big) next to
Little 'a). check_impl ran only the formation check; the superclass check
reached an impl only through the annotation of each method it defines. An
impl that defines no method skipped it, and superclass entailment then saw
an empty proven set, so both disjoint blankets matched and select_impl
stopped the compiler.

check_impl now runs the same superclass check (check_superclass_consistency,
moved out of check_annotation), so such an impl gets the existing
contradictory-representation error at its context. kinds::in_class asserts
a non-empty proven set instead of silently proving every class, as the
solver's in_class does.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
Impls are disjoint only over finite types. A let-bound definition's
wanted predicates are resolved when it is generalized, before the occurs
check of the enclosing binding, so a cyclic argument reached impl
selection. Two impls that are disjoint on finite types both matched it
(('a, 'a) and ('a, 'a -> 'b) for f f), and select_impl stopped the
compiler; an impl that matches at every depth repeated its own context
until the work limit and gave a false impl-resolution-limit error.

resolve_wanted now defers a wanted whose arguments reach a cycle, before
impl selection and relational improvement. The binding's occurs check
reports the infinite type and poisons it, as Elm's CLet occurs fold does,
which drops the wanted. The cycle check (occurs::reaches_cycle) follows
the edges of Elm's occurs but visits each variable once, so types with
shared parts stay linear. The select_impl doc comment now states the real
invariant.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
A rigid type variable at an impl-head position that tests a
representation class kept impl selection from deciding:
same : Eq 'a => list 'a -> list 'a -> bool was rejected with
"No impl for Eq (list 'a)", although Base has an Eq list impl for every
element representation.

Inference now proves such a predicate by coverage (audit item 08,
owner decisions D2, N2, N3):
- nash-ast head::cover_impls finds the rigid subjects, checks the limit
  first (the given closure holds the wanted trait at each subject), then
  selects an impl for every admitted Big/Const/Term cell. One work budget
  pays for all of it.
- nash-solve runs it in resolve_wanted when selection defers or misses,
  and proves each covered impl's context under its cell with a
  non-deferring prover (givens, representations, impls, coverage again).
  The proofs are recorded as Solution::Specialize subs, so the growing
  evidence check rejects polymorphic recursion through coverage. A missing
  element trait or branch context is a missing-constraint error; an
  uncovered cell keeps today's missing-impl text.
- The evidence is Evidence::Specialize { trait_, args }. Code generation
  selects the impl for each use (method B): the demand analysis gives each
  template the type variables that coverage selects with, propagated
  through calls, and the specialization key holds their types.
Superclass proofs in nash-can do not use the rule (N3). nash-can is not
changed.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
wrapper : tag 'p -> bool; wrapper t = same t t, with
same : Ord 'a => 'a -> 'a -> bool, type-checked, but nash test stopped
with "superclass resolution requires a ground type, found p". No use
fixes the phantom 'p, so it stays open in code generation, and the
superclass step of Grounder::one rejected every type variable before
resolution.

nash_solve::evidence::resolve now keeps open type variables. An open
variable equals only itself, so impl heads bind it and reflexive Lift
proves Lift 'a 'a. Resolution reports NonGround only when a decision
needs the variable: impl selection (the new nash_ast::head::Open under
SelectPolicy::Defer), a representation test, a reflexive Lift that the
variable could decide, or a requirement that formation of a type
leaves. A predicate whose open variable no decision tests, such as
Keep ('a -> int) without a function impl, reports MissingImpl.

In nash-codegen, the Super arm keeps only the arity check: inspect_type
loses its require_ground mode, and Error::NonGround is removed. A rigid
variable that would select a superclass impl is already a
missing-constraint error in type checking (new solver test).

Signed-off-by: microproofs <kwhitemsg@gmail.com>
Step 03 replaced linear scans with indexes for kind contracts, recovery
closure, stranded lookups, scheme lookups and the per-scope grouping in
finish. Debug builds kept each old scan as an ORACLE and asserted on
every call that it agreed with the index, so debug runs still paid the
quadratic cost. The constraint tree (05) and coverage (08) changes have
landed, so the oracles go: the debug-build call sites, the scan helpers,
the stranded_handles field that fed one of them, and the two randomized
tests that compared the indexes with the scans
(contract_index_agrees_with_the_contract_scan and
historical_closure_and_stranded_lookup_agree_with_the_scans).

Signed-off-by: microproofs <kwhitemsg@gmail.com>
…cannot decide

09 made open-type evidence select under SelectPolicy::Defer, where any
undecided candidate defers the whole selection. With
impl Label (twins 'a 'a) and impl Label (twins int bool), the open
twins p p matches the first impl for every p, but the second cannot
decide p, so resolution failed with NonGround. A reflexive Lift that p
could decide also returned NonGround before impl search, although
impl Lift (twins 'a 'a) (twins int bool) proves the pair. Type checking
treats p as rigid and selects those impls, so both programs passed
nash check and then stopped nash test with "cannot resolve superclass:
NonGround" (driver test phantom_pair_through_a_superclass).

The resolver uses SelectPolicy::Exact again, so a selected impl wins
over an undecided candidate. Under Exact, select_impl now returns
Deferred when nothing is selected and a candidate is undecided, so the
resolver still reports NonGround there. Entailment never sees Deferred:
its rigid types decide every test. An undecided reflexive Lift falls
through to impl selection and reports NonGround only when no impl is
selected.

docs/traits.md no longer says that reflexive Lift needs a Big
representation. It needs nominally equal arguments only: Lift int int
and Lift () () resolve, and canonicalization accepts it for Big and
Const types.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
render_type::tuple and record joined their entries with Doc::cat, which
is Elm's <> and has no break point. Elm's Render/Type.hs joins them with
D.cat, wl-pprint's cat: the entries stay on one line when they fit and
go one per line otherwise. A tuple wider than the page therefore broke
only inside its last entries and drifted to the right.

Doc::pcat is D.cat (a group of entries separated by LineBreak). tuple
and the record fields use it. No existing snapshot changes. The new
source_pipeline_tuple_wider_than_the_page shows the layout for a type
mismatch.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
missing_constraint and missing_impl rendered the wanted predicate to a
string at width 80 and spliced it into Doc::text. A predicate wider than
80 columns rendered with a line break, so Doc::text's assertion stopped
nash check with "Doc::text must not contain newlines".

Both help lines are now built from document parts, as the messages
already were. with_predicate wraps each message and help sentence of the
two producers in hang 4 (sep [lead, predicate]): a sentence that does
not fit puts the predicate on its own line indented by 4, and a sentence
that fits renders exactly as before. Three existing snapshots change:
two help lines of 88 and 97 columns (via_requires_generator,
map_lift_rejects_inferred_native_pair_components) and an 81-column
representation message (missing_storable_constraint_for_list_element)
now break before the predicate. Doc::text and its assertion are
unchanged.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
The missing-field hint concatenated "Available fields: " with a
vertical record, so the record's later lines started at the left margin
instead of under its opening brace. Doc::align keeps them under the
brace. Six snapshots change, each only in that indentation.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
Constraint::TestScope wrapped each module test so that the walker set a
test_scope flag while it solved the test, and DeferredField copied the
flag to choose between tables.test_fields and tables.fields. The scope
is a lexical fact the generator already knows, so the walker no longer
keeps state for it.

Rtv, the generator's lexical environment, becomes a struct with the
rigid variables and a tests flag, and constrain_test starts from
Rtv::tests(). Constraint::Record and Constraint::Field carry tests, set
from Rtv at the accessor, access and update sites, and from
pattern::State (empty_state(tests)) at record patterns. The solver reads
the flag from the constraint. TestScope and Solver::test_scope are
removed, so the constraint language is Elm's plus SaveType.

Field visibility is unchanged and no snapshot changes. The new
test_imports_grant_field_visibility_to_every_field_form_in_tests covers
the accessor, update and pattern forms in both scopes; its snapshot was
recorded before this change.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
Trait search, overlap checks, superclass entailment, evidence and code
generation each had their own work or depth budget. They counted work
differently, so a program could pass `nash check` and then fail `nash build`
with no location (depth 178 to 255 of a nested `impl C 'a => C (wrap 'a)`).

Replace them with one step counter, `nash_ast::fuel`, that the driver
installs around each module compile. The command line runs with unlimited
fuel. The language server runs with 10,000,000 steps per module, so a
looping program reports `nash::type::out_of_fuel` instead of freezing the
editor. Cycle detection and the polymorphic recursion error stay.

Signed-off-by: microproofs <kwhitemsg@gmail.com>
@MicroProofs
MicroProofs merged commit d47d277 into main Oct 24, 2026
10 checks passed
@MicroProofs
MicroProofs deleted the fixing-issues branch October 24, 2026 18: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.

1 participant