Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions examples/mapr-shortcircuit.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- mapr 2 with short-circuit on first Err: Phase 2 PR3b native dispatch.
--
-- Pre-PR3b the closure callback re-entered the tree interpreter on every
-- element via the OP_CALL_BUILTIN_TREE bridge, costing a NanVal-to-Value
-- round-trip per call and depending on ACTIVE_AST_PROGRAM TLS being live.
-- PR3b emits an OP_FOREACHPREP/NEXT loop with per-element OP_CALL_DYN
-- against the closure NanVal, an ISERR check for short-circuit, and a
-- final OP_WRAPOK on the acc list. The whole pass runs natively on the
-- VM and Cranelift.

-- Named fn-ref: square every element, fail if any input is negative.
chk x:n>R n t;<x 0 ^"negative";~*x x
sq-all xs:L n>R (L n) t;mapr chk xs

-- Capturing lambda: bound each element by `lim`, fail if any exceeds.
cap-all xs:L n lim:n>R (L n) t;mapr (x:n>R n t;>x lim ^"too big";~*x 2) xs

-- Empty input: vacuously Ok with empty list.
sq-empty>R (L n) t;mapr chk []

-- run: sq-all [1,2,3]
-- out: [1, 4, 9]
-- run: cap-all [1,2,3] 5
-- out: [2, 4, 6]
-- run: sq-empty
-- out: []
130 changes: 124 additions & 6 deletions src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,12 +528,10 @@ pub(crate) fn is_tree_bridge_eligible(b: crate::builtins::Builtin, argc: usize)
// srt 2-arg: tree interpreter does the user-fn callback, bridge
// round-trips the result list. Cross-engine parity with srt.
(Builtin::Rsrt, 2) => true,
// mapr fn xs: short-circuit Result-aware map. Tree bridge routes
// through the tree interpreter's Mapr arm, which handles the
// ~v/^e dispatch and ACTIVE_AST_PROGRAM user-fn callbacks the same
// way grp/uniqby/partition/srt do (PR 3b precedent). Returns
// R (L b) e; the bridge unwrap epilogue handles `!` propagation.
(Builtin::Mapr, 2) => true,
// mapr fn xs was on the bridge through Phase 2 PR3; PR 3b lifts it
// natively via OP_CALL_DYN + OP_ISERR short-circuit + OP_WRAPOK so
// closure callbacks dispatch without a tree re-entry. See the
// matching arm in the compiler.
// Closure-bind ctx variants. The fn receives an extra ctx arg the
// native emitters don't shape today — bridge keeps semantics aligned
// with the tree interpreter and adds VM/Cranelift coverage in PR 3c.
Expand Down Expand Up @@ -4520,6 +4518,126 @@ impl RegCompiler {
self.next_reg = out_reg + 1;
return out_reg;
}
// mapr fn xs → Result-aware map with short-circuit on
// first Err. Per-element: OP_CALL_DYN, then ISERR check.
// On Err: short-circuit and return that Err Result as-is.
// On Ok: OP_UNWRAP the inner and OP_LISTAPPEND to acc.
// After the loop, OP_WRAPOK the acc list to form the
// final `Ok(L T)`. Non-Result callback returns raise
// through OP_PANIC_UNWRAP with a "mapr"+"Result" message
// (same shape as flt/partition's predicate typecheck).
//
// Phase 2 PR3b: lift off the tree-bridge so closure
// callbacks dispatch natively. Mirrors the partition
// lift from #387; the new shape here is the
// ISERR-then-UNWRAP short-circuit pattern, plus a final
// OP_WRAPOK to assemble the Result. No new finalizer
// opcode is needed because the wrap step is a single
// existing OP_WRAPOK.
(Builtin::Mapr, 2) => {
let fn_reg = self.compile_expr(&args[0]);
let xs_reg = self.compile_expr(&args[1]);

// Single accumulator list for the unwrapped Ok
// inners. On short-circuit we never wrap this.
let acc_reg = self.alloc_reg();
self.emit_abx(OP_LISTNEW, acc_reg, 0);

let idx_reg = self.alloc_reg();
let zero_ki = self.current.add_const(Value::Number(0.0));
self.emit_abx(OP_LOADK, idx_reg, zero_ki);
self.reg_is_num[idx_reg as usize] = true;

let item_reg = self.alloc_reg();
let nil_ki = self.current.add_const(Value::Nil);
self.emit_abx(OP_LOADK, item_reg, nil_ki);

// res_reg + arg_reg contiguous for OP_CALL_DYN ABI.
let res_reg = self.alloc_reg();
self.emit_abx(OP_LOADK, res_reg, nil_ki);
let arg_reg = self.alloc_reg();
assert!(
arg_reg == res_reg + 1,
"mapr HOF: arg reg must follow result reg contiguously"
);
self.emit_abx(OP_LOADK, arg_reg, nil_ki);

// Scratch for ISERR / ISOK typechecks.
let chk_reg = self.alloc_reg();
self.emit_abx(OP_LOADK, chk_reg, nil_ki);

// Final output register. On the short-circuit path
// we move res_reg here; on the happy path we WRAPOK
// acc_reg here.
let out_reg = self.alloc_reg();
self.emit_abx(OP_LOADK, out_reg, nil_ki);

let _loop_top = self.current.code.len();
self.emit_abc(OP_FOREACHPREP, item_reg, xs_reg, idx_reg);
let exit_jump_a = self.emit_jmp_placeholder();

let body_top = self.current.code.len();
self.emit_abc(OP_MOVE, arg_reg, item_reg, 0);
self.emit_abc(OP_CALL_DYN, res_reg, fn_reg, 1);

// Short-circuit on Err: ISERR → if true, jump to
// the short-circuit emitter past the wrap-acc tail.
self.emit_abc(OP_ISERR, chk_reg, res_reg, 0);
let shortcircuit_jump = self.emit_jmpt(chk_reg);

// Non-Err path: must be Ok. ISOK typecheck — if the
// callback returned something that isn't a Result
// at all, raise a runtime error mirroring the tree
// walker's "mapr: fn must return a Result" message.
self.emit_abc(OP_ISOK, chk_reg, res_reg, 0);
let isok_jump = self.emit_jmpt(chk_reg);
let err_text_ki = self.current.add_const(Value::Text(Arc::new(
"mapr: fn must return a Result (~v or ^e)".to_string(),
)));
self.emit_abx(OP_LOADK, arg_reg, err_text_ki);
self.emit_abc(OP_WRAPERR, arg_reg, arg_reg, 0);
self.emit_abc(OP_PANIC_UNWRAP, 0, arg_reg, 0);
self.current.patch_jump(isok_jump);

// Ok path: UNWRAP into arg_reg (free scratch) then
// append to acc.
self.emit_abc(OP_UNWRAP, arg_reg, res_reg, 0);
self.emit_abc(OP_LISTAPPEND, acc_reg, acc_reg, arg_reg);

self.emit_abc(OP_FOREACHNEXT, item_reg, xs_reg, idx_reg);
let exit_jump_b = self.emit_jmp_placeholder();
self.emit_jump_to(body_top);

// Short-circuit path: out_reg = res_reg, then jump
// past the WRAPOK happy-path tail.
self.current.patch_jump(shortcircuit_jump);
self.emit_abc(OP_MOVE, out_reg, res_reg, 0);
let done_jump = self.emit_jmp_placeholder();

// Loop fall-through (both natural exits): wrap acc.
self.current.patch_jump(exit_jump_a);
self.current.patch_jump(exit_jump_b);
self.emit_abc(OP_WRAPOK, out_reg, acc_reg, 0);

self.current.patch_jump(done_jump);

self.current_all_regs_numeric = false;
self.reg_is_num[out_reg as usize] = false;

self.next_reg = out_reg + 1;

// Handle `mapr!` / `mapr!!` auto-unwrap exactly the
// same way emit_call_builtin_tree did before the
// native lift. verify already rejects `!`/`!!` on
// non-Result builtins, so this stays cross-engine
// consistent.
if unwrap.is_any() {
self.emit_result_unwrap(out_reg, *unwrap);
self.next_reg = out_reg + 1;
}

return out_reg;
}
// Builtins that fall through:
// - tree-bridge eligible (rgx, rgxall, fmt-variadic,
// rd 2-arg, rdb, sleep, grp/uniqby/srt 2-arg from
Expand Down
173 changes: 173 additions & 0 deletions tests/regression_phase2_hof_finalizers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Cross-engine regression coverage for Phase 2 PR3b: HOF finalizer
// opcodes.
//
// PR3 (#387) lifted `partition 2` off the tree-bridge. PR3b extends the
// migration to the remaining bridge HOFs that need post-loop finalization:
// `srt 2` / `grp 2` / `uniqby 2` / `mapr 2`. Each lift uses OP_CALL_DYN
// per element for closure-aware dispatch, then assembles the final result
// via either an existing opcode (mapr: OP_WRAPOK on the acc list) or a
// new dedicated finalizer opcode (srt/grp/uniqby: 179-181, follow-up PR).
//
// This PR ships the `mapr 2` lift. The remaining three HOFs are deferred
// to PR3c per the time-box on the original PR3b scope.
//
// Each migrated HOF is exercised in three shapes:
// 1. Non-capturing inline lambda (Phase 1 shape, FnRef to __lit_N).
// 2. Capturing inline lambda (Phase 2 shape, Expr::MakeClosure).
// 3. Named top-level fn (FnRef to user fn).
//
// Plus an empty-input case and (for mapr) a short-circuit case that
// returns Err mid-loop without producing a full result list.
//
// Each shape runs on `--run-tree`, `--run-vm`, and `--run-cranelift`
// (when the `cranelift` feature is enabled) so all three engines stay
// in lockstep.

use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};

fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
}

fn write_src(name: &str, src: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!("ilo_p3b_{name}_{}_{n}.ilo", std::process::id()));
std::fs::write(&path, src).expect("write src");
path
}

fn run_ok(engine: &str, src: &str, entry: &str, args: &[&str]) -> String {
let path = write_src(entry, src);
let mut cmd = ilo();
cmd.arg(&path).arg(engine).arg(entry);
for a in args {
cmd.arg(a);
}
let out = cmd.output().expect("failed to run ilo");
let _ = std::fs::remove_file(&path);
assert!(
out.status.success(),
"ilo {engine} failed for `{src}`: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}

fn run_all(src: &str, entry: &str, args: &[&str], expected: &str) {
#[cfg(feature = "cranelift")]
let engines: &[&str] = &["--run-tree", "--run-vm", "--run-cranelift"];
#[cfg(not(feature = "cranelift"))]
let engines: &[&str] = &["--run-tree", "--run-vm"];
for engine in engines {
let actual = run_ok(engine, src, entry, args);
assert_eq!(
actual, expected,
"engine {engine} produced {actual:?}, expected {expected:?} for src `{src}`"
);
}
}

fn run_err_combined(engine: &str, src: &str, entry: &str, args: &[&str]) -> (i32, String) {
let path = write_src(entry, src);
let mut cmd = ilo();
cmd.arg(&path).arg(engine).arg(entry);
for a in args {
cmd.arg(a);
}
let out = cmd.output().expect("failed to run ilo");
let _ = std::fs::remove_file(&path);
let code = out.status.code().unwrap_or(-1);
// mapr short-circuits via an Err Result falling out of main; the CLI
// unwraps and prints the inner message to STDERR (matches the
// tree-walker's ILO-R009 path). We check stderr for the message.
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
(code, stderr)
}

fn run_err_all_contains(src: &str, entry: &str, args: &[&str], needle: &str) {
#[cfg(feature = "cranelift")]
let engines: &[&str] = &["--run-tree", "--run-vm", "--run-cranelift"];
#[cfg(not(feature = "cranelift"))]
let engines: &[&str] = &["--run-tree", "--run-vm"];
for engine in engines {
let (code, stderr) = run_err_combined(engine, src, entry, args);
assert_ne!(
code, 0,
"engine {engine}: expected non-zero exit for `{src}`"
);
assert!(
stderr.contains(needle),
"engine {engine}: expected stderr to contain `{needle}`, got `{stderr}`"
);
}
}

// ── mapr: non-capturing inline lambda ──────────────────────────────────

#[test]
fn mapr_inline_lambda_non_capturing() {
let src = "f xs:L n>R (L n) t;mapr (x:n>R n t;~*x x) xs";
run_all(src, "f", &["[1,2,3]"], "[1, 4, 9]");
}

// ── mapr: capturing inline lambda ──────────────────────────────────────

#[test]
fn mapr_inline_lambda_single_capture() {
// Multiplier t is captured by the lambda; result is each item * t.
let src = "f xs:L n t:n>R (L n) t;mapr (x:n>R n t;~*x t) xs";
run_all(src, "f", &["[1,2,3]", "10"], "[10, 20, 30]");
}

// ── mapr: named fn (FnRef) ─────────────────────────────────────────────

#[test]
fn mapr_named_fn_ref() {
let src = "sq x:n>R n t;~*x x\nf xs:L n>R (L n) t;mapr sq xs";
run_all(src, "f", &["[2,3,4]"], "[4, 9, 16]");
}

// ── mapr: empty input ──────────────────────────────────────────────────

#[test]
fn mapr_empty_input() {
let src = "sq x:n>R n t;~*x x\nf xs:L n>R (L n) t;mapr sq xs";
run_all(src, "f", &["[]"], "[]");
}

// ── mapr: short-circuit on first Err ───────────────────────────────────
//
// Tree walker returns the Err from the first failing element and stops
// iterating. The native lift must match: per-element ISERR branches to
// out_reg = res_reg and skips the WRAPOK acc tail.
//
// The CLI prints an unwrapped Err Result as the inner message (no
// ^prefix in stdout since it falls out the top of `main` unwrapped).
// We assert via run_err_all_stdout (non-zero exit + the Err message).

#[test]
fn mapr_short_circuit_on_err() {
let src = "chk x:n>R n t;>x 5 ^\"too big\";~*x 2\nf xs:L n>R (L n) t;mapr chk xs";
run_err_all_contains(src, "f", &["[1,2,10,4]"], "too big");
}

#[test]
fn mapr_short_circuit_all_ok() {
// Same predicate, but all items pass — no short-circuit, full result.
let src = "chk x:n>R n t;>x 5 ^\"too big\";~*x 2\nf xs:L n>R (L n) t;mapr chk xs";
run_all(src, "f", &["[1,2,3]"], "[2, 4, 6]");
}

// ── mapr: short-circuit on first element ───────────────────────────────
//
// Edge case: the very first element fails. The acc list is empty when
// we short-circuit, but we must still return the Err, not Ok([]).

#[test]
fn mapr_short_circuit_first_element() {
let src = "chk x:n>R n t;>x 5 ^\"too big\";~*x 2\nf xs:L n>R (L n) t;mapr chk xs";
run_err_all_contains(src, "f", &["[100,2,3]"], "too big");
}
Loading