fieldtest: loop-recur — 10 examples, 6 findings (milestone CLOSE, clean on all axes)

Post-audit downstream-LLM-author field test of the shipped
loop/recur surface (DESIGN.md + public examples only). 3 real
iterative programs (Newton isqrt, Collatz, Euclidean gcd) + 5
plausible-mistake negatives + 2 no-termination probes, all run
through the public ail CLI. 0 bugs. 4 working findings on the
milestone's own axes: rejection diagnostics point-exact AND
self-fixing; recur tail-position threads through match/let/outer-if
(spec only showed if); loop composes as a value sub-expression +
byte-stable round-trip; no-termination boundary exact. This
empirically substantiates the "LLM author can now write iterative
programs" claim.

Two orthogonal non-blocking findings, neither in loop/recur scope,
both routed to P2 todos (refused the scope creep into a loop/recur
tidy): niladic (app f) spec_gap independently re-confirms the
existing mut-local-F3 roadmap item (the design-fork decision
deliberately NOT auto-ratified under /boss — parked,
priority-strengthened); module-level (doc) diagnostic-hint friction
(one-line tidy). Boss-verified independently (gcd->27;
recur-outside-loop fires exact).

The standalone loop/recur milestone is fully ratified and CLOSED:
3 iterations + tidy shipped, audit clean (drift resolved, bench
pristine carry-on), fieldtest clean on every axis. Roadmap P0
marked closed.
This commit is contained in:
2026-05-18 00:31:19 +02:00
parent 2ee97943bd
commit 2ed355c6fa
13 changed files with 471 additions and 18 deletions
@@ -0,0 +1,23 @@
(module loop_recur_1_isqrt_newton
(fn main
(doc "Integer square root of 152399025 (= 12345^2) via Newton's method, then a sanity-difference. Expected stdout: 12345 then 0.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let r (app isqrt 152399025)
(seq (app print r)
(app print (app - r 12345))))))
(fn isqrt
(doc "Newton's method for floor(sqrt(n)). Two binders: x (current guess) and prev (previous guess, to detect the fixpoint/2-cycle). The recur is buried inside a nested if, not at body toplevel.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(if (app < n 2)
n
(loop (x (con Int) n) (prev (con Int) 0)
(if (app == x prev)
x
(let next (app / (app + x (app / n x)) 2)
(if (app == next x)
next
(recur next x)))))))))
@@ -0,0 +1,20 @@
(module loop_recur_2_collatz
(fn main
(doc "Collatz step counts. collatz_len(27)=111, collatz_len(97)=118, collatz_len(1)=0. Expected stdout (per line): 111, 118, 0.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(seq (app print (app collatz_len 27))
(seq (app print (app collatz_len 97))
(app print (app collatz_len 1))))))
(fn collatz_len
(doc "Number of Collatz steps to reach 1. Two binders: n (current value) and steps (accumulator). Parity dispatch is a `match` on n%2; the recur lives in the tail of each match arm, not at body toplevel.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params start)
(body
(loop (n (con Int) start) (steps (con Int) 0)
(if (app == n 1)
steps
(match (app % n 2)
(case (pat-lit 0) (recur (app / n 2) (app + steps 1)))
(case _ (recur (app + (app * 3 n) 1) (app + steps 1)))))))))
@@ -0,0 +1,16 @@
; NEGATIVE FIXTURE. The LLM-natural mistake: author writes a
; self-recursive helper and reaches for `recur` as if it were a
; generic self-tail-call, but there is no enclosing `loop`.
; Expected: `ail check` exits 1 with code recur-outside-loop.
(module loop_recur_3a_recur_outside_loop
(fn countdown
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(if (app == n 0)
0
(recur (app - n 1)))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app countdown 5)))))
@@ -0,0 +1,17 @@
; NEGATIVE FIXTURE. The LLM-natural mistake: a two-binder loop
; (acc, i) but the author writes `(recur (app + acc i))` forgetting
; to also advance `i` — passes 1 arg for 2 binders.
; Expected: `ail check` exits 1 with code recur-arity-mismatch.
(module loop_recur_3b_recur_arity
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (acc (con Int) 0) (i (con Int) 1)
(if (app > i n)
acc
(recur (app + acc i))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app sum_to 10)))))
@@ -0,0 +1,17 @@
; NEGATIVE FIXTURE. The LLM-natural mistake: the author intends to
; recur with the updated Int accumulator but accidentally passes a
; Bool (the comparison result) into the first binder position.
; Expected: `ail check` exits 1 with code recur-type-mismatch.
(module loop_recur_3c_recur_type
(fn count_down
(type (fn-type (params (con Int)) (ret (con Int))))
(params start)
(body
(loop (i (con Int) start)
(if (app == i 0)
i
(recur (app > i 0))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app count_down 5)))))
@@ -0,0 +1,18 @@
; NEGATIVE FIXTURE. The LLM-natural mistake: the author treats
; `recur` like a value-returning recursive call and multiplies its
; result, computing factorial as `n * recur(n-1)`. recur is an
; argument to *, not in tail position.
; Expected: `ail check` exits 1 with code recur-not-in-tail-position.
(module loop_recur_3d_recur_not_tail
(fn factorial
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (i (con Int) n)
(if (app == i 1)
1
(app * i (recur (app - i 1)))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app factorial 5)))))
@@ -0,0 +1,23 @@
; NEGATIVE FIXTURE. The LLM-natural mistake: inside the loop body
; the author builds a closure that closes over the loop binder
; `acc` (a `\delta. acc + delta` adder) and applies it. Loop
; binders are alloca-resident and may not be captured.
; Expected: `ail check` exits 1 with code loop-binder-captured-by-lambda.
(module loop_recur_3e_binder_captured
(fn apply_int
(type (fn-type (params (fn-type (params (con Int)) (ret (con Int))) (con Int)) (ret (con Int))))
(params f x)
(body (app f x)))
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (acc (con Int) 0) (i (con Int) 1)
(if (app > i n)
acc
(recur (app apply_int (lam (params (typed d (con Int))) (ret (con Int)) (body (app + acc d))) i)
(app + i 1))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app sum_to 10)))))
@@ -0,0 +1,24 @@
; Two things at once:
; (1) `loop` used as a sub-expression in argument position — the
; (loop ...) result of `gcd` is passed straight into another
; call (`app print (app + (app gcd 48 18) (app gcd 1071 462)))`),
; not used as a whole fn body. Expected stdout: 27 (gcd(48,18)=6
; plus gcd(1071,462)=21).
; (2) Euclid's algorithm is a textbook real iterative algorithm an
; LLM author reaches for `loop`/`recur` to write; single-binder
; pair? no — two binders (a, b), recur swaps/reduces.
(module loop_recur_4_gcd_value_pos
(fn gcd
(doc "Euclidean gcd via loop/recur. Two binders a,b; recur in the tail of the else branch.")
(type (fn-type (params (con Int) (con Int)) (ret (con Int))))
(params x y)
(body
(loop (a (con Int) x) (b (con Int) y)
(if (app == b 0)
a
(recur b (app % a b))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print (app + (app gcd 48 18) (app gcd 1071 462))))))
@@ -0,0 +1,19 @@
; NO-TERMINATION BOUNDARY fixture. An LLM-natural shape: a "tick"
; event loop that never exits (no non-recur branch). Per the
; loop-recur spec this MUST `ail check` AND `ail build` cleanly —
; AILang makes no termination claim. BUILD-ONLY: by design this
; binary never returns, so it is never executed.
(module loop_recur_5_event_loop_noterm
(fn run_forever
(doc "Infinite event loop. Two binders: tick counter and a running parity flag. No branch ever exits via a non-recur term.")
(type (fn-type (params) (ret (con Int))))
(params)
(body
(loop (tick (con Int) 0) (parity (con Int) 0)
(if (app == (app % tick 2) 0)
(recur (app + tick 1) 1)
(recur (app + tick 1) 0)))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app run_forever)))))
@@ -0,0 +1,21 @@
; NO-TERMINATION BOUNDARY fixture (loop-axis-covering sibling of
; loop_recur_5_event_loop_noterm.ail, which surfaced the orthogonal
; niladic-call obstacle). An LLM-natural shape: a "tick" event loop
; seeded from a start value that never exits (no non-recur branch).
; Per the loop-recur spec this MUST `ail check` AND `ail build`
; cleanly — AILang makes no termination claim. BUILD-ONLY: by
; design this binary never returns, so it is never executed.
(module loop_recur_5b_event_loop_noterm
(fn run_forever
(doc "Infinite event loop. Two binders: tick counter and a running parity flag. No branch ever exits via a non-recur term.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params start)
(body
(loop (tick (con Int) start) (parity (con Int) 0)
(if (app == (app % tick 2) 0)
(recur (app + tick 1) 1)
(recur (app + tick 1) 0)))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app run_forever 0)))))