fieldtest(series-step1): 5 examples, 5 findings (1 bug, 1 friction, 1 spec-gap)

Post-ship field test of the shipped `series` library extension as a
downstream Form-A author working from the public surface only. Five
fixtures under examples/fieldtest/ across the construction/auto-import,
ring-buffer/financial-indexing, len/total_count bookkeeping, param-in
error-quality, and operator-naming axes; spec at
docs/specs/0067-fieldtest-series-step1.md.

Wins (carry-on): auto-import works with no `(import series)`; the
financial index (0 = newest) maps directly onto the bounded-buffer
mental model and stays correct after multiple wraps; the param-in
diagnostic for a disallowed element type names the type, the
type-variable, the qualified type, and the allowed set.

Findings routed:
- [bug] An owned ADT threaded through tail-recursion whose base case
  neither returns nor consumes it is never dropped — a general
  drop-soundness leak (a plain `Box` control with no Series/RawBuf
  also leaks). Series surfaces it heavily on the whitepaper's headline
  streaming pattern, and the committed reference examples/series_sma.ail
  itself leaks (live=8). Minimal repro: series-step1_5 (live=4).
- [friction] `ail describe ... Series.push` (the canonical type-scoped
  form) reports module-not-found; only `series.push` / bare `push`
  resolve, and no command lists a type's op set.
- [spec_gap] referencing a kernel base type (`RawBuf`) from a user ADT
  ctor field requires full qualification (`raw_buf.RawBuf`) while bare
  `Series` works in type-position via auto-import — the asymmetry is
  undocumented.

The fixtures that leak still produce correct output; the leak is a
separate observable under AILANG_RC_STATS and an orthogonal,
pre-existing bug, not a Series defect.

refs #61
This commit is contained in:
2026-06-02 13:22:30 +02:00
parent 73fbb240be
commit 3b3c8c4f07
6 changed files with 518 additions and 0 deletions
@@ -0,0 +1,79 @@
; Fieldtest series-step1.1 — ring-buffer + financial-indexing + warmup.
;
; Task an LLM author is naturally given: "stream a sequence of Int
; readings into a window of size 3 and, after each push, print the
; maximum of the values currently in the window." This is the canonical
; bounded-buffer streaming task: the window only ever holds the last 3
; readings, so the running max must respect eviction (a large old value
; that has scrolled out of the window must NOT keep winning).
;
; Exercises:
; - (new Series (con Int) 3) + auto-import (no (import series))
; - Series.push threading the owned Series through the stream
; - Series.at with financial indexing (0 = newest) — we scan i in
; [0, Series.len) so warmup (fewer than 3 elements) is handled by
; reading only the live count, never a stale slot.
; - Series.len as the live-count bound (saturates at lookback=3).
;
; Stream: 4, 1, 7, 2, 3, 1
; after 4 -> window {4} max 4
; after 1 -> window {4,1} max 4
; after 7 -> window {4,1,7} max 7
; after 2 -> window {1,7,2} max 7 (4 evicted)
; after 3 -> window {7,2,3} max 7
; after 1 -> window {2,3,1} max 3 (7 evicted) <-- the eviction proof
; Expected stdout (one per line): 4 4 7 7 7 3
(module series-step1_1_rolling_max
(data IntList
(ctor INil)
(ctor ICons (con Int) (con IntList)))
; Max over the live window: scan indices 0..len, return the largest.
(fn window_max_step
(type (fn-type
(params (borrow (con Series (con Int))) (own (con Int)) (own (con Int)) (own (con Int)))
(ret (own (con Int)))))
(params s len i acc)
(body
(if (app eq i len)
acc
(tail-app window_max_step s len
(app + i 1)
(if (app gt (app Series.at s i) acc) (app Series.at s i) acc)))))
(fn window_max
(type (fn-type
(params (borrow (con Series (con Int))))
(ret (own (con Int)))))
(params s)
(body (app window_max_step s (app Series.len s) 0 0)))
(fn run_stream
(type (fn-type
(params (own (con Series (con Int))) (own (con IntList)))
(ret (own (con Unit))) (effects IO)))
(params s input)
(body
(match input
(case (pat-ctor INil) (do io/print_str ""))
(case (pat-ctor ICons v rest)
(let s_new (app Series.push s v)
(seq
(seq (app print (app window_max s_new)) (do io/print_str "\n"))
(tail-app run_stream s_new rest)))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let s (new Series (con Int) 3)
(app run_stream s
(term-ctor IntList ICons 4
(term-ctor IntList ICons 1
(term-ctor IntList ICons 7
(term-ctor IntList ICons 2
(term-ctor IntList ICons 3
(term-ctor IntList ICons 1
(term-ctor IntList INil))))))))))))
@@ -0,0 +1,75 @@
; Fieldtest series-step1.2 — financial-indexing off-by-one at the wrap
; boundary + len-vs-total_count bookkeeping.
;
; Task: "keep the last 3 event codes seen on a stream; after the whole
; stream, dump the surviving window oldest-first, then report how many
; events were seen in total." This is the classic 'ring of last N' task.
;
; The interesting part is the DUMP: index 0 is the newest, so to print
; oldest-first I walk i from (len - 1) down to 0. After the buffer has
; wrapped more than once (total >> lookback), the financial index must
; still map correctly onto the underlying slot — this is the wrap-boundary
; off-by-one trap the axis calls out.
;
; total_count is the monotone "events ever seen" (does NOT saturate);
; len is the live window size (saturates at lookback=3). The task needs
; BOTH: len to bound the dump walk, total_count for the summary.
;
; Stream of 7 events: 10 20 30 40 50 60 70 (lookback 3)
; After all 7 pushes the window holds the newest 3: 50 60 70.
; Dumped oldest-first: 50, 60, 70.
; total_count = 7, len = 3.
; Expected stdout (one per line): 50 60 70 then "total=" path -> 7 and 3.
; Concretely the lines are: 50 60 70 7 3
(module series-step1_2_last_n_events
(data IntList
(ctor INil)
(ctor ICons (con Int) (con IntList)))
; Push every element of the list into the series, returning the series.
(fn fill
(type (fn-type
(params (own (con Series (con Int))) (own (con IntList)))
(ret (own (con Series (con Int))))))
(params s input)
(body
(match input
(case (pat-ctor INil) s)
(case (pat-ctor ICons v rest)
(tail-app fill (app Series.push s v) rest)))))
; Print the live window oldest-first: walk i from (len-1) down to 0.
; index 0 = newest, index (len-1) = oldest live element.
(fn dump_step
(type (fn-type
(params (borrow (con Series (con Int))) (own (con Int)))
(ret (own (con Unit))) (effects IO)))
(params s i)
(body
(if (app lt i 0)
(do io/print_str "")
(seq
(seq (app print (app Series.at s i)) (do io/print_str "\n"))
(tail-app dump_step s (app - i 1))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let s0 (new Series (con Int) 3)
(let s (app fill s0
(term-ctor IntList ICons 10
(term-ctor IntList ICons 20
(term-ctor IntList ICons 30
(term-ctor IntList ICons 40
(term-ctor IntList ICons 50
(term-ctor IntList ICons 60
(term-ctor IntList ICons 70
(term-ctor IntList INil)))))))))
(seq
(app dump_step s (app - (app Series.len s) 1))
(seq
(seq (app print (app Series.total_count s)) (do io/print_str "\n"))
(seq (app print (app Series.len s)) (do io/print_str "\n")))))))))
@@ -0,0 +1,82 @@
; Fieldtest series-step1.3 — Float element + warmup-aware emission using
; the len/total_count bookkeeping distinction.
;
; Task: "compute a rolling sum over a window of 4 Float samples, but only
; emit a value once the window is actually full (warmup suppressed)." A
; downstream author reaches for the bookkeeping fields to gate emission:
; emit iff the live count has reached the lookback. This is the warmup
; pattern, but gated on `Series.len` (the saturating live count) rather
; than `Series.total_count` (the monotone total) — both are offered and
; the author must pick the right one.
;
; Using `len` as the gate means: emit the first time len hits 4, and on
; every push after that (len stays saturated at 4). Using total_count
; would behave identically here BUT the author who wants "full window"
; semantics should reach for len; this example pins that reading.
;
; Stream: 2.0 4.0 6.0 8.0 10.0 12.0 (lookback 4)
; push 2.0 len 1 (warmup, suppressed)
; push 4.0 len 2 (warmup, suppressed)
; push 6.0 len 3 (warmup, suppressed)
; push 8.0 len 4 -> sum {2,4,6,8} = 20.0
; push 10.0 len 4 -> sum {4,6,8,10} = 28.0 (2.0 evicted)
; push 12.0 len 4 -> sum {6,8,10,12} = 36.0 (4.0 evicted)
; Expected stdout (one per line): 20.0 28.0 36.0
(module series-step1_3_warmup_rolling_sum
(data FloatList
(ctor FNil)
(ctor FCons (con Float) (con FloatList)))
(fn sum_window_step
(type (fn-type
(params (borrow (con Series (con Float))) (own (con Int)) (own (con Int)) (own (con Float)))
(ret (own (con Float)))))
(params s len i acc)
(body
(if (app eq i len)
acc
(tail-app sum_window_step s len
(app + i 1)
(app + acc (app Series.at s i))))))
; Emit the rolling sum only once the window is full (len == lookback=4).
(fn emit_if_full
(type (fn-type
(params (borrow (con Series (con Float))))
(ret (own (con Unit))) (effects IO)))
(params s)
(body
(if (app eq (app Series.len s) 4)
(seq (app print (app sum_window_step s (app Series.len s) 0 0.0))
(do io/print_str "\n"))
(do io/print_str ""))))
(fn run_stream
(type (fn-type
(params (own (con Series (con Float))) (own (con FloatList)))
(ret (own (con Unit))) (effects IO)))
(params s input)
(body
(match input
(case (pat-ctor FNil) (do io/print_str ""))
(case (pat-ctor FCons v rest)
(let s_new (app Series.push s v)
(seq (app emit_if_full s_new)
(tail-app run_stream s_new rest)))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let s (new Series (con Float) 4)
(app run_stream s
(term-ctor FloatList FCons 2.0
(term-ctor FloatList FCons 4.0
(term-ctor FloatList FCons 6.0
(term-ctor FloatList FCons 8.0
(term-ctor FloatList FCons 10.0
(term-ctor FloatList FCons 12.0
(term-ctor FloatList FNil)))))))))))
)
@@ -0,0 +1,27 @@
; Fieldtest series-step1.4 (MUST FAIL) — param-in error quality for a
; disallowed element type.
;
; Task framing: an author who has used Series for Int/Float reaches for
; "a ring of the last N log lines" — i.e. (con Series (con Str)). The
; element type Str is NOT in Series's param-in set {Int, Float}, so this
; must be rejected. The axis question is the QUALITY of the diagnostic:
; - does it name Series and the offending type Str?
; - does it state the allowed set?
; - does it point at a location?
;
; This fixture is otherwise valid: a single push of a Str literal into a
; Series of Str. The ONLY defect is the element type.
;
; Expected: `ail check` rejects. The whitepaper (0007 §param-in) names the
; diagnostic ParamNotInRestrictedSet, "naming the offending type and the
; allowed set." We record what actually surfaces.
(module series-step1_4_paramin_str_reject
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let s (new Series (con Str) 3)
(let s2 (app Series.push s "hello")
(do io/print_str "unreachable\n"))))))
@@ -0,0 +1,55 @@
; Fieldtest series-step1.5 (LEAK REPRO) — owned Series threaded through a
; tail-recursive stream loop is not dropped, leaking the RawBuf slab.
;
; This is the minimal form of a leak that surfaces on the CANONICAL
; streaming pattern the whitepaper itself uses (design/models/0007 §Series,
; the SMA worked example, and examples/series_sma.ail): a `run`-style fn
; takes (own (con Series ...)) plus a worklist, and on each element does
; (Series.push s v) then tail-recurses; the base case returns Unit.
;
; Under AILANG_RC_STATS the program reports `live > 0` (here live=4):
; the intermediate Series values produced by push, and/or the final Series
; alive at the base-case arm, are never freed. The committed reference
; example examples/series_sma.ail exhibits the same leak (live=8), as do
; series-step1_1_rolling_max.ail (live=8) and
; series-step1_3_warmup_rolling_sum.ail (live=8).
;
; The leak is NOT present when the owned Series is RETURNED from the
; recursion (ret own Series) and dropped at the final caller's scope close
; (see series-step1_2_last_n_events.ail, which is live=0): the trigger is
; an owned Series reaching a base case whose return type is not Series and
; that does not explicitly consume it.
;
; This fixture is the smallest reproduction: push two Ints into a size-3
; Series via tail recursion, base case prints "done".
;
; Expected stdout: "done".
; Observed under AILANG_RC_STATS: `ailang_rc_stats: allocs=7 frees=3 live=4`.
(module series-step1_5_drop_leak_repro
(data IntList
(ctor INil)
(ctor ICons (con Int) (con IntList)))
(fn run
(type (fn-type
(params (own (con Series (con Int))) (own (con IntList)))
(ret (own (con Unit))) (effects IO)))
(params s input)
(body
(match input
(case (pat-ctor INil) (do io/print_str "done\n"))
(case (pat-ctor ICons v rest)
(let s_new (app Series.push s v)
(tail-app run s_new rest))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let s (new Series (con Int) 3)
(app run s
(term-ctor IntList ICons 1
(term-ctor IntList ICons 2
(term-ctor IntList INil))))))))