Skip to content

feat(lang): add times and fold-while - #594

Merged
singaraiona merged 2 commits into
devfrom
feat/times-fold-while
Sep 20, 2026
Merged

singaraiona merged 2 commits into
devfrom
feat/times-fold-while

Conversation

@singaraiona

@singaraiona singaraiona commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #590 (now merged), which added while and the backward branch (emit_jump_back/write_jump_offset) that the times compiler case reuses.

These are the two remaining forms requested in #588. Neither is an unblock — while already covers the reporter's case, and he said "any one of them solves it" — but they complete the vocabulary he asked for.

times

(times n body...) runs the body exactly n times, returns null.

do is already progn here, so the bounded loop could not reuse that name. Overloading do on an integer head was considered and rejected as genuinely ambiguous: (do 5) would have to mean either "loop five times over nothing" or "return 5", and a computed first expression that happened to be an integer would silently change meaning.

The count is evaluated once on entry, so a body mutating whatever produced it cannot change how many passes remain:

(set k 3)
(times k (set k (+ k 10)))      ; runs 3 times, not forever

Zero or less runs zero times rather than trapping — a bound that computes to empty is a no-op, not an error. A non-integer count is a type error.

How it compiles

A counted loop over a hidden local slot:

     LOADCONST(norm) <n> CALL1  STOREENV c
top: LOADENV c  JMPF -> end
     <body...> POP each
     LOADCONST(dec) LOADENV c CALL1 STOREENV c
     JMP -> top
end: LOADCONST(null)

Three things worth a reviewer's eye:

  • ray_times_norm_fn type-checks and clamps a negative bound to zero on entry. That is what lets the per-pass test be a bare truthiness check on the counter — is_truthy makes 0 falsy — so there is no comparison call per pass and a negative bound cannot run away.
  • norm and dec are pushed as constant-pool objects, not resolved by name. The loop's own arithmetic is therefore unnamable from source and cannot be swapped out by a (set - ...) or (set > ...) override. They are built fresh per compile site rather than held as singletons, so their lifetime is the code object's constant pool — no pointer survives a runtime teardown.
  • The counter's slot is addressed by index and its sym carries a space, so no source token can collide with it and nested times counters stay apart. There's a three-deep nesting test.

As with while, the body compiles inline, so return unwinds the enclosing lambda from inside the loop.

fold-while

(fold-while pred f init xs) offers the accumulator to pred before each step and stops on a falsy answer, yielding the accumulator as it stands. The test precedes the first element, so a predicate false at the start returns init untouched.

The predicate takes the accumulator, not the element — that is the form expressing "iterate until the running result says stop", which is the early termination actually being asked for. (The issue's own wording was ambiguous between the two readings.)

(fold-while (fn [acc] (< acc 100)) + 0 (til 1000))   ; => 105

One deliberate divergence from fold. ray_fold_fn routes its collection through unbox_vec_argto_boxed_list, boxing every element up front. For a primitive whose entire purpose is stopping early, paying for the tail it never reaches is the cost being removed, so elements are pulled one at a time via collection_elem. A plain variadic builtin — no compiler work, since it dispatches through the normal call path.

Performance

Release builds:

per iteration / call vs baseline
while + manual counter, 1e6 passes 180 ns
times, 1e6 passes 100 ns 1.8×
fold-left stopping after 3 of 1e6 369 ms
fold-while same 3.9 µs ~95 000×

The second pair is the pathological early-stop case, where the old form had to box and walk all million elements to discover it was done after three.

Testing

test/rfl/lang/times.rfl and test/rfl/collection/fold_while.rfl, each behaviour asserted on both evaluator paths where applicable.

Worth noting one gap I caught in my own coverage: the count's type check and negative clamp run through completely different code on the two paths (the tree walker's own check vs ray_times_norm_fn from bytecode), so both are now asserted separately. Also covered: count-evaluated-once, hidden-counter non-collision, return from inside a body, error propagation from count and body with the pass count pinned, three-deep nesting, fold-while touching exactly as many elements as the predicate allows, and both callbacks raising.

Full suite 3903/3903, ASan+UBSan output clean.

Not in scope

Converge/adverb forms, scan-while, TCE.

The two remaining early-termination forms requested in #588, alongside the
`while` that landed in #590.  Neither is an unblock — `while` already covers
the reporter's case — but both complete the vocabulary he asked for.

`times`
-------
(times n body...) runs the body exactly n times and returns null.  `do` is
already progn in this language, so the bounded loop could not reuse that name;
overloading `do` on an integer head was rejected as genuinely ambiguous —
(do 5) would have to mean either "loop five times over nothing" or "return 5",
and a computed first expression that happened to be an integer would silently
change meaning.

The count is evaluated ONCE on entry, so a body mutating whatever produced it
cannot change how many passes remain.  A count of zero or less runs zero times
rather than trapping; a non-integer is a type error.

Compiled as a counted loop over a hidden local slot, reusing the backward
branch added for `while`.  ray_times_norm_fn type-checks the count and clamps
a negative bound to zero on entry, which lets the per-pass test be a bare
truthiness check on the counter — 0 is falsy, so no comparison call is needed
per pass and a negative bound cannot run away.  That helper and the decrement
are pushed as constant-pool objects rather than resolved by name, so the
loop's own arithmetic is unnamable from source and cannot be swapped out by a
`(set - ...)` override.  The counter's slot is addressed by index and its sym
carries a space, so no source token can collide with it and nested `times`
counters stay apart.  As with `while`, the body compiles inline, so `return`
unwinds the enclosing lambda from inside the loop.

fold-while
----------
(fold-while pred f init xs) offers the accumulator to `pred` before each step
and stops on a falsy answer, yielding the accumulator as it stands.  The test
precedes the first element, so a predicate false at the start returns `init`
untouched.  The predicate takes the accumulator rather than the element: that
is the form that expresses "iterate until the running result says stop", which
is the early termination actually being asked for.

One deliberate divergence from ray_fold_fn: that routes its collection through
unbox_vec_arg -> to_boxed_list, boxing every element up front.  For a
primitive whose purpose is to stop early, paying for the tail it never reaches
is the cost being removed, so elements are pulled one at a time via
collection_elem.  A plain variadic builtin — no compiler work, since it
dispatches through the normal call path.

Measured, release builds: `times` 100 ns/pass against 180 ns for `while` plus
a manual counter over 1e6 passes.  `fold-while` stopping after three elements
of a 1e6-element vector costs 3.9 us against 369 ms for the `fold-left`
equivalent, which had to box and walk all million to discover it was done.

Tests: test/rfl/lang/times.rfl and test/rfl/collection/fold_while.rfl, each
behaviour asserted on both evaluator paths where applicable — including the
count validation and negative clamp on the compiled path, which runs through
entirely different code from the tree walker's check.
@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown

Rayforce targeted audit passed

The required Rayforce audit gate passed on the latest run.

Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/35467574130

@singaraiona
singaraiona changed the base branch from feat/while-form to dev September 19, 2026 20:29
@singaraiona
singaraiona merged commit 1c22f9d into dev Sep 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant