# 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 <fixture>` (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.<op>` 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.<op>` | friction | plan |
| Whitepaper ctor-field mode / bare-RawBuf-in-field asymmetry | spec_gap | ratify / tighten ledger |
