feat(lang): add times and fold-while - #594
Merged
Merged
Conversation
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.
Rayforce targeted audit passedThe required Rayforce audit gate passed on the latest run. Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/35467574130 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #590 (now merged), which added
whileand the backward branch (emit_jump_back/write_jump_offset) that thetimescompiler case reuses.These are the two remaining forms requested in #588. Neither is an unblock —
whilealready 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.dois already progn here, so the bounded loop could not reuse that name. Overloadingdoon 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:
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
typeerror.How it compiles
A counted loop over a hidden local slot:
Three things worth a reviewer's eye:
ray_times_norm_fntype-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_truthymakes0falsy — so there is no comparison call per pass and a negative bound cannot run away.normanddecare 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.timescounters stay apart. There's a three-deep nesting test.As with
while, the body compiles inline, soreturnunwinds the enclosing lambda from inside the loop.fold-while(fold-while pred f init xs)offers the accumulator topredbefore 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 returnsinituntouched.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.)
One deliberate divergence from
fold.ray_fold_fnroutes its collection throughunbox_vec_arg→to_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 viacollection_elem. A plain variadic builtin — no compiler work, since it dispatches through the normal call path.Performance
Release builds:
while+ manual counter, 1e6 passestimes, 1e6 passesfold-leftstopping after 3 of 1e6fold-whilesameThe 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.rflandtest/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_fnfrom bytecode), so both are now asserted separately. Also covered: count-evaluated-once, hidden-counter non-collision,returnfrom inside a body, error propagation from count and body with the pass count pinned, three-deep nesting,fold-whiletouching 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.