The inline generator was owed the want the form already knew

(array-gen [3 4] (fn [i j] ...)) — the canonical form — was refused:
check_fn saw no (Fn ...) want and no position to take types from. But the
form knows them: one i32 index per dimension is the rank's own promise.
check_array_gen now hands an inline fn its parameter types directly, with
the annotated element type as the return want where the annotation reaches
that deep, and the return left for the body to say where it does not — so
a bare inline fn infers its element type the way a fill value does, and a
body that disagrees with an annotated element is reported at the
generator's answer, per element. Named defn generators check as before.

check_fn grows a ?gen way in for exactly this: parameter types without a
Fn want, return optional. An inferred-return body sees Unit as ctx.ret, a
rough edge left rough on purpose.

Pins: inline at rank 1 and 2, inferred element, annotated defvar, the
per-element mismatch, inline arity. The acceptance program gains the
inline form, a struct-valued fill (the per-element store is a struct
copy), and evaluated-once (a counting fill value called one time for four
elements) — riding the three existing rows, no new ones. And the FIX.org
entry the pass never wrote: dims by the [n T] rule, one index per
dimension, the Zero+While/Set/Pindex lowering with no backend edits,
composition by nesting the forms, and this fix.
This commit is contained in:
Joseph Ferano 2026-09-20 21:34:14 +07:00
parent d4def945a9
commit 47b3ceb878
5 changed files with 178 additions and 32 deletions

47
FIX.org
View File

@ -1978,3 +1978,50 @@ message at a *new* site — check_fn's empty-body refusal — and rewrote none.
parse.ml edits are structural: a dropped guard in ~when~, a dropped guard in
~fn~, a new arm at the top of ~dmap~. Expect a rebase, not a conflict of
intent.
* (array-fill [n ...] v) and (array-gen [n ...] f), 2026-09-20
DISCUSS.org asked for a value-producing array constructor: =(array n T)= is
the zeroed array and =dotimes= is Unit, so "an array of these" had no spelling
that could stand where an expression must — a defvar's initialiser being the
line the note was written about. These two are that expression, at any rank.
The dimensions sit in brackets and are the same compile-time lengths the
=[n T]= type spelling takes — an integer literal or a defconst's name, one
rule in one place (=array_len=) — with one extra condition the type spelling
does not need: a dimension has to fit an i32, because every index in the
language is an i32 and so is the loop that writes the elements.
The generator is a function value called once per element with one i32 index
per dimension, first dimension's index first, and its return type is the
element type. Row-major order is pinned as a promise, and the fill value and
the generator *value* are each evaluated once, before any loop runs — =(array-fill
[n] (next-id))= is one call and n copies of its answer.
The lowering is want-driven and reaches no backend: bind a slot, =Zero= it,
one =While= per dimension writing each element through =Set= of a =Pindex=,
answer the slot. Those are nodes both backends already had, so LLVM, x86 and
the js one all get the form with no edit. The annotation's element type is
threaded down as the want, so a fill value that disagrees with =[rows [cols
u8]]= is reported at the value in expected/found words, not as a whole-array
mismatch.
Composition is the ordinary kind: =(array-fill [2] (array-fill [3] 7))= is an
array whose fill value is an array, and it works because the inner form is
just an expression in the value slot. What does *not* exist is a nested
bracket syntax — =[2 [3]]= as a dimension list means nothing; ranks are
spelled flat, =(array-fill [2 3] 7)=.
** The inline fn, and the want it was owed
=(array-gen [3 4] (fn [i j] ...))= — the canonical form — was refused at
first: an fn takes its types from its position, this position carried no
=(Fn ...)= want, and =check_fn= answered "nothing here says what this fn's
parameters are". But the form *does* say: one i32 per dimension is the rank's
own promise. =check_array_gen= now hands an inline fn its parameter types
directly, with the annotated element type as the return want where the
annotation reaches that deep, and with the return left to the body where it
does not — so a bare =(array-gen [3] (fn [i] (* i i)))= infers =[3 i32]= the
same way a fill value infers its element. A body that disagrees with an
annotated element type is reported at the generator's answer — expected u8,
found f64, caret on the offending expression — per element, not per array.
Named defn generators check exactly as before, arity and index types in
array-gen's own words.

View File

@ -2908,38 +2908,53 @@ and block ctx ?want ?(defer_ok = false) loc body =
checkable exactly where something says what is wanted. An argument position
does, because [named_call] threads the callee's parameter type into each
argument; a bare [(let [f (fn [x] x)])] does not, and is refused saying so. *)
and check_fn ctx ~want loc (params : string list) body =
let pts, ret =
match want with
| Some (Types.Fn (ps, r)) when List.length ps = List.length params -> ps, r
| Some (Types.Fn (ps, r)) ->
fail loc
"this fn has %d parameter%s and %s was wanted here"
(List.length params)
(if List.length params = 1 then "" else "s")
(Types.to_string (Types.Fn (ps, r)))
| Some other when other <> Types.Never ->
fail loc "expected %s, found an fn" (Types.to_string other)
| _ ->
fail loc
"nothing here says what this fn's parameters are — an fn takes its \
types from the position it is written in, so it goes in an argument \
whose parameter is a (Fn [T ...] R), and a name already written as a \
defn goes anywhere"
and check_fn ctx ~want ?gen loc (params : string list) body =
(* [gen] is (array-gen ...)'s way in for an inline fn: the *form* knows the
parameter types one i32 index per dimension without there being a
[(Fn ...)] want to say so, and the return is the annotated element type,
or [None] to take the body's own. Everything else threads [want]. The
caller has already checked the arity, in its own words. *)
let pts, ret0 =
match gen with
| Some (pts, r) -> pts, r
| None ->
match want with
| Some (Types.Fn (ps, r)) when List.length ps = List.length params ->
ps, Some r
| Some (Types.Fn (ps, r)) ->
fail loc
"this fn has %d parameter%s and %s was wanted here"
(List.length params)
(if List.length params = 1 then "" else "s")
(Types.to_string (Types.Fn (ps, r)))
| Some other when other <> Types.Never ->
fail loc "expected %s, found an fn" (Types.to_string other)
| _ ->
fail loc
"nothing here says what this fn's parameters are — an fn takes its \
types from the position it is written in, so it goes in an argument \
whose parameter is a (Fn [T ...] R), and a name already written as \
a defn goes anywhere"
in
(* Its own frame and its own empty scope, with [outer] kept only so that a
reference to the enclosing function's locals is refused for the reason it
is really refused for. *)
is really refused for. When the return is being inferred the context gets
Unit provisionally a [return] inside such a body would check against
it, which is a rough edge left rough on purpose: the body of a generator
is an expression, and no machinery is built for the form nobody writes. *)
let fctx =
{ (invented_ctx ctx.env ret) with
{ (invented_ctx ctx.env (Option.value ret0 ~default:Types.Unit)) with
outer = ctx.scope; outer_what = Some "an fn"; owner = ctx.owner }
in
List.iter2
(fun n t -> ignore (bind fctx n t ~assignable:false)) params pts;
let fbody = map_lr (fun e -> check fctx e) body in
(* The same rule an ordinary defn's body follows: the last form is the
answer, and it has to be the declared return type. *)
let fbody =
answer, and it has to be the declared return type or, when nothing
declared one ([ret0] is [None]), the last form's own type *is* the
return, which is what lets a bare generator's element type be read off
its body. *)
let fbody, ret =
match List.rev fbody with
(* An fn with no body answers unit, the same as a defn whose declared
return type is () and whose body is empty. Unlike a defn it declares no
@ -2947,14 +2962,19 @@ and check_fn ctx ~want loc (params : string list) body =
the *position* names one, and a position wanting a value is the case
[Check] has to refuse. Without this the empty body would simply fall
through and the call would read a return value nothing ever wrote. *)
| [] when not (Types.equal ret Types.Unit) ->
fail loc
"an fn with no body answers (), and this one is in a position that \
wants %s write the value it should answer"
(Types.to_string ret)
| [] -> fbody
| [] ->
(match ret0 with
| Some r when not (Types.equal r Types.Unit) ->
fail loc
"an fn with no body answers (), and this one is in a position \
that wants %s write the value it should answer"
(Types.to_string r)
| _ -> fbody, Types.Unit)
| last :: rest ->
List.rev (expect fctx last.Tast.loc ~want:(Some ret) last :: rest)
(match ret0 with
| Some r ->
List.rev (expect fctx last.Tast.loc ~want:(Some r) last :: rest), r
| None -> fbody, last.Tast.ty)
in
(* Named after the function it was written in and numbered within it, which
is the handler clause's rule and is stable for the same reason: a
@ -4248,8 +4268,31 @@ and check_array_fill ctx ~want loc dims v =
and check_array_gen ctx ~want loc dims f =
let ns = array_dims ctx loc dims in
let rank = List.length ns in
let f = check ctx f in
let plural n = if n = 1 then "" else "s" in
let f =
match f.Ast.e with
(* The canonical inline form, [(array-gen [3 4] (fn [i j] ...))]. On its
own [check] would refuse the fn it takes its types from its position,
and only an argument position names them but *this* position knows
them just as well: one i32 index per dimension, and the annotated
element type as the return where the annotation reaches this deep.
With no annotation the return is left for the body to say, which is
the same inference the fill value gets. Arity is checked here so the
refusal talks about dimensions and indices, not about parameters some
(Fn ...) want expected. *)
| Ast.Fn (ps, fbody) ->
let got = List.length ps in
if got <> rank then
fail f.Ast.loc
"this array-gen has %d dimension%s, so its generator is called with \
%d index%s and this one takes %d argument%s"
rank (plural rank) rank
(if rank = 1 then "" else "es") got (plural got);
check_fn ctx ~want:None
~gen:(List.init rank (fun _ -> index_ty), array_elem_want rank want)
f.Ast.loc ps fbody
| _ -> check ctx f
in
let elem =
match f.Tast.ty with
| Types.Fn (ps, r) ->

View File

@ -30,6 +30,16 @@
(set ticks (+ ticks 1))
(- ticks 1))
;; An aggregate element: the store each loop pass writes is a struct copy.
(defstruct Cell [row i32 col i32])
;; Counts its own calls, for the evaluated-once line below.
(defvar calls i32)
(defn bump [] i32
(set calls (+ calls 1))
7)
(defn main [] i32
;; Rank 1.
(let [a (array-fill [5] 7)]
@ -77,6 +87,23 @@
(print (i32 (at copy 0 0))) (print " ")
(print (i32 (at grid 0 0))) (println "")) ; 1 255
;; The generator written in place — the canonical inline form. The brackets
;; are the only thing that says what [i] and [j] are: one i32 index per
;; dimension, and the element type is read off the body.
(let [q (array-gen [2 3] (fn [i j] (+ (* i 10) j)))]
(print (at q 0 0)) (print " ") (print (at q 1 2)) (println "")) ; 0 12
;; A struct-valued fill. The element is an aggregate, so what the loop
;; writes per element is a struct copy, on every backend.
(let [cs (array-fill [2 2] (Cell 3 4))]
(print (.row (at cs 0 0))) (print " ")
(print (.col (at cs 1 1))) (println "")) ; 3 4
;; Evaluated once: the fill *value* is bound before any loop runs, so a
;; call in that position is one call, however many elements get its answer.
(let [f (array-fill [4] (bump))]
(print (at f 3)) (print " ") (print calls) (println "")) ; 7 1
;; A zero dimension is an array with no elements, and the loop that fills it
;; runs no passes. Nothing to read, so the claim is that it compiles and the
;; program carries on.

View File

@ -392,7 +392,10 @@ let () =
generator answers the call number, so with four columns the element at
[1][0] being 4 is row-major, written as a promise rather than as
whatever the nesting happened to do. And "1 255" is the value semantics
a copy written through leaves the global alone.
a copy written through leaves the global alone. "3 4" is the
aggregate element the per-element store is a struct copy and
"7 1" is evaluated-once: the fill value's call ran one time for four
elements.
Three rows, because the fill is a loop over a slot rather than an
aggregate literal and each backend builds that loop itself: the -O0 row
@ -400,7 +403,7 @@ let () =
dev backend that emits the stores by hand. *)
(let fill_out =
"7 7\n1.5\n-1 -1\n1\n0 9 16\n0 1 100 203\n0 1 4 11\n255 255\n1 255\n\
empty ok\n"
0 12\n3 4\n7 1\nempty ok\n"
in
outputs "array-fill and array-gen" "programs/array-fill.flan" fill_out;
outputs ~opt:"-O0" "array-fill and array-gen, -O0"

View File

@ -2269,6 +2269,32 @@ let () =
(defn f [] i32 (let [a (array-gen [2] g)] 0))"
~needle:"a fixed array's element cannot be (Fn [i32] i32)";
(* The inline form, the design's canonical one. An fn normally takes its
types from a (Fn ...) want, and this position has none the *form*
supplies them instead: one i32 index per dimension, and the annotated
element type as the return where there is one. With no annotation the
element type is the body's, the same inference the fill value gets. *)
infers "array-gen takes an inline fn, rank 1"
"(array-gen [5] (fn [i] (* i i)))" "[5 i32]";
infers "array-gen takes an inline fn, rank 2"
"(array-gen [2 3] (fn [i j] (+ (* i 100) j)))" "[2 [3 i32]]";
infers "an inline generator's element type is read off its body"
"(array-gen [3] (fn [i] (i64 i)))" "[3 i64]";
accepts "an annotated defvar takes an inline generator"
"(defvar grid [2 [3 u8]] (array-gen [2 3] (fn [i j] (u8 (+ i j)))))\n\
(defn f [] i32 (i32 (at grid 1 2)))";
(* The annotated element type is the want the body is checked against, so a
disagreement is reported at the generator's answer, in the ordinary
expected/found words not as a whole-array mismatch a line up. *)
rejects_check "an inline generator's body has to answer the element type"
"(defvar grid [2 [3 u8]] (array-gen [2 3] (fn [i j] 1.5)))\n\
(defn f [] i32 0)"
~needle:"expected u8, found f64";
rejects_check "an inline generator takes one argument per dimension too"
"(defn f [] i32 (let [a (array-gen [2] (fn [i j] i))] 0))"
~needle:"this array-gen has 1 dimension, so its generator is called with \
1 index and this one takes 2 arguments";
(* ── Computed global initialisers ──────────────────────────────────
The order they run in is the compiler's to choose, so a global written
above the one it reads is fine... *)