diff --git a/docs/specs/0067-fieldtest-series-step1.md b/docs/specs/0067-fieldtest-series-step1.md new file mode 100644 index 0000000..a9615a9 --- /dev/null +++ b/docs/specs/0067-fieldtest-series-step1.md @@ -0,0 +1,200 @@ +# Fieldtest — series-step1 — 2026-06-02 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill) + +## Scope + +The `Series a` kernel-tier library extension shipped at `35a9bc6` +(model-0007 reconciliation at `73fbb24`). It is a bounded ring buffer +with financial-style indexing (index 0 = newest), built as PURE AILang +over `RawBuf` — zero Rust intercepts, zero `(intrinsic)` markers. The +element type is restricted to `{Int, Float}` via `param-in` (`Bool` +deferred, #61). It is auto-imported like a built-in (no `(import series)` +needed). Public type-scoped API: `(new Series (con T) lookback)`, +`Series.push : (own (Series a), own a) -> own (Series a)`, +`Series.at : (borrow (Series a), Int) -> own a` (0 = newest), +`Series.len : (borrow (Series a)) -> Int` (live count, saturates at +lookback), `Series.total_count : (borrow (Series a)) -> Int` (monotone +total pushes). + +This field test probed five axes: construction/auto-import discovery, +ring-buffer + financial-indexing ergonomics, len/total_count bookkeeping +with warmup, `param-in` error quality, and operator-naming friction. + +The build exercised was the HEAD working tree, invoked via +`cargo run -q --bin ail -- build --alloc=rc ` (debug profile); +each produced native binary was then run directly, and re-run under +`AILANG_RC_STATS=1` for drop soundness. + +## Examples + +### examples/fieldtest/series-step1_1_rolling_max.ail — rolling max over the last-3 window +- Streams 6 Ints into a size-3 Series and, after each push, prints the + max of the live window by scanning `Series.at s i` for `i` in + `[0, Series.len s)`. +- Fits the ring-buffer/financial-indexing + warmup axes: it proves + eviction (a large old value scrolled out of the window stops winning) + and handles warmup by bounding the scan at the live count. +- Outcome: checked, built, ran. Stdout `4 4 7 7 7 3` — exactly expected, + including the eviction proof (max drops from 7 to 3 when 7 is evicted). + **Leaks `live=8` under `AILANG_RC_STATS` (see finding [bug]).** + +### examples/fieldtest/series-step1_2_last_n_events.ail — last-N events ring, oldest-first dump +- Pushes 7 Ints into a size-3 Series, then dumps the live window + oldest-first by walking `Series.at` from `len-1` down to `0`, and + reports `total_count` (7) and `len` (3). +- Fits the wrap-boundary off-by-one axis (the buffer wrapped 2+ times + before the dump) and the len-vs-total_count bookkeeping axis. +- Outcome: checked, built, ran. Stdout `50 60 70 7 3` — correct; + financial indexing maps correctly after multiple wraps; `total_count` + did not saturate, `len` did. **Leak-clean (`live=0`).** + +### examples/fieldtest/series-step1_3_warmup_rolling_sum.ail — Float warmup-gated rolling sum +- Streams 6 Floats into a size-4 Series; emits the rolling sum only once + `Series.len s == 4` (warmup suppressed for the first 3 pushes). +- Fits the Float-element + warmup/bookkeeping axes; pins the reading that + "full window" is gated on the saturating `len`, not `total_count`. +- Outcome: checked, built, ran. Stdout `20.0 28.0 36.0` — correct, with + warmup suppressed and correct eviction. **Leaks `live=8` (same bug).** + +### examples/fieldtest/series-step1_4_paramin_str_reject.ail — param-in Str element (MUST FAIL) +- A single push of a `Str` into `(con Series (con Str))`. The only defect + is the disallowed element type. +- Fits the `param-in` error-quality axis. +- Outcome: `ail check` rejects with exit code 1 and a high-quality + diagnostic (see finding [working]). A `(con Bool)` probe produced the + same clean diagnostic. + +### examples/fieldtest/series-step1_5_drop_leak_repro.ail — minimal drop-leak reproduction +- Smallest form of the leak: push two Ints into a size-3 Series via + `tail-app` recursion whose base case returns Unit. +- Outcome: checked, built, ran. Stdout `done`; **`live=4` under + `AILANG_RC_STATS`**. This is the reduced repro for the [bug] below. + +## Findings + +### [working] Construction, auto-import, and financial indexing are correct and ergonomic +- Examples 1, 2, 3. +- All three reach for `(new Series (con T) lookback)` with **no + `(import series)`** — auto-import works as the whitepaper promises. + `Series.push` / `Series.at` / `Series.len` / `Series.total_count` + resolve type-scoped at the call site with no qualifier. Financial + indexing (0 = newest) is intuitive: walking `[0, len)` gives newest→ + oldest, walking `len-1` down to `0` gives oldest→newest, and the index + maps correctly even after the buffer wraps 2+ times (example 2). All + three programs produced exactly the predicted output on the first run. +- This is a clear win: the bounded-buffer mental model the LLM author + reaches for maps directly onto the API, and eviction/warmup are handled + by the `len`/`at` pair without any off-by-one surprise. +- Recommended downstream action: carry-on. + +### [working] `param-in` diagnostic is exactly what the ledger promised +- Example 4 (plus a `(con Bool)` probe). +- Verbatim: + `error: [param-not-in-restricted-set] main: type-arg `Str` is not in + the restricted set for type-variable `a` of `series.Series` (allowed: + one of {Float, Int})`. Exit code 1. The `Bool` element produced the + identical shape with `Bool` substituted. +- Names the offending type, the type-variable, the fully-qualified type + `series.Series`, and the allowed set — matching the + `ParamNotInRestrictedSet` contract in model 0007 §param-in. Actionable + and precise. +- Recommended downstream action: carry-on. + +### [bug] Owned Series threaded through a tail-recursive stream loop leaks the RawBuf slab +- Examples 1, 3, 5; also the committed reference `examples/series_sma.ail` + (`live=8`). +- Under `AILANG_RC_STATS=1`, the canonical streaming pattern the + whitepaper itself uses leaks heap allocations: + - `series_sma` (committed reference): `allocs=19 frees=11 live=8` + - `series-step1_1_rolling_max`: `live=8` + - `series-step1_3_warmup_rolling_sum`: `live=8` + - `series-step1_5_drop_leak_repro` (minimal): `allocs=7 frees=3 live=4` +- The trigger is an `(own (con Series ...))` value threaded through + `tail-app` recursion whose base case returns a non-Series type (Unit) + and does not explicitly consume the owned Series. The intermediate + push results and/or the final Series alive at the base-case arm are + never dropped. A `let s_new (Series.push s v)` binding adds to the leak + vs. passing the push directly as the tail-app argument + (`live=4` vs `live=2` in the minimal probe). +- **Not Series-specific.** A control probe wrapping a plain heap + `IntList` in a user `Box` ADT and threading it through the identical + tail-recursion pattern also leaks (`live=7`). The root is general + drop soundness for an owned heap value reaching a base case that + neither returns nor consumes it; Series merely surfaces it heavily + (RawBuf slab + wrapper) and on the whitepaper's headline pattern. The + `Series` module source itself is pure AILang and behaves correctly — + example 2 (Series *returned* from the recursion and dropped at the + caller's scope close) is `live=0`. +- One-line repro: + `ail build --alloc=rc examples/fieldtest/series-step1_5_drop_leak_repro.ail -o /tmp/r && AILANG_RC_STATS=1 /tmp/r` + → `ailang_rc_stats: allocs=7 frees=3 live=4`. +- Recommended downstream action: debug (RED-first). Note for the + debugger: probe whether this is the same own-param-at-non-consuming- + base-case drop family seen in prior cutover work, not a Series codegen + issue. The contradiction with model 0007's "dropping the Window frees + the slab / leak-clean" claim should be resolved by fixing the drop, + not by softening the doc. + +### [friction] `ail describe` does not resolve the canonical type-scoped `Series.` form +- Discovery axis (probed via the CLI, not a fixture). +- `ail describe --workspace examples/series_sma.ail Series.push` and + `... Series.at` both fail with `Error: no module `Series` in workspace`. + The same ops resolve via the module-scoped `series.push` or the bare + `push`. `describe --workspace ... Series` prints only the `Series` + TypeDef (with `param-in`), not its operations. +- This is friction, not a bug against a written contract: model 0007 §1 + declares type-scoped access the *canonical* form for type-associated + operations and names `TypeScopedReceiverNotAType` for a bad receiver — + but `describe` treats `Series.push` as a module path and reports a + module-not-found error even though `Series` IS a known type. A + downstream LLM author who has just written `(app Series.push s v)` and + wants to confirm its signature will type `describe ... Series.push` + first (the canonical form) and hit a dead end, then has to know to fall + back to `series.push` or bare `push`. Discovery of the op *set* for a + type is also absent: neither `describe Series` nor `builtins` lists the + five Series ops (`builtins` covers only the 20 primitive operations). +- Recommended downstream action: plan (tidy iteration) — make + `describe`'s name resolver try the type-scoped branch (mirroring the + call-site resolver in model 0007 §1), and consider surfacing a type's + home-module ops when `describe`-ing the type itself. + +### [spec_gap] Whitepaper §Series substrate writes a ctor-field mode that does not parse; correct field form for a Series-like wrapper is undocumented +- Surfaced while drafting (carried over from the raw-buf fieldtest + `examples/fieldtest/rbx_1_float_window_stats.ail`, re-confirmed this + cycle as still the live behaviour). +- Model 0007 §"Series — the library extension" shows the storage ctor + field as `(con RawBuf a)` with a comment "no mode: modes live on fn + params/ret, not on ADT fields" — which is correct and parses. But the + two-tier §"Library extensions" prose and the §Series-substrate framing + elsewhere lead an author to try `(own (RawBuf a))` in a ctor field (a + surface parse error) or a bare unqualified `(con RawBuf (con T))` (a + type-mismatch: `RawBuf` vs `raw_buf.RawBuf`) when writing their own + Series-like wrapper ADT. The only form that checks for a *user* module + is the fully-qualified `(con raw_buf.RawBuf (con T))`. +- The reading I picked: a downstream author wrapping a kernel base type + in their own ADT field must fully-qualify it (`raw_buf.RawBuf`), even + though the *kernel-tier* `series` module uses the bare `a`-parametrised + `(con RawBuf a)` form (which works because `series` is itself + kernel-tier and the element is a type variable, not a concrete bare + ctor ref). Another equally plausible reading: bare `(con RawBuf (con T))` + should resolve via the same kernel auto-import that makes + `(con Series (con Float))` work bare at a call site — the asymmetry + (bare `Series` type-position OK, bare `RawBuf` ctor-field not OK) is + not stated anywhere in the public surface. +- Recommended downstream action: ratify / tighten the design ledger — + document, in model 0007 or contract 0002, the rule for referencing a + kernel base type from a user ADT ctor field (must fully-qualify; bare + works only inside a kernel-tier module or in type-position via + auto-import), so the asymmetry is no longer a guess. + +## Recommendation summary + +| Finding | Class | Action | +|---|---|---| +| Construction / auto-import / financial indexing correct | working | carry-on | +| `param-in` diagnostic quality | working | carry-on | +| Owned Series tail-recursion leaks RawBuf slab | bug | debug | +| `describe` does not resolve type-scoped `Series.` | friction | plan | +| Whitepaper ctor-field mode / bare-RawBuf-in-field asymmetry | spec_gap | ratify / tighten ledger | diff --git a/examples/fieldtest/series-step1_1_rolling_max.ail b/examples/fieldtest/series-step1_1_rolling_max.ail new file mode 100644 index 0000000..20e16e0 --- /dev/null +++ b/examples/fieldtest/series-step1_1_rolling_max.ail @@ -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)))))))))))) diff --git a/examples/fieldtest/series-step1_2_last_n_events.ail b/examples/fieldtest/series-step1_2_last_n_events.ail new file mode 100644 index 0000000..0d0b1a6 --- /dev/null +++ b/examples/fieldtest/series-step1_2_last_n_events.ail @@ -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"))))))))) diff --git a/examples/fieldtest/series-step1_3_warmup_rolling_sum.ail b/examples/fieldtest/series-step1_3_warmup_rolling_sum.ail new file mode 100644 index 0000000..72026d1 --- /dev/null +++ b/examples/fieldtest/series-step1_3_warmup_rolling_sum.ail @@ -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))))))))))) + ) diff --git a/examples/fieldtest/series-step1_4_paramin_str_reject.ail b/examples/fieldtest/series-step1_4_paramin_str_reject.ail new file mode 100644 index 0000000..a9f5109 --- /dev/null +++ b/examples/fieldtest/series-step1_4_paramin_str_reject.ail @@ -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")))))) diff --git a/examples/fieldtest/series-step1_5_drop_leak_repro.ail b/examples/fieldtest/series-step1_5_drop_leak_repro.ail new file mode 100644 index 0000000..5a3ef44 --- /dev/null +++ b/examples/fieldtest/series-step1_5_drop_leak_repro.ail @@ -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))))))))