Skip to content

Exact and bounded binder annotations: x: T fixes the type, x <: T bounds it - #62

Merged
dpmills merged 4 commits into
mainfrom
dmills/subtype-annotations
Aug 17, 2026
Merged

Exact and bounded binder annotations: x: T fixes the type, x <: T bounds it#62
dpmills merged 4 commits into
mainfrom
dmills/subtype-annotations

Conversation

@dpmills

@dpmills dpmills commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

CHL gets two binder-annotation forms, meaning the same thing at every binder position — =, :=, and def parameters. x: T is exact: the binder's type is T, and nothing downstream sees more. x <: T is bounded: the type is inferred with T as an upper bound, so the value's own type flows through. Bounded is the behaviour both positions already had, so existing programs keep working under <: and the corpus needed no migration; what is new is : meaning what it says. That closes the asymmetry that motivated the work — def f(v: {a: Int}): v.b and x: {a: Int} = (a=1, b=2); x.b now agree — and it gives a program its one lever over specialization count. Spec: Two annotation forms: exact and bounded; design: type-inference.md.

The forms coincide only where the value's type already is the annotation. They differ on width (x: {a: Int} = (a=1, b=2) makes x.b an error; <: binds at {a: 1, b: 2}) and on literal singletons (x: Int = 5 discards the singleton, x <: Int = 5 keeps 5 and still discharges arr[x]'s index obligation) — so a "simple" annotation is no guarantee that the two agree.

Type::BoundedHole(T) is a marker in a type slot, not a type

In annotation position only; normalize_annotation erases it into a fresh variable bounded above by T. It is Type::Hole one rung up, and the two compose where a compound annotation is partly specified. It denotes nothing — a bound picks out no set of values — so no typing rule may take one: five solver sites assert that rather than inventing a rule, and only the structural walks that rewrite every slot uniformly pass through. Putting the bound in the type is forced by the multi-parameter encoding: def f(x: A, y <: B, z) uncurries to one tuple annotation, and Tuple([A, BoundedHole(B), Hole]) expresses three modes inside one type.

Neither binder rule needs a mode test

A parameter binds at normalize(annotation) — exact to T itself, bounded to a variable bounded by T — which retires emit_lambda's bind-fresh-then-reconcile step, the reason an exact annotation used to behave as neither reading. A let binds at the same normalization of its completed annotation (emit::complete_annotation fills each Hole from the initializer's type, structurally rather than by constraint, because variables minted after the RHS's level is popped escape inference unresolved). Two special cases fall out as consequences: a deref-copy binds at the annotation because that is what exact means, and a bare _ completes to the initializer's type.

A bound on a mutable variable bounds its value type, so x <: Mut(V) := e and x <: V := e are one declaration: lowering applies the binder's mode to the value it extracts from a Mut(…) annotation (apply_annotation_mode). The value position is also the only one available — the binder's slot must stay structurally a History for mut_value_type, mut_elim and transact_phase to dispatch on — so normalize_annotation asserts a bound never wraps a history rather than collapsing one.

Two things a reader might expect and should not

  • A bounded parameter's type is still the meet of its bound and its body's demands, so def f(v <: {a: Int}): v.b requires callers to supply both fields.
  • <: is not writable in nested positions, because it describes a binder, not a type. That, and the bounded reading arguably being the more common intent while carrying the heavier spelling, is why the spec marks the spelling [Open]; the mode is a two-valued property lowering reads off the surface, so a respelling is a parser change.

Tests cover both differing columns, the two binder positions agreeing, _ completion, a bounded variable's bound constraining seed and writes, and specialization count: def f(v <: Int) called at f(1) and f(2) produces two clones, def f(v: Int) one. Only four existing assertions changed, each asserting the old reading of : directly.

@dpmills
dpmills force-pushed the dmills/subtype-annotations branch 3 times, most recently from 25b064a to 5629a5f Compare August 5, 2026 00:29
@dpmills
dpmills changed the base branch from dmills/annotation-fixups to dmills/explicit-register-reads August 5, 2026 00:30
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 5629a5f to cebcec3 Compare August 5, 2026 03:59
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from cebcec3 to f78705f Compare August 5, 2026 04:05
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from f78705f to 9e6eee8 Compare August 5, 2026 18:41
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 9e6eee8 to 98a843e Compare August 5, 2026 19:46
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 98a843e to 5afd428 Compare August 5, 2026 20:09
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 5afd428 to 2f27abc Compare August 5, 2026 21:06
@dpmills
dpmills marked this pull request as ready for review August 5, 2026 21:08
@dpmills
dpmills requested a review from a team as a code owner August 5, 2026 21:08
@dpmills
dpmills requested review from groundlar and removed request for a team August 5, 2026 21:08
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 2f27abc to 0e8b9a8 Compare August 5, 2026 22:34
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from b003f1c to b984d6b Compare August 10, 2026 17:34
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from b984d6b to 78583cc Compare August 11, 2026 01:21
@dpmills
dpmills requested review from sortalongo and removed request for groundlar August 11, 2026 18:14
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 78583cc to 1a4ffcd Compare August 12, 2026 00:09
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 1a4ffcd to 248e2d6 Compare August 12, 2026 00:38
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch from 248e2d6 to 81b396b Compare August 12, 2026 20:08
@dpmills
dpmills force-pushed the dmills/subtype-annotations branch 2 times, most recently from b8c4ec8 to f49cdba Compare August 12, 2026 22:30
… bounds it

CHL gets two binder-annotation forms, meaning the same thing at every binder position:

- `x: T` is **exact** — the binder's type *is* `T`. The initializer (or argument) must be a subtype of it, and nothing downstream sees more than `T`.
- `x <: T` is **bounded** — the type is inferred with `T` as an upper bound, so the value's own type flows through.

Both are accepted wherever a binder is introduced — `=`, `:=`, and `def` parameters — which closes the asymmetry that motivated the work. `def f(v: {a: Int}): v.b` and `x: {a: Int} = (a=1, b=2); x.b` now agree, and both are written with `<:` when the value's wider type should survive.

The two forms coincide only where the value's type already **is** the annotation, leaving nothing to discard. They differ wherever the value's type is a *strict* subtype of it, and the annotation's own shape does not decide that — a Cambra type carries more than a base:

- **Width.** `x : {a: Int} = (a=1, b=2)` binds `x` at `{a: Int}`, so `x.b` is an error; `x <: {a: Int} = (a=1, b=2)` binds at `{a: 1, b: 2}` and `x.b` is `2`.
- **Literal singletons.** `x : Int = 5` binds `x` at `Int` — the annotation is precisely what discards the singleton — while `x <: Int = 5` leaves it at `5`, and only the second still discharges `arr[x]`'s index-range obligation.

Note that the second example annotates a bare `Int` and the forms still differ, because `5` is a strict subtype of `Int`. A "simple" annotation is no guarantee that the two agree; only a value that knows nothing beyond the annotation is.

**The bounded column is the behaviour both positions already had**, so existing programs keep working under `<:`; what is new is `:` meaning what it says. The whole corpus and test suite needed no migration — only four assertions changed, each asserting the old reading of `:` directly.

In annotation position only; `normalize_annotation` erases it into a fresh variable bounded above by `T`. It is `Type::Hole` one rung up — `Hole` is the unbounded case, and the two compose wherever a compound annotation is partly specified.

**It is not a type**, and that is the first thing to know about it. `Hole`, `Infer`, and `Below` all inhabit the `Type` enum because annotation and binder positions are typed positions, not because they denote anything: `Below(T)` is not "the type of values below `T`" — no such type exists, since a bound picks out no set of values on its own. It records an obligation, and inference discharges it by minting a variable and giving it `T` as an upper bound, after which the bound lives where bounds belong, on a variable in the constraint graph.

So **no typing rule may take a `Below`**: nothing to subtype against, nothing to reduce, nothing to compact, and the solver asserts that at four sites rather than inventing a rule. Only the structural walks that rewrite every slot uniformly — substitution, free-variable collection, refinement stripping — pass through one, because they are indifferent to what a slot means.

Putting the bound *in the type* is forced by the **multi-parameter encoding** rather than chosen for symmetry. A `def` with several parameters uncurries to one tuple parameter with a single `Type::Tuple` annotation, so `def f(x: A, y <: B, z)` must express three modes *inside one type*: `Tuple([A, Below(B), Hole])` does it with no new plumbing. Carrying the mode alongside the type would need a mode *tree* mirroring the type's shape — this variant in a worse spelling.

A parameter binds at `normalize(annotation)`: exact normalizes to `T` itself, bounded to a variable bounded by `T`. The old two-step — bind at a fresh variable, *then* reconcile against the annotation — is what made an exact annotation behave as neither reading, contributing one upper bound among several instead of *being* the type. `emit_lambda` loses that reconcile entirely.

A `let` binds at the same normalization of its completed annotation, and two special cases fall out as consequences rather than tests: a **deref-copy** (`y: Int = x` off a register) binds at the annotation because that is what exact *means*, so the `bound_ty.is_handle()` test is gone; and a bare `_` completes to the initializer's type, so the `annotation_is_unspecified` gate is gone too.

Completed structurally from the initializer (`emit::complete_annotation`), so `x: _ = e` is exactly `x = e` and `x: List(_) = [1, 2, 3]` binds at `List(Int)`.

It is a function on types rather than a constraint because the constraint version does not work: binding at a normalized annotation and relying on the one-way `rhs <: ann` edge to drive its variables leaves them minted after the RHS's level is popped, so they escape inference unresolved. Records complete by *name*, so a field the annotation omits is dropped rather than completed — exactly the width an exact annotation discards. A parameter has no initializer, so a `Hole` there is a fresh variable resolved from the call sites.

`x <: Mut(V) := e` declares a register whose **value type is inferred, subject to `<: V`** — the same declaration as `x <: V := e`, and the same as writing no annotation whenever the inferred type already satisfies `V`. So `x <: Mut(Int) := 5` binds the value at `5`, exactly as the unannotated `x := 5` does, while `x: Mut(Int) := 5` binds it at `Int`. The bound remains an obligation on every contribution to the value type: the seed and each write.

The mode has to land on the value because a `Mut(…)` annotation is not lowered as one type — `mut_annotation_parts` splits it into a value type and a domain — so lowering applies the binder's mode to the value it extracts (`apply_annotation_mode`, shared with `lower_type_annotation` and with the pass-by-reference parameter path). That is what makes `<: Mut(V)` and `<: V` agree by construction.

The value position is also the *only* place the bound can go, and that is a pipeline fact rather than a variance one. A register binder's slot must stay structurally a `History`: `as_register`, the deref coercion, `mut_elim`, and `transact_phase` all dispatch on that shape, and a variable standing for the whole handle would skip a write's `value <: V` edge, so the register would never receive its writes. The value position carries no such requirement — a variable there is the ordinary case, since an unannotated `x := 5` binds at `Mut(?v, ?d)`.

A bound therefore never wraps a history, and `normalize_annotation` asserts it rather than collapsing one: a lowering path that builds a history without routing its annotation through the mode trips the assert instead of silently reading as exact.

An exact parameter annotation is a specialization boundary, and it is the only lever a program has over clone count. Measured through the surface: `def f(v <: Int): v + 1` called at `f(1)` and `f(2)` produces **two** specializations — the domain is a variable, so each argument's *literal singleton* reaches `SpecKey`'s negative read — while `def f(v: Int)` produces **one**. Pinned as a test.

Two caveats recorded in the design. The win is confined to the domain, and the key's codomain read follows the consumer's demand — deliberately, since a key blind to the consumer would under-split — so the collapse reaches only as far as the consumers agree. And the bounded form is checked per call site rather than once, because `freshen_above` copies the bound into every instantiation.

What that no longer has to survive is a *contentless* disagreement in the codomain. Two uses landing in the two operand slots of one `+` reached the enclosing operator's shared `CommonBase(α, β)` requirement from opposite sides and recorded it in opposite argument order, splitting clones of identical code — `f(1) + f(1)` produced two. A symmetric operator's arguments are compared as a multiset now; pinned here alongside the bounded form, which still splits under the same consumer because there the argument reaches the domain.

- A bounded parameter's type is still the **meet** of its bound and its body's demands — that is what bounded means, so `def f(v <: {a: Int}): v.b` requires callers to supply both fields.
- `<:` is not writable in nested positions, because it describes a *binder*, not a type.

`Below` cannot outlive inference: the annotation slots it occupies do not survive it (the base PR clears them), so a binder `ty` is the only place a survivor could hide, where `collect_type_errors` reports `UnresolvedBelow`. That check is a backstop and its test says so — a `Below` reaching the solver un-normalized is rejected earlier, since nothing can be constrained against one.

`bind_annotation` now returns the normalized annotation, because normalizing is **not idempotent**: `Hole` and `Below` mint a fresh variable per call, so a caller that both reconciles against an annotation and binds at it must use one normalization or it relates two unrelated variables.

A new spec section, "Two annotation forms: exact and bounded", plus corrections to §3.1 (which documented the bounded reading of `:` as the only one) and §4.1 ("they refine the inferred parameter type"). The design of record is a new section in `src/ccl/design/type-inference.md`, committed separately ahead of the implementation.

`./ci.sh` green.

The register-read cases in the mutability suite carried one `annotated` case written before the split; it becomes two. A **bounded** parameter is inferred with `Int` as an upper bound, so an unwritten register's seed singleton still reaches it. An **exact** parameter is a specialization boundary that fixes the domain at `Int`, so the singleton never arrives — which is the difference worth pinning, and the reason to keep the two side by side.

The spec gains a "Two annotation forms: exact and bounded" section covering both forms at every binder, the cases where they differ (width, literal singletons, register value types), `_` as declaring nothing, and the specialization-count consequence. It opens with a note marking the *spelling* `[Open]` while the distinction itself is implemented and pinned by tests.

Two things make `:` / `<:` unsatisfying, and they are worth writing down before the tokens harden. `<:` reads as a type operator but describes a **binder**, which is why it cannot appear in a nested position — a restriction that falls out of the implementation rather than out of anything the notation suggests. And the bounded reading is arguably the more common intent, yet it carries the heavier spelling.

Nothing in the design depends on which tokens win: the mode is a two-valued property of a binder that lowering reads off the surface and turns into `Below`-or-not, so a respelling is a parser change. `type-inference.md` says so where it introduces the two forms.
…onflict uncalled

The traits branch below this one leaves a never-called definition's trait
conflicts unreached, because narrowing consumes bases that *arrive* at an
operand and a definition nobody calls delivers none. An exact annotation is
a delivery — it binds the parameter at `Int` rather than at a variable
`Int` sits above — so `def f(x: Int): x + "s"` is rejected with no call
site, and moves back to the rejection cases.

Its bounded twin is added alongside, as a case that is *not* caught. It is
equally ill-typed and equally rejected at a call; `x <: Int` simply puts no
base on the operand, so today's one-delivery-at-a-time narrowing has nothing
to consume. That is a gap in reach rather than anything the exact/bounded
split says about the program — measured, reading `x`'s requirements together
with its `Int` bound rejects it too — and both the test and the design doc
say so, so the pair is not misread as the split promising less of `<:`.
Every program this branch adds with `\n` becomes an `indoc!` block, per
`CLAUDE.md`. The exact/bounded pairs are the ones this matters most for: the
two spellings differ by a single token, so the programs they compare have to
be readable side by side.
Comment thread docs/chl-spec.md Outdated
Comment thread src/ccl/ty.rs Outdated
Comment thread docs/chl-spec.md Outdated
Comment thread docs/chl-spec.md Outdated
Comment thread docs/chl-spec.md Outdated
Comment thread docs/chl-spec.md Outdated
`Type::History` is invariant in both payloads — a mutable variable is read
*and* written through the same binder, and `constrain` relates two histories
of the same kind in both directions. A `:=` binder's type is a `Mut(V, D)`.
Together those rule out the two annotated spellings this branch had been
accepting, and both were being accepted by reinterpretation rather than by
meaning what they say:

- `x: V := e` names the value type while the binder is at `Mut(V, D)`, so
  reading a bare `V` there made `:` mean something at a `:=` binder that it
  means at no other binder — precisely the exactness this branch introduced
  everywhere else.
- `x <: Mut(V) := e` claims nothing `:` does not, since under invariance the
  only type below `Mut(V, D)` is `Mut(V, D)`. It was accepted by *distributing*
  the bound into the value position (`Mut(BoundedHole(V), D)`), which is a
  different claim from the one written.

Both are now rejected, sharing one diagnostic because they share one remedy.
The invariance argument does not depend on the binder being a `:=`, so a
bounded pass-by-reference parameter goes too: a `Mut(…)` annotation is exact
wherever it is written.

That retires the machinery distribution needed. `apply_annotation_mode` existed
only to push a binder's mode through the value/domain split and folds back into
`lower_type_annotation`; `normalize_annotation`'s bound-wraps-a-history arm
keeps its assertion but loses the essay justifying the value position, which is
no longer a position anything can reach.

The cost is that "a mutable whose value type is inferred under a ceiling" has
no spelling. That is the honest position rather than a gap: under invariance it
is not a bound on the binder's type at all, and a bound in the value position
would need `<:` inside a type literal, which does not exist. Recorded as
`[Open]` in the spec so the option stays open.

`Below` also becomes `BoundedHole` throughout — pithy but ambiguous against the
many ordering senses of "below", where `BoundedHole` says what the marker is: a
`Hole` with a ceiling.
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.

2 participants