JOURNAL: Iter 18a — borrow/own annotations on fn signatures

Records the schema-choice rationale (per-position metadata on
Type::Fn vs new Type variants — the latter would be a 250-site
mechanical migration, the former is one iter), the
padding-on-read trick that kept construction sites unchanged,
and the forward-compat note on the new fixture's borrow+own
sharing pattern.
This commit is contained in:
2026-05-08 02:08:27 +02:00
parent b9ca8894a5
commit 1e3697926b
+227
View File
@@ -5610,3 +5610,230 @@ compiler).
`(reuse-as ...)`.
- **Iter 18e** (queued): `(drop-iterative)` + worklist free.
- **Iter 18f** (queued): RC bench + Boehm retirement decision.
## Iter 18a — `(borrow T)` / `(own T)` mode annotations on fn signatures
First concrete step of the RC migration plan from Decision 10.
Adds the form-A surface
```
(fn-type (params (borrow (List Int))) (ret (con Int)))
(fn-type (params (own (List Int))) (ret (own (List Int))))
```
as a *language feature*: schema, parser, JSON, printer,
typechecker passthrough. Deliberately **not** included: codegen
change, linearity enforcement, mode-compatibility unification.
### Schema choice
Decision 10 sketched modes as new `Type` variants
(`Type::Borrow(Box<Type>)` / `Type::Own(Box<Type>)`). I rejected
that during the iter brief because adding new `Type` variants
requires touching every match-arm on `Type` in the entire
codebase — the typechecker alone has ~190 such arms, the
codegen/desugar/printer add another ~50. A new variant means a
~250-site migration, all of which would be "look through and
ignore", i.e. mechanical noise that degrades review signal.
The chosen representation is per-position metadata on `Type::Fn`:
```rust
Type::Fn {
params: Vec<Type>,
param_modes: Vec<ParamMode>, // same length as params
ret: Box<Type>,
ret_mode: ParamMode,
effects: Vec<String>,
}
enum ParamMode { Implicit, Own, Borrow }
```
`Implicit` is the legacy state — semantically equivalent to
`Own` but printed bare (`(con T)`, no wrapper). `Own` and
`Borrow` are explicitly annotated. Type itself is unchanged, so
match-arm churn is zero.
JSON canonical hash invariant: `param_modes` is skipped when
every entry is `Implicit`, `ret_mode` is skipped when
`Implicit`. Existing fixtures (sum, list, hof, closure,
list_map, std_*, etc.) emit byte-identical JSON. The hash
regression test in `crates/ailang-core/src/hash.rs` passes
unchanged. `git diff examples/` is empty after the patch.
Decision 10's "Schema additions" subsection in `docs/DESIGN.md`
was rewritten to match this choice before the implementer ran;
it now describes the per-position layout, not the `Type` variant
sketch.
### One subtle move: padding-on-read
Construction sites in the typechecker / desugar / codegen
elide `param_modes` when none are non-`Implicit`, storing
`vec![]` even when `params.len() == n`. The `PartialEq` impl on
`Type::Fn` therefore pads the shorter slice with `Implicit`
before comparing — a `vec![]` and a `vec![Implicit; n]` compare
equal.
This is the design lever that kept the patch from being a
~100-site mechanical migration. Every Fn-construction site that
already existed pre-18a stays a struct-literal with two new
elided fields (`param_modes: vec![], ret_mode: ParamMode::Implicit`)
rather than needing a per-call `vec![Implicit; n]`. New sites
that opt into mode info (the parser, eventually the inference
pass) write the mode vector explicitly.
A `Type::fn_implicit(params, ret, effects)` constructor helper
exists for sites that want the explicit form. Currently unused
in 18a; will be the canonical constructor in 18c when
construction sites grow inference-derived modes.
The "elide-and-pad" trick has one cost: 18c will need to decide
whether to keep the slack or normalise on construction. Keeping
the slack means consumers always need to be tolerant of partial
mode vectors. Normalising means every construction site has to
think about modes. Defer the call to 18c.
### Parser
`parse_param_with_mode` is the new helper. It peeks for a
leading `(borrow ...)` or `(own ...)` head and consumes the
wrapper, returning the inner `Type` plus the explicit `ParamMode`.
Otherwise it falls through to the regular `parse_type` and
returns the type with `ParamMode::Implicit`. `parse_fn_type`
calls it once per parameter slot and once for the return slot.
`parse_type` (the top-level type parser) explicitly **rejects**
`borrow` / `own` as a type head outside fn-signature positions
with a clear ParseError. Two new unit tests in `parse.rs` cover
the rejection. Reasoning: modes outside fn signatures are
meaningless, and an LLM author who writes `(borrow Int)` as a
let-binding type expects an error — better to error at parse
time than typecheck time.
### Printer
`write_fn_type_slot` is the new helper in `print.rs`. It looks
up the mode for the param/ret position; on `Implicit` it prints
the inner type bare (preserving pre-18a output for every
existing fixture); on `Own` / `Borrow` it wraps with the
matching keyword.
Round-trip verified by the existing
`ailang-surface/tests/round_trip.rs` (parses every `.ailx`,
prints, re-parses, demands canonical-byte equality). All
pre-existing fixtures pass unchanged. The new
`borrow_own_demo.ailx` round-trips identically.
### Typechecker
Modes are transparent for unification in 18a. The typechecker
matches `Type::Fn { params, ret, .. }` everywhere it already
did; the new fields are ignored under the `..` wildcard. No
change to substitution, instantiation, generalisation, occurs,
or apply.
Mode-compatibility checking is deliberately deferred to 18c.
When two `Type::Fn`s unify, a future check will demand
`ParamMode::Borrow ≡ ParamMode::Borrow` (and `Implicit / Own`
are interchangeable). For 18a, the check is absent, so a
`(borrow T)` parameter can be passed where an `(own T)` was
expected. This is unsound under the future enforcement but
harmless under 18a's "all modes are Implicit-equivalent"
runtime contract.
### Fixture
`examples/borrow_own_demo.{ailx,ail.json}` exercises both
modes:
```
(fn list_length
(type (fn-type (params (borrow (con List))) (ret (con Int))))
...)
(fn sum_list
(type (fn-type (params (own (con List))) (ret (con Int))))
...)
```
`main` builds `[1,2,3]`, prints `list_length(xs)` then
`sum_list(xs)`. Stdout is `3` then `6` (one per line via
`io/print_int`).
The fixture exercises sharing — `xs` is used twice, once for
`list_length` (borrow, doesn't consume), once for `sum_list`
(own, consumes). Under 18c's linearity enforcement, this is
exactly the canonical "list_length doesn't take ownership so
sum_list still owns xs" pattern. Under 18a's no-enforcement
runtime, it just runs.
The corresponding E2E test in `crates/ail/tests/e2e.rs` asserts
the stdout AND inspects the canonical JSON for the literal
strings `"param_modes":["borrow"]` and `"param_modes":["own"]`,
so a future serialisation regression flips the test red, not
just the runtime.
### Build / test status
`cargo build --workspace`: clean. `cargo test --workspace`: 154
tests pass (+1 vs the 153 baseline at `pre-rc`), 0 failures, 3
ignored doc-tests. Hash regression test in
`crates/ailang-core/src/hash.rs` passes — pre-18a fixtures all
produce identical canonical JSON, hashes unchanged.
Touched files (15):
- `crates/ailang-core/src/ast.rs``ParamMode` enum, `Type::Fn`
fields, `PartialEq` with `mode_eq` + `mode_slices_eq`,
`fn_implicit` helper.
- `crates/ailang-core/src/{desugar,pretty,hash}.rs`,
`crates/ailang-check/src/{lib,lift,builtins}.rs`,
`crates/ailang-check/tests/workspace.rs`,
`crates/ailang-codegen/src/lib.rs` — mechanical updates: `..`
on patterns, elided-default fields on struct literals.
- `crates/ailang-surface/src/parse.rs``parse_param_with_mode`,
fn-type integration, top-level rejection, two unit tests.
- `crates/ailang-surface/src/print.rs``write_fn_type_slot`.
- `crates/ail/tests/e2e.rs``borrow_own_demo_modes_are_metadata_only`.
- `examples/borrow_own_demo.{ailx,ail.json}` — new fixture.
- `docs/DESIGN.md` — Decision 10 schema-additions clarification +
`--memory=rc``--alloc=rc` flag rename for consistency with
existing CLI.
### Did anything surprise
- **The 250-site match-arm wall.** I had not internalised how
many sites destructure `Type` until I grepped for them. The
per-position-metadata representation chose itself once that
number became visible. The DESIGN.md sketch ("Type::Borrow as
variant") would have been a multi-iter implementation; the
metadata representation is one iter.
- **The padding-on-read trick.** It feels hacky, and it is.
The honest alternative is normalisation on construction —
every Fn-builder site explicitly writes `vec![Implicit; n]`.
18c can pay that cost when it has reason to, e.g. when
introducing a mode-compatibility check that wants to see
full-length vectors. For 18a the slack carries no cost.
- **The fixture's "use xs twice" pattern.** The 18a fixture
*already* shows what 18c will need to enforce: a borrow call
followed by an own call on the same value. Under 18a this
works because nothing checks. Under 18c it should still work
because list_length's borrow declaration says "no consume".
The fixture is therefore a forward compatibility test for
the linearity-enforcement-with-borrow-correctly-spelled
story. If 18c lands and this fixture breaks, 18c got the
semantics wrong, not the fixture.
### Next
Iter 18b: RC runtime (`runtime/rc.c` already pre-staged in
working tree — `ailang_rc_alloc` / `inc` / `dec` with 8-byte
header layout) + codegen `--alloc=rc` routing. No inc/dec
emitted yet; programs leak intentionally. The point is to
validate that compiled programs run correctly under the new
allocator before 18c adds the inc/dec instrumentation that gives
the runtime contract its teeth.