Lowent Manual←↑→

24 pipe — one line for each thing you mean to do

What to know first

chapter 7, Flow · for walks slices and counting loops use while
chapter 9, Sequences · elements can be written only through a mut slice
chapter 22, Generics · named ops are passed instead of values

Looking back

When chapter 7 asked “how do you write a loop counting from 0 to n with for?”, what did it say takes care of filtering, counting and collecting over a slice?

A. pipe. Counting loops are written with while and var, and filtering, transforming and collecting over a slice with pipe. This chapter covers that pipe.

The need for this chapter, and its context

A loop walking a slice almost always has the same skeleton: keep an index, check for the end, take an element, test it, accumulate something, advance the index. Written by hand, what you mean to do (“keep only the digits and count them”) gets buried among indices and counters, and off-by-one mistakes happen there. pipe hands the skeleton to the language and lets people write only what they mean, one word per line. And it still walks once, just like the hand-written loop. As the last chapter of Part VI on abstraction, it shows an abstraction that hides no cost.

By the end of this chapter

You will learn the shape pipe <source> do <stages…> <terminal> end . and its seven stages and five terminals. You will pick up how to pass named ops to stages, how to collect into the caller’s buffer with collect into, and that fold, any, all and take read only as much as needed. You will also understand that walking once without intermediate arrays is not an optimisation but the definition of pipe.

The questions this chapter answers

  1. An op that uses collect into has effects none in its head. It changes the caller’s buffer — is that not an effect?

24.1 The same work, two shapes#

examples/ch24/digits.low

module digits .
rem run: digits_loop [97,49,50,98,51]
rem run: digits_pipe [97,49,50,98,51]

fn is_digit input c u8 . output bool .
do
  return and ge c. 48 . le c. 57 . . .
end .

fn digits_loop input s slice u8 . output u64 .
do
  var n u64 0 .
  var i usize 0 .
  while lt i. len s. . . do
    if is_digit idx s. i. . . do
      set n. add n. 1 . .
    end .
    set i. add i. 1 . .
  end .
  return n. .
end .

fn digits_pipe input s slice u8 . output u64 .
do
  return pipe s. do
    filter is_digit .
    count .
  end . .
end .

Output

$ lowentc --run digits_loop digits.low [97,49,50,98,51]
digits_loop([97,49,50,98,51]) = 3
  arg0 (written) = [97,49,50,98,51]
$ lowentc --run digits_pipe digits.low [97,49,50,98,51]
digits_pipe([97,49,50,98,51]) = 3
  arg0 (written) = [97,49,50,98,51]

The two ops give the same answer. digits_loop has a person line up the counter n, the index i, the end condition and the increments. digits_pipe is two lines.

pipe is a statement, but when it ends with a value-producing terminal (count, fold, any, all) it can be used like an expression, as in return pipe s do … end . — the final stop belongs to the return statement (end closes only the pipe block).

Drawn as a tube that elements flow through:

 s = "a1b22"
               ┌─────────────────┐            ┌───────┐
  a 1 b 2 2 ─▶ │ filter is_digit │ ─▶ 1 2 2 ─▶ │ count │ ─▶ 3
               └─────────────────┘            └───────┘
                 stage: filters                 terminal: ends the flow and gives a value

Elements pass through the tube one at a time. No separate “array of just the digits” is built in between.

24.2 Stages and terminals#

WordShape of the opWhat it doesEnd · empty flow
filter <op>t → boolPasses only elements for which the op is true—
map <op>t → uPasses the op’s answer for each element; the type may become u—
take <n>—Passes the first nstops walking after n
skip <n>—Drops the first n—
enumerate <op>(u64, t) → uGives the position (from 0) and the element to the op—
zip <slice> <op>(t, t2) → uGives the element and the other slice’s element at the same place to the opends when the shorter side ends
scan <init> <op>(a, t) → aPasses the accumulator after each step (the initial value is not passed)—
collect into <buffer>—Terminal. Fills the buffer from the front and gives how many it stored (u64)a buffer too short is refused at translation when the lengths are known (E-COLLECT-FULL), otherwise the run stops
fold <init> <op>(a, t) → aTerminal. Gives the last accumulator (a)empty: the initial value
count—Terminal. Gives the number of elements (u64)empty: 0
any <op>t → boolTerminal. Is any element truestops at the first true · empty: false
all <op>t → boolTerminal. Are all elements truestops at the first false · empty: true

Table 24.1 — The words of pipe — element type t, accumulator type a

The op given to filter·any·all is a predicate and must answer bool. An op that answers a number is refused with E-PIPE-PRED — there is no rule here that reads a nonzero number as true (the same reason as cast in chapter 13).

Used as a value — return pipe … end . — a pipe is worth what its terminal gives. Each stage hands elements on to the next line, and the terminal takes them last and makes one value.

 xs = [1, 5, 2, 7]
      │
      ▼  filter big      (big = gt x 2)       5, 7          1 and 2 are dropped here
      ▼  map dbl         (dbl = x + x)        10, 14
      ▼  collect into out                     out = [10, 14, …]   answer = 2 (how many stored)

examples/ch24/stages.low

module stages .
rem run: big_doubled [1,2,3,4,5] [0,0,0,0,0]
rem run: sum_big [1,2,3,4,5]
rem run: has_zero [4,0,9]
rem run: all_small [1,2,3]
rem run: middle [10,20,30,40] [0,0]

fn over2 input a u8 . output bool .
do
  return gt a. 2 . .
end .

fn dbl input a u8 . output u8 .
do
  return wrap_add a. a. . .
end .

fn addu input acc u64 . input x u8 . output u64 .
do
  return wrap_add acc. widen u64 x. . . .
end .

fn is_zero input a u8 . output bool .
do
  return eq a. 0 . .
end .

fn under10 input a u8 . output bool .
do
  return lt a. 10 . .
end .

proc big_doubled input xs slice u8 . input out mut slice u8 . output usize . effects none .
do
  pipe xs. do
    filter over2 .
    map dbl .
    collect into out. .
  end .
  return len out. . .
end .

fn sum_big input xs slice u8 . output u64 .
do
  return pipe xs. do
    filter over2 .
    fold 0 addu .
  end . .
end .

fn has_zero input xs slice u8 . output bool .
do
  return pipe xs. do
    any is_zero .
  end . .
end .

fn all_small input xs slice u8 . output bool .
do
  return pipe xs. do
    all under10 .
  end . .
end .

proc middle input xs slice u8 . input out mut slice u8 . output usize . effects none .
do
  pipe xs. do
    skip 1 .
    take 2 .
    collect into out. .
  end .
  return len out. . .
end .

Output

$ lowentc --run big_doubled stages.low [1,2,3,4,5] [0,0,0,0,0]
big_doubled([1,2,3,4,5], [6,8,10,0,0]) = 5
  arg0 (written) = [1,2,3,4,5]
  arg1 (written) = [6,8,10,0,0]
$ lowentc --run sum_big stages.low [1,2,3,4,5]
sum_big([1,2,3,4,5]) = 12
  arg0 (written) = [1,2,3,4,5]
$ lowentc --run has_zero stages.low [4,0,9]
has_zero([4,0,9]) = 1
  arg0 (written) = [4,0,9]
$ lowentc --run all_small stages.low [1,2,3]
all_small([1,2,3]) = 1
  arg0 (written) = [1,2,3]
$ lowentc --run middle stages.low [10,20,30,40] [0,0]
middle([10,20,30,40], [20,30]) = 2
  arg0 (written) = [10,20,30,40]
  arg1 (written) = [20,30]

A stage is given a named op. This language has no anonymous functions (lambdas). A stage op takes one element and receives no capabilities. The ops of fold and scan take the accumulator and an element.

Without tuples, how are positions or pairs handled? The pair is not built but passed to the op as arguments.

examples/ch24/pairs.low

module pairs .
rem run: with_index [10,10,10] [0,0,0]
rem run: added [1,2,3] [10,20,30] [0,0,0]
rem run: running [1,2,3,4] [0,0,0,0]

fn idxadd input i u64 . input e u8 . output u8 .
do
  return wrap_add e. narrow_wrap u8 i. . . .
end .

fn addb input a u8 . input b u8 . output u8 .
do
  return wrap_add a. b. . .
end .

proc with_index input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
  pipe xs. do
    enumerate idxadd .
    collect into out. .
  end .
  return 0 .
end .

proc added input xs slice u8 . input ys slice u8 . input out mut slice u8 . output u64 . effects none .
do
  pipe xs. do
    zip ys. addb .
    collect into out. .
  end .
  return 0 .
end .

proc running input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
  pipe xs. do
    scan 0 addb .
    collect into out. .
  end .
  return 0 .
end .

Output

$ lowentc --run with_index pairs.low [10,10,10] [0,0,0]
with_index([10,10,10], [10,11,12]) = 0
  arg0 (written) = [10,10,10]
  arg1 (written) = [10,11,12]
$ lowentc --run added pairs.low [1,2,3] [10,20,30] [0,0,0]
added([1,2,3], [10,20,30], [11,22,33]) = 0
  arg0 (written) = [1,2,3]
  arg1 (written) = [10,20,30]
  arg2 (written) = [11,22,33]
$ lowentc --run running pairs.low [1,2,3,4] [0,0,0,0]
running([1,2,3,4], [1,3,6,10]) = 0
  arg0 (written) = [1,2,3,4]
  arg1 (written) = [1,3,6,10]

enumerate idxadd gives idxadd the position and the element. zip ys addb gives addb an element of xs and the element of ys at the same position; when the shorter one ends the whole flow ends, so pairs always match. scan 0 addb passes the running sum on as the element. Since pairs are never built and immediately taken apart, there is no hidden allocation.

24.2.1 Context — with#

To hand a stage op a value decided at run time, such as a threshold, write with <expr> after the op name. The expression is computed once, before the pass, and its value goes to every call as the last argument.

examples/ch24/context.low

module context .
rem run: count_above [1,5,9,12] 4 3
rem run: clamp_all [1,5,9,12] [0,0,0,0] 4 9

struct band do
  lo u8 .
  hi u8 .
end .

fn above input x u8 . input limit u8 . output bool .
do
  return gt x. limit. . .
end .

rem a threshold known only at run time --- computed once before the pass and handed over as the last argument
export fn count_above input xs slice u8 . input base u8 . input margin u8 . output u64 .
do
  return pipe xs. do
    filter above with wrap_add base. margin. . .
    count .
  end . .
end .

fn clamp input x u8 . input b band . output u8 .
do
  if lt x. field b. lo . . do
    return field b. lo . .
  end .
  if gt x. field b. hi . . do
    return field b. hi . .
  end .
  return x. .
end .

rem several settings go into one struct without references
export proc clamp_all input xs slice u8 . input out mut slice u8 . input lo u8 . input hi u8 . output u64 . effects state .
do
  return pipe xs. do
    map clamp with lit band do lo lo. . hi hi. . end . .
    collect into out. .
  end . .
end .

Output

$ lowentc --run count_above context.low [1,5,9,12] 4 3
count_above([1,5,9,12], 4, 3) = 2
  arg0 (written) = [1,5,9,12]
$ lowentc --run clamp_all context.low [1,5,9,12] [0,0,0,0] 4 9
clamp_all([1,5,9,12], [4,5,9,9], 4, 9) = 4
  arg0 (written) = [1,5,9,12]
  arg1 (written) = [4,5,9,9]

examples/ch24/mistake_context.low

module mistake_context .
rem expect: E-PIPE-CONTEXT-TYPE

rem ✘ a context is a value without references --- a slice carries an address, so it cannot be one
fn over input x u8 . input s slice u8 . output bool .
do
  return gt x. idx s. 0 . . .
end .

export fn count_over input xs slice u8 . output u64 .
do
  return pipe xs. do
    filter over with xs. .
    count .
  end . .
end .

Output

$ lowentc --check mistake_context.low
mistake_context.low:13:0 E-PIPE-CONTEXT-TYPE: a `with` context is a value without references (RFC-0121 §6.2) — this op's context parameter `s` holds a slice (it carries an address — the context would keep watching storage outside the pipe). Put the settings into a struct of numbers, bools and enums, or call the op in an explicit loop

Q. An op that uses collect into has effects none in its head. It changes the caller’s buffer — is that not an effect?

A. The specification’s examples also write effects none. collect into does not require effects state, but if you write it the processor counts the write to caller storage, so no “declared but not performed” warning appears — the same judgement it makes for writes through a mut or mut_ref parameter. Either way, the caller knows writes may happen where it hands over a mut slice (chapter 12).

24.3 Walking once is the definition#

A single pipe runs in one pass, and no intermediate arrays are created between stages. This is not an optimisation the processor may try but the definition of pipe, and a processor that creates intermediate arrays does not conform.

Why make it the definition? If fusion were an optimisation, “how far fusion goes” would differ between processors, and users would hit cliffs — change one line and suddenly an intermediate array appears and things slow down. That cliff is invisible in the source. Lowent left operations that cannot be fused out of the stage words entirely. That is why operations needing to see everything, such as sorting, are not stages.

examples/ch24/bad_stage.low

module bad_stage .
rem expect: E-PIPE-STAGE

fn is_digit input c u8 . output bool .
do
  return and ge c. 48 . le c. 57 . . .
end .

fn broken input s slice u8 . output u64 .
do
  return pipe s. do
    sort .
    count .
  end . .
end .

Output

$ lowentc --check bad_stage.low
bad_stage.low:12:0 E-PIPE-STAGE: this word is not a `pipe` stage. The stage vocabulary is CLOSED (RFC-0010 G5/D-A): stages are `filter <op> .`, `map <op> .`, `take <n> .`, `skip <n> .`, `enumerate <op> .`, `zip <other> <op> .` and `scan <init> <op> .`, and the pipeline ends with exactly one terminal — `collect into <mut slice> .`, `fold <init> <op> .`, `count .`, `any <op> .` or `all <op> .`. Fusion here is the MEANING, not an optimization, so anything that could not fuse into the single loop is not writable as a stage — use an explicit loop, or break the pipeline with an explicit intermediate collect

The list of stages is closed. pipe being a statement rather than an expression has the same reason: as an expression, stages could be detached and passed as values, and where one stream ends would not be visible in the source. do … end makes that boundary visible.

24.4 Reading only as much as needed#

any stops at the first element giving true, all at the first giving false, and take n after passing n elements. Elements after the stop are not read, and stages are not run for them. has_zero [4,0,9] never looks at 9. Only because this property is written into the meaning can pipe handle sources without an end.

A terminal ends the flow. A stage after the terminal is rejected.

examples/ch24/no_terminal.low

module no_terminal .
rem expect: E-PIPE-NO-TERMINAL

fn is_digit input c u8 . output bool .
do
  return and ge c. 48 . le c. 57 . . .
end .

fn broken input s slice u8 . output u64 .
do
  return pipe s. do
    count .
    filter is_digit .
  end . .
end .

Output

$ lowentc --check no_terminal.low
no_terminal.low:13:0 E-PIPE-NO-TERMINAL: a `pipe` stage may not come AFTER the terminal — the terminal ends the pipeline (RFC-0010 G1)

A common misconception. pipe is convenient but slower than a hand-written loop

In many languages iterator chains leave intermediate objects or indirect calls behind, or hope the optimiser removes them. For pipe, walking once is the definition, and stage ops are monomorphised direct calls. One pipe lowers to one loop, and its costs arise at the same places as in a hand-written loop. The words were chosen so that the convenient way and the fast way do not diverge.

24.5 Copying, keeping, folding — write it with pipe#

To copy one slice into another, keep only what matches, or fold a slice into one value, write a pipe. The core ops map, filter and fold used to do this outside pipe, and in 1.13 the module collection took over. But pipe already does the job, and does it better — collection is not needed for it (RFC-0150 §8-24, 2026-10-21).

module pipe_move .

fn scale input x u8 . input k u16 . output u16 . do
  return mul cast u16 x. . k. . .
end .

proc scaled input xs slice u8 . input k u16 . input out mut slice u16 . output u64 . effects none .
do
  return pipe xs. do
    map scale with k. .
    collect into out. .
  end . .
end .

scaled [1,2,3] 300 puts 300, 600 and 900 into out and answers 3. This short text does three things collection cannot.

An op used in one place only goes inside that body as a local op. Writing the 1.12 prefix map f. src. dst. . makes the compiler point to this shape.

24.5.1 When the element type carries the work — the module collection#

map, filter and fold of collection remain for generic code where the element type has to carry the work. This language has no way to pass an op as a value, and a trait does not take the element type as a parameter. So the element is wrapped in a struct of one field, and that struct satisfies the traits collection.mappable (apply), collection.keepable (keep) and collection.foldable (combine). It is the same way the sorting module sortgen finds its comparison in the element type.

module sink_map .

use collection .

struct twice do
  satisfies collection.mappable .
  v u8 .
end .

fn twice.apply input x twice . output twice . do
  return lit twice do v wrap_add field x. v . field x. v . . . end . .
end .

proc doubled input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
  return collection.map twice. view_array twice xs. . view_array twice out. . . .
end .

Putting [1,2] into a three-slot out gives [2,4,0], and the answer is the number written, 2. The other way round, putting [1,2,3] into two slots stops the run as the third is written — a full sink never drops the rest in silence (the same as collect into). view_array twice xs. sees the bytes as a slice of twice; a struct of one field has the same layout. One element type has one apply, so moving the same bytes in two ways takes two wrapper types — write such jobs with the pipe above.

24.6 Common mistakes#

Counter-example. Writing an expression in a stage to imitate a lambda

examples/ch24/mistake_lambda.low

module mistake_lambda .
rem expect: E-FOLD-OP

fn count_big input xs slice u8 . output u64 .
do
  return pipe xs. do
    rem ✘ imitates an anonymous function --- a stage takes one named op
    filter gt 2 .
    count .
  end . .
end .

Output

$ lowentc --check mistake_lambda.low
mistake_lambda.low:8:0 E-FOLD-OP: this `pipe` stage names an op that does not exist

Carrying over filter(x => x > 2) from another language easily gives filter gt 2. A stage takes one named op, so this is E-FOLD-OP (the diagnostic only says “no such op”). Naming a condition may feel like extra work, but the name over2 becomes the explanation of that line, and the same condition can be reused in other pipes.

examples/ch24/lambda_fixed.low

module lambda_fixed .
rem run: count_big [1,2,3,4,5]

rem give the condition a name --- the name becomes its explanation
fn over2 input a u8 . output bool .
do
  return gt a. 2 . .
end .

fn count_big input xs slice u8 . output u64 .
do
  return pipe xs. do
    filter over2 .
    count .
  end . .
end .

Output

$ lowentc --run count_big lambda_fixed.low [1,2,3,4,5]
count_big([1,2,3,4,5]) = 3
  arg0 (written) = [1,2,3,4,5]

Counter-example. Collecting a map that produces wide values into a narrow buffer

examples/ch24/mistake_widecollect.low

module mistake_widecollect .
rem expect: E-TYPE-COLLECT

fn times1000 input a u8 . output u64 .
do
  return mul widen u64 a. . 1000 . .
end .

proc scale input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
  pipe xs. do
    rem ✘ a `map` producing `u64` is collected into a `u8` buffer --- 1000 becomes 232
    map times1000 .
    collect into out. .
  end .
  return 0 .
end .

Output

$ lowentc --check mistake_widecollect.low
mistake_widecollect.low:14:0 E-TYPE-COLLECT: this `collect into` would put a WIDER value into a narrower buffer, and that loses bits silently (measured: 1000 became 232). A conversion that loses value never happens implicitly here (§6.2.5) — put a `map` in front that says which narrowing you mean (`narrow`, `narrow_wrap`, `narrow_sat`, `narrow_try`), or collect into a buffer of the produced width

times1000 produces u64, but out is a u8 buffer. A narrowing that loses value happens only where it is written (§6.2.5), so this is refused with E-TYPE-COLLECT. Until 2026-09-16 it passed and 1000 and 2000 were stored silently as 232 and 208. If narrowing is needed, write narrow, narrow_wrap or narrow_sat inside the op given to map, so the place where it may stop is visible.

Counter-example. Swapping the accumulator and the element in a fold op

examples/ch24/mistake_foldorder.low

module mistake_foldorder .
rem expect: E-FOLD-ORDER

rem ✘ takes the element first --- the accumulator arrives as `x`, the element as `acc`
fn add_small_swapped input x u8 . input acc u64 . output u64 .
  requires le acc. 1000000 . .
do
  guard lt x. 100 . else return acc. . .
  return add acc. widen u64 x. . . .
end .

fn sum_small_wrong input xs slice u8 . output u64 .
do
  return pipe xs. do
    fold 0 add_small_swapped .
  end . .
end .

Output

$ lowentc --check mistake_foldorder.low
mistake_foldorder.low:15:0 E-FOLD-ORDER: the accumulator of a `fold`/`scan` is the FIRST input of its op and also its output, because each step computes `acc = op(acc, element)`. Here the first input and the output are declared with different types, so the running total is handed to a parameter that cannot hold it — a `u8` first input takes a total of 600 as 88 and the answer is silently wrong. Write the op as `input acc <out-type> . input x <element-type> .`

fold gives its op the accumulator first and the element second. add_small_swapped takes them the other way round, so x receives the accumulator and acc receives the element. Each step is acc = op(acc, element), so the first input must have the same type as the output; here they are u8 and u64, and the program is refused with E-FOLD-ORDER. Always write the op of fold or scan in the order input acc … . input x … ..

Counter-example. Using a stage op that can stop, in a fn

examples/ch24/mistake_stageeffect.low

module mistake_stageeffect .
rem expect: E-EFFECT-CALC

proc nonzero input a u8 . output bool . effects panic .
do
  if eq a. 0 . do panic "zero in input" . end .
  return true .
end .

rem ✘ the stage op can stop, yet the op using the `pipe` is written as a `fn`
fn count_checked input xs slice u8 . output u64 .
do
  return pipe xs. do
    filter nonzero .
    count .
  end . .
end .

Output

$ lowentc --check mistake_stageeffect.low
mistake_stageeffect.low:12:1 E-EFFECT-CALC: this fn is declared pure but performs `panic` — make it a `proc` with `effects …`, or remove the effect

A pipe is a loop that calls its stage ops, so the effects of a stage op spread to the op containing the pipe. nonzero may panic, so count_checked performs panic too, and as a fn it is E-EFFECT-CALC. Write it as proc … effects panic ., or instead of stopping, use a pure op that filters out the elements that do not meet the condition.

A common misconception. collect into drops the rest when the buffer is full

examples/ch24/short_buffer.low

module short_buffer .
rem run: big_doubled [1,3,4] [0,0]
rem trap: big_doubled [3,4,5,6] [0,0]

fn over2 input a u8 . output bool .
do
  return gt a. 2 . .
end .

fn dbl input a u8 . output u8 .
do
  return wrap_add a. a. . .
end .

rem the buffer has two slots --- two remaining elements fit; with four, the run stops as the third is stored
proc big_doubled input xs slice u8 . input out mut slice u8 . output usize . effects none .
do
  pipe xs. do
    filter over2 .
    map dbl .
    collect into out. .
  end .
  return len out. . .
end .

Output

$ lowentc --run big_doubled short_buffer.low [1,3,4] [0,0]
big_doubled([1,3,4], [6,8]) = 2
  arg0 (written) = [1,3,4]
  arg1 (written) = [6,8]
$ lowentc --run big_doubled short_buffer.low [3,4,5,6] [0,0]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: collect into / map / filter: the receiving slice is full and the source still has elements (panic). A full sink never drops the rest in silence (X-0062): give a longer buffer, or say how many with take N

From [1,3,4] two elements remain (3, 4), and they fit in two slots. [3,4,5,6] leaves four, so the run stops as the third is stored. It used to end quietly after two, and nobody learned that the other two were gone. When both lengths are known at translation (an array 5 u8 input, say), there is nothing to stop: it is refused with E-COLLECT-FULL. A pipe does not allocate, so it does not grow the buffer either. To keep only what fits, write take 2.

24.7 This chapter’s syntax at a glance#

ShapeMeaningWhy
pipe xs. do … end .scan the source xs oncethe language owns the skeleton (index, end test)
filter over2 . · map dbl .keep · transform — pass a named opno lambdas — the name is the explanation
take 2 . · skip 1 .only the first few · drop the first fewread only as much as needed
enumerate idxadd . · zip ys addb .pass the index or partner as op argumentsno tuples are built
scan 0 addb . · fold 0 addu .emit running values · accumulate into one valuethe op takes the accumulator first, then the element
filter above with limit .context — computed once before the pass, the op’s last argumenta run-time value without a lambda — values without references only
count . · any is_zero . · all under10 .terminators that produce a valueusable as return pipe … end . .
collect into out .store into the caller’s buffera pipe never allocates — a buffer too short is refused or stops the run
exactly one terminator, at the enda stage after it is E-PIPE-NO-TERMINALthe end of the flow is in one place
a word like sortdoes not exist — E-PIPE-STAGEoperations that cannot fuse were left out

Table 24.2 — pipe syntax — shape · meaning · why it looks this way

Recap

pipe <source> do … end . passes through stages (filter, map, take, skip, enumerate, zip, scan) and ends with one terminal (collect into, fold, count, any, all). Stages take named ops, and the buffer to collect into is given by the caller. Walking once with no intermediate arrays is the definition, and operations that cannot be fused are not in the vocabulary. any, all and take read only as much as needed.