From 4c266a64b478c6a6106958d577faea5ac30dfa3e Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 15:45:28 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20form-a-scalar-param-mode-carveout=20?= =?UTF-8?q?=E2=80=94=20M1=20fieldtest=20friction+spec=5Fgap#1=20docs-hones?= =?UTF-8?q?ty=20tidy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../form-a-scalar-param-mode-carveout.md | 463 ++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 docs/plans/form-a-scalar-param-mode-carveout.md diff --git a/docs/plans/form-a-scalar-param-mode-carveout.md b/docs/plans/form-a-scalar-param-mode-carveout.md new file mode 100644 index 0000000..46982d5 --- /dev/null +++ b/docs/plans/form-a-scalar-param-mode-carveout.md @@ -0,0 +1,463 @@ +# 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/.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: + +```rust +use std::fs; +``` + +Replace it with: + +```rust +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): + +```rust + +#[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 +in `form_a.md` (the doc edits are Tasks 2–3). 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 2–3 (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". ✔