Files
AILang/docs/specs/0065-fieldtest-eliminate-implicit-mode.md
Brummel e25580e3a3 test(cutover): live accept/reject corpus for the #55 ownership surface
Wires the #55-cutover fieldtest into a durable regression net. The
fieldtest exercised the post-cutover ParamMode={Own,Borrow} surface as a
downstream LLM author would and produced 12 fixtures + a report; left as
loose files they would be dead fixtures that drift. This commits them as
a protected property.

crates/ail/tests/cut55_cutover_surface.rs — one table-driven test running
each fixture through `ail check` and asserting accept (exit 0) or reject
at the right pipeline stage. Reject rows key on the bracketed diagnostic
CODE (consume-while-borrowed, borrow-over-value, borrow-return-not-permitted,
surface-parse-error), NOT message text, so the test is decoupled from the
diagnostic-wording improvement tracked separately.

examples/fieldtest/cut55_*.ail — the corpus. cut55_2c is the #58 repro
(now correctly rejected). cut55_2b was misanalysed by the fieldtest as a
consume-while-borrowed reject; in fact bump's recursive param is
(borrow List) so the tail is only borrowed, never consumed into an own
slot — `ail check` correctly accepts it. Renamed to
cut55_2b_borrow_traversal_clean and recast as the #58 false-positive
guard (a borrow-position use of a heap sub-binder must stay accepted).
cut55_1b similarly does not fire over-strict-mode (its recursive call
consumes the tail, so the lint's consume_count==0 premise fails) — comment
corrected; it is a plain accept. cut55_1c is the genuine over-strict-mode
example (accepts exit 0, emits the warning).

docs/specs/0065-eliminate-implicit-mode fieldtest report — moved 2c to
the reject set, added the 2b false-positive-guard section, marked the
double-free spec_gap RESOLVED (it shipped as the #58 fix).

Relates to #55.
2026-06-02 00:45:21 +02:00

11 KiB

Fieldtest — #55 eliminate-Implicit-mode cutover — 2026-06-02

Status: Draft — awaiting orchestrator triage Author: fieldtester (dispatched by fieldtest skill)

Scope

The #55 cutover (commit 76b21c0, spec 0062, plan 0121) deleted ParamMode::Implicit. Every fn-type slot on every signature now carries an explicit (own T) or (borrow T); there is no default. The parser rejects a bare fn-type slot; borrow-over-value rejects (borrow V) on a value type (Int/Bool/Float/Unit); borrow-return rejects a borrowed return at the signature; consuming a borrowed value is rejected (consume-while-borrowed); and the over-strict-mode advisory lint suggests relaxing an (own T) heap param that the body never consumes to (borrow T). This fieldtest exercises those decision points as a downstream LLM author who has only the design ledger, the CLI, and the examples corpus. Build exercised: cargo run -q -p ail against HEAD (workspace built clean before the run).

Examples

examples/fieldtest/cut55_1_readonly_length.ail — read-only list helper (decision a)

  • A length that walks a list and counts, never moving a heap field out.
  • Fits scope: the canonical "should I reach for borrow?" decision. From the ledger ("borrow only to read/pass-through a lent heap value") I wrote (borrow (con List)) without hesitation.
  • Outcome: check ok (exit 0); run prints 3. Correct first try.

examples/fieldtest/cut55_1b_overstrict_own.ail — over-strict own, recursive (decision a, wrong mode)

  • length annotated (own (con List)) while only reading.
  • Fits scope: probes the over-strict-mode lint.
  • Outcome: check ok, NO warning. Correct: the recursive call passes the tail t (heap-typed binder) to an own param, so condition 2 of the lint (no heap binder consumed) legitimately fails — the lint stays silent. Confirms the lint is not naive.

examples/fieldtest/cut55_1c_overstrict_isempty.ail — over-strict own, true read-only (decision a, wrong mode)

  • is_empty inspects only the head ctor; no binder consumed; (own).
  • Fits scope: the clean over-strict trigger.
  • Outcome: check prints warning: [over-strict-mode] is_empty: param \xs` is annotated `(own ...)` but the body does not consume it; `(borrow ...)` would sufficethenok`, exit 0. Names fn, param, current+suggested mode. Exemplary.

examples/fieldtest/cut55_2_consume_map.ail — transform an owned list (decision b)

  • bump rebuilds a list with each element +1; print_all consumes it.
  • Fits scope: own territory; tests the consume analysis + drop soundness the cutover activated.
  • Outcome: check ok; run prints 11 / 21 / 31. No crash, no leak symptom, correct. (Authoring note: my first draft used unit for the Nil arm and got [unbound-var]: unit; the canonical Unit literal is (lit-unit), recoverable from the corpus — not a cutover finding.)

examples/fieldtest/cut55_2b_borrow_traversal_clean.ail — clean borrow-traverse-and-rebuild (decision b, false-positive guard)

  • bump borrows xs and rebuilds a fresh +1 list; the recursive call's param is (borrow ...), so the tail t is only borrowed, never moved into an (own) slot — nothing of the borrowed xs is consumed.
  • Fits scope: the false-positive guard for the #58 fix — a borrow-position use of a heap sub-binder must stay accepted.
  • Outcome: check ok (exit 0) — accepted, as required. (The fixture was originally mislabelled cut55_2b_consume_borrow_reject claiming a consume-while-borrowed reject; that analysis was wrong — the recursive param is (borrow), not (own), so nothing is consumed. Renamed and reclassified to the accepted set; see #55-cutover commit b129af1.)

examples/fieldtest/cut55_2c_consume_borrow_reject.ail — consume a tail binder of a borrowed scrutinee (decision b, intended-wrong)

  • leak borrows xs, passes the tail binder t to an (own) sink sum_own.
  • Fits scope: the consume-while-borrowed boundary.
  • Outcome: check errors [consume-while-borrowed] leak: \t` is consumed while a borrow of it is still live`, exit 1. Rejected as expected (post-#58; see finding below).

examples/fieldtest/cut55_2d_consume_borrow_whole.ail — consume the whole borrowed param (decision b, wrong mode)

  • passthrough borrows xs and hands the whole xs to an (own) sink.
  • Fits scope: the direct consume-while-borrowed case.
  • Outcome: check errors [consume-while-borrowed] passthrough: \xs` is consumed while a borrow of it is still live`, exit 1. Rejected as expected.

examples/fieldtest/cut55_3_value_param.ail — value-typed params read repeatedly (decision c)

  • clamp reads three Int params multiple times.
  • Fits scope: confirms the author writes own for value types and the over-strict lint stays silent on them.
  • Outcome: check ok (no warning); run prints 5 / 3. Correct.

examples/fieldtest/cut55_3b_borrow_over_value.ail — borrow on an Int (decision c, wrong mode)

  • double annotated (borrow (con Int)).
  • Fits scope: the borrow-over-value reject.
  • Outcome: check errors [borrow-over-value] double: borrow over value type in \double`: `Int` is an unboxed value type and cannot be borrowed; use `(own Int)``, exit 1. Names the fix. Exemplary.

examples/fieldtest/cut55_4_borrow_return_reject.ail — borrowed return (decision d, wrong mode)

  • tail_view declares (ret (borrow (con List))).
  • Fits scope: the borrow-may-not-escape rule at the signature.
  • Outcome: check errors [borrow-return-not-permitted] tail_view: borrow-return not permitted ... a (borrow …) return is refcount-invisible and can outlive its source; this language version forbids it ..., exit 1. Clear wall; see friction below re: fix not named.

examples/fieldtest/cut55_4b_clone_to_return.ail — projection via clone (decision d)

  • head_or_zero borrows, returns the value-typed head (own Int); tail_copy returns (clone t) to lift a borrowed heap field to own.
  • Fits scope: the correct escape hatch after borrow-return is rejected.
  • Outcome: check ok; run prints 10. The clone hatch works as the ledger describes.

examples/fieldtest/cut55_5_bare_slot_reject.ail — bare param slot (the cutover's core promise)

  • length with a bare (con List) param slot, no mode wrapper.
  • Fits scope: "no default; Implicit is gone."
  • Outcome: surface parse error [surface-parse-error] ... fn-type slot requires a mode: write (own T) or (borrow T) at byte 642, exit 1. Names both legal forms. Exemplary.

Findings

[working] borrow / own / value-mode authoring is naturally guessable from the ledger

  • Examples: cut55_1, cut55_2, cut55_3, cut55_4b.
  • For every example I knew which mode to write from the ledger alone, no guessing: read-only heap ⇒ borrow; transform/consume ⇒ own; value type ⇒ own; escape a projection ⇒ return own + clone. The decision rule in model/contract 0008 maps cleanly onto realistic tasks.
  • This is the feature-acceptance (LLM-utility) evidence: an author produces correct-moded code on first try, and the modes carry real meaning (the same length is borrow while bump is own).
  • Recommended action: carry-on.

[working] the four reject diagnostics are precise and (mostly) name the fix

  • Examples: cut55_3b (borrow-over-value), cut55_5 (bare slot), cut55_2d (consume-while-borrowed), cut55_1c (over-strict-mode).
  • borrow-over-value and the bare-slot parse error both name the exact legal rewrite (use (own Int) / write (own T) or (borrow T)). over-strict-mode names param + suggested mode. All exit with the right code (errors exit 1, the warning exits 0). This is the surface doing its job for an LLM consumer.
  • Recommended action: carry-on.

[spec_gap — RESOLVED by #58] consuming a heap field-binder of a borrowed scrutinee

  • Examples: cut55_2c vs cut55_2d.
  • What happened at fieldtest time: leak borrows xs and moves the Cons tail t into an (own)-param sink — accepted (exit 0, the missed reject). passthrough borrows xs and moves the whole xs into the same sink — rejected (consume-while-borrowed, exit 1).
  • Why spec_gap: the ledger stated only "consuming a borrowed value is rejected" and "a borrow may not escape into a return". It did not say whether a pattern-binder projected out of a borrowed scrutinee is itself borrowed. Two readings: (i) sub-binders inherit borrow, so moving t into an own sink is an illegal escape (then cut55_2c is a missed reject — a latent unsoundness, a double-free of the caller's allocation); (ii) only the named param is protected (then cut55_2c is correct and the ledger is silent).
  • Resolution: reading (i) was adopted and shipped as the #58 fix (b129af1): walk_arm now seeds a heap-typed sub-binder of a borrowed scrutinee with borrow_count = 1, so cut55_2c now rejects (consume-while-borrowed on t, exit 1). The false-positive boundary is pinned by cut55_2b (a borrow-position use of such a sub-binder stays accepted). Value-typed sub-binders (the Int head) stay freely consumable.

[friction] borrow-return error explains why but does not name the fix

  • Example: cut55_4.
  • The borrow-return-not-permitted message gives a thorough rationale (refcount-invisible, can outlive its source) but stops there. An author is told what is forbidden, not what to do instead. The natural remedy — return (own ...) and (clone ...) the projected field (which I had to derive myself in cut55_4b) — is exactly the escape hatch the language provides and would be cheap to name. Sibling diagnostics (borrow-over-value, the bare-slot error) DO name the fix, so this one reads as inconsistent.
  • Recommended action: plan a one-line diagnostic-text addendum suggesting "return (own T) and (clone …) the value you want to escape."

[friction] consume-while-borrowed wording implies a separate live borrow alias that isn't present

  • Example: cut55_2d.
  • Message: "xs is consumed while a borrow of it is still live." In this case xs IS the borrowed param being consumed; there is no separate live borrow alias. The wording (lifted from the let-alias class, spec 0064 class 2) reads oddly for the whole-param case and, like the borrow-return error, does not name the fix (annotate own, or clone).
  • Recommended action: plan — branch the wording for the "param-itself-consumed" case vs the "alias-still-live" case, and append the fix hint.

Recommendation summary

Finding Action
working — mode authoring guessable from ledger carry-on
working — reject diagnostics precise & name fix carry-on
spec_gap — borrowed-scrutinee field consume accepted vs whole rejected RESOLVED by #58 (b129af1, reading (i)); cut55_2c now rejects, cut55_2b guards the boundary
friction — borrow-return error omits the fix plan (tidy diagnostic)
friction — consume-while-borrowed wording + no fix hint plan (tidy diagnostic)