Skip to content

feat: recursive data types as iso-recursive mu, with fold and unfold (E9, D7) - #438

Merged
hyperpolymath merged 1 commit into
mainfrom
claude/e2e-language-pass-ow72tj
Nov 10, 2026
Merged

hyperpolymath merged 1 commit into
mainfrom
claude/e2e-language-pass-ow72tj

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Summary

v2 plan rung E9, the recursive type half. Decision D7 is ratified (owner, 2026-11-10): iso-recursive types, with fold and unfold from the desugarer. The closure half merged in #437.

A data type that refers to itself was refused. It is now the core type mu X. T, behind the flag --unstable-core.

data IntList = Nil | Cons(I32, IntList)

fn sum(l: IntList): I32 = match l of | Nil => 0 | Cons(h, t) => h + sum(t) end

fn main(): I32 = sum(Cons(1, Cons(2, Cons(3, Nil))))

ephapax run --unstable-core prints 6, as wasm and in the interpreter. This is the acceptance program of the rung.

How

  • Core (ephapax-syntax): Ty::Mu { var, body }, ExprKind::Fold { ty, value }, ExprKind::Unfold(e).
  • Desugarer: a data type that is entered again during its own expansion becomes mu #Name. body. A constructor gets a fold, and a match gets an unfold on its scrutinee. A data type that does not refer to itself has the same lowering as before.
  • Checker: fold[mu X. T](e) needs e : T[X := mu X. T], and unfold gives that type. 2 recursive types are equal when their bodies are, up to the name of the bound variable. A recursive type is not equal to its unfolding.
  • Backends: a value of a recursive type is a pointer to its unfolding (a sum cell, a pair). Fold and unfold do nothing at run time, in wasm and in the interpreter.
  • Linearity: mu X. T is linear when T is linear with X not linear. This is the least fixed point that the plan gives for the grade of a recursive type.

Type parameters

data List(a) = Nil | Cons(a, List(a)) works at each element type.

  • The annotation of a constructor of a recursive data type binds the type parameters (forall a. ...). The checker gives each one a type from the use of the value, and the fold makes the annotations of the injections agree.
  • In the body of a data type, a type parameter now stands for its argument at each depth. Before, only a parameter that was a whole field got its argument: data Box(a) = MkBox((a, a)) kept a.

Refused by name

Form Error
2 data types that refer to each other the data types Tree -> Forest -> Tree refer to each other
a type that refers to itself with other type arguments recursive type Grow refers to itself with other type arguments
a type alias that refers to itself recursive type alias ... only a data type can refer to itself

A type alias in the cycle of a data type is permitted: it is transparent.

The flag

Without --unstable-core, check, compile and run refuse a program that uses a recursive type: "behind the flag --unstable-core: the recursive type IntList ...". A declaration with no use is accepted. The probe rec1 now has this verdict, so its 2 rows in tests/probes/expectations.tsv change from error:DesugarError to error:UnstableCore, and scripts/probe-battery.sh knows that kind.

Verification

  • cargo test --workspace --no-fail-fast: 636 passed, 0 failed.
  • cargo fmt --all --check and cargo clippy --workspace --all-targets: clean.
  • scripts/status-gate.sh: 633 all-target tests (8 more than before).
  • scripts/probe-battery.sh: 51 SAME, 7 SKIP (the SKIP rows need the wasmtime CLI). scripts/test-probe-battery.sh: 4 passed.
  • New file recursive_types.rs, 6 tests, each program as wasm and in the interpreter:
    • a_recursive_data_type_runs (10 programs: sum, range, append, map with a closure, guards and literals, values in pairs, lists and closures, 3 constructors, an alias in the cycle);
    • a_recursive_data_type_with_type_parameters_runs (7 programs: Bool, I64, F64, a list of lists, a rose tree, a generic function, a type from its use);
    • a_recursive_data_type_crosses_a_module (5 programs);
    • a_recursive_type_keeps_the_linear_rules (a list of strings: used 2 times, a part not consumed, not used, and a region escape are each refused);
    • a_recursive_form_with_no_lowering_is_refused_by_name;
    • a_recursive_data_type_is_behind_the_flag.
  • linearity_holes.rs: the rec1 test now checks the flag, and 1 new test checks the 3 named desugar errors.
  • ephapax-ir: a round trip test for fold, unfold, mu and a bound type parameter.

Not in this pull request

  • 2 data types that refer to each other (refused by name).
  • A positivity check: data Self = MkSelf(Self -> I32) is accepted.
  • The typed-wasm gate for E9 (it follows E8, which waits for the upstream typed-wasm change).
  • Promotion out of --unstable-core (it waits for proof rung P17).

Generated by Claude Code

…(E9, D7)

v2 plan rung E9, the recursive type half. Decision D7 is ratified
(owner, 2026-11-10): iso-recursive types, with fold and unfold from the
desugarer.

A `data` type that refers to itself was refused. It is now the core
type `mu X. T`. The desugarer puts a `fold` at each constructor and an
`unfold` at each `match`. A value of the type is a pointer to its
unfolding (a sum cell, a pair), so fold and unfold do nothing at run
time, in wasm and in the interpreter. The form is behind the flag
`--unstable-core`, as the plan says for E9.

- Core: `Ty::Mu`, `ExprKind::Fold`, `ExprKind::Unfold`. A recursive
  type is linear when its body is (the least fixed point of the plan).
- Checker: the 2 rules, and equality of 2 recursive types up to the
  name of the bound variable. A recursive type is not equal to its
  unfolding.
- A `data` type with type parameters works: `List(a)`. The annotation
  of a constructor binds the type parameters, and the checker finds
  them from the use of the value.
- In the body of a `data` type, a type parameter now stands for its
  argument at each depth. Only a parameter that was a whole field got
  its argument before.
- Refused by name: 2 `data` types that refer to each other, a type
  that refers to itself with other type arguments, and a `type` alias
  that refers to itself. A `type` alias in the cycle of a `data` type
  is permitted.

The linear rules hold for a recursive type with a linear component: a
value is used 1 time, each part is consumed, and it cannot leave its
region.

Tests: recursive_types.rs (6 tests, each program as wasm and in the
interpreter), 1 IR round trip, 1 more test in linearity_holes.rs. The
probe rec1 is now refused for the flag: its 2 rows in the probe battery
have the new verdict UnstableCore. 636 tests pass; the all-target count
is 633.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u
@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

Clippy (1.99.1)

Clippy execution failed

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@sonarqubecloud

Copy link
Copy Markdown

@hyperpolymath
hyperpolymath marked this pull request as ready for review November 10, 2026 19:56
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ❌ Failed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T20:02:15.817587Z a1f48c7 Draft marked ready
🔒 Security Review ❌ Failed 2026-11-10T19:56:24.508060Z
We couldn’t complete this review. Comment @codex security review to try again.
a1f48c7 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@hyperpolymath
hyperpolymath merged commit 7252515 into main Nov 10, 2026
47 of 48 checks passed
@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has exhausted its Codex allowance and has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@hyperpolymath
hyperpolymath deleted the claude/e2e-language-pass-ow72tj branch November 10, 2026 19:56
hyperpolymath added a commit that referenced this pull request Nov 10, 2026
## Summary

A constructor of a `data` type with type parameters did not check when
the other side of its sum had a type parameter.

| Program | Before | Now |
|---|---|---|
| `fn none(): Option(I32) = None` | Type mismatch: expected `I32`, found
`Var("a")` | accepted |
| `or(None, 2)` with `or(o: Option(I32), d: I32)` | Type mismatch | 2 |
| `let o = None in match o of ...` | Type mismatch | runs |
| `pick(L(42))` with `pick(e: Either(I32, Bool))` | Type mismatch | runs
|
| `fn none<T>(): Option(T) = None` | Type mismatch (`Var("a")` against
`Var("T")`) | accepted |

`Some(n)` checked before, because the other side of its sum is `()`.

## Cause and change

The annotation of an injection had the type parameter itself
(`inl[a](())`), which is no type at the constructor. It checked only
when a generic function gave its own type parameter the same name as the
`data` type did.

The annotation now binds the type parameters of the `data` type
(`inl[forall a. a](())`), for each `data` type with type parameters.
#438 did this for a `data` type that refers to itself. The checker gives
each bound parameter a type from the use of the value.

Each annotation gets its own type: an injection has each type on its
other side. So a side that nothing uses stays free. A use at another
type is a type error, as before: `g(B(1))` with `g(t: T(Bool))` gives
"Type mismatch".

No flag: a `data` type with type parameters is not behind
`--unstable-core`.

## Verification

- `cargo test --workspace --no-fail-fast`: 637 passed, 0 failed. No
program of the corpus changed.
- `cargo fmt --all --check` and `cargo clippy --workspace
--all-targets`: clean.
- `scripts/status-gate.sh`: 634 all-target tests (1 more than before).
- `scripts/probe-battery.sh`: 51 SAME, 7 SKIP (the SKIP rows need the
wasmtime CLI).
- New test `a_constructor_gets_its_type_arguments_from_its_use` in
`run_wasm.rs`: 7 programs, as wasm and in the interpreter (a result, an
argument, no annotation, the 2 branches of an `if`, a generic function,
a constructor in a constructor, 2 type parameters at I64 and F64, 3
constructors), and 1 program that is refused.

---
_Generated by [Claude
Code](https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u)_

Co-authored-by: Claude <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1f48c729f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +398 to +400
if let (false, Decl::Fn { params, ret_ty, .. }) = (unstable_core, decl) {
let tys = params.iter().map(|(_, ty)| ty).chain([ret_ty]);
if let Some(ty) = tys.filter_map(recursive_name).next() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce unstable-core for recursive extern signatures

When a recursive type appears only in an extern function signature, both check and compile accept it without --unstable-core: the declaration loop skips every Decl::Extern, while this new signature check handles only local functions. For example, an unflagged program can declare extern source/sink functions over IntList and shuttle sink(source()) through an ordinary function whose own signature is non-recursive; no Fold or Unfold is present for the expression traversal to catch, bypassing the feature gate entirely.

Useful? React with 👍 / 👎.

Comment on lines +1152 to +1154
(Ty::Mu { var: v1, body: b1 }, Ty::Mu { var: v2, body: b2 }) => {
let b2 = b2.subst_var(v2, &Ty::Var(v1.clone()));
self.unify(s, b1, &b2)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply recursive alpha-equivalence at every type comparison

Although this arm makes mu types equal modulo their bound variable, existing comparison sites still use derived Ty equality instead of unify, notably annotated let! bindings and list elements. Consequently, two recursive data declarations with identical structural bodies but different names are accepted in function arguments or ordinary let annotations yet rejected in let! x: A = EndB or [EndA, EndB], solely because their generated binders are #A and #B; those sites need to use the same alpha-equivalent comparison.

Useful? React with 👍 / 👎.

hyperpolymath added a commit that referenced this pull request Nov 10, 2026
…ve types (#440)

## Summary

`data` types that refer to each other were refused by name (#438). They
are now accepted, behind the flag `--unstable-core` as each recursive
type is.

```
data Tree = Leaf | Node(I32, Forest)

data Forest = FNil | FCons(Tree, Forest)
```

The core has no new former, and the checker and the 2 backends do not
change. Only the type expansion of the desugarer changes.

## How

The types of a cycle become nested `mu` types (Bekic's construction), in
the order of their names. A `data` type that is entered hides, for its
body, each `data` type in progress that comes after it: in that body,
such a type is not a variable, and it is expanded again. For `Forest`
before `Tree`:

```
Forest = mu F. () + ((() + (I32 * F)) * F)
Tree   = () + (I32 * Forest)        (not folded)
```

The reason for this order rule: with iso-recursive types, a type is not
equal to its unfolding. So the unfolding of each type must have the
other types in it as they are. With the rule it does, for a cycle of
each form and each number of types. A field of a constructor then has
the type that its declaration names, and no fold or unfold is missing or
in excess.

2 more changes follow from the nesting:

- A `data` type is folded when its own expansion refers to it. A type
whose type is the recursive type of another `data` type (`data B =
MkB(A)`) is not folded.
- Equality of 2 recursive types gives the 2 bound variables a new name
when their names differ: a body can have a recursive type in it that
binds the name of the other variable.

## Limit, with a named error

A dense cycle makes the nested types grow fast.

| Cycle | Bodies in the largest type |
|---|---|
| 2 types, each refers to the 2 | 7 |
| 3 types, each refers to the 3 | 64 |
| 4 types, each refers to the 4 | 3613 |
| 5 types, each refers to the 5 | 8 million |
| a ring of 32 types | 127 |
| 5 types as in a syntax tree (Expr, Stmt, Decl, Pat, Type) | fewer than
400 |

A type of more than 10000 bodies, or of more than 256 nested named
types, is refused: "the type `T4` is too large ...". Without the limit,
the dense cycle of 5 does not end, and a ring of 700 types overflows the
stack. The depth limit also applies to `data` types that do not refer to
each other: 700 nested `data` types overflowed the stack before this
change, and are now the named error.

A recursive type of n variables (as the `rec` groups of WasmGC) removes
the size limit. It is a change of the core language, so it is a decision
for the owner and not part of this pull request. The plan document
records this next to the D7 ratification.

## Verification

- `cargo test --workspace --no-fail-fast`: 638 passed, 0 failed.
- `cargo fmt --all --check` and `cargo clippy --workspace
--all-targets`: clean.
- `scripts/status-gate.sh`: 635 all-target tests (1 more than before).
- `scripts/probe-battery.sh`: 51 SAME, 7 SKIP (the SKIP rows need the
wasmtime CLI).
- New test `data_types_that_refer_to_each_other_run`, each program as
wasm and in the interpreter:
- Tree and Forest, Even and Odd, a cycle where no type refers to itself,
type parameters at I64, a linear component;
- a cycle of 3 types where each one refers to the 2 others, in each of
the 6 orders of its names (each order is another nesting);
- refused: a value of a linear type of a cycle used 2 times (2
programs), a `Forest` as a `Tree`, a `Tree` as a `Forest`.
- `a_recursive_form_with_no_lowering_is_refused_by_name`: the 2 limits
(the dense cycle of 5, a ring of 300).
- `a_recursive_data_type_crosses_a_module`: 2 types that refer to each
other, used from another module.

---
_Generated by [Claude
Code](https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u)_

Co-authored-by: Claude <noreply@anthropic.com>
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.

3 participants