Exact and bounded binder annotations: x: T fixes the type, x <: T bounds it - #62
Merged
Conversation
Contributor
Author
This was referenced Aug 4, 2026
dpmills
force-pushed
the
dmills/subtype-annotations
branch
3 times, most recently
from
August 5, 2026 00:29
25b064a to
5629a5f
Compare
dpmills
changed the base branch from
dmills/annotation-fixups
to
dmills/explicit-register-reads
August 5, 2026 00:30
This was referenced Aug 5, 2026
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 03:59
5629a5f to
cebcec3
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 04:05
cebcec3 to
f78705f
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 18:41
f78705f to
9e6eee8
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 19:46
9e6eee8 to
98a843e
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 20:09
98a843e to
5afd428
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 21:06
5afd428 to
2f27abc
Compare
dpmills
marked this pull request as ready for review
August 5, 2026 21:08
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 5, 2026 22:34
2f27abc to
0e8b9a8
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 10, 2026 17:34
b003f1c to
b984d6b
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 11, 2026 01:21
b984d6b to
78583cc
Compare
This was referenced Aug 11, 2026
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 12, 2026 00:09
78583cc to
1a4ffcd
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 12, 2026 00:38
1a4ffcd to
248e2d6
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
from
August 12, 2026 20:08
248e2d6 to
81b396b
Compare
dpmills
force-pushed
the
dmills/subtype-annotations
branch
2 times, most recently
from
August 12, 2026 22:30
b8c4ec8 to
f49cdba
Compare
… 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.
sortalongo
approved these changes
Aug 14, 2026
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CHL gets two binder-annotation forms, meaning the same thing at every binder position —
=,:=, anddefparameters.x: Tis exact: the binder's type isT, and nothing downstream sees more.x <: Tis bounded: the type is inferred withTas 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.bandx: {a: Int} = (a=1, b=2); x.bnow 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)makesx.ban error;<:binds at{a: 1, b: 2}) and on literal singletons (x: Int = 5discards the singleton,x <: Int = 5keeps5and still dischargesarr[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 typeIn annotation position only;
normalize_annotationerases it into a fresh variable bounded above byT. It isType::Holeone 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, andTuple([A, BoundedHole(B), Hole])expresses three modes inside one type.Neither binder rule needs a mode test
A parameter binds at
normalize(annotation)— exact toTitself, bounded to a variable bounded byT— which retiresemit_lambda's bind-fresh-then-reconcile step, the reason an exact annotation used to behave as neither reading. Aletbinds at the same normalization of its completed annotation (emit::complete_annotationfills eachHolefrom 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) := eandx <: V := eare one declaration: lowering applies the binder's mode to the value it extracts from aMut(…)annotation (apply_annotation_mode). The value position is also the only one available — the binder's slot must stay structurally aHistoryformut_value_type,mut_elimandtransact_phaseto dispatch on — sonormalize_annotationasserts a bound never wraps a history rather than collapsing one.Two things a reader might expect and should not
def f(v <: {a: Int}): v.brequires 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 atf(1)andf(2)produces two clones,def f(v: Int)one. Only four existing assertions changed, each asserting the old reading of:directly.