diff --git a/rust/crates/clj-eval/src/concurrency.rs b/rust/crates/clj-eval/src/concurrency.rs index 0ab493827c..10dd772fc2 100644 --- a/rust/crates/clj-eval/src/concurrency.rs +++ b/rust/crates/clj-eval/src/concurrency.rs @@ -71,7 +71,7 @@ use clj_value::{ use crate::core::{cce_err, is_ifn}; use crate::error::{NativeError, Signal, internal_throwable}; use crate::eval::Evaluator; -use crate::ext::{FastArith, NativeFn}; +use crate::ext::{FastArith, NativeFn, as_native}; use crate::lazy::as_pending; use crate::ns::{NativeFnPtr, check_arity, npe, signal_to_native, str_of}; use crate::vars::check_min_arity; @@ -254,6 +254,61 @@ fn try_publish(atom: &Atom, old: &Value, new: &Value) -> Option { Some(state.watches.clone()) } +/// A fused `swap!` int op: add/sub/mul by a constant, or negate. +#[derive(Clone, Copy, Debug)] +enum SwapArith { + Add(i64), + Sub(i64), + Mul(i64), + Neg, +} + +impl SwapArith { + fn apply(self, old: i64) -> Option { + match self { + SwapArith::Add(x) => old.checked_add(x), + SwapArith::Sub(x) => old.checked_sub(x), + SwapArith::Mul(x) => old.checked_mul(x), + SwapArith::Neg => old.checked_neg(), + } + } +} + +/// Classify `(swap! a f extra...)` for the fast path: known core +/// arithmetic natives over int extras. Anything else (closures, +/// non-int extras, other arities) runs the generic loop. +fn swap_arith_op(f: &Value, extra: &[Value]) -> Option { + let native = as_native(f)?; + match (native.display.as_ref(), extra) { + ("clojure.core/+", [Value::Int(x)]) => Some(SwapArith::Add(*x)), + ("clojure.core/+", []) => Some(SwapArith::Add(0)), + ("clojure.core/-", [Value::Int(x)]) => Some(SwapArith::Sub(*x)), + ("clojure.core/-", []) => Some(SwapArith::Neg), + ("clojure.core/*", [Value::Int(x)]) => Some(SwapArith::Mul(*x)), + ("clojure.core/inc", []) => Some(SwapArith::Add(1)), + ("clojure.core/dec", []) => Some(SwapArith::Sub(1)), + _ => None, + } +} + +/// Run a fused int `swap!`: read, compute, and publish under one +/// lock (atomic — no CAS retry needed, and the mutex serializes +/// against generic publishers). `None` bails to the generic loop +/// (validator/watches installed, non-int state, or overflow — +/// the generic path throws `long overflow`, like `+` itself). +fn swap_fast(atom: &Atom, op: SwapArith) -> Option<(Value, Value)> { + let mut state = atom.lock(); + if state.validator.is_some() || !state.watches.is_empty() { + return None; + } + let Value::Int(old) = state.value else { + return None; + }; + let new = op.apply(old)?; + state.value = Value::Int(new); + Some((Value::Int(old), Value::Int(new))) +} + /// The `swap!` CAS loop: apply `f`, validate, publish iff the state /// is unchanged (else retry — `f` published meanwhile). Validation /// vetoes and `f` failures leave the state untouched and fire no @@ -264,6 +319,11 @@ fn swap_loop( f: &Value, extra: &[Value], ) -> Result<(Value, Value), NativeError> { + if let Some(op) = swap_arith_op(f, extra) { + if let Some(done) = swap_fast(atom, op) { + return Ok(done); + } + } loop { let old = atom.lock().value.clone(); let mut f_args = Vec::with_capacity(extra.len() + 1); diff --git a/rust/crates/clj-eval/src/core.rs b/rust/crates/clj-eval/src/core.rs index 0677f716d7..56aaca8109 100644 --- a/rust/crates/clj-eval/src/core.rs +++ b/rust/crates/clj-eval/src/core.rs @@ -892,6 +892,9 @@ fn num_rational(n: &Num) -> num_rational::BigRational { /// everything else keeps the rational path. fn compare_exact(a: &Num, b: &Num) -> std::cmp::Ordering { match (a, b) { + // Direct `i64` compare (the sort hot path — the rational + // fallback below allocates two `BigInt`s per comparison). + (Num::Int(x), Num::Int(y)) => x.cmp(y), (Num::BigDec(d), Num::BigDec(e)) => d.compare(e), (Num::BigDec(d), Num::Int(i)) => d.compare(&clj_value::BigDec::from_bigint((*i).into())), (Num::Int(i), Num::BigDec(e)) => clj_value::BigDec::from_bigint((*i).into()).compare(e), @@ -5146,6 +5149,25 @@ mod tests { let _ = num_bigdec(&Num::Double(0.5)); } + /// `Int` pairs compare directly (no rational round-trip): + /// less, equal (including extremes), greater. + #[test] + fn int_pairs_compare_directly() { + use std::cmp::Ordering; + assert_eq!( + jvm_compare(&Value::Int(-3), &Value::Int(7)).unwrap(), + Ordering::Less + ); + assert_eq!( + jvm_compare(&Value::Int(i64::MIN), &Value::Int(i64::MIN)).unwrap(), + Ordering::Equal + ); + assert_eq!( + jvm_compare(&Value::Int(i64::MAX), &Value::Int(i64::MIN)).unwrap(), + Ordering::Greater + ); + } + /// Instants order by millis (unit-tested: `#inst` reads /// a `TaggedLiteral`, so no surface form builds a real /// instant). diff --git a/rust/crates/clj-eval/src/env.rs b/rust/crates/clj-eval/src/env.rs index 8f685be92b..c2186c03f8 100644 --- a/rust/crates/clj-eval/src/env.rs +++ b/rust/crates/clj-eval/src/env.rs @@ -1504,23 +1504,40 @@ impl Env { Arc::ptr_eq(&parent.frame, &outer.frame) && frame.base == outer.len() }); if flat_over_outer { - let Slots::Shared(slots) = &mut frame.slots else { - return false; - }; - let dst = Arc::get_mut(slots).expect("slots ride with their frame"); - // Destructuring loops bind fewer values than slots (one - // per pattern), so a mismatch falls back to a fresh - // bind — only exact rebinds go in place. Values move - // (never clone): each assignment drops the old slot - // value, and the drain leaves the buffer for the - // caller's pool (#235: a `clone_from_slice` here - // cloned every slot per `recur`). - if dst.len() == values.len() { - for (slot, new_val) in dst.iter_mut().zip(values.drain(..)) { - *slot = new_val; - } + // Single-slot `Inline` frames (the `extend_one` call + // path) rebind in place exactly like `Shared` ones — + // the frame is unshared, so the slot is unobserved. + // Exactly one value always arrives (the analyzer + // arity-checks every `recur`); popping it drains the + // buffer for the caller's pool, like `Shared` below. + if let Slots::Inline(slot) = &mut frame.slots { + *slot = values.pop().unwrap_or(Value::Nil); return true; } + // `Patchable` (`letfn` setup) frames never rebind in + // place (`recur` binds fresh since #171), and neither + // do length mismatches (destructuring loops bind fewer + // values than slots — one per pattern): anything but + // an exact `Shared` hit answers `false`. + let mut rebound = false; + if let Slots::Shared(slots) = &mut frame.slots { + let dst = Arc::get_mut(slots).expect("slots ride with their frame"); + // Destructuring loops bind fewer values than slots + // (one per pattern), so a mismatch falls back to a + // fresh bind — only exact rebinds go in place. + // Values move (never clone): each assignment drops + // the old slot value, and the drain leaves the + // buffer for the caller's pool (#235: a + // `clone_from_slice` here cloned every slot per + // `recur`). + if dst.len() == values.len() { + for (slot, new_val) in dst.iter_mut().zip(values.drain(..)) { + *slot = new_val; + } + rebound = true; + } + } + return rebound; } false } @@ -1892,4 +1909,18 @@ mod tests { b.pop_frame(); assert!(b.is_empty()); } + + #[test] + fn rebind_declines_patchable_frames() { + // `Patchable` (`letfn` setup) frames never rebind in place + // (`recur` binds fresh since #171): the decline leaves the + // values untouched for the caller's fresh bind. No + // Clojure-level `recur` targets a setup frame, so this pins + // the contract directly. + let outer = Env::new(); + let mut frame = outer.extend_patchable(2); + let mut values = vec![Value::Int(1), Value::Int(2)]; + assert!(!frame.try_rebind(&outer, &mut values)); + assert_eq!(values, vec![Value::Int(1), Value::Int(2)]); + } } diff --git a/rust/crates/clj-eval/src/eval.rs b/rust/crates/clj-eval/src/eval.rs index 08c5e55dbf..5fe87d54ae 100644 --- a/rust/crates/clj-eval/src/eval.rs +++ b/rust/crates/clj-eval/src/eval.rs @@ -18,8 +18,8 @@ use std::sync::{Arc, LazyLock}; use clj_reader::{Reader, ReaderOpts, Span}; use clj_value::{ - Delay, Keyword, List, Map, PrimitiveVector, Promise, Symbol, ThrowClass, Throwable, Value, Var, - Vector, new_map, + Delay, ExtensionValue, Keyword, List, Map, PrimitiveVector, Promise, Symbol, ThrowClass, + Throwable, Value, Var, Vector, new_map, }; use smallvec::SmallVec; @@ -227,6 +227,236 @@ struct DefMacroParts<'a> { span: Span, } +/// The outcome of [`Evaluator::try_int_loop`]: `Slow` falls through +/// to the standard interpreter loop from the entry `env` (the fast +/// path mutates nothing, so the fallback is exactly the +/// unaccelerated run); `Resume` falls through from the carried +/// values instead (an exit branch already ran — rerunning from +/// entry would repeat its effects, so the slow loop continues from +/// the current state); `Done` carries the loop's value. +enum LoopAccel { + Slow, + Done(Result), + Resume(Vec), +} + +/// The `clojure.core` vars an integer loop answers by identity +/// (`assoc`, `nth`, `=` — the `fast`-tagged ops ride the +/// [`DirectNative`](Expr::DirectNative) node instead; see +/// [`Evaluator::try_int_loop`]). +struct IntLoopIds { + assoc: u64, + nth: u64, + eq: u64, +} + +/// One direct call a loop plan inlines: the var id plus the +/// analyzed root the call site captured. The plan guard rechecks +/// every use (no thread binding, the epoch fresh, the root still +/// current — the same checks as [`Evaluator::direct_valid`]). +struct PlanUse { + var_id: u64, + expect: Arc, +} + +impl PlanUse { + fn of(call: &DirectCall) -> Self { + Self { + var_id: call.var.id, + expect: Arc::clone(&call.expect), + } + } +} + +/// One loop-carried binding's kind: plain integers run tagless, +/// maps thread through [`Map::insert_threaded`], packed int +/// vectors through [`Vector::assoc_int_threaded`]. +#[derive(Clone, Copy, PartialEq, Eq)] +enum LoopVarKind { + Int, + Map, + Vec, +} + +/// One loop-carried integer expression: loop vars, frozen entry +/// constants, and the direct integer ops — compiled to postfix +/// (leaves and leaf-built steps compute directly in registers; +/// anything nested runs the iterative machine, so deeply nested +/// source can't overflow the native stack; any overflow bails to +/// the interpreter, which throws `long overflow` like the JVM). +struct IntExpr { + ops: Vec, +} + +/// One postfix integer op (see [`IntExpr`]): n-ary counts ride the +/// op (popped as one run — the matcher guarantees the shape, and +/// any surprise bails rather than miscomputing). +enum IntOp { + Var(usize), + Const(i64), + Inc, + Dec, + Add(usize), + Sub(usize), + Mul(usize), +} + +impl IntExpr { + /// A single-op expression (a leaf). + fn single(op: IntOp) -> Self { + Self { ops: vec![op] } + } + + /// Push one op onto a matched operand (the `inc`/`dec` step). + fn push_op(mut self, op: IntOp) -> Self { + self.ops.push(op); + self + } +} + +/// The loop test: a chained comparison (`<`, `<=`, `>`, `>=`, `=`). +struct IntTest { + op: IntCmp, + args: Vec, +} + +#[derive(Clone, Copy)] +enum IntCmp { + Lt, + Le, + Gt, + Ge, + Eq, +} + +/// One threaded-`assoc` key or value: a frozen constant (a +/// literal or an outer local — locals are immutable, so the value +/// rides unchanged) or a per-round integer expression. +enum MapKeyVal { + Const(Value), + Int(IntExpr), +} + +/// One threaded map step: an `assoc` chain onto a loop-carried map +/// (nested assocs flatten inner-first, like the interpreter). +struct MapStep { + base: usize, + assocs: Vec<(MapKeyVal, MapKeyVal)>, + /// Whether another step threads the same base (shared bases + /// clone per round so every step sees the pre-round map, like + /// the slow path; unique bases move). + shared_base: bool, +} + +/// One `recur` step: an integer expression, a threaded map step, +/// or a threaded vector step. +enum LoopStep { + Int(IntExpr), + Map(MapStep), + Vec(VecStep), +} + +/// One evaluated `recur` step (which next-buffer it lands in). +enum SteppedValue { + Int(i64), + Map(Map), + Vec(Vector), +} + +/// The classified entry buffers of an integer loop (see +/// [`Evaluator::try_int_loop`]). +struct LoopEntry { + kinds: Vec, + saved: Vec, + ints: Vec, + maps: Vec>, + vecs: Vec>, +} + +/// One threaded-vector operand: a per-round integer expression or +/// an `nth` read against a loop-carried vector. +enum VecVal { + Int(IntExpr), + Nth { base: usize, idx: IntExpr }, +} + +/// One threaded vector step: a loop-var passthrough, a multi-pair +/// `assoc` chain onto a loop-carried vector (nested assocs +/// flatten inner-first, like the interpreter), or an `if` over an +/// integer comparison with threaded branches. +enum VecStep { + Pass { + base: usize, + shared_base: bool, + }, + Assoc { + base: usize, + pairs: Vec<(VecVal, VecVal)>, + /// Whether another step threads the same base (shared bases + /// clone per round so every step sees the pre-round vector, + /// like the slow path; unique bases move). + shared_base: bool, + }, + If { + op: IntCmp, + lhs: VecVal, + rhs: VecVal, + then: Box, + els: Box, + }, +} + +/// One `if` branch: another round with computed steps, or the exit +/// expression (evaluated once with the final values; a surfaced +/// `recur` continues the loop exactly like the slow path). +/// Integer-only loops keep their steps untagged (`RecurInt`) so the +/// tagless round loop unwraps nothing per round. +enum IntBranch<'a> { + Recur(Vec), + RecurInt(Vec), + Exit(&'a Expr), +} + +/// The outcome of [`Evaluator::run_loop_rounds`]: `Done` carries +/// the loop's value; `Bail` re-runs the standard loop from the +/// restored entry `env` (only when no exit ran yet — nothing +/// observable happened); `Resume` continues it from the carried +/// values instead (an exit may have run effects — rerunning would +/// repeat them). +enum RoundOutcome { + Done(Result), + Bail, + Resume(Vec), +} + +/// The outcome of one exit-branch evaluation: `Done` leaves the +/// loop; `Reseed` carries a surfacing `recur`'s values back to the +/// round loop (which reseeds the buffers, or resumes slow on exotic +/// shapes). +enum ExitAction { + Done(RoundOutcome), + Reseed(Vec), +} + +/// A matched counted loop (bundles `run_loop_rounds`'s plan). +struct IntLoopPlan<'a> { + test: IntTest, + then_act: IntBranch<'a>, + else_act: IntBranch<'a>, + kinds: Vec, + /// The validated root epoch (re-checked per round — every + /// inlined direct call observes redefinition, like the + /// evaluator's own direct calls). + guard_epoch: u64, +} + +/// The shared matching context of the round planner: loop-var +/// ids, the outer frame length, and the entry env. +struct LoopMatchCtx<'e> { + ids: &'e IntLoopIds, + outer_len: usize, + env: &'e Env, +} impl Evaluator { /// A fresh evaluator loading from `source` (`:file`, used in /// spans and `def` meta), with a lone `user` namespace current @@ -1044,6 +1274,9 @@ impl Evaluator { for arg in args { values.push(self.eval(arg, env)?); } + if let Some(closure) = Self::local_closure(head, &callee) { + return self.apply_closure(closure, &values); + } self.invoke_args(&callee, values, *span) } Expr::DirectNative(call) => self.eval_direct_native(call, env), @@ -1714,8 +1947,1621 @@ impl Evaluator { })) } + /// Run a counted loop without interpreter dispatch: the body + /// is one `if` over a chained integer comparison, integer steps + /// run tagless in `i64`s, and map steps thread one assoc chain + /// through [`Map::insert_threaded`]. Calls match only validated + /// [`Expr::DirectNative`] sites (like the evaluator's own direct + /// calls, the plan observes redefinition: any root or binding + /// change declines, and rounds re-check the epoch). Any + /// overflow, exotic state, or exotic shape bails — from the + /// restored entry `env` when no exit ran yet, or by resuming the + /// standard loop from the current state once an exit may have + /// run effects (rerunning would repeat them) — so overflow + /// errors, coercions, and arity failures stay exactly the + /// interpreter's. + fn try_int_loop( + &mut self, + bindings: &[(Pattern, Expr)], + body: &[Expr], + env: &mut Env, + outer: &Env, + ) -> LoopAccel { + let [only] = body else { + return LoopAccel::Slow; + }; + let Expr::If { test, then, els } = only else { + return LoopAccel::Slow; + }; + let n_vars = bindings.len(); + if !Self::loop_bindings_plain(bindings, env, outer, n_vars) { + return LoopAccel::Slow; + } + let Some(entry) = Self::classify_loop_entry(env, outer, n_vars) else { + return LoopAccel::Slow; + }; + let ids = match self.int_loop_ids() { + Some(ids) => ids, + None => return LoopAccel::Slow, + }; + let outer_len = outer.len(); + let ctx = LoopMatchCtx { + ids: &ids, + outer_len, + env, + }; + let mut uses = Vec::new(); + let Some(plan) = self.build_loop_plan(test, then, els, entry.kinds, ctx, &mut uses) else { + return LoopAccel::Slow; + }; + let has_maps = plan.kinds.contains(&LoopVarKind::Map); + let has_vecs = plan.kinds.contains(&LoopVarKind::Vec); + if has_maps { + // Drop the entry frame's references (in-place map + // threading needs unshared nodes); the rounds never + // touch `env`, and a bail rebuilds it from the saved + // entry values. Vector threading is copy-on-write, so + // vec-only loops keep `env` (bail needs no rebuild). + *env = outer.clone(); + } + // Integer-only loops run the tagless round loop (no map + // buffers); map/vector loops thread below. Every plan + // carries the validated root epoch (see `plan_guard_epoch`). + let outcome = if has_maps || has_vecs { + self.run_loop_rounds(&plan, outer, entry.ints, entry.maps, entry.vecs) + } else { + self.run_int_rounds(&plan, outer, entry.ints) + }; + Self::finish_loop_accel(outcome, has_maps, outer, entry.saved, env) + } + + /// Whether the loop binds plain symbols over sequential single + /// slots (each `Symbol` extends by exactly one). + fn loop_bindings_plain( + bindings: &[(Pattern, Expr)], + env: &Env, + outer: &Env, + n_vars: usize, + ) -> bool { + env.len() == outer.len() + n_vars + && bindings + .iter() + .all(|(pat, _)| matches!(pat, Pattern::Symbol(_))) + } + + /// Entry guard: every loop var holds an `Int`, an unsorted + /// `Map`, or a packed int `Vector` (anything else — ratios, + /// strings, `nil`, sorted maps, generic vectors — takes the + /// slow path, which keeps the exact coercion, ordering, and + /// error behavior). `None` declines to the slow path. + fn classify_loop_entry(env: &Env, outer: &Env, n_vars: usize) -> Option { + let mut kinds = Vec::with_capacity(n_vars); + let mut saved = Vec::with_capacity(n_vars); + let mut ints = Vec::with_capacity(n_vars); + let mut maps: Vec> = Vec::with_capacity(n_vars); + let mut vecs: Vec> = Vec::with_capacity(n_vars); + for slot in outer.len()..outer.len() + n_vars { + match env.get(slot) { + Value::Int(v) => { + kinds.push(LoopVarKind::Int); + saved.push(Value::Int(v)); + ints.push(v); + maps.push(None); + vecs.push(None); + } + Value::Map(m) if !m.is_sorted() => { + kinds.push(LoopVarKind::Map); + saved.push(Value::Map(m.clone())); + ints.push(0); + // Thread a deep copy (fresh nodes mutate in + // place); the shallow clone above stays pristine + // for bailout. + maps.push(Some(m.deep_clone())); + vecs.push(None); + } + Value::Vector(v) if v.is_packed() => { + kinds.push(LoopVarKind::Vec); + saved.push(Value::Vector(v.clone())); + ints.push(0); + maps.push(None); + // Thread under a fresh identity (chunks detach + // on first touch); the shallow clone above stays + // pristine for bailout. + vecs.push(Some(v.thread_entry())); + } + _ => return None, + } + } + Some(LoopEntry { + kinds, + saved, + ints, + maps, + vecs, + }) + } + + /// Match the test, both branches, and the root guard into a + /// round plan (`None` declines to the slow path). + fn build_loop_plan<'a>( + &mut self, + test: &Expr, + then: &'a Expr, + els: &'a Expr, + kinds: Vec, + ctx: LoopMatchCtx<'_>, + uses: &mut Vec, + ) -> Option> { + let test = Self::match_int_test(test, ctx.ids, &kinds, ctx.outer_len, ctx.env, uses)?; + let then_act = + Self::match_loop_branch(then, ctx.ids, &kinds, ctx.outer_len, ctx.env, uses)?; + let else_act = Self::match_loop_branch(els, ctx.ids, &kinds, ctx.outer_len, ctx.env, uses)?; + let guard_epoch = self.plan_guard_epoch(uses)?; + Some(IntLoopPlan { + test, + then_act, + else_act, + kinds, + guard_epoch, + }) + } + + /// Validate every direct call the plan inlined and capture + /// the root epoch (`None` declines to the slow path): the same + /// checks [`Evaluator::direct_valid`] runs per call (no thread + /// binding, a fresh epoch, the analyzed root still current), + /// once per plan instead of once per call — including the + /// slow road's cache fill (a loop that calls first would + /// otherwise always miss, like a first call before its + /// [`deref_var_cached`](Self::deref_var_cached)). Rounds + /// re-check the epoch (bindings cannot change mid-loop on this + /// thread, and every root change bumps the epoch). + fn plan_guard_epoch(&mut self, uses: &[PlanUse]) -> Option { + let epoch = self.registry.epoch(); + if epoch != self.root_epoch { + self.root_cache.clear(); + self.root_epoch = epoch; + } + for use_ in uses { + if self.bindings.get(use_.var_id).is_some() { + return None; + } + let cached = match self.root_cache.get(&use_.var_id) { + Some(v) => v.clone(), + None => { + let root = self.registry.root_cloned(use_.var_id)?; + self.root_cache.insert(use_.var_id, root.clone()); + root + } + }; + let Value::Extension(current) = &cached else { + return None; + }; + if !Arc::ptr_eq(current, &use_.expect) { + return None; + } + } + Some(epoch) + } + + /// Whether a direct call site is the genuine `display` native + /// (the var id plus the analyzed root's installed display — a + /// native-for-native swap like `with-redefs [assoc conj]` + /// keeps the id but changes the root, so id alone would + /// mis-thread it; test doubles install under `user`, never + /// these displays). + fn direct_expect_is(call: &DirectCall, id: u64, display: &str) -> bool { + call.var.id == id + && call + .expect + .as_any() + .downcast_ref::() + .is_some_and(|n| n.display.as_ref() == display) + } + + /// Translate a round outcome into the accelerator's answer, + /// rebuilding the entry frame on a map-loop bail. + fn finish_loop_accel( + outcome: RoundOutcome, + has_maps: bool, + outer: &Env, + saved: Vec, + env: &mut Env, + ) -> LoopAccel { + match outcome { + RoundOutcome::Done(result) => LoopAccel::Done(result), + RoundOutcome::Bail => { + if has_maps { + *env = outer.extend(saved); + } + LoopAccel::Slow + } + // The slow loop binds these itself (entry state is + // behind us — the entry `env` stays dropped). + RoundOutcome::Resume(vals) => LoopAccel::Resume(vals), + } + } + + /// Run integer-only rounds (see [`Evaluator::try_int_loop`]): + /// tagless `i64` state in double-buffered rounds, with no + /// per-round allocation past the epoch re-check. + fn run_int_rounds( + &mut self, + plan: &IntLoopPlan, + outer: &Env, + mut ints: Vec, + ) -> RoundOutcome { + let n_vars = plan.kinds.len(); + let mut next = Vec::with_capacity(n_vars); + // One exit env reused across exit rounds (rebound in place + // when unobserved, like `eval_loop`'s frame — an exit that + // captures and recurs falls back to a fresh frame per + // round, so closures keep observing their own round). + let mut exit_env: Option = None; + // Whether an exit evaluated yet (exits run arbitrary code — + // once one has, a bail resumes slow from the current + // integers instead of rerunning from entry, which would + // repeat its effects). + let mut effects = false; + loop { + // A root changed mid-loop (redefinition from another + // thread): bail so the slow path observes it. + if self.registry.epoch() != plan.guard_epoch { + return Self::bail_int_rounds(effects, &ints); + } + let cond = match Self::eval_int_test(&ints, &plan.test) { + Some(cond) => cond, + None => return Self::bail_int_rounds(effects, &ints), + }; + let action = if cond { &plan.then_act } else { &plan.else_act }; + match action { + IntBranch::RecurInt(steps) => { + // Every step evaluates against the pre-`recur` + // state (like the slow path's value vector). + next.clear(); + if !Self::apply_int_steps(steps, &ints, &mut next) { + return Self::bail_int_rounds(effects, &ints); + } + std::mem::swap(&mut ints, &mut next); + } + IntBranch::Recur(_) => return Self::bail_int_rounds(effects, &ints), + IntBranch::Exit(exit) => { + effects = true; + match self.run_int_exit(exit, outer, &mut exit_env, &ints) { + ExitAction::Done(outcome) => return outcome, + // A surfacing `recur` reseeds the buffers + // (keep looping); exotic shapes resume slow + // from their own values (never rerun — the + // exit just ran). + ExitAction::Reseed(vals) => { + if let Err(vals) = Self::reseed_ints(vals, n_vars, &mut ints) { + return RoundOutcome::Resume(vals); + } + } + } + } + } + } + } + + /// Bail out of integer-only rounds: rerun from entry when no + /// exit ran yet (nothing observable happened — pure `recur` + /// steps compute into buffers); otherwise resume the slow loop + /// from the current integers (the integers are always exact — + /// steps compute into the next buffer, never in place). + fn bail_int_rounds(effects: bool, ints: &[i64]) -> RoundOutcome { + if effects { + let vals = ints.iter().map(|&v| Value::Int(v)).collect(); + RoundOutcome::Resume(vals) + } else { + RoundOutcome::Bail + } + } + + /// Apply integer-only `recur` steps into the next buffer + /// (`false` bails to the interpreter). + fn apply_int_steps(steps: &[IntExpr], ints: &[i64], next: &mut Vec) -> bool { + for step in steps { + match Self::eval_int_expr(ints, step) { + Some(v) => next.push(v), + None => return false, + } + } + true + } + + /// Rebind the integers from an exit-branch `recur` (`Err` + /// carries the values back: the caller resumes slow from them — + /// never reruns, since the exit just ran. Length mismatches only + /// arise from hand-built plans — the analyzer arity-checks every + /// `recur` — so resume callers always receive well-formed + /// lengths). + fn reseed_ints(vals: Vec, n_vars: usize, ints: &mut [i64]) -> Result<(), Vec> { + if vals.len() != n_vars { + return Err(vals); + } + let exotic = vals.iter().any(|v| !matches!(v, Value::Int(_))); + if exotic { + return Err(vals); + } + // Infallible (validated above — every value is an `Int`). + for (i, v) in vals.into_iter().enumerate() { + if let Value::Int(x) = v { + ints[i] = x; + } + } + Ok(()) + } + + /// Run one integer-only exit branch: `Done` leaves the loop, + /// `Reseed` carries a surfacing `recur` back (keep looping — the + /// slow path rebinds the same way). + fn run_int_exit( + &mut self, + exit: &Expr, + outer: &Env, + exit_env: &mut Option, + ints: &[i64], + ) -> ExitAction { + let mut finals: Vec = ints.iter().map(|&v| Value::Int(v)).collect(); + let env = match exit_env.as_mut() { + Some(env) => { + if !env.try_rebind(outer, &mut finals) { + *env = outer.extend(finals); + } + env + } + None => exit_env.insert(outer.extend(finals)), + }; + match self.eval(exit, env) { + Ok(v) => ExitAction::Done(RoundOutcome::Done(Ok(v))), + Err(Signal::Recur(vals)) => ExitAction::Reseed(vals), + Err(other) => ExitAction::Done(RoundOutcome::Done(Err(other))), + } + } + + /// Run accelerated rounds (see [`Evaluator::try_int_loop`]): + /// every step evaluates against the pre-`recur` state (like the + /// slow path's value vector), with double-buffered integers and + /// maps (no allocation per round past the threaded assocs + /// themselves). + fn run_loop_rounds( + &mut self, + plan: &IntLoopPlan, + outer: &Env, + mut ints: Vec, + mut maps: Vec>, + mut vecs: Vec>, + ) -> RoundOutcome { + let kinds = &plan.kinds; + let n_vars = kinds.len(); + let mut next_ints = Vec::with_capacity(n_vars); + let mut next_maps: Vec> = Vec::with_capacity(n_vars); + let mut next_vecs: Vec> = Vec::with_capacity(n_vars); + // One exit env reused across exit rounds (see + // `run_int_rounds`). + let mut exit_env: Option = None; + // Whether an exit evaluated yet (see `run_int_rounds`): past + // that point a bail resumes slow from the round-start + // snapshot (threaded buffers may hold partial in-place + // updates, so the integers alone are not exact). + let mut effects = false; + let mut snap: Vec = Vec::new(); + loop { + if effects { + Self::snap_round(kinds, &ints, &maps, &vecs, &mut snap); + } + // A root changed mid-loop (redefinition from another + // thread): bail so the slow path observes it. + if self.registry.epoch() != plan.guard_epoch { + return Self::bail_mixed_rounds(effects, std::mem::take(&mut snap)); + } + let cond = match Self::eval_int_test(&ints, &plan.test) { + Some(cond) => cond, + None => return Self::bail_mixed_rounds(effects, std::mem::take(&mut snap)), + }; + let action = if cond { &plan.then_act } else { &plan.else_act }; + match action { + IntBranch::Recur(steps) => { + next_ints.clear(); + next_maps.clear(); + next_vecs.clear(); + if !Self::apply_recur_steps( + steps, + &ints, + &mut maps, + &mut vecs, + &mut next_ints, + &mut next_maps, + &mut next_vecs, + ) { + return Self::bail_mixed_rounds(effects, std::mem::take(&mut snap)); + } + std::mem::swap(&mut ints, &mut next_ints); + std::mem::swap(&mut maps, &mut next_maps); + std::mem::swap(&mut vecs, &mut next_vecs); + } + IntBranch::RecurInt(_) => { + return Self::bail_mixed_rounds(effects, std::mem::take(&mut snap)); + } + IntBranch::Exit(exit) => { + let Some(finals) = Self::take_finals(kinds, &ints, &mut maps, &mut vecs) else { + // Drained buffers at an exit arm: only + // hand-built plans (real flow keeps buffers + // full at every round start — entry fills, + // recur swaps, exit-continue reseeds). + return Self::bail_mixed_rounds(effects, std::mem::take(&mut snap)); + }; + effects = true; + match self.run_loop_exit(exit, outer, &mut exit_env, finals) { + ExitAction::Done(outcome) => return outcome, + // A surfacing `recur` reseeds the buffers + // (keep looping); exotic shapes resume slow + // from their own values (never rerun — the + // exit just ran). + ExitAction::Reseed(vals) => { + if let Err(vals) = + Self::reseed_from_vals(kinds, vals, &mut ints, &mut maps, &mut vecs) + { + return RoundOutcome::Resume(vals); + } + } + } + } + } + } + } + + /// Snapshot the round-start values for a resume (see + /// [`Evaluator::bail_mixed_rounds`]): ints copy, maps/vecs bump + /// their `Arc`s (threading detaches on write, so the snapshot + /// stays pristine; round-start buffers are always full in real + /// flow — entry fills, recur swaps, exit-continue reseeds). + fn snap_round( + kinds: &[LoopVarKind], + ints: &[i64], + maps: &[Option], + vecs: &[Option], + snap: &mut Vec, + ) { + snap.clear(); + for (i, kind) in kinds.iter().enumerate() { + match kind { + LoopVarKind::Int => snap.push(Value::Int(ints[i])), + LoopVarKind::Map => { + snap.push(Value::Map( + maps[i].clone().expect("round-start map present"), + )); + } + LoopVarKind::Vec => snap.push(Value::Vector( + vecs[i].clone().expect("round-start vector present"), + )), + } + } + } + + /// Bail out of mixed rounds: rerun from entry when no exit ran + /// yet (nothing observable happened); otherwise resume the slow + /// loop from the round-start snapshot (see + /// [`Evaluator::snap_round`]). + fn bail_mixed_rounds(effects: bool, snap: Vec) -> RoundOutcome { + if effects { + RoundOutcome::Resume(snap) + } else { + RoundOutcome::Bail + } + } + + /// Evaluate one `recur` step against the round's buffers + /// (`None` bails to the interpreter). + fn eval_loop_step( + step: &LoopStep, + ints: &[i64], + maps: &mut [Option], + vecs: &mut [Option], + ) -> Option { + match step { + LoopStep::Int(expr) => Self::eval_int_expr(ints, expr).map(SteppedValue::Int), + LoopStep::Map(ms) => Self::eval_map_step(ms, ints, maps).map(SteppedValue::Map), + LoopStep::Vec(vs) => Self::eval_vec_step(vs, ints, vecs).map(SteppedValue::Vec), + } + } + + /// Apply every `recur` step into the next-buffers (`false` bails + /// to the interpreter). + fn apply_recur_steps( + steps: &[LoopStep], + ints: &[i64], + maps: &mut [Option], + vecs: &mut [Option], + next_ints: &mut Vec, + next_maps: &mut Vec>, + next_vecs: &mut Vec>, + ) -> bool { + for step in steps { + match Self::eval_loop_step(step, ints, maps, vecs) { + Some(SteppedValue::Int(v)) => { + next_ints.push(v); + next_maps.push(None); + next_vecs.push(None); + } + Some(SteppedValue::Map(m)) => { + next_ints.push(0); + next_maps.push(Some(m)); + next_vecs.push(None); + } + Some(SteppedValue::Vec(v)) => { + next_ints.push(0); + next_maps.push(None); + next_vecs.push(Some(v)); + } + None => return false, + } + } + true + } + + /// Collect the exit branch's loop-var values (`None` bails to + /// the interpreter). + fn take_finals( + kinds: &[LoopVarKind], + ints: &[i64], + maps: &mut [Option], + vecs: &mut [Option], + ) -> Option> { + let mut finals = Vec::with_capacity(kinds.len()); + for (i, kind) in kinds.iter().enumerate() { + match kind { + LoopVarKind::Int => finals.push(Value::Int(ints[i])), + LoopVarKind::Map => finals.push(Value::Map(maps[i].take()?)), + LoopVarKind::Vec => finals.push(Value::Vector(vecs[i].take()?)), + } + } + Some(finals) + } + + /// Rebind the buffers from an exit-branch `recur` (`Err` + /// carries the values back: the caller resumes slow from them — + /// never reruns, since the exit just ran. Length mismatches only + /// arise from hand-built plans — the analyzer arity-checks every + /// `recur` — so resume callers always receive well-formed + /// lengths). + fn reseed_from_vals( + kinds: &[LoopVarKind], + vals: Vec, + ints: &mut [i64], + maps: &mut [Option], + vecs: &mut [Option], + ) -> Result<(), Vec> { + if vals.len() != kinds.len() { + return Err(vals); + } + let fits = kinds + .iter() + .zip(vals.iter()) + .all(|(kind, v)| match (kind, v) { + (LoopVarKind::Int, Value::Int(_)) => true, + (LoopVarKind::Map, Value::Map(m)) => !m.is_sorted(), + (LoopVarKind::Vec, Value::Vector(v)) => v.is_packed(), + _ => false, + }); + if !fits { + return Err(vals); + } + // Infallible (validated above — every pair fits its kind). + for (i, (kind, v)) in kinds.iter().zip(vals).enumerate() { + match kind { + LoopVarKind::Int => { + if let Value::Int(x) = v { + ints[i] = x; + } + } + LoopVarKind::Map => { + if let Value::Map(m) = v { + maps[i] = Some(m); + } + } + LoopVarKind::Vec => { + if let Value::Vector(v) = v { + vecs[i] = Some(v.thread_entry()); + } + } + } + } + Ok(()) + } + + /// Run one exit branch over collected finals: `Done` leaves the + /// loop, `Reseed` carries a surfacing `recur` back (keep looping + /// — the slow path rebinds the same way). + fn run_loop_exit( + &mut self, + exit: &Expr, + outer: &Env, + exit_env: &mut Option, + mut finals: Vec, + ) -> ExitAction { + let env = match exit_env.as_mut() { + Some(env) => { + if !env.try_rebind(outer, &mut finals) { + *env = outer.extend(finals); + } + env + } + None => exit_env.insert(outer.extend(finals)), + }; + match self.eval(exit, env) { + Ok(v) => ExitAction::Done(RoundOutcome::Done(Ok(v))), + Err(Signal::Recur(vals)) => ExitAction::Reseed(vals), + Err(other) => ExitAction::Done(RoundOutcome::Done(Err(other))), + } + } + + /// Resolve the loop-op vars answered by identity (`assoc`, + /// `nth`, `=` — the `fast`-tagged ops ride the `DirectNative` + /// node instead). A missing core var declines the fast path. + fn int_loop_ids(&self) -> Option { + let resolve = |name: &str| { + let sym = Symbol::new(None, name); + self.registry + .resolve_target_and_id("clojure.core", &sym) + .map(|(_, id)| id) + }; + Some(IntLoopIds { + assoc: resolve("assoc")?, + nth: resolve("nth")?, + eq: resolve("=")?, + }) + } + + /// Match one integer operand: an integer loop var, an `Int` + /// literal, a frozen outer `Int` local (no `set!` on locals, and + /// the fast loop runs no code that could convey bindings), or a + /// validated direct integer op. Indirect calls decline (their + /// roots are redefined or non-native, so the slow path must + /// observe them). + fn match_int_expr( + expr: &Expr, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + match expr { + Expr::Local(s) => Self::match_int_local(*s, kinds, outer_len, env), + Expr::Literal(Value::Int(c)) => Some(IntExpr::single(IntOp::Const(*c))), + Expr::DirectNative(call) => Self::match_direct_int(call, kinds, outer_len, env, uses), + _ => None, + } + } + + /// Match an integer loop-var or frozen-outer `Local` (map loop + /// vars decline — integer positions never read map state). + fn match_int_local( + s: usize, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + ) -> Option { + if s < outer_len { + match env.get(s) { + Value::Int(c) => Some(IntExpr::single(IntOp::Const(c))), + _ => None, + } + } else { + let var = s - outer_len; + (kinds.get(var) == Some(&LoopVarKind::Int)).then(|| IntExpr::single(IntOp::Var(var))) + } + } + + /// One n-ary integer op over matched operands (postfix: every + /// operand's ops, then the counted op — `(-)` stays on the slow + /// path, which arity-checks exactly). + fn fold_int_tag(tag: FastArith, all: Vec) -> Option { + let op = if tag == FastArith::Add { + IntOp::Add(all.len()) + } else if tag == FastArith::Sub && !all.is_empty() { + IntOp::Sub(all.len()) + } else if tag == FastArith::Mul { + IntOp::Mul(all.len()) + } else { + return None; + }; + let mut ops = Vec::new(); + for e in all { + ops.extend(e.ops); + } + ops.push(op); + Some(IntExpr { ops }) + } + + /// Match a direct integer-op call (`inc`/`dec`/`+`/`-`/`*`; + /// arities the JVM rejects stay on the slow path so the error + /// matches). Records the use for the plan guard. + fn match_direct_int( + call: &DirectCall, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + let out = Self::apply_int_tag(call.fast, &call.args, kinds, outer_len, env, uses)?; + uses.push(PlanUse::of(call)); + Some(out) + } + + /// Build an integer expression from a tagged op and matched + /// operands. + fn apply_int_tag( + tag: FastArith, + args: &[Expr], + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + if tag == FastArith::Inc || tag == FastArith::Dec { + let [arg] = args else { return None }; + let inner = Self::match_int_expr(arg, kinds, outer_len, env, uses)?; + return Some(if tag == FastArith::Inc { + inner.push_op(IntOp::Inc) + } else { + inner.push_op(IntOp::Dec) + }); + } + let all = Self::match_int_args(args, kinds, outer_len, env, uses)?; + Self::fold_int_tag(tag, all) + } + + /// Match every operand of an integer op or test. + fn match_int_args( + args: &[Expr], + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option> { + args.iter() + .map(|arg| Self::match_int_expr(arg, kinds, outer_len, env, uses)) + .collect() + } + + /// Match the loop test: a chained integer comparison over a + /// validated direct call (`=` rides tagless, answered by var + /// identity; zero-arg comparisons are arity errors on the slow + /// path). + fn match_int_test( + test: &Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + let Expr::DirectNative(call) = test else { + return None; + }; + let op = match call.fast { + FastArith::Lt => IntCmp::Lt, + FastArith::Lte => IntCmp::Le, + FastArith::Gt => IntCmp::Gt, + FastArith::Gte => IntCmp::Ge, + FastArith::None if Self::direct_expect_is(call, ids.eq, "clojure.core/=") => IntCmp::Eq, + _ => return None, + }; + if call.args.is_empty() { + return None; + } + let matched = Self::match_int_args(&call.args, kinds, outer_len, env, uses)?; + uses.push(PlanUse::of(call)); + Some(IntTest { op, args: matched }) + } + + /// Match one `if` branch: a same-arity `recur` (each step an + /// integer or threaded-map step per its kind), or the exit + /// expression (evaluated once with the final values). + fn match_loop_branch<'a>( + branch: &'a Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option> { + if let Expr::Recur(steps) = branch { + if steps.len() != kinds.len() { + return None; + } + if !kinds.contains(&LoopVarKind::Map) && !kinds.contains(&LoopVarKind::Vec) { + let matched = Self::match_int_steps(steps, kinds, outer_len, env, uses)?; + return Some(IntBranch::RecurInt(matched)); + } + let mut matched = Self::match_mixed_steps(steps, kinds, ids, outer_len, env, uses)?; + Self::mark_shared_bases(&mut matched); + return Some(IntBranch::Recur(matched)); + } + Some(IntBranch::Exit(branch)) + } + + /// Match integer-only `recur` steps. + fn match_int_steps( + steps: &[Expr], + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option> { + let mut matched = Vec::with_capacity(steps.len()); + for step in steps { + matched.push(Self::match_int_expr(step, kinds, outer_len, env, uses)?); + } + Some(matched) + } + + /// Match one `recur` step against its loop var's kind. + fn match_kind_step( + step: &Expr, + kind: LoopVarKind, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + match kind { + LoopVarKind::Int => { + Self::match_int_expr(step, kinds, outer_len, env, uses).map(LoopStep::Int) + } + LoopVarKind::Map => { + Self::match_map_step(step, ids, kinds, outer_len, env, uses).map(LoopStep::Map) + } + LoopVarKind::Vec => { + Self::match_vec_step(step, ids, kinds, outer_len, env, uses).map(LoopStep::Vec) + } + } + } + + /// Match mixed-kind `recur` steps. + fn match_mixed_steps( + steps: &[Expr], + kinds: &[LoopVarKind], + ids: &IntLoopIds, + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option> { + let mut matched = Vec::with_capacity(steps.len()); + for (i, step) in steps.iter().enumerate() { + matched.push(Self::match_kind_step( + step, kinds[i], ids, kinds, outer_len, env, uses, + )?); + } + Some(matched) + } + + /// A step's threaded base, tagged by buffer (map and vector + /// bases live in separate buffers, so only same-kind steps + /// share; `None` threads nothing). + fn step_base_kind(step: &LoopStep) -> Option<(bool, usize)> { + match step { + LoopStep::Map(ms) => Some((false, ms.base)), + LoopStep::Vec(vs) => Some((true, Self::vec_step_base(vs))), + LoopStep::Int(_) => None, + } + } + + /// Whether a base is threaded by more than one step. + fn base_is_shared(bases: &[Option<(bool, usize)>], base: (bool, usize)) -> bool { + bases.iter().filter(|b| *b == &Some(base)).count() > 1 + } + + /// Install one step's shared-base flag (see + /// `MapStep`/`VecStep`; `Int` steps thread nothing, so the + /// `None` base arm doubles as their no-op — matching the step + /// and base jointly leaves no dead arm). + fn mark_step_shared( + step: &mut LoopStep, + base: Option<(bool, usize)>, + bases: &[Option<(bool, usize)>], + ) { + match (step, base) { + (LoopStep::Map(ms), Some(base)) => { + ms.shared_base = Self::base_is_shared(bases, base); + } + (LoopStep::Vec(vs), Some(base)) => { + Self::set_vec_shared(vs, Self::base_is_shared(bases, base)); + } + _ => {} + } + } + + /// Shared bases clone per round (see `MapStep`/`VecStep`). + fn mark_shared_bases(matched: &mut [LoopStep]) { + let bases: Vec> = matched.iter().map(Self::step_base_kind).collect(); + for (step, base) in matched.iter_mut().zip(&bases) { + Self::mark_step_shared(step, *base, &bases); + } + } + + /// Match one threaded-`assoc` key or value: a frozen constant or + /// a per-round integer expression. + fn match_map_keyval( + expr: &Expr, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + match expr { + Expr::Literal(v) => Some(MapKeyVal::Const(v.clone())), + Expr::Local(s) if *s < outer_len => Some(MapKeyVal::Const(env.get(*s))), + _ => Self::match_int_expr(expr, kinds, outer_len, env, uses).map(MapKeyVal::Int), + } + } + + /// Match one threaded map step: a 3-arg `assoc` chain onto a + /// loop-carried map (nested assocs flatten inner-first, like the + /// interpreter; anything else declines). + fn match_map_step( + expr: &Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + let mut assocs = Vec::new(); + let mut cur = expr; + let base = loop { + let call = match cur { + // `assoc` rides `DirectNative` with `fast == None` + // (it observes roots): var identity plus the + // analyzed root's installed display (verified at the + // plan, see `try_int_loop`). + Expr::DirectNative(call) + if call.fast == FastArith::None + && Self::direct_expect_is(call, ids.assoc, "clojure.core/assoc") => + { + call + } + Expr::Local(s) => { + if *s < outer_len { + return None; + } + let idx = *s - outer_len; + if kinds.get(idx) != Some(&LoopVarKind::Map) { + return None; + } + break idx; + } + _ => return None, + }; + if call.args.len() != 3 { + return None; + } + uses.push(PlanUse::of(call)); + let key = Self::match_map_keyval(&call.args[1], kinds, outer_len, env, uses)?; + let val = Self::match_map_keyval(&call.args[2], kinds, outer_len, env, uses)?; + assocs.push((key, val)); + cur = &call.args[0]; + }; + assocs.reverse(); + Some(MapStep { + base, + assocs, + shared_base: false, + }) + } + + /// Match one threaded vector step: a loop-var passthrough, a + /// flat multi-pair `assoc` onto a loop-carried vector, or an + /// `if` over an integer comparison with threaded branches. + /// Every read and write must hit a single base (mixed-base + /// steps decline — cross-step read/write aliasing stays on the + /// slow path). Nested `assoc` chains decline too (their + /// inner-applies-first order differs from flat multi-pair arg + /// order once `nth` reads appear in outer pairs). + fn match_vec_step( + expr: &Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + let step = Self::match_vec_step_inner(expr, ids, kinds, outer_len, env, uses)?; + let base = Self::vec_step_base(&step); + Self::vec_step_single_base(&step, base).then_some(step) + } + + /// Match one threaded vector step without the single-base check + /// (see [`Evaluator::match_vec_step`]). + fn match_vec_step_inner( + expr: &Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + match expr { + Expr::Local(s) => { + let base = Self::match_vec_base(*s, kinds, outer_len)?; + Some(VecStep::Pass { + base, + shared_base: false, + }) + } + Expr::DirectNative(call) => { + Self::match_vec_assoc(call, ids, kinds, outer_len, env, uses) + } + Expr::If { test, then, els } => { + let (op, lhs, rhs) = Self::match_vec_cond(test, ids, kinds, outer_len, env, uses)?; + let then = Self::match_vec_step_inner(then, ids, kinds, outer_len, env, uses)?; + let els = Self::match_vec_step_inner(els, ids, kinds, outer_len, env, uses)?; + Some(VecStep::If { + op, + lhs, + rhs, + then: Box::new(then), + els: Box::new(els), + }) + } + _ => None, + } + } + + /// Match a loop-carried vector local to its base index. + fn match_vec_base(s: usize, kinds: &[LoopVarKind], outer_len: usize) -> Option { + if s < outer_len { + return None; + } + let idx = s - outer_len; + if kinds.get(idx) != Some(&LoopVarKind::Vec) { + return None; + } + Some(idx) + } + + /// Match a flat multi-pair `assoc` onto a loop-carried vector + /// (even trailing args stay on the slow path, which arity + /// checks exactly). + fn match_vec_assoc( + call: &DirectCall, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + // `assoc` rides `DirectNative` with `fast == None` (it + // observes roots): var identity plus the analyzed root's + // installed display (verified at the plan, see + // `try_int_loop`). + if call.fast != FastArith::None + || !Self::direct_expect_is(call, ids.assoc, "clojure.core/assoc") + { + return None; + } + if call.args.len() < 3 || call.args.len() % 2 == 0 { + return None; + } + let Expr::Local(s) = &call.args[0] else { + return None; + }; + let base = Self::match_vec_base(*s, kinds, outer_len)?; + let mut pairs = Vec::with_capacity(call.args.len() / 2); + for pair in call.args[1..].chunks_exact(2) { + let index = Self::match_vec_val(&pair[0], ids, kinds, outer_len, env, uses)?; + let val = Self::match_vec_val(&pair[1], ids, kinds, outer_len, env, uses)?; + pairs.push((index, val)); + } + uses.push(PlanUse::of(call)); + Some(VecStep::Assoc { + base, + pairs, + shared_base: false, + }) + } + + /// Match one threaded-vector operand: a per-round integer + /// expression or an `nth` read against a loop-carried vector. + fn match_vec_val( + expr: &Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option { + if let Some(e) = Self::match_int_expr(expr, kinds, outer_len, env, uses) { + return Some(VecVal::Int(e)); + } + let Expr::DirectNative(call) = expr else { + return None; + }; + // `nth` rides `DirectNative` with `fast == None` (it + // observes roots): var identity plus the analyzed root's + // installed display (verified at the plan, see + // `try_int_loop`). + if call.fast != FastArith::None + || !Self::direct_expect_is(call, ids.nth, "clojure.core/nth") + { + return None; + } + // Two-arg `nth` only (the not-found arity stays slow). + let [coll, index] = call.args.as_slice() else { + return None; + }; + let Expr::Local(s) = coll else { + return None; + }; + let idx = Self::match_vec_base(*s, kinds, outer_len)?; + let index = Self::match_int_expr(index, kinds, outer_len, env, uses)?; + uses.push(PlanUse::of(call)); + Some(VecVal::Nth { + base: idx, + idx: index, + }) + } + + /// Match an `if` condition over threaded vectors: a binary + /// integer comparison (indirect or linked) between threaded + /// operands. + fn match_vec_cond( + test: &Expr, + ids: &IntLoopIds, + kinds: &[LoopVarKind], + outer_len: usize, + env: &Env, + uses: &mut Vec, + ) -> Option<(IntCmp, VecVal, VecVal)> { + let Expr::DirectNative(call) = test else { + return None; + }; + let op = match call.fast { + FastArith::Lt => IntCmp::Lt, + FastArith::Lte => IntCmp::Le, + FastArith::Gt => IntCmp::Gt, + FastArith::Gte => IntCmp::Ge, + FastArith::None if Self::direct_expect_is(call, ids.eq, "clojure.core/=") => IntCmp::Eq, + _ => return None, + }; + let [lhs, rhs] = call.args.as_slice() else { + return None; + }; + let lhs = Self::match_vec_val(lhs, ids, kinds, outer_len, env, uses)?; + let rhs = Self::match_vec_val(rhs, ids, kinds, outer_len, env, uses)?; + uses.push(PlanUse::of(call)); + Some((op, lhs, rhs)) + } + + /// The single base a vector step threads (see + /// [`Evaluator::match_vec_step`]). + fn vec_step_base(step: &VecStep) -> usize { + match step { + VecStep::Pass { base, .. } | VecStep::Assoc { base, .. } => *base, + VecStep::If { then, .. } => Self::vec_step_base(then), + } + } + + /// Whether every read and write in a vector step hits `base`. + fn vec_step_single_base(step: &VecStep, base: usize) -> bool { + match step { + VecStep::Pass { base: b, .. } | VecStep::Assoc { base: b, .. } => { + if *b != base { + return false; + } + if let VecStep::Assoc { pairs, .. } = step { + for (index, val) in pairs { + if !Self::vec_val_single_base(index, base) + || !Self::vec_val_single_base(val, base) + { + return false; + } + } + } + true + } + VecStep::If { + lhs, + rhs, + then, + els, + .. + } => { + Self::vec_val_single_base(lhs, base) + && Self::vec_val_single_base(rhs, base) + && Self::vec_step_single_base(then, base) + && Self::vec_step_single_base(els, base) + } + } + } + + /// Whether an operand reads no vector base but `base`. + fn vec_val_single_base(val: &VecVal, base: usize) -> bool { + match val { + VecVal::Int(_) => true, + VecVal::Nth { base: b, .. } => *b == base, + } + } + + /// Install the shared-base flag through a vector step (see + /// `match_loop_branch`). + fn set_vec_shared(step: &mut VecStep, shared: bool) { + match step { + VecStep::Pass { shared_base, .. } | VecStep::Assoc { shared_base, .. } => { + *shared_base = shared; + } + VecStep::If { then, els, .. } => { + Self::set_vec_shared(then, shared); + Self::set_vec_shared(els, shared); + } + } + } + + /// Evaluate one threaded map step: unique bases move (the round + /// owns them), shared bases clone (every step sees the pre-round + /// map); `None` is an overflowing key or value (bail to the + /// interpreter, which throws `long overflow` like the JVM). + fn eval_map_step(step: &MapStep, ints: &[i64], maps: &mut [Option]) -> Option { + let mut base = if step.shared_base { + maps.get(step.base)?.as_ref()?.clone() + } else { + maps.get_mut(step.base)?.take()? + }; + for (k, v) in &step.assocs { + let key = Self::eval_map_keyval(k, ints)?; + let val = Self::eval_map_keyval(v, ints)?; + base = base.insert_threaded(key, val); + } + Some(base) + } + + /// Evaluate one threaded-`assoc` key or value. + fn eval_map_keyval(kv: &MapKeyVal, ints: &[i64]) -> Option { + match kv { + MapKeyVal::Const(v) => Some(v.clone()), + MapKeyVal::Int(e) => Self::eval_int_expr(ints, e).map(Value::Int), + } + } + + /// Evaluate one threaded vector step: unique bases move (the + /// round owns them), shared bases clone (every step sees the + /// pre-round vector); `None` bails to the interpreter (an + /// overflowing index/value throws `long overflow` there, an out + /// of bounds one throws exactly — the partial round is + /// discarded and the slow path recomputes from entry). + fn eval_vec_step(step: &VecStep, ints: &[i64], vecs: &mut [Option]) -> Option { + let base = Self::vec_step_base(step); + let shared = Self::vec_step_shared(step); + let v = if shared { + vecs.get(base)?.as_ref()?.clone() + } else { + vecs.get_mut(base)?.take()? + }; + Self::eval_vec_step_owned(step, ints, v) + } + + /// The shared-base flag of a vector step's head (see + /// `match_loop_branch` — every nested flag carries the same + /// value). + fn vec_step_shared(step: &VecStep) -> bool { + match step { + VecStep::Pass { shared_base, .. } | VecStep::Assoc { shared_base, .. } => *shared_base, + VecStep::If { then, .. } => Self::vec_step_shared(then), + } + } + + /// Evaluate a vector step against its owned (moved or cloned) + /// base: conditions and operands read the pre-step vector, like + /// the interpreter (which evaluates every argument before + /// applying any pair). + fn eval_vec_step_owned(step: &VecStep, ints: &[i64], v: Vector) -> Option { + match step { + VecStep::Pass { .. } => Some(v), + VecStep::Assoc { pairs, .. } => Self::eval_vec_assoc_owned(pairs, ints, v), + VecStep::If { + op, + lhs, + rhs, + then, + els, + } => { + let l = Self::eval_vec_val(lhs, ints, &v)?; + let r = Self::eval_vec_val(rhs, ints, &v)?; + let branch = if Self::apply_int_cmp(*op, l, r) { + then + } else { + els + }; + Self::eval_vec_step_owned(branch, ints, v) + } + } + } + + /// Apply one multi-pair `assoc` against its owned base: all + /// indexes and values first (in arg order — the first failure + /// bails, like the interpreter), then the pairs apply left to + /// right. + fn eval_vec_assoc_owned( + pairs: &[(VecVal, VecVal)], + ints: &[i64], + mut v: Vector, + ) -> Option { + let mut done: SmallVec<[(i64, i64); 4]> = SmallVec::new(); + for (index, val) in pairs { + let index = Self::eval_vec_val(index, ints, &v)?; + let val = Self::eval_vec_val(val, ints, &v)?; + done.push((index, val)); + } + for (index, val) in done { + let at = usize::try_from(index).ok()?; + if !v.assoc_int_threaded(at, val) { + return None; + } + } + Some(v) + } + + /// Apply one integer comparison. + fn apply_int_cmp(op: IntCmp, l: i64, r: i64) -> bool { + match op { + IntCmp::Lt => l < r, + IntCmp::Le => l <= r, + IntCmp::Gt => l > r, + IntCmp::Ge => l >= r, + IntCmp::Eq => l == r, + } + } + + /// Evaluate one threaded-vector operand against the pre-step + /// vector. + fn eval_vec_val(val: &VecVal, ints: &[i64], v: &Vector) -> Option { + match val { + VecVal::Int(e) => Self::eval_int_expr(ints, e), + VecVal::Nth { idx, .. } => { + let at = usize::try_from(Self::eval_int_expr(ints, idx)?).ok()?; + v.nth_int(at) + } + } + } + + /// Evaluate one integer expression: leaves and leaf-built + /// steps compute directly (no stack, no call — the counted-loop + /// shapes stay in registers); anything nested runs the general + /// machine. `None` is overflow (bail to the interpreter, which + /// throws `long overflow` like the JVM). + fn eval_int_expr(state: &[i64], expr: &IntExpr) -> Option { + let ops = &expr.ops; + if ops.len() == 1 { + if let IntOp::Var(v) = ops[0] { + return state.get(v).copied(); + } + if let IntOp::Const(c) = ops[0] { + return Some(c); + } + } + Self::eval_int_compound(state, ops) + } + + /// Evaluate a multi-op expression (see + /// [`Evaluator::eval_int_expr`]). + fn eval_int_compound(state: &[i64], ops: &[IntOp]) -> Option { + // `inc`/`dec` over a leaf (the counted-loop step). + if ops.len() == 2 { + if let IntOp::Inc = ops[1] { + if let Some(x) = Self::int_leaf(state, &ops[0]) { + return x.checked_add(1); + } + } + if let IntOp::Dec = ops[1] { + if let Some(x) = Self::int_leaf(state, &ops[0]) { + return x.checked_sub(1); + } + } + } + if let Some(folded) = Self::try_fold_leaves(state, ops) { + return Some(folded); + } + Self::eval_int_general(state, ops) + } + + /// Read one leaf operand (`None` is a non-leaf — the caller + /// falls back to the general machine). + fn int_leaf(state: &[i64], op: &IntOp) -> Option { + if let IntOp::Var(v) = op { + return state.get(*v).copied(); + } + if let IntOp::Const(c) = op { + return Some(*c); + } + None + } + + /// Fold a counted op over all-leaf operands (`None` declines to + /// the general machine — any non-leaf operand). + fn try_fold_leaves(state: &[i64], ops: &[IntOp]) -> Option { + let (op, operands) = ops.split_last()?; + match op { + IntOp::Add(n) => Self::fold_leaf_add(state, operands, *n), + IntOp::Sub(n) => Self::fold_leaf_sub(state, operands, *n), + IntOp::Mul(n) => Self::fold_leaf_mul(state, operands, *n), + _ => None, + } + } + + /// Fold `+` over leaf operands (see + /// [`Evaluator::try_fold_leaves`]). + fn fold_leaf_add(state: &[i64], operands: &[IntOp], n: usize) -> Option { + if operands.len() != n { + return None; + } + let mut acc = 0i64; + for op in operands { + acc = acc.checked_add(Self::int_leaf(state, op)?)?; + } + Some(acc) + } + + /// Fold `-` over leaf operands (see + /// [`Evaluator::try_fold_leaves`]). + fn fold_leaf_sub(state: &[i64], operands: &[IntOp], n: usize) -> Option { + if operands.len() != n || n == 0 { + return None; + } + let mut rest = operands.iter(); + let first = Self::int_leaf(state, rest.next()?)?; + if rest.len() == 0 { + return first.checked_neg(); + } + let mut acc = first; + for op in rest { + acc = acc.checked_sub(Self::int_leaf(state, op)?)?; + } + Some(acc) + } + + /// Fold `*` over leaf operands (see + /// [`Evaluator::try_fold_leaves`]). + fn fold_leaf_mul(state: &[i64], operands: &[IntOp], n: usize) -> Option { + if operands.len() != n { + return None; + } + let mut acc = 1i64; + for op in operands { + acc = acc.checked_mul(Self::int_leaf(state, op)?)?; + } + Some(acc) + } + + /// Evaluate a nested expression iteratively over a scratch + /// stack (no recursion — deeply nested source can't overflow + /// the native stack): `None` bails to the interpreter. + fn eval_int_general(state: &[i64], ops: &[IntOp]) -> Option { + // Stack-inline scratch (spills past depth 8): nested + // expressions are cold, so one scratch per eval is fine. + let mut stack: SmallVec<[i64; 8]> = SmallVec::new(); + for op in ops { + Self::apply_general_op(state, &mut stack, op)?; + } + stack.pop() + } + + /// Apply one general-machine op (see + /// [`Evaluator::eval_int_general`]). + fn apply_general_op(state: &[i64], stack: &mut SmallVec<[i64; 8]>, op: &IntOp) -> Option<()> { + match op { + IntOp::Var(v) => Self::push_int_var(state, stack, *v), + IntOp::Const(c) => { + stack.push(*c); + Some(()) + } + IntOp::Inc => Self::step_int_top(stack, 1), + IntOp::Dec => Self::step_int_top(stack, -1), + IntOp::Add(n) => Self::eval_int_add(stack, *n), + IntOp::Sub(n) => Self::eval_int_sub(stack, *n), + IntOp::Mul(n) => Self::eval_int_mul(stack, *n), + } + } + + /// Push one loop-var read (see [`Evaluator::eval_int_general`]). + fn push_int_var(state: &[i64], stack: &mut SmallVec<[i64; 8]>, v: usize) -> Option<()> { + stack.push(*state.get(v)?); + Some(()) + } + + /// Step the top of stack by `delta` (`inc` passes `1`, `dec` + /// `-1` — `checked_add(-1)` is exactly `checked_sub(1)`). + fn step_int_top(stack: &mut SmallVec<[i64; 8]>, delta: i64) -> Option<()> { + let x = stack.pop()?; + stack.push(x.checked_add(delta)?); + Some(()) + } + + /// Fold the top `n` stack slots with `+` (`(+)` is `0`, like the + /// JVM). + fn eval_int_add(stack: &mut SmallVec<[i64; 8]>, n: usize) -> Option<()> { + let base = stack.len().checked_sub(n)?; + let mut acc = 0i64; + for x in &stack[base..] { + acc = acc.checked_add(*x)?; + } + stack.truncate(base); + stack.push(acc); + Some(()) + } + + /// Fold the top `n` stack slots with `-` (unary negates, like the + /// JVM). + fn eval_int_sub(stack: &mut SmallVec<[i64; 8]>, n: usize) -> Option<()> { + let base = stack.len().checked_sub(n)?; + let mut rest = stack[base..].iter(); + let first = *rest.next()?; + let acc = if rest.len() == 0 { + first.checked_neg()? + } else { + let mut acc = first; + for x in rest { + acc = acc.checked_sub(*x)?; + } + acc + }; + stack.truncate(base); + stack.push(acc); + Some(()) + } + + /// Fold the top `n` stack slots with `*` (`(*)` is `1`, like the + /// JVM). + fn eval_int_mul(stack: &mut SmallVec<[i64; 8]>, n: usize) -> Option<()> { + let base = stack.len().checked_sub(n)?; + let mut acc = 1i64; + for x in &stack[base..] { + acc = acc.checked_mul(*x)?; + } + stack.truncate(base); + stack.push(acc); + Some(()) + } + + /// Evaluate the chained test (`None` is an overflowing operand — + /// comparisons themselves cannot overflow). + fn eval_int_test(state: &[i64], test: &IntTest) -> Option { + let mut prev = Self::eval_int_expr(state, &test.args[0])?; + for arg in &test.args[1..] { + let next = Self::eval_int_expr(state, arg)?; + if !Self::apply_int_cmp(test.op, prev, next) { + return Some(false); + } + prev = next; + } + Some(true) + } /// `(loop …)`: bind once, then run the body, rebinding through /// the patterns on every `recur`. + /// Run the counted-loop accelerator for one `(loop …)`: + /// `Some` already finished; `None` runs the slow loop — a + /// `Resume` rebinds the carried values into `env` first (an + /// exit already ran, so rerunning would repeat its effects). + fn run_loop_accel( + &mut self, + bindings: &[(Pattern, Expr)], + body: &[Expr], + env: &mut Env, + outer: &Env, + pats: &[&Pattern], + ) -> Result>, Signal> { + match self.try_int_loop(bindings, body, env, outer) { + LoopAccel::Done(result) => Ok(Some(result)), + LoopAccel::Slow => Ok(None), + LoopAccel::Resume(vals) => { + *env = self.bind_values(pats, vals, outer)?; + Ok(None) + } + } + } + fn eval_loop( &mut self, bindings: &[(Pattern, Expr)], @@ -1735,6 +3581,12 @@ impl Evaluator { // closures from this iteration keep observing the old // frame). let pats: SmallVec<[&Pattern; 8]> = bindings.iter().map(|(pat, _)| pat).collect(); + // Counted integer loops run without interpreter dispatch + // (see [`Evaluator::try_int_loop`]): `Some` already + // finished, `None` runs the slow loop below. + if let Some(finished) = self.run_loop_accel(bindings, body, &mut env, &outer, &pats)? { + return finished; + } loop { match self.eval_body(body, &env) { Ok(v) => return Ok(v), @@ -1897,6 +3749,18 @@ impl Evaluator { /// memoized seq chunks free as they advance, like the JVM's /// dead locals); everything else borrows it (the buffer drops /// at return, as before). + /// The closure a local-head call applies directly (`None` + /// takes generic dispatch). Closure calls never observe the + /// span (every span reader sits behind its own native call); + /// the depth guard and conveyed bindings still apply, so the + /// caller funnels through `apply_closure`. + fn local_closure<'v>(head: &Expr, callee: &'v Value) -> Option<&'v Closure> { + if !matches!(head, Expr::Local(_)) { + return None; + } + as_closure(callee) + } + fn invoke_args( &mut self, callee: &Value, @@ -2444,6 +4308,78 @@ impl Evaluator { /// Bind one arity's parameters (sequentially, like `let`) and /// run the body, looping on `recur`. + /// Apply one fixed arity with a caller-reused frame (the + /// peeled-map/filter hot path): the first apply binds fresh, + /// later applies rebind in place when unobserved, else bind + /// fresh — the [`apply_arity`](Self::apply_arity) + /// copy-on-capture promise across applies as well as recurs. + /// Caller guarantees `arity` is `closure`'s only arity, fixed, + /// matching `args`, with no conveyed bindings; the depth guard + /// and stack growth match [`apply_closure`](Self::apply_closure) + /// apply-for-apply. + pub(crate) fn apply_reused( + &mut self, + closure: &Closure, + arity: &crate::expr::Arity, + frame: &mut Option, + args: &[Value], + ) -> Result { + if self.call_depth >= MAX_CALL_DEPTH { + return Err(self.throw(NativeError::new( + ThrowClass::StackOverflowError, + String::new(), + ))); + } + self.call_depth += 1; + let result = stacker::maybe_grow(STACK_RED_ZONE, STACK_SEGMENT, || { + self.apply_reused_inner(closure, arity, frame, args) + }); + self.call_depth -= 1; + result + } + + /// Bind (or rebind) the reused frame and run the body, looping + /// on `recur` to the fn (see [`apply_reused`](Self::apply_reused)). + fn apply_reused_inner( + &mut self, + closure: &Closure, + arity: &crate::expr::Arity, + frame: &mut Option, + args: &[Value], + ) -> Result { + let outer = closure.env.clone(); + let pats: SmallVec<[&Pattern; 8]> = arity.fixed.iter().collect(); + let fr = match frame.as_mut() { + Some(fr) => { + // Fresh call args (never a pooled `recur` buffer), + // so no recycle on the rebind. + let mut values = args.to_vec(); + if !fr.try_rebind(&outer, &mut values) { + *fr = self.bind_values(&pats, values, &outer)?; + } + fr + } + None => frame.insert(self.bind_values(&pats, args.to_vec(), &outer)?), + }; + loop { + match self.eval_body(&arity.body, fr) { + Ok(v) => return Ok(v), + Err(Signal::Recur(values)) => { + let mut values = values; + if fr.try_rebind(&outer, &mut values) { + // Recycle the drained buffer (see `recur_pool`). + if self.recur_pool.len() < RECUR_POOL_MAX { + self.recur_pool.push(values); + } + } else { + *fr = self.bind_values(&pats, values, &outer)?; + } + } + Err(signal) => return Err(signal), + } + } + } + /// Bind precomputed values against patterns (call params, `recur` /// rebinds — no inits evaluate here, so no observer can see an /// intermediate frame): all-symbol patterns extend once, with a @@ -3652,4 +5588,111 @@ mod tests { assert_eq!(Evaluator::fast_int2(Inc, 1, 2), None); assert_eq!(Evaluator::fast_int2(Dec, 1, 2), None); } + + #[test] + fn int_rounds_bail_on_mixed_branch() { + // Defense in depth: an integer-only plan never matches a + // mixed `Recur` branch (see `match_loop_branch`), so the + // bail arm is pinned directly with a hand-built plan. + let mut eval = Evaluator::new("test"); + let exit = Expr::Literal(Value::Nil); + let plan = IntLoopPlan { + test: IntTest { + op: IntCmp::Gt, + args: vec![ + IntExpr::single(IntOp::Const(1)), + IntExpr::single(IntOp::Const(0)), + ], + }, + then_act: IntBranch::Recur(vec![]), + else_act: IntBranch::Exit(&exit), + kinds: vec![LoopVarKind::Int], + guard_epoch: eval.registry.epoch(), + }; + let outcome = eval.run_int_rounds(&plan, &Env::new(), vec![0]); + assert!(matches!(outcome, RoundOutcome::Bail)); + } + + #[test] + fn loop_rounds_bail_on_int_branch() { + // Defense in depth: a mixed plan never matches an + // integer-only `RecurInt` branch (see + // `match_loop_branch`), so the bail arm is pinned + // directly with a hand-built plan. + let mut eval = Evaluator::new("test"); + let exit = Expr::Literal(Value::Nil); + let plan = IntLoopPlan { + test: IntTest { + op: IntCmp::Gt, + args: vec![ + IntExpr::single(IntOp::Const(1)), + IntExpr::single(IntOp::Const(0)), + ], + }, + then_act: IntBranch::RecurInt(vec![]), + else_act: IntBranch::Exit(&exit), + kinds: vec![LoopVarKind::Map], + guard_epoch: eval.registry.epoch(), + }; + let outcome = eval.run_loop_rounds(&plan, &Env::new(), vec![0], vec![None], vec![None]); + assert!(matches!(outcome, RoundOutcome::Bail)); + } + + #[test] + fn loop_take_rejects_drained_buffers() { + // Defense in depth: round-start buffers are always full in + // real flow (entry fills, recur swaps, exit-continue + // reseeds), so the take-failure arm is pinned directly with + // a hand-built plan over drained buffers. + let mut eval = Evaluator::new("test"); + let exit = Expr::Literal(Value::Nil); + let plan = IntLoopPlan { + test: IntTest { + op: IntCmp::Gt, + args: vec![ + IntExpr::single(IntOp::Const(1)), + IntExpr::single(IntOp::Const(0)), + ], + }, + then_act: IntBranch::Exit(&exit), + else_act: IntBranch::Exit(&exit), + kinds: vec![LoopVarKind::Map], + guard_epoch: eval.registry.epoch(), + }; + let outcome = eval.run_loop_rounds(&plan, &Env::new(), vec![0], vec![None], vec![None]); + assert!(matches!(outcome, RoundOutcome::Bail)); + } + + #[test] + fn loop_reseed_rejects_mismatched_shapes() { + // Defense in depth: the analyzer arity-checks every + // `recur` (see `analyze_recur`), so reseeds always match + // — the mismatch arms are pinned directly. + let mut ints = vec![0i64]; + assert!(Evaluator::reseed_ints(vec![Value::Int(1), Value::Int(2)], 1, &mut ints).is_err()); + let kinds = vec![LoopVarKind::Int]; + let mut maps: Vec> = vec![]; + let mut vecs: Vec> = vec![]; + assert!( + Evaluator::reseed_from_vals( + &kinds, + vec![Value::Int(1), Value::Int(2)], + &mut ints, + &mut maps, + &mut vecs + ) + .is_err() + ); + let ids = IntLoopIds { + assoc: 11, + nth: 12, + eq: 5, + }; + let branch = Expr::Recur(vec![Expr::Literal(Value::Int(1))]); + let kinds = vec![LoopVarKind::Int, LoopVarKind::Int]; + assert!( + Evaluator::match_loop_branch(&branch, &ids, &kinds, 0, &Env::new(), &mut Vec::new()) + .is_none() + ); + } } diff --git a/rust/crates/clj-eval/src/expr.rs b/rust/crates/clj-eval/src/expr.rs index 299d1128de..8ed49c3052 100644 --- a/rust/crates/clj-eval/src/expr.rs +++ b/rust/crates/clj-eval/src/expr.rs @@ -1064,18 +1064,7 @@ impl<'a> Analyzer<'a> { // Macro expansion (`analyzeSeq` order: specials, then // macros): an expanded form re-analyzes from the top. if let Some(expanded) = self.try_expand_macro(form, head, &items[1..], span)? { - let out = self.analyze(&expanded, span, tail); - // B1: macro outputs are the one unbounded-depth class - // (30k threading steps build 30k-deep output). Dropping - // one recurses per level and can overflow _during - // unwinding_ — after the budget already tripped. On the - // plan path the build fails right after this either - // way, so leak the output instead of dropping it; the - // run path is untouched (no budget, same as before). - if out.is_err() && self.limit.is_some() { - std::mem::forget(expanded); - } - return out; + return self.analyze_expanded(expanded, span, tail); } // Private cross-namespace invocation (the `isInline` // analog): the wrapper names the operator as written, the @@ -1104,6 +1093,27 @@ impl<'a> Analyzer<'a> { Ok(self.direct_or_invoke(head_expr, args, span)) } + /// Re-analyze a macro expansion from the top. + fn analyze_expanded( + &mut self, + expanded: Value, + span: Span, + tail: bool, + ) -> Result { + let out = self.analyze(&expanded, span, tail); + // B1: macro outputs are the one unbounded-depth class + // (30k threading steps build 30k-deep output). Dropping + // one recurses per level and can overflow _during + // unwinding_ — after the budget already tripped. On the + // plan path the build fails right after this either + // way, so leak the output instead of dropping it; the + // run path is untouched (no budget, same as before). + if out.is_err() && self.limit.is_some() { + std::mem::forget(expanded); + } + out + } + /// A direct-threaded call when the head is a global whose /// current root is a native fn, else the ordinary `Invoke` /// (split from [`analyze_list`](Self::analyze_list) for the diff --git a/rust/crates/clj-eval/src/lazy.rs b/rust/crates/clj-eval/src/lazy.rs index fa7880fdc0..f7fbb68eca 100644 --- a/rust/crates/clj-eval/src/lazy.rs +++ b/rust/crates/clj-eval/src/lazy.rs @@ -755,7 +755,7 @@ fn step_pending_unit(eval: &mut Evaluator, p: &PendingSeq) -> Result Result { - let s = step_realized(&sv.items(), false).expect("subvectors are never empty"); + let s = step_realized(sv.items(), false).expect("subvectors are never empty"); step_seq(&s, true, false, SeqKind::Subvec, None) } @@ -881,7 +881,7 @@ fn step_map_unit(m: &Map) -> Result { .collect(); let small = !m.is_hashy(); let kind = if small { SeqKind::Map } else { SeqKind::Key }; - step_realized(&entries, false).map_or_else( + step_realized(entries, false).map_or_else( || Ok(StepUnit::Empty), |s| step_seq(&s, small, small, kind, None), ) @@ -889,7 +889,7 @@ fn step_map_unit(m: &Map) -> Result { /// Step a set. fn step_set_unit(s: &Set) -> Result { - step_realized(&s.iter().cloned().collect::>(), false).map_or_else( + step_realized(s.iter().cloned().collect::>(), false).map_or_else( || Ok(StepUnit::Empty), |s| step_seq(&s, false, false, SeqKind::Key, None), ) @@ -946,11 +946,11 @@ pub(crate) fn step_unit(eval: &mut Evaluator, v: &Value) -> Result step_map_unit(m), Value::Set(s) => step_set_unit(s), Value::Str(s) => step_str_unit(s), - Value::Array(a) => step_realized(&a.snapshot(), false).map_or_else( + Value::Array(a) => step_realized(a.snapshot(), false).map_or_else( || Ok(StepUnit::Empty), |s| step_seq(&s, true, false, SeqKind::Array, None), ), - Value::PrimitiveVector(p) => step_realized(&p.items(), true).map_or_else( + Value::PrimitiveVector(p) => step_realized(p.items(), true).map_or_else( || Ok(StepUnit::Empty), |s| step_seq(&s, true, true, SeqKind::Chunked, None), ), @@ -1008,49 +1008,57 @@ fn seq_tail(s: Seq, counted: bool, idrop: bool, kind: SeqKind, meta: Option<&Map /// Build a pure seq from items (chunked for vectors, eager /// otherwise), or `None` when empty. -fn step_realized(items: &[Value], chunked: bool) -> Option { +fn step_realized(items: Vec, chunked: bool) -> Option { if chunked { - Seq::chunked(items.to_vec()) + Seq::chunked(items) } else { - Seq::eager(items.to_vec()) + Seq::eager(items) } } -/// Step a range: up to [`CHUNK_SIZE`] verbatim heads plus the -/// shifted range (nil tail when done). An `Int`+`Int` overflow -/// ends the walk (like `Range.forceChunk`, whose promoting +/// Step up to [`CHUNK_SIZE`] range heads (shared by +/// `step_range_value` and `chunked_range_parts`): the items plus the +/// live tail, materialized once — never per element. An `Int`+`Int` +/// overflow ends the walk (like `Range.forceChunk`, whose promoting /// `Numbers.addP` always lands out of bounds past `i64`); a /// `BigDec`/ratio step with no exact decimal raises the JVM /// `ArithmeticException` (oracle `range-mixed`). -fn step_range_value(eval: &mut Evaluator, r: &RangeValue) -> Result { +fn step_range_chunk( + eval: &mut Evaluator, + r: &RangeValue, +) -> Result<(Vec, Option), Signal> { let mut items = Vec::new(); let mut cur = r.clone(); - let mut tail = Value::Nil; + let mut live = false; while items.len() < CHUNK_SIZE { - let Some((head, next)) = cur.stepped() else { + let Some((head, more)) = cur.next_head() else { + live = false; break; }; items.push(head); - match next { - Some(nxt) => { - tail = Value::Extension(Arc::new(nxt.clone())); - cur = nxt; - } - None => { - if cur.step_inexact() { - return Err(eval.throw(range_step_error())); - } - tail = Value::Nil; - break; + if !more { + if cur.step_inexact() { + return Err(eval.throw(range_step_error())); } + live = false; + break; } + live = true; } // Empty ranges never exist as values (`range` nil-puns), so // stepping always yields at least one head. debug_assert!(!items.is_empty(), "stepped ranges are non-empty"); - if cur.done() { - tail = Value::Nil; + if !live || cur.done() { + return Ok((items, None)); } + Ok((items, Some(cur))) +} + +/// Step a range: up to [`CHUNK_SIZE`] verbatim heads plus the +/// shifted range (nil tail when done). +fn step_range_value(eval: &mut Evaluator, r: &RangeValue) -> Result { + let (items, tail) = step_range_chunk(eval, r)?; + let tail = tail.map_or(Value::Nil, |t| Value::Extension(Arc::new(t))); Ok(StepUnit::Chunk { items: Arc::from(items), tail, @@ -1218,7 +1226,7 @@ pub(crate) fn realized_value( idrop: bool, kind: SeqKind, ) -> Value { - match step_realized(&items, chunked) { + match step_realized(items, chunked) { Some(s) => Value::Extension(Arc::new(RealizedSeq::flagged(s, counted, idrop, kind))), None => Value::Nil, } @@ -2840,7 +2848,7 @@ fn chunked_realized_parts(s: &RealizedSeq) -> Option<(crate::collmisc::ChunkValu } let head: Vec = items.iter().take(CHUNK_SIZE).cloned().collect(); let rest: Vec = items.iter().skip(CHUNK_SIZE).cloned().collect(); - let tail = step_realized(&rest, true).map(|seq| { + let tail = step_realized(rest, true).map(|seq| { Value::Extension(Arc::new(RealizedSeq::flagged( seq, s.is_counted(), @@ -2857,31 +2865,8 @@ fn chunked_range_parts( eval: &mut Evaluator, r: &RangeValue, ) -> Result)>, Signal> { - let mut items = Vec::new(); - let mut cur = r.clone(); - let mut tail: Option = None; - while items.len() < CHUNK_SIZE { - let Some((head, next)) = cur.stepped() else { - break; - }; - items.push(head); - match next { - Some(nxt) => { - tail = Some(Value::Extension(Arc::new(nxt.clone()))); - cur = nxt; - } - None => { - if cur.step_inexact() { - return Err(eval.throw(range_step_error())); - } - tail = None; - break; - } - } - } - if cur.done() { - tail = None; - } + let (items, tail) = step_range_chunk(eval, r)?; + let tail = tail.map(|t| Value::Extension(Arc::new(t))); Ok(Some((crate::collmisc::head_chunk(items), tail))) } @@ -3214,26 +3199,15 @@ fn first_direct(v: &Value) -> Option { /// `first`; iterate heads read without stepping; entry views /// cast; anything else steps one unit). fn first_seq_value(eval: &mut Evaluator, v: &Value) -> Result { - if let Some(p) = as_pending(v) { - let state = p.state.lock().unwrap_or_else(|e| e.into_inner()); - match &*state { - SeqState::Chunk { items, pos, .. } if *pos < items.len() => { - return Ok(items[*pos].clone()); - } - // An exhausted chunk continues in its tail (like the - // JVM's `ChunkedCons.more()`; `rest` seqs the tail at - // the last item, so this only sees crafted states). - SeqState::Chunk { tail, .. } => { - let tail = tail.clone(); - drop(state); - return first_value(eval, &tail); - } - _ => {} - } + if let Some(done) = first_pending_chunk(eval, v) { + return done; } if let Some(r) = as_realized(v) { return Ok(r.seq().first().cloned().unwrap_or(Value::Nil)); } + if let Some(r) = as_range(v) { + return first_range_head(eval, r); + } if let Some(it) = as_iterate(v) { return iterate_seed(eval, it); } @@ -3243,6 +3217,43 @@ fn first_seq_value(eval: &mut Evaluator, v: &Value) -> Result { if let Some(k) = as_vals(v) { return keys_first(eval, k.underlying(), false); } + first_stepped(eval, v) +} + +/// The head of a chunked pending seq (`None` is neither pending +/// nor chunked — the caller falls through to the other shapes). +fn first_pending_chunk(eval: &mut Evaluator, v: &Value) -> Option> { + let p = as_pending(v)?; + let state = p.state.lock().unwrap_or_else(|e| e.into_inner()); + match &*state { + SeqState::Chunk { items, pos, .. } if *pos < items.len() => Some(Ok(items[*pos].clone())), + // An exhausted chunk continues in its tail (like the + // JVM's `ChunkedCons.more()`; `rest` seqs the tail at + // the last item, so this only sees crafted states). + SeqState::Chunk { tail, .. } => { + let tail = tail.clone(); + drop(state); + Some(first_value(eval, &tail)) + } + _ => None, + } +} + +/// A range's head, direct (stepping a 32-chunk for one head is +/// pure waste); an inexact first step still raises like the walk. +/// Inexactness is static — the step winner is fixed by the (cur, +/// step) types, so a bad BigDec/ratio pair fails at head 0 or +/// never — hence checking the current step matches the old +/// chunk-eager raise exactly. +fn first_range_head(eval: &mut Evaluator, r: &RangeValue) -> Result { + if r.step_inexact() { + return Err(eval.throw(range_step_error())); + } + Ok(r.head_or_nil()) +} + +/// A seq value's head once the direct shapes miss: one step. +fn first_stepped(eval: &mut Evaluator, v: &Value) -> Result { match step_unit(eval, v)? { StepUnit::Empty => Ok(Value::Nil), StepUnit::One { head, .. } => Ok(head), @@ -3298,7 +3309,7 @@ fn range_rest(eval: &mut Evaluator, r: &RangeValue) -> Result { /// The JVM `ArithmeticException` for a range step that adds /// `BigDec` to a ratio with no exact decimal (oracle /// `range-mixed`). -fn range_step_error() -> NativeError { +pub(crate) fn range_step_error() -> NativeError { NativeError::new( ThrowClass::ArithmeticException, clj_value::NON_TERMINATING.to_owned(), @@ -3889,6 +3900,72 @@ pub(crate) fn pending_value(thunk: Thunk) -> Value { PendingSeq::pending(thunk) } +/// A peelable pipeline op: single-input `map`/`filter`/`remove` +/// with its function value (the sub-input travels alongside the +/// op out of [`peel_pipe_owned`]). +#[derive(Clone, Debug)] +pub(crate) enum PeelOp { + Map(Value), + Filter { f: Value, invert: bool }, +} + +/// Peel one uniquely-owned pending pipeline op off `v`: the op +/// plus its sub-input. Anything else — shared cells, forced +/// state, meta/kind-carrying cells, multi-input `map`, other +/// thunks, non-seqs — comes back untouched in `Err`. +/// +/// Uniqueness keeps the peel unobservable: the caller walks the +/// peeled ops instead of stepping (and memoizing into) the cell, +/// and a sole-owned cell has no other observer of that skipped +/// memoization. +pub(crate) fn peel_pipe_owned(v: Value) -> Result<(PeelOp, Value), Value> { + let Value::Extension(e) = v else { + return Err(v); + }; + if std::sync::Arc::strong_count(&e) != 1 { + return Err(Value::Extension(e)); + } + let peeled = { + let Some(seq) = e.as_any().downcast_ref::() else { + return Err(Value::Extension(e)); + }; + if seq.meta.is_some() || !matches!(seq.kind, LazyKind::LazySeq) { + return Err(Value::Extension(e)); + } + // A poisoned lock recovers with its guard (like every other + // seq-state lock — poisoning needs a panicking holder, and + // declining here would be an untestable arm). + let guard = seq.state.lock().unwrap_or_else(|e| e.into_inner()); + match &*guard { + SeqState::Pending(thunk) => match thunk.as_ref() { + Thunk::Map { f, inputs } if inputs.len() == 1 => { + Some((PeelOp::Map(f.clone()), inputs[0].clone())) + } + Thunk::Filter { f, input } => Some(( + PeelOp::Filter { + f: f.clone(), + invert: false, + }, + input.clone(), + )), + Thunk::Remove { f, input } => Some(( + PeelOp::Filter { + f: f.clone(), + invert: true, + }, + input.clone(), + )), + _ => None, + }, + _ => None, + } + }; + match peeled { + Some((op, input)) => Ok((op, input)), + None => Err(Value::Extension(e)), + } +} + /// A `Thunk::Map` tail value (for `map`). pub(crate) fn map_thunk(f: Value, inputs: Vec) -> Value { pending_value(Thunk::Map { f, inputs }) @@ -4706,4 +4783,36 @@ mod tests { assert_eq!(chunk.items_arc().len(), 32); assert!(tail.is_some()); } + + #[test] + fn peel_declines_meta_and_chunked_cons() { + // Coverage for the peel guards (no suite peels a meta'd or + // `ChunkedCons`-classed seq): both decline untouched. + let chunked = Value::Extension(Arc::new(pending_seq( + SeqState::Empty, + LazyKind::ChunkedCons, + ))); + assert!(super::peel_pipe_owned(chunked).is_err()); + let mut seq = pending_seq(SeqState::Empty, LazyKind::LazySeq); + seq.meta = Some(new_map()); + let meta = Value::Extension(Arc::new(seq)); + assert!(super::peel_pipe_owned(meta).is_err()); + } + + #[test] + fn peel_declines_settled_states() { + // Coverage for the settled-state arm (only `Pending` thunks + // peel): chunked and empty cells decline untouched. + let chunk = Value::Extension(Arc::new(pending_seq( + SeqState::Chunk { + items: Arc::from([Value::Int(1)]), + pos: 0, + tail: Value::Nil, + }, + LazyKind::LazySeq, + ))); + assert!(super::peel_pipe_owned(chunk).is_err()); + let empty = Value::Extension(Arc::new(pending_seq(SeqState::Empty, LazyKind::LazySeq))); + assert!(super::peel_pipe_owned(empty).is_err()); + } } diff --git a/rust/crates/clj-eval/src/seqs.rs b/rust/crates/clj-eval/src/seqs.rs index 3bd9dd6ba7..dcacc82edf 100644 --- a/rust/crates/clj-eval/src/seqs.rs +++ b/rust/crates/clj-eval/src/seqs.rs @@ -15,21 +15,22 @@ use std::cmp::Ordering; use std::sync::Arc; -use clj_value::{List, ThrowClass, Value, Vector, new_map}; +use clj_value::{CHUNK_SIZE, List, ThrowClass, Value, Vector, new_map}; use smallvec::SmallVec; use crate::core::{as_reduced, cce_err, check_ifn, jvm_compare, repeat_count, take_count}; use crate::error::{NativeError, jvm_class_of}; use crate::eval::Evaluator; -use crate::ext::{FastArith, NativeFn, NativeImpl}; +use crate::expr::Arity; +use crate::ext::{Closure, FastArith, NativeFn, NativeImpl, as_closure, as_native}; use crate::lazy::{ - as_subvector, collect_all, collect_packed, concat_thunk, cycle_thunk, dedupe_thunk, - distinct_thunk, drop_value, drop_while_thunk, empty_list_value, filter_thunk, first_value, - interleave_thunk, interpose_thunk, iterate_thunk, keep_indexed_thunk, keep_thunk, keys_thunk, - map_indexed_thunk, map_thunk, mapcat_thunk, next_value, nthrest_value, partition_all_thunk, - partition_by_thunk, partition_thunk, range1, range2, range3, realized_value, reductions_thunk, - repeat_thunk, repeatedly_thunk, seq_value, take_nth_thunk, take_thunk, take_walk_coll, - take_while_thunk, + PeelOp, as_range, as_subvector, collect_all, collect_packed, concat_thunk, cycle_thunk, + dedupe_thunk, distinct_thunk, drop_value, drop_while_thunk, empty_list_value, filter_thunk, + first_value, interleave_thunk, interpose_thunk, iterate_thunk, keep_indexed_thunk, keep_thunk, + keys_thunk, map_indexed_thunk, map_thunk, mapcat_thunk, next_value, nthrest_value, + partition_all_thunk, partition_by_thunk, partition_thunk, peel_pipe_owned, range_step_error, + range1, range2, range3, realized_value, reductions_thunk, repeat_thunk, repeatedly_thunk, + seq_value, take_nth_thunk, take_thunk, take_walk_coll, take_while_thunk, }; use crate::ns::{NativeFnPtr, check_arity, deref_core, signal_to_native}; use crate::vars::check_min_arity; @@ -88,6 +89,24 @@ fn walk_seq( coll: Value, mut visit: impl FnMut(&mut Evaluator, &Value) -> Result, ) -> Result<(), NativeError> { + // Ranges walk head-at-a-time with no per-element dispatch, + // clones, or tail allocs (the generic walk steps a chunked + // `first` plus a shifted tail per `next`). + if let Some(r) = as_range(&coll) { + let owned = r.clone(); + drop(coll); + return walk_range(eval, owned, visit); + } + // Uniquely-owned `map`/`filter`/`remove` chains over a range + // walk fused: same heads, same order, same per-chunk checks + // and calls as stepping the chain — without per-element + // thunk stepping, memoization, or locks. Sole ownership at + // every level keeps the skipped memoization unobservable + // (shared chains walk the generic path below). + let coll = match peel_chain(coll) { + Ok((ops, range)) => return walk_peeled(eval, range, &ops, visit), + Err(coll) => coll, + }; let mut seq = seq_value(eval, &coll).map_err(signal_to_native)?; // The walk holds `seq`, never `coll`: owned callers nilled the // arg slot (`take_walk_coll`), so dropping the head here frees @@ -104,6 +123,223 @@ fn walk_seq( Ok(()) } +/// Walk a range head-by-head (short-circuitable): same heads, same +/// order, and same inexact-raise point as the generic +/// `first`/`rest` walk (heads yielded before the failing step stay +/// yielded). +fn walk_range( + eval: &mut Evaluator, + r: clj_value::RangeValue, + mut visit: impl FnMut(&mut Evaluator, &Value) -> Result, +) -> Result<(), NativeError> { + let mut cur = r; + loop { + let Some((head, more)) = cur.next_head() else { + return Ok(()); + }; + if matches!(visit(eval, &head)?, Walk::Stop) { + return Ok(()); + } + if !more { + if cur.step_inexact() { + return Err(signal_to_native(eval.throw(range_step_error()))); + } + return Ok(()); + } + } +} + +/// Peel a uniquely-owned `map`/`filter`/`remove` chain down to +/// its range base: the ops outermost-first plus the owned range. +/// Anything else comes back in `Err` for the generic walk — a +/// declined base is never a range, so shared mid-chain cells +/// (whose memoization must be preserved) decline naturally; the +/// peeled outer shells (proven unique, hence unobservable) are +/// rebuilt fresh around the declined base. +fn peel_chain(mut cur: Value) -> Result<(SmallVec<[PeelOp; 4]>, clj_value::RangeValue), Value> { + let mut ops: SmallVec<[PeelOp; 4]> = SmallVec::new(); + loop { + match peel_pipe_owned(cur) { + Ok((op, input)) => { + ops.push(op); + cur = input; + } + Err(base) => { + if ops.is_empty() { + return Err(base); + } + let Some(range) = as_range(&base).cloned() else { + // Rebuild the peeled (proven unique) shells + // around the declined base for the generic + // walk — observably the chain as it was. + let mut rebuilt = base; + for op in ops.into_iter().rev() { + rebuilt = match op { + PeelOp::Map(f) => map_thunk(f, vec![rebuilt]), + PeelOp::Filter { f, invert } => filter_thunk(f, rebuilt, invert), + }; + } + return Err(rebuilt); + }; + return Ok((ops, range)); + } + } + } +} + +/// Walk a peeled pipeline over a range in [`CHUNK_SIZE`] rounds: +/// each round pulls raw heads, pushes the chunk through the ops +/// innermost-first (map replaces, filter retains — checks once +/// per op per chunk, like the step fns), then visits the +/// survivors in order. An op that empties the chunk skips the +/// ops behind it for that round (their checks don't fire — like +/// a filter step looping for its next non-empty chunk). +/// Inexact ranges visit the pulled heads, then raise — the +/// [`walk_range`] tail. +fn walk_peeled( + eval: &mut Evaluator, + r: clj_value::RangeValue, + ops: &[PeelOp], + mut visit: impl FnMut(&mut Evaluator, &Value) -> Result, +) -> Result<(), NativeError> { + let mut cur = r; + let mut buf: SmallVec<[Value; 32]> = SmallVec::new(); + loop { + buf.clear(); + let (done, raise) = pull_peeled_chunk(&mut cur, &mut buf); + // Innermost-first: `ops` peeled outermost-first. + for op in ops.iter().rev() { + let emptied = apply_peeled_op(eval, op, &mut buf).map_err(signal_to_native)?; + if emptied { + break; + } + } + for v in buf.iter() { + if matches!(visit(eval, v)?, Walk::Stop) { + return Ok(()); + } + } + if done { + if raise { + return Err(signal_to_native(eval.throw(range_step_error()))); + } + return Ok(()); + } + } +} + +/// Pull one raw-head chunk: `(done, raise)` — `raise` visits the +/// pulled heads first, then raises (the [`walk_range`] tail). +fn pull_peeled_chunk( + cur: &mut clj_value::RangeValue, + buf: &mut SmallVec<[Value; 32]>, +) -> (bool, bool) { + while buf.len() < CHUNK_SIZE { + let Some((head, more)) = cur.next_head() else { + return (true, false); + }; + buf.push(head); + if !more { + return (true, cur.step_inexact()); + } + } + (false, false) +} + +/// Push one chunk through a peeled op (`Ok(true)` emptied the +/// chunk — the ops behind it skip this round, like a filter step +/// looping for its next non-empty chunk). +fn apply_peeled_op( + eval: &mut Evaluator, + op: &PeelOp, + buf: &mut SmallVec<[Value; 32]>, +) -> Result { + match op { + PeelOp::Map(f) => { + apply_peeled_map(eval, f, buf)?; + Ok(false) + } + PeelOp::Filter { f, invert } => apply_peeled_filter(eval, f, *invert, buf), + } +} + +/// A peeled-map/filter fn eligible for the reused-frame apply: +/// a closure with exactly one fixed arity of one (no rest, no +/// conveyed bindings — anything else applies generically, with +/// identical dispatch and errors). +fn peeled_closure(f: &Value) -> Option<(&Closure, &Arity)> { + let closure = as_closure(f)?; + if closure.conveyed.is_some() || closure.arities.len() != 1 { + return None; + } + let arity = &closure.arities[0]; + if arity.rest.is_some() || arity.fixed.len() != 1 { + return None; + } + Some((closure, arity)) +} + +/// Map one chunk in place. +fn apply_peeled_map( + eval: &mut Evaluator, + f: &Value, + buf: &mut SmallVec<[Value; 32]>, +) -> Result<(), crate::error::Signal> { + check_ifn(eval, f)?; + if let Some((closure, arity)) = peeled_closure(f) { + // One arity, one frame for the chunk (see + // [`Evaluator::apply_reused`]). + let mut frame: Option = None; + for v in buf.iter_mut() { + *v = eval.apply_reused(closure, arity, &mut frame, std::slice::from_ref(v))?; + } + return Ok(()); + } + for v in buf.iter_mut() { + *v = eval.call(f, std::slice::from_ref(v))?; + } + Ok(()) +} + +/// Retain one chunk's passing heads (`Ok(true)` emptied it). +fn apply_peeled_filter( + eval: &mut Evaluator, + f: &Value, + invert: bool, + buf: &mut SmallVec<[Value; 32]>, +) -> Result { + check_ifn(eval, f)?; + if let Some((closure, arity)) = peeled_closure(f) { + // One arity, one frame for the chunk (see + // [`Evaluator::apply_reused`]). + let mut frame: Option = None; + let mut write = 0; + for i in 0..buf.len() { + let item = std::mem::replace(&mut buf[i], Value::Nil); + if eval + .apply_reused(closure, arity, &mut frame, std::slice::from_ref(&item))? + .is_truthy() + != invert + { + buf[write] = item; + write += 1; + } + } + buf.truncate(write); + return Ok(buf.is_empty()); + } + let mut write = 0; + for i in 0..buf.len() { + let item = std::mem::replace(&mut buf[i], Value::Nil); + if eval.call(f, std::slice::from_ref(&item))?.is_truthy() != invert { + buf[write] = item; + write += 1; + } + } + buf.truncate(write); + Ok(buf.is_empty()) +} + /// Force one step plus `n` heads (mirrors `dorun`'s walk: the /// initial `seq` always realizes one step, then `n` `next`s). fn force_n(eval: &mut Evaluator, coll: Value, n: i64) -> Result<(), NativeError> { @@ -391,68 +627,204 @@ fn native_reduce(eval: &mut Evaluator, args: &[Value]) -> Result bool { + as_native(f).is_some_and(|n| &*n.display == "clojure.core/+") +} + +/// A fused `(reduce + ...)` attempt: `Done` carries the sum, +/// `Raise` the inexact-tail error, `Bail` falls through to the +/// generic walk (which recomputes identically — the attempt runs +/// no user code and mutates nothing). +enum PlusRange { + Done(Value), + Bail, +} + +/// Tagless `(reduce + init? range)`: `init` is the 3-arg start +/// (`None` takes the first head, like the generic walk). Heads +/// must be `Int` (anything else bails); overflow bails (the +/// generic walk throws `long overflow`, like `+` itself). +/// Inexact steps always surface a non-`Int` head first (an +/// inexact pair needs a `BigDec`/ratio cursor, whose heads are +/// never `Int`), so they bail above and the generic walk raises +/// [`range_step_error`] there — unlike [`walk_range`], there is +/// no inexact tail to raise. +fn plus_range(r: clj_value::RangeValue, init: Option) -> PlusRange { + let mut cur = r; + let mut acc = match init { + Some(x) => x, + None => { + // A spent range never arrives (every range constructor + // nil-puns an empty to `()`), so `unwrap_or` only spells + // the empty sum: the generic walk would call `(f)`, and + // `(+)` is `0` — the synthetic continuing head folds + // through the loop below and exhausts to `Done(0)`, + // identically (this attempt only runs for `+`). + let (head, more) = cur.next_head().unwrap_or((Value::Int(0), true)); + let Value::Int(x) = head else { + return PlusRange::Bail; + }; + if !more { + // A single `Int` head with a failed step is always + // checked-`Int` overflow (inexact pairs need a + // non-`Int` cursor, bailed above). + return PlusRange::Done(Value::Int(x)); + } + x + } + }; + loop { + let Some((head, more)) = cur.next_head() else { + return PlusRange::Done(Value::Int(acc)); + }; + let Value::Int(h) = head else { + return PlusRange::Bail; + }; + acc = match acc.checked_add(h) { + Some(v) => v, + None => return PlusRange::Bail, + }; + if !more { + // As above: every head so far is `Int`, so the failed + // step is checked-`Int` overflow, never inexact. + return PlusRange::Done(Value::Int(acc)); + } + } +} + pub(crate) fn native_reduce_owned( eval: &mut Evaluator, mut args: SmallVec<[Value; 4]>, ) -> Result { check_arity("clojure.core/reduce", &args, &[2, 3])?; let f = args[0].clone(); - // Eductions reduce without `IReduce` (2-arg takes the JVM cast) - // and complete through `(completing f)` in 3-arg (oracle - // `eduction-reduce*`, `core.clj` source). - let coll = if args.len() == 2 { &args[1] } else { &args[2] }; - if let Some((xform, ecoll)) = crate::xform::Eduction::parts(coll) { - if args.len() == 2 { - return Err(NativeError::cast( - &jvm_class_of(coll), - "clojure.lang.IReduce", - )); - } - let completing = crate::xform::completing_fn(eval, f.clone(), None); - return crate::xform::transduce_into( - eval, - &xform.clone(), - &completing, - args[1].clone(), - ecoll.clone(), - ); + if let Some(done) = reduce_eduction(eval, &f, &args) { + return done; } - let (mut acc, has_acc) = if args.len() == 3 { + let (acc, has_acc) = if args.len() == 3 { (args[1].clone(), true) } else { (Value::Nil, false) }; + if let Some(done) = try_plus_range_reduce(&f, &args, &acc, has_acc) { + return done; + } let coll_idx = if args.len() == 3 { 2 } else { 1 }; let coll = take_walk_coll(&mut args, coll_idx); - let mut first = true; - let mut empty = true; + let mut state = ReduceWalk { + acc, + has_acc, + first: true, + empty: true, + }; walk_seq(eval, coll, |eval, head| { - empty = false; - if !has_acc && first { - first = false; - acc = head.clone(); - return Ok(Walk::Continue); - } - first = false; - // Owned step call (#227): the accumulator moves through - // (no clone, no borrow pin), so a uniquely-owned acc - // mutates in place inside an owned `conj` instead of - // path-copying per step. - let acc_taken = std::mem::replace(&mut acc, Value::Nil); - let mut step_args = SmallVec::new(); - step_args.push(acc_taken); - step_args.push(head.clone()); - acc = call_checked_owned(eval, &f, step_args)?; - if let Some(inner) = as_reduced(&acc) { - acc = inner.clone(); - return Ok(Walk::Stop); - } - Ok(Walk::Continue) + reduce_walk_visit(&mut state, eval, &f, head) })?; - if empty && !has_acc { + if state.empty && !state.has_acc { return call_checked(eval, &f, &[]); } - Ok(acc) + Ok(state.acc) +} + +/// Reduce an eduction (`None` is not an eduction): without +/// `IReduce` (2-arg takes the JVM cast) and complete through +/// `(completing f)` in 3-arg (oracle `eduction-reduce*`, +/// `core.clj` source). +fn reduce_eduction( + eval: &mut Evaluator, + f: &Value, + args: &[Value], +) -> Option> { + let coll = if args.len() == 2 { &args[1] } else { &args[2] }; + let (xform, ecoll) = crate::xform::Eduction::parts(coll)?; + if args.len() == 2 { + return Some(Err(NativeError::cast( + &jvm_class_of(coll), + "clojure.lang.IReduce", + ))); + } + let completing = crate::xform::completing_fn(eval, f.clone(), None); + Some(crate::xform::transduce_into( + eval, + &xform.clone(), + &completing, + args[1].clone(), + ecoll.clone(), + )) +} + +/// Fused `(reduce + init? range)`: tagless `i64` accumulation (no +/// per-element application, clones, or dispatch). `None` falls +/// through to the generic walk (non-`+` f, non-int init/head, +/// overflow, empty 2-arg, inexact tail). +fn try_plus_range_reduce( + f: &Value, + args: &[Value], + acc: &Value, + has_acc: bool, +) -> Option> { + if !is_plus_native(f) { + return None; + } + let range = if args.len() == 3 { + as_range(&args[2]).cloned() + } else { + as_range(&args[1]).cloned() + }; + let r = range?; + let init = if has_acc { + match acc { + Value::Int(x) => Some(Some(*x)), + _ => None, + } + } else { + Some(None) + }; + match plus_range(r, init?) { + PlusRange::Done(v) => Some(Ok(v)), + PlusRange::Bail => None, + } +} + +/// The mutable walk state of a generic `reduce`. +struct ReduceWalk { + acc: Value, + has_acc: bool, + first: bool, + empty: bool, +} + +/// Fold one head into a generic `reduce` walk. +fn reduce_walk_visit( + state: &mut ReduceWalk, + eval: &mut Evaluator, + f: &Value, + head: &Value, +) -> Result { + state.empty = false; + if !state.has_acc && state.first { + state.first = false; + state.acc = head.clone(); + return Ok(Walk::Continue); + } + state.first = false; + // Owned step call (#227): the accumulator moves through + // (no clone, no borrow pin), so a uniquely-owned acc + // mutates in place inside an owned `conj` instead of + // path-copying per step. + let acc_taken = std::mem::replace(&mut state.acc, Value::Nil); + let mut step_args = SmallVec::new(); + step_args.push(acc_taken); + step_args.push(head.clone()); + state.acc = call_checked_owned(eval, f, step_args)?; + if let Some(inner) = as_reduced(&state.acc) { + state.acc = inner.clone(); + return Ok(Walk::Stop); + } + Ok(Walk::Continue) } fn native_take_last(eval: &mut Evaluator, args: &[Value]) -> Result { diff --git a/rust/crates/clj-eval/tests/control.rs b/rust/crates/clj-eval/tests/control.rs index 9e1a014550..e767f41135 100644 --- a/rust/crates/clj-eval/tests/control.rs +++ b/rust/crates/clj-eval/tests/control.rs @@ -1146,3 +1146,17 @@ fn case_star_shift_mask_dispatch() { ":d" ); } + +#[test] +fn single_arg_fn_recur_rebinds_inline() { + // A one-parameter `fn` binds its slot inline and rebinds it in + // place per `recur` (no fresh frame while unobserved). + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(do (defn countdown [n] (if (pos? n) (recur (dec n)) :done)) (countdown 100))" + ), + ":done" + ); +} diff --git a/rust/crates/clj-eval/tests/int_loop.rs b/rust/crates/clj-eval/tests/int_loop.rs new file mode 100644 index 0000000000..b4cb877595 --- /dev/null +++ b/rust/crates/clj-eval/tests/int_loop.rs @@ -0,0 +1,511 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! Guarded integer-loop accelerator (`Evaluator::try_int_loop`): +//! counted `loop`s over `if`/comparison/`recur` run tagless in +//! `i64`s; overflow, non-`Int` state, exotic shapes, and any +//! redefinition bail to the interpreter with exactly its behavior +//! (including the JVM's `long overflow` throws, probed on JVM +//! master — and unlike the JVM's `:inline` arithmetic, every +//! inlined call observes redefinition, like the evaluator's own +//! direct calls). + +use clj_eval::{Evaluator, SourceError, boot}; +use clj_value::{PrintOpts, Value, pr_to_string}; + +fn pr(v: &Value) -> String { + pr_to_string(v, &PrintOpts::pr()) +} + +fn run_last(eval: &mut Evaluator, src: &str) -> String { + let vs = eval.eval_source(src).unwrap(); + let v = vs.last().unwrap(); + let r = eval.realize_deep(v).unwrap(); + pr(&r) +} + +fn uncaught(eval: &mut Evaluator, src: &str) -> String { + match eval.eval_source(src) { + Err(SourceError::Uncaught(t)) => format!( + "{} | {}", + t.class.jvm_name(), + t.message.as_deref().unwrap_or("") + ), + Err(other) => panic!("expected an uncaught throw, got {other}"), + Ok(vs) => panic!("expected an uncaught throw for {src}, got {vs:?}"), + } +} + +#[test] +fn int_loop_sum() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 0] (if (< i 100) (recur (inc i) (+ s i)) s))" + ), + "4950" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i (long 0) s (long 0)] (if (< i 1000000) (recur (inc i) (+ s i)) s))" + ), + "499999500000" + ); +} + +#[test] +fn int_loop_counts_down() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 10 s 0] (if (> i 0) (recur (dec i) (+ s i)) s))" + ), + "55" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 5 a 1] (if (> i 1) (recur (dec i) (* a i)) a))" + ), + "120" + ); +} + +#[test] +fn int_loop_zero_iterations() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 10 s 7] (if (< i 3) (recur (inc i) (+ s i)) s))" + ), + "7" + ); +} + +#[test] +fn int_loop_nested_and_nary() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 1 s 0] (if (< i 6) (recur (inc i) (+ s (* i i))) s))" + ), + "55" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 0] (if (< i 10) (recur (+ i 1 1) (+ s i 100)) s))" + ), + "520" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 100] (if (< i 3) (recur (inc i) (- s i 1)) s))" + ), + "94" + ); + assert_eq!( + run_last(&mut eval, "(loop [i 0] (if (< i 5 10) (recur (inc i)) i))"), + "5" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 5] (if (< i 1) (recur (inc i) (+)) s))" + ), + "0" + ); + // Nested steps run the general machine (leaf-built shapes never + // reach it): nested `*` over a constant, nested `inc`, nested + // `*` under `-`, unary `-` over a compound, nested `dec`, and a + // zero-arg op nested as an operand (declines the leaf fold). + assert_eq!( + run_last( + &mut eval, + "(loop [i 1 s 0] (if (< i 4) (recur (inc i) (+ s (* i 2))) s))" + ), + "12" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 0] (if (< i 3) (recur (inc i) (+ (inc i) s)) s))" + ), + "6" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 1 s 100] (if (< i 4) (recur (inc i) (- s (* i i))) s))" + ), + "86" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 5] (if (< i 2) (recur (inc i) (- (+ s i))) s))" + ), + "4" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 3 s 0] (if (> i 0) (recur (dec i) (+ s (dec i))) s))" + ), + "3" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 0] (if (< i 3) (recur (inc i) (+ (+) s i)) s))" + ), + "3" + ); +} + +#[test] +fn int_loop_exit_polarity() { + let mut eval = boot(); + // `recur` in the `else` branch, `=` test, keyword exit value. + assert_eq!( + run_last(&mut eval, "(loop [i 0] (if (= i 5) :done (recur (inc i))))"), + ":done" + ); + // Single-arg tests hold (like the interpreter); the exit sits in + // the `then` branch. + assert_eq!( + run_last(&mut eval, "(loop [i 10] (if (< i) :t (recur (dec i))))"), + ":t" + ); + // The exit evaluates with the final bindings (unary `-`). + assert_eq!( + run_last(&mut eval, "(loop [i 0] (if (< i 2) (recur (inc i)) (- i)))"), + "-2" + ); +} + +#[test] +fn int_loop_outer_const() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [n 50] (loop [i 0 s 0] (if (< i n) (recur (inc i) (+ s i)) s)))" + ), + "1225" + ); +} + +#[test] +fn int_loop_exit_recur_continues() { + let mut eval = boot(); + // The exit branch recurs back in (the surfaced `recur` resumes + // the fast loop with its values). + assert_eq!( + run_last( + &mut eval, + "(loop [i 0] (if (< i 5) (recur (inc i)) (if (< i 10) (recur (inc i)) i)))" + ), + "10" + ); +} + +#[test] +fn int_loop_bail_runs_effects_once() { + let mut eval = boot(); + // An overflow bail after exit rounds with effects resumes the + // slow loop from the current state (it must not rerun from + // entry: the two `swap!`s run exactly once, then the overflow + // still throws like the interpreter). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (try (loop [i 0] (if (< i 2) (do (swap! a inc) (recur (inc i))) (recur (+ i 9223372036854775807)))) (catch ArithmeticException e [:threw @a])))" + ), + "[:threw 2]" + ); + // A root write (epoch bump) after exit rounds likewise resumes + // instead of rerunning (the `def` in round 0 bumps the epoch; + // round 1 resumes slow and the loop completes with three + // increments, not four). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (loop [i 0] (if (< i 3) (do (swap! a inc) (def int-loop-bail-pin i) (recur (inc i))) @a)))" + ), + "3" + ); + // An exotic exit `recur` resumes from its own values (the + // `:boom` binds `i`, like the slow path — the test then throws + // exactly as evaluating `(< :boom 2)` directly, and the two + // `swap!`s run once). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (try (loop [i 0] (if (< i 2) (do (swap! a inc) (recur (if (< i 1) (inc i) :boom))) @a)) (catch Exception e [:caught @a])))" + ), + "[:caught 2]" + ); +} + +#[test] +fn int_loop_exit_capture_keeps_round() { + // Exit rounds reuse one env rebound in place — a round that + // captures its env in a closure and recurs still observes its + // own round (the shared frame falls back to a fresh bind). + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [a (atom [])] (loop [i 0] (if (< i 3) (do (swap! a conj (fn [] i)) (recur (inc i))) (mapv (fn [f] (f)) @a))))" + ), + "[0 1 2]" + ); +} + +#[test] +fn int_loop_redefs_observed() { + // Unlike the JVM's `:inline` arithmetic (which ignores + // `with-redefs`), the accelerator observes redefinition like + // the evaluator's own direct calls: any binding declines the + // plan and the slow path answers. + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(with-redefs [+ -] (loop [i 0 s 0] (if (< i 10) (recur (inc i) (+ s i)) s)))" + ), + "-45" + ); +} + +#[test] +fn int_loop_overflow_throws() { + let mut eval = boot(); + for src in [ + "(loop [i 0 s 0] (if (< i 3) (recur (inc i) (+ s 9223372036854775800)) s))", + "(loop [i -9223372036854775808] (if (= i -9223372036854775808) (recur (- i)) :no))", + "(loop [i 0 s 2] (if (< i 1) (recur (inc i) (* s 9223372036854775807)) s))", + ] { + assert_eq!( + uncaught(&mut eval, src), + "java.lang.ArithmeticException | long overflow", + "{src}" + ); + } +} + +#[test] +fn int_loop_slow_shapes() { + let mut eval = boot(); + // Non-`Int` entries decline the fast path. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s \"x\"] (if (< i 3) (recur (inc i) (str s i)) s))" + ), + "\"x012\"" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 1/2] (if (< i 2) (recur (inc i) (+ s 1)) s))" + ), + "5/2" + ); + assert_eq!( + run_last(&mut eval, "(loop [i 0.0] (if (< i 3) (recur (+ i 1.0)) i))"), + "3.0" + ); + // Destructuring binds decline (multi-slot patterns). + assert_eq!( + run_last( + &mut eval, + "(loop [[a b] [0 0]] (if (< a 3) (recur [(inc a) (+ b a)]) b))" + ), + "3" + ); + // Non-arithmetic test operands decline. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0] (if (= (str i) \"3\") :three (recur (inc i))))" + ), + ":three" + ); +} + +#[test] +fn int_loop_slow_errors() { + let mut eval = boot(); + // Arity failures inside steps decline (the interpreter raises). + assert_eq!( + run_last( + &mut eval, + "(try (loop [i 0] (if (< i 3) (recur (do (-) (inc i))) i)) (catch clojure.lang.ArityException e :arity))" + ), + ":arity" + ); + // Zero-arg tests decline (the interpreter raises). + assert_eq!( + uncaught(&mut eval, "(loop [i 0] (if (<) :t :f))"), + "clojure.lang.ArityException | Wrong number of args (0) passed to: clojure.core/<" + ); + // Throws from the exit branch propagate. + assert_eq!( + uncaught( + &mut eval, + "(loop [i 0] (if (< i 3) (recur (inc i)) (throw (ex-info \"boom\" {}))))" + ), + "clojure.lang.ExceptionInfo | boom" + ); +} + +#[test] +fn int_loop_two_million_canary() { + // Exercises the fast path at scale (milliseconds fast, seconds + // slow — a silent deopt shows up as a slow suite, not a wrong + // answer). + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 s 0] (if (< i 2000000) (recur (inc i) (+ s i)) s))" + ), + "1999999000000" + ); +} + +#[test] +fn int_loop_bail_pins() { + let mut eval = boot(); + // Overflow inside the loop TEST bails (the interpreter throws). + assert_eq!( + uncaught( + &mut eval, + "(loop [i 9223372036854775807] (if (< i (+ i 1)) (recur i) :done))" + ), + "java.lang.ArithmeticException | long overflow" + ); + // Non-`Global` step and test heads decline (slow path decides). + assert_eq!( + run_last( + &mut eval, + "(loop [i 0] (if (< i 2) (recur ((fn [a] (inc a)) i)) i))" + ), + "2" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0] (if ((fn [a b] (< a b)) i 2) (recur (inc i)) i))" + ), + "2" + ); + // A non-comparison `Direct` test declines (truthy exit here). + assert_eq!( + run_last( + &mut eval, + "(loop [i 5] (if (+ i 1) :immediate (recur (inc i))))" + ), + ":immediate" + ); + // Outer non-`Int` operands decline (the interpreter raises). + assert_eq!( + uncaught( + &mut eval, + "(let [n :kw] (loop [i 0] (if (< i n) (recur (inc i)) i)))" + ), + "java.lang.ClassCastException | class clojure.lang.Keyword cannot be cast to class java.lang.Number (clojure.lang.Keyword is in unnamed module of loader 'app'; java.lang.Number is in module java.base of loader 'bootstrap')" + ); + // An exit-branch `recur` with a non-`Int` value bails, then + // the interpreter raises at the re-entered test. + assert_eq!( + uncaught( + &mut eval, + "(loop [i 0] (if (< i 5) (recur (inc i)) (recur :oops)))" + ), + "java.lang.ClassCastException | class clojure.lang.Keyword cannot be cast to class java.lang.Number (clojure.lang.Keyword is in unnamed module of loader 'app'; java.lang.Number is in module java.base of loader 'bootstrap')" + ); +} + +#[test] +fn int_loop_guard_observes_bindings_and_closures() { + let mut eval = boot(); + // A thread binding on a loop-called var declines the plan (the + // slow path observes the binding): a dynamic alias of `+` + // shares the `+` native (hence its fast tag) but carries its + // own thread binding. + assert_eq!( + run_last( + &mut eval, + "(def ^:dynamic myplus clojure.core/+) (binding [myplus (fn [a b] (+ a b 100))] (loop [i 0] (if (< i 3) (recur (myplus i 1)) i)))" + ), + "101" + ); + // A redefinition to a non-native (a closure) declines the plan + // too (the guard only trusts the analyzed root); the redefined + // root computes through the slow path. + assert_eq!( + run_last( + &mut eval, + "(let [orig +] (with-redefs [+ (fn [a b] (orig a b 100))] (loop [i 0] (if (< i 3) (recur (+ i 1)) i))))" + ), + "101" + ); + // A redefinition to plain data declines the plan as well (the + // guard only inlines `Extension` roots); the slow path calls + // the data and raises exactly like the interpreter. + assert_eq!( + uncaught( + &mut eval, + "(with-redefs [+ 5] (loop [i 0] (if (< i 3) (recur (+ i 1)) i)))" + ), + "java.lang.ClassCastException | Cannot cast java.lang.Long to clojure.lang.IFn" + ); +} + +#[test] +fn int_loop_leaf_edge_shapes() { + let mut eval = boot(); + // `inc`/`dec` over a zero-operand leaf (`(+)` folds to a bare + // counted op, not a `Var`/`Const`): the leaf read misses, so + // the general machine computes it. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0] (if (< (dec (+)) i (inc (+))) (recur (inc i)) :done))" + ), + ":done" + ); + // A leaf-built step that ends in a non-counted op (nested + // `inc`) skips the leaf fold for the general machine. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0] (if (< i 5) (recur (inc (inc i))) i))" + ), + "6" + ); + // A counted `*` over a non-flat operand (a nested `+`) + // declines the leaf fold (operand count mismatch) for the + // general machine. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 j 3] (if (< 0 j) (recur (* (+ i 1) j) (dec j)) i))" + ), + "9" + ); +} diff --git a/rust/crates/clj-eval/tests/local_apply.rs b/rust/crates/clj-eval/tests/local_apply.rs new file mode 100644 index 0000000000..1a3e334578 --- /dev/null +++ b/rust/crates/clj-eval/tests/local_apply.rs @@ -0,0 +1,130 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! Local-head closure-call pins: calls through a local bound to a +//! closure skip generic dispatch (shared arity selection), with +//! identical values and errors — while locals bound to anything +//! else fall through untouched. + +use clj_eval::{Evaluator, boot}; +use clj_value::{PrintOpts, Value, pr_to_string}; + +fn pr(v: &Value) -> String { + pr_to_string(v, &PrintOpts::pr()) +} + +fn run_last(eval: &mut Evaluator, src: &str) -> String { + let vs = eval.eval_source(src).unwrap(); + let v = vs.last().unwrap(); + let r = eval.realize_deep(v).unwrap(); + pr(&r) +} + +#[test] +fn local_apply_values() { + let mut eval = boot(); + for (src, want) in [ + ("(let [f (fn [x] (+ x 1))] (f 1) (f 2))", "3"), + ( + "(let [fib (fn fib [n] (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))] (fib 10))", + "55", + ), + ("(let [f (fn [x & xs] [x xs])] (f 1 2 3))", "[1 (2 3)]"), + ( + "(let [f (fn ([x] x) ([x y] (+ x y)))] [(f 1) (f 1 2)])", + "[1 3]", + ), + ( + "(let [f (fn [x] {:pre [(pos? x)]} (* x 2))] [(f 3) (try (f -1) (catch Throwable e :pre))])", + "[6 :pre]", + ), + ("(let [f +] (f 1 2))", "3"), + ("(map (let [f inc] f) [1 2])", "(2 3)"), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } +} + +#[test] +fn local_apply_fallthrough() { + // Non-closure locals take the generic path (values unchanged). + let mut eval = boot(); + for (src, want) in [ + ("(let [m {:a 1}] (m :a))", "1"), + ("(let [v [10 20]] (v 1))", "20"), + ("(let [k :a] (k {:a 1}))", "1"), + ("(let [s #{1 2}] (s 2))", "2"), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } +} + +#[test] +fn single_arg_call_and_recur() { + // One-parameter closures bind through the inline call frame + // (values identical), and `recur` rebinds that frame in place. + let mut eval = boot(); + for (src, want) in [ + ("((fn [x] (+ x 1)) 41)", "42"), + ( + "((fn cd [n] (if (zero? n) :done (recur (dec n)))) 100000)", + ":done", + ), + ( + "(let [s ((fn grab [n] (if (zero? n) [(fn [] n)] (recur (dec n)))) 3)] ((first s)))", + "0", + ), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } +} + +/// Run `f` on a 256 MiB worker: deep recursion trips the +/// catchable guard there, never a real stack abort. +fn on_worker(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(256 * 1024 * 1024) + .spawn(f) + .expect("spawn worker") + .join() + .expect("worker panicked") +} + +#[test] +fn local_apply_depth_guard() { + // Deep local-head recursion throws the catchable + // `StackOverflowError` (like the generic path), never aborts. + on_worker(|| { + let mut eval = boot(); + for src in [ + "(try ((fn f [n] (if (zero? n) 0 (f (dec n)))) 100000) (catch StackOverflowError e :so))", + "(try (let [f (fn f2 [n] (if (zero? n) 0 (f2 (dec n))))] (f 100000)) (catch StackOverflowError e :so))", + ] { + assert_eq!(run_last(&mut eval, src), ":so", "{src}"); + } + }); +} + +#[test] +fn local_apply_arity_errors() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(try (let [f (fn [x] x)] (f 1 2)) (catch clojure.lang.ArityException e :arity))" + ), + ":arity" + ); + assert_eq!( + run_last( + &mut eval, + "(try (let [f (fn [x] x)] (f)) (catch clojure.lang.ArityException e :arity))" + ), + ":arity" + ); +} diff --git a/rust/crates/clj-eval/tests/map_loop.rs b/rust/crates/clj-eval/tests/map_loop.rs new file mode 100644 index 0000000000..1d717bec1f --- /dev/null +++ b/rust/crates/clj-eval/tests/map_loop.rs @@ -0,0 +1,367 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! Threaded-map loop pins (`Evaluator::try_int_loop` over map +//! bindings): build loops thread one assoc chain through +//! `Map::insert_threaded` with slow-path-identical values, +//! iteration order, entry preservation, and identity — while +//! sorted maps, exotic keys, and exotic shapes decline to the +//! interpreter. + +use clj_eval::{Evaluator, SourceError, boot}; +use clj_value::{PrintOpts, Value, pr_to_string}; + +fn pr(v: &Value) -> String { + pr_to_string(v, &PrintOpts::pr()) +} + +fn run_last(eval: &mut Evaluator, src: &str) -> String { + let vs = eval.eval_source(src).unwrap(); + let v = vs.last().unwrap(); + let r = eval.realize_deep(v).unwrap(); + pr(&r) +} + +fn uncaught(eval: &mut Evaluator, src: &str) -> String { + match eval.eval_source(src) { + Err(SourceError::Uncaught(t)) => format!( + "{} | {}", + t.class.jvm_name(), + t.message.as_deref().unwrap_or("") + ), + Err(other) => panic!("expected an uncaught throw, got {other}"), + Ok(vs) => panic!("expected an uncaught throw for {src}, got {vs:?}"), + } +} + +#[test] +fn map_loop_builds() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [m (loop [i 0 m {}] (if (< i 100000) (recur (inc i) (assoc m i (* i 2))) m))] [(count m) (get m 99999) (reduce + 0 (vals m))])" + ), + "[100000 199998 9999900000]" + ); +} + +#[test] +fn map_loop_preserves_entry() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [m0 {:a 1}] [(loop [m m0 i 0] (if (< i 3) (recur (assoc m i i) (inc i)) (count m))) m0])" + ), + "[4 {:a 1}]" + ); + // A hash entry survives threading intact (deep-copy path). + assert_eq!( + run_last( + &mut eval, + "(let [m0 (into {} (map (fn [i] [i i]) (range 100)))] (let [r (loop [m m0 i 100] (if (< i 110) (recur (assoc m i (* i 2)) (inc i)) m))] [(count r) (get r 109) (count m0) (get m0 50)]))" + ), + "[110 218 100 50]" + ); +} + +#[test] +fn map_loop_identity() { + let mut eval = boot(); + // Mutated loops answer a fresh version ... + assert_eq!( + run_last( + &mut eval, + "(let [m0 {}] (let [r (loop [m m0 i 0] (if (< i 2) (recur (assoc m i i) (inc i)) m))] [(identical? m0 r) (count r)]))" + ), + "[false 2]" + ); + // ... even when every round rewrites existing keys ... + assert_eq!( + run_last( + &mut eval, + "(let [m0 {:a 1}] (let [r (loop [m m0 i 0] (if (< i 3) (recur (assoc m :a (inc i)) (inc i)) m))] [r (identical? m0 r)]))" + ), + "[{:a 3} false]" + ); + // ... while a loop that never assocs answers the entry itself. + assert_eq!( + run_last( + &mut eval, + "(let [m0 {:a 1}] (identical? m0 (loop [m m0 i 10] (if (< i 3) (recur (assoc m i i) (inc i)) m))))" + ), + "true" + ); +} + +#[test] +fn map_loop_nested_and_const() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m {}] (if (< i 3) (recur (inc i) (assoc (assoc m :a i) :b i)) m))" + ), + "{:a 2, :b 2}" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m {}] (if (< i 3) (recur (inc i) (assoc m \"k\" i)) m))" + ), + "{\"k\" 2}" + ); + assert_eq!( + run_last( + &mut eval, + "(let [v 99] (loop [i 0 m {}] (if (< i 3) (recur (inc i) (assoc m i v)) m)))" + ), + "{0 99, 1 99, 2 99}" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m (with-meta {} {:a 1})] (if (< i 2) (recur (inc i) (assoc m i i)) (meta m)))" + ), + "{:a 1}" + ); +} + +#[test] +fn map_loop_two_vars() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 a {} b {}] (if (< i 3) (recur (inc i) (assoc a i 1) (assoc b i 2)) [a b]))" + ), + "[{0 1, 1 1, 2 1} {0 2, 1 2, 2 2}]" + ); +} + +#[test] +fn map_loop_shared_base() { + // Both steps assoc `m` (each sees the pre-round map, like the + // slow path — `n` inherits `m`'s round-1 entry). + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m {} n {}] (if (< i 2) (recur (inc i) (assoc m :a i) (assoc m :b i)) [m n]))" + ), + "[{:a 1} {:a 0, :b 1}]" + ); +} + +#[test] +fn map_loop_exit_recur_continues() { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m {}] (if (< i 2) (recur (inc i) (assoc m i i)) (if (< i 4) (recur (inc i) (assoc m i i)) (count m))))" + ), + "4" + ); +} + +#[test] +fn map_loop_bail_runs_effects_once() { + let mut eval = boot(); + // Like `int_loop_bail_runs_effects_once`, over a mixed map/int + // loop (the overflow bail resumes from the round-start snapshot, + // effects exactly once). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (try (loop [i 0 m {}] (if (< i 2) (do (swap! a inc) (recur (inc i) (assoc m i i))) (recur (+ i 9223372036854775807) m))) (catch ArithmeticException e [:threw @a])))" + ), + "[:threw 2]" + ); + // A root write after threaded exit rounds resumes with the map + // intact (three increments and three entries, not four). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (loop [i 0 m {}] (if (< i 3) (do (swap! a inc) (def map-loop-bail-pin i) (recur (inc i) (assoc m i i))) [(count m) @a])))" + ), + "[3 3]" + ); + // An exotic exit `recur` resumes from its own values (like the + // integer loop above — the test throws exactly as `(< :boom 2)` + // directly, effects once). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (try (loop [i 0 m {}] (if (< i 2) (do (swap! a inc) (recur (if (< i 1) (inc i) :boom) (assoc m i i))) @a)) (catch Exception e [:caught @a])))" + ), + "[:caught 2]" + ); +} + +#[test] +fn map_loop_exit_capture_keeps_round() { + // Like `int_loop_exit_capture_keeps_round`, over a mixed + // map/int loop (the reused exit env falls back per round once + // a closure observes it). + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [a (atom [])] (loop [i 0 m {}] (if (< i 3) (do (swap! a conj (fn [] i)) (recur (inc i) (assoc m i i))) [(count m) (mapv (fn [f] (f)) @a)])))" + ), + "[3 [0 1 2]]" + ); +} + +#[test] +fn map_loop_slow_shapes() { + let mut eval = boot(); + // Sorted entries decline (comparator order preserved — the keys + // insert reversed so insertion order would differ). + assert_eq!( + run_last( + &mut eval, + "(loop [m (sorted-map) i 0] (if (< i 3) (recur (assoc m (- 2 i) i) (inc i)) (vec m)))" + ), + "[[0 2] [1 1] [2 0]]" + ); + // Tests over the map decline. + assert_eq!( + run_last( + &mut eval, + "(count (loop [i 0 m {}] (if (< (count m) 3) (recur (inc i) (assoc m i i)) m)))" + ), + "3" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m {}] (if (= m {}) (recur (inc i) (assoc m i i)) :never))" + ), + ":never" + ); + // Multi-pair assocs decline. + assert_eq!( + run_last( + &mut eval, + "(count (loop [i 0 m {}] (if (< i 6) (recur (+ i 2) (assoc m i i (inc i) (inc i))) m)))" + ), + "6" + ); + // Non-integer key expressions decline. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 m {}] (if (< i 2) (recur (inc i) (assoc m (str i) i)) m))" + ), + "{\"0\" 0, \"1\" 1}" + ); +} + +#[test] +fn map_loop_assoc_redefs_observed() { + // `assoc` is not inlined (JVM observes too): a rebound `assoc` + // declines threading and the slow path answers. + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(with-redefs [assoc (fn [m k v] :mocked)] (loop [i 0 m {}] (if (< i 2) (recur (inc i) (assoc m i i)) m)))" + ), + ":mocked" + ); +} + +#[test] +fn map_loop_overflow_bails() { + let mut eval = boot(); + assert_eq!( + uncaught( + &mut eval, + "(loop [i 0 m {}] (if (< i 2) (recur (inc i) (assoc m (+ i 9223372036854775807) i)) m))" + ), + "java.lang.ArithmeticException | long overflow" + ); +} + +#[test] +fn map_loop_bail_pins() { + let mut eval = boot(); + // Overflow inside the loop TEST bails (the interpreter throws). + assert_eq!( + uncaught( + &mut eval, + "(loop [i 9223372036854775807 m {}] (if (< i (+ i 1)) (recur i m) :done))" + ), + "java.lang.ArithmeticException | long overflow" + ); + // Throws from a map-loop exit branch propagate. + assert_eq!( + uncaught( + &mut eval, + "(loop [i 0 m {}] (if (< i 3) (recur (inc i) (assoc m i i)) (throw (ex-info \"boom\" {}))))" + ), + "clojure.lang.ExceptionInfo | boom" + ); + // A root change under an exit-branch `recur` bails mid-loop + // (the slow path re-runs from the entry). + assert_eq!( + run_last( + &mut eval, + "(loop [m {} i 0] (if (< i 1) (recur (assoc m i i) (inc i)) (if (= i 1) (do (def epoch-bump-9 9) (recur m (inc i))) :done)))" + ), + ":done" + ); + // Non-matching map steps decline (slow path decides). + assert_eq!( + run_last( + &mut eval, + "(loop [m {} i 0] (if (< i 2) (recur ((fn [x] x) m) (inc i)) m))" + ), + "{}" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [m {:a 1} i 0] (if (< i 1) (recur (dissoc m :a) (inc i)) m))" + ), + "{}" + ); + assert_eq!( + run_last( + &mut eval, + "(let [outer {}] (loop [m {} i 0] (if (< i 2) (recur outer (inc i)) m)))" + ), + "{}" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [m {} i 0] (if (< i 2) (recur i (inc i)) m))" + ), + "1" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [m {} i 0] (if (< i 2) (recur {:a 1} (inc i)) m))" + ), + "{:a 1}" + ); + // A natively-rooted `assoc` stand-in fails the display check + // (declines; the slow path answers without calling it). + assert_eq!( + run_last( + &mut eval, + "(with-redefs [assoc conj] (loop [m {} i 9] (if (< i 2) (recur (assoc m i i) (inc i)) :exited)))" + ), + ":exited" + ); +} diff --git a/rust/crates/clj-eval/tests/seq_peel.rs b/rust/crates/clj-eval/tests/seq_peel.rs new file mode 100644 index 0000000000..ee51f90d21 --- /dev/null +++ b/rust/crates/clj-eval/tests/seq_peel.rs @@ -0,0 +1,472 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! Seq-peel pins: uniquely-owned `map`/`filter`/`remove` chains over +//! a range walk fused, with identical values, errors, check timing, +//! and side-effect order to the generic stepping walk — while held +//! (shared) chains keep generic memoization. + +use clj_eval::{Evaluator, SourceError, boot}; +use clj_value::{PrintOpts, Value, pr_to_string}; + +fn pr(v: &Value) -> String { + pr_to_string(v, &PrintOpts::pr()) +} + +fn run_last(eval: &mut Evaluator, src: &str) -> String { + let vs = eval.eval_source(src).unwrap(); + let v = vs.last().unwrap(); + let r = eval.realize_deep(v).unwrap(); + pr(&r) +} + +fn uncaught(eval: &mut Evaluator, src: &str) -> String { + match eval.eval_source(src) { + Err(SourceError::Uncaught(t)) => format!( + "{} | {}", + t.class.jvm_name(), + t.message.as_deref().unwrap_or("") + ), + Err(other) => panic!("expected an uncaught throw, got {other}"), + Ok(vs) => panic!("expected an uncaught throw for {src}, got {vs:?}"), + } +} + +#[test] +fn peel_values() { + let mut eval = boot(); + for (src, want) in [ + // Fresh chains (peeled) compute the same values. + ("(reduce + 0 (filter odd? (map inc (range 100))))", "2500"), + ("(reduce + 0 (map inc (filter odd? (range 100))))", "2550"), + ("(reduce + 0 (remove even? (range 20)))", "100"), + ("(reduce + (map inc (range 5)))", "15"), + ("(reduce + 0 (map inc (range 0)))", "0"), + ( + "(reduce + 0 (map #(* % 2) (filter odd? (map inc (range 50)))))", + "1250", + ), + // Short-circuit (`reduced`) stops the fused walk. + ( + "(reduce (fn [a x] (if (> x 50) (reduced a) (+ a x))) 0 (filter odd? (map inc (range 100))))", + "625", + ), + // Other fused-walk callers. + ("(dorun (map inc (range 10)))", "nil"), + ("(doall (map inc (range 3)))", "(1 2 3)"), + ("(every? pos? (map inc (range 10)))", "true"), + ("(some even? (map inc (range 10)))", "true"), + ( + "(frequencies (map #(mod % 3) (range 9)))", + "{0 3, 1 3, 2 3}", + ), + // Non-peelable shapes stay correct (generic path). + ("(reduce + 0 (map + (range 5) (range 5)))", "20"), + ("(reduce + 0 (take 5 (map inc (range 100))))", "15"), + ("(reduce + 0 (map inc [1 2 3]))", "9"), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } +} + +#[test] +fn peel_held_parity() { + // Held chains walk the generic path; fresh chains peel — same + // results either way (each pair pins one against the other). + let mut eval = boot(); + for (fresh, held) in [ + ( + "(reduce + 0 (filter odd? (map inc (range 100))))", + "(let [s (filter odd? (map inc (range 100)))] (reduce + 0 s))", + ), + ( + "(reduce (fn [a x] (if (> x 50) (reduced a) (+ a x))) 0 (filter odd? (map inc (range 100))))", + "(let [s (filter odd? (map inc (range 100)))] (reduce (fn [a x] (if (> x 50) (reduced a) (+ a x))) 0 s))", + ), + ( + "(doall (map inc (filter odd? (range 20))))", + "(let [s (map inc (filter odd? (range 20)))] (doall s))", + ), + ( + "(frequencies (map #(mod % 3) (range 9)))", + "(let [s (map #(mod % 3) (range 9))] (frequencies s))", + ), + ] { + assert_eq!( + run_last(&mut eval, fresh), + run_last(&mut eval, held), + "{fresh}" + ); + } +} + +#[test] +fn peel_check_timing() { + // `IFn` checks fire per chunk reached (like the step fns): + // empty inputs never check, and an op behind an emptying + // filter never checks — fresh and held alike. + let mut eval = boot(); + for (src, want) in [ + ("(reduce + 0 (map 42 (range 0)))", "0"), + ( + "(reduce + 0 (map 42 (filter (fn [_] false) (range 10))))", + "0", + ), + ( + "(let [s (map 42 (filter (fn [_] false) (range 10)))] (reduce + 0 s))", + "0", + ), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } + // Reached bad callees fail identically on both paths. + for src in [ + "(reduce + 0 (map 42 (range 10)))", + "(reduce + 0 (filter odd? (map 42 (range 10))))", + "(let [s (map 42 (range 10))] (reduce + 0 s))", + ] { + let err = run_last( + &mut eval, + &format!("(try {src} (catch Throwable e [(str (class e)) (ex-message e)]))"), + ); + let want = run_last( + &mut eval, + "(try (reduce + 0 (map 42 (range 10))) (catch Throwable e [(str (class e)) (ex-message e)]))", + ); + assert_eq!(err, want, "{src}"); + assert!(err.contains("ClassCastException"), "{src} -> {err}"); + } +} + +#[test] +fn peel_effect_order() { + // Side-effect order matches the generic chunked walk across + // the 32-item chunk boundary (40 heads: 32 + 8). + let tmpl = "(let [log (atom []) f (fn [x] (swap! log conj [:m x]) (inc x)) p (fn [x] (swap! log conj [:p x]) (odd? x))] CHAIN @log)"; + let fresh = tmpl.replace("CHAIN", "(reduce + 0 (filter p (map f (range 40))))"); + let held = tmpl.replace( + "CHAIN", + "(let [s (filter p (map f (range 40)))] (reduce + 0 s))", + ); + let mut eval = boot(); + let a = run_last(&mut eval, &fresh); + let b = run_last(&mut eval, &held); + assert_eq!(a, b); + // Sanity: map runs chunk-at-a-time ahead of filter. + assert!(a.starts_with("[[:m 0] [:m 1]"), "{a}"); +} + +#[test] +fn peel_memoization() { + // Held chains memoize through the generic walk; mid-chain + // sharing rebuilds the peeled shells and still memoizes the + // shared cell. + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [s (map inc (range 10))] (reduce + 0 s) (realized? s))" + ), + "true" + ); + assert_eq!( + run_last( + &mut eval, + "(let [inner (map inc (range 10))] [(reduce + 0 (map #(* % 2) inner)) (realized? inner)])" + ), + "[110 true]" + ); +} + +#[test] +fn reduce_plus_range() { + // Fused `(reduce + init? range)` accumulates tagless. + let mut eval = boot(); + for (src, want) in [ + ("(reduce + (range 100))", "4950"), + ("(reduce + 5 (range 100))", "4955"), + ("(reduce + (range 1))", "0"), + ("(reduce + 7 (range 0))", "7"), + ("(reduce + [])", "0"), + ("(reduce + (range 0))", "0"), + ("(reduce + (range 1000000))", "499999500000"), + // Redefined `+` takes the generic path (observes the root). + ("(with-redefs [+ -] (reduce + (range 5)))", "-10"), + ("(with-redefs [+ -] (reduce + 100 (range 5)))", "90"), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } + // Overflow bails and throws `long overflow`, like `+` itself + // (3-arg and 2-arg alike). + assert!( + uncaught(&mut eval, "(reduce + 9223372036854775807 (range 10))").contains("long overflow") + ); + assert!( + uncaught( + &mut eval, + "(reduce + (range 9223372036854775802 9223372036854775807))" + ) + .contains("long overflow") + ); +} + +#[test] +fn range_walk_tail_pins() { + let mut eval = boot(); + // Early stop short-circuits a range walk. + assert_eq!( + run_last(&mut eval, "(reduce (fn [_ _] (reduced :x)) 0 (range 10))"), + ":x" + ); + // A completing walk visits the final head (exact tail). + assert_eq!( + run_last( + &mut eval, + "(reduce (fn [a b] (+ a b)) 0 (range 9223372036854775807 1e300 1))" + ), + "9223372036854775807" + ); + // ... and raises at an inexact tail after yielding its heads. + assert_eq!( + uncaught(&mut eval, "(reduce (fn [a b] (+ a b)) 0 (range 0M 1M 1/3))"), + "java.lang.ArithmeticException | Non-terminating decimal expansion; no exact representable decimal result." + ); + // Peeled chains exhaust and raise the same way. + assert_eq!( + run_last( + &mut eval, + "(pr-str (doall (map inc (filter odd? (range 10)))))" + ), + "\"(2 4 6 8 10)\"" + ); + assert_eq!( + uncaught(&mut eval, "(doall (map inc (range 0M 1M 1/3)))"), + "java.lang.ArithmeticException | Non-terminating decimal expansion; no exact representable decimal result." + ); + // A realized base stops the peel (generic walk from there). + assert_eq!( + run_last( + &mut eval, + "(pr-str (doall (filter odd? (doall (map inc (range 10))))))" + ), + "\"(1 3 5 7 9)\"" + ); +} + +#[test] +fn plus_range_edge_pins() { + let mut eval = boot(); + // Empty 2-arg: the generic walk calls `(f)`. + assert_eq!(run_last(&mut eval, "(reduce + (range 0))"), "0"); + // Non-`Int` heads bail (first head, later head). + assert_eq!(run_last(&mut eval, "(reduce + (range 0.5 2))"), "2.0"); + assert_eq!(run_last(&mut eval, "(reduce + (range 0 2 0.5))"), "3.0"); + // Single-head exact tail (overflow-stepped, non-long). + assert_eq!( + run_last(&mut eval, "(reduce + (range 9223372036854775807 1e300 1))"), + "9223372036854775807" + ); + // Multi-head exact tail with an offset init. + assert_eq!( + run_last( + &mut eval, + "(reduce + -9223372036854775806 (range 9223372036854775806 1e300 1))" + ), + "9223372036854775807" + ); + // Non-`Int` init bails (generic walk adds). + assert_eq!(run_last(&mut eval, "(reduce + 0.5 (range 10))"), "45.5"); + // Inexact steps bail on their non-`Int` heads (generic raises). + assert_eq!( + uncaught(&mut eval, "(reduce + (range 0M 1M 1/3))"), + "java.lang.ArithmeticException | Non-terminating decimal expansion; no exact representable decimal result." + ); +} + +#[test] +fn peel_reused_frame_pins() { + let mut eval = boot(); + // Captures observe their own apply (the shared frame falls back + // to a fresh bind per apply). + assert_eq!( + run_last( + &mut eval, + "(let [fs (doall (map (fn [x] (fn [] x)) (range 3)))] (mapv (fn [f] (f)) fs))" + ), + "[0 1 2]" + ); + // `recur` to the mapped fn loops like `apply_arity`. + assert_eq!( + run_last( + &mut eval, + "(doall (map (fn [x] (if (> x 0) (recur (dec x)) :done)) (range 3)))" + ), + "(:done :done :done)" + ); + // Capture plus `recur` (the shared frame rebinds fresh, and + // each capture keeps its own round). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom [])] (doall (map (fn [x] (do (swap! a conj (fn [] x)) (if (> x 0) (recur (dec x)) :done))) (range 2))) [(count @a) (mapv (fn [f] (f)) @a)])" + ), + "[3 [0 1 0]]" + ); + // A throwing map fn propagates (like the generic apply). + assert_eq!( + run_last( + &mut eval, + "(try (doall (map (fn [x] (if (= x 1) (throw (ex-info \"b\" {})) x)) (range 3))) (catch Exception e :caught))" + ), + ":caught" + ); + // The specialized filter arm (closure predicate). + assert_eq!( + run_last(&mut eval, "(doall (filter #(> % 1) (range 4)))"), + "(2 3)" + ); + // Ineligible fns apply generically (identical values). + assert_eq!( + run_last(&mut eval, "(doall (map (fn ([x] x) ([x y] y)) (range 3)))"), + "(0 1 2)" + ); + assert_eq!( + run_last(&mut eval, "(doall (map (bound-fn [x] (* x 2)) (range 3)))"), + "(0 2 4)" + ); + assert_eq!( + run_last(&mut eval, "(doall (map (fn [& xs] (count xs)) (range 3)))"), + "(1 1 1)" + ); +} + +fn on_worker(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(256 * 1024 * 1024) + .spawn(f) + .expect("spawn worker") + .join() + .expect("worker panicked") +} + +#[test] +fn peel_reused_depth_parity() { + // Nested peeled maps trip the same `StackOverflowError` as the + // generic path (the guard matches `apply_closure` + // apply-for-apply). + on_worker(|| { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(try (doall (map (fn f [n] (if (zero? n) 0 (first (doall (map f (range (dec n) n)))))) [100000])) (catch StackOverflowError e :so))" + ), + ":so" + ); + }); +} + +#[test] +fn peel_ineligible_fn_pins() { + let mut eval = boot(); + // Ineligible fns decline the reused frame inside a peeling + // walk too (identical values to the generic walk): multi-arity + // and conveyed (`bound-fn`) closures, and rest-arg closures. + assert_eq!( + run_last( + &mut eval, + "(reduce + 0 (map (fn ([x] (inc x)) ([x y] (+ x y))) (range 3)))" + ), + "6" + ); + assert_eq!( + run_last( + &mut eval, + "(reduce + 0 (map (bound-fn [x] (* x 2)) (range 3)))" + ), + "6" + ); + assert_eq!( + run_last( + &mut eval, + "(reduce + 0 (map (fn [& xs] (apply + xs)) (range 3)))" + ), + "3" + ); +} + +#[test] +fn peel_reused_frame_dorun_pins() { + let mut eval = boot(); + // A frame observed across applies (a retained capture) falls + // back to a fresh bind per apply. + assert_eq!( + run_last( + &mut eval, + "(let [a (atom nil)] (dorun (map (fn [x] (reset! a (fn [] x)) x) (range 3))))" + ), + "nil" + ); + // `recur` to the mapped fn loops the reused frame in place. + assert_eq!( + run_last( + &mut eval, + "(dorun (map (fn [x] (if (> x 0) (recur (dec x)) :done)) (range 3)))" + ), + "nil" + ); + // Capture plus `recur` (a capture retained outside the unwound + // body keeps the frame observed, so the `recur` rebinds fresh; + // every round still runs exactly once). + assert_eq!( + run_last( + &mut eval, + "(def cap-rec (atom [])) (dorun (map (fn [x] (do (swap! cap-rec conj (fn [] x)) (if (> x 0) (recur (dec x)) :done))) (range 2))) (count @cap-rec)" + ), + "3" + ); + // A throwing map fn propagates through the reused apply. + assert_eq!( + run_last( + &mut eval, + "(try (dorun (map (fn [x] (if (= x 1) (throw (ex-info \"b\" {})) x)) (range 3))) (catch Exception e :caught))" + ), + ":caught" + ); +} + +#[test] +fn peel_walk_tail_and_empty_pins() { + let mut eval = boot(); + // A peeling walk visits the pulled heads, then raises at an + // inexact tail (the `walk_range` tail). + assert_eq!( + uncaught(&mut eval, "(dorun (map inc (range 0M 1M 1/3)))"), + "java.lang.ArithmeticException | Non-terminating decimal expansion; no exact representable decimal result." + ); + // An empty 2-arg `+` reduce (an empty range is `()`) walks + // generically, which calls `(f)`. + assert_eq!(run_last(&mut eval, "(reduce + (range 5 5))"), "0"); +} + +#[test] +fn peel_reused_depth_guard() { + // Depth grown entirely through the reused apply (direct calls + // don't count) trips the reused apply's own guard — a nil- + // message `StackOverflowError`, like the generic apply. + on_worker(|| { + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(try ((fn f [x] (dorun (map f (range x (inc x))))) 0) (catch StackOverflowError e [:caught (nil? (ex-message e))]))" + ), + "[:caught true]" + ); + }); +} diff --git a/rust/crates/clj-eval/tests/swap_fast.rs b/rust/crates/clj-eval/tests/swap_fast.rs new file mode 100644 index 0000000000..12178aca7e --- /dev/null +++ b/rust/crates/clj-eval/tests/swap_fast.rs @@ -0,0 +1,98 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! `swap!` int-arithmetic pins: known natives over int state fuse +//! to one locked read-compute-publish, with identical values — +//! while validators, watches, non-int state, and overflow fall +//! back to the generic CAS loop. + +use clj_eval::{Evaluator, boot}; +use clj_value::{PrintOpts, Value, pr_to_string}; + +fn pr(v: &Value) -> String { + pr_to_string(v, &PrintOpts::pr()) +} + +fn run_last(eval: &mut Evaluator, src: &str) -> String { + let vs = eval.eval_source(src).unwrap(); + let v = vs.last().unwrap(); + let r = eval.realize_deep(v).unwrap(); + pr(&r) +} + +#[test] +fn swap_fast_values() { + let mut eval = boot(); + for (src, want) in [ + ("(let [a (atom 0)] (swap! a + 5) @a)", "5"), + ("(let [a (atom 10)] (swap! a - 3) @a)", "7"), + ("(let [a (atom 10)] (swap! a *) @a)", "10"), + ("(let [a (atom 6)] (swap! a * 7) @a)", "42"), + ("(let [a (atom 6)] [(swap! a inc) @a])", "[7 7]"), + ("(let [a (atom 6)] [(swap! a dec) @a])", "[5 5]"), + ("(let [a (atom 6)] [(swap! a +) @a])", "[6 6]"), + ("(let [a (atom 6)] [(swap! a -) @a])", "[-6 -6]"), + ("(let [a (atom 0)] (swap-vals! a + 41))", "[0 41]"), + // A hot loop over the fast path. + ( + "(let [a (atom 0)] (doseq [i (range 100000)] (swap! a + i)) @a)", + "4999950000", + ), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } +} + +#[test] +fn swap_fast_fallbacks() { + let mut eval = boot(); + // Overflow throws (generic path), like `+` itself — state + // untouched. + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 9223372036854775807)] (try (swap! a + 1) (catch ArithmeticException e [:overflow @a])))" + ), + "[:overflow 9223372036854775807]" + ); + // Non-int state runs the generic application. + assert_eq!( + run_last(&mut eval, "(let [a (atom 1.5)] (swap! a + 1) @a)"), + "2.5" + ); + // Non-int extras run the generic application. + assert_eq!( + run_last(&mut eval, "(let [a (atom 0)] (swap! a + 1.5) @a)"), + "1.5" + ); + // Validators still veto. + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 1 :validator pos?)] (try (swap! a - 5) (catch Throwable e [:vetoed @a])))" + ), + "[:vetoed 1]" + ); + // Watches still fire with old/new. + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0) log (atom [])] (add-watch a :w (fn [_ _ o n] (swap! log conj [o n]))) (swap! a + 3) @log)" + ), + "[[0 3]]" + ); + // Closures (even core-named ones in other namespaces) apply + // generically — user fns observe their bodies. + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (swap! a (fn [o x] (+ o x 100)) 1) @a)" + ), + "101" + ); +} diff --git a/rust/crates/clj-eval/tests/vec_loop.rs b/rust/crates/clj-eval/tests/vec_loop.rs new file mode 100644 index 0000000000..c93353bd2c --- /dev/null +++ b/rust/crates/clj-eval/tests/vec_loop.rs @@ -0,0 +1,366 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! Threaded-vector loop pins (`Evaluator::try_int_loop` over packed +//! int vectors): conditional-swap loops thread one vector through +//! `Vector::assoc_int_threaded` with slow-path-identical values, +//! entry preservation, and identity — while generic vectors, +//! exotic shapes, bounds failures, and redefined roots decline to +//! the interpreter. + +use clj_eval::{Evaluator, SourceError, boot}; +use clj_value::{PrintOpts, Value, pr_to_string}; + +fn pr(v: &Value) -> String { + pr_to_string(v, &PrintOpts::pr()) +} + +fn run_last(eval: &mut Evaluator, src: &str) -> String { + let vs = eval.eval_source(src).unwrap(); + let v = vs.last().unwrap(); + let r = eval.realize_deep(v).unwrap(); + pr(&r) +} + +fn uncaught(eval: &mut Evaluator, src: &str) -> String { + match eval.eval_source(src) { + Err(SourceError::Uncaught(t)) => format!( + "{} | {}", + t.class.jvm_name(), + t.message.as_deref().unwrap_or("") + ), + Err(other) => panic!("expected an uncaught throw, got {other}"), + Ok(vs) => panic!("expected an uncaught throw for {src}, got {vs:?}"), + } +} + +#[test] +fn vec_loop_basics() { + let mut eval = boot(); + for (src, want) in [ + // Plain assoc loop. + ( + "(loop [i 0 v (vec (range 10))] (if (< i 10) (recur (inc i) (assoc v i (* i 2))) v))", + "[0 2 4 6 8 10 12 14 16 18]", + ), + // Multi-pair assoc. + ( + "(loop [i 0 v [0 0 0 0]] (if (< i 2) (recur (inc i) (assoc v i 1 (+ i 2) 2)) v))", + "[1 1 2 2]", + ), + // Conditional swap over `nth` reads (the bubble inner shape). + ( + "(loop [j 0 arr [3 1 2]] (if (>= j 2) arr (recur (inc j) (if (> (nth arr j) (nth arr (inc j))) (assoc arr j (nth arr (inc j)) (inc j) (nth arr j)) arr))))", + "[1 2 3]", + ), + // Index-at-len appends (like the interpreter). + ( + "(loop [i 0 v []] (if (< i 5) (recur (inc i) (assoc v i i)) v))", + "[0 1 2 3 4]", + ), + // Passthrough steps keep the pre-round vector. + ( + "(loop [i 0 v [7 8]] (if (< i 3) (recur (inc i) (if (> i 1) (assoc v 0 9) v)) v))", + "[9 8]", + ), + // Two vectors thread disjoint bases. + ( + "(loop [i 0 a [1 2] b [3 4]] (if (< i 2) (recur (inc i) (assoc a i 9) (assoc b i 8)) [a b]))", + "[[9 9] [8 8]]", + ), + // Two steps, one base: each sees the pre-round vector. + ( + "(loop [i 0 x [0 0] y [0 0]] (if (< i 1) (recur (inc i) (assoc x 0 1) (assoc x 1 2)) [x y]))", + "[[1 0] [0 2]]", + ), + ] { + assert_eq!(run_last(&mut eval, src), want, "{src}"); + } +} + +#[test] +fn vec_loop_bubble() { + // The h2h bubble shape (nested loops, hashed init) sorts. + let mut eval = boot(); + assert_eq!( + run_last( + &mut eval, + "(let [a (vec (map (fn [x] (mod (* x 7919) 200)) (range 200))) n (count a)] [(nth (loop [i 0 arr a] (if (>= i n) arr (recur (inc i) (loop [j 0 arr arr] (if (>= j (- n i 1)) arr (recur (inc j) (if (> (nth arr j) (nth arr (inc j))) (assoc arr j (nth arr (inc j)) (inc j) (nth arr j)) arr))))))) 0) (nth (loop [i 0 arr a] (if (>= i n) arr (recur (inc i) (loop [j 0 arr arr] (if (>= j (- n i 1)) arr (recur (inc j) (if (> (nth arr j) (nth arr (inc j))) (assoc arr j (nth arr (inc j)) (inc j) (nth arr j)) arr))))))) 199)])" + ), + "[0 199]" + ); + // Threaded and explicitly-slow shapes agree element-wise + // (3-arg `nth` declines to the interpreter but never misses — + // every read is in bounds). + let mut eval = boot(); + let init = "(vec (map (fn [x] (mod (* x 7919) 50)) (range 50)))"; + let fast = "(fn [a] (let [n (count a)] (loop [i 0 arr a] (if (>= i n) arr (recur (inc i) (loop [j 0 arr arr] (if (>= j (- n i 1)) arr (recur (inc j) (if (> (nth arr j) (nth arr (inc j))) (assoc arr j (nth arr (inc j)) (inc j) (nth arr j)) arr)))))))))"; + let slow = "(fn [a] (let [n (count a)] (loop [i 0 arr a] (if (>= i n) arr (recur (inc i) (loop [j 0 arr arr] (if (>= j (- n i 1)) arr (recur (inc j) (if (> (nth arr j :nf) (nth arr (inc j) :nf)) (assoc arr j (nth arr (inc j) :nf) (inc j) (nth arr j :nf)) arr)))))))))"; + let a = run_last(&mut eval, &format!("({fast} {init})")); + let b = run_last(&mut eval, &format!("({slow} {init})")); + assert_eq!(a, b); + let v: Vec = a + .trim_matches(|c| c == '[' || c == ']') + .split_whitespace() + .map(|n| n.parse().unwrap()) + .collect(); + assert_eq!(v.len(), 50); + assert!(v.windows(2).all(|w| w[0] <= w[1]), "{a}"); +} + +#[test] +fn vec_loop_preserves_entry() { + let mut eval = boot(); + // The entry vector is never mutated... + assert_eq!( + run_last( + &mut eval, + "(let [v0 [3 1 2]] [(loop [j 0 arr v0] (if (>= j 2) arr (recur (inc j) (if (> (nth arr j) (nth arr (inc j))) (assoc arr j (nth arr (inc j)) (inc j) (nth arr j)) arr)))) v0])" + ), + "[[1 2 3] [3 1 2]]" + ); + // ...and the threaded result never aliases its identity. + assert_eq!( + run_last( + &mut eval, + "(let [v0 [3 1 2]] (identical? v0 (loop [j 0 arr v0] (if (>= j 1) arr (recur (inc j) (assoc arr j 9))))))" + ), + "false" + ); +} + +#[test] +fn vec_loop_bail_runs_effects_once() { + let mut eval = boot(); + // Like `map_loop_bail_runs_effects_once`, over a threaded + // vector (the root-write bail resumes with the vector intact). + assert_eq!( + run_last( + &mut eval, + "(let [a (atom 0)] (loop [i 0 v [9 9 9]] (if (< i 3) (do (swap! a inc) (def vec-loop-bail-pin i) (recur (inc i) (assoc v i i))) [(nth v 2) @a])))" + ), + "[2 3]" + ); +} + +#[test] +fn vec_loop_bails() { + let mut eval = boot(); + // Out-of-bounds assoc: the fast path bails and the slow path + // throws exactly (past-the-end, where not even `assoc` appends). + let oob_fast = "(loop [i 0 v [1 2]] (if (< i 5) (recur (inc i) (assoc v 9 9)) v))"; + let oob_slow = "(loop [i 0 v (assoc (assoc [1 2] 0 :k) 0 1)] (if (< i 5) (recur (inc i) (assoc v 9 9)) v))"; + assert_eq!(uncaught(&mut eval, oob_fast), uncaught(&mut eval, oob_slow)); + // Out-of-bounds `nth` in the condition bails to the exact throw. + let nth_fast = + "(loop [i 0 v [1 2]] (if (< i 5) (recur (inc i) (if (> (nth v 9) 0) (assoc v 0 9) v)) v))"; + let nth_slow = "(loop [i 0 v [1 2]] (if (< i 5) (recur (inc i) (if (> (nth v 9 -1) 0) (assoc v 0 9) v)) v))"; + // The slow shape reads the not-found default (no throw); the + // fast shape bails and the interpreter throws exactly. + assert_eq!(run_last(&mut eval, nth_slow), "[1 2]"); + assert!(uncaught(&mut eval, nth_fast).contains("IndexOutOfBounds")); + // Overflowing indexes throw `long overflow` both ways. + let ovf = + "(loop [i 0 v [1 2]] (if (< i 2) (recur (inc i) (assoc v (+ 9223372036854775807 1) 9)) v))"; + assert!(uncaught(&mut eval, ovf).contains("long overflow")); + // Redefined roots decline to the interpreter (which observes + // the redefinition). + assert_eq!( + run_last( + &mut eval, + "(with-redefs [assoc (fn [c & _] c)] (loop [i 0 v [1 2]] (if (< i 3) (recur (inc i) (assoc v i 9)) v)))" + ), + "[1 2]" + ); + assert_eq!( + run_last( + &mut eval, + "(with-redefs [nth (fn [& _] 0)] (loop [i 0 v [5 6]] (if (< i 2) (recur (inc i) (if (> (nth v i) 0) (assoc v i 9) v)) v)))" + ), + "[5 6]" + ); + // Nested `assoc` chains decline (same values, slow path). + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 v [0 0]] (if (< i 1) (recur (inc i) (assoc (assoc v 0 1) 1 2)) v))" + ), + "[1 2]" + ); + // Generic vectors decline (same values, slow path). + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 v [:a :b]] (if (< i 2) (recur (inc i) (assoc v i i)) v))" + ), + "[0 1]" + ); +} + +#[test] +fn vec_loop_decline_pins() { + let mut eval = boot(); + // Non-matching vector bases decline (slow path decides). + assert_eq!( + run_last( + &mut eval, + "(let [o [9]] (loop [v [1] i 0] (if (< i 1) (recur o (inc i)) v)))" + ), + "[9]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur i (inc i)) v))" + ), + "0" + ); + // Non-matching `assoc` shapes decline. + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur ((fn [x] x) v) (inc i)) v))" + ), + "[1]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur (conj v 2) (inc i)) v))" + ), + "[1 2]" + ); + assert_eq!( + uncaught( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur (assoc v) (inc i)) v))" + ), + "clojure.lang.ArityException | Wrong number of args (1) passed to: clojure.core/assoc" + ); + // Non-matching `assoc` operands decline. + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur (assoc v 0 \"s\") (inc i)) v))" + ), + "[\"s\"]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur (assoc v 0 ((fn [c k] (nth c k)) v 0)) (inc i)) v))" + ), + "[1]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur (assoc v 0 (get v 0)) (inc i)) v))" + ), + "[1]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [1] i 0] (if (< i 1) (recur (assoc v 0 (nth [9] 0)) (inc i)) v))" + ), + "[9]" + ); + // Cross-base reads decline (each step sees one base). + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 a [0] b [7]] (if (< i 1) (recur (inc i) (assoc a 0 (nth b 0)) b) a))" + ), + "[7]" + ); + // Mixed-base `if` branches decline. + assert_eq!( + run_last( + &mut eval, + "(loop [i 0 a [0] b [0]] (if (< i 1) (recur (inc i) (if (> i 0) (assoc a 0 9) b) b) a))" + ), + "[0]" + ); + // Pass-only steps need no root guard. + assert_eq!( + run_last( + &mut eval, + "(loop [v [5] i 0] (if (< i 2) (recur (if (< i 9) v v) (inc i)) v))" + ), + "[5]" + ); + // A natively-rooted `nth` stand-in fails the display check + // (declines; the slow path answers without calling it). + assert_eq!( + run_last( + &mut eval, + "(with-redefs [nth first] (loop [v [5] i 9] (if (< i 2) (recur (assoc v 0 (nth v 0)) (inc i)) :exited)))" + ), + ":exited" + ); +} + +#[test] +fn vec_loop_cond_pins() { + let mut eval = boot(); + // Indirect and exotic `if` conditions decline. + assert_eq!( + run_last( + &mut eval, + "(loop [v [0] i 0] (if (< i 2) (recur (if (odd? i) (assoc v 0 9) v) (inc i)) v))" + ), + "[9]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [0] i 0] (if (< i 1) (recur (if ((fn [a] a) i) (assoc v 0 9) v) (inc i)) v))" + ), + "[9]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [0] i 0] (if (< i 1) (recur (if 1 (assoc v 0 9) v) (inc i)) v))" + ), + "[9]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [0] i 0] (if (< i 1) (recur (if (+ i 1) (assoc v 0 9) v) (inc i)) v))" + ), + "[9]" + ); + assert_eq!( + run_last( + &mut eval, + "(loop [v [0] i 0] (if (< i 1) (recur (if (< i) (assoc v 0 9) v) (inc i)) v))" + ), + "[9]" + ); + // Every comparison threads through `if` steps. + for (cond, want) in [ + ("(< (nth v 0) i)", "[9]"), + ("(<= (nth v 0) i)", "[9]"), + ("(>= (nth v 0) i)", "[0]"), + ("(= (nth v 0) i)", "[0]"), + ] { + assert_eq!( + run_last( + &mut eval, + &format!( + "(loop [v [0] i 1] (if (< i 2) (recur (if {cond} (assoc v 0 9) v) (inc i)) v))" + ) + ), + want, + "{cond}" + ); + } +} diff --git a/rust/crates/clj-value/src/seq.rs b/rust/crates/clj-value/src/seq.rs index 5ffe5e22f2..52e96ee4db 100644 --- a/rust/crates/clj-value/src/seq.rs +++ b/rust/crates/clj-value/src/seq.rs @@ -989,6 +989,41 @@ impl RangeValue { Some((head, next)) } + /// The head, nil-punning done ranges (for `first` — no chunk + /// step needed for one head). + #[must_use] + pub fn head_or_nil(&self) -> Value { + if self.done() { + Value::Nil + } else { + self.cur.value() + } + } + + /// Step one head in place (for chunked stepping — no per-element + /// `end`/`step` clones): `None` when done, else the head plus + /// whether the walk continues past it. A successful step advances + /// `cur`, decrements `count`, and drops `meta` (like + /// [`RangeValue::stepped`]); a failed advance (checked-`Int` + /// overflow, inexact decimal pair) leaves `self` untouched and + /// reports the head as final — callers check + /// [`RangeValue::step_inexact`] and raise there. + pub fn next_head(&mut self) -> Option<(Value, bool)> { + if self.done() { + return None; + } + let head = self.cur.value(); + match self.advance() { + Some(nxt) => { + self.cur = nxt; + self.count = self.count.map(|c| c - 1); + self.meta = None; + Some((head, true)) + } + None => Some((head, false)), + } + } + /// Call `f` per item (pure walk; infinite ranges never return, /// like the JVM's `hashCode` walk; a `long overflow` ends the /// walk early — the `hash` fn realizes first and reports it). diff --git a/rust/crates/clj-value/src/transient.rs b/rust/crates/clj-value/src/transient.rs index c3451b7c8c..5bd3c01a09 100644 --- a/rust/crates/clj-value/src/transient.rs +++ b/rust/crates/clj-value/src/transient.rs @@ -104,8 +104,8 @@ impl TransientVector { /// A transient over `v`'s items. #[must_use] pub fn from_vector(v: &Vector) -> Self { - let items = match v.packed_slice() { - Some(ints) => TransientItems::Packed(ints.to_vec()), + let items = match v.packed_ints() { + Some(ints) => TransientItems::Packed(ints), None => TransientItems::Generic(v.iter().collect()), }; Self { items, valid: true } diff --git a/rust/crates/clj-value/src/value.rs b/rust/crates/clj-value/src/value.rs index 3c63cfd5a5..b69524aff4 100644 --- a/rust/crates/clj-value/src/value.rs +++ b/rust/crates/clj-value/src/value.rs @@ -245,25 +245,209 @@ pub struct Vector { id: u64, } -/// Vector storage: the generic trie, or a shared packed `i64` run. +/// Vector storage: the generic trie, or packed `i64` chunks. /// Packing is decided at construction (`from_vec` scans, `from_ints` -/// and packed transients skip the scan); `push_back` of an `Int` -/// onto owned packed storage stays packed, anything else unpacks -/// (one-time cost, then generic speed). Reads always stay packed. +/// and packed transients skip the scan); `Int` mutating ops stay +/// packed at any size (one chunk copied per op), foreign values +/// unpack once. Reads always stay packed. #[derive(Clone, Debug)] enum VectorInner { Generic(RpdsVector), - Packed(Arc>), + Packed(PackedInts), } -/// Longest packed `i64` run (#227): a shared packed update clones -/// the whole run (`Arc::make_mut`), and loop-threaded vectors are -/// shared at update time (the old binding is alive while the new -/// value is computed), so an unbounded run turns `conj`/`assoc` -/// chains quadratic. Past this length `Int` vectors ride the -/// generic trie (log-time shared updates); at or below it a shared -/// update clones at most 8KB. -const MAX_PACKED_LEN: usize = 1024; +/// Packed tail-chunk capacity: mutating ops copy at most one chunk +/// (512 bytes), while freezes append one chunk in O(log n). +/// (Chunked packing instead of #234's single-run cap — see #240 +/// for the reconcile/unify follow-up with proxy comparisons.) +const PACKED_CHUNK: usize = 64; + +/// Frozen full chunks of a packed int run (each exactly +/// [`PACKED_CHUNK`] items) in an rpds spine, so appends and chunk +/// swaps stay logarithmic no matter how big the run grows. +type PackedChunks = rpds::VectorSync>>; + +/// An all-`Int` vector as frozen [`PACKED_CHUNK`]-item chunks plus a +/// tail chunk (len ≤ `PACKED_CHUNK`): `conj`/`assoc`/`pop` copy at +/// most one chunk — O(1) persistent updates (a flat run copies the +/// whole run per op when shared; the generic trie path-copies plus +/// one `Arc` per leaf). Indexing divides once, iteration walks +/// chunks front to back, density stays ~8 bytes/element, and old +/// versions share frozen chunks plus the pre-mutation tail. +/// +/// Invariants: every frozen chunk has len exactly `PACKED_CHUNK`; +/// `len = chunks.len() * PACKED_CHUNK + tail.len()`. +#[derive(Clone, Debug)] +struct PackedInts { + chunks: PackedChunks, + tail: Arc>, +} + +impl PackedInts { + /// The element count. + fn len(&self) -> usize { + self.chunks.len() * PACKED_CHUNK + self.tail.len() + } + + /// The item at `index` (`None` past the end). + fn get(&self, index: usize) -> Option { + let frozen = self.chunks.len() * PACKED_CHUNK; + if index < frozen { + let (c, o) = (index / PACKED_CHUNK, index % PACKED_CHUNK); + self.chunks + .get(c) + .expect("chunk index computed below len") + .get(o) + .map(|n| Value::Int(*n)) + } else { + self.tail.get(index - frozen).map(|n| Value::Int(*n)) + } + } + + /// Append in place (always stays packed for `Int`s: a tail + /// push copies at most one chunk, a full tail freezes in O(log + /// n)). The `&mut` form needs no placeholder swap for owned + /// [`push_back`](Vector::push_back) (like the trie's + /// `push_back_mut`), and loop threading owns its `PackedInts` + /// behind `&mut`. + fn push_back_mut(&mut self, n: i64) { + if self.tail.len() < PACKED_CHUNK { + Arc::make_mut(&mut self.tail).push(n); + } else { + let full = std::mem::replace(&mut self.tail, Arc::new(vec![n])); + self.chunks.push_back_mut(full); + } + } + + /// Loop-threaded replace (see `Evaluator::try_int_loop`): like + /// [`PackedInts::set_nth`], but uniquely-owned chunks update in + /// place (a shared chunk detaches on first touch, so the entry + /// version stays pristine for bailout) and the spine never + /// grows. `false` is out of bounds (the slow path throws). + fn set_nth_threaded(&mut self, index: usize, n: i64) -> bool { + let frozen = self.chunks.len() * PACKED_CHUNK; + if index < frozen { + // Below `frozen` the chunk and slot always exist (like + // [`PackedInts::get`]: the index math bounds `c`, and + // frozen chunks hold exactly `PACKED_CHUNK` items). + let (c, o) = (index / PACKED_CHUNK, index % PACKED_CHUNK); + let chunk = self + .chunks + .get_mut(c) + .expect("chunk index computed below len"); + let slot = Arc::make_mut(chunk) + .get_mut(o) + .expect("frozen chunk is full"); + *slot = n; + true + } else { + let o = index - frozen; + if o >= self.tail.len() { + return false; + } + Arc::make_mut(&mut self.tail)[o] = n; + true + } + } + + /// The raw `i64` at `index` (`None` past the end — for loop + /// threading, which compares tagless). + fn nth_int(&self, index: usize) -> Option { + let frozen = self.chunks.len() * PACKED_CHUNK; + if index < frozen { + let (c, o) = (index / PACKED_CHUNK, index % PACKED_CHUNK); + self.chunks.get(c)?.get(o).copied() + } else { + self.tail.get(index - frozen).copied() + } + } + + /// Replace the item at `index` (`None` when out of bounds; + /// copies one chunk). + #[must_use] + fn set_nth(&self, index: usize, n: i64) -> Option { + let frozen = self.chunks.len() * PACKED_CHUNK; + if index < frozen { + let (c, o) = (index / PACKED_CHUNK, index % PACKED_CHUNK); + let chunk = self.chunks.get(c).expect("chunk index computed below len"); + let mut chunk = Arc::clone(chunk); + Arc::make_mut(&mut chunk)[o] = n; + self.chunks.set(c, chunk).map(|chunks| Self { + chunks, + tail: Arc::clone(&self.tail), + }) + } else { + let o = index - frozen; + if o >= self.tail.len() { + return None; + } + let mut tail = Arc::clone(&self.tail); + Arc::make_mut(&mut tail)[o] = n; + Some(Self { + chunks: self.chunks.clone(), + tail, + }) + } + } + + /// Without the last item (`None` when empty; emptying the tail + /// unfreezes the last chunk). + #[must_use] + fn drop_last(&self) -> Option { + if !self.tail.is_empty() { + let mut tail = Arc::clone(&self.tail); + Arc::make_mut(&mut tail).pop(); + Some(Self { + chunks: self.chunks.clone(), + tail, + }) + } else if !self.chunks.is_empty() { + let last = self.chunks.len() - 1; + let chunk = self.chunks.get(last).expect("last chunk exists"); + let tail = chunk[..PACKED_CHUNK - 1].to_vec(); + self.chunks.drop_last().map(|chunks| Self { + chunks, + tail: Arc::new(tail), + }) + } else { + None + } + } + + /// Build from `i64`s in order (single-chunk inputs move + /// zero-copy, so small transient freezes stay free). + #[must_use] + fn from_ints(ints: Vec) -> Self { + if ints.len() <= PACKED_CHUNK { + return Self { + chunks: PackedChunks::new_sync(), + tail: Arc::new(ints), + }; + } + let tail_len = ints.len() % PACKED_CHUNK; + let frozen = ints.len() - tail_len; + let mut chunks = PackedChunks::new_sync(); + for piece in ints[..frozen].chunks_exact(PACKED_CHUNK) { + chunks.push_back_mut(Arc::new(piece.to_vec())); + } + Self { + chunks, + tail: Arc::new(ints[frozen..].to_vec()), + } + } + + /// All items in order (one pass — for unpacking and transient + /// materialization of multi-chunk runs). + #[must_use] + fn collect_all(&self) -> Vec { + let mut out = Vec::with_capacity(self.len()); + for chunk in self.chunks.iter() { + out.extend_from_slice(chunk); + } + out.extend_from_slice(&self.tail); + out + } +} /// Owned-value vector iteration (packed ints box on read). Neither /// `Clone` nor `Debug`: the generic arm wraps rpds's iterator, @@ -274,7 +458,81 @@ pub struct VectorIter<'a> { enum VectorIterInner<'a> { Generic(rpds::vector::Iter<'a, Value, archery::ArcTK>), - Packed(std::slice::Iter<'a, i64>), + Packed(PackedIter<'a>), +} + +/// Front-to-back packed iteration over chunked runs: frozen chunks +/// stream front to back (fast path), `next_back` indexes (rare +/// path); yielded counts keep the sides from crossing. +struct PackedIter<'a> { + ints: &'a PackedInts, + chunks: rpds::vector::Iter<'a, Arc>, archery::ArcTK>, + front: std::slice::Iter<'a, i64>, + tail: Option>, + yielded_front: usize, + yielded_back: usize, +} + +impl<'a> PackedIter<'a> { + fn new(ints: &'a PackedInts) -> Self { + Self { + ints, + chunks: ints.chunks.iter(), + front: [].iter(), + tail: Some(ints.tail.iter()), + yielded_front: 0, + yielded_back: 0, + } + } + + fn remaining(&self) -> usize { + self.ints + .len() + .saturating_sub(self.yielded_front + self.yielded_back) + } +} + +impl Iterator for PackedIter<'_> { + type Item = Value; + + fn next(&mut self) -> Option { + if self.remaining() == 0 { + return None; + } + loop { + if let Some(n) = self.front.next() { + self.yielded_front += 1; + return Some(Value::Int(*n)); + } + match self.chunks.next() { + Some(chunk) => self.front = chunk.iter(), + None => { + // Chunks spent: the tail holds the rest (items + // remain, so it is still pending — an empty tail + // with spent chunks means fully yielded, which + // the `remaining` gate above already excluded). + let tail = self.tail.take().expect("tail pending while items remain"); + self.front = tail; + } + } + } + } + + fn size_hint(&self) -> (usize, Option) { + let rest = self.remaining(); + (rest, Some(rest)) + } +} + +impl DoubleEndedIterator for PackedIter<'_> { + fn next_back(&mut self) -> Option { + if self.remaining() == 0 { + return None; + } + let index = self.ints.len() - self.yielded_back - 1; + self.yielded_back += 1; + self.ints.get(index) + } } impl Iterator for VectorIter<'_> { @@ -283,7 +541,7 @@ impl Iterator for VectorIter<'_> { fn next(&mut self) -> Option { match &mut self.inner { VectorIterInner::Generic(it) => it.next().cloned(), - VectorIterInner::Packed(it) => it.next().map(|n| Value::Int(*n)), + VectorIterInner::Packed(it) => it.next(), } } @@ -299,7 +557,7 @@ impl DoubleEndedIterator for VectorIter<'_> { fn next_back(&mut self) -> Option { match &mut self.inner { VectorIterInner::Generic(it) => it.next_back().cloned(), - VectorIterInner::Packed(it) => it.next_back().map(|n| Value::Int(*n)), + VectorIterInner::Packed(it) => it.next_back(), } } } @@ -587,13 +845,24 @@ impl List { } } +/// Copy packed chunks into a fresh trie (for the first foreign +/// value into an all-`Int` vector — one-time cost, then generic +/// speed). +fn unpack_packed(packed: &PackedInts) -> RpdsVector { + let mut out = RpdsVector::new_sync(); + for n in packed.collect_all() { + out.push_back_mut(Value::Int(n)); + } + out +} + impl Vector { /// An empty vector (packed: `conj` chains over `Int`s stay /// packed from the first push). #[must_use] pub fn new_sync() -> Self { Self { - inner: VectorInner::Packed(Arc::new(Vec::new())), + inner: VectorInner::Packed(PackedInts::from_ints(Vec::new())), meta: None, id: next_vector_id(), } @@ -601,7 +870,7 @@ impl Vector { /// Build a vector from items in order (packed when every item /// is an `Int` — one pass, completing generically at the first - /// foreign value or past `MAX_PACKED_LEN`). + /// foreign value). #[must_use] pub fn from_vec(items: Vec) -> Self { let mut items = items.into_iter(); @@ -632,43 +901,29 @@ impl Vector { /// Build a packed vector from `i64`s in order (no scan — for /// callers that already know, like ranges and packed - /// transients). Past `MAX_PACKED_LEN` the vector rides the - /// generic trie instead (a long packed run would clone per - /// shared update — #227). + /// transients). #[must_use] pub fn from_ints(ints: Vec) -> Self { - if ints.len() > MAX_PACKED_LEN { - let mut trie = RpdsVector::new_sync(); - for n in ints { - trie.push_back_mut(Value::Int(n)); - } - return Self { - inner: VectorInner::Generic(trie), - meta: None, - id: next_vector_id(), - }; - } Self { - inner: VectorInner::Packed(Arc::new(ints)), + inner: VectorInner::Packed(PackedInts::from_ints(ints)), meta: None, id: next_vector_id(), } } /// Whether this vector rides on packed `i64` storage (8 bytes - /// per element instead of one 72-byte `Value` per trie leaf). + /// per element instead of one boxed `Value` per trie leaf). #[must_use] pub fn is_packed(&self) -> bool { matches!(self.inner, VectorInner::Packed(_)) } - /// The packed `i64` run when packed (`None` for the generic - /// trie) — for transients that freeze back to packed storage - /// without re-scanning. - #[must_use] - pub fn packed_slice(&self) -> Option<&[i64]> { + /// All packed `i64`s in order (`None` for the generic trie) — + /// for transients that freeze back to packed storage without + /// re-scanning. + pub(crate) fn packed_ints(&self) -> Option> { match &self.inner { - VectorInner::Packed(ints) => Some(ints), + VectorInner::Packed(packed) => Some(packed.collect_all()), VectorInner::Generic(_) => None, } } @@ -680,18 +935,13 @@ impl Vector { // empty trie `Arc::new`s its root, one alloc per `conj` // before #227), so uniquely-owned `conj` chains mutate in // place while versions shared with other handles still - // path-copy. Packed storage meeting a foreign value — or an - // `Int` at `MAX_PACKED_LEN` — rebuilds once (unpack), like + // path-copy. Packed storage meeting a foreign value + // rebuilds once (unpack), like // [`set_nth_owned`](Self::set_nth_owned). - let unpack = match (&self.inner, &v) { - (VectorInner::Packed(ints), Value::Int(_)) => ints.len() >= MAX_PACKED_LEN, - (VectorInner::Packed(_), _) => true, - (VectorInner::Generic(_), _) => false, - }; - if unpack { + if matches!(&self.inner, VectorInner::Packed(_)) && !matches!(v, Value::Int(_)) { let mut out = RpdsVector::new_sync(); - for n in self.packed_slice().expect("guarded packed above") { - out.push_back_mut(Value::Int(*n)); + for n in self.packed_ints().expect("guarded packed above") { + out.push_back_mut(Value::Int(n)); } out.push_back_mut(v); self.inner = VectorInner::Generic(out); @@ -707,16 +957,14 @@ impl Vector { // path-copies nodes shared with *other* versions). trie.push_back_mut(v); } - VectorInner::Packed(ints) => { - // Owned packed storage mutates in place when - // unique; `make_mut` copies the 8-byte run when - // shared — at most `MAX_PACKED_LEN` ints, since - // longer runs unpack above (a shared big run would - // clone per `conj`, turning chains quadratic — #227). - // `Int`-only below the cap here (foreign values and - // at-cap pushes unpacked above). + VectorInner::Packed(packed) => { + // Stays packed at any size (one chunk copied per + // push — the flat run this replaced copied the + // whole run when shared, making build loops + // quadratic: 200K pushes took 44s). `Int`-only + // here (foreign values unpacked above). if let Value::Int(n) = v { - Arc::make_mut(ints).push(n); + packed.push_back_mut(n); } } } @@ -743,53 +991,91 @@ impl Vector { /// only nodes shared with *other* versions, so threading one /// accumulator through an `assoc` chain mutates in place after /// the first step instead of path-copying per step. Packed `Int` - /// storage mutates through `Arc::make_mut` (in place when - /// unique, at most `MAX_PACKED_LEN` ints when shared — - /// [`from_ints`](Self::from_ints) and - /// [`push_back`](Self::push_back) never build longer runs); a - /// foreign value unpacks once, like [`push_back`](Self::push_back). + /// storage copies at most one chunk per op; a foreign value + /// unpacks once, like [`push_back`](Self::push_back). #[must_use] pub fn set_nth_owned(mut self, index: usize, v: Value) -> Option { // Packed storage meeting a foreign value rebuilds once // (unpack); every other shape mutates through `&mut`, so // uniquely-owned chains stay allocation-free (no // `mem::replace` placeholder — even an empty trie would - // `Arc::new` its root). - if matches!(&self.inner, VectorInner::Packed(_)) && !matches!(v, Value::Int(_)) { - if index >= self.len() { - return None; - } - let mut out = RpdsVector::new_sync(); - for n in self.packed_slice().expect("guarded packed above") { - out.push_back_mut(Value::Int(*n)); - } - // `index` is below the length (guarded above over these - // same items), so this cannot fail. - assert!(out.set_mut(index, v)); - self.inner = VectorInner::Generic(out); - self.id = next_vector_id(); - return Some(self); - } - match &mut self.inner { - VectorInner::Generic(trie) => { - if !trie.set_mut(index, v) { + // `Arc::new` its root). One match over (storage, value): + // the `Int` arm precedes the foreign arm, so each arm's + // shape is proven by position (no re-match, no dead + // `else`); the unpacked trie installs after the borrow + // ends. + let len = self.len(); + let mut unpacked = None; + match (&mut self.inner, v) { + (VectorInner::Generic(trie), w) => { + if !trie.set_mut(index, w) { return None; } } - VectorInner::Packed(ints) => { - if index >= ints.len() { + (VectorInner::Packed(packed), Value::Int(n)) => { + // One chunk copied per op. + let next = packed.set_nth(index, n)?; + *packed = next; + } + (VectorInner::Packed(packed), foreign) => { + if index >= len { return None; } - // `Int`-only here (foreign values unpacked above). - if let Value::Int(n) = v { - Arc::make_mut(ints)[index] = n; - } + let mut out = unpack_packed(packed); + // `index` is below the length (guarded above over + // these same items), so this cannot fail. + assert!(out.set_mut(index, foreign)); + unpacked = Some(out); } } + if let Some(out) = unpacked { + self.inner = VectorInner::Generic(out); + } self.id = next_vector_id(); Some(self) } + /// Detach for loop threading (see `Evaluator::try_int_loop`): + /// same storage, fresh identity — threaded versions must never + /// alias the entry version's identity (like + /// [`Map::insert_threaded`]'s sidecar detach). Chunks stay + /// shared until first touch (copy-on-write in + /// [`Vector::assoc_int_threaded`]), so the entry version stays + /// pristine for bailout. + #[must_use] + pub fn thread_entry(mut self) -> Self { + self.id = next_vector_id(); + self + } + + /// Loop-threaded int `assoc`: like [`Vector::set_nth`] with an + /// `Int` (plus the index-at-`len` append the interpreter + /// allows), but chunks update in place when uniquely owned + /// (first touch detaches from the entry version). `false` + /// declines — generic storage or past-the-end (the slow path + /// throws exactly). + pub fn assoc_int_threaded(&mut self, index: usize, n: i64) -> bool { + let VectorInner::Packed(packed) = &mut self.inner else { + return false; + }; + if index == packed.len() { + packed.push_back_mut(n); + return true; + } + packed.set_nth_threaded(index, n) + } + + /// The raw `i64` at `index` (`None` for generic storage or past + /// the end — loop threading bails, and the slow path throws + /// exactly). + #[must_use] + pub fn nth_int(&self, index: usize) -> Option { + match &self.inner { + VectorInner::Packed(packed) => packed.nth_int(index), + VectorInner::Generic(_) => None, + } + } + /// The vector without its last item (`None` when empty). /// /// The shared-storage form, for callers holding only a `&Vector`: @@ -806,10 +1092,9 @@ impl Vector { /// [`is_empty`](Self::is_empty) first when the vector must survive). /// /// The #212 pattern (`mut self` + `drop_last_mut`): packed - /// storage pops through `Arc::make_mut`, so `pop` chains over + /// storage pops copy at most one chunk, so `pop` chains over /// uniquely-owned vectors mutate in place while versions shared - /// with other handles still path-copy (at most - /// `MAX_PACKED_LEN` ints — longer runs never pack). + /// with other handles copy one chunk per pop. #[must_use] pub fn drop_last_owned(mut self) -> Option { // Through `&mut` (no `mem::replace` placeholder — even an @@ -821,11 +1106,10 @@ impl Vector { return None; } } - VectorInner::Packed(ints) => { - if ints.is_empty() { - return None; - } - Arc::make_mut(ints).pop(); + VectorInner::Packed(packed) => { + // One chunk copied per op (`None` when empty). + let next = packed.drop_last()?; + *packed = next; } } self.id = next_vector_id(); @@ -838,7 +1122,7 @@ impl Vector { VectorIter { inner: match &self.inner { VectorInner::Generic(inner) => VectorIterInner::Generic(inner.iter()), - VectorInner::Packed(ints) => VectorIterInner::Packed(ints.iter()), + VectorInner::Packed(packed) => VectorIterInner::Packed(PackedIter::new(packed)), }, } } @@ -850,7 +1134,7 @@ impl Vector { pub fn get(&self, index: usize) -> Option { match &self.inner { VectorInner::Generic(inner) => inner.get(index).cloned(), - VectorInner::Packed(ints) => ints.get(index).map(|n| Value::Int(*n)), + VectorInner::Packed(packed) => packed.get(index), } } @@ -859,7 +1143,7 @@ impl Vector { pub fn len(&self) -> usize { match &self.inner { VectorInner::Generic(inner) => inner.len(), - VectorInner::Packed(ints) => ints.len(), + VectorInner::Packed(packed) => packed.len(), } } @@ -1081,11 +1365,15 @@ impl Map { if let Some(small) = self.small.take() { return self.insert_shaped_mut(small, k, v); } - // Owned `self`: mutate the trie in place (same rationale as - // `Vector::push_back` — rpds still path-copies nodes shared - // with *other* versions, so persistence is preserved). - if self.trie().get(&k).is_some() { - self.trie_mut().insert_mut(k, v); + // Insert first, then read the size delta instead of a + // pre-`get` (one traversal + one hash per assoc, not two — + // the delta answers new/existing exactly, since + // `insert_mut` bumps `size` only for new keys). + let size = self.trie().size(); + let promote = !self.hashy && (size >= 64 || (size >= 8 && !matches!(k, Value::Keyword(_)))); + let key = k.clone(); + self.trie_mut().insert_mut(k, v); + if self.trie().size() == size { // Fresh identity only when shared: a live alias must // keep its sidecar `Arc` (see [`Map::id`]). if Arc::strong_count(&self.keys) > 1 { @@ -1093,13 +1381,66 @@ impl Map { } return; } - let size = self.trie().size(); - let promote = !self.hashy && (size >= 64 || (size >= 8 && !matches!(k, Value::Keyword(_)))); // `make_mut`: in place when unique; a header clone plus a // path copy when shared (exactly the old clone-then-push). - Arc::make_mut(&mut self.keys).push_back_mut(k.clone()); + Arc::make_mut(&mut self.keys).push_back_mut(key); + self.hashy = self.hashy || promote; + } + + /// Loop-threaded `assoc` (see `Evaluator::try_int_loop`): like + /// [`Map::insert`], but a uniquely owned sidecar grows in place + /// instead of path-copying per version. The first call detaches + /// the sidecar when shared (so the threaded versions never alias + /// the entry version's identity); intermediate versions share + /// one sidecar `Arc` (unobservable — only values escape the + /// loop). Never call on sorted maps (their sidecars stay in + /// comparator order through `sorted_assoc_map`, not here). + #[must_use] + pub fn insert_threaded(mut self, k: Value, v: Value) -> Self { + if let Some(small) = self.small.take() { + self.insert_shaped_mut(small, k, v); + return self; + } + // Detach first (even for existing keys): the entry frame's + // copy shares this sidecar, and the threaded versions must + // not alias its identity. + if Arc::get_mut(&mut self.keys).is_none() { + self.keys = Arc::new((*self.keys).clone()); + } + let size = self.trie().size(); + let promote = !self.hashy && (size >= 64 || (size >= 8 && !matches!(k, Value::Keyword(_)))); + let key = k.clone(); self.trie_mut().insert_mut(k, v); + if self.trie().size() == size { + return self; + } + let keys = Arc::get_mut(&mut self.keys).expect("threaded sidecar detached"); + keys.push_back_mut(key); self.hashy = self.hashy || promote; + self + } + + /// A structural deep copy: the same entries (in iteration order), + /// class flags, and metadata, but fresh trie nodes and a fresh + /// sidecar — nothing structural shared. Loop threading + /// (`insert_threaded`) runs on the copy while a shallow clone + /// stays pristine for bailout; shaped maps return a shallow + /// clone instead (threading copies on the first write — the + /// entry's storage is shared — and mutates only consumed + /// versions after, so sharing stays safe). + #[must_use] + pub fn deep_clone(&self) -> Self { + if self.small.is_some() { + return self.clone(); + } + let mut out = new_map(); + for (k, v) in self.iter() { + out = out.insert(k.clone(), v.clone()); + } + out.meta = self.meta.clone(); + out.hashy = self.hashy; + out.sort = self.sort.clone(); + out } /// The shaped arm of [`Map::insert_mut`] (linear scan — the `==` diff --git a/rust/crates/clj-value/tests/map_threaded.rs b/rust/crates/clj-value/tests/map_threaded.rs new file mode 100644 index 0000000000..913266178d --- /dev/null +++ b/rust/crates/clj-value/tests/map_threaded.rs @@ -0,0 +1,121 @@ +// Copyright (c) Rich Hickey. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. +// You must not remove this notice, or any other, from this software. + +//! Loop-threaded map pins (`Map::insert_threaded` / `deep_clone`): +//! threaded assoc chains equal regular `insert` chains entry for +//! entry, in iteration order, while the entry version stays intact +//! with a distinct identity. + +use clj_value::{Map, Value, new_map}; + +fn int_map(n: i64) -> Map { + let mut m = new_map(); + for i in 0..n { + m = m.insert(Value::Int(i), Value::Int(i * 10)); + } + m +} + +fn entries(m: &Map) -> Vec<(Value, Value)> { + m.iter().map(|(k, v)| (k.clone(), v.clone())).collect() +} + +/// Threaded chains equal `insert` chains (values, count, order). +#[test] +fn threaded_matches_insert() { + let mut threaded = new_map().deep_clone(); + let mut plain = new_map(); + for i in 0..500 { + threaded = threaded.insert_threaded(Value::Int(i), Value::Int(i * 2)); + plain = plain.insert(Value::Int(i), Value::Int(i * 2)); + } + assert_eq!(entries(&threaded), entries(&plain)); + assert_eq!(threaded.get(&Value::Int(499)), Some(&Value::Int(998))); + assert_eq!(threaded.get(&Value::Int(500)), None); +} + +/// The entry version stays intact with a distinct identity. +#[test] +fn threaded_preserves_entry() { + let entry = int_map(100); + let entry_entries = entries(&entry); + let entry_id = entry.id(); + let mut threaded = entry.deep_clone(); + for i in 100..200 { + threaded = threaded.insert_threaded(Value::Int(i), Value::Int(i)); + } + assert_eq!(entries(&entry), entry_entries); + assert_eq!(entry.id(), entry_id); + assert_ne!(threaded.id(), entry_id); + assert_eq!(entries(&threaded).len(), 200); + assert_eq!(threaded.get(&Value::Int(50)), Some(&Value::Int(500))); +} + +/// Existing-key rounds keep values and refresh identity. +#[test] +fn threaded_existing_keys() { + let entry = int_map(50); + let entry_id = entry.id(); + let mut threaded = entry.deep_clone(); + for i in 0..50 { + threaded = threaded.insert_threaded(Value::Int(i), Value::Int(-i)); + } + assert_eq!(entries(&threaded).len(), 50); + assert_eq!(threaded.get(&Value::Int(7)), Some(&Value::Int(-7))); + assert_ne!(threaded.id(), entry_id); + assert_eq!(entry.get(&Value::Int(7)), Some(&Value::Int(70))); +} + +/// Shaped maps thread through the shaped arm (values equal, entry +/// intact). +#[test] +fn threaded_shaped() { + let entry = int_map(5); + assert!(entry.is_shaped()); + let mut threaded = entry.deep_clone(); + for i in 5..10 { + threaded = threaded.insert_threaded(Value::Int(i), Value::Int(i)); + } + assert_eq!(entries(&threaded).len(), 10); + assert_eq!(entries(&entry).len(), 5); + // Past 16 the threaded map promotes like `insert` does. + for i in 10..20 { + threaded = threaded.insert_threaded(Value::Int(i), Value::Int(i)); + } + assert!(!threaded.is_shaped()); + assert_eq!(entries(&threaded).len(), 20); + assert_eq!(threaded.get(&Value::Int(3)), Some(&Value::Int(30))); +} + +/// The first threaded assoc detaches a shared sidecar (the entry +/// version keeps its identity and entries). +#[test] +fn threaded_detaches_shared_sidecar() { + let entry = int_map(500); + let entry_id = entry.id(); + let shared = entry.clone(); + let threaded = shared.insert_threaded(Value::Int(500), Value::Int(5000)); + assert_ne!(threaded.id(), entry_id); + assert_eq!(entries(&entry).len(), 500); + assert_eq!(threaded.get(&Value::Int(500)), Some(&Value::Int(5000))); +} + +/// `deep_clone` preserves flags, metadata, and iteration order. +#[test] +fn deep_clone_preserves_shape() { + let m = int_map(100).with_sort(Some(clj_value::SortSpec::Natural)); + assert!(m.is_sorted()); + let order: Vec = m.iter().map(|(k, _)| k.clone()).collect(); + let c = m.deep_clone(); + assert!(c.is_sorted()); + assert_eq!(c.iter().map(|(k, _)| k.clone()).collect::>(), order); + assert_ne!(c.id(), m.id()); + let meta = int_map(2); + let m2 = int_map(30).with_meta(Some(meta)); + assert_eq!(entries(&m2.deep_clone()), entries(&m2)); +} diff --git a/rust/crates/clj-value/tests/packed.rs b/rust/crates/clj-value/tests/packed.rs index dff45f50bf..d029e1bb5b 100644 --- a/rust/crates/clj-value/tests/packed.rs +++ b/rust/crates/clj-value/tests/packed.rs @@ -42,6 +42,26 @@ fn empty_from_vec_is_packed() { assert!(v.is_empty()); } +/// In-bounds `Int` assoc stays packed (one chunk copied). +#[test] +fn packed_int_assoc_stays_packed() { + let v = Vector::from_ints(vec![1, 2, 3]); + let w = v.set_nth_owned(1, Value::Int(9)).expect("in bounds"); + assert!(w.is_packed()); + let back: Vec = w.iter().collect(); + assert_eq!(back, vec![Value::Int(1), Value::Int(9), Value::Int(3)]); +} + +/// Threaded entry points decline generic storage. +#[test] +fn threaded_entry_points_decline_generic() { + let v = Vector::from_vec(vec![Value::Int(1), Value::Nil]); + assert!(!v.is_packed()); + assert_eq!(v.nth_int(0), None); + let mut t = v.thread_entry(); + assert!(!t.assoc_int_threaded(0, 5)); +} + /// One non-`Int` item keeps the generic trie. #[test] fn mixed_from_vec_unpacks() { @@ -241,82 +261,245 @@ fn transient_generic_arms() { assert!(tv.pop().is_err()); } -/// Long runs never pack (#227): a shared packed update clones -/// the whole run, so past the cap `Int` vectors ride the generic -/// trie (log-time shared updates, no quadratic chains). The cap is -/// 1024 (mirrors `MAX_PACKED_LEN` in value.rs). +/// Pushing an `Int` onto a shared small packed run copies cheaply +/// and stays packed; both versions stay intact (persistence). +#[test] +fn push_back_shared_small_int_stays_packed() { + let v = Vector::from_vec(ints(10)); + assert!(v.is_packed()); + let shared = v.clone(); + let w = v.push_back(Value::Int(10)); + assert!(w.is_packed()); + assert_eq!(w.len(), 11); + assert_eq!(w.iter().collect::>(), ints(11)); + assert!(shared.is_packed()); + assert_eq!(shared.len(), 10); +} + +/// Pushing an `Int` onto a uniquely owned big packed run mutates +/// in place and stays packed. +#[test] +fn push_back_unique_big_int_stays_packed() { + let v = Vector::from_ints((0..1500).collect()); + assert!(v.is_packed()); + let w = v.push_back(Value::Int(1500)); + assert!(w.is_packed()); + assert_eq!(w.len(), 1501); + assert_eq!(w.get(1500), Some(Value::Int(1500))); + assert_eq!(w.get(0), Some(Value::Int(0))); +} + +/// Pushing an `Int` onto a shared big packed run stays packed +/// (one chunk copied, never the whole run); the old version stays +/// packed and intact. +#[test] +fn push_back_shared_big_int_stays_packed() { + let v = Vector::from_ints((0..1500).collect()); + assert!(v.is_packed()); + let shared = v.clone(); + let w = v.push_back(Value::Int(1500)); + assert!(w.is_packed()); + assert_eq!(w.len(), 1501); + assert_eq!(w.iter().collect::>(), ints(1501)); + assert!(shared.is_packed()); + assert_eq!(shared.len(), 1500); + assert_eq!(shared.iter().collect::>(), ints(1500)); +} + +/// `set_nth` of an `Int` on a big packed run stays packed (one +/// chunk copied); the old version stays packed and intact. #[test] -fn long_runs_stay_generic() { +fn set_nth_big_int_stays_packed() { + let v = Vector::from_ints((0..1500).collect()); + let w = v.set_nth(700, Value::Int(99)).unwrap(); + assert!(w.is_packed()); + assert_eq!(w.len(), 1500); + assert_eq!(w.get(700), Some(Value::Int(99))); + assert_eq!(w.get(0), Some(Value::Int(0))); + assert!(v.is_packed()); + assert_eq!(v.get(700), Some(Value::Int(700))); +} + +/// `drop_last` on a big packed run stays packed; the old version +/// stays packed and intact. +#[test] +fn drop_last_big_stays_packed() { + let v = Vector::from_ints((0..1500).collect()); + let w = v.drop_last().unwrap(); + assert!(w.is_packed()); + assert_eq!(w.len(), 1499); + assert_eq!(w.iter().collect::>(), ints(1499)); + assert!(v.is_packed()); + assert_eq!(v.len(), 1500); +} + +/// Freezing: pushing past a full 64-chunk keeps every item in +/// order (frozen chunk + new tail), and `get`/`len` span both. +#[test] +fn push_back_freezes_full_tail_into_chunks() { + let mut v = Vector::from_ints((0..64).collect()); + assert_eq!(v.len(), 64); + v = v.push_back(Value::Int(64)); + assert!(v.is_packed()); + assert_eq!(v.len(), 65); + assert_eq!(v.iter().collect::>(), ints(65)); + assert_eq!(v.get(0), Some(Value::Int(0))); + assert_eq!(v.get(63), Some(Value::Int(63))); + assert_eq!(v.get(64), Some(Value::Int(64))); + assert_eq!(v.get(65), None); +} + +/// Exact multiples leave an empty tail (two frozen chunks here); +/// dropping unfreezes the last chunk back into the tail. +#[test] +fn exact_multiple_runs_unfreeze_on_drop() { + let v = Vector::from_ints((0..128).collect()); + assert!(v.is_packed()); + assert_eq!(v.len(), 128); + assert_eq!(v.iter().collect::>(), ints(128)); + let w = v.drop_last().unwrap(); + assert!(w.is_packed()); + assert_eq!(w.len(), 127); + assert_eq!(w.iter().collect::>(), ints(127)); + assert_eq!(v.len(), 128); +} + +/// `set_nth` inside a frozen chunk copies just that chunk: later +/// chunks, the tail, and the old version are untouched. +#[test] +fn set_nth_frozen_region_shares_the_rest() { + let v = Vector::from_ints((0..200).collect()); + let w = v.set_nth(10, Value::Int(99)).unwrap(); + assert!(w.is_packed()); + assert_eq!(w.get(10), Some(Value::Int(99))); + assert_eq!(w.get(100), Some(Value::Int(100))); + assert_eq!(w.get(199), Some(Value::Int(199))); + assert_eq!(v.get(10), Some(Value::Int(10))); + // Out of bounds stays `None` on multi-chunk runs. + assert_eq!(v.set_nth(200, Value::Int(1)), None); +} + +/// Forward iteration streams chunk after chunk; `next_back` +/// indexes from the end; mixed ends never cross or double-yield. +#[test] +fn packed_iter_walks_chunks_both_ends() { + let v = Vector::from_ints((0..200).collect()); + let mut it = v.iter(); + assert_eq!(it.size_hint(), (200, Some(200))); + assert_eq!(it.next(), Some(Value::Int(0))); + assert_eq!(it.next_back(), Some(Value::Int(199))); + assert_eq!(it.next(), Some(Value::Int(1))); + assert_eq!(it.next_back(), Some(Value::Int(198))); + assert_eq!(it.size_hint(), (196, Some(196))); + let rest: Vec<_> = it.collect(); + assert_eq!(rest.len(), 196); + assert_eq!(rest[0], Value::Int(2)); + assert_eq!(rest[195], Value::Int(197)); + // Fully consumed from both sides ends cleanly. + let mut it = v.iter(); + for _ in 0..100 { + it.next(); + it.next_back(); + } + assert_eq!(it.next(), None); + assert_eq!(it.next_back(), None); +} + +/// A foreign value into a multi-chunk run unpacks once to the +/// trie; the old version stays packed and intact. +#[test] +fn foreign_push_on_multichunk_unpacks_once() { + let v = Vector::from_ints((0..200).collect()); + let w = v.clone().push_back(Value::Nil); + assert!(!w.is_packed()); + assert_eq!(w.len(), 201); + assert_eq!(w.get(200), Some(Value::Nil)); + assert_eq!(w.get(199), Some(Value::Int(199))); + assert!(v.is_packed()); + let s = v.set_nth(150, Value::Nil).unwrap(); + assert!(!s.is_packed()); + assert_eq!(s.get(150), Some(Value::Nil)); + assert_eq!(s.get(0), Some(Value::Int(0))); +} + +/// Metadata rides alongside packed storage (vectors stay `IObj`). +#[test] +fn meta_survives_packing() { + let v = Vector::from_vec(ints(10)); + assert!(v.is_packed()); + assert!(v.meta().is_none()); + let m = clj_value::new_map(); + let vm = v.with_meta(Some(m)); + assert!(vm.is_packed()); + assert!(vm.meta().is_some()); + let back: Vec = vm.iter().collect(); + assert_eq!(back, ints(10)); +} +/// Long runs stay packed (chunked packing supersedes #234's +/// 1024 cap: updates touch one 64-item chunk, never the whole run, +/// so long `Int` vectors keep packed storage with no quadratic +/// chains — same values, every version intact). +#[test] +fn long_runs_stay_packed() { let v = Vector::from_vec(ints(1024)); assert!(v.is_packed()); let v = Vector::from_vec(ints(1025)); - assert!(!v.is_packed()); + assert!(v.is_packed()); let back: Vec = v.iter().collect(); assert_eq!(back, ints(1025)); let w = Vector::from_ints((0..2000).collect()); - assert!(!w.is_packed()); + assert!(w.is_packed()); assert_eq!(w.len(), 2000); assert_eq!(w.get(1999), Some(Value::Int(1999))); } -/// `push_back` past the cap unpacks once; both versions stay -/// correct (persistence holds across the unpack). +/// `push_back` past the old cap stays packed (one chunk copied); +/// shared pushes keep every version intact (persistence holds +/// across the chunk detach). #[test] -fn push_back_past_cap_unpacks_once() { - let mut v = Vector::from_vec(ints(1024)); +fn push_back_past_old_cap_stays_packed() { + let v = Vector::from_vec(ints(1024)); + assert!(v.is_packed()); + let v = v.push_back(Value::Int(1024)); assert!(v.is_packed()); - v = v.push_back(Value::Int(1024)); - assert!(!v.is_packed()); assert_eq!(v.len(), 1025); let back: Vec = v.iter().collect(); assert_eq!(back, ints(1025)); - // Shared updates on the long vector keep every version intact. + // Shared pushes keep every version intact. let shared = v.clone(); let w = v.push_back(Value::Int(1025)); + assert!(w.is_packed()); assert_eq!(w.len(), 1026); assert_eq!(shared.len(), 1025); assert_eq!(shared.get(1024), Some(Value::Int(1024))); assert_eq!(w.get(1025), Some(Value::Int(1025))); } -/// `assoc` on a long `Int` vector keeps every version intact (the -/// generic trie path-copies; no whole-run clone per update). +/// `assoc` on a long `Int` vector keeps every version intact (one +/// chunk detaches per update; no whole-run clone). #[test] fn set_nth_long_int_vector_persists() { let v = Vector::from_vec(ints(5000)); - assert!(!v.is_packed()); + assert!(v.is_packed()); let w = v.set_nth(4999, Value::Int(7)).unwrap(); + assert!(w.is_packed()); assert_eq!(w.get(4999), Some(Value::Int(7))); assert_eq!(v.get(4999), Some(Value::Int(4999))); assert_eq!(w.len(), 5000); } -/// `persistent!` of a long packed transient freezes generically -/// with every item intact. +/// `persistent!` of a long packed transient freezes packed with +/// every item intact. #[test] -fn transient_persistent_long_freezes_generic() { +fn transient_persistent_long_stays_packed() { let mut tv = TransientVector::new(); for i in 0..2000 { tv.conj(Value::Int(i)).unwrap(); } assert!(tv.is_packed()); let frozen = tv.persistent().unwrap(); - assert!(!frozen.is_packed()); + assert!(frozen.is_packed()); assert_eq!(frozen.len(), 2000); let back: Vec = frozen.iter().collect(); assert_eq!(back, ints(2000)); } - -/// Metadata rides alongside packed storage (vectors stay `IObj`). -#[test] -fn meta_survives_packing() { - let v = Vector::from_vec(ints(10)); - assert!(v.is_packed()); - assert!(v.meta().is_none()); - let m = clj_value::new_map(); - let vm = v.with_meta(Some(m)); - assert!(vm.is_packed()); - assert!(vm.meta().is_some()); - let back: Vec = vm.iter().collect(); - assert_eq!(back, ints(10)); -} diff --git a/rust/crates/clj-value/tests/seq.rs b/rust/crates/clj-value/tests/seq.rs index 86fbda6a39..24b1ea10a6 100644 --- a/rust/crates/clj-value/tests/seq.rs +++ b/rust/crates/clj-value/tests/seq.rs @@ -11,7 +11,9 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use clj_value::{List, Map, Seq, Set, Value, Vector, map_entry, seq_vec}; +use clj_value::{ + List, Map, RangeNum, RangeValue, Seq, Set, Value, Vector, map_entry, new_map, seq_vec, +}; fn int(i: i64) -> Value { Value::Int(i) @@ -325,3 +327,75 @@ fn seq_vec_coerces_seqables() { assert_eq!(seq_vec(&Value::Int(1)), None); assert_eq!(seq_vec(&Value::Bool(true)), None); } + +/// `next_head` steps a long range in place: verbatim heads, the +/// count decrements, metadata drops after the first step, and the +/// walk ends with `None` (never a final `false` — long advances +/// never fail). +#[test] +fn next_head_steps_long_range_in_place() { + let mut r = RangeValue::new( + RangeNum::Int(0), + Some(RangeNum::Int(3)), + RangeNum::Int(1), + true, + Some(3), + ) + .with_meta(Some(new_map())); + assert!(r.meta().is_some()); + assert_eq!(r.next_head(), Some((Value::Int(0), true))); + assert_eq!(r.count(), Some(2)); + assert!(r.meta().is_none()); + assert_eq!(r.next_head(), Some((Value::Int(1), true))); + assert_eq!(r.next_head(), Some((Value::Int(2), true))); + assert_eq!(r.count(), Some(0)); + assert_eq!(r.next_head(), None); +} + +/// `next_head` on a done range is `None` without touching state. +#[test] +fn next_head_done_range_is_none() { + let mut r = RangeValue::new( + RangeNum::Int(3), + Some(RangeNum::Int(3)), + RangeNum::Int(1), + true, + Some(0), + ); + assert_eq!(r.next_head(), None); + assert_eq!(r.count(), Some(0)); +} + +/// `next_head` reports a failed advance as a final head and leaves +/// the range untouched (the non-`long` checked walk overflows past +/// `i64::MAX`; callers check `step_inexact` and raise there). +#[test] +fn next_head_overflow_reports_final_head_untouched() { + let mut r = RangeValue::new(RangeNum::Int(i64::MAX), None, RangeNum::Int(1), false, None); + assert!(!r.step_inexact()); + assert_eq!(r.next_head(), Some((Value::Int(i64::MAX), false))); + // Untouched: stepping again reports the same final head. + assert_eq!(r.next_head(), Some((Value::Int(i64::MAX), false))); +} + +/// `head_or_nil` reads the head directly (no chunk step), and +/// nil-puns done ranges. +#[test] +fn head_or_nil_reads_head_and_nil_puns_done() { + let live = RangeValue::new( + RangeNum::Int(7), + Some(RangeNum::Int(9)), + RangeNum::Int(1), + true, + Some(2), + ); + assert_eq!(live.head_or_nil(), Value::Int(7)); + let done = RangeValue::new( + RangeNum::Int(9), + Some(RangeNum::Int(9)), + RangeNum::Int(1), + true, + Some(0), + ); + assert_eq!(done.head_or_nil(), Value::Nil); +}