flan/lib/prelude.ml
Joseph Ferano db7be70f7d Rounding from the one mode the language has, and sqrt from libm
floor, ceil and round over f32, which is what a position and a tile coordinate
are here. The only rounding mode available is the cast's truncation toward
zero, so each of these is that cast plus the correction the mode does not
make, and the content is which inputs make the cast itself undefined. NaN
fails every comparison, so it needs its own (not (= x x)) and nothing else
finds it; the infinities fall out of the magnitude test; and above 2^23 an f32
has no fractional bits left, which makes returning the input there the exact
answer and also the guard that keeps the cast inside i32.

round is half away from zero, written as floor of the magnitude and mirrored.
The obvious (floor-f32 (+ x 0.5)) is wrong twice: half-up rather than
half-away, so -2.5 comes out -2, and at the largest f32 below 0.5 the addition
alone rounds to 1.0 and answers 1 for a number under a half. Both are in the
table, which is why every case there is a negative or a half.

sqrt is the decision in this commit and it goes out to libm, which is a change
to the release link and so is said out loud. Every other number in the prelude
is reachable from the four operations and a cast; a square root is not.
Newton's method needs a starting guess, the good guess comes from
reinterpreting the exponent bits, and the language has only value-preserving
casts - no bit-cast between f32 and u32. Without one the iteration needs a
scaling loop to normalise and still produces a result that is merely close,
which is the one thing a standard library must not hand back. IEEE-754 makes
sqrt correctly rounded, so libm's answer is the same bit pattern on native and
on wasm32; for this function the byte-identical argument points at C rather
than away from it.

The cost is -lm on every link, and its placement matters. It goes after the
objects, not in the leading flags, because --as-needed drops a library named
before the object that wants it. Worse, at -O2 LLVM folds most sqrtf calls
into the hardware instruction and the symbol never has to resolve - so this
looked linked before the flag existed and failed only at -O0, which is exactly
why the table runs both. Untested against --target=wasm32: wasi-libc ships
libm.a as a stub because the symbols live in libc, so it should be inert
there, but nothing here exercises it.

The better fix is not in this lane. llvm.sqrt.f32 as a builtin in check.ml and
emit.ml is one instruction, no symbol and no flag, and it belongs to whoever
owns the compiler.
2026-09-11 19:43:07 +07:00

415 lines
18 KiB
OCaml
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(** The milestone-2 prelude, written in Flan.
Printing is deliberately *not* a primitive (plan.org, Milestone-2
primitives): [write-stdout] is the one output primitive and everything
above it is Flan. That is what keeps a second backend cheap — a primitive
is the only thing implemented twice.
It lives here as a string rather than as a file because there is no package
loader yet; at milestone 3 it becomes an ordinary [core:] package and this
module goes away. The acceptance programs may call anything defined here.
No overloading: [print-f64] and [print-str] name the type, because
compile-time overloading before the checker is stable is how a small
language stops being one. A single [println] is milestone 5. *)
let source = {flan|
(defn print-bytes [b [u8]]
(write-stdout b))
(defn print-str [s string]
(write-stdout (bytes s)))
(defn print-f64 [x f64]
(write-stdout (f64->bytes x)))
(defn print-i64 [x i64]
(write-stdout (i64->bytes x)))
(defn newline []
(write-stdout (bytes "\n")))
;; A seeded PRNG in Flan rather than libc's, because a grid hash is only a
;; regression test if the sequence is byte-identical on native and wasm32
;; (plan.org, RNG is ours). PCG-XSH-RR 32: one u64 LCG step per draw, folded
;; down to 32 bits by an xorshift and rotated by the state's top five bits.
(defvar rand-state u64 6364136223846793005)
(defn rand-seed [seed u64]
(set rand-state (+ (* seed 6364136223846793005) 1442695040888963407)))
(defn rand-u32 [] u32
(let [s rand-state]
(set rand-state (+ (* s 6364136223846793005) 1442695040888963407))
;; The rotate is masked to 5 bits: a 32-bit shift by 32 is poison in LLVM,
;; and r = 0 is the case that would ask for it.
(let [x (u32 (>> (bit-xor (>> s 18) s) 27))
r (u32 (>> s 59))]
(bit-or (>> x r) (<< x (bit-and (- 32 r) 31))))))
;; In [0, 1). The divisor is 2^32 exactly, so the result never reaches 1.0.
(defn rand-f32 [] f32
(/ (f32 (rand-u32)) 4294967296.0))
;; Prints s and then a newline. Takes a string, not an Option or an any —
;; there is nothing to dispatch on yet.
(defn print-line [s string]
(print-str s)
(newline))
;; ── Slice algorithms, all in place ────────────────────────────────────
;;
;; Over [i32] and nothing else. There are no generics, so one of these per
;; element type is one *copy* per element type, emitted into every program;
;; i32 is the type indices, ids and tile values already have, and f32 copies
;; wait until a program actually wants them.
;;
;; A slice is ptr+len and non-owning, so these mutate the storage they were
;; handed: sorting (slice grid 4 9) sorts those five elements of grid and
;; leaves the rest alone. That is the whole reason the shape is in-place —
;; there is no allocator to return a new sequence from.
(defn swap-i32! [s [i32] i i32 j i32]
(let [t (at s i)]
(set (at s i) (at s j))
(set (at s j) t)))
(defn reverse-i32! [s [i32]]
(let [i 0
j (- (len s) 1)]
(while (< i j)
(swap-i32! s i j)
(set i (+ i 1))
(set j (- j 1)))))
;; Insertion sort: in place, no recursion, no auxiliary array and no
;; comparison function — quicksort would want a stack and mergesort a buffer,
;; and neither exists. Ascending, and stable, though with no payload type to
;; carry that is not yet observable.
(defn sort-i32! [s [i32]]
(let [i 1]
(while (< i (len s))
(let [j i]
;; `and` short-circuits, which is load-bearing: at j = 0 the left test
;; fails and (at s -1) is never evaluated, so this does not trap.
(while (and (> j 0) (> (at s (- j 1)) (at s j)))
(swap-i32! s (- j 1) j)
(set j (- j 1))))
(set i (+ i 1)))))
;; The first index holding x. None rather than -1, because Option is what the
;; language has and a sentinel index is the bug this avoids.
(defn index-of-i32 [s [i32] x i32] (Option i32)
(dotimes [i (len s)]
(when (= (at s i) x)
(return (Some i))))
None)
;; None for an empty slice: there is no least i32 that is also an honest
;; answer, and returning one would be a value the caller cannot tell from a
;; real element.
(defn min-i32 [s [i32]] (Option i32)
(if (= (len s) 0)
None
(let [m (at s 0)]
(dotimes [i (len s)]
(set m (min m (at s i))))
(Some m))))
(defn max-i32 [s [i32]] (Option i32)
(if (= (len s) 0)
None
(let [m (at s 0)]
(dotimes [i (len s)]
(set m (max m (at s i))))
(Some m))))
;; Accumulates in i64 and each element is widened explicitly — there is no
;; implicit widening anywhere in the language, and summing a screenful of i32
;; into an i32 is how a total silently wraps.
(defn sum-i32 [s [i32]] i64
(let [t (i64 0)]
(dotimes [i (len s)]
(set t (+ t (i64 (at s i)))))
t))
;; ── Bytes ─────────────────────────────────────────────────────────────
;;
;; Over [u8] and not over string, so (bytes s) is what a caller writes and one
;; copy of each serves strings and byte slices both — which is as close to a
;; generic as a language without them gets. Nothing here allocates: every
;; result is a bool, an index, or a number.
(defn bytes=? [a [u8] b [u8]] bool
(if (!= (len a) (len b))
false
(do
(dotimes [i (len a)]
(when (!= (at a i) (at b i))
(return false)))
true)))
;; The length test comes first and `and` short-circuits, so the slice is only
;; built once it is known to be in bounds — otherwise a prefix longer than the
;; string would trap rather than answer false.
(defn starts-with? [s [u8] p [u8]] bool
(and (<= (len p) (len s))
(bytes=? (slice s 0 (len p)) p)))
(defn ends-with? [s [u8] p [u8]] bool
(and (<= (len p) (len s))
(bytes=? (slice s (- (len s) (len p)) (len s)) p)))
(defn index-of-byte [s [u8] b u8] (Option i32)
(dotimes [i (len s)]
(when (= (at s i) b)
(return (Some i))))
None)
;; The whole slice is an integer, or it is None. bytes->i64 is strtoll, which
;; answers 0 for "" and for "abc" and stops at the first junk byte in "12x" —
;; three wrong answers a caller cannot tell from a real 12. This is also the
;; one that has to be Flan rather than the primitive: strtoll is locale- and
;; libc-dependent, and a parser in the language gives the same answer on
;; wasm32 as on native for the same reason rand-f32 does.
;; Overflow wraps, as all arithmetic here does; it is not reported.
(defn parse-i64 [s [u8]] (Option i64)
(let [i 0
n (i64 0)
neg false]
(when (= (len s) 0)
(return None))
(when (or (= (at s 0) \-) (= (at s 0) \+))
(set neg (= (at s 0) \-))
(set i 1))
(when (= i (len s))
(return None)) ; a lone sign is not a number
(while (< i (len s))
(let [b (at s i)]
(when (or (< b \0) (> b \9))
(return None))
(set n (+ (* n 10) (i64 (- b \0)))))
(set i (+ i 1)))
(if neg (Some (- 0 n)) (Some n))))
;; ── Numbers ───────────────────────────────────────────────────────────
;;
;; Only the ones that encode a decision. clamp is (min hi (max lo x)) over two
;; builtins and abs is (max x (- 0 x)); a wrapper over those is a function
;; emitted into every program to save a caller nothing. The one honest caveat
;; on that abs: at the least representable integer it answers itself, because
;; the negation wraps. That is what every two's-complement abs does, a
;; function here would do it too, and the only fix is not to hand it that
;; value — so it is written down rather than wrapped.
;; Zero for zero, and zero for NaN — neither is positive nor negative, so
;; neither comparison fires. A caller that needs to know which it got should
;; be testing for NaN, not reading a sign.
(defn sign-f32 [x f32] f32
(cond
(> x 0.0) 1.0
(< x 0.0) -1.0
:else 0.0))
;; Written as the weighted sum and not as a + t*(b - a): the second form does
;; not return b exactly at t = 1.0 once rounding is involved, and a position
;; that does not arrive is the bug an interpolation gets reported for.
(defn lerp [a f32 b f32 t f32] f32
(+ (* (- 1.0 t) a) (* t b)))
;; ── More of the RNG ───────────────────────────────────────────────────
;;
;; Both draw exactly one rand-u32, so the sequence a program consumes is the
;; same one; neither touches the generator.
;; [lo, hi). An empty or reversed range answers lo — a defined value rather
;; than a remainder by zero, which is immediate undefined behaviour and not a
;; wrong number. The span must fit in i32, since hi - lo is computed there.
;; One draw, and therefore modulo bias: the low (2^32 % span) values of the
;; range come up very slightly more often. Rejection sampling would remove it
;; and would consume an unpredictable number of draws, which is the one thing
;; this generator exists not to do.
(defn rand-i32-range [lo i32 hi i32] i32
(if (<= hi lo)
lo
(+ lo (i32 (% (rand-u32) (u32 (- hi lo)))))))
;; [lo, hi), because rand-f32 never reaches 1.0.
(defn rand-f32-range [lo f32 hi f32] f32
(+ lo (* (rand-f32) (- hi lo))))
;; ── Rounding, and the one thing that is not Flan ──────────────────────
;;
;; All three answer an f32 and take the f32 path, because that is what a
;; position, a tile coordinate and a velocity are here. f64 versions wait for
;; a program that wants them, for the same reason the f32 slice algorithms do.
;;
;; The cast to i32 truncates toward zero, which is the only rounding mode the
;; language has, so each of these is that cast plus the correction the mode
;; does not make. Three inputs would make the cast itself undefined and each
;; is named before it happens: NaN (which fails every comparison, so it is
;; tested for by (not (= x x)) and nothing else), and the two infinities,
;; which are caught by the magnitude test. Above 2^23 an f32 has no fractional
;; bits left at all, so returning x there is not an approximation — it is the
;; answer — and it doubles as the guard that keeps the cast inside i32.
;; Zero is returned as itself rather than through the cast, which would turn
;; -0.0 into +0.0. That is one line for a value most callers never look at,
;; and it is here because floorf is specified to return it: a sign of zero is
;; how a caller recovers which side a position approached from once the
;; magnitude has already been rounded away.
(defn floor-f32 [x f32] f32
(if (or (not (= x x))
(>= x 8388608.0)
(<= x -8388608.0)
(= x 0.0))
x
(let [t (f32 (i32 x))]
(if (> t x) (- t 1.0) t))))
;; One deviation from C's ceilf, written down rather than branched around:
;; between -1.0 and 0.0 this answers +0.0 where IEEE asks for -0.0, because
;; the outer (- 0.0 …) is a subtraction and not a negation. Nothing here reads
;; the sign of a zero; a caller that does should test the input instead.
(defn ceil-f32 [x f32] f32
(- 0.0 (floor-f32 (- 0.0 x))))
;; Half away from zero, which is C's round and not the even-tie rule: -2.5
;; goes to -3. Written as floor of the *magnitude* and mirrored, because
;; (floor-f32 (+ x 0.5)) is wrong twice over — it is half-*up* rather than
;; half-away for negatives, and at the largest f32 below 0.5 the addition
;; itself rounds to 1.0 and answers 1 for a number under a half.
(defn round-f32 [x f32] f32
(let [m (if (< x 0.0) (- 0.0 x) x)
f (floor-f32 m)
r (if (>= (- m f) 0.5) (+ f 1.0) f)]
(if (< x 0.0) (- 0.0 r) r)))
;; sqrt is the one function in this file that is not Flan, and it is a
;; `declare` rather than a body for a reason that is not laziness. Every other
;; number here is reachable from the four operations and a cast; a square root
;; is not. Newton's method needs a starting guess, a good one comes from
;; reinterpreting the exponent bits, and the language has no bit-cast between
;; f32 and u32 — only value-preserving casts. Without it the iteration needs a
;; scaling loop to normalise, converges slowly from a poor guess, and produces
;; a result that is *close*, which is exactly what a standard library must not
;; hand back. IEEE-754 makes sqrt correctly rounded, so libm's answer is the
;; same bit pattern on native and on wasm32 — the byte-identical property that
;; keeps rand-u32 in Flan is, for this one, an argument for going out to C.
;;
;; The cost is one `declare` line in every module, which LLVM drops where it
;; is unused, and one -lm on every link, which build.ml now passes. That flag
;; is not optional and not obvious: at -O2 LLVM folds most sqrtf calls into
;; the hardware instruction and nothing is left to resolve, so this appears to
;; link without it and then fails at -O0, where the call survives.
;;
;; The better fix belongs to the compiler and not here: llvm.sqrt.f32 as a
;; builtin in check.ml and emit.ml is one instruction with no symbol at all.
(declare sqrt-f32 [x f32] f32 "sqrtf")
;; ── Byte classes ──────────────────────────────────────────────────────
;;
;; ASCII only, and deliberately: a byte is a byte here, there is no code point
;; type, and a UTF-8 continuation byte is not a digit under any locale. Both
;; exist because something below needs them — parse-f64 the first, trim the
;; second — and both are what a caller writing a tokenizer reaches for anyway.
(defn digit? [b u8] bool
(and (>= b \0) (<= b \9)))
(defn space? [b u8] bool
(or (= b \space) (= b \tab) (= b \newline) (= b \return)))
;; ── More of the bytes family ──────────────────────────────────────────
;; Substring search, first occurrence. The length test is first and returns
;; before the loop, so a needle longer than the haystack answers None rather
;; than building a slice that runs off the end. An empty needle is Some 0,
;; which is the answer that makes (index-of-bytes s p) agree with
;; (starts-with? s p) on every p.
;;
;; Naive, O(n·m), and that is the deliberate choice: BoyerMoore wants a skip
;; table, which is an array sized by the needle, which is an allocation.
(defn index-of-bytes [s [u8] p [u8]] (Option i32)
(when (> (len p) (len s))
(return None))
(let [last (- (len s) (len p))
i 0]
(while (<= i last)
(when (bytes=? (slice s i (+ i (len p))) p)
(return (Some i)))
(set i (+ i 1))))
None)
;; Returns a slice *of the input*, which is the whole reason trim can exist
;; without an allocator: there is no new storage, only a narrower view of the
;; caller's. It follows that the result dies with its owner, and that trimming
;; does not modify anything.
;;
;; The two loops both test (< lo hi), so an all-whitespace input walks lo up
;; to hi and stops there, and the result is the empty slice. Without that test
;; lo would pass hi and (slice s lo hi) would be a reversed range, which traps.
(defn trim [s [u8]] [u8]
(let [lo 0
hi (len s)]
(while (and (< lo hi) (space? (at s lo)))
(set lo (+ lo 1)))
(while (and (< lo hi) (space? (at s (- hi 1))))
(set hi (- hi 1)))
(slice s lo hi)))
;; The grammar is Flan's and the rounding is libc's, which is a split and not
;; a dodge. parse-i64 is entirely Flan because strtoll's *answers* are wrong
;; for a caller — 0 for "", 0 for "abc", 12 for "12x" — and reproducing
;; correct-to-the-last-bit decimal-to-binary conversion is a different problem
;; from rejecting junk. So this validates the whole slice first, and only a
;; slice that is entirely a number is handed to bytes->f64; every string this
;; returns Some for is one strtod converts exactly, correctly rounded, and
;; identically everywhere, because that much IEEE-754 requires.
;;
;; The locale worry that keeps parse-i64 in Flan does apply to strtod's
;; decimal point — and is moot here because nothing in the runtime calls
;; setlocale, so the program stays in the C locale for its whole life. If that
;; ever stops being true this function is the thing that breaks.
;;
;; Accepts [+-]? digits [. digits] [eE [+-] digits], needing at least one
;; mantissa digit; refuses "", ".", "1e", "nan", "0x10", " 1" and "1 ". The
;; 511 cap is flan_bytes_to_f64's buffer: past it the shim truncates, and a
;; validator that said yes to 600 digits would be approving a different
;; number than the one strtod reads.
(defn parse-f64 [s [u8]] (Option f64)
(let [i 0
digits 0]
(when (or (= (len s) 0) (> (len s) 511))
(return None))
(when (or (= (at s 0) \-) (= (at s 0) \+))
(set i 1))
(while (and (< i (len s)) (digit? (at s i)))
(set i (+ i 1))
(set digits (+ digits 1)))
(when (and (< i (len s)) (= (at s i) \.))
(set i (+ i 1))
(while (and (< i (len s)) (digit? (at s i)))
(set i (+ i 1))
(set digits (+ digits 1))))
(when (= digits 0)
(return None)) ; "." and "+" and "e5" are not numbers
(when (and (< i (len s)) (or (= (at s i) \e) (= (at s i) \E)))
(set i (+ i 1))
(when (and (< i (len s)) (or (= (at s i) \-) (= (at s i) \+)))
(set i (+ i 1)))
(let [e 0]
(while (and (< i (len s)) (digit? (at s i)))
(set i (+ i 1))
(set e (+ e 1)))
(when (= e 0)
(return None)))) ; a lone exponent marker
;; Trailing junk is the case strtod is silent about, so the position has
;; to land exactly on the end.
(if (= i (len s)) (Some (bytes->f64 s)) None)))
|flan}
let file = "<prelude>"
let forms () = Reader.read_all ~file source