Lowent Manual←↑→

9 Sequences — arrays and slices

What to know first

chapter 7, Flow · for walks a slice, and guard turns a condition into a fact
chapter 5, Ops · writes visible to the caller need a proc

Looking back

In chapter 7′s head_or_zero, why was idx data. 0 . safe?

A. Because the guard ge len data. . 1 . else return 0 . . right before it handed the code below the fact that the slice was not empty. Code after a guard lives only in a world where the condition is true. This chapter covers what that slice is, and when bounds checks remain and when they disappear.

The need for this chapter, and its context

The most expensive defects in C come from people remembering separately how many items are at the address a pointer points to, and getting it wrong. That is the buffer overflow. Lowent has no pointers; pointing at several values is the job of a slice, which carries its length with it. Slices open Part III because nearly all data — strings, buffers, file contents — travels as slices.

By the end of this chapter

You will learn the difference between array n t and slice t, how to read with len, idx and for, and that out-of-range access stops. You will pick up the rule that writing elements requires a mut slice, and how to narrow a window with subslice. You will also see the principle by which contracts remove bounds checks in the body, and that a string literal is a table that can be indexed directly.

The questions this chapter answers

  1. Is there no separate string type?
  2. Can [3,4,5] given to --run be used for a slice u64?

9.1 Start and length together#

slice t is a contiguous run of values of type t. A slice value carries its start address and its length together, so nobody has to remember separately “how many are here”.

examples/ch09/basics.low

module basics .
rem run: head [9,8,7]
rem run: last4 [1,2,3,4]
rem run: total [10,20,30]
rem trap: at [1,2,3] 5

fn head input data slice u8 .
  output u8 .
  requires ge len data. . 1 . .
do
  return idx data. 0 . .
end .

fn last4 input xs array 4 u8 . output u8 .
do
  return idx xs. 3 . .
end .

fn total input xs slice u8 . output u64 .
do
  var acc u64 0 .
  for x xs. do
    set acc. add acc. widen u64 x. . . .
  end .
  return acc. .
end .

fn at input xs slice u8 . input i usize . output u8 .
do
  return idx xs. i. . .
end .

Output

$ lowentc --run head basics.low [9,8,7]
head([9,8,7]) = 9
  arg0 (written) = [9,8,7]
$ lowentc --run last4 basics.low [1,2,3,4]
last4([1,2,3,4]) = 4
  arg0 (written) = [1,2,3,4]
$ lowentc --run total basics.low [10,20,30]
total([10,20,30]) = 60
  arg0 (written) = [10,20,30]
$ lowentc --run at basics.low [1,2,3] 5
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

An array writes the length first and the element type after it — the same order as vec 4 u32 and matrix 2 3 4 u32. Because the element type comes last, an array of arrays needs no parentheses: array 3 array 4 u8. The reverse order is rejected, and lowentc --fmt rewrites it.

examples/ch09/array_order.low

module array_order .
rem expect: E-TYPE-ORDER

fn last input xs array u8 4 . output u8 .
do
  return idx xs. 3 . .
end .

Output

$ lowentc --check array_order.low
4:24 E-TYPE-ORDER: the count comes first: `array <count> <element type>` — `array 4 u8`, and an array of arrays is `array 3 array 4 u8`. The old order `array u8 4` is no longer read; `lowentc --fmt` rewrites it — found `u8` (RFC-0142: a name opens, a stop closes; a vari

For one month from 2026-09-27 the element type came first (array u8 4). On 2026-10-29 the length moved back to the front, and vec and matrix moved with it, so all three share one order. Today array is used only in op input positions; in outputs, locals and struct fields, write slice with a contract.

Q. Is there no separate string type?

A. There is not. A string literal "hello" is a slice u8. Questions such as how many characters it has or whether it is valid UTF-8 are answered by library ops, not by a type (chapter 33). It is a choice to avoid the defects that start the moment bytes and characters are treated as the same thing.

9.2 Writing needs mut slice#

To write the elements of a slice, the slice must be mut. And changing bytes the caller passed is visible to the caller, so it must be a proc (chapter 5).

examples/ch09/fill.low

module fill .
rem run: fill_two [0,0,5]

proc fill_two input xs mut slice u8 . output u64 . effects state .
  requires ge len xs. . 2 . .
do
  set idx xs. 0 . 10 .
  set idx xs. 1 . 20 .
  var s u64 0 .
  for x xs. do
    set s. add s. widen u64 x. . . .
  end .
  return s. .
end .

Output

$ lowentc --run fill_two fill.low [0,0,5]
fill_two([10,20,5]) = 35
  arg0 (written) = [10,20,5]

The fill_two([10,20,5]) the VM shows is the argument’s state after the op ended; the caller’s bytes changed. Writing without mut is rejected.

examples/ch09/readonly.low

module readonly .
rem expect: E-TYPE-MUT

fn poke input xs slice u8 . output u64 .
do
  set idx xs. 0 . 1 .
  return 0 .
end .

Output

$ lowentc --check readonly.low
readonly.low:6:0 E-TYPE-MUT: writing an element of a slice that is not declared `mut` (a shared slice is read-only)

mut is a permission on the slice’s elements. It is a different question from whether the name holding the slice was made with let (chapter 6). It is followed by the rule that a slice whose elements can be written may be borrowed in only one place at a time (chapter 12).

9.3 Narrowing the window#

subslice s. from. to. . makes a new slice pointing from position from of s up to, but not including, to. It copies no bytes; it just places another, narrower window over the same bytes.

examples/ch09/windows.low

module windows .
rem run: middle_sum [1,2,3,4,5]
rem run: prefix_char 4

fn middle_sum input xs slice u8 . output u64 .
  requires ge len xs. . 3 . .
do
  let mid slice u8 subslice xs. 1 sub len xs. . 1 . . .
  var s u64 0 .
  for x mid. do
    set s. add s. widen u64 x. . . .
  end .
  return s. .
end .

fn prefix_char input k usize . output u8 .
  requires lt k. 11 . .
do
  return idx "/api/users/" k. . .
end .

Output

$ lowentc --run middle_sum windows.low [1,2,3,4,5]
middle_sum([1,2,3,4,5]) = 9
  arg0 (written) = [1,2,3,4,5]
$ lowentc --run prefix_char windows.low 4
prefix_char(4) = 47

middle_sum sums the middle elements, leaving out both ends. prefix_char indexes a string literal directly, because a literal is a slice u8.

In practice. From an if chain to one table

An op returning a constant per index is easy to write as a chain like if eq k. 0 . do return 47 . end .. The emitted C then has one comparison and branch per character. Indexing a string literal becomes one static table and one index. In a benchmark that reads HTTP requests, replacing such a chain with a table lookup brought branch mispredictions down to the level of hand-written C and cut run time by about 25%. The answer did not change by a single bit.

examples/ch09/table.low

module table .
rem run: prefix_char 4
rem run: prefix_chain 4

rem a string literal is a slice u8 and can be indexed as it is
fn prefix_char input k usize . output u8 .
  requires lt k. 11 . .
do
  return idx "/api/users/" k. . .
end .

rem the same job as an if chain costs one comparison and one branch per character
fn prefix_chain input k u64 . output u8 . do
  if eq k. 0 . do return 47 . end .
  if eq k. 1 . do return 97 . end .
  if eq k. 2 . do return 112 . end .
  if eq k. 3 . do return 105 . end .
  if eq k. 4 . do return 47 . end .
  return 0 .
end .

Output

$ lowentc --run prefix_char table.low 4
prefix_char(4) = 47
$ lowentc --run prefix_chain table.low 4
prefix_chain(4) = 47

The two ops give the same answer. Looking with --emit-c, prefix_char is one string table and one index, while prefix_chain is a chain of comparisons and gotos. In this edition, though, the index check in prefix_char remains — the analysis does not connect the literal’s length 11 with the contract lt k. 11 ..

9.4 Writing a sequence directly — list literals#

When you need a sequence written out as values, list it with lit. lit array 5 u32 2 3 5 7 11 . is an array 5 u32 value written as length, element type, then the elements; lit slice u32 4 5 6 . is a slice value with no length written (the element count is the length). A list closes with its own period. At the end of a statement the list’s period and the statement’s period come together (. .); in the middle of a call one period closes the list and the next argument follows.

examples/ch09/listlit.low

module listlit .
rem run: primes
rem run: padded
rem run: inside

fn total input xs slice u32 . output u64 .
do
  var s u64 0 .
  for x xs. do
    set s. add s. widen u64 x. . . .
  end .
  return s. .
end .

rem an array list --- element type, length, elements, then the list's own dot
export fn primes output u64 .
do
  let p lit array 5 u32 2 3 5 7 11 . .
  return total p. . .
end .

rem a trailing _ means the remaining cells are zero
export fn padded output u64 .
do
  let p lit array 6 u32 1 2 _ . .
  return add cast u64 len p. . . total p. . . .
end .

rem a slice list has no length --- the element count is the length. In the middle it closes with one dot
export fn inside output u64 .
do
  return add total lit slice u32 4 5 6 . . 1 . .
end .

Output

$ lowentc --run primes listlit.low
primes() = 28
$ lowentc --run padded listlit.low
padded() = 9
$ lowentc --run inside listlit.low
inside() = 16

If there are fewer elements than the length, end the list with _ to say “the remaining cells are zero”. Without it the list is E-LIT-COUNT — cells are never filled silently. Each element must fit its type (E-TYPE-WIDTH).

A let list whose elements are all constants is read-only bytes baked into the program. Passing that name to a mut position is refused (E-TYPE-ARGMUT). When you need a sequence you can change, bind it with var. A list bound with var, and a list with elements computed at run time, live in this op’s frame — like a local array in C. Write a cell with set idx buf. i. . v. ..

examples/ch09/framelist.low

module framelist .
rem run: squares 5
rem run: spread 3 4
rem run: again 3

rem a list bound with var is a slot in this op's frame --- write its cells with set (index …)
export fn squares input n usize . output u64 .
do
  var buf lit array 8 u64 _ . .
  var i usize 0 .
  while lt i. 8 . do
    set idx buf. i. . cast u64 mul i. i. . . .
    set i. add i. 1 . .
  end .
  guard lt n. 8 . else return 0 . .
  return idx buf. n. . .
end .

rem elements may be computed at run time --- such a let list also lives in the frame
export fn spread input a u64 . input b u64 . output u64 .
do
  let t lit array 3 u64 a. b. mul a. b. . . .
  return add idx t. 2 . cast u64 len t. . . . .
end .

rem a declaration inside a loop is filled afresh on each pass --- it never sees the last pass
export fn again input n u64 . output u64 .
do
  var total u64 0 .
  var k u64 0 .
  while lt k. n. . do
    var cell lit array 1 u64 _ . .
    set idx cell. 0 . add idx cell. 0 . 10 . .
    set total. add total. idx cell. 0 . . .
    set k. add k. 1 . .
  end .
  return total. .
end .

Output

$ lowentc --run squares framelist.low 5
squares(5) = 25
$ lowentc --run spread framelist.low 3 4
spread(3, 4) = 15
$ lowentc --run again framelist.low 3
again(3) = 30

A frame slot lives until the end of the block that declares it. When the declaration runs again (the next pass of a loop), the slot is filled afresh. So a slice that sees the slot may not leave that block — returning it, storing it in a name declared further out or in a parameter’s cell, or sending it to an actor is refused (E-LIT-ESCAPE). Reading a cell out as a scalar is fine.

examples/ch09/mistake_frameescape.low

module mistake_frameescape .
rem expect: E-LIT-ESCAPE

rem ✘ returning a var list --- its slot becomes something else when this op returns
fn hold output slice u8 . do
  var buf lit array 4 u8 1 2 3 4 . .
  return buf. .
end .

Output

$ lowentc --check mistake_frameescape.low
mistake_frameescape.low:7:0 E-LIT-ESCAPE: this carries a view of a list literal's frame storage — or of bytes an allocator gets back at the end of the block (RFC-0135 D11) — OUT of the block that declared it (a `return`, a name declared further out, a parameter's field or element, or an actor). That storage belongs to the declaring block (RFC-0132 §13.2 ⓐ): after the block — or on the next pass of a loop — the bytes are something else. Copy what you need into storage that outlives the block, or declare the list further out
mistake_frameescape.low:6:0 W-VAR-NEVER-SET: this `var` is never changed — write `let` (a `let` cannot be reassigned, which is what this name already does, and the interval analysis keeps its facts) — RFC-0113 O4

Re-pointing the array name itself with set buf … is E-ARRAY-SET — write the cells one by one. The frame lists of one op may not add up to more than 64 KiB (4 KiB on a small bare-metal machine) (E-FRAME-SIZE). An op holding frame lists may not recurse (E-FRAME-RECURSIVE) — the frame would repeat at every level, and the depth is not known at translation. If you need a buffer inside a recursion, take it from an allocator (chapter 20). And if the lists alive on the stack at once along a call path exceed the budget (4 MiB), that is E-STACK-BUDGET. An op that runs as a task (spawn) runs on a small stack, so its budget is 64 KiB. Running out of stack is ruled out before the program runs. lowentc --stack-report <file> prints, for every entry op and task, the list bytes on its deepest path and its budget.

When only a few cells of a long array differ, fill the cells you choose. Inside do … end write <idx> <value> ., and a final _ <value> . fills every remaining cell. Every cell must be decided, so a leftover cell without _ is E-LIT-COUNT. An index is a constant inside the length, written once (E-LIT-INDEX). The block closes the list, so the list has no period of its own.

examples/ch09/cellfill.low

module cellfill .
rem run: table 3 5
rem run: table 3 1

rem fill chosen cells --- name cells 0 and 5, every other cell holds 1
export fn table input a u64 . input i usize . output u64 .
do
  let t lit array 6 u64 do 0 100 . 5 mul a. 2 . . _ 1 . end . .
  guard lt i. 6 . else return 0 . .
  return idx t. i. . .
end .

Output

$ lowentc --run table cellfill.low 3 5
table(3, 5) = 6
$ lowentc --run table cellfill.low 3 1
table(3, 1) = 1

Values may be expressions and are computed in the order written.

The elements may be structs too, as long as the struct has a byte layout — every field a sized number.

examples/ch09/structlist.low

module structlist .
rem run: total 7

struct pt do
  x u32 .
  y u64 .
end .

rem a list whose elements are structs --- each element is a copy of its value, and a field is written with `(field ps 1 y)`
export fn total input a u64 . output u64 .
do
  let p pt lit pt do x 1 . y a. . end . .
  var ps lit array 3 pt p. lit pt do x 2 . y 20 . end . _ . .
  set field ps. 2 y . 100 .
  var s u64 0 .
  for q ps. do
    set s. add s. add widen u64 field q. x . . field q. y . . . .
  end .
  return s. .
end .

Output

$ lowentc --run total structlist.low 7
total(7) = 130

To copy one slice’s contents into another, write copy <to> <from> ..

examples/ch09/copying.low

module copying .
rem run: shift 9

rem `copy` copies one slice's contents into another --- overlap is fine
export fn shift input a u8 . output u64 .
do
  var buf lit array 6 u8 1 2 3 4 5 6 . .
  var dst lit array 3 u8 _ . .
  copy dst. subslice buf. 0 3 . .
  copy subslice buf. 1 6 . subslice buf. 0 5 . .
  set idx dst. 0 . a. .
  var s u64 0 .
  for x buf. do
    set s. add mul s. 10 . widen u64 x. . . .
  end .
  return add mul s. 1000 . add mul widen u64 idx dst. 0 . . 100 . widen u64 idx dst. 2 . . . . .
end .

Output

$ lowentc --run shift copying.low 9
shift(9) = 112345903

When the elements are fixed-length arrays, the list is a list of rows. Its type is slice array 4 u8.

examples/ch09/rows.low

module rows .
rem run: grid 5

rem a list of rows --- `index g 1` is a view of that row (no copy)
export fn grid input a u8 . output u64 .
do
  var g lit slice array 4 u8 lit array 4 u8 1 2 3 4 . lit array 4 u8 _ . lit array 4 u8 _ . . .
  let row mut slice u8 idx g. 1 . .
  set idx row. 2 . a. .
  set idx idx g. 2 . 3 . 9 .
  var s u64 0 .
  for r g. do
    for x r. do
      set s. add s. widen u64 x. . . .
    end .
  end .
  return add mul s. 100 . add cast u64 len g. . . mul cast u64 len idx g. 0 . . . 10 . . . .
end .

Output

$ lowentc --run grid rows.low 5
grid(5) = 2443

A row table can also sit on bytes you already have. view_array array 4 u8 b. is a slice array 4 u8 that reads b four bytes at a time — nothing is copied, and the cells can be written when b can. If the length of b is not a multiple of 4, the program stops. So one op takes a table written in the source and bytes read from elsewhere alike.

9.5 Arrays of arrays#

When the elements are arrays again, it is an array of arrays. array 2 array 3 u8 is a table of 2 rows of 3 cells laid one after the other — the same layout as C’s u8 a[2][3] — and, because the element type comes last, it needs no parentheses.

examples/ch09/nested.low

module nested .
rem run: table 7

rem an array of arrays --- 2 rows of 3. `idx g. r.` is a view of that row (no copy)
struct board do
  id u8 .
  cells array 2 array 3 u8 .
end .

fn rowsum input g array 2 array 3 u8 . input r usize . output u64 .
  requires lt r. 2 . .
do
  var s u64 0 .
  for x idx g. r. . do
    set s. add s. widen u64 x. . . .
  end .
  return s. .
end .

export fn table input a u8 . output u64 .
do
  var b lit board do id 1 . cells lit array 2 array 3 u8 lit array 3 u8 1 2 3 . _ . . end . .
  set idx idx field b. cells . 1 . 2 . a. .
  return add mul rowsum field b. cells . 0 . 100 . rowsum field b. cells . 1 . . .
end .

Output

$ lowentc --run table nested.low 7
table(7) = 607

9.6 Several dimensions — matrix#

When there are two or more dimensions and the sizes are known in advance, write a matrix. matrix 2 3 4 u32 is a table of u32 in 3 rows of 4: the number of dimensions (2), one size per dimension, then the element type (the same order as array). Because the number of dimensions comes first, it is clear where the sizes end.

examples/ch09/matrix.low

module matrix .
rem run: row_sum 2

rem a table of 3 rows and 4 columns --- the number of dimensions (2), then the sizes
fn table output t matrix 2 3 4 u32 .
do
  repeat r usize dim t. 0 . do
    repeat c usize dim t. 1 . do
      set idx t. r. c. . cast u32 add mul r. 10 . c. . . .
    end .
  end .
  return t. .
end .

rem one index per dimension --- each is checked against its own size
export fn row_sum input r usize . output u64 .
  requires lt r. 3 . .
do
  let t matrix 2 3 4 u32 table . .
  var s u64 0 .
  repeat c usize dim t. 1 . do
    set s. add s. widen u64 idx t. r. c. . . . .
  end .
  return s. .
end .

rem `flat` sees the same elements as one row
export fn total output u64 .
do
  let t matrix 2 3 4 u32 table . .
  var s u64 0 .
  for x flat t. . do
    set s. add s. widen u64 x. . . .
  end .
  return s. .
end .

Output

$ lowentc --run row_sum matrix.low 2
row_sum(2) = 86

Give as many indexes as there are dimensions. An idx with one index fewer does not mean a row — it could not be told apart from a forgotten index.

examples/ch09/mistake_matrixrow.low

module mistake_matrixrow .
rem expect: E-MATRIX-INDEX

fn f output u64 .
do
  let m lit matrix 2 2 3 u32 1 2 3 4 5 6 . .
  rem ✘ two dimensions, one index --- there is no form that takes a single row
  return widen u64 idx m. 1 . . .
end .

Output

$ lowentc --check mistake_matrixrow.low
mistake_matrixrow.low:8:20 E-MATRIX-INDEX: `idx <matrix>. i1 … ik .` takes one index per dimension — as many indexes as the type has dimensions. One row is `row m. i. .`; all the elements as one row are `flat m. .`

When you want one row, say row. The same example holds a table as a struct field and an op that does not fix its sizes.

examples/ch09/matrixmore.low

module matrixmore .
rem run: total

rem a table as a struct field
struct board do
  id u64 .
  g matrix 2 3 4 u32 .
end .

rem any size --- the sizes are translation-time inputs
fn sum input comptime r usize . input comptime c usize . input m matrix 2 r c u32 . output u64 .
do
  var t u64 0 .
  repeat i usize dim m. 0 . do
    rem `row` sees one row as a slice
    for x row m. i. . do
      set t. add t. widen u64 x. . . .
    end .
  end .
  return t. .
end .

export fn total output u64 .
do
  var b lit board do id 1 . g lit matrix 2 3 4 u32 1 2 3 4 5 6 7 8 9 10 11 12 . . end . .
  set idx field b. g . 0 0 . 100 .
  let small lit matrix 2 2 2 u32 1 2 3 4 . .
  return add widen u64 idx field b. g . 0 0 . . sum 2 2 small. . . .
end .

Output

$ lowentc --run total matrixmore.low
total() = 110

9.7 Contracts remove bounds checks#

An idx bounds check remains only where it is not proven. Write the length condition as requires, and the check happens once on entry to the op while the index checks in the body disappear.

examples/ch09/bounds.low

module bounds .
rem run: sum_first [3,4,5,6] 3

fn sum_first input a slice u8 . input n usize . output u64 .
  requires le n. len a. . . .
do
  var s u64 0 .
  var i usize 0 .
  while lt i. n. . do
    set s. add s. widen u64 idx a. i. . . . .
    set i. add i. 1 . .
  end .
  return s. .
end .

Output

$ lowentc --run sum_first bounds.low [3,4,5,6] 3
sum_first([3,4,5,6], 3) = 12
  arg0 (written) = [3,4,5,6]

With requires le n. len a. . . ., i inside while lt i n is always less than len a. .. The compiler’s interval analysis works that out and removes the check in idx a. i. .. The rules behind this reasoning are proven in Coq, and an independent checker re-verifies the arithmetic evidence the compiler leaves at every removed check (chapter 41).

One thing to watch: a count n uses le, but an index itself uses lt. requires le i. len a. . . . allows i = len a, which is one past the end.

A common misconception. Putting len in a loop condition recounts every time

len s. . is neither a call nor a memory read. It just takes the length field out of the slice value {start, length}. Writing elements does not change the length; the only way the length changes is putting a different slice into the name. In fact, storing the length ahead as let m u64 len xs. . . and then replacing xs with a shorter slice leaves m as a stale length that can go out of range. Storing it ahead is a choice of meaning, not an optimisation.

examples/ch09/stale_len.low

module stale_len .
rem run: fresh [1,2,3,4]
rem trap: stale [1,2,3,4]

rem len stays in the condition --- each round reads the current length
fn fresh input xs slice u8 . output u64 . do
  var s slice u8 xs. .
  var t u64 0 .
  var i usize 0 .
  while lt i. len s. . . do
    set t. wrap_add t. widen u64 idx s. i. . . . .
    set s. subslice s. 0 2 . .
    set i. add i. 1 . .
  end .
  return t. .
end .

rem the length was stored ahead --- m stays even when s gets shorter
fn stale input xs slice u8 . output u64 . do
  var s slice u8 xs. .
  let m usize len s. . .
  var t u64 0 .
  var i usize 0 .
  while lt i. m. . do
    set t. wrap_add t. widen u64 idx s. i. . . . .
    set s. subslice s. 0 2 . .
    set i. add i. 1 . .
  end .
  return t. .
end .

Output

$ lowentc --run fresh stale_len.low [1,2,3,4]
fresh([1,2,3,4]) = 3
  arg0 (written) = [1,2,3,4]
$ lowentc --run stale stale_len.low [1,2,3,4]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

fresh reads the current length each time round, so when s shrinks to two elements the loop stops there too. stale stored the first length 4 in m, so on the third round it tries to read element 2 of the two-element s and stops. len reads the current value every time, and that is what keeps it safe.

Q. Can [3,4,5] given to --run be used for a slice u64?

A. Bracketed --run arguments are sequences of bytes. That is why this book’s runnable examples take slices as slice u8. Passing one to a slice u64 stops the VM, because the byte count is not a multiple of 8. Slices of wider elements are usually built inside the program and passed along.

9.8 Common mistakes#

Almost every slice mistake comes down to being off by one. Where C would read someone else’s memory and carry on, Lowent stops.

Counter-example. Looping one step too far with le

examples/ch09/mistake_offbyone.low

module mistake_offbyone .
rem trap: total [1,2,3]

fn total input xs slice u8 . output u64 .
do
  var t u64 0 .
  var i usize 0 .
  rem ✘ with `le` it also runs when i is 3 --- a slice of length 3 has indexes 0, 1, 2 only
  while le i. len xs. . . do
    set t. add t. widen u64 idx xs. i. . . . .
    set i. add i. 1 . .
  end .
  return t. .
end .

Output

$ lowentc --run total mistake_offbyone.low [1,2,3]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

A slice of length 3 has indexes 0, 1 and 2. le i. len xs. . . is still true when i is 3, so reading the fourth cell stops with E-VM-BOUNDS. Because indexes start at 0, the right condition is “less than the count” — while lt i. len xs. . .. If you are walking every element, for x xs. do uses no index at all and removes the mistake completely.

Counter-example. Reading with brackets, as in xs[0]

examples/ch09/mistake_cindex.low

module mistake_cindex .
rem expect: E-CHAR

fn first input xs slice u8 . output u8 .
do
  rem ✘ there is no bracket indexing --- read with `index xs 0`
  return xs[0] .
end .

Output

$ lowentc --check mistake_cindex.low
7:12 E-CHAR: unexpected character
7:14 E-CHAR: unexpected character

There is no bracket indexing. Reading an element is idx xs. 0 ., writing it is set idx xs. 0 . v. .. Using names instead of symbols makes reading, writing and the out-of-range stop all the same shape of form, with fewer symbols to remember.

Counter-example. Reading the first element of what may be an empty slice

examples/ch09/mistake_empty.low

module mistake_empty .
rem trap: first []

fn first input xs slice u8 . output u8 .
do
  rem ✘ an empty slice has no element 0
  return idx xs. 0 . .
end .

Output

$ lowentc --run first mistake_empty.low []
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

It is easy to forget that an input may be empty. Index 0 exists only when there is at least one element. Filter first, then read.

examples/ch09/empty_fixed.low

module empty_fixed .
rem run: first_or [] 0
rem run: first_or [7,8] 0

fn first_or input xs slice u8 . input fallback u8 . output u8 .
do
  rem leave here if empty --- below this line, having at least one element is a fact
  guard gt len xs. . 0 . else return fallback. . .
  return idx xs. 0 . .
end .

Output

$ lowentc --run first_or empty_fixed.low [] 0
first_or([], 0) = 0
  arg0 (written) = []
$ lowentc --run first_or empty_fixed.low [7,8] 0
first_or([7,8], 0) = 7
  arg0 (written) = [7,8]

Below the guard, “not empty” is a fact, so idx xs. 0 . is safe — and the compiler uses the same fact to remove the bounds check (chapter 7).

Counter-example. Swapping the start and end of subslice

examples/ch09/mistake_subrev.low

module mistake_subrev .
rem trap: tail_len [1,2,3,4]

fn tail_len input xs slice u8 . output usize .
do
  rem ✘ the start (3) is after the end (1) --- `subslice s from to` needs from ≤ to ≤ len
  return len subslice xs. 3 1 . . .
end .

Output

$ lowentc --run tail_len mistake_subrev.low [1,2,3,4]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: subslice out of bounds (panic)

subslice s. from. to. . needs from ≤ to ≤ len s. Swapping them does not give an empty window; it stops — because a miscalculated boundary that quietly became an empty result would only surface much later.

A common misconception. subslice s. 1 3 . is the three cells from 1 to 3

examples/ch09/subslice_end.low

module subslice_end .
rem run: middle [10,20,30,40,50]

fn middle input xs slice u8 . output usize .
do
  rem from index 1 up to (not including) 3 --- indexes 1 and 2, two elements
  return len subslice xs. 1 3 . . .
end .

Output

$ lowentc --run middle subslice_end.low [10,20,30,40,50]
middle([10,20,30,40,50]) = 2
  arg0 (written) = [10,20,30,40,50]

The end index is not included: indexes 1 and 2, two cells. With this rule the length is simply to − from, and subslice s. 0 k. . and subslice s. k. len s. . . split the slice with no cell overlapping or missing. That is why most languages use the same rule (a half-open range).

Counter-example. Taking a string literal as mut slice and changing it

examples/ch09/mistake_litwrite.low

module mistake_litwrite .
rem expect: E-TYPE-ARGMUT

rem ✘ taking a string literal as mut slice and changing it; a literal is read-only bytes baked into the program
fn poke output u8 . do
  let buf mut slice u8 "abc" .
  set idx buf. 0 . 65 .
  return idx buf. 0 . .
end .

Output

$ lowentc --check mistake_litwrite.low
mistake_litwrite.low:6:0 E-TYPE-ARGMUT: a string LITERAL was bound to a name declared `mut`. A literal is bytes baked into the program, not a place that can be written: the VM used to change them while the native build did not, so the same program gave two answers, and a library op writing there killed the native build. Take bytes you will change from `alloc_bytes` or from the caller's buffer, and copy the literal into them

A string literal is bytes baked into the program, not a place to change. So binding it to a name declared mut, or passing it where a mut place is taken, is refused with E-TYPE-ARGMUT. Until 2026-09-16 both passed, and when run the VM returned 65 while native code returned 97 — the same program with two answers — and a standard-library op writing those bytes killed the native build with a segmentation fault. Take bytes you will change from alloc_bytes or from the caller’s buffer, and copy the literal into them.

9.9 This chapter’s syntax at a glance#

ShapeMeaningWhy
slice u8a run of u8 values (start + length)no separate count to carry around
mut slice u8a run whose elements may be writtenwritability is visible in the type
input xs array 4 u8 .take a run of exactly 4 (input position only)length, then element type — checked at entry
len xs. .number of elementsjust reads the length field; no cost
idx xs. i. .element i (from 0)stops when out of range — never reads someone else’s memory
set idx xs. i. . v. .write element ineeds a mut slice and a proc
for x xs. do … end .each element in turnno room for index mistakes
subslice xs. from. to. .a window from from up to, not including, to (no copy)half-open — the length is to − from
"hello"a literal of type slice u8 — indexable as isthere is no separate string type
lit array 3 u32 1 2 3 .an array written as values — closes with its own periodthe length is a promise — end with _ if short
lit slice u32 4 5 .a slice written as valuesthe element count is the length
var buf lit array 8 u8 _ . .a writable local array — in the op’s framecannot leave its declaring block
lit array 8 u8 do 2 5 . _ 0 . end .an array with chosen cells filledevery cell must be decided — the rest by _
matrix 2 3 4 u32u32 in 3 rows of 4 — the number of dimensions, the sizes, then the element typethe shape is in the type — another size is another type
idx m. r. c. . · dim m. 0 . · flat m. .one cell · the size of one dimension · the elements as one roweach index is checked against its own size
requires le n. len xs. . . .a length condition as a contractbounds checks in the body are removed

Table 9.1 — Slice syntax — shape · meaning · why it looks this way

Recap

A slice carries its start and length together. len takes the length field, idx stops when out of range, and for walks the elements. array n t writes the length and then the element type and is used in input positions; a sequence written out as values is listed with lit array or lit slice. A list bound with var is a local array in the frame that you may write inside its block; writing someone else’s elements needs a mut slice and a proc. subslice narrows the window without copying. Writing the length condition as a contract removes bounds checks in the body, and a string literal is a table that can be indexed directly.