diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 9ed5419..ec6c60c 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -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::Own(Box)`). 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, + param_modes: Vec, // same length as params + ret: Box, + ret_mode: ParamMode, + effects: Vec, +} + +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.