Type functions: state a relationship between type variables other than subtyping - #51
Closed
dpmills wants to merge 15 commits into
Closed
Type functions: state a relationship between type variables other than subtyping#51dpmills wants to merge 15 commits into
dpmills wants to merge 15 commits into
Conversation
Contributor
Author
|
This change is part of the following stack:
Change managed by git-spice. |
This was referenced Jul 31, 2026
dpmills
force-pushed
the
dmills/type-operators
branch
from
July 31, 2026 22:02
7297fa5 to
d1d1cc5
Compare
dpmills
marked this pull request as ready for review
August 3, 2026 17:08
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 3, 2026 21:47
b0245dc to
5560332
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 3, 2026 21:47
18e16a6 to
252baec
Compare
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 4, 2026 22:00
5560332 to
944d117
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 4, 2026 22:00
252baec to
ce8911c
Compare
This was referenced Aug 4, 2026
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 5, 2026 18:41
944d117 to
06c2704
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 5, 2026 18:41
ce8911c to
8c353e8
Compare
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 5, 2026 19:46
06c2704 to
888e37f
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
2 times, most recently
from
August 5, 2026 21:06
e45100f to
261f066
Compare
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 5, 2026 22:34
888e37f to
0684978
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
2 times, most recently
from
August 6, 2026 22:41
356826f to
955b560
Compare
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 6, 2026 22:44
e18f60f to
066d40f
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 6, 2026 22:45
955b560 to
7d119e1
Compare
dpmills
force-pushed
the
dmills/mono-spec-key
branch
from
August 7, 2026 00:00
066d40f to
c825cef
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 7, 2026 00:00
7d119e1 to
0f30719
Compare
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 7, 2026 00:05
0f30719 to
a18aedc
Compare
Argument resolution is re-entrant and deposits nothing, so the same variable is re-derived along every path that reaches it, and a cache keyed on the variable removes most of that work. It is still not worth having. ## What it bought 13% of whole-suite time, 22% of `type_check`. It did **not** change the shape of the cost: an applied chain of generic wrappers is exponential in depth either way. | depth | with memo | without | |---|---|---| | 2 | 22.7 ms | 24.1 ms | | 6 | 831.9 ms | 866.9 ms | | 10 | 37,137 ms | 44,855 ms | That exponential is the thing worth attacking if inference cost ever matters, and it is untouched by the cache. Nothing in the suite depends on the memo for correctness — disabling the lookup leaves every test passing. ## What it cost A cache is only sound while its subject has not changed, so it needed to know when the bound graph moved: a per-thread generation counter, bumped inside `InferVar::bounds_mut`, with `bound_lists` private so no write could slip past it. That is a standing obligation on **every future write to the graph**, enforced by an accessor whose reason for existing was invisible at the call sites — exactly the kind of dependency that is easy to acquire by accident and expensive to discover you have broken. Trading that for 15% is the wrong way round. Both the memo and the counter are gone; `bounds()` / `bounds_mut()` stay as plain encapsulation, now justified by keeping "who writes the bound graph" a grep-able question rather than by cache invalidation. The `debug_assert` that compaction never writes the graph goes with them — it was checking the cache's premise, and there is no cheap way to state it without the counter. ## What stays `ARGS_IN_FLIGHT` and its RAII guard, which are **termination**, not optimization: without the cut-off, `x := 7; for i in []: x += 1; x` overflows the stack.
`SpecKey` decides whether two uses of a generalized `let` may share one monomorphized clone. It recorded the unreduced `Type::App`s reached while walking a use's instantiation type, on the reasoning that two uses whose applications differ must specialize apart — over-splitting is a wasted clone, under-splitting is a miscompile, so record and let the imprecision run the safe way. That contribution has no discriminating power, and cannot have any. ## Why Keys are only ever compared **within one `SpecializeFrame`** — `frame.specs.iter().find(|s| s.key == key)`, one frame per generalized `let`. So both sides of every comparison come from uses of the *same definition*, whose body is fixed. **Function identity therefore never decides.** A `TypeFn` carries its own non-type parameters, so `FieldOf(.a)` and `FieldOf(.b)` are distinct functions exactly as `Add` and `Sub` are — but a definition that projects `.a` projects `.a` at every use. Every `TypeFn` at every position is identical across the keys being compared. **Nor can the type arguments.** Each argument is a variable in the definition's type, and there are two cases: - It also appears elsewhere in that type — as `Add(α, β)`'s operands are the scheme's two domains. The key walks it there, so recording it here says the same thing twice. - It appears *only* inside the application. Then no use site can bound it: a use constrains through `arg <: domain` and `codomain <: demand`, and an obligation against an `App` is parked rather than decomposed, so nothing reaches the variable. It resolves alike for every use. Either way the contribution is redundant or constant, for any `TypeFn` — not merely today's two. ## Measured, not only argued Instrumenting `specialize_use` to log which specialization each use selects, over the whole suite with `--test-threads=1`: 96 decisions, **byte-identical** before and after the removal. An earlier probe on `KeyView::eq` looked like the opposite — 79 comparisons where this field alone decided inequality — but that counts *nested* comparisons, including `KeyView::union`'s dedup, and none of them propagate to a different clone. The decision site is the only place the answer means anything. ## What goes The `compute` field, `same_compute`, its contribution to `union` and `Display`, and `an_applications_argument_order_is_information` — whose subject was the comparison, not the key. `key_go`'s `App` arm becomes `KeyView::default()`, with the argument above recorded there for whoever wonders why an application is invisible to the key.
A pass over everything the type-function work touched, after four commits of removal. Three things were describing code that no longer exists, and one was describing it wrongly. - §4.7's account of the retired operand bound claimed the in-flight set and the resolution memo were both still needed to survive it. Only the first is: retiring the bound did not make resolution non-re-entrant (an argument resolves through the ordinary pipeline, which reaches other applications), so the cut-off stays and the memo went. - `reduce`'s "Missing arguments" said the same thing, and pointed at a memo that is gone. - `test_unresolved_operand_chains_terminate`'s comment said "the cycle cannot form and the machinery is gone". Both halves were wrong — cycles still form for the ordinary reason, and the in-flight set is what terminates them. It now says what the cases are a regression net *for*: the cut-off going away, and a future type function that states its requirement as a self-referential bound. - `common_base` → `shared_base` in the one place the rename was missed. Trimmed the justification for having no refinement law from two paragraphs to two sentences: the substance is the selects-vs-computes distinction, which "Which operators need one" already states, so restating it here was duplication. Renamed "What it genuinely cost" to "An operand nothing else determines stays undetermined" — a reader arriving at §4.7 because `\x -> x + 1` did not infer `Int` should be able to find that heading.
…the docs ## The law was stated backwards, and its own tests said so Law 3 claimed that dropping arguments "only weakens the answer — never claims more than the full argument list would." Two tests ten lines apart in `reduce.rs` refute it: ```rust reduce(&add(), &[Some(int()), Some(String)]) // → Err(NoCommonBase) reduce(&add(), &[None, Some(1)]) // → Ok(Int) ``` Dropping an argument turned an error into a type. Fewer arguments means fewer agreement checks, so the answer is *more* defined and *more* specific — the opposite of coarsening. The real obligation is **totality**: a rule must answer from the arguments it has rather than erroring, because a `None` means *cyclic*, not *conflicting*, and a rule that errors there turns a cycle into a spurious type error. ## And the example was wrong about who needs it The law claimed to keep an unapplied `\x -> x > 1` at result type `Bool`. That program never reaches the path — instrumenting the cut-off records **zero** hits across all of `type_check`. What actually needs it is a **register that reads itself in its own write**. `x += 1` makes the register's value type the join over its seed and its writes, and one write is `x + 1`, whose type is `Add(value(x), 1)` — so resolving `value(x)` reaches an application whose own argument is `value(x)`. Measured, adding a self-read is exactly what creates the cycle: | program | cut-offs | |---|---| | `1 + 2` | 0 | | `x := 7; x` | 0 | | `x := 7; x += 1; x` | 10 | | `x := 7; for i in [1,2]: x += 1; x` | 10 | 1,910 across the suite, all in the pipeline tests, none in `type_check`. Making `reduce` error on a missing argument fails 13 tests — every one an accumulator. So the law's guarantee is that `Add(⟨cyclic⟩, 1)` is still `Int`, without which no accumulator types at all. That also makes the `FieldOf(ρ, 𝑘)` escape hatch concrete rather than hypothetical: a rule that cannot answer without its argument would reject every accumulator flowing through it, so it has to report the cycle rather than guess. ## Docs: the retired design is gone `CommonBase` no longer appears anywhere outside `NoCommonBase`'s name. Removed the module section arguing what `shared_base` is and is not, §4.7's "Why not a bound on the operand variables", `binary_operands`'s account of the bound, the freshening comment's `CommonBase(α', β')` example, and the parked-obligation arm's description of traffic that no longer exists. The alternatives table keeps one row, which is the right amount: the audit table already says arithmetic rejects operands with no shared base, and nothing needs more detail than that. §4.7 goes from 3,498 to 3,077 words with no content lost — only claims about code that isn't there.
A cyclic argument arrived as `None`, indistinguishable from "absent", and law 3 asked every rule to answer anyway. That is right for the two rules we have and wrong for the first one that cannot — `FieldOf(ρ, 𝑘)` has no field type to name without `ρ`, so answering would mean inventing one. The obligation to report instead lived in prose, where the rule most likely to need it is the one whose author is most likely to reach for a plausible guess. ## Cycles are a property of the program A register that reads itself in its own write makes one. `x += 1` gives the register's value type as the join over its seed and its writes, one write being `x + 1` typed `Add(value(x), 1)`, so ```text value(x) = join(seed, Add(value(x), 1)) ``` is a fixpoint equation — because an accumulator *is* one. Measured: 1,910 cyclic arguments across the suite, and a self-read is exactly what creates them (`x := 7; x` has none; `x := 7; x += 1; x` has ten). All of them reach arithmetic. A comparison can be cyclic too — `b := (b == True)` types as `Mut(Bool, _)` with ten cut-offs — but nothing in the suite writes one. ## The shape `Option<Type>` becomes `Arg::Known(_) | Arg::Cyclic`, so the distinction a rule has to reason about is in the signature rather than in prose: the argument is not unknown-for-now, it is being defined in terms of this very application. `TypeFn::cycle_tolerance()` declares `Any` or `AllKnown`, and `reduce` enforces it centrally, so a rule that cannot answer cannot forget to say so. **Not per-argument.** Every rule's condition is about *how many* arguments are cyclic rather than which: arithmetic's operands are interchangeable to its rule, and a rule that needs an argument needs it whichever position it occupies. A rule wanting "at least `n` known" would generalize this to a count; none does, so it is two cases rather than a mask. Both of today's rules are `Any`, for reasons worth keeping apart. A comparison is *constant on its domain*, so a cyclic operand costs nothing at all. Arithmetic answers the shared base of what it can see, so a cyclic operand costs the agreement **check** while keeping a usable type — which is what lets an accumulator have one. Thirteen accumulator tests fail the moment a rule refuses. ## The part that is a warning rather than a mechanism **Answering at a cycle is one step of a fixpoint iteration from ⊥**, and it is exact only because the base sublattice is flat: `Add(⊥, Int)` is `Int`, and re-substituting gives `Int` again, so one step saturates. A rule over a lattice with infinite ascending chains does not saturate. The range-aware `Arithmetic` that "Known gaps" promises walks `x := 0; x += 1` through `[0,0] → [0,1] → [0,2] → …` and needs **widening**, not one step. It cannot land on this machinery unchanged, and that is now written down where whoever writes it will be standing. `check_cycle_tolerance` is factored out so both arms are testable — no rule reports `AllKnown` yet, so exercising the dispatch through `reduce` would only ever reach one of them.
The claim that a range-aware `Arithmetic` would need widening was wrong, and it was wrong in a way that hid the real hazard. Traced, one materialization of `x := 0; x := x + 1; x` reduces ```text Add(⟨cyclic⟩, 1) → Int the cut Add(Int, 1) → Int the frame that asked for it ``` and stops. `x + x` reaches three levels because each operand re-enters separately; a second write adds more. The depth is fixed by the shape of the program, never by an iteration count, and in every shape measured the outermost frame — the one whose answer becomes the node's type — sees every argument known. So a range rule lands on `Int` here, exactly as today's does; it does not walk `[0,0] → [0,1] → [0,2] → …`, because nothing re-enters to walk. Confirmed by making the rule's result lattice non-flat — keeping the operand's value-level claim at every level instead of dropping to the base — and rerunning: 140 reductions, terminating, no divergence. The hazard is therefore the opposite of the familiar one. Because nothing iterates, whatever a rule answers at a cut is **what the unrolling carries**; there is no later pass that widens it. A rule must already be sound at the cut, not merely improvable. Both of today's rules are, for free and for different reasons: arithmetic drops to the shared base — the top of the chain the missing operand could have contributed to — and comparison is constant on its domain. A sharper rule that kept what it could see would return whatever a bounded unrolling happened to reach, sound only by accident of how far the program unrolled. Also drops a stale paragraph that still described `ReduceError::CyclicArgument` as unwritten and `FieldOf` as violating law 3 — both fixed when the rule began declaring a `CycleTolerance`, and `FieldOf` is now named as the first expected `AllKnown` rule, with the second reason it cannot answer at a cut: it selects rather than computes, so it has no base to fall back to.
The cycle machinery was measured but not tested. `compilation_pipeline::mutability` covers the loop-carried accumulator (`for i in …: x := x + i`) end-to-end in 63 cases, and every one of them is the *same* cyclic shape: one cyclic operand, arithmetic, `Int`. The shapes that stress the rule were reachable and unpinned. Twelve cases, each a register whose type is unknowable if a rule refuses to answer through a cycle: - **Both** operands cyclic at once (`x := x * x`, `x := x - x`) — the rule answers with no operand type to work from. - Nested, so an inner application's result is an operand of an outer one. - Routed through user functions, so the cycle crosses a call boundary. - Mutual and three-way cycles between registers, where the cycle spans several equations. - A `String` accumulator, so the answer is the operands' shared base rather than a hardcoded `Int`. - A **comparison** cycle, `b := (b == True)`. Nothing in the suite writes one — the measured count was zero — but it is constructible, and `Compare` is constant on its domain, so a cyclic operand costs it nothing at all. The tolerance is a claim about every rule, not just the arithmetic ones. Verified to fail for the stated reason: flipping `Arithmetic` to `CycleTolerance::AllKnown` fails eleven of the twelve, and the twelfth is the comparison case — which still passes, so it is pinned independently rather than riding on the others. `test_a_cycle_does_not_hide_an_operand_conflict` pins the other half: the cut costs the agreement *check*, not the answer, so `x := x + "a"` must still be a diagnostic. It is, because the outermost frame of the unrolling reduces with every argument known.
The `SharedHole` edge related the key application to the **lambda binder** `__gb_k`, which put the marker on an artifact of this particular desugaring and made the two positions equal. It now relates the key application to the domain of the group-by's own `data_fun` annotation. Three things improve, none of them behavioural — A/B'd across bare group-bys, lookups, a record key, a constant key function, and a rejected key type, the two placements give identical results: - **The claim lands on what it is about.** "A partition function's domain is the type of its keys" is a fact about the group-by. `__gb_k` is a name this lowering invented; a different encoding of the same construct would not have it, and the fact would have nowhere to live. - **The variable becomes a conduit rather than a position's type.** Before, the shared variable *was* the parameter's type, so one variable served both as where produced keys land and as a negative position. Now it appears only as an upper bound of the key application and a lower bound of the parameter. - **The edge is directional using the lattice's own variance.** `bind_annotation` records `inferred <: ann`, and a function type is contravariant in its domain, so annotating the domain reduces to `key_ty <: ⟨the parameter⟩` — produced keys flow *into* the domain. The binder placement forced the two equal instead. The `data_fun` annotation already existed as the `Data`-kind provenance stamp, so this fills a hole that was already there rather than adding a stamp, and `__gb_k` goes back to being an ordinary `Type::Hole` that takes its type from the annotation like any other parameter. One cost, recorded where it bites: `symbolic` does not render annotations, so `test_lower_groupby`'s four snapshots no longer show the relation at all. `test_groupby_key_type_comes_from_the_key_function` is what covers it, and its comment now says so.
A review pass over what this PR added to the docs. **Stale:** the prior-work paragraph cited "Law 5" as the refinement discipline. There is no law 5 — the refinement law was deliberately dropped, which the same section says four paragraphs earlier. `Type::SharedHole`'s own doc still described the id as relating a *binder* to a type, which stopped being true when the relation moved to the group-by's annotated domain. **Missing:** `SharedHole` appeared nowhere in the design docs. It now gets a short paragraph in §5's `groupby` — what it is, where the id is carried, and why the edge is directional — rather than a section of its own. **Duplication:** §4.7 restated `reduce.rs`'s module docs at length — the four laws with their guarantees, the no-refinement-law argument, the cycle-cut trace. Those are normative in the code, so the doc now names the laws and points at the heading that states them, and gives the cut one paragraph instead of a subsection. Same for the parked-obligation mechanics, which `context.rs` already carries. §4.7 drops from ~3,700 to ~3,150 words with no design content removed. Also worth recording, since it cost time: `git diff main..dmills/type-operators` misreports this PR's doc footprint. `main` is one commit ahead (`#48`, positional tuple access), so its additions render as deletions attributed here. Against the merge base the footprint is `type-inference.md` alone.
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 10, 2026 16:47
efdf88e to
b9daafe
Compare
`"a" * "b"` typed as `String`. `shared_base` answers "do the operands describe the same thing", which that satisfies perfectly — so nothing objected. Agreement is necessary and not sufficient: the base also has to be one the operation is defined on. `"a" - "b"` and `True + True` were wrong the same way. The domain, measured across the suite rather than assumed — every `(kind, base)` pair that reaches the rule: - `Add` — `Int`, `String`. It is string concatenation until `lambda_elim` rewrites it to `Concat`, which runs after inference. - `Sub`, `Mul`, `FloorDiv` — `Int`. Nothing reaches it at `UInt`, `Bool` or `UIntRange`. This is what the `kind` on `TypeFn::Arithmetic` earns today, ahead of the range-aware rule it was recorded for. ## Judged per operand, because no frame is guaranteed to see them all The obvious placement — check the joined base — does not work through a cycle, and the reason is worth recording. Traced: ```text x := 2; x := x * x reaches [Int, Int] x := "a"; x := x * x never reaches [String, String] ``` Every reduction of the second has at least one cyclic operand, so a check on the join never fires and the program typed as `Mut(String)`. The rule therefore judges each **available** operand: a known operand outside the operation's domain is a violation whatever the others turn out to be, which is also what makes it sound at a cut. That corrects a claim in the module docs — that the outermost frame sees every argument known is shape-dependent, not a property to build on. ## A poisoned argument is not a cyclic one Fixing the above exposed why the cyclic case stayed silent even once the rule rejected: `resolve_argument` ended in `.ok()`, so an argument whose *own* reduction failed came back as `None` — the same answer as "this resolution re-entered". The enclosing rule then saw `Arg::Cyclic` and coarsened past a real error, 32 raised failures discarded on the way. It now returns `Result<Option<Type>, ReduceError>`, keeping the three outcomes apart, and an `App` whose argument failed poisons its own position — the rule `CompactType::merge` already followed for merging, now applied to argument resolution too. `ReduceError::UndefinedForBase` is the dual of `NoCommonBase`: there the operands disagree, here they agree and the *operator* has nothing to say, so the message names one base rather than a pair.
dpmills
force-pushed
the
dmills/type-operators
branch
from
August 10, 2026 17:34
3c3fe14 to
96f06fb
Compare
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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 and comparison now state a **trait** instead: three unrelated inference variables `A → B → O`, plus an obligation `Addable(A, B) ⇝ O` beside the graph. Because `O` is an ordinary variable rather than a marker standing for a computation, information still flows backwards through a result — so a function typechecks without its call sites, and `(1 + 2) and True` is an ordinary bound conflict. `apply_binary_scheme`'s refinement strip 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" — *`A` and `B` are types some implementation accepts*, **and** *each associated position is what that implementation associates*. A trait is a requirement, not a function, so it names **associated types** (Rust's term) rather than having *an* output — an implementation carries `args` of any arity and a set of associations. `Output` is the only one today, but a trait may associate **none**, which is exactly what a bare requirement like `Orderable(γ)` is. ## What an obligation determines Each operand position holds a candidate set that only shrinks as base types arrive, and the output is deposited on `O` once every survivor agrees on it — **the output only**, since the obligation is its sole source of information while an operand always has the program's own `left <: A` edge. The consequence worth flagging before review: how much is determined is a property of the table and shrinks as the table grows, *including the output*. `\x -> x + 1` has result `Int` today only because `Int` in the second position leaves one row, and adding `Addable(Float, Int) ⇝ Float` would open the result just as the parameter already is. Hence agreement, not uniqueness, as the deposit condition. ## Delivery The mechanism rests on one invariant — a concrete type reaching an operand variable must reach the obligation watching it — which the bound closure does *not* provide on its own. A variable's lower bounds are written in four places and delivery is wired into each; all four are load-bearing, confirmed by deleting each and checking that only its own case in `a_concrete_operand_reaches_its_obligation` fails. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) has the argument, and `verify_narrowing_is_complete` checks it on every program rather than trusting it. ## Consequences `test_lambda_unapplied` asserts an open parameter with the codomain given per case. `test_collect_multi_conflict` sees `NoTraitImpl` naming the trait rather than `IncompatibleBounds`; the bound-collision path itself is unchanged. `max`'s comparability tripwire does **not** flip. A zero-association trait is now representable, but not enforceable by narrowing alone — there is no other position to rule the last candidate out — so it needs verifying against a resolved type, which is the check pass's job. Recorded at the test. `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 at all — the base it needs sits on the register's seed.
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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*. ## 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. That puts the operators in three shapes — unary with an `Output` (`Negatable`), binary with one (`Addable`), and binary with **none** (`Equatable`/`Orderable`) — each exercised by ordinary programs, with the matrix pinned by `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 belongs to the *operator's* signature, not the trait — `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 `Int → Int` scheme for the same reason, and gives the unary arity a real user. ## 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. So how much is determined is a property of the table and shrinks as the table grows, *including an associated type*: `\x -> x + 1` has result `Int` today 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. ## Delivery The mechanism rests on one invariant — a concrete type reaching an operand variable must reach the obligation watching it — which the bound closure does *not* provide on its own. A variable's lower bounds are written in four places and delivery is wired into each; all four are load-bearing, confirmed by deleting each and checking that only its own case in `a_concrete_operand_reaches_its_obligation` fails. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) has the argument, and `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 does **not** flip. A zero-association trait is representable now, but a *unary* one is still not enforceable by narrowing — there is no other position to rule the last candidate out — so it needs verifying against a resolved type, which is the check pass's job. Recorded at the test.
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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*. ## 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. That puts the operators in three shapes — unary with an `Output` (`Negatable`), binary with one (`Addable`), and binary with **none** (`Equatable`/`Orderable`) — each exercised by ordinary programs, with the matrix pinned by `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 belongs to the *operator's* signature, not the trait — `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 `Int → Int` scheme for the same reason, and gives the unary arity a real user. ## 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. So how much is determined is a property of the table and shrinks as the table grows, *including an associated type*: `\x -> x + 1` has result `Int` today 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. ## Delivery The mechanism rests on one invariant — a concrete type reaching an operand variable must reach the obligation watching it — which the bound closure does *not* provide on its own. A variable's lower bounds are written in four places and delivery is wired into each; all four are load-bearing, confirmed by deleting each and checking that only its own case in `a_concrete_operand_reaches_its_obligation` fails. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) has the argument, and `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 does **not** flip. A zero-association trait is representable now, but a *unary* one is still not enforceable by narrowing — there is no other position to rule the last candidate out — so it needs verifying against a resolved type, which is the check pass's job. Recorded at the test.
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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*. ## 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. That puts the operators in three shapes — unary with an `Output` (`Negatable`), binary with one (`Addable`), and binary with **none** (`Equatable`/`Orderable`) — each exercised by ordinary programs, with the matrix pinned by `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 belongs to the *operator's* signature, not the trait — `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 `Int → Int` scheme for the same reason, and gives the unary arity a real user. ## 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. So how much is determined is a property of the table and shrinks as the table grows, *including an associated type*: `\x -> x + 1` has result `Int` today 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. ## Delivery The mechanism rests on one invariant — a concrete type reaching an operand variable must reach the obligation watching it — which the bound closure does *not* provide on its own. A variable's lower bounds are written in four places and delivery is wired into each; all four are load-bearing, confirmed by deleting each and checking that only its own case in `a_concrete_operand_reaches_its_obligation` fails. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) has the argument, and `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 does **not** flip. A zero-association trait is representable now, but a *unary* one is still not enforceable by narrowing — there is no other position to rule the last candidate out — so it needs verifying against a resolved type, which is the check pass's job. Recorded at the test.
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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*. ## 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. That puts the operators in three shapes — unary with an `Output` (`Negatable`), binary with one (`Addable`), and binary with **none** (`Equatable`/`Orderable`) — each exercised by ordinary programs, with the matrix pinned by `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 belongs to the *operator's* signature, not the trait — `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 `Int → Int` scheme for the same reason, and gives the unary arity a real user. ## 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. So how much is determined is a property of the table and shrinks as the table grows, *including an associated type*: `\x -> x + 1` has result `Int` today 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. ## Delivery The mechanism rests on one invariant — a concrete type reaching an operand variable must reach the obligation watching it — which the bound closure does *not* provide on its own. A variable's lower bounds are written in four places and delivery is wired into each; all four are load-bearing, confirmed by deleting each and checking that only its own case in `a_concrete_operand_reaches_its_obligation` fails. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) has the argument, and `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 does **not** flip. A zero-association trait is representable now, but a *unary* one is still not enforceable by narrowing — there is no other position to rule the last candidate out — so it needs verifying against a resolved type, which is the check pass's job. Recorded at the test.
dpmills
marked this pull request as draft
August 10, 2026 23:37
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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*. ## 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. That puts the operators in three shapes — unary with an `Output` (`Negatable`), binary with one (`Addable`), and binary with **none** (`Equatable`/`Orderable`) — each exercised by ordinary programs, with the matrix pinned by `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 belongs to the *operator's* signature, not the trait — `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 `Int → Int` scheme for the same reason, and gives the unary arity a real user. ## 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. So how much is determined is a property of the table and shrinks as the table grows, *including an associated type*: `\x -> x + 1` has result `Int` today 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. ## Delivery The mechanism rests on one invariant — a concrete type reaching an operand variable must reach the obligation watching it — which the bound closure does *not* provide on its own. A variable's lower bounds are written in four places and delivery is wired into each; all four are load-bearing, confirmed by deleting each and checking that only its own case in `a_concrete_operand_reaches_its_obligation` fails. [The design doc](src/ccl/design/type-inference.md#delivery-the-watch-follows-the-edge) has the argument, and `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 does **not** flip. A zero-association trait is representable now, but a *unary* one is still not enforceable by narrowing — there is no other position to rule the last candidate out — so it needs verifying against a resolved type, which is the check pass's job. Recorded at the test.
dpmills
added a commit
that referenced
this pull request
Aug 10, 2026
…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*. ## 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`). 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. ## 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. ## 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](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. ## 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. That was only possible because 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). Collapsing the last two let composites through — `(1, 2) == (3, 4)` type-checked as `Bool`, because a tuple narrowed nothing and a comparison has no associated position to leave unresolved. Composites now satisfy no trait (`a_composite_satisfies_no_trait`), which is what the closed tables were always meant to mean.
dpmills
added a commit
that referenced
this pull request
Aug 11, 2026
…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*. ## 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`). 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. ## 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](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. ## 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. That was only possible because 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). Collapsing the last two let composites through — `(1, 2) == (3, 4)` type-checked as `Bool`, because a tuple narrowed nothing and a comparison has no associated position to leave unresolved. Composites now satisfy no trait (`a_composite_satisfies_no_trait`), which is what the closed tables were always meant to mean.
dpmills
added a commit
that referenced
this pull request
Aug 11, 2026
…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*. ## 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`). 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. ## 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](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. ## 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. That was only possible because 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). Collapsing the last two let composites through — `(1, 2) == (3, 4)` type-checked as `Bool`, because a tuple narrowed nothing and a comparison has no associated position to leave unresolved. Composites now satisfy no trait (`a_composite_satisfies_no_trait`), which is what the closed tables were always meant to mean.
dpmills
added a commit
that referenced
this pull request
Aug 11, 2026
…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*. ## 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`). 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. ## 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](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. ## 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. 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.
dpmills
added a commit
that referenced
this pull request
Aug 12, 2026
…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.
dpmills
added a commit
that referenced
this pull request
Aug 12, 2026
…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.
dpmills
added a commit
that referenced
this pull request
Aug 13, 2026
…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.
dpmills
added a commit
that referenced
this pull request
Aug 14, 2026
…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.
dpmills
added a commit
that referenced
this pull request
Aug 14, 2026
…y are the same (#73) 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*. ## 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](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. ## 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.
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.
The constraint solver relates type variables in exactly one way: subtyping.
α <: βis the only fact it can record. That is enough whenever a rule's result type is one of its input types —max(xs)returns an element,x.freturns the field — because "is one of" is expressible as sharing a lattice position.It is not enough for a rule whose result is computed from its inputs. Addition is the smallest example: the result of
a + bis neither operand.What went wrong without it
The encoding available was to share a variable:
∀α. α → α → α. A variable carries every lattice dimension, so this states far more than intended, and it is wrong in both directions at once.\x -> x + 1{Int | __elem == 1} ⇒ Intxy = 1; z = 1; y + z{Int | __elem == 1}Int1 > "a"Bool, then panics in the interpreterThe first two are the two polarities of one mistake. Operand occurrences are domains — negative positions, where refinement sets union — so one operand's refinement becomes a requirement on the other. The result occurrence is positive, where refinement sets intersect, so a refinement both operands carry survives onto the result. The second row needs two operands carrying the same refinement (intersecting a set with itself returns it), which is why it stayed hidden.
The mechanism
Type::App { fun: TypeFn, args }is the type "whateverfuncomputes fromargs" —1 + xtypes asAdd({Int | __elem == 1}, α).An
Appdenotes a type rather than constructing one:Add(Int, Int)isInt, written in a form that does not yet know it. It adds no inhabitants and no subtyping edges to the lattice, so reduction is normalization, and it is transient in the same sense asInfer— everyTypethat escapes inference isApp-free.Reduction is demand-driven: materializing an
Appresolves each argument through the ordinary pipeline and applies the rule. Nothing is deposited, so no phase ordering can make the answer wrong. A rule is a pure function of resolved argument types — no inference context, no access to the bound graph — which is also what leaves room for user-declared type schemas on UDFs later.Four laws make a rule sound: pure, monotone in the subtype order, declares a
CycleTolerance, and normalizing. They are stated insrc/ccl/infer/solver/reduce.rswith what each buys inference, and summarized insrc/ccl/design/type-inference.md, "4.7 Type functions".A cycle is a recurrence in the program, and a rule says whether it can answer through one
An argument arrives cyclic when resolving it would re-enter the resolution already computing it, which is what a self-read is:
x += 1makesvalue(x) = join(seed, Add(value(x), 1)), a fixpoint equation, because an accumulator is one. Measured: 1,910 cyclic arguments across the suite, and adding a self-read is exactly what creates them.So
Option<Type>isArg::Known(_) | Arg::Cyclic— the distinction a rule must reason about is not "absent" but cyclic, and there is no later point at which more is known.TypeFn::cycle_tolerance()declaresAnyorAllKnownandreduceenforces it centrally, so a rule that cannot answer cannot forget to say so. It is deliberately not per-position: every rule's condition is about how many arguments are cyclic rather than which.Both of today's rules are
Any, for reasons worth keeping apart — a comparison is constant on its domain so a cyclic operand costs nothing, while arithmetic loses the agreement check but keeps a usable type, which is what lets an accumulator have one.The cut is an unrolling, not an iteration
Traced, one materialization of
x := 0; x := x + 1; xreducesAdd(⟨cyclic⟩, 1) → Intand thenAdd(Int, 1) → Int, and stops;x + xreaches three levels because each operand re-enters separately. The depth is fixed by the shape of the program, never by an iteration count, and in every shape measured the outermost frame — the one whose answer becomes the node's type — sees every argument known.The consequence is the opposite of the familiar one. Because nothing iterates, whatever a rule answers at a cut is what the unrolling carries; there is no later pass that widens it. A rule must already be sound at the cut, not merely improvable. Both current rules are, for free: arithmetic drops to the shared base — the top of the chain the missing operand could have contributed to — and comparison is constant on its domain. A sharper rule that kept what it could see would return whatever a bounded unrolling happened to reach, sound only by accident of how far the program unrolled.
The operand requirement lives in the result rule
An operator's operands are two unrelated scheme variables. Nothing relates them, and nothing needs to: what checks them is the rule that reduces the result.
That works because of one property, which is silent when violated and so is recorded where schemes are defined: a scheme that wants its operands checked must mention them in its result. A rule runs when something materializes the application it belongs to, and an application is materialized only when some node's type reaches it. A scheme's operand variables are nobody's node type — a use records
left <: αandright <: β, soαandβare reachable from the operands, but nothing walks from them. The only thing that can reach them is the result.So
1 + "a"is rejected because the addition's result isAdd(α, β); materializing the node runs the rule and finds the conflict. A comparison declaring a bareBoolmentions neither operand, so nothing ever materializes them, the rule never runs, and1 > "a"types cleanly and then panics. A comparison's result is thereforeCompare(kind, α, β), reducing toBoolfor operands that share a base and to an error for those that do not.What that costs
\x -> x + 1no longer infersx : Int. Its result still resolves — the rule answers from the literal operand — but the parameter is determined by nothing.That is the honest answer rather than a regression. The lambda works for any two things
+accepts, so it is polymorphic in its parameter, andTypehas no∀to say so. As a program value it is an ambiguous program, rejected downstream exactly as\x -> [x, x]already was. InferringIntread as precision; it was a single-numeric-type lattice showing through, and it would have become wrong the momentInt + Float → Floatexisted.An earlier revision of this branch bought that inference back with a self-referential bound on the scheme's own variables (
α <: CommonBase(α, β)), so that resolving one operand pulled the other's base. It is gone: redundant as a guard, wrong as a claim about what makes two values addable, and — as a pull — compensating for two defects elsewhere that are fixed below it.src/ccl/design/type-inference.md, "Why not a bound on the operand variables" records the argument so it is not re-added.Obligations that are not decidable yet are parked
constrain_gocannot compare an unreducedAppagainst anything: reduction resolves the arguments off the bound graph, and emission runs while that graph is still being built, so an answer read there could be superseded by the next edge.The obligation is therefore parked rather than dropped.
constrain_gorecords it with the in-flight substitutions applied,require_subtags it with the node to blame, andInferCtx::check_parked_obligationsretries it between emission and coalesce — the point at which every edge the program implies exists. Only fully-determined obligations are checked, which keeps the retry from perturbing the graph it reads: a side still holding a variable is one the program never determined, and coalesce reports that asUnresolvedInfer.This is what makes
(1 + 2) and Trueandsum(["a" + "b"])ordinary diagnostics. Accepting the obligation silently let inference returnOk, and the conflict then reached thecheck_pre_desugarconsistency wall, which cannot distinguish a user type error from a compiler bug and panics on both.InferCtx::constrain_and_claimis the single choke point every emission-timeconstrain_subtypegoes through, with adebug_assertthat the solver's park list is empty on the way out — a call that bypassed it would silently drop its obligation, which is the defect parking exists to fix.The audit of the other operators
Every scheme in the registry was checked against the rule that decides it: sharing a lattice position between an input and the result is right exactly when the operator selects an existing value or merges several — the result then is one of those values, so a fact about it survives — and wrong when the operator computes a new one.
Sum,Neg,Not,ConcatandBoolLogicinherit nothing;Maxshares its element type and should;final_or_defaultand theget_prev_*accessors merge;Listmerges across elements;Projselects the field, refinement included. Tests pin the table, and half their cases assert a refinement is still present, because dropping it is the same bug from the other side.CollectionUnionis why type functions coexist with variable sharing rather than replacing it: its codomain is a real join while its domain is a real computation, and no single scheme can say both. It andProj'sFieldOf(ρ, k)are the natural next clients.Two inference defects this exposed
Both were found by asking why the retired bound appeared to be load-bearing, and both are fixed in their own commits.
groupbynever related its key domain to its key function. The lowered shape bindsλ __gb_kwhose only occurrence is an operand of the partition predicate's==, so the key's type could only arrive backwards along an operator's operand relation — the wrong place for that fact to come from, and a place this PR deliberately removes.Type::SharedHole(id)lets lowering state the relation directly: aHolewith an identity, where every occurrence of one id normalizes to the same inference variable.It is carried by the key application and by the domain of the group-by's own
data_funannotation — the two places the claim is actually about. "A partition function's domain is the type of its keys" is a fact about the group-by;__gb_kis a name this lowering invented, and a different encoding of the same construct would not have it. Annotating the domain also makes the edge directional with no new machinery:bind_annotationrecordsinferred <: annand a function type is contravariant in its domain, so it reduces tokey_ty <: ⟨the parameter⟩— produced keys flow into the domain, rather than the two being forced equal.One caveat worth knowing:
symbolicdoes not render annotations, sotest_lower_groupby's snapshots do not show this relation.test_groupby_key_type_comes_from_the_key_functionis what covers it.Freshening asked
type_levela question it does not answer.type_levelis predicate-blind by design (it drives extrusion, where refinements are lattice-blind), butfreshen_aboveused it to decide whether a type needs copying. A quantified variable reachable only through a nested refinement's predicate therefore short-circuited, and the specialization clone kept pointing at the definition's own variables — visible as a duplicate specialization, since the clone reached one generalizedletthrough both the freshened variable and the original. Freshening now asksfreshen_level, which counts predicates.The strict wall could not see through an application
Making a result an
Appsurfaced a hole this area already had:typecheckcould not catch a corrupted1 + 2 : Stringeither, and no test noticed because the one test there used a comparison, whose result was still concrete.Check reconciles a rule-derived type against the recorded one. For an operator scheme that type is a fresh variable whose lower bound is the
App, andconstrain_subtypetreats an unreduced application as opaque, so the bound-closure walk stops there. Check now resolves a rule-derived type that is still a bare variable before reconciling. Narrow on purpose: every other rule rebuilds a ground type from its children, where resolving is the identity.Errors
A reduction conflict has its own error rather than borrowing
IncompatibleBounds, whose rendering is a specific claim ("won't infer an untagged sum from a collision") that is not what happened: each operand is well typed and nothing collided on a variable. Compaction returns a bag of contributions rather than aResult, so a failed reduction rides the position it poisoned (CompactType::reduce_error) andcoalesce_compactraises it where aTypeis produced — which is a node, with a span.Prior work
The name is deliberately GHC's. Associated type synonyms (Chakravarty, Keller & Peyton Jones, ICFP 2005) and open type functions (Schrijvers, Peyton Jones, Chakravarty & Sulzmann, ICFP 2008) are the closest analogue; the substantive difference is that GHC's families are open, so the checker must decide entailment between unreduced applications and the rule set needs confluence and termination side conditions. Cambra's set is closed and every rule is normalizing, so there is never an equality to decide between two unreduced applications — reduce, then compare.
Reducing before comparing is the conversion discipline of a dependent type checker (Coquand, An algorithm for type-checking dependent types, 1996), restricted to a closed first-order rule set. Parking a constraint whose reduction is blocked is Agda's constraint postponement (Norell, Chalmers, 2007) at coarser granularity — retried once after emission rather than woken per-solution, which is sound because emission is a single bounded phase.
The lattice underneath is unchanged: MLsub (Dolan & Mycroft, POPL 2017) as presented by Parreaux (ICFP 2020). Because reduction is normalization, a type function adds nothing to it.
Alternatives considered — including named predicate constraints (
(SameBase α β) ⇒ …, deferred rather than rejected) — are tabulated in the design doc.