Skip to content

Commit f9e1a7a

Browse files
Mutable closures + module aliasing + benchmark suite
Track 1 — Mutable closures via Rc<RefCell> capture: Closure capture went from snapshot-by-value to shared-reference. Bank-account pattern works: multiple closures created in the same scope share the same captured bindings; mutations propagate. fn make_account(balance) { h deposit = fn(amount) { balance = balance + amount; ... }; h withdraw = fn(amount) { balance = balance - amount; ... }; ... } # deposit(50) then withdraw(30) → balance = 120, both see it Architecture changes: Value::Function.captured: HashMap → Rc<RefCell<HashMap>> Interpreter.locals: Vec<HashMap> → Vec<Rc<RefCell<HashMap>>> Lambda eval clones the current scope's Rc (siblings share) New assign_var method walks outward looking for existing binding Statement::Assignment now uses assign_var (not set_var) call_first_class_function pushes captured env Rc as a scope frame 9 locals-access sites refactored to use the new Rc semantics Counter pattern: works (3 → 4 with second closure independent). Bank account pattern: works (100 → 150 → 120 → 120 across siblings). Test runner still passes 5/6 (1 intentional failure unchanged). Track 2 — Module aliasing (import "path" as alias): The optional `as` clause was previously parsed but ignored. Now wired through: aliased imports register their functions as literal "alias.fname" keys in the function table. import "examples/math_module.omc" as math; println(arr_join(math.fib_up_to(100), ", ")); Module resolver gained literal-path support — absolute paths, ./relative paths, and .omc-suffixed names now resolve directly without OMC_STDLIB_PATH. Short names still go through search paths for stdlib imports. Fixed an infinite-recursion bug while wiring this: call_function now checks self.functions for the exact name BEFORE splitting at `.`, so `call_function("math.fib") → call_module_function("math", "fib") → call_function("math.fib")` doesn't loop. Two example files: examples/math_module.omc — reusable utility module examples/module_demo.omc — usage demo Track 3 — Benchmark suite (examples/benchmarks.omc): OMC's first proper benchmark suite. Times int arithmetic, string ops, array ops, harmonic primitives, and recursive fib with now_ms() and reports per-op nanoseconds. Tree-walk numbers (200K int_adds in 89ms = 445 ns/op, recursive fib(22) in 53ms = 2.4ms/op). Honest finding: OMC_VM=1 produces nearly-identical numbers on this suite because benchmarks dispatch user fns via call(fn, args), which routes through invoke_user_function (tree-walk). The VM advantage applies to direct Op::Call paths, not reflective dispatch. Useful signal for future VM work. VM gains first-class-function-value fallback: Op::LoadVar now falls back to checking module.functions and the interpreter's function table when a name isn't a variable, so arr_map(xs, bench_int_add) works under OMC_VM=1. main.rs pre-registers user fn definitions into the VM's internal interpreter (vm.interp_mut().register_user_functions(&statements)) before vm.run_module(...) so reflective dispatch resolves them. Documentation: STDLIB.md — closures section rewritten for mutable semantics; counter and bank-account examples; VM caveat noted. README — "What's proven right now" gains four rows (mutable closures, modules, benchmark suite, plus the test runner that was already there). CHANGELOG — full track-by-track entry. Regression: V.9b ✓✓✓ unchanged. H.5: 6/6 converge. safe_keyword_host on both interpretation paths identical. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b3d362d commit f9e1a7a

10 files changed

Lines changed: 574 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,97 @@ All notable changes to OMNIcode will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added (Mutable closures + module aliasing + benchmark suite, 2026-05-14)
8+
9+
🎯 **Three more architectural moves: closures gain shared mutable state, the module system gets namespaced imports, and OMC has its first benchmark suite.**
10+
11+
#### Track 1 — Mutable closures (Rc<RefCell> capture)
12+
13+
The closure model went from snapshot-by-value to shared-reference. The bank-account pattern now works correctly:
14+
15+
```omc
16+
fn make_account(balance) {
17+
h deposit = fn(amount) { balance = balance + amount; return balance; };
18+
h withdraw = fn(amount) { balance = balance - amount; return balance; };
19+
h bal = fn() { return balance; };
20+
return [deposit, withdraw, bal];
21+
}
22+
23+
h acct = make_account(100);
24+
println(arr_get(acct, 0)(50)); # deposit: 150
25+
println(arr_get(acct, 1)(30)); # withdraw: 120
26+
println(arr_get(acct, 2)()); # balance: 120
27+
```
28+
29+
Architecture changes:
30+
- `Value::Function.captured`: `Option<HashMap>``Option<Rc<RefCell<HashMap>>>`.
31+
- `Interpreter.locals`: `Vec<HashMap>``Vec<Rc<RefCell<HashMap>>>`. Each scope frame is a shareable Rc.
32+
- Lambda evaluation clones the Rc of the current scope frame (instead of taking a HashMap snapshot). Sibling closures created in the same enclosing call see the SAME underlying map; mutations propagate.
33+
- New `assign_var` method on Interpreter: walks locals from inner to outer looking for an existing binding; if found, mutates in-place. `Statement::Assignment` now routes through `assign_var` instead of `set_var`. `h x = ...` (declaration) keeps using `set_var` to always create a fresh innermost binding.
34+
- `call_first_class_function` pushes the captured env Rc as a scope frame BEFORE the args frame, so lookups via lexical chain hit the captured bindings naturally.
35+
36+
The single-closure case (counter pattern) and multi-closure-shared-state (bank account) both work. Refactor touched 9 scope-access sites in `interpreter.rs`. Verified end-to-end with `examples/test_runner.omc` (which uses lambdas internally) and a counter/bank-account smoke test.
37+
38+
#### Track 2 — Module aliasing (`import "path" as alias`)
39+
40+
`import` already parsed an optional `as` clause but the alias was ignored. Now it's wired through:
41+
42+
```omc
43+
import "examples/math_module.omc" as math;
44+
println(arr_join(math.fib_up_to(100), ", ")); # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
45+
println(math.euclid_gcd(89, 144)); # 1 (consecutive Fibonacci → coprime)
46+
```
47+
48+
When an import has an alias, every function the module DEFINES gets renamed to `alias.fname` in the function table. Top-level statements still execute against the global namespace. Re-importing the same path is idempotent (deduped on path, not on alias).
49+
50+
The module resolver gained literal-path support — `import "/abs/path.omc"` and `import "./local.omc"` now work without `OMC_STDLIB_PATH` setup. Still falls back to search-path resolution for short names like `import core` or `import std/io`.
51+
52+
The dotted-call dispatch in `call_module_function` now checks for the full `module.fname` in the user function table BEFORE splitting at `.` and delegating. Otherwise we'd infinite-loop: `call_function("math.fib") → call_module_function("math", "fib") → call_function("math.fib") → …`. Fixed at the entry to `call_function` (check exact name before splitting).
53+
54+
Two new example files:
55+
- `examples/math_module.omc` — a reusable utility module with `fib_up_to`, `cube_root`, `sum_range`, `euclid_gcd`.
56+
- `examples/module_demo.omc` — demonstrates `import as` usage and idempotent re-import.
57+
58+
#### Track 3 — Benchmark suite (`examples/benchmarks.omc`)
59+
60+
OMC's first benchmark suite. Times common operations with `now_ms()` and reports per-operation nanoseconds. Run both ways:
61+
62+
```sh
63+
./target/release/omnimcode-standalone examples/benchmarks.omc # tree-walk
64+
OMC_VM=1 ./target/release/omnimcode-standalone examples/benchmarks.omc # Rust VM
65+
```
66+
67+
Sample output (tree-walk on a modern laptop):
68+
69+
```
70+
======================================================================
71+
OMC Benchmark Suite — N ops, ms total, ns per op
72+
======================================================================
73+
int_add (sum 0..N) 200000 iters 89 ms 445 ns/op
74+
int_mul (sum i*3 0..N) 200000 iters 104 ms 520 ns/op
75+
str_concat (build N a's) 20000 iters 24 ms 1200 ns/op
76+
str_split + str_join 20000 iters 28 ms 1400 ns/op
77+
arr_push + arr_get walk 5000 iters 523 ms 104600 ns/op
78+
is_fibonacci 0..N 50000 iters 19 ms 380 ns/op
79+
harmony_value 0..N 50000 iters 20 ms 400 ns/op
80+
recursive fib(N) 22 iters 53 ms 2409090 ns/op
81+
======================================================================
82+
```
83+
84+
**Honest finding** revealed by the benchmark: `OMC_VM=1` produces nearly-identical numbers to tree-walk on this suite. Reason: benchmarks dispatch their bodies via the new `call(fn, args)` primitive, which routes user-function calls through `invoke_user_function` (tree-walk semantics). The VM advantage applies to **direct** `Op::Call` dispatch, not to reflective `call(...)` dispatch.
85+
86+
That's exactly the kind of signal a benchmark suite should produce — concrete data about where the VM helps and where it doesn't. Future work could add a direct-call variant of the suite to isolate the VM hot path.
87+
88+
#### VM gains a first-class-function-value fallback
89+
90+
`Op::LoadVar` in the bytecode VM now falls back to checking `module.functions` AND the interpreter's function table when a name isn't a variable. This makes `arr_map(xs, bench_int_add)` work under `OMC_VM=1``bench_int_add` resolves as `Value::Function`. Tree-walk had this fallback already; the VM was missing it.
91+
92+
Also: `main.rs` now calls `vm.interp_mut().register_user_functions(&statements)` before `vm.run_module(...)`, pre-populating the interpreter's function table with user-defined fn bodies so reflective dispatch (`call(name, args)`) can resolve them at runtime.
93+
94+
#### Regression
95+
96+
V.9b: ✓✓✓. H.5: 6/6 demos converge. test_runner: 5/6 (1 intentional failure). safe_keyword_host on both tree-walk and OMC_VM=1: identical output. The 9-site `locals` refactor touched a lot of code but no surface broke.
97+
798
### Added (Closures + harmonic_hash/diff/dedupe + test runner — 15 new builtins, 2026-05-14)
899

9100
🎯 **Three more tracks land: closures over local scope, three new harmonic variants, and a built-in test runner.**

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ What this is **not**: a fast runtime, a production toolchain, a stable API, a de
4848
| OMNIcode harmonic variants — operations that USE the φ-math substrate | `examples/harmonic_variants.omc` | `harmonic_write_file` gates writes by resonance ≥ 0.5; `harmonic_sort` puts Fibonacci values first; `harmonic_split` chunks strings at φ-aligned word boundaries; `harmonic_partition` buckets by nearest attractor |
4949
| Closures over local scope (snapshot capture) | `examples/test_runner.omc` | `fn make_adder(n) { return fn(x) { return x + n; }; }` — partial application, currying, captured-state patterns |
5050
| Built-in test runner | `examples/test_runner.omc` | `fn test_*()` functions auto-discovered via `defined_functions()` and dispatched via `call(name, args)`. `assert_eq` / `assert_array_eq` etc. record failures in host-side state |
51+
| Mutable closures (Rc<RefCell> shared capture) | `examples/test_runner.omc` | Bank-account pattern: multiple closures from `make_account(100)` share the same `balance` binding; mutations propagate across all of them |
52+
| Module system with namespace aliasing | `examples/module_demo.omc` | `import "math_module.omc" as math` then `math.fib_up_to(100)``0,1,1,2,3,5,8,13,21,34,55,89`. Idempotent re-import; literal-path resolution |
53+
| Benchmark suite | `examples/benchmarks.omc` | Times `int_add` / `str_concat` / `arr_push` / `recursive fib(22)` / `is_fibonacci` etc. with per-op ns. Run with `OMC_VM=1` to compare against the bytecode VM |
5154

5255
Run any of these with the binary built from this repo:
5356

STDLIB.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -305,16 +305,32 @@ These take ordinary operations and route them through the φ-math substrate. Any
305305
| `call(fn, args_arr)` | `function, array -> T` | Dispatch a function value (or function-name string) with an arbitrary argument list unpacked from an array. Lets the test runner invoke zero-arg tests; lets user code do dynamic-arity dispatch. |
306306
| `defined_functions()` | `-> array<string>` | Sorted array of all user-defined function names. Auto-generated `__lambda_N` anonymous functions are excluded. Used by the test runner to discover `test_*` functions. |
307307

308-
Lambdas — `fn(params) { body }` expression form — capture the current local scope by VALUE (snapshot, not reference). Read-only closures over their environment. Mutable closures require shared refs and are future work.
308+
Lambdas — `fn(params) { body }` expression form — capture the enclosing local scope by REFERENCE (shared `Rc<RefCell>`). Multiple closures created in the same scope share state, and assignments to captured names propagate. Read-and-write closures.
309309

310310
```omc
311-
fn make_adder(n) {
312-
return fn(x) { return x + n; };
311+
fn make_counter() {
312+
h n = 0;
313+
return fn() {
314+
n = n + 1;
315+
return n;
316+
};
317+
}
318+
h c = make_counter();
319+
println(c()); # 1
320+
println(c()); # 2
321+
println(c()); # 3
322+
323+
# Multiple closures over shared state — bank account pattern:
324+
fn make_account(balance) {
325+
h deposit = fn(amount) { balance = balance + amount; return balance; };
326+
h withdraw = fn(amount) { balance = balance - amount; return balance; };
327+
h bal = fn() { return balance; };
328+
return [deposit, withdraw, bal];
313329
}
314-
h add5 = make_adder(5);
315-
println(add5(10)); # 15
316330
```
317331

332+
VM caveat: lambdas execute via tree-walk, not the Rust bytecode VM. The VM's `OMC_VM=1` path bails with an error on `Expression::Lambda` because the bytecode VM has no captured-scope plumbing. Tree-walk works cleanly.
333+
318334
---
319335

320336
## Test runner

examples/benchmarks.omc

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# =============================================================================
2+
# OMC Benchmark Suite
3+
# =============================================================================
4+
# Times common operations and reports per-op nanoseconds. Run both ways
5+
# to compare the tree-walker vs the bytecode VM:
6+
#
7+
# ./target/release/omnimcode-standalone examples/benchmarks.omc # tree-walk
8+
# OMC_VM=1 ./target/release/omnimcode-standalone examples/benchmarks.omc # Rust VM
9+
#
10+
# Tweak the `iters` constants at the top to scale up/down for your hardware.
11+
# The default settings put each benchmark in roughly the 100ms-1s range
12+
# under tree-walk on a modern laptop — short enough to iterate, long enough
13+
# for `now_ms()` resolution.
14+
# =============================================================================
15+
16+
# --- Benchmark configuration -----------------------------------------------
17+
18+
h N_INT_OPS = 200000; # tight integer arithmetic
19+
h N_STR_OPS = 20000; # string operations (slower per-op)
20+
h N_ARR_OPS = 5000; # array allocations + access
21+
h N_FIB = 22; # recursive fib depth (exponential — keep small)
22+
h N_RESONANCE = 50000; # is_fibonacci / harmony_value calls
23+
24+
# --- Bench harness ---------------------------------------------------------
25+
# Run a function, time it, return ms elapsed. Uses `call(fn, args_array)`
26+
# for dispatch (so we can iterate over a list of benchmarks generically).
27+
28+
fn run_bench(label, fn_value, iters) {
29+
h start = now_ms();
30+
call(fn_value, [iters]);
31+
h elapsed_ms = now_ms() - start;
32+
# ns per op = ms * 1e6 / iters
33+
h ns_per_op = elapsed_ms * 1000000 / iters;
34+
println(concat_many(
35+
str_pad_right(label, 32, " "),
36+
str_pad_left(to_string(iters), 8, " "), " iters ",
37+
str_pad_left(to_string(elapsed_ms), 6, " "), " ms ",
38+
str_pad_left(to_string(ns_per_op), 8, " "), " ns/op"
39+
));
40+
return elapsed_ms;
41+
}
42+
43+
# --- Benchmark bodies ------------------------------------------------------
44+
45+
fn bench_int_add(n) {
46+
h i = 0;
47+
h sum = 0;
48+
while i < n {
49+
sum = sum + i;
50+
i = i + 1;
51+
}
52+
return sum;
53+
}
54+
55+
fn bench_int_mul(n) {
56+
h i = 0;
57+
h prod = 1;
58+
while i < n {
59+
prod = prod + (i * 3);
60+
i = i + 1;
61+
}
62+
return prod;
63+
}
64+
65+
fn bench_str_concat(n) {
66+
h s = "";
67+
h i = 0;
68+
while i < n {
69+
s = str_concat(s, "a");
70+
i = i + 1;
71+
}
72+
return str_len(s);
73+
}
74+
75+
fn bench_str_split_join(n) {
76+
h base = "the quick brown fox jumps over the lazy dog";
77+
h i = 0;
78+
while i < n {
79+
h parts = str_split(base, " ");
80+
h joined = str_join(parts, ",");
81+
i = i + 1;
82+
}
83+
return n;
84+
}
85+
86+
fn bench_arr_push_walk(n) {
87+
h arr = arr_new(0, 0);
88+
h i = 0;
89+
while i < n {
90+
arr_push(arr, i);
91+
i = i + 1;
92+
}
93+
h sum = 0;
94+
h j = 0;
95+
while j < arr_len(arr) {
96+
sum = sum + arr_get(arr, j);
97+
j = j + 1;
98+
}
99+
return sum;
100+
}
101+
102+
fn fib(n) {
103+
if n < 2 { return n; }
104+
return fib(n - 1) + fib(n - 2);
105+
}
106+
107+
fn bench_recursive_fib(n) {
108+
return fib(n);
109+
}
110+
111+
fn bench_is_fibonacci(n) {
112+
h hits = 0;
113+
h i = 0;
114+
while i < n {
115+
if is_fibonacci(i) {
116+
hits = hits + 1;
117+
}
118+
i = i + 1;
119+
}
120+
return hits;
121+
}
122+
123+
fn bench_harmony_value(n) {
124+
h total = 0;
125+
h i = 0;
126+
while i < n {
127+
h r = harmony_value(i);
128+
i = i + 1;
129+
}
130+
return total;
131+
}
132+
133+
# --- Run them all ----------------------------------------------------------
134+
135+
println("");
136+
println(str_repeat("=", 70));
137+
println("OMC Benchmark Suite — N ops, ms total, ns per op");
138+
println(str_repeat("=", 70));
139+
140+
run_bench("int_add (sum 0..N)", bench_int_add, N_INT_OPS);
141+
run_bench("int_mul (sum i*3 0..N)", bench_int_mul, N_INT_OPS);
142+
run_bench("str_concat (build N a's)", bench_str_concat, N_STR_OPS);
143+
run_bench("str_split + str_join", bench_str_split_join, N_STR_OPS);
144+
run_bench("arr_push + arr_get walk", bench_arr_push_walk, N_ARR_OPS);
145+
run_bench("is_fibonacci 0..N", bench_is_fibonacci, N_RESONANCE);
146+
run_bench("harmony_value 0..N", bench_harmony_value, N_RESONANCE);
147+
run_bench("recursive fib(N)", bench_recursive_fib, N_FIB);
148+
149+
println(str_repeat("=", 70));
150+
println("Done. Run with OMC_VM=1 to compare against the Rust bytecode VM.");
151+
println(str_repeat("=", 70));

examples/math_module.omc

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# =============================================================================
2+
# Example module — Fibonacci utilities
3+
# =============================================================================
4+
# This file is meant to be imported, not run directly.
5+
# Use as:
6+
# import "examples/math_module.omc" as math;
7+
# println(arr_join(math.fib_up_to(100), ","));
8+
# =============================================================================
9+
10+
# Generate the Fibonacci sequence up to and including the largest value ≤ n.
11+
fn fib_up_to(n) {
12+
h out = arr_new(0, 0);
13+
h a = 0;
14+
h b = 1;
15+
while a <= n {
16+
arr_push(out, a);
17+
h t = a + b;
18+
a = b;
19+
b = t;
20+
}
21+
return out;
22+
}
23+
24+
# Newton-Raphson cube root. Integer-aligned; precision falls off for
25+
# non-cubes due to OMC's integer-division semantics, but useful as a
26+
# demo of how a module exposes mathematical utility functions.
27+
fn cube_root(x) {
28+
h r = x / 3;
29+
h i = 0;
30+
while i < 8 {
31+
r = r - (r * r * r - x) / (3 * r * r);
32+
i = i + 1;
33+
}
34+
return r;
35+
}
36+
37+
# Sum the elements of an integer range [lo, hi] inclusive.
38+
fn sum_range(lo, hi) {
39+
h total = 0;
40+
h k = lo;
41+
while k <= hi {
42+
total = total + k;
43+
k = k + 1;
44+
}
45+
return total;
46+
}
47+
48+
# Greatest common divisor — already a built-in in core, but useful as a
49+
# pedagogical example for how a module reimplements / overrides.
50+
fn euclid_gcd(a, b) {
51+
while b != 0 {
52+
h t = b;
53+
b = a % b;
54+
a = t;
55+
}
56+
return a;
57+
}

examples/module_demo.omc

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# =============================================================================
2+
# Module system demo — import-with-alias
3+
# =============================================================================
4+
# Run as:
5+
# ./target/release/omnimcode-standalone examples/module_demo.omc
6+
# =============================================================================
7+
8+
# Plain `import "path"` merges the module's functions into the global namespace.
9+
# `import "path" as alias` registers them as `alias.fname` so they're reached
10+
# via dotted-call syntax. Re-importing the same path is a no-op (idempotent).
11+
12+
import "examples/math_module.omc" as math;
13+
14+
# Every defined function in the module is reachable under `math.`:
15+
println(concat_many("First 100 Fibonacci numbers <= 100: ",
16+
arr_join(math.fib_up_to(100), ", ")));
17+
18+
println(concat_many("sum_range(1, 10) = ", math.sum_range(1, 10)));
19+
println(concat_many("euclid_gcd(89, 144) = ", math.euclid_gcd(89, 144)));
20+
println(concat_many("cube_root(27) ≈ ", math.cube_root(27)));
21+
22+
# Re-importing the same module is idempotent — no double-execute, no
23+
# namespace conflict, no extra cost. The dedup key is the module path
24+
# (not the alias), so a second import call returns immediately. If you
25+
# need two namespaces over the same code, point them at different files.
26+
import "examples/math_module.omc" as math;
27+
println("re-import was idempotent (no-op)");

0 commit comments

Comments
 (0)