Skip to content

Traits: state what an operator requires of its operands, not that they are the same - #73

Merged
dpmills merged 6 commits into
mainfrom
dmills/traits
Aug 14, 2026
Merged

Traits: state what an operator requires of its operands, not that they are the same#73
dpmills merged 6 commits into
mainfrom
dmills/traits

Conversation

@dpmills

@dpmills dpmills commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

A polymorphic operator's requirement cannot be stated as a signature. The only relation a signature can put between two operands is that they share a variable, which also forces every other lattice dimension to agree — so one operand's refinement became a requirement on the other, and a refinement both operands carried survived onto a value the operator computed rather than selected. Arithmetic, comparison and negation now state a trait instead: unrelated inference variables per operand, plus an obligation recorded beside the graph. Because an operator's result is an ordinary variable rather than a marker standing for a computation, information still flows backwards through it — so a function typechecks without its call sites, and (1 + 2) and True is an ordinary bound conflict. The refinement strip in apply_binary_scheme goes away rather than being fixed: a syntactic peel does nothing for an operand still holding a Type::Infer.

src/ccl/infer/solver/traits.rs opens with the vocabulary; type-inference.md carries the design. One term to fix here, because it reads two ways: an obligation is a single claim with two halves, and neither alone is "the obligation" — the operands are types some implementation accepts, and each associated position is what that implementation associates.

Traits associate types; they are not functions

A trait names associated types (Rust's term) rather than having an output, and a type is associated only when it depends on the types satisfying the trait. Three shapes result — unary with an Output (Negatable), binary with one (Addable), binary with none (Equatable/Orderable) — each exercised by ordinary programs (each_trait_shape_types_a_real_program).

What an obligation determines

A candidate set shrinks as base types arrive, and an associated type is deposited once every survivor agrees on it — onto associated positions only, since the obligation is their sole source of information while an operand always has the program's own left <: A edge. How much is determined is therefore a property of the table, and shrinks as it grows including for an associated type: \x -> x + 1 has result Int only because Int in the second position leaves one row, and Addable(Float, Int) ⇝ Float would open the result just as the parameter already is. Hence agreement, not uniqueness, as the deposit condition.

Each variant's doc states its shape — how many types it is over, and what it associates — and Trait::arity/assocs read that shape off the table so the prose cannot drift from the rows (every_trait_has_a_consistent_shape).

Delivery

One invariant carries the mechanism — a concrete type reaching an operand variable must reach the obligation watching it — and the bound closure does not provide it. A variable's lower bounds are written in four places, delivery is wired into each, and all four are load-bearing: deleting one fails exactly its own case in a_concrete_operand_reaches_its_obligation. The design doc argues it; verify_narrowing_is_complete checks it on every program rather than trusting it.

Consequences

test_lambda_unapplied asserts an open parameter with the codomain per case; test_collect_multi_conflict and test_unary_neg_wrong_type see NoTraitImpl naming the trait instead of a mismatch against a hardcoded domain. a_register_that_reads_itself_still_gets_a_type ports #51's recurrence cases, which pin something stronger here: narrowing consumes bounds as they are recorded rather than resolved types, so an obligation never enters the recurrence — the base it needs sits on the register's seed.

max's comparability tripwire flips, closing type-checker-traits-comparability. Comparable(γ) on its codomain is the fourth shape — unary, associating nothing, since the scheme already returns an element of what it consumes.

@dpmills

dpmills commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

This change is part of the following stack:

Change managed by git-spice.

@sortalongo sortalongo left a comment

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.

Review in progress. Stopped at ## Discharge is incremental for now

Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
Comment thread src/ccl/design/type-inference.md Outdated
…y are the same

A polymorphic operator's requirement cannot be stated as a signature. The only relation a signature can put between two operands is that they *share a variable*, which also forces every other lattice dimension to agree — so one operand's refinement became a requirement on the other, and a refinement both operands carried survived onto a value the operator computed rather than selected. Arithmetic, comparison and negation now state a **trait** instead: unrelated inference variables per operand, plus an obligation recorded beside the graph. Because an operator's result is an ordinary variable rather than a marker standing for a computation, information still flows backwards through it — so a function typechecks without its call sites, and `(1 + 2) and True` is an ordinary bound conflict. The refinement strip in `apply_binary_scheme` goes away rather than being fixed: a syntactic peel does nothing for an operand still holding a `Type::Infer`.

`src/ccl/infer/solver/traits.rs` opens with the vocabulary; [type-inference.md](src/ccl/design/type-inference.md#traits) carries the design. One term to fix here, because it reads two ways: an **obligation** is a single claim with two halves, and neither alone is "the obligation" — *the operands are types some implementation accepts*, **and** *each associated position is what that implementation associates*.

A trait names **associated types** (Rust's term) rather than having *an* output, and a type is associated only when it **depends** on the types satisfying the trait. Three shapes result — unary with an `Output` (`Negatable`), binary with one (`Addable`), binary with **none** (`Equatable`/`Orderable`) — each exercised by ordinary programs (`each_trait_shape_types_a_real_program`).

The third is the correction worth reviewing. A comparison's `Bool` is the same for every pair the trait accepts, so it says nothing about them: it is the *operator's* signature, not the trait's — `PartialEq` has no associated type either. Modelling it as one made "a comparison settles its output at birth" look like a property when it was a constant mis-recorded as a computed type. `Neg` moves off its monomorphic scheme for the same reason, giving the unary arity a real user.

A candidate set shrinks as base types arrive, and an associated type is deposited once every survivor agrees on it — onto **associated positions only**, since the obligation is their sole source of information while an operand always has the program's own `left <: A` edge. How much is determined is therefore a property of the table, and shrinks as it grows *including for an associated type*: `\x -> x + 1` has result `Int` only because `Int` in the second position leaves one row, and `Addable(Float, Int) ⇝ Float` would open the result just as the parameter already is. Hence agreement, not uniqueness, as the deposit condition.

Each variant's doc states its shape — how many types it is over, and what it associates — and `Trait::arity`/`assocs` read that shape off the table so the prose cannot drift from the rows (`every_trait_has_a_consistent_shape`).

One invariant carries the mechanism — a concrete type reaching an operand variable must reach the obligation watching it — and the bound closure does *not* provide it. A variable's lower bounds are written in four places, delivery is wired into each, and all four are load-bearing: deleting one fails exactly its own case in `a_concrete_operand_reaches_its_obligation`. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) argues it; `verify_narrowing_is_complete` checks it on every program rather than trusting it.

`test_lambda_unapplied` asserts an open parameter with the codomain per case; `test_collect_multi_conflict` and `test_unary_neg_wrong_type` see `NoTraitImpl` naming the trait instead of a mismatch against a hardcoded domain. `a_register_that_reads_itself_still_gets_a_type` ports #51's recurrence cases, which pin something stronger here: narrowing consumes bounds as they are recorded rather than resolved types, so an obligation never enters the recurrence — the base it needs sits on the register's seed.

**`max`'s comparability tripwire flips**, closing `type-checker-traits-comparability`. `Comparable(γ)` on its codomain is the fourth shape — unary, associating nothing, since the scheme already returns an element of what it consumes.

That needed a fix worth reviewing on its own: a contribution is now classified three ways rather than two — it offers a base, is *not determined yet* (a variable, a hole, a `Feed` handle), or is *determined and not a base* (a tuple, record, variant, function). Only the middle one is "nothing to say"; the third is rejected. Collapsing the two let composites through, so `(1, 2) == (3, 4)` type-checked as `Bool` — a tuple narrowed nothing, and a comparison has no associated position to leave unresolved, so no later wall saw it either. Composites now satisfy no trait (`a_composite_satisfies_no_trait`), which is what the closed tables were always meant to mean.
…oday

The Traits section described a trait as an association between types, which
reads as the whole story rather than the corner that is implemented. Say what
the general shape is and where this sits inside it.

- **A new subsection, "A trait is a relation, and today it relates only
  types."** A trait over `𝑁` operand positions, `𝐴` associated types and `𝐹`
  associated *functions* is an `(𝑁 + 𝐴 + 𝐹)`-ary relation whose `𝑁` operand
  types functionally determine the rest; an implementation is a hyper-edge and
  discharge is the search for a consistent one. Cambra implements
  `𝑁 ∈ {1, 2}`, `𝐴 ∈ {0, 1}`, `𝐹 = 0`, built into the compiler rather than
  declared in CHL. `𝐹 = 0` is named as a gap: `Addable`'s `String` and `Int`
  rows denote different functions, and because the obligation carries neither,
  the function is recovered outside the trait — `simplify.rs`'s `Concat`
  rewrite, then `apply_binop_column`'s dispatch on the operand column's
  runtime representation.

- **Associated types move inside the trait name** — `Addable(𝐴, 𝐵 ⇝ 𝑂)`,
  `Negatable(𝐴 ⇝ 𝑂)`, `Equatable(𝐴, 𝐵)` — so the notation shows the relation's
  structure instead of listing the associations beside it. Implementation rows
  take the same form (`Addable(Int, Int ⇝ Int)`), which retires the split
  between a trait written `Addable(𝐴, 𝐵)` and a row written `(𝐴, 𝐵) ⇝ 𝑂`.

- **"Refinements are transparent" answers whether it is permanent.** It is,
  and not by fiat: a candidate set only ever shrinks and a refinement is
  something a bound can deliver late, so a refined type satisfying a
  requirement its base does not would force a dropped candidate to be
  re-admitted and discharge would stop being order-independent. Growing `𝐹`
  does not change that — selecting a function by a predicate is dispatch on a
  fact about a value.

- A **trait** is a requirement on a *list* of types, not on a type.

Also drops the parenthetical about the signature this replaced, and re-homes
its `an_operator_result_carries_no_operand_refinement` citation to §3's
"operator does not inherit its operands' refinements" bullet, where the fact
it pins is actually stated.

## `traits.rs`'s module doc stops mirroring the design section

It had reproduced the section's five headings in the same order — 96 lines
against the design doc's, with the two most mechanism-specific parts
(*Delivery: the watch follows the edge*, *Requirements are generalized*)
present only in the doc. That inverts the split: the module comment carried
the theory and omitted the wiring, and each of the changes above had to be
written twice with nothing checking that the copies agreed.

It now follows `channelize.rs`'s shape — what the code *is*, plus a "Where to
read more" pointer naming `type-inference.md`, "Traits" as the design of
record. `Vocabulary` stays, because its terms are this module's types
(`Trait`, `TraitImpl`, `Assoc`, `TraitObligation`) and the intra-doc links
navigate the API. The rationale leaves: the `(𝑁 + 𝐴 + 𝐹)` framing, the
one-way-deposit argument, and the `λ 𝑥 → 𝑥 + 1` worked example. *Refinements
are transparent* and *Discharge is incremental* collapse into one section
stating what `narrow` and `try_deposit` do, keeping their links. 96 lines to
58.
…ecking

#89 resolves a definition nobody calls, which puts two of its results in
contact with this branch.

**A gap becomes observable.** Narrowing is delivery-driven, so a conflict
that is only a trait conflict is not reached in a definition that delivers
nothing: `f = \a -> (a + 1, a + "s")` places two requirements on `a`,
each satisfiable alone and jointly not. Under the equality rule the two
`+`s collided structurally, so resolution found it. This is the residual
gap #89 already names; the two cases keep their coverage, as a test
asserting they are *accepted*, so the gap has a name rather than being an
absence. Called, both are still rejected, and name the trait, the operand
position, and what that position accepts.

**A performance guard's calibration moves.** Stating what an operator
requires of each operand makes a concrete specialization far cheaper — over
the doubling chain the live case drops ~437x against the dead one's ~109x —
so the factor picked against the old numbers no longer holds. The bound is
now the module's own claim, that dead must not cost *more* than live, rather
than a factor calibrated to one measurement; `DEPTH` is 6 because that is
where sharing (0.81x) and a lost memo (1.54x) are furthest apart.
The Traits section read as an argument for the design instead of a
description of it. Three habits, removed throughout:

**A fixed defect used as the rationale.** `Discharge is incremental`
justified its three-way classification by recounting that `(1, 2) == (3, 4)`
once type-checked. The distinction is now stated forward, as a table of
contribution kinds and outcomes, with that program as an illustration of why
"determined and not a base" is a rejection rather than silence.

**Counterfactual as the main clause.** "The asymmetry is not soundness —",
"Read `F = 0` as a gap and not as the design", "The notation is a relation
because a trait *is* one". Each states the rule first now, with the
alternative it rules out following where it earns the space.

**Editorial voice.** "That is the type honestly tracking a language that has
become more permissive", "worth reading twice". Cut.

Two prose blocks that were carrying tabular content — the contribution
classification and the four lower-bound writers — become a table and a list.
No claim changes.
The convention `CLAUDE.md` records — `indoc!` for a multi-line program, never
a single string with `\n` — with its corollary applied rather than the rule
alone: a fixed line every case shares belongs in the test body, so a case
carries only its own content and a genuine one-liner stays a plain string.

`a_generalized_function_instantiates_its_operator_requirements` is the case
where that matters most: three programs shared a definition and differed only
in the uses, so the definition is hoisted and each program is one visible
line of source. The register-cycle cases are the reverse — every one is a
seed, a self-referential write and a read, which is a shape you cannot see in
`"x := 0\nx := x + 1\nx"`.
Two renames from review, both against terms this repo had already spent
elsewhere. "Discharge" means eliminating a proof obligation throughout —
`Subst::discharge` for Pi binders, refinement and assert discharge, the
mutability phases — so a trait sense of it was a third meaning rather than a
reuse; finding a table row is *resolution*, which is also what the literature
calls it. "Implementation" is what a compiler calls its own code, and the CHL
spec already says "instance" for the same thing.

`TraitImpl` becomes `TraitInstance`, `Trait::impls` becomes
`Trait::instances`, and `InferError::NoTraitImpl` becomes `NoTraitInstance`
(its message now reads "No Addable instance for ...").

Three clarifications on top, all in the design doc's Traits section:

- An obligation is what one *use* of a trait records, not "one recorded
  instance" — a phrase that both misdescribed it and collided with the row
  sense of instance.
- A signature carrying an obligation is stated in general, `f : A ⇒ B requires
  MyTrait(A ⇝ B)`, with the operator table as the shapes that form takes
  today. Obligations already ride into schemes, so a user-written function
  carries its operators' requirements; only the surface for *writing*
  `requires` is missing.
- What the tables hold — base types only, homogeneous rows — is now its own
  subsection, separate from how resolution works. `Equatable` rejecting a
  variant is what those rows happen to be rather than a claim that variants
  are incomparable, and the contribution table no longer says a non-base is a
  shape no instance can *ever* accept.

Also fixes the section's function arrows, which were the term-level `→` where
the type-level `⇒` belongs.
@dpmills
dpmills merged commit 280fabd into main Aug 14, 2026
1 check passed
@dpmills
dpmills deleted the dmills/traits branch August 14, 2026 21:41
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