Files
AILang/docs/plans/form-a-scalar-param-mode-carveout.md
T

19 KiB
Raw Blame History

form_a.md scalar-parameter mode carve-out — Implementation Plan

Parent spec: docs/specs/2026-05-18-fieldtest-embedding-abi-m1.md (findings [friction] "The published authoring rule steers an LLM away from the only spelling that works for scalar export params" + [spec_gap]#1 "form_a.md mandates a mode on every fn param; scalar params accept (and require) no mode"). Orchestrator design decision recorded in docs/roadmap.md P1 entry "[todo] form_a.md scalar-parameter mode carve-out". This is a docs-honesty tidy in the docs-honesty-lint class, NOT a milestone — there is no docs/specs/<milestone>.md; the fieldtest spec + roadmap entry are the design carrier.

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Reconcile crates/ailang-core/specs/form_a.md and docs/DESIGN.md §"Embedding ABI (M1)" with the actual checker behaviour — scalar (Int/Bool/Unit/Str) (fn …) parameters take no mode and are written bare, mirroring the existing return-type carve-out — and pin the corrected state against regrowth.

Architecture: Pure documentation + a regression pin. Zero language / checker / codegen change: the iteration only edits two docs and adds one #[test]. The checker behaviour being documented is already shipped and unchanged (recon Section D: the linearity pass in crates/ailang-check/src/linearity.rs:324-329 skips entirely unless all params carry an explicit mode; a mode on a scalar makes the checker hold the primitive to linear discipline, so an ordinary reuse trips use-after-consume / consume-while-borrowed pointed at the body — crates/ailang-check/src/linearity.rs:757 / :1074). The fix is RED-first: the pin is written first asserting the post-edit state, fails (new carve-out absent + old unconditional phrasing still present), then the doc edits turn it green.

Tech Stack: crates/ailang-core/specs/form_a.md, docs/DESIGN.md, crates/ailang-core/tests/docs_honesty_pin.rs (uses ailang_core::FORM_A_SPEC — the include_str!'d constant at crates/ailang-core/src/lib.rs:120, the single canonical in-code handle for that spec, also used by tests/spec_drift.rs; the DESIGN.md half reuses the file's existing read("docs/DESIGN.md") helper). spec_drift.rs is not a lockstep partner — recon confirmed all its FORM_A_SPEC anchors are short keyword tokens ((own / (borrow survive because the carve-out adds, never removes, the mode wrappers); no spec_drift.rs assertion pins any altered text.

Scope: four form_a.md sites + one DESIGN.md insertion + one pin. Recon found the unconditional rule restated in four places inside form_a.md; all four are in scope because a docs-honesty tidy whose thesis is "the doc mirrors only the true current state, consistently" cannot fix some and leave others contradicting them (that ships the exact internal inconsistency the pin class forbids). Site 5 (form_a.md:521-524, a few-shot annotation) already states the correct rule using the canonical "heap-shaped / primitive value type" vocabulary — it is left verbatim (no churn), and the new carve-out text deliberately reuses its framing so the document is internally consistent end to end.


Files this plan creates or modifies

  • Modify: crates/ailang-core/specs/form_a.md:96-98 — site 1, the ### Function prose mode sentence.
  • Modify: crates/ailang-core/specs/form_a.md:230 — site 2, the grammar-block implicit mode comment.
  • Modify: crates/ailang-core/specs/form_a.md:347-352 — site 3, "Schema invariants" item 1.
  • Modify: crates/ailang-core/specs/form_a.md:381-383 — site 4, the "Forgetting mode annotations" Pitfalls bullet.
  • Modify: docs/DESIGN.md:2285 — insert the canonical bare-scalar export-param Form-A example after the §"Embedding ABI (M1)" scalar-constraint paragraph, before ## Data model (2287).
  • Test: crates/ailang-core/tests/docs_honesty_pin.rs:12 / :26 — add use ailang_core::FORM_A_SPEC; and a new #[test] fn form_a_scalar_param_carveout_present_and_old_rule_absent.

Files that do NOT change (verified by recon, recorded so the implementer does not "tidy" them): crates/ailang-core/specs/form_a.md:521-524 (already-correct few-shot annotation, left verbatim); crates/ailang-core/tests/spec_drift.rs (not a lockstep partner); crates/ailang-core/src/lib.rs (only defines the const); any crates/ailang-check/** source (zero behaviour change).


Task 1: RED — write the regression pin asserting the post-edit state

Files:

  • Test: crates/ailang-core/tests/docs_honesty_pin.rs

  • Step 1: Add the FORM_A_SPEC import

In crates/ailang-core/tests/docs_honesty_pin.rs, the current line 12 is:

use std::fs;

Replace it with:

use ailang_core::FORM_A_SPEC;
use std::fs;

(FORM_A_SPEC is pub const FORM_A_SPEC: &str at crates/ailang-core/src/lib.rs:120; this integration test is in the ailang-core crate's tests/ dir and may use the crate's public API, exactly as tests/spec_drift.rs:17 does.)

  • Step 2: Append the new pin test

Append this #[test] fn to the END of crates/ailang-core/tests/docs_honesty_pin.rs (after the closing } of prose_roundtrip_md_has_no_wunschdenken, which is the last item, currently line 100):


#[test]
fn form_a_scalar_param_carveout_present_and_old_rule_absent() {
    // form_a.md is read via the canonical include_str! const, not a
    // re-derived path: single source of truth, compile-checked, no
    // stale-path bug. norm() collapses the docs' hard line-wrap so
    // every asserted substring below is the single-spaced form
    // (planner Step-5 item 6: line-wrap is structurally discharged,
    // the substrings need not be contiguous in the .md source).
    let f = norm(FORM_A_SPEC);
    let d = norm(&read("docs/DESIGN.md"));

    // --- ABSENT: the four old unconditional phrasings must be gone ---
    assert!(!f.contains("Mode annotations on every `(fn ...)` parameter."),
        "form_a.md item 1 must no longer head with the unconditional 'every (fn ...) parameter' rule (site 3)");
    assert!(!f.contains("All parameters of a `(fn ...)` MUST carry a mode annotation"),
        "form_a.md ### Function prose must no longer state the unconditional all-params rule (site 1)");
    assert!(!f.contains("(DO NOT USE in new (fn ...) defs)"),
        "form_a.md grammar block must no longer blanket-forbid the bare (implicit-mode) form (site 2)");
    assert!(!f.contains("Wrap every `(fn ...)` parameter in `(own ...)` or `(borrow ...)`."),
        "form_a.md Pitfalls bullet must no longer state the unconditional 'wrap every parameter' imperative (site 4)");

    // --- PRESENT: the scalar-parameter carve-out, the four sites ---
    assert!(f.contains("A scalar (non-heap-shaped) parameter or return takes **no** mode: write it bare, e.g. `(con Int)`."),
        "form_a.md item 1 must state the scalar-parameter carve-out symmetrically with the return-type carve-out (site 3)");
    assert!(f.contains("a scalar (non-heap-shaped: `(con Int)`, `(con Bool)`, `(con Unit)`, `(con Str)`) takes **no** mode and is written bare"),
        "form_a.md ### Function prose must carry the scalar carve-out (site 1)");
    assert!(f.contains("; no mode — REQUIRED for scalars (Int/Bool/Unit/Str), rejected for heap-shaped"),
        "form_a.md grammar block comment must state the bare form is required for scalars (site 2)");
    assert!(f.contains("Putting a mode on a scalar parameter."),
        "form_a.md Pitfalls must carry the inverse pitfall — over-wrapping a scalar (site 4)");

    // --- PRESENT: the DESIGN.md §\"Embedding ABI (M1)\" mirror ---
    assert!(d.contains("Export parameters are written **bare**: a scalar type carries no `own`/`borrow` mode"),
        "DESIGN.md §Embedding ABI (M1) must mirror the canonical bare-scalar export-param rule");
}
  • Step 3: Run the pin — it MUST FAIL (this is the RED)

Run: cargo test -p ailang-core --test docs_honesty_pin form_a_scalar_param_carveout_present_and_old_rule_absent

Expected: FAIL. The first failing assertion is the first ABSENT check — form_a.md item 1 must no longer head with the unconditional 'every (fn ...) parameter' rule (site 3) — because the old phrasing Mode annotations on every \(fn ...)` parameter.is still present inform_a.md` (the doc edits are Tasks 23). This proves the pin discriminates the pre-edit state.

  • Step 4: Confirm the rest of the file's pins are unaffected

Run: cargo test -p ailang-core --test docs_honesty_pin

Expected: the four pre-existing tests (design_md_has_no_wunschdenken, design_md_has_no_doc_archaeology, design_md_present_tense_anchors_present, prose_roundtrip_md_has_no_wunschdenken) PASS; only form_a_scalar_param_carveout_present_and_old_rule_absent FAILS. ("test result: FAILED. 4 passed; 1 failed".)


Task 2: form_a.md — the four carve-out sites

Files:

  • Modify: crates/ailang-core/specs/form_a.md

Each step Reads nothing extra — the verbatim current text is given as old and the replacement as new. The implementer applies each as an exact string replacement.

  • Step 1: Site 1 — ### Function prose (lines 96-98)

Replace, verbatim:

old:

`type` is a `(fn-type ...)` (possibly wrapped in `(forall ...)` for
polymorphic defs). All parameters of a `(fn ...)` MUST carry a mode
annotation — see *Modes* below.

new:

`type` is a `(fn-type ...)` (possibly wrapped in `(forall ...)` for
polymorphic defs). A *heap-shaped* parameter or return of a
`(fn ...)` MUST carry a mode annotation; a scalar (non-heap-shaped:
`(con Int)`, `(con Bool)`, `(con Unit)`, `(con Str)`) takes **no**
mode and is written bare — see *Modes* below.
  • Step 2: Site 2 — grammar-block comment (line 230)

Replace, verbatim (single line):

old:

TYPE                ; implicit mode (DO NOT USE in new (fn ...) defs)

new:

TYPE                ; no mode — REQUIRED for scalars (Int/Bool/Unit/Str), rejected for heap-shaped
  • Step 3: Site 3 — "Schema invariants" item 1 (lines 347-352)

Replace, verbatim:

old:

1. **Mode annotations on every `(fn ...)` parameter.** Every type in
   the `(params ...)` clause of a `(fn ...)` definition's
   `(fn-type ...)` MUST be wrapped in `(own T)` or `(borrow T)`. The
   return type MUST also carry a mode whenever the type is heap-shaped
   (i.e. anything other than `(con Int)`, `(con Bool)`, `(con Unit)`,
   `(con Str)`). Implicit mode on a `(fn ...)` def is rejected.

new:

1. **Mode annotations on every heap-shaped `(fn ...)` parameter.** A
   parameter type in the `(params ...)` clause of a `(fn ...)`
   definition's `(fn-type ...)`, and the return type, MUST be wrapped
   in `(own T)` or `(borrow T)` whenever the type is heap-shaped
   (i.e. anything other than `(con Int)`, `(con Bool)`, `(con Unit)`,
   `(con Str)`). A scalar (non-heap-shaped) parameter or return takes
   **no** mode: write it bare, e.g. `(con Int)`. Scalars are
   primitive value types, not linear resources; putting a mode on a
   scalar makes the checker hold the primitive to linear discipline
   (single use under `own`, no use while a `borrow` is live), so an
   ordinary scalar reused in the body (`sample * sample`) trips a
   body-pointing `use-after-consume` / `consume-while-borrowed`
   rather than a mode error. Implicit (omitted) mode on a
   *heap-shaped* `(fn ...)` parameter or return is rejected.
  • Step 4: Site 4 — Pitfalls bullet (lines 381-383)

Replace, verbatim:

old:

- **Forgetting mode annotations.** `(fn-type (params (con List)) ...)`
  is accepted by the parser but rejected by the checker. Wrap every
  `(fn ...)` parameter in `(own ...)` or `(borrow ...)`.

new:

- **Forgetting mode annotations on a heap-shaped parameter.**
  `(fn-type (params (con List)) ...)` is accepted by the parser but
  rejected by the checker. Wrap every *heap-shaped* `(fn ...)`
  parameter in `(own ...)` or `(borrow ...)`.
- **Putting a mode on a scalar parameter.** The inverse trap:
  `(fn-type (params (own (con Int))) ...)` is wrong — scalars
  (`Int`/`Bool`/`Unit`/`Str`) take no mode, write them bare
  `(con Int)`. A mode on a scalar makes the checker treat the
  primitive as a linear resource, so an ordinary reuse trips a
  body-pointing `use-after-consume` / `consume-while-borrowed`
  instead of pointing at the signature.
  • Step 5: Run the pin — form_a.md half now GREEN, DESIGN half still RED

Run: cargo test -p ailang-core --test docs_honesty_pin form_a_scalar_param_carveout_present_and_old_rule_absent

Expected: still FAIL, but now the only failing assertion is the last one — DESIGN.md §Embedding ABI (M1) must mirror the canonical bare-scalar export-param rule (the four form_a.md ABSENT + four form_a.md PRESENT assertions now all pass; DESIGN.md is edited in Task 3). If any form_a.md assertion still fails, an exact-string replacement above did not match — re-read the named line range and fix the replacement, do not weaken the assertion.

  • Step 6: Confirm spec_drift.rs is unaffected (it is not a lockstep partner)

Run: cargo test -p ailang-core --test spec_drift

Expected: PASS, all 8 tests (spec_mentions_every_term_variant, spec_mentions_every_pattern_variant, spec_mentions_every_type_variant, spec_mentions_every_literal_variant, spec_mentions_every_def_kind, spec_mentions_mode_keywords, spec_mentions_tail_variants, spec_mentions_suppress_clause). spec_mentions_mode_keywords pins only the bare (own / (borrow tokens, which the carve-out keeps; this run confirms the recon finding that no spec_drift.rs assertion couples to the altered text.


Task 3: DESIGN.md §"Embedding ABI (M1)" mirror

Files:

  • Modify: docs/DESIGN.md:2285

  • Step 1: Insert the canonical bare-scalar export-param example

In docs/DESIGN.md, the §"Embedding ABI (M1)" scalar-constraint paragraph ends at line 2285 (The value/record layout freeze is M3.), followed by a blank line (2286) and ## Data model (2287).

Replace, verbatim:

old:

change this C signature; do not treat the M1 signature as frozen.
The value/record layout freeze is M3.

## Data model

new:

change this C signature; do not treat the M1 signature as frozen.
The value/record layout freeze is M3.

Export parameters are written **bare**: a scalar type carries no
`own`/`borrow` mode (modes apply only to heap-shaped types, which the
scalar-only rule above forbids at an export boundary anyway). The
canonical M1 export shape:

(fn step (export "backtest_step") (type (fn-type (params (con Int) (con Int)) (ret (con Int)))) (params state sample) (body (app + state (app * sample sample))))


## Data model

(This Form-A snippet is the step fn of the public examples/embed_backtest_step.ail corpus fixture — recon confirmed lines 3-11 — so the example is corpus-grounded, not minted.)

  • Step 2: Run the pin — now fully GREEN

Run: cargo test -p ailang-core --test docs_honesty_pin form_a_scalar_param_carveout_present_and_old_rule_absent

Expected: PASS. All ABSENT + PRESENT assertions hold.

  • Step 3: Full docs_honesty_pin suite GREEN

Run: cargo test -p ailang-core --test docs_honesty_pin

Expected: PASS, "test result: ok. 5 passed; 0 failed" (the four pre-existing tests + the new carve-out pin).


Task 4: Regression gate — zero behavioural drift

Files: (none — verification only)

  • Step 1: Full ailang-core test suite

Run: cargo test -p ailang-core

Expected: PASS, no test regressed. In particular spec_drift (8) and docs_honesty_pin (5) green; all pre-existing ailang-core tests green. Record the total ("N passed; 0 failed").

  • Step 2: Workspace-wide no-behavioural-drift assertion

This iteration changes only two docs and adds one test — no language / checker / codegen byte changes. The full workspace suite must therefore not lose any passing test.

Run: cargo test --workspace 2>&1 | grep -E "test result:" | awk '{p+=$4} END {print "TOTAL_PASSED="p}'

Expected: TOTAL_PASSED= a number ≥ the count on main HEAD before this iteration (the only delta is +1, the new form_a_scalar_param_carveout_present_and_old_rule_absent). If the total is lower than the pre-iteration baseline, a pre-existing test broke — STOP and investigate; a docs+pin iteration cannot regress a behavioural test, so any drop is a real problem, not an expected cost. (Unfiltered suite + explicit pass-count per planner Step-5 item 8 — "nothing ran" cannot masquerade as "nothing regressed".)

  • Step 3: Confirm zero source-code change

Run: git diff --stat -- crates/ailang-check crates/ailang-codegen crates/ailang-core/src crates/ailang-surface

Expected: empty output (no diff). The only modified paths are crates/ailang-core/specs/form_a.md, docs/DESIGN.md, and crates/ailang-core/tests/docs_honesty_pin.rs. Any source-code diff under those crate src/ trees is out of scope for this tidy and a defect — STOP.


Self-review (planner Step 5)

  1. Spec coverage: [friction] + [spec_gap]#1 both resolved — form_a.md carve-out (Task 2, four sites) + DESIGN.md mirror (Task 3) + anti-regression pin (Task 1). Roadmap entry's "symmetrically with the existing return-type carve-out" satisfied (item 1 new text scopes params to heap-shaped exactly as the pre-existing return-type clause does). ✔
  2. Placeholder scan: no "TBD/TODO/implement later/similar to/add appropriate". The literal string ; no mode — REQUIRED for scalars … and the test phrases are content, not placeholders. ✔
  3. Type/name consistency: test fn form_a_scalar_param_carveout_present_and_old_rule_absent named identically in Task 1 Step 2/3, Task 2 Step 5, Task 3 Step 2. FORM_A_SPEC, norm, read match the actual file. ✔
  4. Step granularity: every step is one exact replacement or one command, 2-5 min. ✔
  5. No commit steps: none. ✔
  6. Pin/replacement substring contiguity: the pin norm()s both docs and every asserted substring is written single-spaced; the norm() whitespace-collapse structurally discharges the line-wrap family (the docs-honesty-lint precedent's norm() is reused verbatim, not a fresh keep-on-one-line constraint). Every PRESENT substring appears single-spaced-contiguously inside the exact new replacement bodies in Tasks 23 (verified: e.g. "A scalar (non-heap-shaped) parameter or return takes no mode: write it bare, e.g. (con Int)." is one clause in the Task 2 Step 3 new block; line-wrap between "takes" and "no" is collapsed by norm()). Every ABSENT substring is verbatim contiguous in the corresponding old block and verbatim gone from the new block. ✔
  7. Compile-gate vs. deferred-caller ordering: N/A — no signature change, no crate-wide compile break; the only Rust change is one additive #[test] + one use. Task ordering is RED-pin → doc edits → green, the docs-honesty-lint pattern. ✔
  8. Verification-command filter strings resolve: --test docs_honesty_pin and --test spec_drift are real test-target files (recon-confirmed); the filter form_a_scalar_param_carveout_present_and_old_rule_absent is the exact name of the test added in Task 1 (not guessed from a feature word). Task 4 Step 2 uses the unfiltered workspace suite + an explicit TOTAL_PASSED count assertion so "0 ran" cannot read as "0 regressed". ✔