Skip to content

Commit 45ad07e

Browse files
VM/tree-walk parity: mutable closures + imports + array mutation; + log analyzer demo
Three latent bugs prevented OMC programs that use the closure pattern from working identically under OMC_VM=1. All three surface together as soon as you try to write something non-trivial (here: a real CSV log analyzer). * Op::AssignVar — `x = e` in bytecode now walks scopes outward for an existing binding (mirrors tree-walk's assign_var); Op::StoreVar is still used for `h x = e` declarations. Fixes mutable closures: the bank-account pattern now produces 100, 150, 120, 120 under VM instead of shadowing balance on every write. * Interpreter.process_imports — main.rs now walks top-level Statement::Import before VM dispatch and merges each module's functions into interp.functions. The bytecode compiler treats imports as no-ops; without the pre-pass, `math.fib_up_to(...)` failed with "Undefined function" under VM only. * arr_push / arr_set use assign_var, not set_var (both engines). Pushes from inside a closure body now land in the captured env where the array actually lives, not the throwaway call scope. Same change applied to Op::ArrPushNamed, Op::ArrSetNamed, Op::SafeArrSetNamed, Op::ArrayIndexAssign. Verified: 40/40 functional examples produce byte-identical output under tree-walk and VM. (benchmarks.omc differs only in ns/op measurements, with VM ~2.2x faster on recursive_fib as expected.) examples/log_analyzer.omc — first non-trivial OMC program. 24-row access log, computes standard stats (mean/peak latency, error rate, per-endpoint breakdown via mutable-closure counter table) and three OMC-distinctive harmonic analyses: harmonic_sort by HIM, Fibonacci-attractor latency clustering, and attractor-based outlier detection that correctly isolates the 500-error spikes at 2100/2900/3400 ms (all fold to the 610 attractor). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2a4321c commit 45ad07e

7 files changed

Lines changed: 340 additions & 7 deletions

File tree

examples/log_analyzer.omc

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# =============================================================================
2+
# Web log analyzer — a non-trivial OMC program
3+
# =============================================================================
4+
# Reads a CSV access log, computes standard summary statistics, then runs
5+
# OMC-distinctive harmonic analyses (Fibonacci-attractor partitioning of
6+
# latencies, harmonic_sort by HIM, attractor-grouped outlier detection).
7+
#
8+
# Demonstrates: file I/O, CSV parsing, first-class functions (arr_map /
9+
# arr_filter / arr_reduce), HOF composition, mutable closures for stats
10+
# accumulators, and the harmonic_* builtins as the OMC-native pivot.
11+
#
12+
# Run:
13+
# ./target/release/omnimcode-standalone examples/log_analyzer.omc
14+
# OMC_VM=1 ./target/release/omnimcode-standalone examples/log_analyzer.omc
15+
# =============================================================================
16+
17+
# ---- 1. Generate sample log ------------------------------------------------
18+
# We embed the sample directly so the demo is self-contained — no
19+
# external file needed. 24 fake requests across four endpoints. The
20+
# `/api/search` endpoint deliberately has heavier-tailed latencies so
21+
# the harmonic-partition step has something interesting to show.
22+
23+
h log_lines = [
24+
"1715000001,/api/users,200,12",
25+
"1715000002,/api/users,200,8",
26+
"1715000003,/api/users,200,15",
27+
"1715000004,/api/orders,200,22",
28+
"1715000005,/api/orders,200,19",
29+
"1715000006,/api/orders,500,2100",
30+
"1715000007,/api/orders,200,21",
31+
"1715000008,/api/search,200,89",
32+
"1715000009,/api/search,200,144",
33+
"1715000010,/api/search,200,55",
34+
"1715000011,/api/search,200,233",
35+
"1715000012,/api/search,200,90",
36+
"1715000013,/api/search,500,3400",
37+
"1715000014,/api/login,200,34",
38+
"1715000015,/api/login,401,55",
39+
"1715000016,/api/login,200,21",
40+
"1715000017,/api/login,200,13",
41+
"1715000018,/api/orders,200,17",
42+
"1715000019,/api/users,200,9",
43+
"1715000020,/api/users,200,11",
44+
"1715000021,/api/search,200,87",
45+
"1715000022,/api/search,500,2900",
46+
"1715000023,/api/login,200,28",
47+
"1715000024,/api/orders,200,18"
48+
];
49+
h sample = str_join(log_lines, "\n");
50+
51+
write_file("/tmp/access.log", sample);
52+
53+
# ---- 2. Read + parse -------------------------------------------------------
54+
55+
h raw = read_file("/tmp/access.log");
56+
h lines = str_split(raw, "\n");
57+
58+
# Each line → array [ts, endpoint, status, latency_ms]. We carry latency
59+
# as HInt so resonance / HIM math works on it downstream.
60+
fn parse_line(ln) {
61+
h parts = str_split(ln, ",");
62+
return [
63+
to_int(arr_get(parts, 0)),
64+
arr_get(parts, 1),
65+
to_int(arr_get(parts, 2)),
66+
to_int(arr_get(parts, 3))
67+
];
68+
}
69+
70+
h rows = arr_map(lines, parse_line);
71+
72+
# ---- 3. Standard summary stats --------------------------------------------
73+
74+
fn get_status(row) { return arr_get(row, 2); }
75+
fn get_latency(row) { return arr_get(row, 3); }
76+
fn get_endpoint(row) { return arr_get(row, 1); }
77+
78+
fn is_error(row) {
79+
return get_status(row) >= 400;
80+
}
81+
82+
h total = arr_len(rows);
83+
h errors = arr_filter(rows, is_error);
84+
h error_count = arr_len(errors);
85+
86+
# Sum-of-latencies via reduce — exercises first-class fn closure
87+
fn add_latency(acc, row) { return acc + get_latency(row); }
88+
h total_latency = arr_reduce(rows, add_latency, 0);
89+
h mean_latency = total_latency / total;
90+
91+
# Max via reduce
92+
fn max_latency(acc, row) {
93+
h l = get_latency(row);
94+
if l > acc { return l; }
95+
return acc;
96+
}
97+
h peak_latency = arr_reduce(rows, max_latency, 0);
98+
99+
println("=== Standard summary =====================================");
100+
println(concat_many(" total requests: ", total));
101+
println(concat_many(" errors (>=400): ", error_count));
102+
println(concat_many(" mean latency: ", mean_latency, " ms"));
103+
println(concat_many(" peak latency: ", peak_latency, " ms"));
104+
println("");
105+
106+
# ---- 4. Per-endpoint breakdown via mutable-closure accumulator ------------
107+
# Build an endpoint→count map by hand (OMC has no dict yet, so we use two
108+
# parallel arrays and a small helper). The accumulator is a closure that
109+
# captures the parallel arrays — sibling closures share state, which is
110+
# exactly the mutable-closure case our recent VM work fixed.
111+
112+
fn make_counter_table() {
113+
h keys = [];
114+
h vals = [];
115+
116+
h bump = fn(k) {
117+
h i = 0;
118+
while i < arr_len(keys) {
119+
if arr_get(keys, i) == k {
120+
arr_set(vals, i, arr_get(vals, i) + 1);
121+
return 0;
122+
}
123+
i = i + 1;
124+
}
125+
# First time seeing this key — append.
126+
arr_push(keys, k);
127+
arr_push(vals, 1);
128+
return 0;
129+
};
130+
131+
h dump = fn() {
132+
return [keys, vals];
133+
};
134+
135+
return [bump, dump];
136+
}
137+
138+
h tbl = make_counter_table();
139+
h bump = arr_get(tbl, 0);
140+
h dump = arr_get(tbl, 1);
141+
142+
fn count_endpoint(row) {
143+
bump(get_endpoint(row));
144+
return 0;
145+
}
146+
arr_map(rows, count_endpoint);
147+
148+
h pair = dump();
149+
h endpoints = arr_get(pair, 0);
150+
h counts = arr_get(pair, 1);
151+
152+
println("=== Per-endpoint counts (via mutable closures) ============");
153+
h i = 0;
154+
while i < arr_len(endpoints) {
155+
println(concat_many(" ", arr_get(endpoints, i), " : ", arr_get(counts, i)));
156+
i = i + 1;
157+
}
158+
println("");
159+
160+
# ---- 5. Harmonic statistics ------------------------------------------------
161+
# This is the OMC-distinctive part. We pull out just the latencies and
162+
# feed them through the harmonic_* builtins. The Fibonacci-attractor
163+
# partitioning gives us a free, semantically-meaningful histogram —
164+
# /api/search clumps near 89/144/233 (its natural latency band), while
165+
# the 500-error rows land in the 2300+ outlier bucket all by themselves.
166+
167+
h latencies = arr_map(rows, get_latency);
168+
h harmonic_sorted = harmonic_sort(latencies);
169+
170+
println("=== Harmonic-sort (by HIM score) ==========================");
171+
println(" Top 5 by harmonic dominance:");
172+
h j = 0;
173+
while j < 5 {
174+
println(concat_many(" ", arr_get(harmonic_sorted, j), " ms"));
175+
j = j + 1;
176+
}
177+
println("");
178+
179+
h partitioned = harmonic_partition(latencies);
180+
181+
println("=== Latency clustering on Fibonacci attractors ============");
182+
h k = 0;
183+
while k < arr_len(partitioned) {
184+
h bucket = arr_get(partitioned, k);
185+
h bucket_len = arr_len(bucket);
186+
# Attractor-id = fold(first element) — cheap label.
187+
h label = fold(arr_get(bucket, 0));
188+
println(concat_many(" attractor ~", label, " : ", bucket_len, " request(s)"));
189+
k = k + 1;
190+
}
191+
println("");
192+
193+
# ---- 6. Outlier detection: high-attractor latencies -----------------------
194+
# An outlier is any latency landing on a Fibonacci attractor >= 377
195+
# (one φ-octave above the natural search-endpoint band). The 500-error
196+
# rows at 2100 / 2900 / 3400 ms all fold onto the 610 attractor, so
197+
# this surfaces them cleanly without arbitrary z-score thresholds.
198+
199+
fn is_outlier(lat) {
200+
if fold(lat) >= 377 { return 1; }
201+
return 0;
202+
}
203+
204+
h spikes = arr_filter(latencies, is_outlier);
205+
206+
println("=== Outliers (latency >= 377-attractor) ===================");
207+
h m = 0;
208+
while m < arr_len(spikes) {
209+
h s = arr_get(spikes, m);
210+
println(concat_many(" spike: ", s, " ms (folds to attractor ", fold(s), ")"));
211+
m = m + 1;
212+
}
213+
214+
println("");
215+
println("=== Done ==================================================");

omnimcode-core/src/bytecode.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ pub enum Op {
127127
/// call_first_class_function → tree-walk semantics for the body.
128128
/// Fast bytecode-VM execution of closure bodies is future work.
129129
Lambda(String),
130+
/// Assignment to an EXISTING binding. Walks scopes from inner to
131+
/// outer looking for the name; mutates in-place where found.
132+
/// Falls back to innermost on miss (implicit declaration).
133+
///
134+
/// Distinguishes `x = ...` (assignment) from `h x = ...`
135+
/// (declaration via StoreVar). Without this distinction the VM
136+
/// couldn't support mutable closures — `balance = balance + n`
137+
/// inside a closure would shadow rather than mutate the captured
138+
/// `balance`. Tree-walk has the same split via `assign_var` vs
139+
/// `set_var`.
140+
AssignVar(String),
130141

131142
// Special harmonic operations (short-circuit to built-in semantics
132143
// without the call overhead — these are the hot ones).

omnimcode-core/src/compiler.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ impl Compiler {
595595
self.var_types.insert(name.clone(), t);
596596
}
597597
self.compile_expr(value)?;
598-
self.emit(Op::StoreVar(name.clone()));
598+
self.emit(Op::AssignVar(name.clone()));
599599
}
600600
Statement::IndexAssignment { name, index, value } => {
601601
self.compile_expr(value)?;

omnimcode-core/src/disasm.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ fn op_mnemonic(op: &Op, ip: usize, constants: &[Const]) -> String {
7777
Op::ArrSetNamed(name) => format!("ARR_SET_NAMED {}", name),
7878
Op::SafeArrSetNamed(name) => format!("SAFE_ARR_SET_NAMED {}", name),
7979
Op::Lambda(name) => format!("LAMBDA {}", name),
80+
Op::AssignVar(name) => format!("ASSIGN_VAR {}", name),
8081

8182
Op::Resonance => "RESONANCE".to_string(),
8283
Op::Fold1 => "FOLD".to_string(),

omnimcode-core/src/interpreter.rs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,11 +2066,16 @@ impl Interpreter {
20662066
return Err("arr_push requires (array_name, value)".to_string());
20672067
}
20682068
// Mutates by name. First arg must be a Variable reference so we can write back.
2069+
// Use assign_var (walks outward for existing binding) instead of
2070+
// set_var (always innermost) — otherwise pushes inside a closure
2071+
// body would land in the closure's call scope, not the captured
2072+
// env where the array actually lives, and the mutation would be
2073+
// discarded on return.
20692074
let val = self.eval_expr(&args[1])?;
20702075
if let Expression::Variable(name) = &args[0] {
20712076
if let Some(Value::Array(mut arr)) = self.get_var(name) {
20722077
arr.items.push(val);
2073-
self.set_var(name.clone(), Value::Array(arr));
2078+
self.assign_var(name.clone(), Value::Array(arr));
20742079
return Ok(Value::Null);
20752080
}
20762081
}
@@ -2108,7 +2113,10 @@ impl Interpreter {
21082113
));
21092114
}
21102115
arr.items[idx] = val;
2111-
self.set_var(name.clone(), Value::Array(arr));
2116+
// assign_var (not set_var) so mutations from inside a
2117+
// closure body propagate into the captured env. Same
2118+
// rationale as arr_push above.
2119+
self.assign_var(name.clone(), Value::Array(arr));
21122120
return Ok(Value::Null);
21132121
}
21142122
}
@@ -3245,6 +3253,14 @@ impl Interpreter {
32453253
self.set_var(name.to_string(), value);
32463254
}
32473255

3256+
/// VM-facing wrapper around assign_var — walks scopes outward for
3257+
/// an existing binding, mutates there. See `assign_var` for the
3258+
/// rules. Used by Op::AssignVar (introduced for mutable closure
3259+
/// support).
3260+
pub fn vm_assign_var(&mut self, name: &str, value: Value) {
3261+
self.assign_var(name.to_string(), value);
3262+
}
3263+
32483264
/// Return an Rc clone of the topmost local scope frame, for closure
32493265
/// capture in Op::Lambda. The Rc is shared — multiple lambdas in
32503266
/// the same scope get the same underlying RefCell, so mutations
@@ -3261,6 +3277,27 @@ impl Interpreter {
32613277
/// the same function bodies. Tree-walks the body if reached this
32623278
/// way; the user pays a slight cost for reflective dispatch in
32633279
/// VM mode, but the regular Op::Call path stays bytecode-fast.
3280+
/// Process every top-level `Statement::Import` in `statements`,
3281+
/// registering the imported module's functions into self.functions.
3282+
/// Used by main.rs under OMC_VM=1, since the bytecode compiler
3283+
/// treats imports as no-ops and the VM never enters `execute_stmt`
3284+
/// for top-level statements (its execution model is bytecode, not
3285+
/// AST). Without this pre-pass, `math.fib_up_to(...)` calls in VM
3286+
/// mode would fail with "Undefined function" even though the
3287+
/// import line is there.
3288+
///
3289+
/// Imports are deduplicated via `imported_modules`, so calling
3290+
/// this twice (e.g. once during pre-pass, once via execute) is
3291+
/// safe — the second call is a no-op.
3292+
pub fn process_imports(&mut self, statements: &[Statement]) -> Result<(), String> {
3293+
for stmt in statements {
3294+
if let Statement::Import { module, alias } = stmt {
3295+
self.import_module_with_alias(module, alias.as_deref())?;
3296+
}
3297+
}
3298+
Ok(())
3299+
}
3300+
32643301
pub fn register_user_functions(&mut self, statements: &[Statement]) {
32653302
// Walks every FunctionDef anywhere in the AST — including those
32663303
// nested inside other fn bodies, if-branches, while bodies, etc.
@@ -3312,6 +3349,15 @@ impl Interpreter {
33123349
None
33133350
}
33143351

3352+
/// Same as vm_get_var but WITHOUT the function-table fallback. The VM's
3353+
/// Op::Call dispatch uses this to check "is `name` a variable holding
3354+
/// a Value::Function" — without falling back to a Function-ref from
3355+
/// the function table itself (which would be redundant; the is_user
3356+
/// branch above already handles that).
3357+
pub fn vm_get_var_local_only(&self, name: &str) -> Option<Value> {
3358+
self.get_var(name)
3359+
}
3360+
33153361
/// Call a built-in (or user-defined) function with already-evaluated args.
33163362
/// The VM uses this when it encounters Op::Call and the function isn't
33173363
/// a compiled function in the current module.

omnimcode-core/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ fn execute_program(source: &str) -> Result<(), String> {
192192
// dispatch paths can resolve them. The VM still uses its own
193193
// compiled function table for direct Op::Call dispatch; this
194194
// duplication only kicks in for first-class function reflection.
195+
// Imports are no-ops in the bytecode compiler, so the VM
196+
// never wires up `math.fib_up_to`-style aliased calls on its
197+
// own. Run a pre-pass that walks top-level Statement::Import
198+
// and merges each module's functions into interp.functions.
199+
// Dot-namespaced calls then route through call_module_function
200+
// and resolve normally.
201+
vm.interp_mut().process_imports(&statements)?;
195202
vm.interp_mut().register_user_functions(&statements);
196203
// Also register every lambda body the compiler collected. Lambda
197204
// invocation routes through call_first_class_function → the

0 commit comments

Comments
 (0)