Derive: composes Equals had a coherence row and no body #229

Manually merged
hkowalski merged 5 commits from feat/229-derive-composes-equals-had-a-coherence-row-and into master 2026-09-15 15:49:40 +02:00
Owner

Derive: composes Equals had a coherence row and no body

Along the way:

  • derive: composes Equals had a coherence row and no body
  • coerce: a written x::String did nothing, and said nothing about it
  • capability: the String surface, probed end to end and written down
  • diag: E0702 / E0722 named two types and no way out
  • docs: ch.3 said nothing widens by itself, and meant scalars only

Touches 40 files: docs/03-types.md, docs/examples/bounded_coercion_is_written/Main.ck, docs/examples/bounded_coercion_is_written/expect, docs/examples/bounded_coercion_is_written/project.ck, docs/examples/bounded_narrowing_refuses/Main.ck, docs/examples/bounded_narrowing_refuses/expect and 34 more.

Derive: composes Equals had a coherence row and no body Along the way: - derive: composes Equals had a coherence row and no body - coerce: a written `x::String` did nothing, and said nothing about it - capability: the String surface, probed end to end and written down - diag: E0702 / E0722 named two types and no way out - docs: ch.3 said nothing widens by itself, and meant scalars only Touches 40 files: `docs/03-types.md`, `docs/examples/bounded_coercion_is_written/Main.ck`, `docs/examples/bounded_coercion_is_written/expect`, `docs/examples/bounded_coercion_is_written/project.ck`, `docs/examples/bounded_narrowing_refuses/Main.ck`, `docs/examples/bounded_narrowing_refuses/expect` and 34 more.
hkowalski approved these changes 2026-09-15 08:00:00 +02:00
hkowalski left a comment

Approving. I would have split src/rust/crates/ck_load/src/derive/tests.rs differently, but not enough to hold this up.

Approving. I would have split `src/rust/crates/ck_load/src/derive/tests.rs` differently, but not enough to hold this up.
@ -0,0 +119,4 @@
let mut this = Self {
aliases: HashMap::new(),
explicit: HashSet::new(),
candidates: Vec::new(),
Owner

candidates allocates a fresh buffer per call. If this runs per frame or per token, can the buffer be reused?

`candidates` allocates a fresh buffer per call. If this runs per frame or per token, can the buffer be reused?
@ -0,0 +66,4 @@
}
#[test]
fn a_field_with_no_equals_is_left_alone() {
Owner

a_field_with_no_equals_is_left_alone reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?

`a_field_with_no_equals_is_left_alone` reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?
Author
Owner

Split. The decision in a_field_with_no_equals_is_left_alone is a pure function now, with its own tests in src/rust/crates/ck_load/src/derive/tests.rs.

Split. The decision in `a_field_with_no_equals_is_left_alone` is a pure function now, with its own tests in `src/rust/crates/ck_load/src/derive/tests.rs`.
Author
Owner

It runs per token. The buffer behind candidates lives on the struct in src/rust/crates/ck_load/src/derive.rs now and is cleared rather than reallocated.

It runs per token. The buffer behind `candidates` lives on the struct in `src/rust/crates/ck_load/src/derive.rs` now and is cleared rather than reallocated.
The wrong reasoning is one sentence, and it was written down: coherence
phase 2 said the derived body was "deferred with the instantiation set
(no code-gen consumer yet); the *row* is what TY4c needs." A row with no
body is not a deferral, it is a promise nothing keeps.

The mechanism. `register_derives` allocated an `ImplId` carrying a span
and zero methods, then inserted it at `(Equals, [Pt])`. That is enough
for `T is Equals` to answer Yes in one hash probe, so `p == q` resolved
through TY5, typed `Bool`, and `ckc check` reported the program CLEAN;
monomorphization then found no `FnId` behind that `ImplId`, and
`emit_call_value` fell past user-fn, machine-op, syscall and the tick
grain to its terminal `Err(CodegenError::Unsupported(callee_span))` —
`unsupported Floor IR form`, at the surface an author reaches last.

`plan_traits.md` §3 forbids the cheap repair ("there is no compiler-level
`==`"), so the derive owes a real hand-writable impl. `ck_load::derive`
writes it source-to-source on the §2a actor desugar's precedent, in
`finish` where the whole closure is loaded, and CONSUMES the sugar: the
`Equals` token is cut out of the `composes` clause by byte range (every
other trait keeps its text and its generic arguments) and the page gains

  impl Equals<Pt> {
    fn equals(a: Pt, b: Pt) -> Bool = (a.x == b.x && a.y == b.y)
    fn not_equals(a: Pt, b: Pt) -> Bool = !((a.x == b.x && a.y == b.y))
  }

The conjunction folds LEFT with explicit parentheses because CkLang has
no universal associativity — `a && b && c` is E0146. Two fields hide
that; `todo_effect`'s three-field `Task` and `Dijkstra`'s `Arc` found it.

What moved: `ck_load` only. `ck_types` was NOT touched — with the clause
consumed, phase 2 never sees a derived record, and the generated impl
registers through phase 1 like any hand-written one. Its comment is
rewritten to say why its own reasoning was wrong.

What did NOT move: the derive fires only where the equality it writes
genuinely resolves — a field type reaching a real `impl Equals<…>`, or
another derived record, over a project-global fixpoint with `type`
aliases followed. That is stricter than E0711 on purpose: a `data …
composes Equals` holds a coherence row no `==` can reach (its values
type as the VARIANTS — the still-OPEN `data_sum_…` cell), so E0711
passes the stdlib's `record LogLine { level: LogLevel, … }`, which is in
every app's closure, and a generated body over it would not resolve.
Records out of reach — `snake`'s `Player { body: List<Pos, 256>, … }` —
keep their clause, their bodyless row and today's behaviour byte for
byte. Generic records are refused: one coherence row cannot serve every
instantiation. `data` is untouched, and stays OPEN.

Ledgers. `BROKEN_CELL_COUNT` 43 -> 42; the cell is renamed
`auto_derived_equals_on_a_record` and declares `runs: 42`. `CELL_COUNT`
440 -> 442 for the two cells that make it discriminate:
`auto_derived_equals_discriminates_on_a_field` (fields differ -> 7, so a
body answering `true` blindly cannot pass) and
`auto_derived_equals_not_equals_is_derived_too` (the `!=` glyph, so the
derive owes both of the trait's methods). R11 `ck_load/src` 9243 -> 9863,
a RAISE carried with a written justification in its doc log —
owner-visible, veto = revert; `ck_load/src/desugar` does not move.

Gates. `./scripts/test.sh` ALL GREEN — 4 legs: unit 1364, corpus 106
apps, capability 442 cells, docs 35 expectations, 0 failures. Workspace
`cargo test --workspace --tests`: 1621 passed, 1 failed. `cargo fmt
--all -- --check` 0 diffs; `cargo clippy --workspace --all-targets -D
warnings` 0 errors 0 warnings.

The one red is `r3_no_file_exceeds_loc_cap` —
`ck_driver/src/lib.rs: 1323 lines (cap 862)`. STANDING, not caused here:
the file is byte-identical to HEAD and untouched by this commit, and
plan.md §1 already tracks it as the one open item ("splitting the driver
root — a slice, not a cleanup").
`plan_values_02.md` §1a makes the `fn` boundary strict in both directions and
names exactly one way across it: "an argument's type must already equal the
parameter's type … or the programmer coerces it explicitly with a turbofish."
It then says the resulting call-site verbosity is "accepted, not a defect to
fix … load-bearing". That only holds while the verbose spelling works.

It did not. `synth_local_ref` and `synth_coercion` both opened with a scalar
test and returned the receiver's type unchanged for anything else:

    if !from_float && !from_intlike {
        // A non-scalar local carries no width conversion; keep its type.
        return local_ty;
    }

So `widen(a::String)` over a `String<2>` local produced `String<2>`, the E0702
fired on the value the author had just coerced, and nothing anywhere pointed at
the coercion. The strict boundary had no escape at all: the only route across
was to launder the value through an annotated `let`, which is a different
surface with a different law. That is the whole of "String is the worst
surface" — not a missing subsumption rule, a missing escape hatch.

The comment was half right. A `String` has no WIDTH to convert, so the scalar
clause (`ireduce`/`sextend`) genuinely does not govern it. §1.4 has a second
clause that does — container into same-constructor, same-element-type
container, bound differing only, fit-gated one-way — and §5 prices it:

    an identity retype — a coercion between two types whose lowered word
    layout is identical (e.g. the `String<6> -> String<64>` widening: the same
    `(position, len)` pair, the bound is type-level only) — is ZERO
    instructions and lowers as a no-op.

Both clauses were already written. Only the container one was unreachable from
a non-literal receiver, so it fired for `"hi"::String<4>` and never for
`a::String`.

`coerce.rs` gains that clause's non-literal half and stays its one home:
`rebound_target_bound` (extracted from `over_measured_bound`, so "does this
clause govern?" and "does the value fit?" cannot drift apart), `declared_measure`
(the measure the compiler HAS for a value it cannot read — the value's own
bound, conservative in the only safe direction), and `coerce_container_into`.
Keyed on SHAPE — a `Ty::Nominal` pair differing only in its `Ty::Value`
argument — so `List` rides it for the same reason `String` does and no arm
names a type. Lowering needed nothing: `Lowering::coerce` already folds a
non-scalar pair to identity, which is §5's zero instructions.

  widen(a::String)        String<2>  -> String<64>   E0702  -> runs 42
  (a ++ b)::String        String<128> -> String<64>  E0722  -> E0715
  widen((a)::List<Int,8>) List<Int,2> -> List<Int,8> E0702  -> runs 42

The middle row is the same clause refusing: the compiler's measure for a `++`
result is the bound it computed, 128, and 128 into 64 has no total operation.
Narrowing is still written by re-presenting the descriptor through the newtype
constructor — the round-trip `Types.ck` already uses — which is the author
asserting a length, not the compiler proving one.

CELL_COUNT 442 -> 447, BROKEN_CELL_COUNT 42 -> 43. Three cells land with the
clause; `list_concat_bound_does_not_narrow_through_the_turbofish` comes OFF the
broken list (it pinned "the pin is simply not consulted" — it is consulted now,
and answers E0715); and two cells pin what the same probe found and this commit
deliberately does NOT fix:

  · `coercion_turbofish_with_type_arguments_needs_parentheses` — `x::String<128>`
    dies in the PARSER at E0146, because after an identifier `::Name` is read as
    a path segment and a path segment cannot carry generic arguments. Only the
    argument-free `x::String` and the parenthesised `(x)::String<128>` reach the
    checker. A parser change, not this one.

  · `a_let_slot_narrows_a_computed_bound_unchecked` — `coerce_into_slot` bails
    when the initializer has no LITERAL measure, so `let s: String = a ++ a`
    takes bound 64 over a `String<128>` with no gate and no diagnostic. Passing
    the same measure the turbofish now uses is one line, and it is not made:
    `List` has no `set`, so `let joined: List<Int,4> = take ++ [v] ++ drop` is
    the only spelling of a bounded-list write there is. Which surface closes is
    an owner call about the language.

Gates: 4 legs green; test-integration 250 passed, 1 ignored, and the standing
`r3_no_file_exceeds_loc_cap` red (ck_driver/src/lib.rs, 1323 vs cap 862)
unchanged. fmt clean, clippy clean.
A systematic sweep of `String` across every position the type appears in —
`let` / param / return / record field / record literal, `==` and `!=`, `++`,
substring and slice, `size_of` / `len`, trait impl heads and bodies, generic
`String<n>` impls, actor `receive<M>` payloads and `send`, the `Show` return
path, cross-module and cross-`.ckh` uses, module `const`s. No compiler change
here; this is the record.

Seven cells, and the split matters more than the count.

THE POSITIVE, and it was an open question rather than an assumption.
`a_string_payload_crosses_the_actor_boundary` — a `String` field in a message
record survives `send` with its length intact. §4 of `plan_values_02.md` states
the one-copy rule ("a `String`'s content bytes travel inline in the buffer …
every position rebases for free"), and §5 listed actor-boundary crossing as
deferred with no instrument behind it. It lands. The cell earns that claim with
a control: the receiver routes the length through a `Panic::raise` guard, and
the same fixture with the guard set to 4 instead of 5 was measured at
`runs: 101`, so `runs: 0` is only reachable when `str_len` really answers 5 on
the far side.

FOUR GAPS, none of them regressions, none fixed here.

  · `a_module_const_string_does_not_fold` — `const tag: String = "ab"` is
    accepted and cannot be READ: every reference is `lower: ConstUnfoldable`.
    Measured in all four corners (alias / turbofish / exact width / unread), so
    it is neither the alias nor the coercion — it is the const folder, which has
    no answer for a two-word value. `project.ck`'s `const name: String` lowers
    today only because nothing reads it.

  · `string_size_of_is_composed_but_never_implemented` — the String parity of
    the existing List cell. `Sized` reaches `String` DIRECTLY and `List` only
    through `Collection composes Sized`, so a fix that took one route would
    leave the other red and look complete from either side.

  · `string_ord_is_composed_but_never_implemented` — `Types.ck:61` composes
    `Ord`; `Prelude.ck` writes fifteen scalar `impl Ord` blocks and no string
    one. Worth reading beside `string_plus_string_is_unresolved`: both are
    E0728, one is the law (`Add` is deliberately absent from the clause) and one
    is a hole (`Ord` is in it), and only the declaration line tells them apart.

  · `collection_len_on_a_string_does_not_lower` — type-checks clean, dies at
    `codegen: Unsupported`, and resolves against an `impl Collection<String<n>,
    u8>` in `List.ck` for a trait `Types.ck` deliberately does NOT compose on
    `String`. So a `composes` clause is read back in neither direction: a clause
    with no impl is accepted, an impl with no clause is dispatched. That is one
    defect wearing three faces across these last three cells.

TWO BY DESIGN, recorded because lawful is not the same as discoverable.
`a_show_impl_returning_a_bare_string_literal_refuses` is §1a's strict-out
boundary meeting the most-implemented signature in the language: `fn show(x: P)
-> String = "p"` is E0722 naming `String<2>` and `String<64>`, two types the
author never wrote, with no remedy in the text. Three lawful spellings run and
the cell lists all three. `an_actor_state_slot_does_not_coerce_a_string` is the
`let mut` in a state block that reads like §1.3's coercing slot and is a record
FIELD — the diagnostic points at desugared source that exists in no file.
Neither may be "fixed" by widening the boundary; that is the pin channel §1a
deletes, and `Money<USD>` vs `Money<EUR>` rides the same strictness.

Also strengthens `coercion_turbofish_with_type_arguments_needs_parentheses`
with the measurement that makes it a grammar fork rather than a one-line patch:
relaxing `parse_bare_angle_generic_args` to admit a terminal bare-angle list
would swallow `two(a < b, c > d)` — a running program on this compiler,
`runs: 42` — as a generic-argument list on `a`. The narrower follow-token rule
that would work is spelled out; ratifying it belongs in `Grammar.ckx` first.

CELL_COUNT 447 -> 454, BROKEN_CELL_COUNT 43 -> 47.

Gates: 4 legs green (1369 unit, 106 corpus apps, 454 cells, 35 book
expectations). fmt clean, clippy clean.
Strict-in/strict-out (`plan_values_02.md` §1a) is settled and the refusals are
correct. The messages were the problem:

    argument has type `String<2>`, parameter declared with type `String<64>`

Both types are ones the author never wrote. They wrote `"hi"` and `String`, and
the alias's bound is invisible at that spelling — `type String = String<64>` is
in `Types.ck`, not in their file. The sentence offers nothing to do, and the two
conclusions it does support are both false: that the same-name alias is broken,
or that a `String` literal cannot cross a boundary. §1a is explicit that the
call-site verbosity is "accepted, not a defect to fix … load-bearing"; a
diagnostic that never names the verbose spelling is most of what makes it feel
like a defect instead.

So both sites now append the remedy when the two types differ ONLY in their size
bound:

    argument has type `String<2>`, parameter declared with type `String<64>`
      — the two differ only in their size bound, and a boundary coerces nothing
        in either direction; coerce at the site with `(value)::String<64>`

E0749 already set this precedent for the homogeneous-trait tie ("Coerce one
operand to the other's size with the letter form"); this is the same advice at
the two boundary sites.

Three things keep it honest.

It asks `crate::coerce` the SAME two questions the coercion surfaces ask —
does §1.4's container clause govern this pair, and does the source fit — so it
is one reader, not a second opinion that can drift. A narrowing therefore gets
no advice at all: `returning String<128> … surface String<64>` stays bare,
because `(value)::String<64>` there is E0715 and advice that trades one error
for another is worse than none.

It is keyed on SHAPE, which the blessed goldens show directly: `Name<5>` ->
`Name<8>`, `Car<4>` -> `Car<5>` and `Str<8>` -> `Str<64>` are user-declared
newtypes and records in the conformance fixtures, and they get the clause for
the same reason `String` and `List` do. `param_mixed_list` is the discriminator
in one file — its `List<i64,4>` -> `List<i64,8>` row gains the remedy and its
`List<i64,4>` -> `List<Bool,4>` row does not, because a different element type
is not a re-bound.

It prints a spelling that PARSES. `x::String<64>` does not — after an identifier
`::Name` is read as a path segment, which cannot carry generic arguments
(`coercion_turbofish_with_type_arguments_needs_parentheses`) — so the clause
suggests the parenthesised form, which routes through the general postfix
surface and works in every position. A message must not print something the
compiler would then reject.

It says "a boundary" rather than "the `fn` boundary" because E0702 also serves
RECORD CONSTRUCTION, strict for the same §1a reason, which is how an actor's
state block reaches it (`an_actor_state_slot_does_not_coerce_a_string`).

No cell moves: the capability matrix matches on the error VARIANT and never on a
rendered message, which is the discipline that makes rewording safe. Seven
conformance goldens under `tests/types/` carry rendered text and are blessed.

Gates: 4 legs green (1369 unit, 106 corpus apps, 454 cells, 35 book
expectations); test-integration ALL GREEN — 251 passed, 1 ignored, and the
allow-listed `r3_no_file_exceeds_loc_cap` standing red (ck_driver/src/lib.rs,
1323 vs cap 862) unchanged throughout. fmt clean, clippy clean.
"Nothing widens by itself" was written about `u8` and `Int` and stopped there.
The rule it states applies just as hard to a bound — `String<2>` is not
`String<64>`, `List<Int, 2>` is not `List<Int, 8>` — and that is where a reader
actually meets it, because a string literal self-types to its own CONTENT width
while nearly every declared surface in the stdlib is the bare alias, which is
64 wide. So the widening is needed almost everywhere, on values whose types the
author never wrote down, and the book had no sentence about it.

Two sections and two gated fixtures.

`bounded_coercion_is_written` (check clean) carries the widening at both a
`String` and a `List` parameter, so the chapter's claim that this is one law and
not a String special case is machine-read rather than asserted. The section also
names the OTHER surface — the annotated `let` — because that is what `Types.ck`
and `Prelude.ck` reach for and it is why `let s: String = "hi"` needs no
ceremony; a reader who only learns the turbofish will think the common line is
the exception.

`bounded_narrowing_refuses` (check E0715) pins the one-way half. `(a ++ b)::String`
over two `String<64>`s is a `String<128>` whose content is four bytes and would
fit sixty-four easily — and is refused, because the compiler cannot read content
and the measure it holds is the bound. The chapter quotes the diagnostic
verbatim and says what a program that KNOWS the length does instead: the
newtype-constructor round-trip `date_text` / `money_text` / `span_text` already
use, which is an author's assertion and the right shape for a fact the compiler
cannot check.

Both are DELIVERED claims, so both fail if the behaviour moves in either
direction — including the narrowing one, which fails the day the gate is
relaxed. 35 book expectations -> 37.

Gates: 4 legs green; test-integration ALL GREEN (251 passed, 1 ignored, the
allow-listed `r3_no_file_exceeds_loc_cap` standing red unchanged).
hkowalski manually merged commit 9e25426ae8 into master 2026-09-15 15:49:40 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
northgale/cklang-bootstrap!229
No description provided.