effects: a written row is reconciled against what the body incurs #227

Manually merged
hkowalski merged 6 commits from feat/227-effects-a-written-row-is-reconciled-against-wh into master 2026-09-11 08:00:00 +02:00
Owner

effects: a written row is reconciled against what the body incurs

Along the way:

  • fix: measure.rs was over the R3 cap, hidden behind the standing red
  • scripts: the integration sweep — run by hand, deliberately not a leg
  • lift: a qualified type path skipped the head-shape tail
  • scripts: delete what nothing calls, and one vendor cache that was never ours
  • compressed CLAUDE.md

Touches 135 files: CLAUDE.md, plan.md, scripts/test-integration.sh, scripts/test.sh, scripts/training/.gitignore, src/rust/Cargo.lock and 129 more.

effects: a written row is reconciled against what the body incurs Along the way: - fix: measure.rs was over the R3 cap, hidden behind the standing red - scripts: the integration sweep — run by hand, deliberately not a leg - lift: a qualified type path skipped the head-shape tail - scripts: delete what nothing calls, and one vendor cache that was never ours - compressed CLAUDE.md Touches 135 files: `CLAUDE.md`, `plan.md`, `scripts/test-integration.sh`, `scripts/test.sh`, `scripts/training/.gitignore`, `src/rust/Cargo.lock` and 129 more.
hkowalski approved these changes 2026-09-10 13:28:30 +02:00
hkowalski left a comment

Reads well. One question on src/rust/crates/ck_resolve/src/effect_rows.rs, nothing blocking.

Reads well. One question on `src/rust/crates/ck_resolve/src/effect_rows.rs`, nothing blocking.
@ -0,0 +55,4 @@
/// E0506 was checked over, which is what keeps the two passes agreeing about
/// what an entry means.
#[must_use]
pub fn collect_declared_rows(
Owner

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

`collect_declared_rows` 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

Nothing outside calls collect_declared_rows. It is pub(crate) in src/rust/crates/ck_resolve/src/effect_rows.rs now.

Nothing outside calls `collect_declared_rows`. It is pub(crate) in `src/rust/crates/ck_resolve/src/effect_rows.rs` now.
mdoorn approved these changes 2026-09-11 07:55:00 +02:00
mdoorn left a comment

Read it too. No objection to src/rust/crates/ck_resolve/src/effect_rows.rs.

Read it too. No objection to `src/rust/crates/ck_resolve/src/effect_rows.rs`.
`docs/10-effects.md` said the compiler checks the row law. It did not. E0506
asked whether a row entry DENOTES and nothing asked whether the claim was
TRUE, so a body could incur `Log` with no row, or name `Log` and never log,
and both compiled. Both halves are now refused:

    direct ∖ declared  ->  E0413   incurred and not named
    declared ∖ direct  ->  E0509   named and not incurred

THE LAW LIVES WITH THE GRAPH. `ck_effects::rows` is the consumer ruling #30
reserved that crate for, and it uses the `ck_diag` edge the crate had declared
in its manifest and used in zero source files since it landed. Both directions
compare against `direct_effects`, never `reaches`: comparing against the
transitive set would demand exactly the restatement the law forbids.

The transitive half is not idle — it is what makes E0509 actionable. Every real
over-declaration in this tree is a row restating a callee's effect, so the
message names the callee (`carries_only`'s question, asked through `callees`).
Where no carrier is among the fed frames it says nothing about callees at all:
the closure is app-only, so "nothing it calls incurs one" is a claim this
compiler cannot always support.

THE SEAMS ARE EXISTING ONES, NOT NEW PARALLEL WALKS.

  · `ck_syntax::effect_row_of` — one reader for a row's syntactic shape. Three
    callers were hand-rolling `child_node_of(node, EffectClause)`; the four row
    positions now share one definition and a fifth would inherit it.
  · `ck_resolve::effect_rows` — collects `FnId -> WrittenRow` off `module_fns`,
    already the canonical "every function this module declares". Actor slots are
    covered without being named (the §2a desugar hands them over as impl
    methods) and bodiless declarations are excluded without an exemption
    (`module_fns` skips traits and effect ops, so the law inherits the right
    domain instead of allow-listing to it). A fn with no row maps to an EMPTY
    row, never an absent key: "I incur nothing" is a claim, and it is the one
    E0413 most often refutes.
  · `ck_driver::effect_rows::Frames` — feeds the fixpoint from
    `ResolvedBody::resolutions`, the same map `interface_ops` reads, so provider
    DI and the effect graph cannot disagree about what a call is.

An under-declaration anchors on the declaration's own name token, not on
`fn_data(id).span`: N1 mints the `FnId` from the `.ckh`, so the interner's span
points at the header while the body is in the `.ck`. The row is part of the
SIGNATURE, though — E0403 checks impl against header — so a fn whose body incurs
an effect declares it in both files or in neither.

THE MIGRATION: 301 rows added, 9 removed, 127 files. Every removal was one
shape, a row restating an effect a callee incurs, and
`tests/corpus_test_run/greeter` is where that shape had been planted
deliberately all along — the cell cited it at its pre-`0d6d1c13` path, which no
checkout contains.

TWO DEFECTS THIS SURFACED, NEITHER OF THEM THE ROW LAW'S:

  · `grammar_dsl/measure.rs` scanned `delimited_after(src, "effect", '{', '}')`,
    which matches the `effect` of a ROW and then measures the function's body as
    a declaration body. Corpus-wide rows made it report M14 = 24 at a `test fn`.
    Guarded on declaration position; `Grammar.ckx`'s table needed no change,
    because the measurement was false rather than shifted.
  · Spans on a desugared page point into generated text — an existing E0506 on a
    row at real line 28 reports 108, the same shift. Pre-existing and left
    alone, but E0413 will be the most-read diagnostic on those pages, so it
    wants its own slice.

`effect_row_is_not_checked_yet` is renamed to
`effect_row_is_checked_in_both_directions` (a rename, so `CELL_COUNT` holds at
440) and declares `typecheck: E0413, E0509, E0509` — the three violations its
page carries, and not redundant: `quiet` falls to the direct set alone, while
`spawn` really is on a path to `Log` and falls only to the rule that a row never
restates a callee's.

The cell carried no `// STATUS:` line while its own prose said it was BROKEN, so
it sat in the unmarked set — invisible to `grep -l '^// STATUS: BROKEN'`, which
is the undercount `UNMARKED_CELL_COUNT` warns about. Marking it is what put it
in the fix queue, and it came straight back out: `BROKEN_CELL_COUNT` 44 -> 45 ->
44, `UNMARKED_CELL_COUNT` 146 -> 145.

`ck_driver/src/load.rs` hit the R3 cap, so the effect-graph seam split to its own
module. `ck_driver/src/lib.rs` went 1322 -> 1323 for one `mod` line; that red is
still the standing one and still wants the driver-root split, not a cleanup.

Gate: 4 legs green. Workspace sweep 1610 passed, 1 failed — the standing R3.
`clippy --workspace --all-targets -D warnings` 0, `fmt` clean.
`1867b888` added the guard that stops `delimited_after(src, "effect", …)` from
reading a ROW as a declaration, and pushed this file to 864 lines against a cap
of 862. That is a NEW R3 violation, and it shipped: `r3_no_file_exceeds_loc_cap`
reports every offending file from ONE test, and that test was already red on
`ck_driver/src/lib.rs`, so the new line simply joined a list nobody could go
green on.

The count did not move either — `cargo test --workspace --tests` still read
"1610 passed, 1 failed", which is what the commit claimed as evidence it matched
the documented baseline. It matched by number and not by content: one failing
test was carrying two violations.

That is the cost of a permanently-red gate, and it is worth stating plainly
because the allow-list in `scripts/test-integration.sh` inherits the same
hazard: a standing red is a place new breakage hides, so it is allow-listed by
TEST NAME and reconciled in both directions rather than tolerated as a count.

Trimmed to 860 by tightening the comment and the helper the same commit added —
no behaviour change, and `starts_its_line` reads better as `opens_its_line`.
`lib.rs` is once again the only R3 violation.
`./scripts/test.sh` prints what it cannot see and trusts you to run the rest.
That did not survive contact: the effect-row slice was four legs ALL GREEN over
NINE reds living only in the integration binaries — a broken `trap.md` in five
apps, a false `Grammar.ckx` measurement, four behaviour goldens, a missing R1
edge, and the R3 violation the previous commit fixes. Two of those were defects
the slice itself introduced. They were found by running the sweep by hand, which
is exactly the step a gate exists to make unforgettable.

So this script is the sweep, written once: the rotating mask, the toolchain
guard, `--test '*'` so the 1354 unit tests are not run twice, per-phase timing,
and a known-red allow-list reconciled in BOTH directions — an unlisted failure
is red, and so is a listed one that started passing.

IT IS NOT A LEG, and `test.sh` does not call it (owner, 2026-08-18). It costs
~37s where the legs cost 0.7-5s. That is not the weight of the work: it is 57
binaries that each statically link the whole compiler, run one at a time by
cargo, each holding twelve idle threads while the next waits. Making the common
path eight times slower to cover a remainder that shrinks with every leg script
is the wrong trade, so the gate keeps naming the gap and now names the command
that closes it.

Measured for whoever revisits that: running the built binaries concurrently — 12
at a time, one thread each, the same total budget — took 10.9s against 36.6s for
an identical 251/1/1. Recorded in the header as ONE measurement that was never
validated for concurrency safety; several of these binaries link and execute
programs and some want a container or a port. Prove it before trusting it.

What it covers that the legs do not is listed in the header rather than
summarized here, because "and some others" is how a coverage hole becomes
permanent — `corpus.rs`'s 233 apps against the corpus leg's 106, `conformance.rs`
per front-end stage, `architecture/`'s R1-R12, the size and frame pins,
`Grammar.ckx`'s table, ~30 behaviour gates. `capability.rs` is the one genuine
overlap, and it is the migration mid-flight.
The wrong reasoning was that resolving the NAME is the whole job of the
two-segment `Mod::Type` branch. It is not: `lift_type_expr` returned
`Ty::nominal(id, [])` straight off the nominal-roster hit, while every bare
head goes on through `lift_scope_head` -> `nominal_ty_of` -> `data_or_nominal`.
Same `TypeId`, two shapes.

As arithmetic: `Shade` lifts to the union `Shade::Dim or Shade::Bright`;
`Hue::Shade` lifted to the opaque nominal `Shade`. So E0714's
`assignable(Shade::Dim, elem_ty)` took case 2 (tag membership -> holds) inside
the declaring module and case 1 (TypeId equality, `Shade::Dim != Shade` -> ())
outside it — one diagnostic per element, plus E0702 on the literal against the
parameter, whose own `List<Shade, 2>` had been lifted the expanded way. Three
diagnostics from one missing call.

What moved: the two roster returns in that branch now end in `data_or_nominal`,
and the branch comment — which claimed parity with `lift_scope_head` it did not
have — says what was missing. Net-zero on line count: `lift_type_expr` sits at
exactly MAX_FN_LOC (100) and is the sole binding function for that cap.

What deliberately did not: the `resolve_variant_type_path` return above it stays
a plain nominal (a variant is not a `data` parent), and the other roster lifts
(`lift_type_name` and friends) are untouched — the cell pins type position, and
that is the branch that was wrong.

The cell's OWNER CALL — "is a `data` sum's variant module-private in element
position?" — is answered by the mechanism: it is not, in any position. The
answer needed no design ruling once the branch was read.

`tests/capability/data_variant_does_not_lift_to_its_union_across_a_module` ->
`…_lifts_…`, declaring `runs: 42` (a rename, so `CELL_COUNT` holds at 440).
`BROKEN_CELL_COUNT` 44 -> 43. `a_data_union_declared_only_in_the_header_binds_in_the_impl`
cited element position as BROKEN to justify its argument-position spelling —
that sentence is now false and is rewritten; the spelling stays, because one
cell per law is still right.

Gates: ./scripts/test.sh ALL GREEN — 4 legs, 11.7s (unit; corpus 106 apps;
capability 440 cells; docs 35 expectations held, 0 failed). The cell
self-retired first under `test-capability.sh -v`, printing `runs: 42` — that
failure is the proof the fix landed. NOT RUN at the owner's instruction: the
`cargo test --workspace` sweep of the ~40 binaries `test.sh` prints as not
covered, `cargo fmt --check`, `cargo clippy`. The standing red that sweep
carries (`architecture::r3_no_file_exceeds_loc_cap`, `ck_driver/src/lib.rs`)
is pre-existing and untouched here.
Four removals, chosen by asking what actually reads each file rather than
what each file says about itself:

  · scripts/training/unsloth_compiled_cache/ — 39 files, 1.8 MB, headed
    "Unsloth auto generated code" and version-stamped 2026.5.3. Swept into
    cc9a60ec with the training scaffolding; the library regenerates it on
    import when its version moves, so the committed copy could only ever go
    stale. Now gitignored, with the reason written down.
  · scripts/training/diagnose-hf.sh — one-off curl triage for a slow
    HuggingFace download. Referenced by no step of run.sh, no README, no
    ITERATION_LOG entry; the incident it diagnosed is no longer described
    anywhere.
  · scripts/tictactoe.sh, scripts/ttt_train_report.sh — a demo and a metrics
    report. Nothing outside their own headers called either.
  · scripts/cklang_ai.sh — 1951 lines rendering plan_training.md §7/§8/§9, a
    spec this repo does not contain.

The two comments that cited cklang_ai.sh are corrected in the same commit,
because a deletion that leaves its citations standing just moves the lie:
ck_driver's `interactive` doc drops it from the caller list, and
tensor_race/Main.ck names `od` as the reader of race.results rather than a
script that is gone. The layout claim itself is unchanged — it was always
about the bytes, not about who read them.

Nothing else in scripts/ is dead. The four legs, test-integration.sh, both
lib/ helpers and docs/verify.sh call each other; deploy.sh, clean.sh and
ckeditor-integration.sh are cited from crate doc comments; offline-core16
is installed and the degraded core is still offline; every remaining
training script is a numbered step of run.sh.

Gates: four legs ALL GREEN (1354 unit / 106 apps / 440 cells / 35 book
expectations), integration sweep 251 passed with the one known-red
architecture entry, fmt clean, clippy 0 warnings.
hkowalski manually merged commit c6c46e6188 into master 2026-09-11 08:00:00 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 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!227
No description provided.