Commit Graph

17 Commits

Author SHA1 Message Date
Brummel b636b883a3 iter 22b.2.1: add Constraint struct, Type::Forall.constraints 2026-05-09 17:34:39 +02:00
Brummel f25d7b6cd6 iter 22b.1.1: ClassDef/InstanceDef AST + downstream match arms
Adds Def::Class(ClassDef) and Def::Instance(InstanceDef) variants per
Decision 11 §"Form-A schema". All optional fields (superclass, doc,
default body) carry skip_serializing_if so canonical-JSON bytes of
pre-22b fixtures stay bit-identical.

Downstream match-on-Def sites are extended with explicit Class/Instance
arms. Behaviour for 22b.1 is placeholder (skip in typecheck/codegen,
one-line summary in pretty/manifest, placeholder marker in prose/print)
with TODO comments naming the deferred sub-iters (22b.2 typecheck,
22b.3 codegen, 22b.4 prose-projection arms).
2026-05-09 12:31:23 +02:00
Brummel 50b68267fe check: mode-strict-because suppression (iter 19b)
Closes the 19a/19a.1/19b arc. Corpus signal from 19a.1 (5/65
fixtures fire over-strict-mode, all deliberate RC codegen-test
fixtures) justified shipping the suppress mechanism end-to-end.

Schema: Suppress { code, because } on FnDef. Pre-19b hashes
bit-identical via skip_serializing_if. Typechecker drops matching
diagnostics; empty 'because' is Error severity; wrong codes are
silent no-ops (open-set registry).

Form-A: (suppress (code "...") (because "...")) clause, parser
+ printer round-trip clean. Form-B: '// @suppress <code>: <reason>'
above the doc string, lossless contract metadata.

5 RC fixtures migrated (.ail.json + .ailx + .prose.txt). Corpus
signal: 5/65 -> 0/65. Lint still fires on any future fn that's
accidentally over-strict without an authored reason.

Test counts: ailang-check 55->61, ailang-core 26->28, ailang-surface
21->26, ailang-prose 49->52, e2e 70 unchanged.

Known debt: .ailx comment headers lost on regen (ail render's
contract excludes comments); parse_suppress_attr accepts
duplicate code/because attrs without diagnose (bounded by canonical
print order).
2026-05-08 19:36:04 +02:00
Brummel ce6ab8ee44 Iter 18e: (drop-iterative) annotation + worklist allocator
Closes the 18-arc's stack-recursion limit. Recursive drop
cascades from 18c.4 overflow on long ADT chains (Linux's 8 MB
default stack maxes out around 1M cells of List). The new
opt-in (drop-iterative) annotation on a Def::Type switches the
synthesised drop_<m>_<T> body from recursive to iterative-with-
explicit-worklist for that type.

Schema:
- TypeDef.drop_iterative: bool. Default false; serde-skip
  when false so existing fixtures' canonical JSON hashes stay
  stable.
- Form-A: (drop-iterative) clause inside (data T ...).

Worklist runtime (4 new ABI symbols in runtime/rc.c):
- ailang_drop_worklist_new(initial_capacity)
- ailang_drop_worklist_push(wl, ptr)
- ailang_drop_worklist_pop(wl) -> ptr
- ailang_drop_worklist_free(wl)

Heap stretchy buffer, doubling on overflow, null-filtering on
push. Lean 4 / Roc precedent documented in the runtime; the
slot-repurposing strategy was considered and rejected because
not every box has a free pointer-typed slot to thread the
worklist through (Cons head is i64, slot 1 is ptr but it's
the field we're following — no free slot).

Codegen (emit_iterative_drop_fn_for_type): for a
drop_iterative type, drop_<m>_<T>(ptr %p) emits a worklist
loop. Fields of the SAME annotated type push onto the
worklist (mono-typed); fields of DIFFERENT types call their
own drop fn directly (recursive on those, but only if THEY
are themselves recursive — i.e. one level of cascade jump
maximum). Mono-typed-worklist is sound for the deep-self-
recursion case the iter targets (List of List of T just
needs the spine flattened).

Tests:
- examples/rc_drop_iterative_long_list — 1M-cell List of Int
  with (drop-iterative) annotation.
- alloc_rc_drop_iterative_handles_million_cell_list E2E —
  builds + runs under --alloc=rc, asserts clean exit. With
  annotation: exits 0. Without annotation (control): SIGSEGV
  at exit code 139 (verified by hand). Worklist is load-
  bearing.
- iter18e_drop_iterative_emits_worklist_body_no_self_recursion
  IR-shape: worklist body has br to loop_head AND no direct
  recursive call into drop_<m>_<T>.
- iter18e_no_annotation_keeps_recursive_drop_body — control:
  unannotated variant still emits the 18c.4 recursive shape.
- 3 surface parse-tests for the annotation round-trip.

Test deltas: e2e 58 -> 61 (+3), surface 18 -> 21 (+3). All
other buckets unchanged. cargo test --workspace green.

Known debt (deliberate):
- Mono-typed worklist: cross-type drop-iterative fields call
  the other type's drop fn directly. A heterogeneous
  worklist would be more general but adds tag tracking
  complexity for a case (drop-iterative T containing
  drop-iterative T') that's narrower than the deep-self-
  recursion target. Documented in
  emit_iterative_drop_fn_for_type's doc.
- Closure / Type::Var / Type::Forall fields fall back to
  shallow ailang_rc_dec via field_drop_call — same as the
  recursive variant.
- Dynamic-tag partial-drop fallback (head_or_zero epilogue
  shallow dec when moved_slots non-empty) — out of scope per
  brief.
2026-05-08 12:53:09 +02:00
Brummel 74379b29e5 Iter 18d.1: Term::ReuseAs schema + linearity check
Schema floor for explicit reuse hints. Adds Term::ReuseAs
{ source: Box<Term>, body: Box<Term> } with serde tag "reuse-as",
form-A (reuse-as <source> <body>). Schema choice (wrapper, not
modifier-on-Ctor) and rationale recorded in DESIGN.md.

Codegen is identity under all --alloc strategies — lower body,
drop source on the floor. Iter 18d.2 will lower this as the
in-place rewrite under --alloc=rc.

User-facing diagnostics:
- typecheck reuse-as-non-allocating-body: body must be a
  Term::Ctor or Term::Lam, the two AST shapes that allocate.
  Other shapes (literal, var, app, ...) emit this with a
  suggested_rewrite that drops the wrapper.
- linearity reuse-as-source-not-bare-var: source must be a
  Term::Var referring to an in-scope binder. Anything else
  (literal, nested expression) is rejected here. Suggested
  rewrite drops the wrapper.
- linearity use-after-consume at a reuse-as site: source must
  not have been consumed earlier in the body. Suggested rewrite
  drops the wrapper.
- Visiting reuse-as marks source consumed for the rest of the
  body, so a subsequent use of the same binder flags
  use-after-consume against it.

Uniqueness inference treats source as Consume — the consume
shows up in the side table for the codegen consumer.

Tests:
- 4 surface parse-tests (round-trip, no-args, one-arg, full
  parse_term/term_to_form_a round-trip).
- 1 typecheck unit test (non-allocating body).
- 2 linearity unit tests (non-var source, after-consume).
- 1 integration test (happy-path map_inc in all-explicit mode
  is linearity-clean).
- 1 E2E test running the canonical map_inc-via-reuse-as fixture
  under --alloc=gc, asserting stdout 9.
- New fixture examples/reuse_as_demo.{ailx,ail.json}.

Test deltas: E2E 55 -> 56, ailang-check unit 43 -> 46,
ailang-check workspace 8 -> 9, surface 14 -> 18. cargo test
--workspace green. No existing fixture's canonical JSON changed.
2026-05-08 10:55:31 +02:00
Brummel 7dfc88a4f3 Iter 18c.1: Term::Clone schema (no inc emission yet)
Adds (clone X) as a new Term variant. Form-A:
  (let p (expensive_fn x)
    (do consume_a (clone p))    ; explicit RC inc — needs 18c.3
    (do consume_b p))

Schema floor for the LLM-aware RC design (Decision 10): explicit
clone is the author-visible alternative to implicit sharing. In
18c.1 the variant is purely additive — typechecker treats it as
identity (same type as inner), codegen lowers it to the inner
term's IR with no extra calls. Iter 18c.3 will replace the
codegen identity-arm with a single call:

  Term::Clone { value } =>
      let v = self.lower_term(value, ...)?;
      self.emit("call void @ailang_rc_inc(ptr {v})");
      Ok(v)

Match-arm count: ~10 sites across 6 files, all mechanical
structural recursion through `value`. The codegen `lower_term`
arm carries an explicit comment marking the 18c.3 emission seam.

Hash invariant: `(clone X)` uses a new serde tag "clone" that no
pre-18c.1 fixture contains. Every existing fixture's canonical
JSON is bit-identical (git diff examples/ empty).

New fixture examples/clone_demo.{ailx,ail.json}:
  (let x 42 (do io/print_int (clone x)))
Stdout 42 under both --alloc=gc and --alloc=rc.

Tail-position propagates through clone (verify_tail_positions
treats `(clone tail-call)` as itself a tail call), per the
identity-of-clone semantics in 18c.1.

Verified:
- cargo build --workspace clean
- cargo test --workspace green: 52 e2e (+1), 14 surface parse
  (+2 for clone tests), all other buckets unchanged
- ail render round-trip exact
- clone_demo --alloc=gc → 42; --alloc=rc → 42
2026-05-08 02:26:12 +02:00
Brummel b9ca8894a5 Iter 18a: (borrow T) / (own T) mode annotations on fn signatures
Adds form-A surface (borrow T) / (own T) wrappers in fn-type
params and ret slots. Internally these are not new Type variants
but per-position metadata on Type::Fn:

  Type::Fn { params, param_modes, ret, ret_mode, effects }
  enum ParamMode { Implicit, Own, Borrow }

This keeps Type itself unchanged, so unification, occurs, apply,
and ~190 other Type match-arms in the typechecker need no new
branch. Fields use serde skip-if-default predicates so canonical
JSON hashes for every pre-18a fixture stay bit-identical (sum,
list, hof, closure, list_map, etc.: zero diff under git).

Iter 18a is purely additive: typechecker treats modes as
transparent (mode-compat check deferred to 18c), no codegen
change (--alloc=gc / Boehm path unchanged), no linearity
enforcement. The annotation info flows into the JSON side-table
that 18c will consume.

Equality of mode slices is length-tolerant: a vec![] (the form
written by mechanically-updated construction sites that elide
modes) compares equal to vec![Implicit; n]. This kept the patch
from being a 100-site mechanical migration through the
typechecker. 18c will choose: keep the slack, or normalise to
full-length on construction.

New fixture examples/borrow_own_demo.{ailx,ail.json} exercises
both modes. list_length declares (borrow (con List)); sum_list
declares (own (con List)). Stdout: 3 then 6.

Documentation: DESIGN.md schema-additions block clarified that
modes are Type::Fn metadata (not Type variants); migration plan
flag rename --memory=rc → --alloc=rc to match the existing CLI
flag.

Verification:
- cargo build --workspace green
- cargo test --workspace green (154 tests, +1 vs baseline 153)
- git diff examples/{sum,list,hof,closure,list_map,...}.ail.json: empty
- borrow_own_demo runtime stdout: "3\n6\n"
- canonical JSON contains "param_modes":["borrow"] and ["own"]
2026-05-08 02:07:07 +02:00
Brummel af35612c1d Iter 16d: chain-terminator via __unreachable__ builtin
Eliminates the Unit-typed chain default that 16c left in place.
Adds __unreachable__ : forall a. a — codegen lowers to LLVM
unreachable. The 16a/16c chain machinery now uses it as the
deepest fall-through, so exhaustive matches with non-Unit arms
no longer need a (case _ ...) workaround.

- check/builtins.rs: register __unreachable__ as Forall(a, a).
- codegen: Term::Var "__unreachable__" emits LLVM unreachable
  and sets block_terminated; If/Match/fn-body gate downstream
  work on that flag.
- desugar: chain default switched from Lit{Unit} to Var.
- examples/lit_pat: categorize_first's trailing _ arm removed.
  Hash changes from c4faec3abc2ed388 to 644de0c0ec15fc17.
- examples/unreachable_demo: positive fixture using safe_div
  with __unreachable__ in the impossible branch.
- e2e + desugar tests: 122 → 124 (+2).

Path-a from 16b.2 planning: real primitive over a desugar-time
exhaustiveness pre-check (which would need cross-module type
registry access from desugar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:00:23 +02:00
Brummel e5f9828a04 Iter 16b.7: nested LetRec mutual capture (params, not name)
Inner LetRec capturing outer LetRec's params now lifts cleanly via
the existing post-order traversal — outer's params are KnownType
in inner's outer-scope, so 16b.2's fast-path handles them. Inner
LetRec capturing outer's NAME remains rejected (closure-of-self
in body, deferred separately).

Plus hardening: lift.rs's deferred path tracks the outer LetRec
name in a parallel `enclosing_letrec_names` set so the symmetric
inner-captures-outer-name rejection fires there too — without it
a silent lift produced a runtime arity mismatch.

- examples/nested_let_rec.{ailx,ail.json}: outer counts down via
  inner that captures the outer's threshold param.
- desugar.rs: clarified panic message for outer-name capture.
- lift.rs: enclosing_letrec_names tracking.
- e2e + desugar tests: 120 → 122 (+2).

End-of-16b series: feature-complete except for closure-of-self
in body and closure-with-polymorphism, both queued separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:48:14 +02:00
Brummel 76d61aeced Iter 16b.6: LetRec inside polymorphic enclosing fn
Lifts the monomorphic-only restriction. Lifted top-level fn now
becomes Forall(α..., Fn(t..., T...) -> tr) when the enclosing fn
is polymorphic; capture types may mention outer type vars and
the existing mono pipeline (Iter 12b/14a) specializes them
correctly at call sites.

- desugar.rs / lift.rs: scope-building unified — Forall fn-params
  are now KnownType, not LetBound. Lift wraps augmented_ty in
  Forall mirroring the enclosing fn.
- Name-as-value-in-in-term in a polymorphic enclosing fn is
  rejected (eta-Lam wrap from 16b.5 has no Forall). Queued
  separately as closure-conversion-with-polymorphism.
- examples/poly_rec_capture.{ailx,ail.json}: apply_n_times :
  Forall(a). drives at Int and Bool to exercise two mono
  instantiations.
- e2e + check + desugar tests: 116 → 120 (+4).

Codegen pipeline untouched — apply_subst_to_type substitutes
through Fn.params uniformly (original params + appended captures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:39:28 +02:00
Brummel ca507c9f52 Iter 16b.5: LetRec name as value (in_term only) via eta-Lam wrapper
Drops the direct-call-only restriction on the LetRec name when it
appears in `in_term`. No new ABI: the lift wraps the in-term in
`(let f (lam P (app f$lr_N P CAPTURES)) in_term')` so codegen's
existing Iter-8b closure-pair machinery handles it. Body-position
name-as-value still rejected (deferred — eta-of-self is harder).

- desugar.rs / lift.rs: split find_non_callee_use into body
  (panic) and in_term (wrap).
- examples/local_rec_as_value.{ailx,ail.json}: no-capture path
  (factorial passed to apply5 → 120).
- examples/local_rec_as_value_capture.{ailx,ail.json}: capture
  path; lifted fn has captures, eta-Lam picks them up via 8b.
- e2e + desugar tests: 113 → 116 (+3).

Codegen Lam machinery handled the wrapped form on the first try.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:27:21 +02:00
Brummel c0668178bb Iter 16b.4: LetRec captures of match-arm pattern bindings
Flips the desugar's MatchArm-capture rejection from panic to defer.
The 16b.3 lift_letrecs pass already handled match-arm bindings via
type_check_pattern_for_lift (Pattern::Var, Pattern::Ctor with sub-
patterns, ctor-field substitution against scrutinee args), so this
iter is a single-line classification change in desugar plus the
fixture and tests that exercise it.

- desugar.rs: MatchArm classification now defers (same arm as
  LetBound); EnclosingLetRec panic remains for 16b.7.
- examples/local_rec_match_capture.{ailx,ail.json}: enclosing fn
  pattern-matches on Pair<Int,Int>, inner LetRec captures both
  match-arm bindings simultaneously. Lifts to
  loop$lr_0(i: Int, threshold: Int, n: Int) -> Int.
- e2e + check + desugar tests: 110 → 113 (+3).

First fixture lifting more than one capture; subst_call_with_extras
already handled it generically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:16:12 +02:00
Brummel ca30606aec Iter 16b.3: post-typecheck lift for LetRec with Let-bound captures
Adds path-2 from the 16b.2 planning entry. Desugar now defers
LetRecs whose captures include Term::Let-bound names; a new
ailang-check::lift_letrecs pass runs after typecheck and uses the
elaborated env to resolve capture types. ailang-check learns a real
Term::LetRec typing rule in synth and verify_tail_positions.

- desugar: Term::LetRec arm gains a defer-arm; helpers promoted to
  pub for reuse by the lift pass; find_non_callee_use moved before
  classification.
- ailang-check::synth/verify_tail_positions: real LetRec rules
  (effect-subset, locals install, recursive name in body+in_term).
- ailang-check::lift.rs (new, 720 LOC): post-typecheck lift with
  post-order traversal, env-walk for capture-type resolution,
  fast-path skip when no LetRec is present.
- ail::main.rs: build path now does load → check → desugar →
  lift_letrecs → codegen.
- examples/local_rec_let_capture.{ailx,ail.json}: new fixture
  capturing a let-bound `threshold` in a recursive helper.
- e2e + check unit tests: 106 → 110 (+4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:07:05 +02:00
Brummel d5f63bc3e5 Iter 16b.2 — LetRec captures of fn-params (path-1 safe subset)
Lift 16b.1's no-capture restriction. The desugar pass's `scope`
becomes a `BTreeMap<String, ScopeEntry>` carrying `KnownType` for
fn/Lam-params, sentinels for Let-/Match-bindings. LetRec captures
of `KnownType` names are lifted: the synthetic top-level fn's
signature gets the capture types appended, and every call site of
the LetRec name is rewritten via `subst_call_with_extras` to pass
the captures positionally. Captures from let-bindings, match-arm
patterns, polymorphic enclosing fns, and name-as-value uses are
rejected at desugar with panics pointing at 16b.3-16b.7.

Demo: `sum_below(n)` recurses via `(let-rec loop (params i) ...
(body ... uses n ...) (in (app loop 1)))`. Lifts to
`loop$lr_0(i: Int, n: Int) -> Int` with `(app loop X)` rewritten
to `(app loop$lr_0 X n)`. Outputs 0, 10, 45.

Tests: 103 -> 106 (+1 e2e local_rec_capture_demo, +2 desugar unit;
the 16b.1 capture-panic test was repurposed into the positive
fn-param-capture test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:00:49 +02:00
Brummel 4683af6114 Iter 16c — literal patterns via desugar
Desugar `Pattern::Lit` (top-level and nested in Ctor) to
`Term::If { cond = (== sv lit), then = body, else_ = fall_k }`.
After 16c, no Pattern::Lit survives the desugar pass; codegen
and typechecker never see one. `is_flat` reclassifies Lit as
non-flat to force the chain machinery; new `build_eq` helper
constructs the equality test per literal kind.

Tests: 99 → 103 (+1 e2e lit_pat_demo, +3 desugar unit). Demo
fixture exercises top-level lit arms (classify) and nested
lit-in-Ctor (categorize_first) with a local IntList; output
100/200/999/-1/0/7.

Known limitations documented in DESIGN.md and JOURNAL: (a)
chain machinery's Unit terminator means non-Wild-terminated
exhaustive matches with lit arms still need a trailing `_`
catch-all to type-check; (b) `==` is currently Int-only, so
Bool/Str/Unit lit patterns desugar correctly but error at
typecheck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:47:21 +02:00
Brummel 8860600e37 Iter 16b.1 — local recursive let (no-capture)
Add `(let-rec NAME (params ...) (type ...) (body ...) (in ...))`
as a form-A surface and a `Term::LetRec` AST variant. The 16a
desugar pass lifts each LetRec whose body has no captures from
the enclosing scope to a synthetic top-level fn `<hint>$lr_N`
and substitutes the original name; typecheck and codegen never
see LetRec. Capture detection panics at desugar time, queued
for 16b.2.

Tests: 95 → 99 (+1 e2e local_rec_factorial_demo, +2 desugar
unit, +1 parse unit). The new fixture `examples/local_rec_demo`
runs `fact` at n=1, 3, 5 → prints 1, 6, 120.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:33:48 +02:00
Brummel 0e90709a94 Iter 16a: nested constructor patterns via AST desugaring
Lifts the gate that rejected `(pat-ctor Cons a (pat-ctor Cons b _))`.
Approach: a pure AST → AST pass `ailang_core::desugar::desugar_module`
that flattens nested-Ctor sub-patterns into chains of single-level
matches with let-bound fresh vars and duplicate fall-through. Both
ailang-check and ailang-codegen call the pass at pipeline entry.
Hash-relevant canonical bytes are untouched: the pass runs after
load_module, in memory only. `check`'s returned CheckedModule.symbols
still carries hashes of the original defs so `ail diff` / `ail manifest`
see on-disk identities.

Lit sub-patterns inside a Ctor still rejected — that's the narrower
scope of `nested-ctor-pattern-not-allowed` post-16a; the variant doc
reflects the new meaning.

Fresh-name safety: `$` is a valid ident character in form (A), so
the Desugarer pre-walks every Term::Var / Pattern::Var name into a
BTreeSet and bumps its counter past any user-spelled `$mp_N`.

Already-flat matches go through `is_flat()` and emit identical AST
shapes; every existing fixture produces the same output as before.

New: examples/nested_pat.{ailx,ail.json} prints 30 from
first_two_sum on [10, 20, 30]. e2e test
`nested_ctor_pattern_first_two_sum` guards the path.

Tests: 92/92 (e2e 32→33, ailang-core unit 10→12).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:10:01 +02:00