for Loops over the Collections

A plan for for over lists, tensors, sets and maps, from the parser to the backends. Every collection is walked through a cursor whose whole protocol is runtime functions; a backend that open-codes them is optimizing, not defining them. It replaces "Immutable Cursors for the Collections", which predated fat generics. Written and implemented October 2026, all five phases; what was built differs from the plan where What was built says. The language side is in mandocs/drafts/for-loops-for-collections.md.

Contents

What it is for

sys/std and the demos walk collections with an index counter, a wrapping add, and an indexed read per element. For a list that read is a bounds check and, for a linear element, a clone or a helper function to get around ? (ord.contains and equal_to_at). For a set or a map it is worse: set.get, map.key_at and map.value_at walk the leaf chain from the leftmost leaf to the position on every call (btreeset_get_at_impl, btreemap_at_inner), so a whole walk is quadratic, and each read clones.

A survey of sys/, demos/ and benchvs/ found about 88 loops over a collection. About 33 are plain forward walks that for item in ref xs covers directly, 7 more walk a map through map.keys() and would be for k, v, and about 10 use the index for something besides reading the same list (index_of, least, greatest, build_*_slots, separators). The rest -- binary searches, heaps, merges, sieves, writes through the index, lockstep walks, and 19 loops over another module's counts -- are out of reach of any for, and stay loop while.

The language

for item in ref xs          // list, set, tensor
for i, item in ref xs       // list, tensor: i is the index along axis 0
for key, value in ref m     // map

The protocol

Every collection kind has a cursor type and the same five operations. The kind is always known statically -- an erased [T] is still a list -- so nothing dispatches on a descriptor's tag.

Operation Does Precondition
begin Fills a cursor at the first element, or done for an empty collection The collection is frozen until the walk ends
done Whether the walk is over --
elem A pointer at the current element; a map's current key Not done
value A pointer at a map's current value Not done; maps only
advance Moves to the next element, or to done after the last Not done

The loop tests at the top, so begin and done carry the empty case and no operation needs a non-empty collection.

Three cursor types cover the four collections:

Collection Cursor Done when
list StrideCursor remaining == 0
tensor, rank 1 StrideCursor remaining == 0
tensor, rank above 1 RowCursor remaining == 0
set, map TreeCursor leaf is null

Each is plain data in datalove-rtdt. It owns nothing, so it is copy: drops, tracking bytes and leak checking never see it. A cursor holds pointers into the collection's heap storage, which is sound only because the iteratee is frozen.

Every function, with its types:

// Lists and rank-1 tensors.
dtlv_rti_list_cursor_begin_local(rt: LocalRtHandle, list_ref: *const u8, list_tydesc: *const TyDesc, cursor_out: *mut StrideCursor) -> RtStatus
dtlv_rti_tensor_cursor_begin_local(rt: LocalRtHandle, tensor_ref: *const u8, tensor_tydesc: *const TyDesc, cursor_out: *mut StrideCursor) -> RtStatus
dtlv_rti_stride_cursor_done_local(rt: LocalRtHandle, cursor_ref: *const StrideCursor, done_out: *mut u8) -> RtStatus
dtlv_rti_stride_cursor_elem_local(rt: LocalRtHandle, cursor_ref: *const StrideCursor, elem_out: *mut *const u8) -> RtStatus
dtlv_rti_stride_cursor_advance_local(rt: LocalRtHandle, cursor_mut: *mut StrideCursor) -> RtStatus

// Tensors of rank above 1.
dtlv_rti_tensor_row_cursor_begin_local(rt: LocalRtHandle, tensor_ref: *const u8, tensor_tydesc: *const TyDesc, cursor_out: *mut RowCursor) -> RtStatus
dtlv_rti_row_cursor_done_local(rt: LocalRtHandle, cursor_ref: *const RowCursor, done_out: *mut u8) -> RtStatus
dtlv_rti_row_cursor_elem_local(rt: LocalRtHandle, cursor_ref: *const RowCursor, elem_out: *mut *const u8) -> RtStatus
dtlv_rti_row_cursor_advance_local(rt: LocalRtHandle, cursor_mut: *mut RowCursor) -> RtStatus

// Sets and maps.
dtlv_rti_btreeset_cursor_begin_local(rt: LocalRtHandle, set_ref: *const u8, set_tydesc: *const TyDesc, cursor_out: *mut TreeCursor) -> RtStatus
dtlv_rti_btreemap_cursor_begin_local(rt: LocalRtHandle, map_ref: *const u8, map_tydesc: *const TyDesc, cursor_out: *mut TreeCursor) -> RtStatus
dtlv_rti_btreeset_cursor_advance_local(rt: LocalRtHandle, cursor_mut: *mut TreeCursor, set_tydesc: *const TyDesc) -> RtStatus
dtlv_rti_btreemap_cursor_advance_local(rt: LocalRtHandle, cursor_mut: *mut TreeCursor, map_tydesc: *const TyDesc) -> RtStatus
dtlv_rti_tree_cursor_done_local(rt: LocalRtHandle, cursor_ref: *const TreeCursor, done_out: *mut u8) -> RtStatus
dtlv_rti_tree_cursor_elem_local(rt: LocalRtHandle, cursor_ref: *const TreeCursor, elem_out: *mut *const u8) -> RtStatus
dtlv_rti_tree_cursor_value_local(rt: LocalRtHandle, cursor_ref: *const TreeCursor, value_out: *mut *const u8) -> RtStatus

Sixteen functions, all in the runtime's usual shape: each takes the handle, returns an RtStatus, and writes what it answers through an out parameter, a flag as a u8 that is 1 or 0. Begin, and a tree's advance, read the collection and take its descriptor. The rest touch only the cursor and cannot fail. A cursor is not a value of the language and has no descriptor, so the one rule they leave out is a descriptor after every value pointer.

An earlier version had done, elem, value and the strided and row advances skip the handle and return their answer directly, for speed. Nothing needs that: compiled code open-codes all ten, and the interpreter calls them in the same crate graph, where thin LTO inlines them whatever their shape.

Strided cursors: lists and rank-1 tensors

#[repr(C)]
pub struct StrideCursor {
    pub elem: *const u8,     // The current element.
    pub stride: usize,       // Bytes from one element to the next.
    pub remaining: Index,    // Elements from this one to the end; 0 is done.
}

The count, rather than an end pointer, is what makes zero-sized elements work. Unit and Atom are zero-sized, so a [()] of length 3 has its end pointer equal to its start, and an end-pointer loop would run no times. A count also survives a stride standing for a negative one, which the tensor's field comment allows and nothing creates today.

Row cursors: tensors of rank above 1

#[repr(C)]
pub struct RowCursor {
    pub view: Tensor,        // The current row: a view of rank one less.
    pub stride: Index,       // Elements from one row to the next (strides[0]).
    pub remaining: Index,    // Rows from this one to the end; 0 is done.
}

The view is the one TensorIndexRef builds today: capacity zero, aliasing the parent's buffer, shape and strides one past the parent's. It lives in the cursor, so there is no scratch for a backend to reserve.

Tree cursors: sets and maps

#[repr(C)]
pub struct TreeCursor {
    pub leaf: *const u8,       // The current leaf; null is done.
    pub elem: *const u8,       // The current key; a set's element.
    pub value: *const u8,      // A map's current value; null for a set.
    pub elem_stride: usize,    // Bytes from one key to the next.
    pub value_stride: usize,   // Bytes from one value to the next; 0 for a set.
    pub slot: u32,             // The current key's slot within the leaf.
    pub leaf_len: u32,         // How many keys the current leaf holds.
    pub next_leaf_offset: u32, // Where a leaf keeps its next pointer.
    pub keys_offset: u32,      // Where a leaf's keys start.
    pub values_offset: u32,    // Where a leaf's values start; 0 for a set.
}

There is no count. The end is the leaf chain's end: the last leaf's next_leaf is null, and an empty collection's root is null.

No leaf is empty. Both trees rebalance on removal (fix_underflow, MIN_KEYS), so the leaf advance moves to always holds an element and nothing has to skip. A debug assertion in advance says so. Until October 2026 a set's removal only shifted keys within the leaf, leaving empty leaves in the chain; that was fixed ahead of this plan, and btreeset_tests checks the tree's shape after every removal.

In the IR

One type and five instructions:

IrType::Cursor(CursorKind)          // CursorKind::{Stride, Row, Tree}

CursorBegin   { cursor: SlotId, coll: Operand }
CursorDone    { dest: ValueId, cursor: SlotId }            // dest: Bool
CursorElem    { dest: ValueId, cursor: SlotId }            // dest: Ref(element or key)
CursorValue   { dest: ValueId, cursor: SlotId }            // dest: Ref(value)
CursorAdvance { cursor: SlotId, coll: Operand }

CursorKind picks the layout (struct_layout::<rtdt::StrideCursor>() and so on) and the functions. CursorAdvance names the collection for the tree's descriptor; the other kinds ignore it.

A loop lowers to:

    cursorbegin s_c, v_coll
    slotstore.copy s_i, 0             // indexed form only
    goto head
head:
    d = cursordone s_c
    brif d, exit, body
body:
    r = cursorelem s_c
    v = cursorvalue s_c               // maps only
    i = slotload.copy s_i             // indexed form only
    ...                               // item is *r
    goto next
next:                                 // continue jumps here
    cursoradvance s_c, v_coll
    slotstore.copy s_i, (add i, 1)    // indexed form only
    goto head
exit:

The element reference is made once per iteration, at the head of the body, and item is bound to Operand::ValueRef(r). Under fat generics a Ref is a plain pointer described by the table entry for its pointee (plan-fat-generics.md, "What it does for other work"), and r lives only within one iteration, dominating every use. The precedent is promoted function consts, the one named Ref value today (const/src/promote.rs). An index binding is the loaded i, as Operand::Value, so it cannot be assigned either.

The indexed form

The index is a counter the lowering keeps, and only when the loop asks for one. It is never more than the collection's length, so its increment cannot overflow, and no source code has to write the wrapping add. Lists and tensors have it.

Sets and maps do not, for now:

Nothing in the protocol would change if they did: the counter is the lowering's, not the cursor's.

Tradeoffs

A protocol of runtime functions, open-coded later. The full protocol is one place to read, and the interpreter runs exactly it. Open-coding is an optimization in Cranelift and C AOT, and it has to keep the cursor's layout and meaning, field for field: OSR enters compiled code with a cursor the interpreter built. The cost is speed until then. A list for is three calls per element in every engine; the bytecode's loop while it replaces is a fused bounds check and an inline element reference. Measure before and after open-coding.

Three cursor types, not one. One struct for every kind needed fields some kinds ignored and a step meaning bytes, elements or a slot. Per-kind structs are each minimal, and the IR type names its kind, so a backend still knows the layout statically.

A count for strided cursors, a sentinel for trees. An end pointer fails for zero-sized elements, as above. A tree has no such problem, since it moves by slot, and its chain already ends in null, so a count would be bookkeeping the walk does not need. Both done tests are one load and one compare.

Testing at the top. The loop calls done once more than a rotated loop would, on entry. In exchange, begin and advance are total over the states a loop can reach, and no caller has to check emptiness first.

The row view inside the cursor. The earlier plan built the view in caller-lent scratch. Inside the cursor it needs no scratch in any backend, and the C AOT static view (see Bugs) never comes into it. The cost is a cursor of 48 bytes (64 under index-64), and an element pointer pointing into the cursor rather than the collection.

The index in lowering, not in the cursor. A loop without i pays nothing for it. A loop with it keeps a counter beside a cursor that, for a list, could have yielded it by subtraction and division. That is the case to revisit if open-coding makes the counter visible in profiles.

Front end

Every statement begins with its own keyword, so for takes no name away (botspec.md 2.1). in sits where no name can, as while does after loop, and joins that list. ref after in is the mode, not a value named ref, for the same reason -- but only while the mode is mandatory. A by-value for x in xs would make the word after in either a mode or a name, and 2.1 settles that by reserving ref for values. See Open questions.

Where the statement threads through:

Site Change
ast/src/ast.rs, ast_serde.rs Statement::For(StmtFor): bindings, iteratee place, body
ast/src/reachable.rs statement_completes is true; contains_break does not descend into it
parser/src/statement.rs dispatch on for, parse_for after parse_loop, the P001 keyword list
sema/src/lib.rs a new StmtKey kind (18)
tycheck/src/statement.rs the iteratee's type gives the bindings' types; arity by kind; loop_depth
ownership/src/lib.rs see Ownership
lower/src/stmt.rs, script.rs, context.rs the loop shape; LoopLowerContext gains next, where continue goes: the header for loop, the advance for for
worldgen the shape in ALL_SHAPES and the generator
datalove-mode.el, the four datalove-prism.js copies keyword and indentation

The script lowering duplicates break/continue; both copies need the continue target. The cursor and counter slots are not bindings, so the lowering stores and loads them with the SlotStoreCopy and SlotLoadCopy instructions directly. emit_slot_store_copy asks is_slot_tracked, which panics for a slot with no binding.

Ownership

Backends

Calls the protocol Open-coded
IR walker every operation nothing
Bytecode every operation: done, elem, value and every advance as their own ops (Op::Cursor*), begin on the walker nothing
Cranelift every begin every done, elem and value; every advance, a tree's across leaves included
C AOT as Cranelift as Cranelift

The bytecode's ops call the runtime's functions, which thin LTO inlines into the bytecode loop, so the interpreter runs the protocol as written and still beats the index loop it replaces. The compiled backends read every field offset with offset_of! on the rtdt structs, so there is no second description of a cursor's layout to drift from the runtime's.

Bugs found on the way

What was built

What the plan says, with these differences:

Fixtures: engines/for_001 to for_009, std_tests/179_for_over_a_shrunk_set, std_tests/182_for_cursor_bookkeeping, error/96 to 103, and datalove-rt-tests/tests/cursor_tests.rs for the protocol alone, transposed and sliced tensors included, which no program can build yet.

Measurements

Release build, one machine, hyperfine means. A list of 100,000 u32s summed 200 times, and a set of 5,000 summed 20 times:

interp JIT AOT
list, index loop 230 ms 38 ms 18.1 ms
list, for, every operation a call 487 ms 155 ms 52 ms
list, for, as built 193 ms 34 ms 16.4 ms
set, set.get by position 58 ms 61 ms 53 ms
set, for 7.2 ms 7.5 ms 1.4 ms

Calls for every operation cost the list two to four times the index loop, which is why open-coding came before porting sys/std. As built, a list's for is faster than its index loop on every engine, and a set's is linear rather than quadratic.

benchvs, before phase 4 and after phase 5:

interp JIT AOT
wordfreq before 851 ms 521 ms 470 ms
wordfreq after 831 ms 509 ms 445 ms
ordered before 2.87 s 674 ms 505 ms
ordered after 2.83 s 670 ms 504 ms

wordfreq stops building a map's keys and values as lists to walk them. ordered spends its time in binary searches, and its one converted walk is in the noise.

Advance reading only the cursor, before and after, on a set of 5,000 u32s summed 2,000 times, wall time with startup included:

interp JIT Cranelift AOT C AOT
tree advance takes the descriptor 183 ms 16.8 ms 13.2 ms 27.1 ms
tree advance reads only the cursor 119 ms 15.5 ms 11.5 ms 27.9 ms

The interpreter's gain is the bytecode running a tree's advance as its own op rather than handing it to the walker. The compiled backends no longer call the runtime at the end of each leaf. A list's begin becoming a call made no difference on a list of 100,000 summed 200 times.

Phases

All five are done.

  1. The runtime protocol, all three cursors, with datalove-rt-tests walking each kind against the positional reads: empty, one element, a [()], lists of linear elements, a transposed and a sliced tensor, rank 2 and 3, trees of one leaf and of several levels, trees after removals have merged leaves, and bulk-built trees.
  2. for over lists on the interpreter: the front end, freezing, the IR, the loop shape, the interpreter's calls. Engine fixtures for: empty, one element, break and continue, a body that never reads item, linear and copy elements, a generic [T], nested loops, a ref parameter, a field and an index projection, each freeze error.
  3. Every collection on every engine, through the calls: tensors, sets and maps, the indexed form, Cranelift and C AOT, the generics gates.
  4. Port sys/std and the demos. ord.contains, index_of, count_of, least, greatest, fixedint.sum/product, list.flattened and concat, the six walks in set.dfm (where is_subset_of gains its early exit), the keys()-then-get_or loops in the demos, and wordfreq.top_checksum. Measure benchvs/ordered.dfs and wordfreq against the loop while versions.
  5. Open-code the strided and row cursors, and the tree's done, elem, value and in-leaf advance, in Cranelift and C AOT. Measure again. Bytecode ops if the interpreter's numbers call for them.

Open questions