feat(stdlib): sorting toolkit — sort_by/sort_desc_by/sort_with + stable merge sort (#11)
Some checks are pending
docs / build-and-deploy (push) Waiting to run

Grow List sorting from a numeric-only insertion sort into a small,
game-friendly toolkit that sorts records and query results by a key or a
full comparator, stably and in O(n log n).

- List.sort_by(s, keyfn)      ascending by a key (draw order, price)
- List.sort_desc_by(s, keyfn) descending (leaderboards)
- List.sort_with(s, cmpfn)    full cmp(a,b)->int comparator (multi-field)

Comparators/keys are passed as named top-level functions rather than
lambdas, so the toolkit ships without waiting on closures (#1).

Engine: a stable bottom-up merge sort. emit_takeright is the single
place stability is decided ("take the right run's head only on a strict
win" -> equal keys keep prior order). List.sort becomes a hybrid:
insertion sort for n<32, merge sort above; both stable, so output is
unchanged. Key functions must return an integer-ish type; record slices
hold pointer elements, so the key/comparator receives the record pointer.

Tests: selfhost/tests/sort.ludic (scalar large-n, sort_by, sort_desc_by,
stability, sort_with). Docs: list-sort_by/desc_by/with + updated
list-sort. All suites green (28 self-host / 46 test / 29 test-tools);
reseeded, C-free bootstrap fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 11:39:26 +03:00
parent b5455cd550
commit 4aa2012231
8 changed files with 9622 additions and 7747 deletions

View file

@ -11,7 +11,7 @@ ns: List
member: sort member: sort
--- ---
Sorts the elements of <code>s</code> in ascending order in place, using an insertion sort. It is intended for slices of scalar elements (ints, entities, fixed) where <code>&lt;</code> is meaningful; reference elements are ordered by identity, which is rarely useful. Insertion sort is simple and fast for the small, nearly-sorted lists games usually hold. Sorts the elements of <code>s</code> in ascending order in place. It is intended for slices of scalar elements (ints, entities, fixed) where <code>&lt;</code> is meaningful; reference elements are ordered by identity, which is rarely useful. Small lists — the nearly-sorted, per-frame kind games usually hold — take a simple insertion sort; larger lists fall to a stable O(n log n) merge sort. Both are stable, so the result is identical either way. To sort records by a field, or in descending order, or with a custom comparator, use <code>List.sort_by</code>, <code>List.sort_desc_by</code>, or <code>List.sort_with</code>.
```ludic ```ludic
program Demo { program Demo {

View file

@ -0,0 +1,25 @@
---
id: list-sort_by
name: List.sort_by
category: list
kind: namespace-method
tokens: List.sort_by
sig: List.sort_by(s, key) -> void
tip: Sort a slice ascending by a key each element maps to.
order: 14
ns: List
member: sort_by
---
Sorts <code>s</code> in place, ascending, by the number each element maps to through <code>key</code> — a function taking one element and returning an <code>int</code>, <code>fixed</code>, or <code>long</code>. This is how you sort records and entities rather than bare scalars: draw order by <code>y</code>, an inventory by price, nearest targets by distance. The sort is <strong>stable</strong> (O(n log n) merge sort), so elements with equal keys keep the order they already had — the tie-break that keeps sprites from flickering when their <code>y</code> matches.
```ludic
# doc-check: skip — illustrative; `key` is any fn(element) -> number in scope
function draw_key(e: Sprite) -> int { return e.y }
program Demo {
handler Step phase Update {
List.sort_by(sprites, draw_key) # back-to-front draw order
}
}
```

View file

@ -0,0 +1,25 @@
---
id: list-sort_desc_by
name: List.sort_desc_by
category: list
kind: namespace-method
tokens: List.sort_desc_by
sig: List.sort_desc_by(s, key) -> void
tip: Sort a slice descending by a key each element maps to.
order: 15
ns: List
member: sort_desc_by
---
Like <code>List.sort_by</code>, but descending — highest key first. The everyday case is a leaderboard: sort players by score so the top scorer lands at index 0. <code>key</code> is a function taking one element and returning a number (<code>int</code>, <code>fixed</code>, or <code>long</code>). The sort is <strong>stable</strong> (O(n log n) merge sort): players tied on score keep their prior order, so a stable tie-break (say, who reached the score first) survives the sort.
```ludic
# doc-check: skip — illustrative; `key` is any fn(element) -> number in scope
function score_of(p: Player) -> int { return p.score }
program Demo {
handler Step phase Update {
List.sort_desc_by(players, score_of) # players[0] is the leader
}
}
```

View file

@ -0,0 +1,25 @@
---
id: list-sort_with
name: List.sort_with
category: list
kind: namespace-method
tokens: List.sort_with
sig: List.sort_with(s, cmp) -> void
tip: Sort a slice with a full two-argument comparator.
order: 16
ns: List
member: sort_with
---
Sorts <code>s</code> in place with a full comparator <code>cmp(a, b) -> int</code>: return a negative number when <code>a</code> should come before <code>b</code>, positive when after, and zero when they tie. Reach for this when the order is not a single key — a multi-field sort (by rarity, then by name), or a comparison that mixes fields. For the common "subtract two numbers" comparator, returning <code>a.field - b.field</code> gives ascending order. The sort is <strong>stable</strong> (O(n log n) merge sort), so ties (a zero result) keep the elements' prior order.
```ludic
# doc-check: skip — illustrative; `cmp` is any fn(a, b) -> int in scope
function by_price(a: Item, b: Item) -> int { return a.price - b.price }
program Demo {
handler Step phase Update {
List.sort_with(shop, by_price) # cheapest first
}
}
```

View file

@ -16,6 +16,7 @@ function is_list_ns(meth: pointer) -> bool {
if (meth == "first") or (meth == "last") or (meth == "pop") or (meth == "swap") { return true } if (meth == "first") or (meth == "last") or (meth == "pop") or (meth == "swap") { return true }
if (meth == "contains") or (meth == "index_of") or (meth == "reverse") { return true } if (meth == "contains") or (meth == "index_of") or (meth == "reverse") { return true }
if (meth == "insert") or (meth == "remove_at") or (meth == "remove") or (meth == "sort") { return true } if (meth == "insert") or (meth == "remove_at") or (meth == "remove") or (meth == "sort") { return true }
if (meth == "sort_by") or (meth == "sort_desc_by") or (meth == "sort_with") { return true }
return false return false
} }
@ -43,6 +44,187 @@ function list_grow_if_full(h: pointer, elt: pointer) -> void {
emit(done); emit(":\n") emit(done); emit(":\n")
} }
# ---- the sorting toolkit ---------------------------------------------------
# sort_by / sort_desc_by / sort_with lower to a stable, bottom-up (iterative)
# merge sort: O(n log n) time, O(n) scratch, and stable — equal elements keep
# their prior order, which is what tie-breaks (leaderboards, draw order) rely
# on. The ordering rule is supplied per call site as one of these modes; every
# mode reduces to a single "should the right run's head come out before the
# left's?" predicate (emit_takeright), the only place stability is decided.
const SORT_WITH: int = 0 # comparator fn(a, b) -> int; a after b when cmp(a, b) > 0
const SORT_KEY_ASC: int = 1 # key fn(x) -> K; ascending by key
const SORT_KEY_DESC: int = 2 # key fn(x) -> K; descending by key
const SORT_SCALAR: int = 3 # bare ascending over scalar elements (List.sort's large-n path)
# address of element `idx` (an i32 code) within a raw base pointer `base`
function gep_at(base: pointer, elt: pointer, idx: pointer) -> pointer {
return emit_bind(`getelementptr inbounds {elt}, ptr {base}, i32 {idx}`)
}
# i1 code for "take the right run's head (`bv`) before the left's (`av`)". True
# only on a STRICT win for the right, so equal keys keep the left (= earlier)
# element first — that is the stability guarantee. `fnsym` is the @fn_ name of
# the user comparator/key function; `keyll` its key's LLVM type (key modes only).
function emit_takeright(elt: pointer, mode: int, fnsym: pointer, keyll: pointer, av: pointer, bv: pointer) -> pointer {
if (mode == SORT_SCALAR) {
return emit_bind(`icmp slt {elt} {bv}, {av}`) # right strictly less than left
}
if (mode == SORT_WITH) {
let c = emit_bind(`call i32 @fn_{fnsym}({elt} {av}, {elt} {bv})`)
return emit_bind(`icmp sgt i32 {c}, 0`) # cmp(left, right) > 0 -> left comes after
}
let kl = emit_bind(`call {keyll} @fn_{fnsym}({elt} {av})`)
let kr = emit_bind(`call {keyll} @fn_{fnsym}({elt} {bv})`)
if (mode == SORT_KEY_DESC) {
return emit_bind(`icmp slt {keyll} {kl}, {kr}`) # desc: right first when its key is larger
}
return emit_bind(`icmp sgt {keyll} {kl}, {kr}`) # asc: right first when its key is smaller
}
# emit a stable bottom-up merge sort of slice header `h` (element LLVM type
# `elt`), ordered by `mode`. Passes double the run width each round, merging
# adjacent runs through a malloc'd scratch buffer and copying the result back,
# so the slice is sorted in place from the caller's view.
function emit_merge_sort(h: pointer, elt: pointer, mode: int, fnsym: pointer, keyll: pointer) -> void {
let dp = slice_field(h, 0)
let data = emit_bind(`load ptr, ptr {dp}`) # data pointer is stable during the sort
let lp = slice_field(h, 1)
let n = emit_bind(`load i32, ptr {lp}`)
let esz = emit_sizeof(elt)
let nz = emit_bind(`zext i32 {n} to i64`)
let byts = emit_bind(`mul i64 {nz}, {esz}`)
let scratch = emit_bind(`call ptr @malloc(i64 {byts})`)
# outer: for (width = 1; width < n; width *= 2)
let wp = emit_alloca("i32"); store_at("i32", "1", wp)
let wc = lbl("ms_wc"); let wb = lbl("ms_wb"); let we = lbl("ms_we")
emit(" br label %"); emit(wc); emit("\n"); emit(wc); emit(":\n")
let w = emit_bind(`load i32, ptr {wp}`)
let wgo = emit_bind(`icmp slt i32 {w}, {n}`)
emit(" br i1 "); emit(wgo); emit(", label %"); emit(wb); emit(", label %"); emit(we); emit("\n")
emit(wb); emit(":\n")
let w2 = emit_bind(`mul i32 {w}, 2`)
# middle: for (i = 0; i < n; i += 2*width) merge [i, i+w) with [i+w, i+2w)
let ip = emit_alloca("i32"); store_at("i32", "0", ip)
let ic = lbl("ms_ic"); let ib = lbl("ms_ib"); let ie = lbl("ms_ie")
emit(" br label %"); emit(ic); emit("\n"); emit(ic); emit(":\n")
let i = emit_bind(`load i32, ptr {ip}`)
let igo = emit_bind(`icmp slt i32 {i}, {n}`)
emit(" br i1 "); emit(igo); emit(", label %"); emit(ib); emit(", label %"); emit(ie); emit("\n")
emit(ib); emit(":\n")
let iw = emit_bind(`add i32 {i}, {w}`)
let mlt = emit_bind(`icmp slt i32 {iw}, {n}`)
let mid = emit_bind(`select i1 {mlt}, i32 {iw}, i32 {n}`) # mid = min(i+w, n)
let iw2 = emit_bind(`add i32 {i}, {w2}`)
let hlt = emit_bind(`icmp slt i32 {iw2}, {n}`)
let hi = emit_bind(`select i1 {hlt}, i32 {iw2}, i32 {n}`) # hi = min(i+2w, n)
let ap = emit_alloca("i32"); store_at("i32", i, ap) # a: cursor in left run [i, mid)
let bp = emit_alloca("i32"); store_at("i32", mid, bp) # b: cursor in right run [mid, hi)
let kp = emit_alloca("i32"); store_at("i32", i, kp) # k: write cursor in scratch
# merge while both runs have elements
let mc = lbl("ms_mc"); let mbb = lbl("ms_mb"); let mend = lbl("ms_mend")
emit(" br label %"); emit(mc); emit("\n"); emit(mc); emit(":\n")
let a = emit_bind(`load i32, ptr {ap}`)
let b = emit_bind(`load i32, ptr {bp}`)
let aok = emit_bind(`icmp slt i32 {a}, {mid}`)
let bok = emit_bind(`icmp slt i32 {b}, {hi}`)
let both = emit_bind(`and i1 {aok}, {bok}`)
emit(" br i1 "); emit(both); emit(", label %"); emit(mbb); emit(", label %"); emit(mend); emit("\n")
emit(mbb); emit(":\n")
let aad = gep_at(data, elt, a)
let av = emit_bind(`load {elt}, ptr {aad}`)
let bad = gep_at(data, elt, b)
let bv = emit_bind(`load {elt}, ptr {bad}`)
let tr = emit_takeright(elt, mode, fnsym, keyll, av, bv)
let k = emit_bind(`load i32, ptr {kp}`)
let sad = gep_at(scratch, elt, k)
let tR = lbl("ms_takeR"); let tL = lbl("ms_takeL"); let tD = lbl("ms_takeD")
emit(" br i1 "); emit(tr); emit(", label %"); emit(tR); emit(", label %"); emit(tL); emit("\n")
emit(tR); emit(":\n")
emit(" store "); emit(elt); emit(" "); emit(bv); emit(", ptr "); emit(sad); emit("\n")
let b1 = emit_bind(`add i32 {b}, 1`); store_at("i32", b1, bp)
emit(" br label %"); emit(tD); emit("\n")
emit(tL); emit(":\n")
emit(" store "); emit(elt); emit(" "); emit(av); emit(", ptr "); emit(sad); emit("\n")
let a1 = emit_bind(`add i32 {a}, 1`); store_at("i32", a1, ap)
emit(" br label %"); emit(tD); emit("\n")
emit(tD); emit(":\n")
let k1 = emit_bind(`add i32 {k}, 1`); store_at("i32", k1, kp)
emit(" br label %"); emit(mc); emit("\n")
emit(mend); emit(":\n")
# drain the tail of the left run
let dac = lbl("ms_dac"); let dab = lbl("ms_dab"); let dae = lbl("ms_dae")
emit(" br label %"); emit(dac); emit("\n"); emit(dac); emit(":\n")
let a2 = emit_bind(`load i32, ptr {ap}`)
let amore = emit_bind(`icmp slt i32 {a2}, {mid}`)
emit(" br i1 "); emit(amore); emit(", label %"); emit(dab); emit(", label %"); emit(dae); emit("\n")
emit(dab); emit(":\n")
let aad2 = gep_at(data, elt, a2)
let av2 = emit_bind(`load {elt}, ptr {aad2}`)
let k2 = emit_bind(`load i32, ptr {kp}`)
let sad2 = gep_at(scratch, elt, k2)
emit(" store "); emit(elt); emit(" "); emit(av2); emit(", ptr "); emit(sad2); emit("\n")
let a3 = emit_bind(`add i32 {a2}, 1`); store_at("i32", a3, ap)
let k3 = emit_bind(`add i32 {k2}, 1`); store_at("i32", k3, kp)
emit(" br label %"); emit(dac); emit("\n")
emit(dae); emit(":\n")
# drain the tail of the right run
let dbc = lbl("ms_dbc"); let dbb = lbl("ms_dbb"); let dbe = lbl("ms_dbe")
emit(" br label %"); emit(dbc); emit("\n"); emit(dbc); emit(":\n")
let b2 = emit_bind(`load i32, ptr {bp}`)
let bmore = emit_bind(`icmp slt i32 {b2}, {hi}`)
emit(" br i1 "); emit(bmore); emit(", label %"); emit(dbb); emit(", label %"); emit(dbe); emit("\n")
emit(dbb); emit(":\n")
let bad2 = gep_at(data, elt, b2)
let bv2 = emit_bind(`load {elt}, ptr {bad2}`)
let k4 = emit_bind(`load i32, ptr {kp}`)
let sad3 = gep_at(scratch, elt, k4)
emit(" store "); emit(elt); emit(" "); emit(bv2); emit(", ptr "); emit(sad3); emit("\n")
let b3 = emit_bind(`add i32 {b2}, 1`); store_at("i32", b3, bp)
let k5 = emit_bind(`add i32 {k4}, 1`); store_at("i32", k5, kp)
emit(" br label %"); emit(dbc); emit("\n")
emit(dbe); emit(":\n")
# copy the merged run scratch[i, hi) back into data[i, hi)
let jp = emit_alloca("i32"); store_at("i32", i, jp)
let cbc = lbl("ms_cbc"); let cbb = lbl("ms_cbb"); let cbe = lbl("ms_cbe")
emit(" br label %"); emit(cbc); emit("\n"); emit(cbc); emit(":\n")
let j = emit_bind(`load i32, ptr {jp}`)
let jmore = emit_bind(`icmp slt i32 {j}, {hi}`)
emit(" br i1 "); emit(jmore); emit(", label %"); emit(cbb); emit(", label %"); emit(cbe); emit("\n")
emit(cbb); emit(":\n")
let ssad = gep_at(scratch, elt, j)
let sv = emit_bind(`load {elt}, ptr {ssad}`)
let ddad = gep_at(data, elt, j)
emit(" store "); emit(elt); emit(" "); emit(sv); emit(", ptr "); emit(ddad); emit("\n")
let j1 = emit_bind(`add i32 {j}, 1`); store_at("i32", j1, jp)
emit(" br label %"); emit(cbc); emit("\n")
emit(cbe); emit(":\n")
let inx = emit_bind(`add i32 {i}, {w2}`); store_at("i32", inx, ip)
emit(" br label %"); emit(ic); emit("\n")
emit(ie); emit(":\n")
store_at("i32", w2, wp) # width *= 2
emit(" br label %"); emit(wc); emit("\n")
emit(we); emit(":\n")
emit(" call void @free(ptr "); emit(scratch); emit(")\n")
}
# resolve a sort_by/sort_with function argument (a bare identifier naming a
# top-level function) to its name, erroring clearly on misuse.
function sort_fn_arg(meth: pointer, e: Node) -> pointer {
if (len(e.kids) < 2) { perr(`List.{meth} needs (slice, function)`) }
if (e.kids[1].kind != E_ID) { perr(`List.{meth}: the second argument must be a function name`) }
let fname = e.kids[1].s
if (find_fn(fname) == null) { perr(`List.{meth}: no function named '{fname}'`) }
return fname
}
function emit_list_ns(meth: pointer, e: Node) -> Val { function emit_list_ns(meth: pointer, e: Node) -> Val {
if (meth == "len") { return emit_len(e) } # same header length as len(s) if (meth == "len") { return emit_len(e) } # same header length as len(s)
if (meth == "push") { return emit_push(e) } # same as push(s, v) if (meth == "push") { return emit_push(e) } # same as push(s, v)
@ -221,7 +403,15 @@ function emit_list_ns(meth: pointer, e: Node) -> Val {
emit(end); emit(":\n") emit(end); emit(":\n")
return val("0", "void") return val("0", "void")
} }
if (meth == "sort") { # ascending insertion sort, in place if (meth == "sort") { # stable ascending sort in place
# small lists take the simple insertion sort (fast, cache-friendly, and
# what nearly-sorted per-frame lists usually are); larger lists take the
# O(n log n) stable merge sort. Both are stable, so the result is identical.
let n0 = emit_bind(`load i32, ptr {lp}`)
let small = emit_bind(`icmp slt i32 {n0}, 32`)
let sIns = lbl("sort_ins"); let sMrg = lbl("sort_mrg"); let sEnd = lbl("sort_end")
emit(" br i1 "); emit(small); emit(", label %"); emit(sIns); emit(", label %"); emit(sMrg); emit("\n")
emit(sIns); emit(":\n")
let l = emit_bind(`load i32, ptr {lp}`) let l = emit_bind(`load i32, ptr {lp}`)
let ip = emit_alloca("i32") let ip = emit_alloca("i32")
store_at("i32", "1", ip) store_at("i32", "1", ip)
@ -261,6 +451,28 @@ function emit_list_ns(meth: pointer, e: Node) -> Val {
store_at("i32", i1, ip) store_at("i32", i1, ip)
emit(" br label %"); emit(oc); emit("\n") emit(" br label %"); emit(oc); emit("\n")
emit(oe); emit(":\n") emit(oe); emit(":\n")
emit(" br label %"); emit(sEnd); emit("\n")
emit(sMrg); emit(":\n")
emit_merge_sort(h, elt, SORT_SCALAR, "", "")
emit(" br label %"); emit(sEnd); emit("\n")
emit(sEnd); emit(":\n")
return val("0", "void")
}
if (meth == "sort_with") { # full comparator: fn(a, b) -> int (a<b when <0)
let fname = sort_fn_arg(meth, e)
if (len(param_types(find_fn(fname))) != 2) { perr(`List.sort_with: '{fname}' must take two arguments`) }
emit_merge_sort(h, elt, SORT_WITH, fname, "i32")
return val("0", "void")
}
if (meth == "sort_by") or (meth == "sort_desc_by") { # key selector: fn(x) -> number
let fname = sort_fn_arg(meth, e)
let kf = find_fn(fname)
if (len(param_types(kf)) != 1) { perr(`List.{meth}: key '{fname}' must take one argument`) }
let keyll = llty(kf.ty)
if (keyll == "ptr") or (keyll == "void") { perr(`List.{meth}: key '{fname}' must return a number`) }
var m = SORT_KEY_ASC
if (meth == "sort_desc_by") { m = SORT_KEY_DESC }
emit_merge_sort(h, elt, m, fname, keyll)
return val("0", "void") return val("0", "void")
} }
# reverse: swap ends inward until the cursors meet # reverse: swap ends inward until the cursors meet

File diff suppressed because it is too large Load diff

74
selfhost/tests/sort.ludic Normal file
View file

@ -0,0 +1,74 @@
program T {
property P { key: int = 0, tag: int = 0 }
function pkey(p: P) -> int { return p.key }
function cmp_key(a: P, b: P) -> int { return a.key - b.key }
entry {
# 1) scalar large-n path (merge sort): 40 descending -> ascending
let xs = new []int
for i in 0 .. 40 { push(xs, 39 - i) }
List.sort(xs)
print(xs[0]) # 0
print(xs[39]) # 39
print(xs[20]) # 20
# 2) sort_by ascending on records by a field
let ps = new []P
let a = new P
a.key = 3
push(ps, a)
let b = new P
b.key = 1
push(ps, b)
let c = new P
c.key = 2
push(ps, c)
List.sort_by(ps, pkey)
print(ps[0].key) # 1
print(ps[1].key) # 2
print(ps[2].key) # 3
# 3) sort_desc_by (leaderboard)
List.sort_desc_by(ps, pkey)
print(ps[0].key) # 3
print(ps[2].key) # 1
# 4) stability: equal keys keep their prior order
let qs = new []P
let q0 = new P
q0.key = 1
q0.tag = 10
push(qs, q0)
let q1 = new P
q1.key = 1
q1.tag = 20
push(qs, q1)
let q2 = new P
q2.key = 0
q2.tag = 30
push(qs, q2)
let q3 = new P
q3.key = 1
q3.tag = 40
push(qs, q3)
List.sort_by(qs, pkey)
print(qs[0].tag) # 30 (key 0)
print(qs[1].tag) # 10 (key 1, original order)
print(qs[2].tag) # 20
print(qs[3].tag) # 40
# 5) sort_with (full comparator, ascending)
let rs = new []P
let r0 = new P
r0.key = 5
push(rs, r0)
let r1 = new P
r1.key = 2
push(rs, r1)
let r2 = new P
r2.key = 8
push(rs, r2)
List.sort_with(rs, cmp_key)
print(rs[0].key) # 2
print(rs[2].key) # 8
}
}

View file

@ -35,6 +35,7 @@ function cmd_selfhost_test() -> int {
print("== the self-host compiler is correct ==") print("== the self-host compiler is correct ==")
sh_case("structs", "7 9 109 2 42") sh_case("structs", "7 9 109 2 42")
sh_case("slices", "0 20 361 777") sh_case("slices", "0 20 361 777")
sh_case("sort", "0 39 20 1 2 3 3 1 30 10 20 40 2 8")
sh_case("control", "55 4 15 1") sh_case("control", "55 4 15 1")
sh_case("match_bits", "1 2 9 16 4 15") sh_case("match_bits", "1 2 9 16 4 15")
sh_case("fixed", "2 3 0 6 1") sh_case("fixed", "2 3 0 6 1")