diff --git a/docs/docs/language/control-flow.md b/docs/docs/language/control-flow.md index 8cf4d7c69..dbe7a7497 100644 --- a/docs/docs/language/control-flow.md +++ b/docs/docs/language/control-flow.md @@ -81,6 +81,35 @@ does push a scope: A loop whose condition never goes false runs until interrupted; Ctrl-C breaks out of one at the REPL. +## Bounded Iteration: times + +`times` runs the body a fixed number of times and returns null. + +```text +‣ (set n 0) +‣ (times 5 (set n (+ n 1))) +‣ n +5 +``` + +The count is evaluated **once**, on entry, so the bound is fixed however the +body mutates whatever produced it: + +```lisp +(set k 3) +(times k (set k (+ k 10))) ; runs 3 times, not forever +``` + +A count of zero or less runs the body zero times rather than raising — a bound +that computes to empty is a no-op, not an error. A non-integer count is a type +error. Like `while`, `times` pushes no scope of its own, so a `let` in the body +binds in the enclosing frame; wrap the body in `do` when a fresh binding per +pass is wanted. + +Reach for `times` when the number of passes is known up front and for `while` +when it is not. Neither allocates a sequence, so neither pays for a range that +exists only to be counted. + ## Variable Binding: set and let `set` creates a global binding. `let` creates a local binding scoped to the enclosing `do`: @@ -238,7 +267,20 @@ Lambdas are auto-mapped over vectors when called directly. Use `map` for explici ‣ (scan + [1 2 3 4 5]) ; => [1 3 6 10 15] +;; fold-while stops as soon as the running result fails the predicate, +;; leaving the rest of the collection untouched +‣ (fold-while (fn [acc] (< acc 100)) + 0 (til 1000)) +; => 105 + ;; where returns indices matching a condition ‣ (where (> (til 10) 3)) ; => [4 5 6 7 8 9] ``` + +`map`, `fold`, `scan` and `prior` all consume their whole input. `fold-while` +is the one member of the family that can stop: the accumulator is offered to +the predicate before each step, and a falsy answer ends the fold and yields the +accumulator as it stands. The test happens before the first element too, so a +predicate that is false at the start returns the initial value untouched. +Elements are pulled one at a time, so a fold that stops after three steps costs +three elements however long the collection is. diff --git a/docs/docs/language/functions.md b/docs/docs/language/functions.md index 58f578b7e..943e1b78a 100644 --- a/docs/docs/language/functions.md +++ b/docs/docs/language/functions.md @@ -133,6 +133,7 @@ Functions that take other functions as arguments. | `filter` | binary | Keep elements where boolean mask is true | `(filter [1 2 3 4] (> [1 2 3 4] 2))` → `[3 4]` | | `fold` | variadic | Reduce with function and initial value | `(fold + 0 [1 2 3])` → `6` | | `fold-left` | variadic | Left-associative fold | `(fold-left - 10 [1 2 3])` → `4` | +| `fold-while` | variadic | Fold that stops when the accumulator fails pred | `(fold-while (fn [a] (< a 100)) + 0 (til 1000))` → `105` | | `fold-right` | variadic | Right-associative fold | `(fold-right - 10 [1 2 3])` → `-8` | | `scan` | variadic | Running fold (returns all intermediate results) | `(scan + (enlist 1 2 3))` → `[1 3 6]` | | `scan-left` | variadic | Left-to-right running fold | `(scan-left + (enlist 1 2 3))` → `[1 3 6]` | diff --git a/docs/docs/reference/all-functions.md b/docs/docs/reference/all-functions.md index fff747dea..eba0d980b 100644 --- a/docs/docs/reference/all-functions.md +++ b/docs/docs/reference/all-functions.md @@ -12,7 +12,7 @@ |---|---|---| | [Arithmetic](#arithmetic) (24) | [Comparison](#comparison) (7) | [Logic](#logic) (3) | | [Aggregation](#aggregation) (25) | [Higher-Order](#higher-order) (13) | [Collection](#collection) (41) | -| [Sorting & Ordering](#sorting) (10) | [Control Flow & Special Forms](#control) (12) | [Table Operations](#table-ops) (20) | +| [Sorting & Ordering](#sorting) (10) | [Control Flow & Special Forms](#control) (13) | [Table Operations](#table-ops) (20) | | [Query](#query) (4) | [Joins](#joins) (7) | [Pivot](#pivot) (1) | | [String](#string-ops) (11) | [Temporal](#temporal) (3) | [Type & Introspection](#type-ops) (5) | | [I/O & Output](#io) (12) | [System & Utility](#system) (15) | [Serialization](#serialization) (2) | @@ -71,7 +71,7 @@ Generated from `src/lang/eval.c` in this checkout. The categorized reference bel `.db.parted.get`, `.db.parted.tables`, `.db.parted.fill`, `alter`, `print`, `.sys.gc`, `.mem.objsize`, `.mem.ts`, `.sys.timeit`, `.sys.env`, `.sys.args`, `.ipc.open`, `.ipc.handle`, `.repl.disconnect`, `.log.open`, `.log.roll`, `.log.snapshot`, `.log.sync`, `.log.close`, `.log.purge`, `quote`, `return`, `.time.now`, `.time.timer.set`, -`fold-left`, `fold-right`, `scan-left`, `scan-right`, `del`, `.sys.build`, `.sys.mem`, `.sys.prof`, +`fold-left`, `fold-while`, `fold-right`, `scan-left`, `scan-right`, `del`, `.sys.build`, `.sys.mem`, `.sys.prof`, `.sys.querylog`, `.sys.querylog.enable`, `modify`, `pivot`, `.sys.info`, `datoms`, `assert-fact`, `retract-fact`, `scan-eav`, `pull`, `rule`, `query`, `dl-program`, `dl-add-edb`, `knn`, `hnsw-build`, `ann`, `.graph.build`, `.graph.pagerank`, `.graph.connected`, `.graph.dijkstra`, `.graph.louvain`, `.graph.degree`, @@ -221,6 +221,7 @@ Functions that take other functions as arguments for mapping, folding, and filte | `pmap` | variadic | — | Parallel map (multi-threaded, returns a list) | `(pmap (fn [x] (* x x)) [1 2 3])` → `(1 4 9)` | | `fold` | variadic | — | Reduce with function and initial value | `(fold + 0 [1 2 3])` → `6` | | `fold-left` | variadic | — | Left-associative fold | `(fold-left - 10 [1 2 3])` → `4` | +| `fold-while` | variadic | — | Fold that stops when the accumulator fails pred | `(fold-while (fn [a] (< a 100)) + 0 (til 1000))` → `105` | | `fold-right` | variadic | — | Right-associative fold | `(fold-right - 10 [1 2 3])` → `-8` | | `scan` | variadic | — | Running fold (all intermediate results) | `(scan + (enlist 1 2 3))` → `[1 3 6]` | | `scan-left` | variadic | — | Left-to-right running fold | `(scan-left + (enlist 1 2 3))` → `[1 3 6]` | @@ -360,6 +361,7 @@ Special forms receive their arguments unevaluated. These are the core language p | `if` | variadic | special | Conditional: (if cond then else) | `(if (> x 0) "pos" "neg")` | | `do` | variadic | special | Sequential execution, returns last value | `(do (set x 1) (set y 2) (+ x y))` | | `while` | variadic | special | Iterate while cond is truthy; returns null | `(while (> n 0) (set n (- n 1)))` | +| `times` | variadic | special | Run body exactly n times (count evaluated once); returns null | `(times 5 (set n (+ n 1)))` | | `fn` | variadic | special | Create lambda function | `(fn [x y] (+ x y))` | | `try` | binary | special | Error handling: (try expr handler-fn-or-fallback-value) | `(try (/ 1 0) (fn [e] 0))` | | `raise` | unary | — | Throw an error with message | `(raise "bad input")` | diff --git a/src/lang/compile.c b/src/lang/compile.c index a20ad8390..f37ccfb6f 100644 --- a/src/lang/compile.c +++ b/src/lang/compile.c @@ -215,7 +215,7 @@ static void emit_jump_back(compiler_t *c, int32_t target) { } /* Cached sym IDs for special forms */ -static _Thread_local int64_t sf_set = -1, sf_let = -1, sf_if = -1, sf_do = -1, sf_while = -1, sf_fn = -1, sf_self = -1, sf_try = -1, sf_return = -1, sf_null = -1; +static _Thread_local int64_t sf_set = -1, sf_let = -1, sf_if = -1, sf_do = -1, sf_while = -1, sf_times = -1, sf_fn = -1, sf_self = -1, sf_try = -1, sf_return = -1, sf_null = -1; static _Thread_local int64_t sf_eval = -1, sf_resolve = -1; static void init_sf_syms(void) { @@ -225,6 +225,7 @@ static void init_sf_syms(void) { sf_if = ray_sym_intern("if", 2); sf_do = ray_sym_intern("do", 2); sf_while= ray_sym_intern("while", 5); + sf_times= ray_sym_intern("times", 5); sf_fn = ray_sym_intern("fn", 2); sf_self = ray_sym_intern("self", 4); sf_try = ray_sym_intern("try", 3); @@ -423,6 +424,71 @@ static void compile_list(compiler_t *c, ray_t *ast) { return; } + /* (times n body...) — a counted loop, desugared onto the same + * backward branch `while` uses. + * + * The count is evaluated ONCE into a hidden local slot, so the + * bound is fixed on entry however the body mutates its source. + * ray_times_norm_fn type-checks it and clamps a negative bound to + * zero, which is what lets the per-pass test be a bare truthiness + * check on the counter: 0 is falsy (is_truthy), everything else is + * truthy, so the loop needs no comparison call and a negative bound + * cannot run away. + * + * Both helpers are pushed as constant-pool objects rather than + * resolved by name, so the loop's own arithmetic is invisible to + * user code and immune to an override of `-` or `>`. The counter's + * slot is likewise addressed by index, never by name, and its + * sym contains a space so no source token can collide with it — + * which is what keeps nested `times` counters apart. + * + * As with `while`, compiling the body inline is what keeps + * `return` unwinding the enclosing lambda from inside the loop. */ + if (sym_id == sf_times && n >= 2) { + ray_t *norm_fn = ray_fn_unary("times norm", RAY_FN_NONE, ray_times_norm_fn); + ray_t *dec_fn = ray_fn_unary("times dec", RAY_FN_NONE, ray_times_dec_fn); + if (!norm_fn || RAY_IS_ERR(norm_fn) || !dec_fn || RAY_IS_ERR(dec_fn)) { + if (norm_fn && !RAY_IS_ERR(norm_fn)) ray_release(norm_fn); + if (dec_fn && !RAY_IS_ERR(dec_fn)) ray_release(dec_fn); + c->error = true; + return; + } + int32_t norm_idx = add_constant(c, norm_fn); + int32_t dec_idx = add_constant(c, dec_fn); + ray_release(norm_fn); + ray_release(dec_fn); + int32_t cslot = add_local(c, ray_sym_intern("times ctr", 9)); + if (cslot < 0 || c->error) { c->error = true; return; } + + /* counter = normalize() */ + emit_const(c, norm_idx); + compile_expr(c, elems[1]); + emit(c, OP_CALL1); + emit(c, OP_STOREENV); + emit(c, (uint8_t)cslot); + + int32_t top = c->code_len; + emit(c, OP_LOADENV); + emit(c, (uint8_t)cslot); + int32_t jmpf_pos = emit_jump(c, OP_JMPF); + for (int64_t i = 2; i < n; i++) { + compile_expr(c, elems[i]); + emit(c, OP_POP); + } + /* counter = counter - 1 */ + emit_const(c, dec_idx); + emit(c, OP_LOADENV); + emit(c, (uint8_t)cslot); + emit(c, OP_CALL1); + emit(c, OP_STOREENV); + emit(c, (uint8_t)cslot); + emit_jump_back(c, top); + patch_jump(c, jmpf_pos); + int32_t null_idx = add_constant(c, RAY_NULL_OBJ); + emit_const(c, null_idx); + return; + } + /* (fn [params] body...) — nested lambda via dynamic eval */ if (sym_id == sf_fn && n >= 3) { if (ast_refs_locals(c, ast)) { diff --git a/src/lang/env.c b/src/lang/env.c index 886bbd47a..65ba600a6 100644 --- a/src/lang/env.c +++ b/src/lang/env.c @@ -839,7 +839,7 @@ int32_t ray_env_list_user(int64_t* sym_ids, ray_t** vals, int32_t max_entries) { /* ---- Prefix lookup ---- */ static const char* s_keywords[] = { - "def", "do", "false", "fn", "if", "let", "set", "true", "while", NULL + "def", "do", "false", "fn", "if", "let", "set", "times", "true", "while", NULL }; /* Compare helper for qsort on const char* */ diff --git a/src/lang/eval.c b/src/lang/eval.c index defce74d0..ced4f8ad1 100644 --- a/src/lang/eval.c +++ b/src/lang/eval.c @@ -1967,6 +1967,79 @@ ray_t* ray_while_fn(ray_t** args, int64_t n) { } } +/* Read a loop count from an evaluated atom. Integers only: floats are + * rejected rather than truncated, because as_i64 on an F64 reads the bit + * pattern, and a silent (times 1.5 ...) meaning "once" hides a mistake + * either way. Returns 0 on success, or fills *err. */ +static int loop_count(ray_t* x, int64_t* out, const char* who, ray_t** err) { + if (ray_is_atom(x)) { + switch (x->type) { + case -RAY_I64: case -RAY_I32: case -RAY_I16: case -RAY_U8: + *out = as_i64(x); + return 0; + default: break; + } + } + *err = ray_error("type", "%s: count must be an integer, got %s", who, ray_type_name(x->type)); + return -1; +} + +/* The compiled `times` loop drives its counter through these two, held + * as constant-pool objects rather than bound in the env — so they carry + * no name a user could write, and the loop's arithmetic cannot be + * changed out from under it by a `(set - ...)` style override. + * + * Normalizing once on entry (type-check, and clamp a negative bound to + * zero) is what lets the loop test the raw counter for truthiness: 0 is + * falsy, every other count is truthy, so no comparison call is needed + * per pass and a negative bound cannot run away. */ +ray_t* ray_times_norm_fn(ray_t* x) { + int64_t reps = 0; + ray_t* err = NULL; + if (loop_count(x, &reps, "times", &err)) return err; + return make_i64(reps < 0 ? 0 : reps); +} + +ray_t* ray_times_dec_fn(ray_t* x) { + return make_i64(as_i64(x) - 1); +} + +/* (times n body...) — evaluate the body exactly n times. Receives + * unevaluated args. Always returns null, like `while`. + * + * `n` is evaluated ONCE, up front: the count is a bound fixed on entry, + * so a body that mutates whatever produced it cannot change how many + * passes remain. A count of zero or less runs the body zero times + * rather than trapping — a bound computed as empty is a no-op, not an + * error. + * + * Pushes no scope, for the reason given on ray_while_fn: `let` binds in + * the top frame only, so a per-pass frame would discard loop-carried + * state here while the compiled path kept it. + * + * The compiled form is emitted by the bytecode compiler — see compile.c. */ +ray_t* ray_times_fn(ray_t** args, int64_t n) { + if (n < 1) return ray_error("domain", "times: expected at least 1 arg (count), got %lld", (long long)n); + ray_t* cnt = ray_eval(args[0]); + if (RAY_IS_ERR(cnt)) return cnt; + if (ray_is_lazy(cnt)) + cnt = ray_lazy_materialize(cnt); + if (RAY_IS_ERR(cnt)) return cnt; + int64_t reps = 0; + ray_t* err = NULL; + int bad = loop_count(cnt, &reps, "times", &err); + ray_release(cnt); + if (bad) return err; + for (int64_t r = 0; r < reps; r++) { + for (int64_t i = 1; i < n; i++) { + ray_t* val = ray_eval(args[i]); + if (RAY_IS_ERR(val)) return val; + ray_release(val); + } + } + return RAY_NULL_OBJ; +} + /* ══════════════════════════════════════════ * Lambda functions * ══════════════════════════════════════════ */ @@ -3191,6 +3264,7 @@ static void ray_register_builtins(void) { register_vary("if", RAY_FN_SPECIAL_FORM, ray_cond_fn); register_vary("do", RAY_FN_SPECIAL_FORM, ray_do_fn); register_vary("while", RAY_FN_SPECIAL_FORM, ray_while_fn); + register_vary("times", RAY_FN_SPECIAL_FORM, ray_times_fn); register_vary("fn", RAY_FN_SPECIAL_FORM, ray_fn); /* Aggregation builtins */ @@ -3512,6 +3586,7 @@ static void ray_register_builtins(void) { /* Directional fold/scan variants */ register_vary("fold-left", RAY_FN_NONE, ray_fold_left_fn); + register_vary("fold-while", RAY_FN_NONE, ray_fold_while_fn); register_vary("fold-right", RAY_FN_NONE, ray_fold_right_fn); register_vary("scan-left", RAY_FN_NONE, ray_scan_left_fn); register_vary("scan-right", RAY_FN_NONE, ray_scan_right_fn); diff --git a/src/lang/eval.h b/src/lang/eval.h index 0630700cf..0cfab1d0d 100644 --- a/src/lang/eval.h +++ b/src/lang/eval.h @@ -364,6 +364,9 @@ ray_t* ray_let_fn(ray_t* name_obj, ray_t* val_expr); ray_t* ray_cond_fn(ray_t** args, int64_t n); ray_t* ray_do_fn(ray_t** args, int64_t n); ray_t* ray_while_fn(ray_t** args, int64_t n); +ray_t* ray_times_fn(ray_t** args, int64_t n); +ray_t* ray_times_norm_fn(ray_t* x); +ray_t* ray_times_dec_fn(ray_t* x); ray_t* ray_fn(ray_t** args, int64_t n); ray_t* ray_raise_fn(ray_t* val); ray_t* ray_try_fn(ray_t* expr, ray_t* handler_expr); diff --git a/src/lang/internal.h b/src/lang/internal.h index 554496de1..0a28a7dbf 100644 --- a/src/lang/internal.h +++ b/src/lang/internal.h @@ -526,6 +526,7 @@ ray_t* ray_binr_fn(ray_t* sorted, ray_t* val); ray_t* ray_map_left_fn(ray_t** args, int64_t n); ray_t* ray_map_right_fn(ray_t** args, int64_t n); ray_t* ray_fold_left_fn(ray_t** args, int64_t n); +ray_t* ray_fold_while_fn(ray_t** args, int64_t n); ray_t* ray_fold_right_fn(ray_t** args, int64_t n); ray_t* ray_scan_left_fn(ray_t** args, int64_t n); ray_t* ray_scan_right_fn(ray_t** args, int64_t n); diff --git a/src/ops/collection.c b/src/ops/collection.c index b3e29db13..b9ac009d8 100644 --- a/src/ops/collection.c +++ b/src/ops/collection.c @@ -4366,6 +4366,54 @@ ray_t* ray_fold_left_fn(ray_t** args, int64_t n) { return ray_fold_fn(args, n); } +/* (fold-while pred f init coll) — a fold that stops when the running + * result says stop. + * + * Before each step the accumulator is offered to `pred`; a falsy answer + * ends the fold and yields the accumulator as it stands. The test comes + * BEFORE the first element, so a predicate that is false at the start + * returns `init` untouched and touches nothing. + * + * Deliberately unlike ray_fold_fn in one respect: that routes its + * collection through unbox_vec_arg -> to_boxed_list, boxing every element + * up front. For a primitive whose whole purpose is to stop early, paying + * for the tail it never reaches is exactly the cost being removed here + * (issue 588), so elements are pulled one at a time via collection_elem — + * the same way map_iterate walks its input. Stopping at element three of + * a million costs three boxed atoms, not a million. */ +ray_t* ray_fold_while_fn(ray_t** args, int64_t n) { + if (n != 4) return ray_error("domain", "fold-while: requires exactly 4 args (pred, fn, init, coll), got %lld", (long long)n); + for (int64_t i = 0; i < n; i++) + if (ray_is_lazy(args[i])) args[i] = ray_lazy_materialize(args[i]); + + ray_t* pred = args[0]; + ray_t* fn = args[1]; + ray_t* coll = args[3]; + if (!is_collection(coll)) + return ray_error("type", "fold-while: coll arg must be a collection, got %s", ray_type_name(coll->type)); + + ray_retain(args[2]); + ray_t* acc = args[2]; + int64_t len = ray_len(coll); + for (int64_t i = 0; i < len; i++) { + ray_t* keep = call_fn1(pred, acc); + if (ray_is_lazy(keep)) keep = ray_lazy_materialize(keep); + if (RAY_IS_ERR(keep)) { ray_release(acc); return keep; } + int go = is_truthy(keep); + ray_release(keep); + if (!go) return acc; + + int alloc = 0; + ray_t* elem = collection_elem(coll, i, &alloc); + ray_t* next = call_fn2(fn, acc, elem); + if (alloc) ray_release(elem); + ray_release(acc); + if (RAY_IS_ERR(next)) return next; + acc = next; + } + return acc; +} + /* (fold-right fn init coll) — right fold */ ray_t* ray_fold_right_fn(ray_t** args, int64_t n) { if (n < 2) return ray_error("domain", "fold-right: requires at least 2 args (fn and vec), got %lld", (long long)n); diff --git a/test/rfl/collection/fold_while.rfl b/test/rfl/collection/fold_while.rfl new file mode 100644 index 000000000..8885c3b3a --- /dev/null +++ b/test/rfl/collection/fold_while.rfl @@ -0,0 +1,75 @@ +;; (fold-while pred f init xs) — a fold that stops when the running result +;; says stop. +;; +;; Before each step the accumulator is offered to `pred`; a falsy answer ends +;; the fold and yields the accumulator as it stands. The predicate is tested +;; BEFORE the first element too, so a predicate that is false at the start +;; returns `init` untouched. +;; +;; Unlike `fold`, this does not box the whole collection up front (`fold` +;; routes through to_boxed_list): a primitive whose purpose is to stop early +;; must not pay for the tail it never reaches. The counting assertions below +;; pin that — they fail if the implementation touches more elements than the +;; predicate allows. + +;; ── basic early termination ────────────────────────────────────────── +;; sum until the running total reaches 100 +(fold-while (fn [acc] (< acc 100)) + 0 (til 1000)) -- 105 + +;; double until past 100 — the sequence is only a step counter here +(fold-while (fn [acc] (< acc 100)) (fn [acc _x] (* acc 2)) 1 (til 1000)) -- 128 + +;; ── the predicate is tested before the first step ──────────────────── +(fold-while (fn [acc] false) + 7 (til 10)) -- 7 + +;; a predicate false at the start touches no element at all +(set seen 0) +(fold-while (fn [acc] false) (fn [acc x] (set seen (+ seen 1)) (+ acc x)) 0 (til 1000)) -- 0 +seen -- 0 + +;; ── it touches exactly as many elements as the predicate allows ────── +;; The step runs while acc < 3, so it runs for acc = 0,1,2 — three times — +;; and never looks at the remaining 997 elements. +(set seen 0) +(set r (fold-while (fn [acc] (< acc 3)) (fn [acc x] (set seen (+ seen 1)) (+ acc 1)) 0 (til 1000))) +r -- 3 +seen -- 3 + +;; ── running to completion ──────────────────────────────────────────── +;; a predicate that never fails folds the whole collection, like fold-left +(fold-while (fn [acc] true) + 0 (til 10)) -- 45 +(fold-left + 0 (til 10)) -- 45 + +;; ── empty and single-element collections ───────────────────────────── +(fold-while (fn [acc] true) + 42 (til 0)) -- 42 +(fold-while (fn [acc] false) + 42 (til 0)) -- 42 +(fold-while (fn [acc] true) + 0 (til 1)) -- 0 + +;; ── collection kinds ───────────────────────────────────────────────── +;; a boxed list, not just a typed vector +(fold-while (fn [acc] (< acc 6)) + 0 (list 1 2 3 4 5)) -- 6 + +;; a typed vector of a narrower width +(fold-while (fn [acc] (< acc 6)) + 0 [1 2 3 4 5]) -- 6 + +;; ── arity and type ─────────────────────────────────────────────────── +(fold-while) !- domain +(fold-while (fn [acc] true) + 0) !- domain +(fold-while (fn [acc] true) + 0 (til 5) 9) !- domain +(fold-while (fn [acc] true) + 0 42) !- type + +;; ── errors propagate from both callbacks ───────────────────────────── +;; from the predicate, before any step runs +(set seen 0) +(== (try (fold-while (fn [acc] (raise 'bad-pred)) (fn [acc x] (set seen (+ seen 1)) acc) 0 (til 5)) (fn [e] e)) 'bad-pred) -- true +seen -- 0 + +;; from the step function, on the first element +(== (try (fold-while (fn [acc] true) (fn [acc x] (raise 'bad-step)) 0 (til 5)) (fn [e] e)) 'bad-step) -- true + +;; ── compiled path ──────────────────────────────────────────────────── +;; the whole form inside a lambda, with the bound as a param +((fn [cap] (fold-while (fn [acc] (< acc cap)) + 0 (til 1000))) 100) -- 105 + +;; a builtin as the predicate rather than a lambda +(fold-while nil? (fn [acc _x] 1) null (til 10)) -- 1 diff --git a/test/rfl/lang/times.rfl b/test/rfl/lang/times.rfl new file mode 100644 index 000000000..05fd9c00b --- /dev/null +++ b/test/rfl/lang/times.rfl @@ -0,0 +1,124 @@ +;; (times n body...) — bounded loop: run the body exactly n times. +;; +;; The count is evaluated ONCE, up front, so a body that mutates whatever +;; produced it cannot change how many passes remain. Returns null, pushes +;; no scope, and is asserted on both evaluators — the tree walker +;; (ray_times_fn) and the bytecode compiler, whose sf_times case desugars +;; to a counted loop over a hidden local slot. + +;; ── basic counting ─────────────────────────────────────────────────── +(set n 0) +(times 5 (set n (+ n 1))) +n -- 5 + +;; a statement form: always null, whatever the body evaluated to +(nil? (times 3 'discarded)) -- true + +;; several body expressions, run in order, every pass +(set a 0) +(set b 0) +(times 4 (set a (+ a 1)) (set b (+ b a))) +a -- 4 +b -- 10 + +;; ── zero and negative counts ───────────────────────────────────────── +(set z 99) +(nil? (times 0 (set z 1))) -- true +z -- 99 + +;; a negative count runs zero times rather than trapping +(set z 99) +(nil? (times -3 (set z 1))) -- true +z -- 99 + +;; body-less form is legal and does nothing observable +(nil? (times 5)) -- true + +;; ── the count is evaluated once, up front ──────────────────────────── +;; If the count were re-read each pass this would not terminate at 3. +(set k 3) +(set passes 0) +(times k (set k (+ k 10)) (set passes (+ passes 1))) +passes -- 3 +k -- 33 + +;; the count is an arbitrary expression, evaluated once +(set calls 0) +(set howmany (fn [] (set calls (+ calls 1)) 4)) +(set p 0) +(times (howmany) (set p (+ p 1))) +p -- 4 +calls -- 1 + +;; ── arity and type ─────────────────────────────────────────────────── +(times) !- domain +(times "3" (set n 1)) !- type +(times 1.5 (set n 1)) !- type + +;; ── compiled path ──────────────────────────────────────────────────── +;; count from a lambda param, accumulator in a let-local +((fn [m] (let acc 0) (times m (let acc (+ acc 2))) acc) 5) -- 10 + +;; the count is validated and clamped on the COMPILED path too, where it +;; runs through ray_times_norm_fn rather than the tree walker's check +((fn [] (times "3" 1))) !- type +((fn [] (times 1.5 1))) !- type +((fn [n] (let z 99) (times n (let z 1)) z) -3) -- 99 +((fn [n] (let z 99) (times n (let z 1)) z) 0) -- 99 + +;; the hidden loop counter must not collide with a user local of any name +((fn [] (let n 0) (let c 100) (times 3 (let n (+ n c))) n)) -- 300 + +;; tree-walk path, same arithmetic +(set acc 0) +(times 5 (set acc (+ acc 2))) +acc -- 10 + +;; ── `return` from inside a body exits the enclosing lambda ─────────── +;; Body compiles inline, so return unwinds the lambda rather than +;; degrading to the tree walker's identity return. +(set hits 0) +((fn [] (let i 0) (times 10 (set hits (+ hits 1)) (if (> i 2) (return 42)) (let i (+ i 1))) 7)) -- 42 +hits -- 4 + +;; without the early return the same loop runs all ten passes +(set hits 0) +((fn [] (times 10 (set hits (+ hits 1))) 7)) -- 7 +hits -- 10 + +;; ── errors ─────────────────────────────────────────────────────────── +;; an error in the body propagates and stops the loop on the first pass +(set c 0) +(== (try (times 10 (set c (+ c 1)) (raise 'boom)) (fn [e] e)) 'boom) -- true +c -- 1 + +;; an error in the count propagates before the body ever runs +(set c 0) +(== (try (times (raise 'bad-count) (set c (+ c 1))) (fn [e] e)) 'bad-count) -- true +c -- 0 + +;; ── nesting ────────────────────────────────────────────────────────── +;; nested counters must not clobber one another +(set total 0) +(times 3 (times 2 (set total (+ total 1)))) +total -- 6 + +;; nested, compiled, with let-locals on both levels +((fn [] (let t 0) (times 3 (times 2 (let t (+ t 1)))) t)) -- 6 + +;; nested three deep +(set total 0) +(times 2 (times 3 (times 4 (set total (+ total 1))))) +total -- 24 + +;; ── composes with while ────────────────────────────────────────────── +(set out 0) +(set i 0) +(while (< i 3) (times 2 (set out (+ out 1))) (set i (+ i 1))) +out -- 6 + +;; ── per-pass isolation via do, as with while ───────────────────────── +(set out (list)) +(set i 0) +(times 3 (do (let sq (* i i)) (set out (concat out sq))) (set i (+ i 1))) +out -- (list 0 1 4)