Files
AILang/docs/specs/0041-fieldtest-embedding-abi-m1.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

13 KiB

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 checkok 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 checkok 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 (Intint64_t, Floatdouble) 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 "")` 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 insample*sample). Switching to (borrow (con Int))instead yields threeerror: [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 resultinguse-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.ailError: 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