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:
+227
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user