A second and third sort, and why there is not a generic one

sort-i32! was the only sort in the language. sort-f32! and sort-bytes! are
the other two, and they are copies rather than an abstraction for a reason
worth naming precisely: map, filter, reduce and a sort taking a comparator
are not blocked on generics, they are blocked on *function values*. Types.Fn
exists and check.ml refuses it with "a function type is not implemented yet --
milestone 5", and there is nothing else in the language to pass. Generics on
top of that is what would make them one copy instead of one per element type.

The f32 family carries one caveat the i32 family cannot have: a NaN makes the
order undefined, because every comparison against one is false, so the
insertion loop never moves it and never moves anything past it. sum-f32
accumulates in f64 for a stronger version of sum-i32's argument -- an f32
total does not wrap, it absorbs, and the answer comes out silently short.
The test prints the difference rather than the total, because %g hides it.

sort-bytes! is the one a caller of split actually wants, and its ordering is
memcmp's: bytewise, unsigned, prefix first. Not alphabetical -- "Zebra" sorts
before "apple" -- and the note says so, for the same reason the ASCII-case
note refuses a locale. The slices move and the bytes never do, so it sorts
fields borrowed out of a string literal, which an in-place byte sort could
not.
This commit is contained in:
Joseph Ferano 2026-09-12 21:58:12 +07:00
parent b5e7351c7e
commit 7ce6043c47
3 changed files with 276 additions and 6 deletions

View File

@ -90,15 +90,27 @@ let source = {flan|
;; 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.
;; One family per element type, because there are no generics: each of these
;; is a *copy* per element type, and the set below is i32 (what indices, ids
;; and tile values are), f32 (what positions, velocities and weights are) and
;; [u8] (what a field coming out of `split` is).
;;
;; 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.
;; leaves the rest alone. That was originally forced there was no allocator
;; to return a new sequence from and it stays the right shape now that there
;; is one, because sorting a thing you already own should not allocate. The
;; allocating tier is further down, and a caller sorts a Vec by sorting
;; (as-slice v).
;;
;; **map, filter, reduce and a sort taking a comparator are not here, and they
;; are not blocked on generics.** They are blocked on *function values*: each
;; of them takes a callable as an argument, Types.Fn exists but check.ml
;; refuses it with "a function type is not implemented yet — milestone 5", and
;; there is nothing else in the language to pass. Generics on top of that is
;; what would make them one copy instead of one per element type; without
;; either, the honest form is the concrete fold, which is what sum-i32 and
;; sum-f32 below already are (reduce + 0) with the + written in.
(defn swap-i32! [s [i32] i i32 j i32]
(let [t (at s i)]
@ -164,6 +176,74 @@ let source = {flan|
(set t (+ t (i64 (at s i)))))
t))
;; The same family over f32
;;
;; sort-i32! was the only sort in the language, which is what NEXT.md's second
;; tier means by "a sort that is not integers-only". This is the second, and it
;; is a copy and not an abstraction see the note above on why.
;;
;; One caveat that has no counterpart in the i32 family, because it cannot
;; arise there: **a NaN in the input makes the order undefined.** Every
;; comparison against a NaN is false, so the insertion loop never moves one and
;; never moves anything past one; what comes out is sorted within each run
;; between NaNs and not sorted across them. That is what C's qsort with a naive
;; comparator does too. The fix is not to have NaNs in the array which is
;; also the only fix, since there is no ordering of the reals that a NaN sits
;; anywhere in.
(defn swap-f32! [s [f32] i i32 j i32]
(let [t (at s i)]
(set (at s i) (at s j))
(set (at s j) t)))
(defn reverse-f32! [s [f32]]
(let [i 0
j (- (len s) 1)]
(while (< i j)
(swap-f32! s i j)
(set i (+ i 1))
(set j (- j 1)))))
(defn sort-f32! [s [f32]]
(let [i 1]
(while (< i (len s))
(let [j i]
(while (and (> j 0) (> (at s (- j 1)) (at s j)))
(swap-f32! s (- j 1) j)
(set j (- j 1))))
(set i (+ i 1)))))
;; None for an empty slice, exactly as min-i32 does. A NaN in the input is not
;; special-cased and propagates the same way it does through the builtins: the
;; comparison fails, so the running value simply does not change.
(defn min-f32 [s [f32]] (Option f32)
(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-f32 [s [f32]] (Option f32)
(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 f64 and widens each element explicitly, which is sum-i32's
;; argument in its floating form and a stronger one: summing a screenful of f32
;; in f32 does not wrap, it *absorbs* once the running total is large enough,
;; adding a small element rounds to no change at all, and the answer is silently
;; short rather than obviously wrong. An f64 accumulator has 29 more bits of
;; mantissa and pushes that failure out of reach of any array a game holds.
(defn sum-f32 [s [f32]] f64
(let [t 0.0]
(dotimes [i (len s)]
(set t (+ t (f64 (at s i)))))
t))
;; Bytes
;;
;; Over [u8] and not over string, so (bytes s) is what a caller writes and one
@ -790,6 +870,52 @@ let source = {flan|
(return false)))
true)))
;; Ordering byte slices, and sorting them
;;
;; The third element type the slice family covers, and the one a caller of
;; `split` actually has: a [[u8]] of fields, wanting to come out in order.
;;
;; The order is bytewise-lexicographic memcmp's, and the one every sane
;; sorted format uses. It is explicitly *not* alphabetical and not a collation:
;; "Zebra" sorts before "apple" because 'Z' is 90 and 'a' is 97, and a
;; non-ASCII byte sorts by its UTF-8 encoding, which for code points happens to
;; agree with code-point order and for anything a human would call alphabetical
;; does not. A locale-aware comparison is not a byte operation at all, for the
;; same reasons the ASCII-case note above gives.
;;
;; The comparison is over u8 and therefore unsigned, which is the bug a version
;; written over a signed byte type has: 0x80 would compare *below* 0x00 and
;; every multi-byte character would sort before every ASCII one.
;;
;; A prefix sorts before what extends it "ab" before "abc" which falls out
;; of running to the shorter length and then comparing lengths, and is the case
;; a loop written to (len a) alone reads off the end for.
(defn bytes<? [a [u8] b [u8]] bool
(let [n (min (len a) (len b))]
(dotimes [i n]
(when (!= (at a i) (at b i))
(return (< (at a i) (at b i)))))
(< (len a) (len b))))
(defn swap-bytes! [s [[u8]] i i32 j i32]
(let [t (at s i)]
(set (at s i) (at s j))
(set (at s j) t)))
;; The same insertion sort as sort-i32!, over the same in-place contract: the
;; *slices* move, never the bytes they point at, so this sorts a [[u8]] of
;; fields borrowed from one buffer without touching the buffer. Stable, and
;; here that is observable two equal fields are two distinct slices of
;; different parts of the input, and a caller can see which one came first.
(defn sort-bytes! [s [[u8]]]
(let [i 1]
(while (< i (len s))
(let [j i]
(while (and (> j 0) (bytes<? (at s j) (at s (- j 1))))
(swap-bytes! s (- j 1) j)
(set j (- j 1))))
(set i (+ i 1)))))
;; Building bytes, which is the tier that needed an allocator
;;
;; Everything above this line is slice-based and allocation-free, because when

View File

@ -0,0 +1,120 @@
;;;; The slice family at its second and third element types.
;;;;
;;;; sort-i32! was the only sort in the language. These are the other two, and
;;;; they are copies rather than an abstraction: map, filter, reduce and a sort
;;;; taking a comparator all need a *function value*, which check.ml refuses
;;;; with "a function type is not implemented yet -- milestone 5". So the
;;;; honest form is the concrete one, and the claim this file makes is only
;;;; that the concrete ones are right.
(defn show-f32 [s [f32]]
(dotimes [i (len s)]
(print (at s i))
(print " "))
(println ""))
(defn show-fields [s [[u8]]]
(dotimes [i (len s)]
(print (string (at s i)))
(print " "))
(println ""))
(defn main [] i32
;; Every float literal is cast. A literal defaults to f64 and an array
;; literal has no context to say otherwise -- a let has no type annotation --
;; so [3.5 -1.0] is an [f64] and (sort-f32!) refuses it by type. The cast is
;; the only spelling available today.
;;
;; sort-f32!: duplicates, negatives, a zero and an odd length, which is the
;; input shape the i32 sort is tested on for the same reasons.
(let [xs [(f32 3.5) (f32 -1.0) (f32 0.0) (f32 3.5) (f32 -2.25) (f32 10.0) (f32 0.5)]]
(sort-f32! (slice xs 0 7))
(show-f32 (slice xs 0 7))) ; -2.25 -1 0 0.5 3.5 3.5 10
;; In place and ptr+len: sorting a subslice leaves its neighbours alone. That
;; is the whole content of the in-place claim, and a version that copied
;; would pass every test above and fail this one.
(let [xs [(f32 9.0) (f32 4.0) (f32 3.0) (f32 2.0) (f32 1.0) (f32 9.0)]]
(sort-f32! (slice xs 1 5))
(show-f32 (slice xs 0 6))) ; 9 1 2 3 4 9
;; Already sorted, reverse sorted, and a single element -- the three inputs
;; where an insertion loop with the comparison the wrong way round still
;; looks plausible.
(let [xs [(f32 1.0) (f32 2.0) (f32 3.0)]]
(sort-f32! (slice xs 0 3))
(show-f32 (slice xs 0 3))) ; 1 2 3
(let [xs [(f32 3.0) (f32 2.0) (f32 1.0)]]
(sort-f32! (slice xs 0 3))
(show-f32 (slice xs 0 3))) ; 1 2 3
(let [xs [(f32 7.0)]]
(sort-f32! (slice xs 0 1))
(show-f32 (slice xs 0 1))) ; 7
;; The empty slice must not read (at s -1).
(let [xs [(f32 7.0)]]
(sort-f32! (slice xs 0 0))
(show-f32 (slice xs 0 0))) ;
(let [xs [(f32 1.0) (f32 2.0) (f32 3.0) (f32 4.0)]]
(reverse-f32! (slice xs 0 4))
(show-f32 (slice xs 0 4))) ; 4 3 2 1
;; min, max and sum. The empty slice is None for the first two -- there is no
;; least f32 that is also an honest answer -- and the sum accumulates in f64,
;; which is why 16777216 + 1 does not absorb here the way it would in f32.
(let [xs [(f32 3.5) (f32 -1.0) (f32 10.0)]]
(match (min-f32 (slice xs 0 3)) (Some m) (print m) None (print "none"))
(print " ")
(match (max-f32 (slice xs 0 3)) (Some m) (print m) None (print "none"))
(print " ")
(print (sum-f32 (slice xs 0 3)))
(println "")) ; -1 10 12.5
(let [xs [(f32 1.0)]]
(match (min-f32 (slice xs 0 0)) (Some m) (print m) None (print "none"))
(print " ")
(print (sum-f32 (slice xs 0 0)))
(println "")) ; none 0
;; The accumulator's width, made visible. 2^24 is where an f32 stops having
;; a bit for 1, so an f32 running total absorbs both of these and the
;; difference below is 0. In f64 they both land, and it is 2.
(let [xs [(f32 16777216.0) (f32 1.0) (f32 1.0)]]
(print (- (sum-f32 (slice xs 0 3)) 16777216.0))
(println "")) ; 2
;; bytes<? is bytewise and unsigned, and explicitly not alphabetical: "Zebra"
;; comes before "apple" because 'Z' is 90. A prefix comes before what extends
;; it, which is the case a loop running only to (len a) reads off the end
;; for, and 0x80 above 0x00 is the case a signed byte gets backwards.
(print (bytes<? (bytes "a") (bytes "b"))) (print " ") ; true
(print (bytes<? (bytes "b") (bytes "a"))) (print " ") ; false
(print (bytes<? (bytes "a") (bytes "a"))) (print " ") ; false
(print (bytes<? (bytes "ab") (bytes "abc"))) (print " ") ; true
(print (bytes<? (bytes "abc") (bytes "ab"))) (print " ") ; false
(print (bytes<? (bytes "") (bytes "a"))) (print " ") ; true
(print (bytes<? (bytes "Zebra") (bytes "apple"))) ; true
(println "")
;; 0x00 below 0x80, which is the pair a comparison over a *signed* byte gets
;; backwards -- and there is no \x escape in the reader, so these are built
;; as u8 arrays rather than written as string literals.
(let [lo [(u8 0)]
hi [(u8 128)]]
(print (bytes<? (slice lo 0 1) (slice hi 0 1))) (print " ") ; true
(print (bytes<? (slice hi 0 1) (slice lo 0 1))) ; false
(println ""))
;; sort-bytes! over the fields split out of one buffer. The slices move and
;; the bytes never do, so this sorts a borrowed view of a string literal --
;; which an in-place byte sort could not, since a literal lives in .rodata.
(let [f (split (bytes "pear,apple,Fig,apple,banana") \,)]
(sort-bytes! (as-slice f))
(show-fields (as-slice f)) ; Fig apple apple banana pear
(free f))
;; And the round trip the whole second tier is for: split, sort, join.
(let [f (split (bytes "delta,alpha,charlie,bravo") \,)]
(sort-bytes! (as-slice f))
(let [j (join (as-slice f) (bytes " < "))]
(println (string (as-slice j))) ; alpha < bravo < charlie < delta
(free j))
(free f))
0)

View File

@ -508,6 +508,30 @@ let () =
outputs "a number with a precision" "programs/format.flan" format_out;
outputs ~opt:"-O0" "a number with a precision, -O0" "programs/format.flan"
format_out;
(* The slice family at its second and third element types. sort-i32! was
the only sort in the language; these are copies rather than an
abstraction, because map/filter/reduce and a comparator sort all need a
function value and check.ml refuses one outright.
Two lines carry the claims that are not about sorting. The subslice sort
leaves its neighbours alone, which is the in-place ptr+len contract and
the one thing a version that copied would fail. And the 2 is the width of
sum-f32's accumulator made visible: 2^24 is where an f32 stops having a
bit for 1, so an f32 running total absorbs both addends and prints 0. *)
let algorithms_out =
"-2.25 -1 0 0.5 3.5 3.5 10 \n\
9 1 2 3 4 9 \n\
1 2 3 \n1 2 3 \n7 \n\n\
4 3 2 1 \n\
-1 10 12.5\nnone 0\n2\n\
true false false true false true true\ntrue false\n\
Fig apple apple banana pear \n\
alpha < bravo < charlie < delta\n"
in
outputs "sorting f32 and byte slices" "programs/algorithms.flan"
algorithms_out;
outputs ~opt:"-O0" "sorting f32 and byte slices, -O0"
"programs/algorithms.flan" algorithms_out;
(* A debug build, because [dty] is a separate path from everything above:
[outputs ~dev:true] goes through the cells, not through DWARF, and a
type with no arm there dies at emit rather than being merely undebugged.