feat(cli): 0.R5 - ludic deps says what a function can come to change (widest_write_reach, --wreach N); a port member and a registry field reach only themselves

A reach through Port.member() follows that member's binding (or its default), and Registry[i].field -
or a local holding Registry[i] - follows that field in each entry, so a question asked of a port or a
table that also holds verbs no longer reaches the verbs. In Maroon Lake that took the valley's
'what is this Thing called' from 47 states it could change to 1. examples/state/write_reach.ludic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-26 23:19:54 +03:00
parent c64f8750e2
commit 5df0747642
7 changed files with 46858 additions and 43583 deletions

View file

@ -0,0 +1,7 @@
bump: minor
type: feat
**`ludic deps` says what a function can come to CHANGE** (`widest_write_reach`, and `--wreach N`
lists the functions by it): the states it reaches as `mut`, through calls, `fn` values and step
lists. Reach itself is sharper: `Port.member()` reaches that member's binding only, and
`Registry[i].field` (or a local holding `Registry[i]`) reaches that field only - a question asked of
a port or a table that also holds verbs no longer reaches the verbs.

View file

@ -0,0 +1,35 @@
# ludic deps --wreach: what a function can come to CHANGE. A port asked one member reaches that
# member's binding only, and a registry read for one field reaches that field only - so a
# question stays a question even when the port or the table beside it also holds a verb.
program WriteReach {
state Store { n: int = 0 }
state Log { lines: int = 0 }
function store_count(s: Store) -> int { return s.n }
function store_add(s: mut Store) -> void { s.n += 1 }
port Shelf {
count: fn() -> int
add: fn() -> void
}
bind Shelf { count: fn store_count, add: fn store_add }
function log_note(l: mut Log) -> void { l.lines += 1 }
property Kind {
key: string = ""
size: int = 0
note: fn() -> void = null
}
registry Kinds of Kind as KD
def Kinds box { size: 3, note: fn log_note }
function asks() -> int { return Shelf.count() + Kinds[KD_BOX].size }
function asks_local() -> int {
let k = Kinds[KD_BOX]
return k.size
}
function changes() -> void {
Shelf.add()
Kinds[KD_BOX].note()
}
entry {
changes()
print(`{asks()} {asks_local()}`)
}
}

View file

@ -9,6 +9,7 @@
# alias <owner> <global> <from> <file>:<line> <local> (a write through a local, below)
# width <n> <name> <file>:<line> <pkg> (the states it takes)
# reach <n> <name> <file>:<line> <pkg> (the states it can come to, fn values too)
# wreach <n> <name> <file>:<line> <pkg> (of those, the ones it can come to change: `mut`)
var g_dp_on: int = -1
var g_dp_key: []pointer = new []pointer # "from to"
var g_dp_cnt: []int = new []int
@ -273,7 +274,10 @@ var g_dr_name: []pointer = new []pointer # the declarations a name can re
var g_dr_node: []Node = new []Node
var g_dr_refs: [][]int = new [][]int # what each one names
var g_dr_bits: [][]int = new [][]int # the states it reaches, 60 to a word
var g_dr_wbits: [][]int = new [][]int # ... and of them the ones it takes `mut` somewhere down
var g_dr_find_k: []pointer = new []pointer
var g_dr_rl_name: []pointer = new []pointer # a local holding one entry of a registry ...
var g_dr_rl_reg: []pointer = new []pointer # ... and which registry
var g_dr_find_v: []Node = new []Node
function dr_state_ix(t: pointer) -> int {
var i = 0
@ -292,9 +296,20 @@ function dr_index(name: pointer) -> int {
}
function dr_walk(n: Node, refs: []int) -> void {
if n == null { return }
if dr_port_member(n, refs) { return }
if n.kind == S_LET and n.s != null and n.a != null and n.a.kind == E_INDEX and n.a.a != null and n.a.a.kind == E_ID and n.a.a.s != null and reg_find(n.a.a.s) >= 0 {
push(g_dr_rl_name, n.s)
push(g_dr_rl_reg, n.a.a.s)
dr_walk(n.a.b, refs)
return
}
if dr_reg_field(n, refs) { return }
if (n.kind == E_ID or n.kind == E_FNREF) and n.s != null {
let k = dr_index(n.s)
if k >= 0 { push(refs, k) }
# a registry entry held in a local and handed on whole reaches the whole registry
let rl = dr_reg_local(n.s)
if k < 0 and rl != null and dr_index(rl) >= 0 { push(refs, dr_index(rl)) }
}
dr_walk(n.a, refs)
dr_walk(n.b, refs)
@ -307,11 +322,75 @@ function dr_walk(n: Node, refs: []int) -> void {
}
}
}
function dr_own(d: Node, bits: []int) -> void {
# `Port.member` reaches what that member is bound to (or its default), not every member of the port:
# a port asked for its save is not asked for its load
function dr_port_member(n: Node, refs: []int) -> bool {
if n.kind != E_MEMBER or n.a == null or n.a.kind != E_ID or n.a.s == null or n.s == null { return false }
let k = dr_index(n.a.s)
if k < 0 { return false }
let v = g_dr_node[k]
if not is_port_var(v) { return false }
if v.a != null and v.a.kind == E_NEW and v.a.a != null {
let rec = v.a.a
var i = 0
while i < len(rec.kids) {
if (rec.kids[i].s == n.s) {
dr_walk(rec.kids[i].a, refs)
return true
}
i += 1
}
}
let c = g_pt_comp[port_find(v.s)]
var j = 0
while j < len(c.kids) {
if (c.kids[j].s == n.s) { dr_walk(c.kids[j].a, refs) }
j += 1
}
return true
}
# `Registry[i].field` reaches what that field holds in each entry, not every fn of every field: a
# kind asked whether it is personal is not asked to be used
function dr_reg_local(name: pointer) -> pointer {
var i = len(g_dr_rl_name) - 1
while i >= 0 {
if (g_dr_rl_name[i] == name) { return g_dr_rl_reg[i] }
i -= 1
}
return null
}
function dr_reg_field(n: Node, refs: []int) -> bool {
if n.kind != E_MEMBER or n.s == null or n.a == null { return false }
var reg: pointer = null
if n.a.kind == E_INDEX and n.a.a != null and n.a.a.kind == E_ID and n.a.a.s != null and reg_find(n.a.a.s) >= 0 {
reg = n.a.a.s
dr_walk(n.a.b, refs)
}
if reg == null and n.a.kind == E_ID and n.a.s != null { reg = dr_reg_local(n.a.s) }
if reg == null { return false }
let k = dr_index(reg)
if k < 0 { return false }
let v = g_dr_node[k]
if v.kind != N_VAR or v.a == null or v.a.kind != E_LIST { return false }
var i = 0
while i < len(v.a.kids) {
let e = v.a.kids[i]
if e.kind == E_NEW and e.a != null {
var j = 0
while j < len(e.a.kids) {
if (e.a.kids[j].s == n.s) { dr_walk(e.a.kids[j].a, refs) }
j += 1
}
}
i += 1
}
return true
}
function dr_own(d: Node, bits: []int, only_mut: bool) -> void {
if d.kind == N_FN {
var k = 0
while k < len(d.kids) {
if d.kids[k].kind == N_PARAM {
if d.kids[k].kind == N_PARAM and (not only_mut or d.kids[k].uns == 1) {
let s = dr_state_ix(d.kids[k].ty)
if s >= 0 { dr_set(bits, s) }
}
@ -332,6 +411,20 @@ function dr_count(bits: []int) -> int {
}
return n
}
# `mine` takes in `theirs`; true when it grew
function dr_join(mine: []int, theirs: []int, nw: int) -> bool {
var grew = false
var w = 0
while w < nw {
let u = mine[w] | theirs[w]
if u != mine[w] {
mine[w] = u
grew = true
}
w += 1
}
return grew
}
function deps_reach(f: pointer) -> void {
ck_tab_init(g_dr_find_k, g_dr_find_v)
var i = 0
@ -350,6 +443,8 @@ function deps_reach(f: pointer) -> void {
var j = 0
while j < len(g_dr_node) {
let refs = new []int
g_dr_rl_name = new []pointer
g_dr_rl_reg = new []pointer
dr_walk(g_dr_node[j], refs)
push(g_dr_refs, refs)
let bits = new []int
@ -358,8 +453,16 @@ function deps_reach(f: pointer) -> void {
push(bits, 0)
w += 1
}
dr_own(g_dr_node[j], bits)
dr_own(g_dr_node[j], bits, false)
push(g_dr_bits, bits)
let wb = new []int
var w3 = 0
while w3 < nw {
push(wb, 0)
w3 += 1
}
dr_own(g_dr_node[j], wb, true)
push(g_dr_wbits, wb)
j += 1
}
var changed = true
@ -367,20 +470,11 @@ function deps_reach(f: pointer) -> void {
changed = false
j = 0
while j < len(g_dr_node) {
let mine = g_dr_bits[j]
let refs = g_dr_refs[j]
var r = 0
while r < len(refs) {
let theirs = g_dr_bits[refs[r]]
var w2 = 0
while w2 < nw {
let u = mine[w2] | theirs[w2]
if u != mine[w2] {
mine[w2] = u
changed = true
}
w2 += 1
}
if dr_join(g_dr_bits[j], g_dr_bits[refs[r]], nw) { changed = true }
if dr_join(g_dr_wbits[j], g_dr_wbits[refs[r]], nw) { changed = true }
r += 1
}
j += 1
@ -394,6 +488,8 @@ function deps_reach(f: pointer) -> void {
var pkg = 0
if not (pkg_of_file(d.file) == "") { pkg = 1 }
if n > 0 { deps_line(f, `reach {itoa(n)} {d.s} {d.file}:{itoa(d.line)} {itoa(pkg)}`) }
let wn = dr_count(g_dr_wbits[j])
if wn > 0 { deps_line(f, `wreach {itoa(wn)} {d.s} {d.file}:{itoa(d.line)} {itoa(pkg)}`) }
}
j += 1
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@
# ludic deps --baseline FILE write the numbers to FILE
# ludic deps --widest N the N functions that take the most states, and what each reaches
# ludic deps --reach N the same, the N that reach the most
# ludic deps --wreach N the N that can come to CHANGE the most states
#
# dependencies: module pairs (a, b) where a uses something of b, not counting b when b uses no
# other module (a leaf); largest_cycle: the largest set of modules that all reach each other, not
@ -23,7 +24,8 @@
# globals, each counted once; widest_function: the most states any function or entry point of the
# program's own takes (0.R2 - a reducer takes one, and the dispatchers should come down to a few);
# widest_reach: the most states any of them can come to - what it takes, and through calls, `fn f`
# and globals holding fn values (a step list, a registry of systems) what those take (0.R4).
# and globals holding fn values (a step list, a registry of systems) what those take (0.R4);
# widest_write_reach: of those, the most it can come to change - taken `mut` somewhere down (0.R5).
var dp_mods: []pointer = null
var dp_pkg: []int = null
var dp_uses: []pointer = null
@ -45,6 +47,9 @@ var dp_reach_own: int = 0 # ... of which it takes itself
var dp_fn_at: []pointer = null # each of the program's own functions: where, what it takes, what it reaches
var dp_fn_w: []int = null
var dp_fn_r: []int = null
var dp_fn_x: []int = null # ... and the states it can come to change
var dp_wreach_n: int = 0
var dp_wreach_at: pointer = ""
function dp_words(line: pointer) -> []pointer {
let out = new []pointer
@ -79,7 +84,8 @@ function dp_load(path: pointer) -> bool {
dp_ef = new []int; dp_et = new []int; dp_ec = new []int; dp_en = new []pointer
dp_wide_n = 0; dp_wide_at = ""
dp_reach_n = 0; dp_reach_at = ""; dp_reach_own = 0
dp_fn_at = new []pointer; dp_fn_w = new []int; dp_fn_r = new []int
dp_fn_at = new []pointer; dp_fn_w = new []int; dp_fn_r = new []int; dp_fn_x = new []int
dp_wreach_n = 0; dp_wreach_at = ""
dp_writes = new []pointer; dp_aliases = new []pointer; dp_wowner = new []pointer; dp_wname = new []pointer; dp_wfrom = new []pointer
let lines = split_lines(text)
for i in 0 .. len(lines) {
@ -105,9 +111,9 @@ function dp_load(path: pointer) -> bool {
dp_wide_n = s_to_int(w[1])
dp_wide_at = `{dp_fn_name(w[2])} ({w[3]})`
}
if len(w) >= 5 and (w[0] == "width" or w[0] == "reach") and w[4] == "0" {
if len(w) >= 5 and (w[0] == "width" or w[0] == "reach" or w[0] == "wreach") and w[4] == "0" {
let k = dp_fn(`{dp_fn_name(w[2])} ({w[3]})`)
if w[0] == "width" { dp_fn_w[k] = s_to_int(w[1]) } else { dp_fn_r[k] = s_to_int(w[1]) }
if w[0] == "width" { dp_fn_w[k] = s_to_int(w[1]) } else if w[0] == "reach" { dp_fn_r[k] = s_to_int(w[1]) } else { dp_fn_x[k] = s_to_int(w[1]) }
}
if len(w) >= 5 and w[0] == "write" {
push(dp_wowner, w[1])
@ -124,6 +130,7 @@ function dp_fn(at: pointer) -> int {
push(dp_fn_at, at)
push(dp_fn_w, 0)
push(dp_fn_r, 0)
push(dp_fn_x, 0)
return len(dp_fn_at) - 1
}
# after the load: the widest reach (a function reaches at least what it takes)
@ -135,42 +142,51 @@ function dp_reach_best() -> void {
dp_reach_at = dp_fn_at[i]
dp_reach_own = dp_fn_w[i]
}
if dp_fn_x[i] > dp_wreach_n {
dp_wreach_n = dp_fn_x[i]
dp_wreach_at = dp_fn_at[i]
}
}
}
# --widest N / --reach N: the top N functions by what they take (or by what they reach), both shown
function dp_top(n: int, by_reach: bool) -> void {
function dp_top(n: int, by: int) -> void {
let order = new []int
for i in 0 .. len(dp_fn_at) { push(order, i) }
for a in 1 .. len(order) {
let x = order[a]
var b = a - 1
while b >= 0 and dp_top_before(x, order[b], by_reach) {
while b >= 0 and dp_top_before(x, order[b], by) {
order[b + 1] = order[b]
b -= 1
}
order[b + 1] = x
}
print("takes reaches function")
print("takes reaches changes function")
var shown = 0
for i in 0 .. len(order) {
let k = order[i]
if shown < n and (dp_fn_w[k] > 0 or dp_fn_r[k] > 0) {
print(`{dp_pad(string(dp_fn_w[k]), 5)} {dp_pad(string(dp_fn_r[k]), 7)} {dp_fn_at[k]}`)
print(`{dp_pad(string(dp_fn_w[k]), 5)} {dp_pad(string(dp_fn_r[k]), 7)} {dp_pad(string(dp_fn_x[k]), 7)} {dp_fn_at[k]}`)
shown += 1
}
}
}
function dp_top_before(x: int, y: int, by_reach: bool) -> bool {
# by: 0 what it takes, 1 what it reaches, 2 what it can come to change
function dp_top_before(x: int, y: int, by: int) -> bool {
var kx = dp_fn_w[x]
var ky = dp_fn_w[y]
var tx = dp_fn_r[x]
var ty = dp_fn_r[y]
if by_reach {
if by == 1 {
kx = dp_fn_r[x]
ky = dp_fn_r[y]
tx = dp_fn_w[x]
ty = dp_fn_w[y]
}
if by == 2 {
kx = dp_fn_x[x]
ky = dp_fn_x[y]
}
if kx != ky { return kx > ky }
return tx > ty
}
@ -290,7 +306,7 @@ var dp_vals: []int = null
var dp_cycle: []int = null
var dp_cycle_all: []int = null # the same with the layers' own edges counted
function dp_numbers() -> void {
dp_names = ["modules", "dependencies", "largest_cycle", "cross_writes", "globals_written_from_outside", "widest_function", "widest_reach"]
dp_names = ["modules", "dependencies", "largest_cycle", "cross_writes", "globals_written_from_outside", "widest_function", "widest_reach", "widest_write_reach"]
var mods = 0
for k in 0 .. len(dp_mods) { if dp_own(k) { mods += 1 } }
var deps = 0
@ -312,7 +328,7 @@ function dp_numbers() -> void {
}
}
dp_reach_best()
dp_vals = [mods, deps, len(dp_cycle), writes, len(seen), dp_wide_n, dp_reach_n]
dp_vals = [mods, deps, len(dp_cycle), writes, len(seen), dp_wide_n, dp_reach_n, dp_wreach_n]
}
# ---- the command --------------------------------------------------------------
@ -322,6 +338,8 @@ function dp_print_numbers() -> void {
if dp_wide_n == 1 { print(`the widest function: {dp_wide_at}, 1 state`) }
if dp_wide_n > 1 { print(`the widest function: {dp_wide_at}, {string(dp_wide_n)} states`) }
if dp_reach_n > dp_reach_own { print(`the widest reach: {dp_reach_at}, {string(dp_reach_n)} states ({string(dp_reach_n - dp_reach_own)} through calls and fn values it does not take)`) }
if dp_wreach_n == 1 { print(`the widest write reach: {dp_wreach_at}, 1 state it can come to change`) }
if dp_wreach_n > 1 { print(`the widest write reach: {dp_wreach_at}, {string(dp_wreach_n)} states it can come to change`) }
let layers = dp_layers()
if layers != "" {
print(`layers: {layers}`)
@ -464,7 +482,7 @@ function cmd_deps() -> int {
if a == "--dot" { mode = "dot" }
else if a == "--graph" { mode = "graph" }
else if a == "--writes" { mode = "writes" }
else if a == "--uses" or a == "--check" or a == "--baseline" or a == "--widest" or a == "--reach" {
else if a == "--uses" or a == "--check" or a == "--baseline" or a == "--widest" or a == "--reach" or a == "--wreach" {
if ai + 1 >= arg_count() { err(`ludic deps: {a} needs an argument\n`); return 2 }
mode = sslice(a, 2, slen(a))
ai += 1
@ -472,7 +490,7 @@ function cmd_deps() -> int {
}
else if a[0] == '-' {
err(`ludic deps: unknown option {a}\n`)
err(" usage: ludic deps [file] [--graph | --dot | --writes | --uses MOD | --check FILE | --baseline FILE | --widest N | --reach N]\n")
err(" usage: ludic deps [file] [--graph | --dot | --writes | --uses MOD | --check FILE | --baseline FILE | --widest N | --reach N | --wreach N]\n")
return 2
}
else { src = a }
@ -505,8 +523,9 @@ function cmd_deps() -> int {
return 0
}
if mode == "uses" { dp_uses_of(arg2); return 0 }
if mode == "widest" { dp_top(s_to_int(arg2), false); return 0 }
if mode == "reach" { dp_top(s_to_int(arg2), true); return 0 }
if mode == "widest" { dp_top(s_to_int(arg2), 0); return 0 }
if mode == "reach" { dp_top(s_to_int(arg2), 1); return 0 }
if mode == "wreach" { dp_top(s_to_int(arg2), 2); return 0 }
if mode == "check" { return dp_check(arg2) }
if mode == "baseline" { return dp_baseline(arg2) }
dp_print_numbers()

View file

@ -476,7 +476,7 @@ function deps_case() -> void {
let lbl = "ludic deps: the numbers, --check against a baseline, --dot, --writes"
let p = "examples/modules/tangle.ludic"
let got = capture(`bin/ludic deps {p} 2>&1`)
let want = "modules: 3\ndependencies: 2\nlargest_cycle: 2\ncross_writes: 1\nglobals_written_from_outside: 1\nwidest_function: 1\nwidest_reach: 1\nin the largest cycle: a b\nthe widest function: a_run (examples/modules/tangle/a/index.ludic:3), 1 state\n"
let want = "modules: 3\ndependencies: 2\nlargest_cycle: 2\ncross_writes: 1\nglobals_written_from_outside: 1\nwidest_function: 1\nwidest_reach: 1\nwidest_write_reach: 1\nin the largest cycle: a b\nthe widest function: a_run (examples/modules/tangle/a/index.ludic:3), 1 state\nthe widest write reach: a_run (examples/modules/tangle/a/index.ludic:3), 1 state it can come to change\n"
if not (s_trim(got) == s_trim(want)) { bad2(lbl, `said [{s_trim(got)}]`); return }
let base = `{tmp_dir()}/deps-base.txt`
if not shq(`bin/ludic deps {p} --baseline {base} > /dev/null`) { bad2(lbl, "--baseline failed"); return }
@ -590,9 +590,16 @@ function deps_reach_case() -> void {
let got = capture(`bin/ludic deps examples/state/reach.ludic 2>&1`)
if not s_contains(got, "widest_reach: 3") or not s_contains(got, "the widest reach: one (examples/state/reach.ludic:13), 3 states (2 through calls and fn values it does not take)") { bad2(lbl, s_trim(got)); return }
let top = capture(`bin/ludic deps examples/state/reach.ludic --reach 2 2>&1`)
if not s_contains(top, " 1 3 one (examples/state/reach.ludic:13)\n 0 2 run_steps (examples/state/reach.ludic:10)") { bad2(lbl, s_trim(top)); return }
if not s_contains(top, " 1 3 3 one (examples/state/reach.ludic:13)\n 0 2 2 run_steps (examples/state/reach.ludic:10)") { bad2(lbl, s_trim(top)); return }
let wide = capture(`bin/ludic deps examples/state/reach.ludic --widest 1 2>&1`)
if s_contains(wide, "takes reaches function") and s_contains(wide, "one (examples/state/reach.ludic:13)") and not s_contains(wide, "step_a") { ok(lbl) } else { bad2(lbl, s_trim(wide)) }
if s_contains(wide, "takes reaches changes function") and s_contains(wide, "one (examples/state/reach.ludic:13)") and not s_contains(wide, "step_a") { ok(lbl) } else { bad2(lbl, s_trim(wide)) }
}
# 0.R5: what a function can come to CHANGE; a port member and a registry field reach only themselves
function deps_wreach_case() -> void {
let lbl = "ludic deps: --wreach, a question through a port or a registry field changes nothing"
let top = capture(`bin/ludic deps examples/state/write_reach.ludic --wreach 10 2>&1`)
if not s_contains(top, " 0 2 2 changes (examples/state/write_reach.ludic:27)") or not s_contains(top, " 0 1 0 asks (examples/state/write_reach.ludic:22)") or s_contains(top, "asks_local") { bad2(lbl, s_trim(top)); return }
ok(lbl)
}
# a program built headless with the toolchain and run: its first line
function headless_case(path: pointer, exp: pointer, label: pointer) -> void {
@ -1017,6 +1024,7 @@ function cmd_dev_test() -> int {
feat_case("actions/modules", "", "2", "modules.ludic (0.R: an action in one module, its reducer in another)")
actions_deps_case()
deps_reach_case()
deps_wreach_case()
feat_case("state/reach", "", "3", "reach.ludic (0.R4: a step list of fn values supplies each step its states)")
feat_case("actions/phases", "dd a q", "1 0 2 0 2 1 1 1 1 2 1 2", "phases.ludic (0.R: the frame loop drains the queue after every phase - Input's actions are reduced before Update)")
feat_case("actions/runaway", "", "actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what dispatches it", "runaway.ludic (0.R: a reducer that dispatches what dispatches it is stopped by name)")