fieldtest: operator-routing-eq-ord — 5 examples, 6 findings (4 working, 1 friction, 1 spec_gap)

Post-audit field test of the operator-routing-eq-ord milestone
(closed via 5170b6a + 4d45bc6). Five `.ail` Surface-form fixtures
under `examples/fieldtest/` written by an LLM-author working
strictly from the design/ ledger + public examples (no
`crates/` / `runtime/` / `bench/` reads — Iron Law honoured):

  eqord_1_fizzbuzz.ail        — FizzBuzz [1..15] via (app eq …)
                                 on Int+Str and (app gt …) on Int
  eqord_2_rational_eq.ail     — data Rational + user-instance
                                 (class prelude.Eq) by cross-mult;
                                 inner Int-eq nested in instance body
  eqord_3_newton_sqrt.ail     — Newton's method via float_lt
                                 (convergence + fabs) and float_eq
                                 (zero-guard)
  eqord_4_float_ord_must_fail.ail — (app lt 1.5 2.5) must reject
                                     at typecheck with float_lt hint
  eqord_5_float_eq_must_fail.ail  — (app eq 1.5 1.5) must reject
                                     at typecheck with float_eq hint

Findings:

  [working] x4 — primitive Eq/Ord at Int/Bool/Str/Unit;
    user-ADT Eq with nested primitive eq calls; Float named-fn
    surface (float_eq/float_lt/etc.); NoInstance Eq/Ord Float
    diagnostic with float_eq / float_lt addendum. The milestone's
    central thesis (class-dispatch is the only comparison
    surface; Float opts out via named fns) is LLM-natural —
    every (app eq …) / (app gt …) / (app float_lt …) call I
    wrote compiled on first try with no diagnostic friction.

  [spec_gap] x1 — `docs/specs/2026-05-20-operator-routing-eq-ord.md:113`
    north-star example writes bare `(class Eq)` where the
    language requires `(class prelude.Eq)`. An LLM-author who
    copies the spec verbatim hits `bare-cross-module-class-ref`.
    The milestone spec is the highest-information reference an
    LLM-author consults; the bare-vs-qualified drift mis-primes
    the pattern. Resolution: tighten-the-design-ledger — fix
    the spec example inline (separate follow-up commit). The
    existing `examples/eq_ord_user_adt.ail` already uses the
    qualified form, so the corpus is consistent; only the spec
    drifted.

  [friction] x1 — `(app compare 1.5 2.5)` at Float fires the
    same diagnostic as `(app lt …)` at Float, naming
    `float_eq / float_lt (and siblings)` as the alternative.
    But the alternatives don't return Ordering; an LLM-author
    who wanted three-way LT/EQ/GT can't satisfy that with
    float_lt + float_eq alone. The spec (lines 433-436)
    acknowledged this case in commentary — "no float_compare
    ships; build it from float_lt + float_eq if you need
    three-way" — but the diagnostic doesn't say so. Resolution:
    file as Gitea backlog issue for a follow-up tidy iteration
    (codegen-side: branch the Float-aware NoInstance addendum
    on the called method-name; the `compare` arm gets a
    one-sentence "no float_compare; build it from float_lt +
    float_eq if you need three-way" addendum).

Status: clean — no bugs, no blockers, the milestone surface is
solidly LLM-usable. Both non-working findings are tractable
forward-fixes that don't disturb the milestone's core
contracts.

Spec: docs/specs/2026-05-21-fieldtest-operator-routing-eq-ord.md
This commit is contained in:
2026-05-21 01:34:19 +02:00
parent 4d45bc6c56
commit 505eb8484e
6 changed files with 470 additions and 0 deletions
@@ -0,0 +1,241 @@
# Fieldtest — operator-routing-eq-ord — 2026-05-21
**Status:** Draft — awaiting orchestrator triage
**Author:** ailang-fieldtester (dispatched by skills/fieldtest)
## Scope
The `operator-routing-eq-ord` milestone removed the surface comparator
names `==` / `!=` / `<` / `<=` / `>` / `>=` from the language. Equality
and ordering are now class methods that resolve via `prelude.Eq` /
`prelude.Ord` class-dispatch — `eq` / `ne` / `lt` / `le` / `gt` / `ge` /
`compare` over the primitive instances (Int, Bool, Str, Unit) plus any
user ADT with a hand-written instance. Float has no Eq/Ord instance by
design; the six monomorphic prelude fns `float_eq` / `float_ne` /
`float_lt` / `float_le` / `float_gt` / `float_ge` are the explicit
named-fn surface for IEEE-aware Float comparison, each lowered to a
single `fcmp` via the codegen intercept with `alwaysinline`.
## Examples
### `examples/fieldtest/eqord_1_fizzbuzz.ail` — FizzBuzz over [1..15]
- **What it does:** classic FizzBuzz, dispatching equality on
`(app eq (app % n K) 0)` for K in {3, 5, 15}, plus the empty-string
guard `(app eq label "")`, plus a tail-recursive loop driver bounded
by `(app gt i stop)`.
- **Why it fits:** exercises the primitive Eq path at Int and Str
simultaneously, plus the Ord-class free helper `gt` at Int — the
three most-reached-for shapes in everyday integer code.
- **Outcome:** compiles, runs, matches expected stdout
(`1\n2\nFizz\n4\nBuzz\nFizz\n7\n8\nFizz\nBuzz\n11\nFizz\n13\n14\nFizzBuzz\n`).
### `examples/fieldtest/eqord_2_rational_eq.ail` — Rational with hand-written Eq
- **What it does:** defines `data Rational (ctor MkRational Int Int)`,
writes `instance prelude.Eq Rational` using cross-multiplication
(`a/b == c/d` iff `a*d == b*c`), drives four comparisons in `main`.
- **Why it fits:** Axis 2 north-star. The user-instance body's inner
`(app eq (app * a d) (app * b c))` dispatches to `prelude.eq__Int`,
while the outer `(app eq r1 r2)` from main dispatches to this
module's `eq__Rational`. End-to-end: user-instance → prelude class
method → primitive intercept.
- **Outcome:** compiles, runs, matches expected stdout
(`true\nfalse\ntrue\nfalse\n`).
### `examples/fieldtest/eqord_3_newton_sqrt.ail` — Newton's method via float_lt / float_eq
- **What it does:** Newton-Raphson iteration `x_{k+1} = (x_k + n/x_k) / 2`
to compute sqrt. Convergence loop guards on
`(app float_lt (app fabs (app - xnew x)) tol)`; pathological-zero
guard on `(app float_eq n 0.0)`. `fabs` implemented inline as
`if (app float_lt x 0.0) then -x else x`.
- **Why it fits:** Axis 3 north-star. Demonstrates the LLM-natural
shape of the Float-comparison named-fn family in numerical code.
Mirrors how Rust/Python/Swift guide users toward bespoke Float-aware
methods (`f64::abs`, `math.isclose`).
- **Outcome:** compiles, runs, matches expected stdout
(`2.0\n3.16228\n0.0\n1.0\n`). The Show Float rendering of
`3.16228` is libc-`%g`-shaped per `float-semantics.md` (the exact
IEEE-correct value 3.1622776601683795 truncates in the default
Show width). Acceptable.
### `examples/fieldtest/eqord_4_float_ord_must_fail.ail` — Ord at Float rejection
- **What it does:** single-statement program
`(app print (if (app lt 1.5 2.5) 1 0))`. Must fail at `ail check`.
- **Why it fits:** Axis 4 north-star. Verifies the diagnostic that
steers an LLM-author from the polymorphic `lt` (an Ord-class free
helper) toward the named-fn `float_lt` when the inferred operand
type is Float.
- **Outcome:** `ail check` exits 1 with stderr:
```
error: [no-instance] main: fn `main` calls `compare` requiring
`instance prelude.Ord Float`, but no such instance exists in the
workspace — Float has no Eq/Ord instance by design (partial
orderability per IEEE-754); use float_eq / float_lt (and siblings
float_ne / float_le / float_gt / float_ge) for explicit IEEE-aware
comparison. See design/contracts/float-semantics.md.
```
### `examples/fieldtest/eqord_5_float_eq_must_fail.ail` — Eq at Float rejection (companion)
- **What it does:** single-statement program
`(app print (if (app eq 1.5 1.5) 1 0))`. Must fail at `ail check`.
- **Why it fits:** Axis 4 companion. Verifies the Eq-class half of the
Float diagnostic.
- **Outcome:** `ail check` exits 1 with stderr substituting `Eq` for
`Ord` and `eq` for `compare`; rest of the addendum identical.
## Findings
### [working] Primitive Eq/Ord at Int/Bool/Str/Unit is friction-free
- **Example(s):** eqord_1_fizzbuzz (Int eq, Str eq, Int gt),
eqord_2_rational_eq (Int eq inside ADT-instance body).
- **What happened:** every `(app eq …)` and `(app gt …)` call I
wrote compiled on first try, lowered correctly, and produced the
expected output. No friction in the surface shape, no diagnostic
needed.
- **Why it's `working`:** the milestone's central thesis (class-dispatch
is the surface for all comparison) holds — there is no second
thought during authoring about which spelling to use, and no
ambiguity about which primitive instance dispatches. The FizzBuzz
fixture is the natural form an LLM would emit on the prompt
"write FizzBuzz in AILang".
- **Recommended downstream action:** carry-on. Worth keeping the
fixtures around as regression protection against any future
re-introduction of operator-name surface.
### [working] User-ADT Eq with nested primitive eq calls Just Works
- **Example(s):** eqord_2_rational_eq.
- **What happened:** the `instance prelude.Eq Rational` body wrote
itself naturally — match both operands, cross-multiply, compare
with inner `(app eq …)` on Int. No coercion ceremony, no manual
qualifier on the inner call (the inner `eq` resolves through the
same class-dispatch as the outer, just at a different inferred
type). The end-to-end run produced the correct cross-multiplication
results.
- **Why it's `working`:** this is the milestone's clause-1 evidence —
the form was structurally impossible before the milestone (the old
built-in `==` rejected user ADTs at codegen with a generic error;
even after writing `instance Eq Point`, the inner `(app eq a1 a2)`
on Int would route via the dead polymorphic-`==` path with no
visible gain). Post-milestone, the LLM-author's mental model
("classes do equality") matches the implementation surface 1:1.
- **Recommended downstream action:** carry-on.
### [working] Float-comparison named-fn surface is LLM-natural
- **Example(s):** eqord_3_newton_sqrt.
- **What happened:** writing `(app float_lt x 0.0)`, `(app float_eq n 0.0)`,
and `(app float_lt (app fabs …) tol)` felt entirely natural while
composing the Newton iteration. The named-fn surface is no more
verbose than the deleted operator surface (`(app < x 0.0)` was three
tokens, `(app float_lt x 0.0)` is three tokens), and the explicit
`float_` prefix is an active reminder during authoring that this is
an IEEE-partial operation rather than a structural-equality one.
- **Why it's `working`:** the spec's clause-3 framing — "the
NaN-comparison anti-pattern stays visible in code as `float_eq`
rather than hiding behind `eq`" — held up in practice. Newton's
method is exactly the kind of numerical code where mistaking
Float-eq for structural-eq would cause real bugs; the explicit
spelling made me pause and think about whether `float_eq n 0.0` was
the right zero-test (it is, because `-0.0 == 0.0` per IEEE, which
is the correct guard).
- **Recommended downstream action:** carry-on.
### [working] NoInstance Eq/Ord Float diagnostic is informative and actionable
- **Example(s):** eqord_4_float_ord_must_fail, eqord_5_float_eq_must_fail.
- **What happened:** both `ail check` invocations exit 1; both
diagnostics name the right class (`Eq` vs `Ord`), the right method
call site (`fn main calls eq` / `calls compare` — note the Ord
helper `lt` is reported via its desugar to `compare`), the named-fn
family `float_eq / float_lt (and siblings …)`, the IEEE-754 design
rationale, and the contract path
`design/contracts/float-semantics.md`. An LLM-author who hits this
diagnostic learns the entire replacement surface in one read.
- **Why it's `working`:** this is the milestone's clause-2 evidence —
the diagnostic carries enough information for an LLM that has only
the stderr to converge on the right call site without re-reading
the spec. Acceptance-criterion #3 ("Float-aware hint pointing at
`float_eq`") is comfortably met.
- **Recommended downstream action:** carry-on. The diagnostic is
the load-bearing surface for clean-break removal of the operator
names; protect it with the existing `eq_float_noinstance.rs` test.
### [spec_gap] Spec's own example program uses bare `(class Eq)` but the language requires `(class prelude.Eq)`
- **Example(s):** confirmed by a /tmp probe; my own
`eqord_2_rational_eq.ail` writes `(class prelude.Eq)` because I
copied the pattern from the existing `eq_ord_user_adt.ail`, not
from the spec.
- **What happened:** the design spec at
`docs/specs/2026-05-20-operator-routing-eq-ord.md:113` writes
`(class Eq)` (bare) in the §"Concrete code shapes" north-star
example program. An LLM-author who copies that pattern verbatim
hits the diagnostic:
```
error: [bare-cross-module-class-ref] module `` contains bare class
name `Eq` that does not resolve to a local class; candidates from
imports: ["prelude.Eq"]
```
The diagnostic is excellent (lists the qualified candidate), but
the spec primed the wrong pattern.
- **Why it's `spec_gap`:** the milestone spec is the primary
reference for the milestone-defining surface; if the spec's own
example doesn't typecheck verbatim, then either the spec is wrong
(most likely) or bare class names are a feature the language should
in fact support (unlikely — qualified is the established style per
every existing fixture). The honest action is to update the spec's
example to use `(class prelude.Eq)`. This is one-line of doc
drift, but it is the highest-information reference an LLM-author
consults when picking up the milestone surface.
- **Recommended downstream action:** tighten the design ledger — fix
the example in `docs/specs/2026-05-20-operator-routing-eq-ord.md`
(lines 109-133) to use `(class prelude.Eq)`, and consider whether
the same drift exists in `design/contracts/prelude-classes.md` or
`design/models/typeclasses.md`. A single forward-fix doc commit.
### [friction] `(app compare …)` at Float diagnostic does not acknowledge there is no `float_compare`
- **Example(s):** /tmp probe writing
`(app compare 1.5 2.5)` inside a match-on-Ordering.
- **What happened:** the diagnostic is identical to the `(app lt …)`
diagnostic — names `float_eq / float_lt (and siblings …)`. None of
the suggested replacements give the three-way LT/EQ/GT result that
`compare` returns. An LLM-author who specifically reached for
`compare` because they want to drive an `Ordering`-match on Float
reads this diagnostic and is left guessing whether they should
pattern on `float_lt` + `float_eq` themselves to reconstruct the
three-way decision, or whether some other surface exists.
- **Why it's `friction`:** the spec at lines 433-436 acknowledged
this case explicitly ("`compare`/`Ord` use case names `float_lt` /
`float_compare` (the latter doesn't ship as a fn …) — so the hint
says 'use float_lt / float_eq for explicit IEEE-aware comparison'").
So the absence of `float_compare` is deliberate. But the
diagnostic doesn't tell the author *that* — it just lists the
replacements without explaining the gap. A one-line addendum
("note: there is no `float_compare` because IEEE-754 ordering is
partial; build the three-way decision from `float_lt` + `float_eq`
if you need it") would close the gap without changing any code
path.
- **Recommended downstream action:** plan a tidy iteration that
extends the float-aware diagnostic to differentiate on the called
method — when the call site is `compare`, append the
"no float_compare exists, build it from float_lt + float_eq"
sentence. Scoped to one branch in the diagnostic addendum, no
contract change.
## Recommendation summary
| Finding | Class | Action |
|---|---|---|
| Primitive Eq/Ord at Int/Bool/Str/Unit friction-free | working | carry-on |
| User-ADT Eq with nested primitive eq Just Works | working | carry-on |
| Float-comparison named-fn surface is LLM-natural | working | carry-on |
| NoInstance Eq/Ord Float diagnostic is informative | working | carry-on |
| Spec example uses bare `(class Eq)` but language needs `(class prelude.Eq)` | spec_gap | tighten the design ledger (doc fix) |
| `(app compare …)` at Float diagnostic doesn't name the missing `float_compare` | friction | plan a tidy iteration (diagnostic addendum) |