ludic.base: tb_reserve and imap_reserve size a table up front; HandlePool (a slot and a generation, nothing made after hd_pool_new, past its capacity a panic naming it); kept_push is the one declared grow, and every growth path left says its bound in @alloc_ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 15:58:11 +03:00
parent f2dd27f443
commit 2d1d06899e
17 changed files with 199 additions and 26 deletions

View file

@ -29,6 +29,7 @@ function grid_moved(g: Grid, from: int, to: int) -> void {
} }
# twice the buckets, every filed row linked again where its key now hashes # twice the buckets, every filed row linked again where its key now hashes
@alloc_ok("past grid_reserve only: the buckets double once past the most rows filed, and are kept")
function grid_rehash(g: Grid) -> void { function grid_rehash(g: Grid) -> void {
g.mask = g.mask * 2 + 1 g.mask = g.mask * 2 + 1
g.head = words(g.mask + 1) g.head = words(g.mask + 1)

View file

@ -63,7 +63,7 @@ export function tb_within_recs<T>(tb: Table<T>, g: Grid, x: float, z: float, r:
let cells = (ix1 - ix0 + 1) * (iz1 - iz0 + 1) let cells = (ix1 - ix0 + 1) * (iz1 - iz0 + 1)
if cells > g.count or cells < 0 { if cells > g.count or cells < 0 {
for row in 0 .. len(g.key) { for row in 0 .. len(g.key) {
if g.key[row] >= 0 and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) <= r * r { push(out, tb.rec[row]) } if g.key[row] >= 0 and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) <= r * r { kept_push(out, tb.rec[row]) }
} }
return len(out) return len(out)
} }
@ -78,7 +78,7 @@ function grid_recs_cell<T>(tb: Table<T>, g: Grid, xs: floats, zs: floats, mcol:
var r = g.head[grid_bucket(g, ix, iz)] var r = g.head[grid_bucket(g, ix, iz)]
while r > 0 { while r > 0 {
let row = r - 1 let row = r - 1
if g.key[row] == k and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) <= r2 { push(out, tb.rec[row]) } if g.key[row] == k and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) <= r2 { kept_push(out, tb.rec[row]) }
r = g.next[row] r = g.next[row]
} }
} }
@ -95,7 +95,7 @@ export function tb_within_of_recs<T>(tb: Table<T>, g: Grid, ix: IntIndex, x: flo
for k in 0 .. n { for k in 0 .. n {
let row = rows[k] let row = rows[k]
if g.gate >= 0 and tb.i[g.gate][row] == 0 { continue } if g.gate >= 0 and tb.i[g.gate][row] == 0 { continue }
if grid_d2(xs, zs, row, x, z) <= r * r { push(out, tb.rec[row]) } if grid_d2(xs, zs, row, x, z) <= r * r { kept_push(out, tb.rec[row]) }
} }
return len(out) return len(out)
} }

View file

@ -55,7 +55,7 @@ function ix_file(ix: IntIndex, r: int, v: int) -> void {
if s < 0 { return } if s < 0 { return }
ix.pos[r] = len(ix.lists[s]) ix.pos[r] = len(ix.lists[s])
ix.val[r] = s ix.val[r] = s
push(ix.lists[s], r) kept_push(ix.lists[s], r)
} }
# out of its list: the list's last row takes its place # out of its list: the list's last row takes its place

View file

@ -4,6 +4,7 @@
const IX_DENSE: int = 1024 # a value below this is its own slot; above, one is given it const IX_DENSE: int = 1024 # a value below this is its own slot; above, one is given it
const IX_CAP: int = 16 # rows a value's list holds before it grows: a list grown in play is a new block const IX_CAP: int = 16 # rows a value's list holds before it grows: a list grown in play is a new block
@alloc_ok("once per distinct value: its list is made the first time the value is filed, and kept")
function ix_list_new() -> words { function ix_list_new() -> words {
let l = words(IX_CAP) let l = words(IX_CAP)
List.clear(l) List.clear(l)
@ -16,15 +17,15 @@ function ix_slot(ix: IntIndex, v: int, make: bool) -> int {
if v < IX_DENSE { if v < IX_DENSE {
if v >= len(ix.lists) { if v >= len(ix.lists) {
if not make { return -1 } if not make { return -1 }
while len(ix.lists) <= v { push(ix.lists, ix_list_new()) } while len(ix.lists) <= v { kept_push(ix.lists, ix_list_new()) }
} }
return v return v
} }
var s = imap_get(ix.sparse, v, -1) var s = imap_get(ix.sparse, v, -1)
if s < 0 and make { if s < 0 and make {
while len(ix.lists) < IX_DENSE { push(ix.lists, ix_list_new()) } while len(ix.lists) < IX_DENSE { kept_push(ix.lists, ix_list_new()) }
s = len(ix.lists) s = len(ix.lists)
push(ix.lists, ix_list_new()) kept_push(ix.lists, ix_list_new())
imap_put(ix.sparse, v, s) imap_put(ix.sparse, v, s)
} }
return s return s

View file

@ -69,6 +69,18 @@ export function imap_del(m: IntMap, k: int) -> bool {
export function imap_len(m: IntMap) -> int { return m.n } export function imap_len(m: IntMap) -> int { return m.n }
export function imap_clear(m: IntMap) -> void { imap_alloc(m, m.mask + 1) } export function imap_clear(m: IntMap) -> void { imap_alloc(m, m.mask + 1) }
# room for `n` keys before the map grows, made now
export function imap_reserve(m: IntMap, n: int) -> void {
if (m.mask + 1) * 3 >= n * 4 { return }
var cap = m.mask + 1
while cap * 3 < n * 4 { cap = cap * 2 }
let ok = m.keys
let ov = m.vals
imap_alloc(m, cap)
for i in 0 .. len(ok) { if ok[i] >= 0 { imap_put(m, ok[i], ov[i]) } }
}
@alloc_ok("past imap_reserve only: doubles when half full; the old arrays are not given back")
function imap_grow(m: IntMap) -> void { function imap_grow(m: IntMap) -> void {
let ok = m.keys let ok = m.keys
let ov = m.vals let ov = m.vals

View file

@ -66,22 +66,10 @@ export function tb_alive<T>(tb: Table<T>, h: int) -> bool { return tb_row(tb, h)
# a new row holding `r`, every column 0 and filed in no index until a setter says where it is # a new row holding `r`, every column 0 and filed in no index until a setter says where it is
export function tb_add<T>(tb: Table<T>, r: T) -> int { export function tb_add<T>(tb: Table<T>, r: T) -> int {
var s = 0 var s = 0
if len(tb.free) > 0 { s = List.pop(tb.free) } else { if len(tb.free) > 0 { s = List.pop(tb.free) } else { s = tb_new_slot(tb) }
s = len(tb.slot_gen)
push(tb.slot_gen, 0)
push(tb.slot_row, -1)
}
tb.slot_row[s] = tb.n tb.slot_row[s] = tb.n
let h = s | (tb.slot_gen[s] << 22) let h = s | (tb.slot_gen[s] << 22)
push(tb.rec, r) tb_push_row(tb, r, h)
push(tb.ent, h)
push(tb.added, tb.tick)
push(tb.changed, tb.tick)
for c in 0 .. len(tb.f) { push(tb.f[c], 0.0) }
for c in 0 .. len(tb.i) { push(tb.i[c], 0) }
tb.n += 1
for g in 0 .. len(tb.grids) { grid_grow(tb.grids[g]) }
for k in 0 .. len(tb.idx) { ix_grow(tb.idx[k]) }
tb_stamp(tb, tb.n - 1) tb_stamp(tb, tb.n - 1)
for k in 0 .. len(tb.on_add) { tb.on_add[k].run(h) } for k in 0 .. len(tb.on_add) { tb.on_add[k].run(h) }
return h return h
@ -91,6 +79,6 @@ export function tb_add<T>(tb: Table<T>, r: T) -> int {
function tb_stamp<T>(tb: Table<T>, row: int) -> void { function tb_stamp<T>(tb: Table<T>, row: int) -> void {
tb.changed[row] = tb.tick tb.changed[row] = tb.tick
let b = row >> 6 let b = row >> 6
while len(tb.blk) <= b { push(tb.blk, 0) } while len(tb.blk) <= b { kept_push(tb.blk, 0) }
tb.blk[b] = tb.tick tb.blk[b] = tb.tick
} }

View file

@ -13,7 +13,7 @@ export function tb_remove<T>(tb: Table<T>, h: int) -> bool {
let s = h & ECS_SLOT let s = h & ECS_SLOT
tb.slot_row[s] = -1 tb.slot_row[s] = -1
tb.slot_gen[s] = (tb.slot_gen[s] + 1) & ECS_GEN tb.slot_gen[s] = (tb.slot_gen[s] + 1) & ECS_GEN
push(tb.free, s) kept_push(tb.free, s)
return true return true
} }

View file

@ -0,0 +1,43 @@
# ludic.base/ecs_table_reserve.ludic - room made at the start: a table sized for its busiest moment
# never grows in play, and nothing that grows is ever given back. What does grow past it is here
export function tb_reserve<T>(tb: Table<T>, rows: int) -> void {
let k = rows - tb.n
if k <= 0 { return }
for j in 0 .. k { tb_push_row(tb, null, 0) }
for j in 0 .. k { tb_pop(tb) }
let s0 = len(tb.slot_gen)
for j in s0 .. rows {
push(tb.slot_gen, 0)
push(tb.slot_row, -1)
push(tb.free, 0)
}
for j in s0 .. rows {
List.pop(tb.slot_gen)
List.pop(tb.slot_row)
List.pop(tb.free)
}
while len(tb.blk) <= (rows >> 6) { push(tb.blk, 0) }
for g in 0 .. len(tb.grids) { grid_reserve(tb.grids[g], rows) }
}
# a row on every column, grid and index
@alloc_ok("past tb_reserve's rows only: a table grows to its high-water once and keeps it")
function tb_push_row<T>(tb: Table<T>, r: T, h: int) -> void {
push(tb.rec, r)
push(tb.ent, h)
push(tb.added, tb.tick)
push(tb.changed, tb.tick)
for c in 0 .. len(tb.f) { push(tb.f[c], 0.0) }
for c in 0 .. len(tb.i) { push(tb.i[c], 0) }
tb.n += 1
for g in 0 .. len(tb.grids) { grid_grow(tb.grids[g]) }
for k in 0 .. len(tb.idx) { ix_grow(tb.idx[k]) }
}
@alloc_ok("past tb_reserve's rows only: a slot is made once and reused through the free list")
function tb_new_slot<T>(tb: Table<T>) -> int {
let s = len(tb.slot_gen)
push(tb.slot_gen, 0)
push(tb.slot_row, -1)
return s
}

View file

@ -59,7 +59,7 @@ export function tb_changed_since<T>(tb: Table<T>, since: int, out: words) -> int
if tb.blk[b] <= since { continue } if tb.blk[b] <= since { continue }
var end = b * 64 + 64 var end = b * 64 + 64
if end > tb.n { end = tb.n } if end > tb.n { end = tb.n }
for r in b * 64 .. end { if tb.changed[r] > since { push(out, r) } } for r in b * 64 .. end { if tb.changed[r] > since { kept_push(out, r) } }
} }
return len(out) return len(out)
} }

View file

@ -0,0 +1,62 @@
# ludic.base/handle.ludic - ids a pool hands out, each a slot and a generation (Table's encoding): a
# freed id's slot is reused under a new generation, so an id kept past its free misses with -1
export property HandlePool {
name: string = ""
gen: words = null # slot -> generation
live: words = null # slot -> 1 while handed out
free: words = null
n: int = 0 # slots made so far
count: int = 0 # handed out now
refused: int = 0 # asks past the capacity
strict: bool = true # past the capacity is a panic naming the pool, else -1
}
# everything the pool will ever hold, made now: nothing after this allocates
export function hd_pool_new(name: string, cap: int) -> HandlePool {
let p = new HandlePool
p.name = name
p.gen = words(cap)
p.live = words(cap)
p.free = words(cap)
List.clear(p.free)
return p
}
export function hd_new(p: HandlePool) -> int {
var s = -1
if len(p.free) > 0 { s = List.pop(p.free) }
else if p.n < len(p.gen) {
s = p.n
p.n += 1
} else { return hd_refuse(p) }
p.live[s] = 1
p.count += 1
return s | (p.gen[s] << 22)
}
function hd_refuse(p: HandlePool) -> int {
p.refused += 1
if p.strict { panic(`handle pool {p.name} is full at {len(p.gen)}`) }
return ECS_NONE
}
# the slot a handle names, or -1 when it was freed (or its slot has been handed out again since)
export function hd_slot(p: HandlePool, h: int) -> int {
if h < 0 { return -1 }
let s = h & ECS_SLOT
if s >= p.n or p.live[s] == 0 or p.gen[s] != ((h >> 22) & ECS_GEN) { return -1 }
return s
}
export function hd_free(p: HandlePool, h: int) -> bool {
let s = hd_slot(p, h)
if s < 0 { return false }
p.live[s] = 0
p.gen[s] = (p.gen[s] + 1) & ECS_GEN
kept_push(p.free, s)
p.count -= 1
return true
}
export function hd_live(p: HandlePool) -> int { return p.count }
export function hd_cap(p: HandlePool) -> int { return len(p.gen) }

View file

@ -3,6 +3,8 @@
module ludic_base uses module ludic_base uses
numbers float numbers float
import "tick.ludic" import "tick.ludic"
import "kept.ludic"
import "handle.ludic"
import "queue.ludic" import "queue.ludic"
import "text_buf.ludic" import "text_buf.ludic"
import "text_intern.ludic" import "text_intern.ludic"
@ -14,6 +16,7 @@ import "system.ludic"
import "ecs_table.ludic" import "ecs_table.ludic"
import "ecs_hooks.ludic" import "ecs_hooks.ludic"
import "ecs_table_remove.ludic" import "ecs_table_remove.ludic"
import "ecs_table_reserve.ludic"
import "ecs_table_set.ludic" import "ecs_table_set.ludic"
import "ecs_grid.ludic" import "ecs_grid.ludic"
import "ecs_grid_file.ludic" import "ecs_grid_file.ludic"

View file

@ -0,0 +1,4 @@
# ludic.base/kept.ludic - the one push the frame is allowed: into a list its owner keeps and clears,
# which grows to the most it has ever held and stays there (a queue's halves, a query's out list)
@alloc_ok("a kept list: it grows to the most it has held once, is cleared and reused, never dropped")
export function kept_push<T>(xs: []T, v: T) -> void { push(xs, v) }

View file

@ -30,7 +30,7 @@ export function queue_new<T>(name: string) -> Queue<T> {
} }
export function q_push<T>(q: Queue<T>, v: T) -> void { export function q_push<T>(q: Queue<T>, v: T) -> void {
push(q.items, v) kept_push(q.items, v)
q.tag.pending = len(q.items) q.tag.pending = len(q.items)
} }
@ -65,7 +65,7 @@ export function q_unheld<T>(q: Queue<T>, pool: []T, out: []T) -> int {
var held = false var held = false
for j in 0 .. len(q.items) { if q.items[j] == r { held = true } } for j in 0 .. len(q.items) { if q.items[j] == r { held = true } }
for j in 0 .. len(q.spare) { if q.spare[j] == r { held = true } } for j in 0 .. len(q.spare) { if q.spare[j] == r { held = true } }
if not held { push(out, r) } if not held { kept_push(out, r) }
} }
return len(out) return len(out)
} }

View file

@ -17,6 +17,8 @@ export property System {
export open registry Systems of System as SYS export open registry Systems of System as SYS
# the declared systems; a registry is not filled when a state's defaults are made, so on first ask
@alloc_ok("once: the runner's list is made the first time it is asked, and kept")
function cs_all(base_st: mut BaseState) -> []System { function cs_all(base_st: mut BaseState) -> []System {
if base_st.cs_list == null { if base_st.cs_list == null {
base_st.cs_list = new []System base_st.cs_list = new []System

View file

@ -179,8 +179,26 @@ program EcsTest {
test "observers hear each add, and a remove once" (hook_st: HookState) { hooks_case(hook_st) } test "observers hear each add, and a remove once" (hook_st: HookState) { hooks_case(hook_st) }
test "change detection skips the blocks nobody wrote" () { blocks_case() } test "change detection skips the blocks nobody wrote" () { blocks_case() }
test "a removed handle misses, rows stay dense, a reused slot is a new handle" () { handles_case() } test "a removed handle misses, rows stay dense, a reused slot is a new handle" () { handles_case() }
function reserve_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
let g = tb_grid(tb, CX, CZ, ON, 8.0)
let ix = tb_index(tb, KIND, ON)
put(tb, "a", 1.0, 1.0, 3)
tb_reserve(tb, 200)
expect_eq(tb_len(tb), 1)
var hs = new []int
for i in 0 .. 150 { push(hs, put(tb, "t", float(i), 2.0, i % 4)) }
expect_eq(tb_len(tb), 151)
expect_eq(ix_count(ix, 3), 38)
expect(tb_row(tb, hs[10]) >= 0)
expect(tb_remove(tb, hs[10]))
expect_eq(tb_row(tb, hs[10]), -1)
expect_eq(grid_count(g), 150)
}
test "the grid answers nearest and within, gated, after moves" () { grid_case() } test "the grid answers nearest and within, gated, after moves" () { grid_case() }
test "the kind index follows the setters, the gate and removals" () { index_case() } test "the kind index follows the setters, the gate and removals" () { index_case() }
test "change ticks name the rows written since" () { ticks_case() } test "change ticks name the rows written since" () { ticks_case() }
test "IntMap puts, finds, deletes and grows" () { map_case() } test "IntMap puts, finds, deletes and grows" () { map_case() }
test "a reserved table holds its rows and indexes as an unreserved one does" () { reserve_case() }
} }

View file

@ -0,0 +1,38 @@
# handle_test.ludic - a pool's ids: a freed one misses once its slot is reused, the generation wraps,
# and past the capacity a pool that is not strict refuses rather than grows
import "ludic.base"
program HandleTest {
numbers float
test "a freed handle misses, and its slot comes back under a new generation" {
let p = hd_pool_new("bufs", 4)
p.strict = false
let a = hd_new(p)
let b = hd_new(p)
expect_eq(hd_slot(p, a), 0)
expect_eq(hd_slot(p, b), 1)
expect(hd_free(p, a))
expect_eq(hd_slot(p, a), -1)
expect(not hd_free(p, a))
let c = hd_new(p)
expect_eq(hd_slot(p, c), 0)
expect(c != a)
expect_eq(hd_slot(p, a), -1)
expect_eq(hd_live(p), 2)
}
test "the generation wraps after 512 reuses and the pool never grows past its capacity" {
let p = hd_pool_new("once", 2)
p.strict = false
var h = hd_new(p)
let first = h
for i in 0 .. 511 {
hd_free(p, h)
h = hd_new(p)
}
hd_free(p, h)
expect_eq(hd_new(p), first)
hd_new(p)
expect_eq(hd_new(p), -1)
expect_eq(p.refused, 1)
expect_eq(hd_cap(p), 2)
}
}

View file

@ -34,6 +34,7 @@ function sb_same(sb: StrBuf, s: string) -> bool {
} }
# the table's string for the buffer's bytes # the table's string for the buffer's bytes
@alloc_ok("intern: one string per distinct text, up to the table's most, then kept")
export function sb_intern(sb: StrBuf, tb: StrTable) -> string { export function sb_intern(sb: StrBuf, tb: StrTable) -> string {
let h = sb_hash(sb) let h = sb_hash(sb)
let mask = len(tb.hs) - 1 let mask = len(tb.hs) - 1