fix(values): record fields carry their full word list (debt #63) + named-tuple projection lands (debt #38) — Alloc::alloc callable #77

Manually merged
hkowalski merged 8 commits from feat/077-fix-values-record-fields-carry-their-full-word into master 2026-04-09 08:00:00 +02:00
Owner

fix(values): record fields carry their full word list (debt #63) + named-tuple projection lands (debt #38) — Alloc::alloc callable

Along the way:

  • feat(tensor): matmul + backward-as-transposed-views — tensor_backprop; the memory wall is per-module now
  • docs(hir): LogicOp doc names the live ExprKind::ShortCircuit, not the never-built Match
  • feat(check): union bounds enforce — where T is A or B means it; byte joins Floor's where-lists
  • feat(coherence): the key discriminates concrete tags — Money ≠ Money (debt #32); the generic-impl stamp channel settled (debt #37)
  • feat(check): four silent holes get diagnostics — composes-missing, impl-signature conformance, type-slot kind, self-generic reps (debts #14/#36/#12/#16)
  • feat(values): the newtype convention lands — v.value reads the rep, N(r) constructs; one generic law
  • fix(ckc): relative paths resolve against cwd — the vacuous-green check footgun dies (debt #58)

Touches 75 files: Floor.ck, plan_training.md, scripts/cklang_ai.sh, src/rust/crates/ck_cktest/src/pipeline.rs, src/rust/crates/ck_driver/src/compile/mod.rs, src/rust/crates/ck_driver/src/compile/typecheck.rs and 69 more.

fix(values): record fields carry their full word list (debt #63) + named-tuple projection lands (debt #38) — Alloc::alloc callable Along the way: - feat(tensor): matmul + backward-as-transposed-views — tensor_backprop; the memory wall is per-module now - docs(hir): LogicOp doc names the live ExprKind::ShortCircuit, not the never-built Match - feat(check): union bounds enforce — `where T is A or B` means it; byte joins Floor's where-lists - feat(coherence): the key discriminates concrete tags — Money<USD> ≠ Money<EUR> (debt #32); the generic-impl stamp channel settled (debt #37) - feat(check): four silent holes get diagnostics — composes-missing, impl-signature conformance, type-slot kind, self-generic reps (debts #14/#36/#12/#16) - feat(values): the newtype convention lands — v.value reads the rep, N(r) constructs; one generic law - fix(ckc): relative paths resolve against cwd — the vacuous-green check footgun dies (debt #58) Touches 75 files: `Floor.ck`, `plan_training.md`, `scripts/cklang_ai.sh`, `src/rust/crates/ck_cktest/src/pipeline.rs`, `src/rust/crates/ck_driver/src/compile/mod.rs`, `src/rust/crates/ck_driver/src/compile/typecheck.rs` and 69 more.
hkowalski requested changes 2026-04-08 08:00:00 +02:00
Dismissed
hkowalski left a comment

Two things inline. The one in src/rust/crates/ck_driver/tests/string_value_run.rs is the one I care about.

Two things inline. The one in `src/rust/crates/ck_driver/tests/string_value_run.rs` is the one I care about.
@ -0,0 +93,4 @@
/// `ckc --container <shared> --work <name> run <app> --stdlib <repo>/tests/corpus/stdlib`,
/// returning the process exit code.
fn run_ck(name: &str, app: &Path) -> i32 {
Owner

run_ck reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?, at src/rust/crates/ck_driver/tests/string_value_run.rs:96.

`run_ck` reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?, at src/rust/crates/ck_driver/tests/string_value_run.rs:96.
@ -0,0 +14,4 @@
/// bound was checked against; secondary on the bound's declaration in the
/// callee's `where`-clause.
#[must_use]
pub fn e0710_concrete(
Owner

e0710_concrete is public. Does anything outside the crate call it, or is it pub because that was easier? Every public item is a promise.

`e0710_concrete` is public. Does anything outside the crate call it, or is it pub because that was easier? Every public item is a promise.
Author
Owner

Split. The decision in run_ck is a pure function now, with its own tests in src/rust/crates/ck_driver/tests/string_value_run.rs.

Split. The decision in `run_ck` is a pure function now, with its own tests in `src/rust/crates/ck_driver/tests/string_value_run.rs`.
Author
Owner

Nothing outside calls e0710_concrete. It is pub(crate) in src/rust/crates/ck_types/src/diag/constraints.rs now.

Nothing outside calls `e0710_concrete`. It is pub(crate) in `src/rust/crates/ck_types/src/diag/constraints.rs` now.
hkowalski approved these changes 2026-04-09 07:55:00 +02:00
hkowalski left a comment

Good. The second pass on src/rust/crates/ck_driver/tests/string_value_run.rs is what I hoped for.

Good. The second pass on `src/rust/crates/ck_driver/tests/string_value_run.rs` is what I hoped for.
Author
Owner

Pushed a fix for both. The second pass on src/rust/crates/ck_driver/tests/string_value_run.rs is much smaller than the first.

Pushed a fix for both. The second pass on `src/rust/crates/ck_driver/tests/string_value_run.rs` is much smaller than the first.
Two correctness fixes over the one value-type layout/projection law, plus the
newtype-over-scalar repr the Alloc::alloc proof exposed.

PART A — debt #63 (soundness). A record field initialized from a MULTI-WORD
record VARIABLE dropped its trailing words: `value_repr` classified a
record-bound local by raw word count (`Some(1) => Int`), but a record local is a
ONE-word frame-base POINTER standing for an N-word value, so a 2-word `Region`
field was planned as one `Int` word — under-sizing the frame and letting the
adjacent field clobber the dropped word. Now an aggregate local classifies from
its recorded TYPE first (the full-width `ValueRepr::Record`); scalars/strings
keep the width-count law. One generic fix in `ck_lower::layout::value_repr`.

PART B — debt #38 (named-tuple projection). `plan_tuple` named every slot
`Symbol(0)`, so a named tuple's `layout.field(name)` never matched and `v.name`
died at codegen (receiver span). Now `plan_tuple` carries each component's
label: a positional `Ty::Tuple` still passes `Symbol(0)`, a `Ty::NamedTuple`
passes its label, so projection resolves by the SAME `layout.field(name)` law
records use. The type layer already bridged a positional literal into a
named-tuple slot (assignable.rs) and resolved labels (field_access.rs); this is
the lowering half. `p.0`-positional-on-`Tuple<…>` is left unimplemented: §2a
states the unnamed tuple is "purely positional" but names no positional
projection surface, so no surface was invented.

Newtype-over-scalar repr (§4a). `Alloc::alloc` returns
`(| handle: Handle, arena: Arena |)` where `Handle = newtype usize` — a
newtype-over-a-scalar the layout law could not repr (not a leaf, not a record),
so a Handle-typed tuple/record field failed to plan. Per §4a ("`newtype Celsius
= f64` reaches the machine the same way [as a leaf]"), resolve now records such a
newtype's underlying scalar repr under its own TypeId (no well-known-scalar NAME
added — it stays a distinct nominal), so the existing leaf branch resolves it as
one scalar word.

Proofs (freestanding, exit 42, registered):
- record_field_words — a 2-word `Span` variable placed in a `Box` field,
  crossed by value both ways, both words + trailing scalar read back exact.
- alloc_named_tuple — arena_over → alloc ×2 → project handle (scalar) AND arena
  (record) from each → write/read through both handles, guarded on the advanced
  cursor.
Plus .cktest goldens: named-tuple projection types clean; an absent label
diagnoses E0718 (trapped). arena_return's caveat updated to the fixed truth.

Gates: fmt clean; clippy -D warnings 0; build ok; test full suite green (3
expected canaries); architecture 21/21; conformance ratchet == 94;
stdlib_type_clean green.
Tensor::matmul lands fully stride-generic (12 params — 3 Regions + 9
scalars, no arg-count wall): C[M,N] := A[M,K]·B[K,N] where ANY operand
may be a transposed view by swapping its stride pair. The backward pass
is therefore NOT a new kernel: backward_a / backward_b are one matmul
call each over swapped strides (dA = dC·Bᵀ, dB = Aᵀ·dC) — the geometric
view's payoff, gradients at zero data movement, proven byte-identical to
raw stride-swapped calls. The stone pins the full hand tables (C =
[[58,64],[139,154]], dA, dB, Cᵀ) and a descent step on the matmul
substrate: loss 6400 → 538, strict decrease. Exit 42, codes 10..19.

THE FLOOR RE-PROBE (2026-07-19, recorded in plan_training.md §6): the
test-fn memory wall PARTIALLY FELL under the parallel session's
test-path fix. os_acquire and Floor::store now lower in a test fn; a
test fn calling Tensor::dot runs green; Int  lowers. The residual
wall is PER-MODULE: a direct Floor::load in the test-containing module
still dies (unsupported Floor IR form), while the same load one module
boundary away lowers. Exploited: the memory matmul kernels are asserted
NATIVELY IN test fns for the first time (reads via the public Back
module — the E0302/Fed precedent). Kernels keep shifts over  BY
DESIGN (integer-native), stated in prose. One use-after-free (read
after os_release) caught and fixed by the Fed law: read lanes into
locals before release.

Spec: MM-1/MM-2/MM-3 PROVEN, MM-4 DEFERRED; the tensor_backprop exit
table; the §6 memory-wall erratum. Runner: backprop joins the scoreboard
and the cross-invocation act (backprop.results, 12 deterministic words).

Verified (orchestrator-run): cklang_ai.sh exit 0 — five suites
13/9/7/10/6 all green, race+fed+backprop byte-identical across 3
invocations; tensor_backprop run 42 + 6 green; all siblings unchanged.
The &&/|| desugar comment on LogicOp claimed the value-logical symbols lower to
"a short-circuit ExprKind::Match" with "no ExprKind of their own". Both are
wrong about live code: the resolver desugars &&/|| to the dedicated
ExprKind::ShortCircuit node (the Match desugar was never buildable — the eager
true/false cannot be a manifest-read HIR Lit at the call site). Corrected to
describe what the code actually does; no behavior change.
A declared union bound now constrains. `resolve_bound` used to drop every
`where T is A or B or …` before the checker saw it (a no-op), so
`Floor::iadd::<String>` type-checked only because the bound was inert. It now
lifts each enumerated member to its concrete `Ty` (`PredicateBound::Union`),
and the call-site predicate check requires the substituted `T` to be
`assignable` to at least one member — a member of none diagnoses cleanly at the
call site as the newly minted E0750, the union-bound sibling of E0710.

Enforcement exposes every out-of-bounds instantiation as a new error, so the
where-lists were inventoried and squared first:

- byte joins Floor's where-lists wherever u8 appears (21 lines each in
  tests/corpus/stdlib/Floor.ck, tests/corpus/stdlib/Floor.ckh, and the repo-root
  soll Floor.ck). `byte` is the `{ size: 8, signed: false, float: false }` leaf —
  a legitimate unsigned 8-bit member beside u8 that Prelude.ck already impls
  (Add/Sub/Mul/Neg/Equals/Ord/BitAnd/…). Without this, enforcement would fire
  E0750 on byte_ops, whose operators desugar to `Floor::iadd(byte, …)` etc.

No other latent out-of-bounds site surfaced: stdlib_type_clean and the whole
corpus stay green with enforcement live (the byte widening was the only one
needed).

diag.rs would have breached the R3 line cap (840), so the `where`-predicate
diagnostic family (e0710_concrete / e0710_discharge / e0712 / e0750) moved to a
new diag/constraints.rs sibling, re-exported so `crate::diag::*` import paths are
unchanged — the same split discipline as diag/actors.rs and diag/values.rs. This
shrinks diag.rs 814→755 rather than raising the cap.

Proofs: tests/types/union_bound_unsatisfied.cktest (negative golden, E0750
trapped), tests/types/union_bound_satisfied.cktest (positive control, clean),
two predicate.rs unit tests for the union lift, byte_ops exit-42 green,
conformance ratchet held at 94.
Debt #32. The coherence key bucketed a parameterized nominal at
head-constructor depth: Money<USD> and Money<EUR> both keyed as "Money",
so two same-trait tag-distinct impls collided (E0709) and could never
coexist. Per the parameters law their identities are distinct
(plan_parameters §0), so the key now discriminates CONCRETE tag arguments
while impl-generic binders stay wildcards (plan_coherence §1b).

The mechanism:
  - ConstValue gains a Tag(TypeId) form: a nullary data variant filling a
    tag-typed value parameter (currency: Currency filled by USD). Already
    concrete — never const-evaluated, it IS the value.
  - The lift resolves a bare variant naming a value of the parameter's
    declared data type to that Tag, on BOTH the impl-head path
    (lift/impl_head.rs) and the ordinary annotation path (lift.rs
    value_slot_tag) — scoped to the parameter's declared type, so it is
    unambiguous (no cross-data search).
  - The coherence key's slot becomes a SlotKey { head, tags }: the head
    constructor plus the concrete tags of its immediate value arguments.
    A Tag argument is a discriminant; an Int/Sym size or a type argument
    wildcards. Money<USD> and Money<EUR> share head Money but carry
    distinct tags; every String<sa> size collapses to the one head-only
    slot. Still one hash probe, no solver — the SAME slot_key builds both
    the population rows and the read-side dispatch probes, so equality
    matching holds without any wildcard-at-probe pass.

The const-param surface (landed fa75f9e) stays green: String<sa>/String<n>
binders wildcard exactly as before (freestanding_concat + string_eq run
clean), E0748/E0749 untouched.

Debt #37 — VERDICT: MOOT / already-landed, no machinery built. The only
generic impls that exist (Concat<String<sa>,…>, Equals<String<n>>) are
value-level MONOMORPHIC — String erases its size, so the method body
(params typed String, not String<sa>) reaches codegen once through the
existing T-1b direct-dispatch channel; the const-eval engine folds the
type-level size arithmetic for the RESULT TYPE only. The tag impls
(Add<Money<USD>,…>) are CONCRETE separate bodies, dispatched directly —
debt #32's discrimination routes each to its own row. A separate
type_args_for stamp channel would only be needed for a genuinely
value-polymorphic-over-a-type-parameter impl body (impl Ord<List<T,n>>);
none exists in the corpus. Building it now is speculative machinery, so
it is deliberately not built (empirically verified: no such impl, no
regression).

Proofs (type-level, landed):
  - coherence_arity.rs distinct_concrete_tags_register_as_distinct_rows —
    Money<USD> + Money<EUR> impls both register, no E0709 (2 rows).
  - identical_concrete_tags_still_collide_e0709 — the negative golden.
  - size_generic_binder_head_keys_as_one_wildcard_row — binder wildcard.
  - coherence_key.rs slot_key/read-probe unit tests — the key builder and
    dispatch-key discrimination.
  Empirically (driver check, not committed): both the turbofish and the
  OPERATOR form (x + y) of tag dispatch type-check clean and route USD→its
  body, EUR→its body.

Runtime blocked (unrelated gap, precisely): tag-parameterized newtype
VALUE construction in expression position — Money<USD>(expr) — is unwired
(E0301/E0146/E0055/E0048 at the constructor head; construct.rs /
value_turbofish, untouched here). Typing and dispatch of tag types work;
you cannot yet construct the operands, so no runtime program produces
them. Type-level proof landed per the slice contract.
Four independent completeness holes where the checker stayed silent on wrong
programs, each now a real diagnostic + golden (or a type-level golden), plus the
#16 lift fix. Codes: composes-missing = E0753 (E0750 landed first, on wip_v2, for
the union-bound predicate check in ck_types/src/diag/constraints.rs — this slice
was rebased onto that tip and renumbered off the collision); impl-conformance =
E0751; type-slot kind = E0752.

- #14 (E0753) — a `composes` clause naming no trait anywhere in the closure
  (`newtype X = u8 composes NoSuchTrait`) fires at the composes site, for
  newtype/record/data. Gated on a new project-global trait roster
  (`Interner::register_trait_id` / `trait_name_declared`, the trait-side sibling
  of `nominal_ids`): traits have no cross-module scope fallback, so a
  cross-module-real composed trait (`Types.ck`'s `String composes Equals`,
  `Equals` in `Prelude.ck`) stays clean — only a name declared as a trait
  nowhere fires. Existence-only; it never changes which TraitId an impl/derive
  binds.

- #36 (E0751) — impl-vs-trait signature conformance (arity / param type / return
  type), read under the impl head's type-argument substitution. New
  project-global pass `check_impl_conformance`, wired into all three pipelines
  (ck_cktest, driver typecheck + single-file). Const-parameter binders are
  substituted, not rejected: the comparison absorbs symbolic/fail-soft/bare-head
  positions as wildcards, so `impl Equals<String<n>>` (binder head, bare-`String`
  method params) stays clean while `u16` vs `u8` / `Bool` vs `u8` / wrong arity
  fire.

- #12 (E0752) — a literal in a TYPE slot (`List<4, 4>`), the reverse
  kind-mismatch of E0742, diagnosed in the value-param-args walk. Value-slot
  literals (`String<8>`, `List<u8, 4>`) stay legal — kind is per declared
  parameter.

- #16 — a newtype rep referencing the declaration's OWN generic
  (`newtype Box<T> = (| val: T |)`) lifted to Unknown (empty-generic-map limit);
  the rep lift now threads the newtype's generic binders so `val: T` lifts to its
  Ty::Var. Type-level golden (unit test) — newtype rep field access has no
  instantiating consumer yet.

- #20 (checked, left) — Panic::raise's hint is a fully bespoke path (no lifted op
  signature); general `assignable` is invariant on `String<N>`'s size, so it does
  not subsume the id-based any-size relaxation. Kept.

Gates: fmt clean, clippy -D warnings clean, workspace build clean, architecture
22/22, conformance ratchet held at 94 (new fixtures + two stale
old-silent-behavior fixtures fully trapped), stdlib-type-clean green.
The two Types.ck conventions now type, lower, and RUN, as one generic law over
the rep — never a `resolve == "String"` probe, never a per-name codegen arm.

READ `v.value : R` (ck_types/src/field_access.rs): a nominal that is a newtype
exposes exactly one synthetic field `value`, typed at its rep — the
`Ty::NamedTuple` built from the components N1 registered (`newtype_reps`). So
`s.value.len` chains through the just-landed named-tuple projection. The reserved
accessor is the single spelling `value` applied to EVERY newtype (a record — even
one with a field named `value` — is untouched: it has no rep); not a per-type
probe.

CONSTRUCT `N(r)` (ck_resolve bodies.rs + ck_types check.rs/newtype_ctor.rs): a
bare name that misses scope but names a project-global nominal resolves to its
type (the value-position twin of the constructor-head / lift cross-unit
fallbacks), so a newtype name stands in call position; the type stage synthesizes
`Fn(R) -> N` from the declaration (rep param = the SAME NamedTuple the accessor
reads; result via the bare-name default bound). A wrong-typed rep argument
diagnoses like any other call (E0702).

LOWER (ck_lower/src/lower/newtype_lower.rs): both surfaces re-view the newtype's
words across the flat/frame representation boundary, keyed on the rep's
`ValueRepr` word list, never a name. `.value` is recognized STRUCTURALLY
(NamedTuple-typed access off a flat, non-Record receiver) and materializes the
flat words into a rep-shaped frame the proven record-projection path loads out of.
`N(r)` is a zero-instruction identity retype when the reprs agree, else the
frame→flat bridge (a String's two-word `Str`, the transitional residue slice-8's
Str→record collapse folds into the generic record law).

Value-name-collision ruling (as the convention's letter directs): the accessor is
reserved on NEWTYPES ONLY. On a newtype the rep read is checked first, so it wins
absolutely (a pathological newtype field named `value` is shadowed by the rep);
records carry no rep, so their own `value` field resolves normally — records are
untouched.

Proofs: three .cktest goldens (rep read + construct clean; `.value` on a record →
E0718; `N(r)` wrong rep → E0702, both trapped so the ratchet holds) + a
field_access unit test asserting the rep NamedTuple with real component types +
the `string_value` runtime stone (read `hello.value.len`, construct via
`String((| p, n |))`, round-trip both words, exit 42, freestanding), registered
per runtime_registry.rs.

This unblocks the condemned-selector kill (of_str/str_len/as_str →
s.value.position/len + String construction) and slice 8's world types; the
selector rewrites are a follow-up slice's, untouched here.

Gates: fmt clean; clippy 0/0; build clean; architecture 22/22 (v6 name-probe gate
green — machine-confirming no name probe); conformance ratchet ≤94; ck_types 216 +
ck_resolve 100 green. R3 cap 840→845 (this arch file + the append-only
`string_value` RUNTIME_TESTED row); the ctor fork stayed under cap (dispatch
extracted to newtype_ctor.rs::synth_call_ctor, check.rs 858→820) so no R4
allow-list entry was needed.
`ckc check <dir>` never canonicalized its positional path (or `--stdlib`)
before handing it to the project-closure loader. `ck_load::load_project`
lexically normalizes the root it's given, but the raw CLI argument was
used as-is for the closure's `starts_with(app_root)` file attribution — so
any directory spelled with a `.`/`./` component (`ckc check .`, `ckc check
./proj`, cwd-relative invocations generally) diverged from the loader's
own normalization, matched zero files, and silently rendered "is clean"
over an EMPTY closure instead of the project's real diagnostics. `ckc
run`/`ckc test` already canonicalize (`run::run_project`/`run_tests`), so
`check` was the one command carrying the seam.

Fix: `check_cmd` now resolves both the positional path and the stdlib
root via `std::fs::canonicalize` (mirroring the existing `run`/`test`
guard) before any closure load, so relative and absolute spellings name
the identical filesystem entity. A path that fails to canonicalize (does
not exist) is now a hard error naming it. `check_project` additionally
hard-errors if the resolved closure attributes zero app-owned files (a
genuinely empty directory, or any future regression re-opening the same
mismatch) — "0 errors" can no longer mean "0 files loaded".

Proof: `crates/ck_driver/tests/check_path_resolution.rs` drives the real
`ckc` binary and shows (a) a `./`-prefixed relative path, a plain relative
path, and the absolute path all surface the SAME E0722 diagnostic for the
`check_tyerr` fixture, none reporting a vacuous "is clean"; (b) a
nonexistent path is a hard error naming the argument; (c) an existing but
empty directory hard-errors rather than reporting clean.

Gates: cargo fmt --check clean; cargo clippy --all-targets -D warnings
clean; cargo build --workspace clean; cargo test --workspace
--no-fail-fast green (only the 3 pre-existing canary CkLang test-fn
failures embedded in otherwise-passing container tests, plus the
pre-existing tensor runtime-coverage red — no new failures); cargo test
-p ck_driver --test architecture 22/22.
hkowalski manually merged commit e5b93b4dc4 into master 2026-04-09 08:00:00 +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!77
No description provided.