plan: re-stage 0121 — fold borrow-over-value into the cutover (mono collision)

First implementation contact surfaced a plan defect: the
borrow-over-value reject cannot land green as a standalone pre-cutover
task. The prelude's polymorphic comparison builtins are
`(params (borrow a) …)` (eq/compare/lt/…); the mono pass
(apply_subst_to_type, subst.rs:165) specialises `a := Int/Bool/Float`
into `compare__Int(borrow Int, …)` — the language's OWN generated code
in the forbidden `(borrow value-type)` shape. The standalone check
broke compare_{int,bool}_mono_symbol_emits_branch_ladder.

Spec 0062's "value types always own" rule is universal, so generated
code must honour it too: the principled fix is a mono-pass coercion
`(borrow value-type) -> (own value-type)` (a no-op — value types carry
no RC, so own vs borrow emits no inc/dec and the branch-ladder IR is
unchanged). The check and the coercion are a matched pair and now live
together in Task 4 (Steps B1-B2), which touches the mono path anyway.

Pre-cutover green tasks are now Task 1 (borrow-return reject) + Task 3
(throwaway migration tool). Task 1's check_def destructure binds only
ret_mode (param_modes stays under `..`); Task 4 Step B2 widens it.

Spec gap to record at the next 0062 brainstorm touch: the
borrow-over-value rule needs its mono-coercion clause spelled out — the
spec tested only the authored case.

refs #55
This commit is contained in:
2026-06-01 16:33:58 +02:00
parent a84ba596cf
commit 7dc21234f0
+206 -121
View File
@@ -68,7 +68,8 @@ figures the plan executes against (verified by `plan-recon`,
## Files this plan creates or modifies ## Files this plan creates or modifies
- Create: `examples/borrow_return_reject.ail` — RED fixture, borrow-return reject (Task 1) - Create: `examples/borrow_return_reject.ail` — RED fixture, borrow-return reject (Task 1)
- Create: `examples/borrow_value_reject.ail` — RED fixture, borrow-over-value reject (Task 2) - Create: `examples/borrow_value_reject.ail` — RED fixture, borrow-over-value reject (Task 4 Step B2)
- Modify: `crates/ailang-codegen/src/subst.rs:165` — mono-coercion `(borrow value-type)→(own value-type)` (Task 4 Step B1)
- Create: `examples/bare_slot_reject.ail` — RED fixture, parser bare-slot reject (Task 4) - Create: `examples/bare_slot_reject.ail` — RED fixture, parser bare-slot reject (Task 4)
- Create: `examples/own_return_provenance_reject.ail` (+ `_let`, `_ctor` variants) — already-green `consume-while-borrowed` regression pins (Task 4) - Create: `examples/own_return_provenance_reject.ail` (+ `_let`, `_ctor` variants) — already-green `consume-while-borrowed` regression pins (Task 4)
- Create: `examples/ownership_total.ail` — post-cutover authoring-surface GREEN fixture (Task 4) - Create: `examples/ownership_total.ail` — post-cutover authoring-surface GREEN fixture (Task 4)
@@ -198,14 +199,15 @@ destructured at `:2240`:
} }
``` ```
Change the pattern to bind the modes, and after the existing Change the pattern to also bind `ret_mode` (leave `param_modes` under
`check_type_well_formed(&ret_ty, &env)?;` line (`:2275`) add the `..` — Task 1 does not read it; Task 4 Step B2 widens further), and
reject. First, widen the destructure: after the existing `check_type_well_formed(&ret_ty, &env)?;` line
(`:2275`) add the reject. First, widen the destructure:
```rust ```rust
let (param_tys, param_modes, ret_ty, ret_mode, declared_effs) = match &inner_ty { let (param_tys, ret_ty, ret_mode, declared_effs) = match &inner_ty {
Type::Fn { params, param_modes, ret, ret_mode, effects } => { Type::Fn { params, ret, ret_mode, effects, .. } => {
(params.clone(), param_modes.clone(), (**ret).clone(), *ret_mode, effects.clone()) (params.clone(), (**ret).clone(), *ret_mode, effects.clone())
} }
other => { other => {
return Err(CheckError::FnTypeRequired( return Err(CheckError::FnTypeRequired(
@@ -227,9 +229,10 @@ Then immediately after `check_type_well_formed(&ret_ty, &env)?;`:
``` ```
> Ensure `ParamMode` is in scope in `lib.rs` (it is — `param_modes` > Ensure `ParamMode` is in scope in `lib.rs` (it is — `param_modes`
> are already referenced elsewhere in the file). If the widened > are already referenced elsewhere in the file). Task 1 binds only
> destructure makes `param_modes`/`ret_mode` unused warnings appear > `ret_mode`, leaving `param_modes` under `..`, so there is no unused
> before Task 2 lands, prefix with `_` and unprefix in Task 2 Step 4. > binding. **Task 4 Step B2** widens this same destructure to also bind
> `param_modes` (its borrow-over-value loop is the first reader).
- [ ] **Step 7: Run the test to verify it passes** - [ ] **Step 7: Run the test to verify it passes**
@@ -245,111 +248,37 @@ rejects the body shapes that would force one).
--- ---
## Task 2: New signature reject — borrow-over-value ## Task 2: New signature reject — borrow-over-value — FOLDED INTO TASK 4
`(borrow value-type)` becomes a check error, fired on the signature. > **Finding during implementation (2026-06-01), re-staged.** The
Independent of `Implicit`; tree stays green; independently committable. > borrow-over-value reject CANNOT land green in isolation before the
> cutover. The prelude's polymorphic comparison builtins are declared
**Files:** > `(fn-type (params (borrow a) (borrow a)) (ret …))` (`eq`, `compare`,
- Create: `examples/borrow_value_reject.ail` > `lt`/`le`/`gt`/`ge`, …). When the mono pass specialises `a := Int`
- Modify: `crates/ailang-check/src/lib.rs` (`CheckError` enum, `code()`, signature check) > (or `Bool`/`Float`), `apply_subst_to_type` (`subst.rs:165`) produces
- Test: `crates/ail/tests/mode_signature_rejects.rs` (extend) > `compare__Int(borrow Int, borrow Int)` — i.e. the language's OWN
> generated code is in the forbidden `(borrow value-type)` shape. The
- [ ] **Step 1: Confirm the corpus has no borrow-over-value today** > standalone check broke `compare_{int,bool}_mono_symbol_emits_branch_ladder`
> (`ailang-codegen` lib tests) with `borrow over value type in
Run: `git grep -nE '\(borrow \(con (Int|Bool|Float|Unit)\)' examples crates/ailang-kernel` > compare__Int`.
Expected: no matches (if any match, that fixture must be re-moded to >
`(own …)` as part of this task before the check can land green — note > **The spec's rule is universal ("value types always own", model
it in the implement report). > §3.2), so the language's generated code must honour it too** — the
> principled fix is that the mono pass coerces `(borrow value-type) →
- [ ] **Step 2: Write the RED fixture** > (own value-type)` when specialising a polymorphic borrow param onto
> a value type (semantically a no-op: value types are never
Create `examples/borrow_value_reject.ail`: > refcounted, so own vs borrow emits no inc/dec and the branch-ladder
> IR is unchanged). The check and the coercion are therefore a matched
```ail > pair and land together in Task 4 (which touches the mono/codegen
(module borrow_value_reject > path anyway), not as a standalone pre-cutover task.
>
(fn ignore > Spec gap to record at the next brainstorm touch of 0062: the
(doc "MUST FAIL post-cutover: borrow over a value type is meaningless.") > borrow-over-value rule needs the mono-coercion clause spelled out;
(type > the spec tested only the authored case.
(fn-type >
(params (borrow (con Int))) > The borrow-over-value variant + check + fixture + the
(ret (own (con Int))))) > `borrow_over_value_is_rejected` test + the mono-coercion now live in
(params n) > **Task 4 Phase B Steps B1B2** below.
(body 0)))
```
- [ ] **Step 3: Extend the test**
Append to `crates/ail/tests/mode_signature_rejects.rs`:
```rust
/// `(borrow value-type)` is rejected with code `borrow-over-value`.
#[test]
fn borrow_over_value_is_rejected() {
check_rejects_with("borrow_value_reject.ail", "borrow-over-value");
}
```
- [ ] **Step 4: Run the test to verify it fails**
Run: `cargo test -p ail --test mode_signature_rejects borrow_over_value_is_rejected`
Expected: FAIL — today the fixture checks clean.
- [ ] **Step 5: Add the `CheckError` variant + code**
In `pub enum CheckError`, add:
```rust
/// A fn-type slot is `(borrow V)` where `V` is an unboxed value
/// type (`Int`/`Bool`/`Float`/`Unit`). Borrow is meaningless over
/// a value type — it has no refcount and is copied by value
/// (model §3.2). Code: `borrow-over-value`.
#[error("borrow over value type in `{def}`: `{ty}` is an unboxed value type and cannot be borrowed; use `(own {ty})`")]
BorrowOverValueType { def: String, ty: String },
```
In `fn code(&self)`, add before `CheckError::Internal(_)`:
```rust
CheckError::BorrowOverValueType { .. } => "borrow-over-value",
```
- [ ] **Step 6: Fire the check at the fn signature**
In `check_def`, immediately after the borrow-return check added in
Task 1 Step 6, add (this uses the `param_modes`/`param_tys` bound in
Task 1; unprefix them if Task 1 prefixed with `_`):
```rust
// spec 0062: borrow over an unboxed value type is meaningless.
// Fired on the signature, before body dataflow.
for (ty, mode) in param_tys.iter().zip(param_modes.iter()) {
if matches!(mode, ParamMode::Borrow) {
if let Type::Con { name, args } = ty {
if args.is_empty() && ailang_core::primitives::is_value_type(name) {
return Err(CheckError::BorrowOverValueType {
def: f.name.clone(),
ty: name.clone(),
});
}
}
}
}
// ret slot: a borrow ret is already rejected above, so a
// borrow-over-value ret cannot reach here.
```
- [ ] **Step 7: Run the test to verify it passes**
Run: `cargo build -p ail && cargo test -p ail --test mode_signature_rejects`
Expected: PASS (both tests).
- [ ] **Step 8: Full check-suite gate**
Run: `cargo build -p ail && python3 bench/check.py && python3 bench/compile_check.py`
Expected: 0 regressions. (Confirms no corpus fixture borrows a value
type today — the Step 1 grep already proved this.)
--- ---
@@ -571,6 +500,13 @@ modes (consumed ⇒ own, read-only-heap ⇒ borrow, value ⇒ own).
### Phase B — delete the variant (compiler-driven site set) ### Phase B — delete the variant (compiler-driven site set)
> Phase B order: Steps 48 (schema + compiler-driven migration + parser
> + printer + drift tag) first, THEN Steps B1B2 (mono-coercion +
> borrow-over-value), because B1 relies on Step 4's length invariant
> (`param_modes.len() == params.len()`, the elision gone) and B2's check
> must not fire until B1 has coerced the generated value-type-borrow
> instances. Final Phase-B gate is at B2.
- [ ] **Step 4: Edit `ParamMode` and `Type::Fn` serde in `ast.rs`** - [ ] **Step 4: Edit `ParamMode` and `Type::Fn` serde in `ast.rs`**
In `crates/ailang-core/src/ast.rs`: In `crates/ailang-core/src/ast.rs`:
@@ -727,6 +663,151 @@ post-cutover).
`"implicit"` exemplar row and the `match` arm that asserts it `"implicit"` exemplar row and the `match` arm that asserts it
serialises and is doc-anchored. serialises and is doc-anchored.
- [ ] **Step B1: Mono-coercion — `(borrow value-type) → (own value-type)`**
The prelude's polymorphic comparison builtins are `(params (borrow a) …)`
(`eq`/`compare`/`lt`/…); specialising `a` to a value type would emit
`compare__Int(borrow Int, …)`, which Step B2's check forbids. Coerce at
the mono specialisation site so the language's generated code honours
"value types always own". In `crates/ailang-codegen/src/subst.rs`,
`apply_subst_to_type`'s `Type::Fn` arm (`:165`), recompute the modes
after substituting the slot types:
```rust
Type::Fn { params, ret, effects, param_modes, ret_mode } => {
let new_params: Vec<Type> = params.iter().map(|p| apply_subst_to_type(p, subst)).collect();
let new_ret = apply_subst_to_type(ret, subst);
// spec 0062: a polymorphic (borrow a) specialised onto a
// value type becomes (own value-type) — borrow-over-value is
// forbidden and is a no-op for unboxed types (no RC).
let coerce = |ty: &Type, m: &ParamMode| -> ParamMode {
if matches!(m, ParamMode::Borrow) {
if let Type::Con { name, args } = ty {
if args.is_empty() && ailang_core::primitives::is_value_type(name) {
return ParamMode::Own;
}
}
}
*m
};
let new_param_modes: Vec<ParamMode> = new_params.iter().zip(param_modes.iter())
.map(|(t, m)| coerce(t, m)).collect();
let new_ret_mode = coerce(&new_ret, ret_mode);
Type::Fn {
params: new_params,
ret: Box::new(new_ret),
effects: effects.clone(),
param_modes: new_param_modes,
ret_mode: new_ret_mode,
}
}
```
> Runs after Step 4, so the length invariant `param_modes.len() ==
> params.len()` is in force (the elision is gone) and the `.zip` is
> total. `ParamMode` is already in scope in `subst.rs` (the arm binds
> `param_modes`/`ret_mode`).
- [ ] **Step B2: borrow-over-value reject (corpus re-mode + check + fixture + test)**
First re-mode any authored `(borrow value-type)` left in the corpus
(the migration tool left explicit `Borrow` untouched in Phase A, so e.g.
`examples/mq3_class_eq_vs_fn_eq_fnmod.ail`'s `(params (borrow (con Int))
(borrow (con Int)))` is still present):
Run: `git grep -lE '\(borrow \(con (Int|Bool|Float|Unit)\)' examples crates/ailang-kernel`
For each hit, replace `(borrow (con <Value>))` with `(own (con <Value>))`.
Re-run the grep until empty.
Add the `CheckError` variant to `pub enum CheckError`
(`crates/ailang-check/src/lib.rs`):
```rust
/// A fn-type slot is `(borrow V)` where `V` is an unboxed value
/// type (`Int`/`Bool`/`Float`/`Unit`). Borrow is meaningless over
/// a value type — it has no refcount and is copied by value
/// (model §3.2). Code: `borrow-over-value`.
#[error("borrow over value type in `{def}`: `{ty}` is an unboxed value type and cannot be borrowed; use `(own {ty})`")]
BorrowOverValueType { def: String, ty: String },
```
Register the code in `fn code(&self)` before `CheckError::Internal(_)`:
```rust
CheckError::BorrowOverValueType { .. } => "borrow-over-value",
```
In `check_def`, first widen Task 1's destructure to also bind
`param_modes` (it was left under `..` in Task 1 Step 6):
```rust
let (param_tys, param_modes, ret_ty, ret_mode, declared_effs) = match &inner_ty {
Type::Fn { params, param_modes, ret, ret_mode, effects } => (
params.clone(), param_modes.clone(), (**ret).clone(), *ret_mode, effects.clone(),
),
other => {
return Err(CheckError::FnTypeRequired(
f.name.clone(),
ailang_core::pretty::type_to_string(other),
));
}
};
```
Then, after the borrow-return check, add the borrow-over-value loop:
```rust
// spec 0062: borrow over an unboxed value type is meaningless.
// Fired on the signature, before body dataflow. The mono-coercion
// (subst.rs, Step B1) guarantees no generated value-type-borrow
// instance reaches here.
for (ty, mode) in param_tys.iter().zip(param_modes.iter()) {
if matches!(mode, ParamMode::Borrow) {
if let Type::Con { name, args } = ty {
if args.is_empty() && ailang_core::primitives::is_value_type(name) {
return Err(CheckError::BorrowOverValueType {
def: f.name.clone(),
ty: name.clone(),
});
}
}
}
}
```
Create `examples/borrow_value_reject.ail`:
```ail
(module borrow_value_reject
(fn ignore
(doc "MUST FAIL post-cutover: borrow over a value type is meaningless.")
(type
(fn-type
(params (borrow (con Int)))
(ret (own (con Int)))))
(params n)
(body 0)))
```
Add to `crates/ail/tests/mode_signature_rejects.rs` (the
`check_rejects_with` helper is created in Task 1 Step 2):
```rust
/// `(borrow value-type)` is rejected with code `borrow-over-value`.
#[test]
fn borrow_over_value_is_rejected() {
check_rejects_with("borrow_value_reject.ail", "borrow-over-value");
}
```
Gate: `cargo test -p ailang-codegen --lib compare` — the
`compare_{int,bool}_mono_symbol_emits_branch_ladder` tests PASS (the
mono-coercion makes their value-type params `own`; branch-ladder IR is
unchanged — value types emit no RC ops). And `cargo test -p ail --test
mode_signature_rejects` PASS (all three: borrow-return, borrow-over-value,
provenance pins).
### Phase C — pins, fixtures, contracts ### Phase C — pins, fixtures, contracts
- [ ] **Step 9: Reset all five hash pins** - [ ] **Step 9: Reset all five hash pins**
@@ -960,11 +1041,15 @@ fixture, and is exercised by `cargo test -p ail --test migrate_modes`.
## Commit shape (orchestrator note, not an implement step) ## Commit shape (orchestrator note, not an implement step)
Tasks 1, 2, and 3 each leave a green, independent tree and may be Task 1 (borrow-return reject) and Task 3 (throwaway migration tool)
committed separately before the cutover (`feat(check): borrow-return each leave a green, independent tree and may be committed separately
reject`, `feat(check): borrow-over-value reject`, `chore: throwaway before the cutover (`feat(check): borrow-return reject`, `chore:
mode-migration tool`). Task 4 is the single irreversible cutover commit throwaway mode-migration tool`). The borrow-over-value reject is NOT a
(`feat: delete ParamMode::Implicit — binary ownership modes (#55)`) standalone pre-cutover task — it is folded into Task 4 (Steps B1B2)
carrying the one-time hash reset. main only ever advances on a green because it is entangled with the mono pass (see the Task 2 finding
tree; nothing half-migrated is committed. note). Task 4 is the single irreversible cutover commit (`feat: delete
ParamMode::Implicit — binary ownership modes (#55)`) carrying the
one-time hash reset and the borrow-over-value reject + mono-coercion.
main only ever advances on a green tree; nothing half-migrated is
committed.
``` ```