# Fieldtest — embedding-abi-m1 — 2026-05-18

**Status:** Draft — awaiting orchestrator triage
**Author:** ailang-fieldtester (dispatched by skills/fieldtest)

## Scope

Embedding ABI M1 makes a compiled AILang scalar `fn` callable
in-process from a C/Rust host. It adds the Form-A `(export "<sym>")`
fn modifier, an additive `FnDef.export: Option<String>` schema field,
a new `ail build --emit=staticlib` mode that produces a relocatable
`lib<entry>.a` (program objects only) plus a separate
`libailang_rt.a`, a typecheck-time export gate (`Int`/`Float` params
& return, empty effect set — codes `export-non-scalar-signature`,
`export-has-effects`), and a DESIGN.md §"Embedding ABI (M1)" that
labels the M1 C signature provisional until M3. No
ADT/record/list/`Str`/effect crosses the boundary in M1.

## Examples

### `examples/fieldtest/embabi1_1_ema_step.ail` — fixed-point EMA accumulator (Int positive axis)
- **What it does:** `ema_step(state, sample) = state + (sample - state)/4`,
  a fixed-point exponential moving average; the `(State, Sample) ->
  State` accumulator a host drives one sample per call.
- **Why it fits:** this is exactly the positive authoring axis — a
  pure numeric fold made callable from a C host loop, the headline
  M1 use case.
- **Outcome:** authored from DESIGN.md §"Embedding ABI (M1)" +
  `crates/ailang-core/specs/form_a.md` + the public
  `embed_backtest_step.ail` form. `ail check` → `ok` first try.
  `ail build --emit=staticlib -o /tmp/ft1` → produced
  `libembabi1_1_ema_step.a` + `libailang_rt.a`. A 4-call C host
  (`cc host.c lib*.a libailang_rt.a`) linked and ran; `s == 67`
  assertion held; exit 0. **Compiles, links, runs, matches.**

### `examples/fieldtest/embabi1_2_leaky_integrator.ail` — leaky integrator (Float scalar-coverage axis)
- **What it does:** `leaky_step(state, sample) = state*0.5 + sample`
  over `Float`; the Float counterpart of example 1.
- **Why it fits:** the optional scalar-coverage axis — exercises the
  other M1 scalar type (`Float` → C `double`) through the same
  author → build → C-call path.
- **Outcome:** `ail check` → `ok` first try.
  `ail build --emit=staticlib` → two archives. C host with
  `extern double leaky_step(double, double)` linked and ran; exact
  `s == 1.875` assertion held; exit 0. **Compiles, links, runs,
  matches.** Author experience byte-for-byte identical to the Int
  axis.

### `examples/fieldtest/embabi1_3_export_list_rejected.ail` — list-param export (rejection axis, non-scalar)
- **What it does:** an `IntList`-folding `sum_chunk` carrying
  `(export "sum_chunk")` — the imperative-trained "pass the whole
  batch in" reflex.
- **Why it fits:** the clause-3 rejection axis — a non-scalar
  signature an author would naturally try and that M1 must reject at
  `ail check`.
- **Outcome:** `ail check` → exit 1, single diagnostic (verbatim
  below). **Correctly rejected; diagnostic is self-correcting.**

### `examples/fieldtest/embabi1_4_export_logged_counter_rejected.ail` — effectful export (rejection axis, effects-only)
- **What it does:** `tick(n) = n + 1` with a `(do io/print_str
  "tick")` trace line; signature is fully scalar `(Int)->Int` but the
  effect set is `!IO`.
- **Why it fits:** isolates the `export-has-effects` clause from the
  non-scalar clause (the public `embed_export_effectful_rejected.ail`
  mixes `Str` param + `IO`; this one is scalar-clean so only the
  effect clause can fire).
- **Outcome:** `ail check` → exit 1, single `export-has-effects`
  diagnostic. **Correctly rejected; diagnostic is self-correcting.**

## Findings

### [working] Positive scalar authoring (Int and Float) is first-try clean end-to-end
- **Examples:** 1, 2.
- **What happened:** With only DESIGN.md §"Embedding ABI (M1)",
  `form_a.md`, the public `embed_*` corpus, and `ail … --help`, both
  the Int and the Float `(State,Sample)->State` folds were authored,
  checked, built as `staticlib`, linked from a hand-written C host,
  and run with the asserted return holding — first try, no re-roll,
  no guessing on the AILang side. The `(export "<sym>")` modifier
  position (after `(export …)` in the `(fn …)` head, before
  `(type …)`) and the C scalar mapping (`Int`→`int64_t`,
  `Float`→`double`) were both unambiguous from DESIGN.md + form_a.md.
  The two-archive output (`lib<entry>.a` + `libailang_rt.a`) and the
  `-o`-is-a-directory rule were learned from `ail build --help`
  (public surface) and matched DESIGN.md's prose. The C symbol being
  author-chosen (`ema_step` / `leaky_step`, decoupled from the
  `ail_<module>_<fn>` mangling) worked exactly as documented.
- **Why working:** the new surface was reached for naturally, used,
  and correct on first try with no diagnostic needed. This is the
  capability the milestone exists to deliver, proven from the
  downstream-author position.
- **Recommended action:** carry-on.

### [working] Both clause-3 rejection diagnostics are precise and self-correcting
- **Examples:** 3, 4.
- **What happened, verbatim:**
  - Ex 3: `error: [export-non-scalar-signature] sum_chunk: export
    \`sum_chunk\`: M1 embedding ABI is scalar-only — parameter type
    \`IntList\` is not \`Int\`/\`Float\`` (exit 1).
  - Ex 4: `error: [export-has-effects] tick: export \`tick\` must be
    pure — declared effects !["IO"] are not allowed on an embedding
    boundary` (exit 1).
  Each names the code, the fn, the C symbol, the specific offending
  position (the parameter *type*; the *effect set*), the rule, and
  (for non-scalar) the allowed types. An author with no compiler
  access can self-correct from the message alone: "make the param
  scalar" / "remove the effect / drop the trace line".
- **Why working:** clause-3 ("wrong code fails to typecheck, with a
  message you can act on") is the load-bearing acceptance criterion
  for this strategic milestone, and it holds empirically with
  high-quality diagnostics.
- **Recommended action:** carry-on.

### [working] `(export)` orthogonality + zero-export staticlib guard behave exactly as spec'd
- **Examples:** out-of-fixture probes (recorded for completeness;
  no fixture kept — these are spec-conformance confirmations).
- **What happened:** (a) a module with `(export "…")` on a fn plus a
  real `main`, built/run in default exe mode, checked `ok` and ran
  with no diagnostic about the inert `(export)` — matches the spec's
  "accepted and ignored" clause. (b) `ail build --emit=staticlib` on
  a module with zero `(export)` fns → `Error: staticlib target needs
  at least one \`(export "<sym>")\` fn` (exit 1) — matches the spec's
  zero-export guard wording.
- **Why working:** confirms the field's orthogonality and
  error-path promises hold from the CLI surface.
- **Recommended action:** carry-on.

### [friction] The published authoring rule steers an LLM away from the only spelling that works for scalar export params
- **Examples:** 1, 2 (succeeded *because* I followed the public
  `embed_backtest_step.ail` fixture, not the prose rule); probes
  with `(own (con Int))` / `(borrow (con Int))` (failed).
- **What happened:** `crates/ailang-core/specs/form_a.md` "Schema
  invariants" item 1 states: "Every type in the `(params …)` clause
  of a `(fn …)` definition's `(fn-type …)` **MUST** be wrapped in
  `(own T)` or `(borrow T)`. … Implicit mode on a `(fn …)` def is
  rejected." The Pitfalls section repeats: "Wrap **every** `(fn …)`
  parameter in `(own …)` or `(borrow …)`." An LLM author reading
  form_a.md before generating (its stated purpose) would write the
  M1 export as `(params (own (con Int)) (own (con Int)))`. That
  yields, verbatim:
  `error: [use-after-consume] step: \`sample\` is used after it was
  already consumed` (the scalar is read twice in `sample*sample`).
  Switching to `(borrow (con Int))` instead yields three
  `error: [consume-while-borrowed] step: \`…\` is consumed while a
  borrow of it is still live`. The *only* spelling that checks is
  bare `(con Int)` — precisely the spelling form_a.md item 1 and the
  Pitfalls bullet tell the author is "rejected" / must be wrapped.
  The headline M1 deliverable is "an LLM, asked to make a scalar
  fold callable, writes the export"; the published authoring spec
  the LLM consults first actively misdirects that exact task, and
  the resulting `use-after-consume` / `consume-while-borrowed`
  diagnostics point at the body, not at the real cause (scalars
  must not carry a mode).
- **Why friction:** it compiles and runs once the author copies the
  public fixture's bare-scalar form, but the documented authoring
  rule forces a wrong first attempt and an unrelated-looking
  linearity diagnostic — exactly the re-roll cost form_a.md claims to
  remove. M1 did not introduce scalar-mode behaviour but made it
  acute: this is now the surface of the milestone's headline task.
- **Recommended action:** plan (tidy iteration) — reconcile
  `form_a.md` item 1 + the Pitfalls bullet with actual behaviour by
  stating the scalar-type carve-out for *parameters* (form_a.md
  already carves the *return* type for non-heap-shaped types; the
  symmetric parameter carve-out is missing). DESIGN.md
  §"Embedding ABI (M1)" should also show the canonical export param
  spelling so the M1 author has an authoritative bare-scalar example
  outside the example corpus.

### [spec_gap] form_a.md mandates a mode on every fn param; scalar params accept (and require) no mode — the doc is unqualified
- **Examples:** non-exported probe `(fn plain (params (con Int) (con
  Int)) …)` → `ail check` `ok` exit 0.
- **What happened:** the friction above has a doc-level root that is
  not export-specific: `form_a.md` "Schema invariants" item 1 is
  written as an unconditional universal ("MUST be wrapped … Implicit
  mode on a `(fn …)` def is rejected"). Behaviour: a *plain*
  non-exported fn with bare `(con Int)` params checks `ok`. So the
  published invariant overstates the rule for scalar parameter
  positions, with no stated exemption (the return-type sentence
  carves out `Int`/`Bool`/`Unit`/`Str` for *returns* only). An LLM
  author cannot derive the true rule ("scalars take no mode;
  heap-shaped params take `own`/`borrow`") from the public docs —
  it has to be reverse-engineered from the example corpus, which the
  Iron Law explicitly forbids treating as a behaviour oracle.
- **Why spec_gap:** the canonical authoring spec does not constrain
  the case correctly; two readings (literal "every param needs a
  mode" vs. corpus-implied "scalars are bare") are both supportable
  from the public text, and the compiler picks the second.
- **Recommended action:** tighten `form_a.md` (and mirror the rule
  in DESIGN.md if DESIGN.md is silent on scalar-param modes) — state
  the scalar-parameter carve-out symmetrically with the existing
  return-type carve-out. This is the doc fix that subsumes the
  friction finding's first half.

### [spec_gap] No public path emits the staticlib IR, though DESIGN.md Decision 5 makes IR readability a first-class LLM affordance
- **Examples:** `ail emit-ir examples/fieldtest/embabi1_1_ema_step.ail`
  → `Error: entry module \`embabi1_1_ema_step\` has no \`main\` def`
  (exit 1).
- **What happened:** DESIGN.md (Decision 5 / "the compiler emits
  LLVM IR as text so the LLM can read what it generated") establishes
  reading generated IR as an intended author affordance, and the M1
  spec/DESIGN.md describe the precise staticlib IR shape (`@<sym>`
  forwarder beside the unchanged `@ail_<module>_<fn>`, no `@main`).
  But `ail emit-ir --help` shows only `-o` — there is **no**
  `--emit=staticlib` flag on `emit-ir` (only `ail build` has it).
  Running `emit-ir` on a `main`-free kernel hits the executable-path
  `MissingEntryMain` rejection. An author who wants to verify the
  generated forwarder for an exported kernel — the exact thing
  Decision 5 says they should be able to do — has no documented way
  to obtain that `.ll`. Whether `ail build` leaves an intermediate
  `.ll` is not derivable from any public source and was not
  inferred from compiler internals (Iron Law); the absence of the
  affordance via the documented `emit-ir` surface is the finding.
- **Why spec_gap:** DESIGN.md asserts an affordance the M1 surface
  does not provide for the artefact M1 introduces; neither DESIGN.md
  nor `--help` resolves how an author inspects a kernel's IR.
- **Recommended action:** ratify the intended behaviour — either add
  `--emit=staticlib` to `ail emit-ir` (symmetry with `ail build`) or
  add one sentence to DESIGN.md §"Embedding ABI (M1)" stating how a
  kernel's IR is obtained (e.g. a documented `ail build` artefact).
  Orchestrator triage: `ratify` vs. tighten DESIGN.md.

## Recommendation summary

| Finding | Class | Action |
|---|---|---|
| Positive Int+Float authoring first-try clean E2E | working | carry-on |
| Both clause-3 rejection diagnostics precise & self-correcting | working | carry-on |
| `(export)` orthogonality + zero-export guard match spec | working | carry-on |
| form_a.md mode rule misdirects the headline export task | friction | plan (tidy iteration) |
| form_a.md "every param needs a mode" unqualified vs. scalar behaviour | spec_gap | tighten `form_a.md` / DESIGN.md |
| No public path emits the staticlib kernel IR vs. Decision 5 | spec_gap | ratify or tighten DESIGN.md |
