Files
AILang/docs/specs/0067-fieldtest-series-step1.md
Brummel 3b3c8c4f07 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
2026-06-02 13:22:30 +02:00

11 KiB

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 Stris not in the restricted set for type-variableaofseries.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/railang_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