Solutions

Every exercise in chapters 1 through 30 has a solution here, and every solution program is a sample like any other: extracted, executed, and snapshot-checked in the same CI run as the chapters. A solution that stops compiling fails the book’s build. Outputs are pasted from those runs.

Solutions are collapsed. Open one when you have written yours, or when you are stuck in the specific way that a hint cannot reach. Reading a solution before attempting the exercise costs you the exercise; the book has no way to stop you and no interest in trying.

The solo project publishes six checkpoints inside its own chapter instead of answers, and it is the only page in the book that withholds one. The coda sets no exercises.

Chapter 1

Exercise 1-1. §1.1

Exercise 1-1 (fingers · lupin). Type the hello program exactly as printed and run it. Then make it print a second line of your choosing.

Solution. ch01/ex1-1.lu:

fn main() -> !int {
    print("hello, wolf")
    print("the moon is up")
    0
}
$ lupin ex1-1.lu
hello, wolf
the moon is up
Exercise 1-2. §1.3

Exercise 1-2 (comprehension · lupin). Before running, write down what this program prints and what echo $? shows afterward:

fn main() -> !int {
    print("working")
    3
}

Solution: it prints working and exits 3: main’s last expression is the process exit code, and print output is unrelated to it.

$ lupin ex1-2.lu
working
$ echo $?
3
Exercise 1-3. §1.1

Exercise 1-3 (fingers · lupin). Braces interpolate expressions, not only names. Print a sentence that computes 6 * 7 twice inside one string literal.

Solution. ch01/ex1-3.lu:

fn main() -> !int {
    let name = "reader"
    print("hello, {name} — {6 * 7} is still {6 * 7}")
    0
}
$ lupin ex1-3.lu
hello, reader — 42 is still 42
Exercise 1-4. §1.4

Exercise 1-4 (fingers · lupin REPL). Open the REPL. Compute the number of seconds in a day, ask :type what type that expression has, then define a function mid-session and call it twice. Before you ask :type, write down your guess.

Solution. One session:

$ lupin
wolf> 60 * 60 * 24
86400 : i32
wolf> :type 60 * 60 * 24
i32
wolf> fn area(r: int) -> int { r * r * 3 }
defined fn `area`
wolf> area(10)
300 : i64
wolf> let a = area(2)
wolf> a + 1
13 : i64
wolf> :quit

Literal arithmetic infers i32; a function annotated int returns i64. Definitions persist for the whole session. The REPL is a workbench, not a calculator.

Exercise 1-5. §1.3

Exercise 1-5 (fingers + extension · lupin). The first table in The C Programming Language converts Fahrenheit to Celsius. Write wolf’s: 0 to 120 degrees in steps of 20, one line per row. Then look hard at the 20-degree row. Is it right?

Solution. ch01/ex1-5.lu:

fn main() -> !int {
    var f = 0
    while f <= 120 {
        let c = (f - 32) * 5 / 9
        print("{f}	{c}")
        f += 20
    }
    0
}
$ lupin ex1-5.lu
0	-17
20	-6
40	4
60	15
80	26
100	37
120	48

The 20-degree row says −6; the exact answer is −6.67, and 20°F is colder than −6°C suggests. Integer division truncates toward zero, so every Celsius entry here is rounded toward warm. K&R’s version had the same bug and fixed it with floats; wolf gets there in chapter 2’s format specs.

Exercise 1-6. §1.5

Exercise 1-6 (spelunking · lupin). Delete the closing brace of a working program’s main and run it. Read the whole diagnostic: the code, the message, the clause tag, the span. Then run echo $?. Which of the manual’s exit codes is this, and why is it not the code a trap would produce?

Solution. ch01/ex1-6.lu (broken on purpose):

$ lupin ex1-6.lu
ex1-6.lu: E0202: the file ends where `}` was required [gram.expr.block] at 6:6
$ echo $?
2

Exit 2 is a static-phase rejection: the program never ran. A trap exits 3 and can only happen to a program that was legal and started: the two codes divide “wolf refused” from “wolf obeyed, and the program hit a rule.” The clause tag [gram.expr.block] names the grammar rule the file broke, and the span points at the end of the file, which is where the absence lives.

Exercise 1-7. §1.2

Exercise 1-7 (fingers · wolf + lupin). Compile the greeting with wolf build, run the binary, then run the source under lupin. Compare the two outputs byte for byte: diff <(./hello) <(lupin hello.lu) will do it. Then say which of the two runs could have printed something different, and what it would mean about the language if it had.

Solution:

$ wolf build hello.lu
$ diff <(./hello) <(lupin hello.lu)
$ echo $?
0

diff printing nothing is the whole result. Either run could have disagreed: the compiler lowered the f-string to a sequence of writes against the runtime’s print shims, and the interpreter evaluated it against its own string model: two separate pieces of code, written from the specification rather than from each other. A byte of disagreement between them is a bug in one implementation or a hole in the specification, and it is found here rather than in your program.

Exercise 1-8. §1.5

Exercise 1-8 (comprehension · wolf + lupin). Build the greeting, then run wolf build hello.lu --verbose a second time without editing the file. A successful build is silent by default; --verbose is how you watch it work. Predict what the second build does before you run it, and where it put what it kept.

Solution:

$ wolf build hello.lu --verbose
wolf build: root: reused object (key a476d75665e8c37a)

The second build compiles nothing. .lu-cache/ beside the source holds the object file keyed by everything that could change its contents (the module’s source, the compiler’s own build id, the profile, and the interface surfaces of what it depends on), so an unchanged key is an answer already on disk. The key in your terminal will differ from the one above; the word reused will not.

Exercise 1-9. §1.3

Exercise 1-9 (fingers · lupin). Drive 1-5’s table the other way: Celsius −20 to 40 in steps of 10, Fahrenheit beside it, both columns right-aligned. This direction has no rounding bug. Say why not before you run it.

Solution. ch01/ex1-9.lu:

fn main() -> !int {
    var c = -20
    while c <= 40 {
        let f = c * 9 / 5 + 32
        print("{c:>4}{f:>6}")
        c += 10
    }
    0
}
$ lupin ex1-9.lu
 -20    -4
 -10    14
   0    32
  10    50
  20    68
  30    86
  40   104

The multiply happens first, and every Celsius entry here is a multiple of 5, so c * 9 is a multiple of 45 and the division by 5 is exact. 1-5 divided a number that was not a multiple of 9 by 9 and lost the remainder. Same operators, opposite order, and one direction tells the truth by accident of the inputs — which is why chapter 2’s format specs, not luck, are the durable fix.

Exercise 1-10. §1.5

Exercise 1-10 (comprehension · lupin). Before running, write down what this program prints and what echo $? shows afterward:

fn main() -> !int {
    let amounts = """
        3
        four
        5
        """
    var total = 0
    for row in amounts.lines() {
        total += row.to_int() else 0
    }
    print("counted")
    total
}

Solution: it prints counted and exits 8. The middle row is not a number, else 0 makes it worth nothing, and the sum of the other two rides out of main as the exit code — the same last-expression rule 1-2 established, with the value computed instead of literal.

$ lupin ex1-10.lu
counted
$ echo $?
8
Exercise 1-11. §1.1

Exercise 1-11 (fingers · lupin). Find the longest line of a multiline block and print its length and the line itself, in that order. One pass, one comparison. What does your program print when two lines tie, and which line of your code decided that?

Solution. ch01/ex1-11.lu:

fn main() -> !int {
    let log = """
        the wolf runs
        the moon watches the ridge
        dawn
        """
    var best = ""
    for line in log.lines() {
        if line.len > best.len { best = line }
    }
    print("{best.len} {best}")
    0
}
$ lupin ex1-11.lu
26 the moon watches the ridge

On a tie the first of the tied lines wins, and the > decided it: a later line replaces best only by being strictly longer. Writing >= hands the tie to the last line instead. One character of the program is the whole policy, which is the reason to know where it is.

Chapter 2

Exercise 2-1. §2.3

Exercise 2-1 (comprehension · lupin REPL). Predict all three before evaluating: "wolf".len, "é".len, "🐺".len.

Solution: 4, 2, 4. len counts UTF-8 bytes: one ASCII letter is one byte, é is two, the wolf emoji is four.

$ lupin eval '"wolf".len'
4 : i64
$ lupin eval '"é".len'
2 : i64
$ lupin eval '"🐺".len'
4 : i64
Exercise 2-2. §2.1

Exercise 2-2 (fingers · lupin). Using format specs, print a two-column table: names left-aligned in 10 columns, numbers right-aligned in 4.

Solution. ch02/ex2-2.lu:

fn main() -> !int {
    var names = List[str]()
    (mut names).push("wolf")
    (mut names).push("marmot")
    (mut names).push("elk")
    var i = 0
    for n in names {
        i += 1
        print("{n:<10}{i:>4}")
    }
    0
}
$ lupin ex2-2.lu
wolf         1
marmot       2
elk          3
Exercise 2-3. §2.3

Exercise 2-3 (comprehension · lupin). "wolf" has four bytes. Predict the exact behavior of:

let s = "wolf"
let t = s[2..9]

Solution: the program traps. Slicing is checked; an out-of-range byte slice is a defined fault, not undefined behavior, and the trap says so in its clause tag:

$ lupin ex2-3.lu
ex2-3.lu: trap(bounds): byte range 2..9 is outside a 4-byte string [mem.ub.defined] at 6:13
$ echo $?
3
Exercise 2-4. §2.4

Exercise 2-4 (extension · lupin). Extend the word counter to also report lines and bytes for a """ multiline block. Predict which of the three numbers is a byte count before running.

Solution. ch02/ex2-4.lu:

fn main() -> !int {
    let text = """
        the wolf runs
        the moon watches
        """
    var words = 0
    for _ in text.words() { words += 1 }
    let lines = text.lines().count()
    print("{words} words, {lines} lines, {text.len} bytes")
    0
}
$ lupin ex2-4.lu
6 words, 2 lines, 31 bytes

31 is bytes: 13 for the first line, 16 for the second, and two newlines (the dedent removed the leading spaces before counting).

Exercise 2-5. §2.2

Exercise 2-5 (comprehension · lupin REPL). Predict all three lengths before evaluating: "\n".len, r"\n".len, r"C:\temp".len.

Solution: 1, 2, 7. In an ordinary literal \n is one byte, a newline. In a raw literal it is two bytes, a backslash and an n: raw means the escape table is off, not that backslashes are special some other way. r"C:\temp" is the seven bytes you can count.

$ lupin eval '"\n".len'
1 : i64
$ lupin eval 'r"\n".len'
2 : i64
$ lupin eval 'r"C:\temp".len'
7 : i64
Exercise 2-6. §2.3

Exercise 2-6 (comprehension · lupin REPL). "wolf" has four bytes. Predict each of these, precisely, as a value or an event: "wolf"[..2], "wolf"[2..], "wolf"[4..4], "wolf"[3..2].

Solution: wo, lf, the empty string, and a trap. An open end fills in the boundary; 4..4 is an empty range at a legal boundary, which is a value, not a fault; 3..2 runs backward, and a backward range is outside the contract no matter how short it is.

$ lupin eval '"wolf"[..2]'
wo : str
$ lupin eval '"wolf"[2..]'
lf : str
$ lupin eval '"wolf"[4..4]'
 : str
$ lupin eval '"wolf"[3..2]'
trap(bounds): byte range 3..2 is outside a 4-byte string [mem.ub.defined] at 0..12
the session survives the trap; the world is as the fault left it [repl.trap.alive]

[4..4] does not trap: the boundary after the last byte is a real position, and it is where appending happens.

Exercise 2-7. §2.4

Exercise 2-7 (extension · lupin). Write encode, a run-length encoder over bytes: "aaabcc" becomes "a3b1c2". Walk the string with byte slices and equality only. What does your encoder do with the empty string, and did you have to write a special case for it?

Solution. ch02/ex2-7.lu:

fn encode(s: str) -> str {
    var out = ""
    var i = 0
    while i < s.len {
        let ch = s[i..i + 1]
        var n = 1
        while i + n < s.len && s[i + n..i + n + 1] == ch { n += 1 }
        out += "{ch}{n}"
        i += n
    }
    out
}
fn main() -> !int {
    print(encode("aaabcc"))
    0
}
$ lupin ex2-7.lu
a3b1c2

The empty string needs no special case: the outer loop’s condition is false immediately and out is returned as the empty string it started as. A loop whose bounds are honest handles its degenerate input by arithmetic, not by an if bolted on the front.

Exercise 2-8. §2.5

Exercise 2-8 (comprehension · lupin REPL). s is "wolfpack". Predict all four values, then say what slicing s cost: did any of these lines copy eight bytes?

wolf> let s = "wolfpack"
wolf> let t = s[..4]
wolf> t
wolf : str
wolf> t.len
4 : i64
wolf> s.len
8 : i64
wolf> s[4..].len
4 : i64

Solution: wolf, 4, 8, 4, and nothing copied. A slice is a view: two words, a pointer and a length, aimed into bytes that already exist. s is untouched by every line here, which is why s.len is still 8 after t was made from it. The chapter’s cost claim is checkable from the values alone: if slicing copied, substring-heavy code would pay by the byte; because it is two words, t = s[..4] costs the same whether s is eight bytes or eight megabytes.

Exercise 2-9. §2.4

Exercise 2-9 (comprehension · wolf). Predict all six numbers before running: for each of "wolf", "é", and "e\u{301}", both .len and .chars().len. Then the pointed half: which of the six could §2.3 have told you, and which one needed this section?

Solution. ch02/ex2-9.lu — the third string is bound before it is printed, so that the accent’s \u{301} is not sitting inside a {…} hole; the hole’s own closing brace and the escape’s would be the same character to a reader and to a highlighter both:

fn main() -> !int {
    let combining = "e\u{301}"
    print("{"wolf".len} {"wolf".chars().len}")
    print("{"é".len} {"é".chars().len}")
    print("{combining.len} {combining.chars().len}")
    0
}
$ wolf run ex2-9.lu
4 4
2 1
3 2

Five of the six are §2.3 material: .len counts bytes (4, 2, 3), and ASCII is the case where every scalar is one byte, so "wolf" counts 4 either way and "é"’s two bytes are one scalar. The number that needed this section is the last: combining.chars().len is 2. A combining accent is its own scalar, so the glyph a reader sees as one letter is two chars in a three-byte string. Bytes count storage, chars count scalars, and neither counts what the reader sees.

Exercise 2-10. §2.4

Exercise 2-10 (fingers · lupin). Reverse each word of "the wolf runs", keeping the words in order. Build each reversal with chars() and string joins alone. Then say why reversing the bytes instead would wreck "éland" while this version does not.

Solution. ch02/ex2-10.lu:

fn main() -> !int {
    var out = ""
    for w in "the wolf runs".words() {
        var r = ""
        for c in w.chars() {
            r = "{c}" + r
        }
        if out.is_empty() { out = r } else { out = out + " " + r }
    }
    print(out)
    0
}
$ lupin ex2-10.lu
eht flow snur

é is two bytes in a fixed order; reversing bytes would emit them backward, and the result is not UTF-8 at all. chars() yields whole scalars, so prepending each one keeps every multi-byte sequence intact. Reversal is the smallest program that shows why “a string is bytes” and “a string is text” need different loops.

Exercise 2-11. §2.4

Exercise 2-11 (fingers · lupin). Squeeze: delete from "howling at the moon" every character that appears in a second string, here "aeiou". One pass, contains, and a growing result. Which of the two strings does your loop walk, and what happens to the program’s cost if you walk the other one?

Solution. ch02/ex2-11.lu:

fn main() -> !int {
    let s = "howling at the moon"
    let drop = "aeiou"
    var t = ""
    for c in s.chars() {
        if drop.contains("{c}") == false {
            t += "{c}"
        }
    }
    print(t)
    0
}
$ lupin ex2-11.lu
hwlng t th mn

The loop walks s and probes drop, so the work is the text’s length times the (tiny, fixed) drop set. Walked the other way — for each character of drop, scan and rebuild s — the cost multiplies by the number of rebuilds and the code needs a mutable copy per pass. Same answer, different bill; the loop order is the algorithm.

Exercise 2-12. §2.1

Exercise 2-12 (comprehension + extension · lupin). Center "DEN LOG" in twenty columns of stars, twice: once with a fill-align spec, once by hand with repeat and len. The padding is thirteen, which does not halve. Predict which side gets the extra star under each spelling before you run them, then state the rule.

Solution. ch02/ex2-12.lu:

fn main() -> !int {
    let title = "DEN LOG"
    print("{title:*^20}")
    let pad = 20 - title.len
    let left = pad / 2
    let right = pad - left
    print("{"*".repeat(left)}{title}{"*".repeat(right)}")
    0
}
$ lupin ex2-12.lu
******DEN LOG*******
******DEN LOG*******

Both give the extra star to the right. The spec’s rule is exactly the hand version’s arithmetic: left gets pad / 2, truncation rounds the left side down, and the remainder lands on the right. The two lines matching is the point — ^ is not magic, it is the division you would have written.

Exercise 2-13. §2.2

Exercise 2-13 (fingers · lupin). Make the invisible visible: print "howl\tat\nthe moon" as one line in which each tab shows as <tab> and each newline as <nl>. Two replace calls chain. Why must the program spell its markers in ordinary literals but would have needed raw ones to match \t if the text had carried a real backslash?

Solution. ch02/ex2-13.lu:

fn main() -> !int {
    let mixed = "howl\tat\nthe moon"
    let visible = mixed.replace("\t", "<tab>").replace("\n", "<nl>")
    print(visible)
    0
}
$ lupin ex2-13.lu
howl<tab>at<nl>the moon

"\t" in the pattern position is the one-byte tab itself, which is what the text contains, so the ordinary literal is the right spelling. Had the text carried a literal backslash-t — two bytes — the pattern would need r"\t" to mean those two bytes, because in an ordinary literal that spelling collapses to the tab. The escape table applies where the literal is written, not where the string is used.

Exercise 2-14. §2.4

Exercise 2-14 (fingers · lupin). Detab: replace each tab in "a\tbb\tccc" with the spaces that carry the column to the next tab stop, stops every four columns. Print the result in brackets so the spacing is checkable. Why is “replace each tab with four spaces” wrong, and which input proves it?

Solution. ch02/ex2-14.lu:

fn main() -> !int {
    let line = "a\tbb\tccc"
    var out = ""
    var col = 0
    for c in line.chars() {
        if c == '\t' {
            let stop = (col / 4 + 1) * 4
            out += " ".repeat(stop - col)
            col = stop
        } else {
            out += "{c}"
            col += 1
        }
    }
    print("[{out}]")
    0
}
$ lupin ex2-14.lu
[a   bb  ccc]

A tab’s width depends on where it sits: after a (column 1) it is three spaces; after bb (column 6) it is two. “Four spaces” is right only when the tab lands on a stop already — the input above proves it by needing two different widths for its two tabs. The tab stop is a destination, not a distance.

Chapter 3

Exercise 3-1. §3.4

Exercise 3-1 (comprehension · lupin). Predict the one line this prints. Both match and if are expressions here; nothing is a statement:

fn main() -> !int {
    let n = 3
    let kind = match n {
        0 => "none",
        1 => "one",
        _ => "many",
    }
    let parity = if n % 2 == 0 { "even" } else { "odd" }
    print("{kind} and {parity}")
    0
}

Solution: many and odd.

$ lupin ex3-1.lu
many and odd
Exercise 3-2. §3.1

Exercise 3-2 (comprehension · wolf + lupin). The pack loses its lead:

struct Pack { lead: str, tail: str }
fn adopt(take w: str) -> str { w }
fn main() -> !int {
    var p = Pack { lead: "ada", tail: "grace" }
    let a = adopt(take p.lead)
    let b = p.tail
    let c = p.lead
    print("{a} {b} {c}")
    0
}

Before running anything, write down two predictions: what wolf says about this program, and what lupin does with it. Which line does each tool blame, and why is let b not the one?

Solution: the compiler rejects it statically; the interpreter runs it and traps at the same use. let b survives because p.tail is a disjoint field: moving p.lead empties only that path.

$ wolf conform-run ./ex3-2.lu
warning[W1003]: `w` is taken, never touched, and returned
 --> ./ex3-2.lu:6:10
  |
6 | fn adopt(take w: str) -> str { w }
  |          ^^^^ consumption that consumes nothing
  |
  = note: the caller gives the value up only to receive it back; if callers could reasonably keep
    it, the signature is wrong.
help: drop the `take` and hand back `copy w` (call sites drop theirs and keep their binding; a `read` parameter returned without the `copy` is E1002, #366)
  |
6 | fn adopt(w: str) -> str { w }
  |

error[E1001]: `p.lead` is used here after its value moved away
  --> ./ex3-2.lu:11:13
   |
 9 |     let a = adopt(take p.lead)
   |                        ------ `p.lead` moved here
10 |     let b = p.tail
11 |     let c = p.lead
   |             ^^^^^^ used after the move
   |
   = note: re-initializing the place (assigning to it) also makes it usable again.
help: to keep the original, copy it at the move
   |
 9 |     let a = adopt(take copy p.lead)
   |

Two diagnostics, and the order is the compiler’s rather than the exercise’s. adopt takes a value and hands it straight back, which is W1003 in its own right — a consumption that consumes nothing — and it is reported first because it is found first. The E1001 the exercise is about is the second. The warning is not the lesson here, and it is on the page anyway: a console block is replayed against the pinned tools byte for byte, so an abridged transcript is a false one.

$ lupin ./ex3-2.lu
./ex3-2.lu: trap(use-after-move): `p.lead` was moved out and is uninitialized here [mem.tier0.move.2] at 11:13; `p.lead` moved here at 9:19
$ echo $?
3

One rule, two enforcement points: the compiler proves it before the program starts; lupin enforces it dynamically and cites the same clause family. This differential is the book’s spine and the reader meets it here first.

Exercise 3-3. §3.3

Exercise 3-3 (comprehension · lupin). 2147483647 is i32’s ceiling. Predict what big + 1 does in a release build. (Trick warning: the answer is the same in every build.)

Solution: it traps, in every profile. X3 is the decision the trap line cites, and the line also names the spelling for intentional wraparound:

$ lupin ex3-3.lu
ex3-3.lu: trap(overflow): `+` produced 2147483648, outside `i32` — checked arithmetic traps in every profile (X3); spell intended overflow `wrapping[i32]` [arith.checked] at 6:16
Exercise 3-4. §3.3

Exercise 3-4 (comprehension · lupin). The divisor is computed, not literal. Does that change anything?

let n = 10
let d = n - 10
print("{n / d}")

Solution: no. Division by zero is a defined trap regardless of how the zero arrived:

$ lupin ex3-4.lu
ex3-4.lu: trap(div-zero): division by zero is defined behavior in wolf: it traps [mem.ub.defined] at 7:13
Exercise 3-5. §3.2

Exercise 3-5 (design). Wolf has no ternary operator. Write the expression you would have used one for, in wolf, and then argue either side: is if-as-expression enough?

Solution (discussion): let parity = if n % 2 == 0 { "even" } else { "odd" } is the whole answer to the common case, and it nests without precedence archaeology, which ?: never did. The honest cost: it is longer, and chained conditions (a ? x : b ? y : z) become match or stacked if/else: more lines, each readable. Wolf’s position is that a second conditional syntax buys keystrokes and costs a grammar production and a style war; the book spends neither.

Exercise 3-6. §3.4

Exercise 3-6 (extension (break-it-on-purpose) · lupin). Using one i32 binding and one *, write the smallest program that traps with overflow on i32. State, before running it, why the number you chose is the smallest one that works, and what the trap line will say the product was.

Solution. ch03/ex3-6.lu:

fn main() -> !int {
    let n: i32 = 46341
    print("{n * n}")
    0
}
$ lupin ex3-6.lu
ex3-6.lu: trap(overflow): `*` produced 2147488281, outside `i32` — checked arithmetic traps in every profile (X3); spell intended overflow `wrapping[i32]` [arith.checked] at 6:13
$ echo $?
3

i32’s ceiling is 2147483647, whose square root is 46340.95…, so 46341 is the smallest integer whose square leaves the type: 46340² is 2147395600 and fits. The trap reports the true product: the machine computed it, checked it against the type, and refused to pretend it fit. The annotation is what makes the multiplication an i32 multiplication: a bare literal is unconstrained until something gives it a type, and let n: i32 is that something.

Exercise 3-7. §3.4

Exercise 3-7 (comprehension · lupin). Predict both lines. If you arrived from Python, predict them twice:

fn main() -> !int {
    let a = 0 - 7
    let b = 2
    print("{a / b} {a % b}")
    print("{(a / b) * b + a % b}")
    0
}

Solution: -3 -1, then -7. Wolf’s integer division truncates toward zero, so -7 / 2 is −3, not Python’s floored −4. The remainder follows the division, so -7 % 2 is −1, not 1. The second line is the law that binds them: (a / b) * b + a % b == a holds for every legal pair, whichever convention a language picks. Pick your division and the remainder is chosen for you.

$ lupin ex3-7.lu
-3 -1
-7
Exercise 3-8. §3.1

Exercise 3-8 (comprehension · lupin). Predict the one printed line, then answer the pointed part: after name = "grace", what happened to "ada", and why does first not care?

fn main() -> !int {
    var name = "ada"
    let first = name
    name = "grace"
    print("{first} {name}")
    0
}

Solution: ada grace. The let first = name line handed the value over: first owns "ada" from that point, so the later rebinding of name replaces what name holds without reaching anything first has. The deep story of “hands the value over” (what it means for the source afterward, and when it traps) is chapter 7’s, on purpose; this chapter needs only the direction of the handover.

A historical honesty note, retired: at this batch’s authoring pins neither tool rejected a reassignment to a let binding. Both do now — E0410, naming the var fix and the shadowing alternative — so the single-assignment rule the book taught on trust is enforced where it was written:

$ lupin ex3-8b.lu
ex3-8b.lu: E0410: `n` is bound with `let`, so it cannot be assigned again; declare the binding with `var` to update it in place (machine-applicable), or shadow it with a second `let` if the next value is really a new thing [gram.item.let] at 7:5
$ echo $?
2
Exercise 3-9. §3.2

Exercise 3-9 (fingers · lupin). The pack drill: print 1 through 15, except that multiples of 3 print howl, multiples of 5 print scratch, and multiples of both print howlscratch. One if-chain as a value, one print. Why must the both test come first, and what prints if it comes last?

Solution. ch03/ex3-9.lu:

fn main() -> !int {
    for i in 1..16 {
        let word = if i % 15 == 0 {
            "howlscratch"
        } else if i % 3 == 0 {
            "howl"
        } else if i % 5 == 0 {
            "scratch"
        } else {
            "{i}"
        }
        print(word)
    }
    0
}
$ lupin ex3-9.lu
1
2
howl
4
scratch
howl
7
8
howl
scratch
11
howl
13
14
howlscratch

An if-chain takes the first arm whose test passes. 15 is a multiple of 3, so with the both test last, 15 prints howl and the howlscratch arm is unreachable — no diagnostic says so, because every arm is still type-correct. Order is logic here, not style.

Exercise 3-10. §3.4

Exercise 3-10 (extension · lupin). Print 1 through 16 with each number’s binary spelling right-aligned beside it. No format spec you have met writes base 2, so build the bits yourself: % 2 peels the low bit, / 2 shifts, and prepending assembles them in the right order. Do not reach for a fixed-width buffer to poke bits into: a str cannot be written in place — s[a..b] reads a view of one, not a place — so every step makes a new string. What does your loop produce for zero, and is that a spelling or an absence?

Solution. ch03/ex3-10.lu:

fn main() -> !int {
    for n in 1..17 {
        var bits = ""
        var rest = n
        while rest > 0 {
            bits = "{rest % 2}" + bits
            rest = rest / 2
        }
        print("{n:>3} {bits:>6}")
    }
    0
}
$ lupin ex3-10.lu
  1      1
  2     10
  3     11
  4    100
  5    101
  6    110
  7    111
  8   1000
  9   1001
 10   1010
 11   1011
 12   1100
 13   1101
 14   1110
 15   1111
 16  10000

For zero the loop body never runs and bits stays empty: an absence. The conventional spelling is "0", and honesty about the boundary costs one if before the print. The table above never reaches it; your extension should, and should decide.

Exercise 3-11. §3.4

Exercise 3-11 (fingers · lupin). One pass over a block of readings: lowest, highest, and mean, integer arithmetic throughout. The first reading has to seed lowest and highest — why is starting them at zero wrong, and which of the two inputs in the block below would have exposed it?

Solution. ch03/ex3-11.lu:

fn main() -> !int {
    let readings = """
        18
        4
        31
        22
        7
        """
    var lowest = 0
    var highest = 0
    var total = 0
    var count = 0
    for row in readings.lines() {
        let n = row.to_int() else 0
        count += 1
        total += n
        if count == 1 {
            lowest = n
            highest = n
        }
        if n < lowest { lowest = n }
        if n > highest { highest = n }
    }
    print("low {lowest} high {highest} mean {total / count}")
    0
}
$ lupin ex3-11.lu
low 4 high 31 mean 16

Zero-seeded, lowest would stay 0 against this all-positive block — every reading loses to it — so the all-positive data is exactly what exposes the bug (an all-negative block would expose highest instead). Seeding from the first element makes the answer a fact about the data, not about the seed. The mean truncates: 82 / 5 is 16 here, and chapter 2’s precision specs are how a report would say 16.4.

Exercise 3-12. §3.4

Exercise 3-12 (fingers · lupin). Print every three-digit palindrome divisible by 7, using arithmetic only: / and % take the digits, and a flipped number is three multiplies away. No strings. How many are there, and why does your divisibility test not need the middle digit?

Solution. ch03/ex3-12.lu:

fn main() -> !int {
    for n in 100..1000 {
        let flipped = n % 10 * 100 + n / 10 % 10 * 10 + n / 100
        if n == flipped {
            if n % 7 == 0 {
                print("{n}")
            }
        }
    }
    0
}
$ lupin ex3-12.lu
161
252
343
434
525
595
616
686
707
777
868
959

Twelve. The trick question dissolves on inspection: the test is n % 7 == 0 on the whole number, so no digit is special — the middle digit needed no test anywhere, because a palindrome check by arithmetic compares n to its flip rather than digit to digit. The question is a nudge to notice your own program did less work than the problem statement implied.

Exercise 3-14. §3.4

Exercise 3-14 (fingers · lupin). 3-9’s pack drill again, decided once instead of in a chain. That version’s order was load-bearing and nothing checked it: put the both test last and an arm goes dead in silence. Write the same 1-through-15 drill as a single match over the pair (n % 3, n % 5). Then move (_, 0) above (0, 0), run it again, and say what is different this time about being wrong.

Solution. ch03/ex3-14.lu:

fn fizzbuzz(n: int) -> str {
    match (n % 3, n % 5) {
        (0, 0) => "fizzbuzz",
        (0, _) => "fizz",
        (_, 0) => "buzz",
        _ => "{n}",
    }
}
fn main() -> !int {
    for n in 1..16 {
        print(fizzbuzz(n))
    }
    0
}
$ lupin ex3-14.lu
1
2
fizz
4
buzz
fizz
7
8
fizz
buzz
11
fizz
13
14
fizzbuzz

The pair is the whole trick. Each arm is a conjunction of column tests — (0, 0) says “the first element is zero and so is the second” — so the three interesting cases are three arms rather than a nested if whose branches have to remember what the outer test already decided. The _ arm catches every other pair and is what makes the match exhaustive; without it the checker hands back a witness pair the arms do not cover.

Put (_, 0) above (0, 0) and the (0, 0) arm can never run: every pair it would match, the arm above already took. That is redundancy, not incompleteness, and it has its own name. ch03/ex3-14b.lu is that program, and it still runs — an unreachable arm is a warning, not a refusal:

$ wolf build ./ex3-14b.lu
warning[E0802]: this arm can never match — the arms above already cover it
 --> ./ex3-14b.lu:8:9
  |
7 |         (_, 0) => "buzz",
  |         ------ this arm already matches those values
8 |         (0, 0) => "fizzbuzz",
  |         ^^^^^^ unreachable arm
  |
  = note: delete the arm, or reorder the arms so the more specific pattern comes first.

The reachability walk reads column by column, which is how it knows that (_, 0) swallows (0, 0) without being handed the values.

That is the whole difference from 3-9. Both programs can be written with a dead branch in them, and both still run; only one of them says so. An if-chain’s dead arm is type-correct and therefore invisible — 3-9’s answer says as much — while a match arm’s coverage is a property the checker already computes, so the same mistake arrives named, located, and with the repair in the note. The order of arms is still logic rather than style, but it is logic the compiler reads too.

Exercise 3-15. §3.2

Exercise 3-15 (fingers · lupin). Days in a month, decided by a match on the month number with a bare if in the February arm, 2 => if leap then 29 else 28, printed for January, both Februaries and April. Then make one branch outgrow the line, a sentence in place of the number, and run wolf fmt on the file: which of your two ifs does the formatter leave as written, what does it do to the other, and would it ever go the other way?

Solution. ch03/ex3-15.lu:

fn days(month: int, leap: bool) -> int {
    match month {
        2 => if leap then 29 else 28,
        4 => 30,
        6 => 30,
        9 => 30,
        11 => 30,
        _ => 31,
    }
}

fn main() -> !int {
    print("{days(1, false)} {days(2, true)} {days(2, false)} {days(4, false)}")
    0
}
$ lupin ex3-15.lu
31 29 28 30
$ wolf fmt --check ./ex3-15.lu
$ echo $?
0

The February arm is the ruling’s own line: then closes the condition, the arm’s comma closes the bare else branch, and the formatter’s --check exits 0 on it, because a bare if that fits its line is a fixed point. Give the leap branch a sentence instead of 29 and the line no longer fits, so wolf fmt rewrites that one if into the braced form, each branch on its own line, and leaves every other arm as written. It never goes the other way: a braced if stays braced on every pass, however short its branches, because the rule is that no program which parses changes shape under the formatter, and the width break is the one exception, one direction only.

Chapter 4

Exercise 4-1. §4.2

Exercise 4-1 (fingers · lupin). Functions are values. Write compose so that compose(double, double) returns a function, and apply it to 10.

Solution. ch04/ex4-1.lu:

fn main() -> !int {
    let double = fn(n) n * 2
    let compose = fn(f, g) fn(x) f(g(x))
    let quad = compose(double, double)
    print("{quad(10)}")
    0
}
$ lupin ex4-1.lu
40
Exercise 4-2. §4.3

Exercise 4-2 (comprehension · lupin). Predict the order of the three lines:

fn main() -> !int {
    defer print("first registered")
    defer print("second registered")
    print("body")
    0
}

Solution: body, then the defers in reverse registration order. defer is a stack, because teardown must unwind what setup wound:

$ lupin ex4-2.lu
body
second registered
first registered
Exercise 4-5. §4.1

Exercise 4-5 (comprehension · lupin). Euclid’s algorithm, in the expression style:

fn gcd(a: int, b: int) -> int {
    if b == 0 { a } else { gcd(b, a % b) }
}

Trace gcd(1071, 462) on paper (write down every (a, b) pair the recursion visits) and state the result before running it.

Solution: the pairs are (1071, 462), (462, 147), (147, 21), (21, 0); the answer is 21. Each step the second argument becomes the remainder, which strictly shrinks, which is why the recursion is finite. The signature promises an int and the arithmetic delivers one.

$ lupin ex4-5.lu
21
Exercise 4-6. §4.3

Exercise 4-6 (comprehension · lupin). A defer is registered when execution reaches it. Predict all five output lines, in order:

fn work(n: int) -> int {
    defer print("one")
    if n == 0 { return 10 }
    defer print("two")
    20
}
fn main() -> !int {
    print("{work(0)}")
    print("{work(1)}")
    0
}

Solution: one, 10, two, one, 20. The early return in work(0) fires only the defer that had already been reached: the second defer line never executed, so it never registered. work(1) reaches both and unwinds them in reverse. A defer is not a property of the function; it is an event in its execution.

$ lupin ex4-6.lu
one
10
two
one
20
Exercise 4-7. §4.4

Exercise 4-7 (extension · lupin). Build day_of_year(month, day, leap) from two functions: days_in(month, leap) as one match, and a loop that sums the months before yours. Verify: March 1st is day 60 in a common year. Which date is day 60 in a leap year, and where in your code did that difference come from?

Solution. ch04/ex4-7.lu:

fn days_in(month: int, leap: bool) -> int {
    match month {
        2 => if leap { 29 } else { 28 },
        4 => 30,
        6 => 30,
        9 => 30,
        11 => 30,
        _ => 31,
    }
}
fn day_of_year(month: int, day: int, leap: bool) -> int {
    var total = 0
    var m = 1
    while m < month {
        total += days_in(m, leap)
        m += 1
    }
    total + day
}
fn main() -> !int {
    print("{day_of_year(3, 1, false)} {day_of_year(3, 1, true)}")
    print("{day_of_year(12, 31, false)} {day_of_year(12, 31, true)}")
    0
}
$ lupin ex4-7.lu
60 61
365 366

In a leap year, day 60 is February 29th, and March 1st moves to 61. The difference lives in exactly one arm of days_in (the 2 => arm) and nowhere else, which is the argument for writing the table as a match instead of scattering the leap rule through the loop.

Exercise 4-8. §4.4

Exercise 4-8 (fingers · lupin). The Collatz walk: halve an even number, triple-and-add-one an odd one, count the steps to reach 1. Write steps(n) and print a two-column table for 1 through 10. Which starting point under 11 takes the longest, and is the answer where you expected it in the table?

Solution. ch04/ex4-8.lu:

fn steps(n: int) -> int {
    var v = n
    var count = 0
    while v != 1 {
        v = if v % 2 == 0 { v / 2 } else { 3 * v + 1 }
        count += 1
    }
    count
}
fn main() -> !int {
    for n in 1..11 {
        print("{n:>3}{steps(n):>5}")
    }
    0
}
$ lupin ex4-8.lu
  1    0
  2    1
  3    7
  4    2
  5    5
  6    8
  7   16
  8    3
  9   19
 10    6

9 takes the longest, at 19 steps, and it is not where intuition puts it: 7 (at 16) beats everything larger in the table, and 8 — bigger than 7 — finishes in 3. The walk’s length has no visible relation to the starting size, which is precisely why the function earns a table instead of a guess. (Whether every start reaches 1 is a famous open question; your loop assumes it, and for 1 through 10 the assumption is checked by termination.)

Exercise 4-9. §4.2

Exercise 4-9 (extension · lupin). Write clamp_to(lo, hi), a function that returns the clamping function for that range, and use clamp_to(0, 100) to sanitize a list of parsed percentages. The closure captures lo and hi by value. What would break, and what would not, if it captured them by place?

Solution. ch04/ex4-9.lu:

fn clamp_to(lo: int, hi: int) -> fn(int) -> int {
    fn(n) if n < lo { lo } else if n > hi { hi } else { n }
}
fn main() -> !int {
    let percent = clamp_to(0, 100)
    let rows = "40\n-12\n130"
    for row in rows.lines() {
        let n = row.to_int() else 0
        print("{n:>5} -> {percent(n)}")
    }
    0
}
$ lupin ex4-9.lu
   40 -> 40
  -12 -> 0
  130 -> 100

Nothing here would break by-place, because lo and hi never change after the closure is made — the difference is unobservable in this program. What by-value buys is that the claim holds by construction: percent means 0..100 forever, even if a later edit reassigns some var the bounds came from. Capture by value makes the closure a sealed fact rather than a live reference to the caller’s mood.

Exercise 4-10. §4.1

Exercise 4-10 (fingers · lupin). Write rtrim: trailing blanks and tabs gone, everything else kept. Walk backward from the end with byte slices and return one slice of the original. Print each result in brackets so the trimming is visible. What does your function return for a line that is all blanks, and did that case cost you code?

Solution. ch04/ex4-10.lu:

fn rtrim(s: str) -> str {
    var end = s.len
    while end > 0 && (s[end - 1..end] == " " || s[end - 1..end] == "\t") {
        end -= 1
    }
    s[..end]
}
fn main() -> !int {
    for line in "howl   \nden\t\t\nmoon".lines() {
        print("[{rtrim(line)}]")
    }
    0
}
$ lupin ex4-10.lu
[howl]
[den]
[moon]

An all-blank line walks end down to 0 and returns s[..0], the empty string — no extra code, because while end > 0 is already the guard the case needs. The return value is a slice of the input, two words aimed at bytes that already exist: rtrim allocates nothing, which is the honest cost §2.5 promised a slice would have.

Exercise 4-11. §4.4

Exercise 4-11 (extension · lupin). Zeller’s congruence names the weekday of any date in four lines of arithmetic — January and February count as months 13 and 14 of the year before. Write weekday(y, m, d) returning the day’s name from one match, and check it against three dates whose weekday is a matter of record. Why does the month shift exist: what property of the calendar is it buying back?

Solution. ch04/ex4-11.lu:

fn weekday(y: int, m: int, d: int) -> str {
    var mm = m
    var yy = y
    if m < 3 {
        mm = m + 12
        yy = y - 1
    }
    let k = yy % 100
    let j = yy / 100
    let h = (d + 13 * (mm + 1) / 5 + k + k / 4 + j / 4 + 5 * j) % 7
    match h {
        0 => "saturday",
        1 => "sunday",
        2 => "monday",
        3 => "tuesday",
        4 => "wednesday",
        5 => "thursday",
        6 => "friday",
        _ => "never",
    }
}
fn main() -> !int {
    print("{weekday(2026, 8, 31)}")
    print("{weekday(2000, 1, 1)}")
    print("{weekday(1969, 7, 20)}")
    0
}
$ lupin ex4-11.lu
monday
saturday
sunday

The shift moves February — the month whose length changes — to the end of the counting year, so the leap day sits after every month the formula counts across. That buys back a uniform month-length pattern: 13 * (mm + 1) / 5 is a fixed staircase (the 31-30-31-30-31 rhythm from March onward), which no formula can be over a year that keeps February in the middle. The _ => "never" arm is the price of matching on an int: % 7 proves the range to you, but the checker wants the row closed, and “never” is the honest name for it.

Chapter 5

Exercise 5-1. §5.1

Exercise 5-1 (fingers · lupin). A List is also a stack. Push three values, pop one, and print the popped value and the remaining length.

Solution. ch05/ex5-1.lu:

fn main() -> !int {
    var xs = List[int]()
    (mut xs).push(1)
    (mut xs).push(2)
    (mut xs).push(3)
    let top = (mut xs).pop()
    print("top={top} len={xs.len}")
    0
}
$ lupin ex5-1.lu
top=3 len=2
Exercise 5-2. §5.1

Exercise 5-2 (fingers · lupin). Score the pack: write two scores into a Map, raise one by reading it back, and print the table with format specs.

Solution. ch05/ex5-2.lu:

fn main() -> !int {
    var scores = Map[str, int]()
    scores["wolf"] = 3
    scores["marmot"] = 5
    scores["wolf"] = (scores["wolf"] else 0) + 1
    for (name, n) in scores.pairs() {
        print("{name:<8}{n:>3}")
    }
    0
}
$ lupin ex5-2.lu
wolf      4
marmot    5
Exercise 5-3. §5.3

Exercise 5-3 (extension · lupin). Write first[T] with a fallback for the empty case, and call it twice: once with the type named, once letting inference name it. Read the element rather than removing it: pop answers T ! {none} and changes the list it is called on, and a function that changes its argument is chapter 7’s mut, at both ends of the call.

Solution. ch05/ex5-3.lu:

fn first[T](xs: List[T], fallback: T) -> T {
    if xs.is_empty() { copy fallback } else { copy xs[0] }
}
fn main() -> !int {
    var howls = List[str]()
    (mut howls).push("awoo")
    let empty = List[int]()
    print("{first[str](howls, "silence")} {first(empty, -1)}")
    0
}
$ lupin ex5-3.lu
awoo -1

Both calls hit the same definition; [str] at the call site is documentation, not a requirement, because the arguments already pin T.

Exercise 5-4. §5.4

Exercise 5-4 (comprehension · lupin). The list has one element. Predict xs[10], precisely: what kind of event, and what exit code.

Solution: a bounds trap, exit 3, the same contract as string slicing, because indexing is checked everywhere:

$ lupin ex5-4.lu
ex5-4.lu: trap(bounds): index 10 is outside a collection of 1 element(s) [mem.ub.defined] at 7:13
$ echo $?
3
Exercise 5-5. §5.3

Exercise 5-5 (design). Wolf writes generics top[T] and indexing m["k"] with the same brackets. Rust chose ::<T> partly to avoid that ambiguity. What does wolf’s choice cost, and where is the cost paid?

Solution (discussion): the cost is real and it is paid in the grammar/sema seam, not by the reader: e[…] parses as one postfix form and semantic analysis decides index versus generic-apply (the corpus pins this in grammar/brackets_index.lu). The compiler carries the complexity so that user code never grows a ::<>. The trade to defend: error messages at that seam must stay excellent, because when sema guesses wrong, the diagnostic, not the syntax, is what saves the reader. That is a bet on tooling quality, made on purpose, and this book holds the compiler to it.

Exercise 5-6. §5.2

Exercise 5-6 (extension · wolf). Count lines the way the head of the chapter counted categories: a Map[str, int] and one line of counting per row. Then print each distinct line once, with its count, in the order the lines first appeared. The Map alone does not give you that order. Which container does, and why is it not the Map?

Solution. ch05/ex5-6.lu, which runs under wolf:

fn seen_at(xs: List[str], s: str) -> int {
    var i = 0
    while i < xs.len {
        if xs[i] == s { return i }
        i += 1
    }
    -1
}
fn main() -> !int {
    let log = """
        howl
        howl
        scratch
        howl
        scratch
        """
    var order = List[str]()
    var counts = Map[str, int]()
    for line in log.lines() {
        if seen_at(order, line) < 0 { (mut order).push(line) }
        counts[line] = (counts[line] else 0) + 1
    }
    for s in order {
        print("{counts[s] else 0:>4} {s}")
    }
    0
}
$ wolf run ex5-6.lu
   3 howl
   2 scratch

The count is the map alone: counts[line] = (counts[line] else 0) + 1 is the whole tally, one line per row, and nothing decides insert versus update, because else 0 reads a missing entry as zero and the assignment inserts it. What the map does not give is the order. pairs() walks the entries in an order the language leaves unspecified, and nothing in a map remembers which key came first; the order list remembers exactly that and nothing else, at the cost of one seen_at scan per line, and the printing loop walks the list and asks the map.

Exercise 5-7. §5.4

Exercise 5-7 (fingers + extension · lupin). A grade book. The block below has one name,score line per student. Parse it into two lists side by side, a List[str] of names and a List[int] of scores, with to_int() else 0 reading each score, then print the top scorer’s name and score using §5.3’s best with a comparison function (best(scores, fn(a, b) a > b)), the class average to the nearest whole point, and each student on their own line with above or below beside the average. Then §5.4’s question: what does your program do on an empty block, and which line traps first? Guard it so an empty block prints one sentence and exits 0.

ada,91
grace,78
linus,84
mira,69

Solution. ch05/ex5-7.lu:

fn best[T](xs: List[T], better: fn(T, T) -> bool) -> T {
    var b = copy xs[0]
    var i = 1
    while i < xs.len {
        if better(xs[i], b) { b = copy xs[i] }
        i += 1
    }
    b
}
fn parse(text: str) -> (List[str], List[int]) {
    var names = List[str]()
    var scores = List[int]()
    for line in text.lines() {
        var i = 0
        while i < line.len && line[i..i + 1] != "," { i += 1 }
        if i == line.len { continue }
        (mut names).push(line[..i])
        (mut scores).push(line[i + 1..].to_int() else 0)
    }
    (names, scores)
}
fn report(text: str) {
    let (names, scores) = parse(text)
    if scores.is_empty() {
        print("no students in the block")
        return
    }
    let top = best(scores, fn(a, b) a > b)
    var who = ""
    var sum = 0
    for i in 0..scores.len {
        sum += scores[i]
        if scores[i] == top { who = names[i] }
    }
    let avg = (sum + scores.len / 2) / scores.len
    print("top: {who} {top}")
    print("average: {avg}")
    for i in 0..scores.len {
        let mark = if scores[i] > avg { "above" } else { "below" }
        print("{names[i]:<8}{scores[i]:>4}  {mark}")
    }
}
fn main() -> !int {
    let text = """
        ada,91
        grace,78
        linus,84
        mira,69
        """
    report(text)
    report("")
    0
}
$ lupin ex5-7.lu
top: ada 91
average: 81
ada       91  above
grace     78  below
linus     84  above
mira      69  below
no students in the block

parse is §5.1’s comma scan run once per line, pushing the name on one list and the score on the other, so the two lists line up by index: a line with no comma is skipped, and a score that is not a number reads as 0 through else. best(scores, fn(a, b) a > b) is §5.3’s function with the comparison handed in, and the name is found by walking back to the index the top score sits at. (sum + n / 2) / n is integer rounding to the nearest point: 322 over 4 is 80.5, and the half added before the division carries it to 81.

The empty block is §5.4’s question with one answer on both machines. Remove the guard and the first line that runs against the empty lists is best’s var b = copy xs[0], which is xs[0] on a list of no elements: wolf-trap: bounds from the compiled program, trap(bounds): index 0 is outside a collection of 0 element(s) from the interpreter, both pointing at that line, exit 3. The average’s division by scores.len would have trapped one line later, and never gets the chance. scores.is_empty() is the guard, one sentence and a return, and the exit code stays 0 because a block with no students is an answer, not a fault.

Exercise 5-11. §5.6

Exercise 5-11 (extension · lupin). Exercise 2-7 encoded runs with byte slices; a coder without a decoder is half a tool. Respell encode over chars(), growing the output with "{prev}{run_len}", then write decode, and round-trip both "wwwwoooolf" and a string whose runs reach two digits. Make main exit nonzero on any mismatch, so the round trip is a check the machine performs rather than a claim. Where in decode does the two-digit case live?

Solution. ch05/ex5-11.lu:

fn encode(s: str) -> str {
    var out = ""
    var run_len = 0
    var prev = ' '
    for c in s.chars() {
        if run_len > 0 && c == prev {
            run_len += 1
        } else {
            if run_len > 0 { out += "{prev}{run_len}" }
            prev = c
            run_len = 1
        }
    }
    if run_len > 0 { out += "{prev}{run_len}" }
    out
}
fn decode(s: str) -> str {
    var out = ""
    var cur = ' '
    var seen = false
    var n = 0
    for c in s.chars() {
        if c >= '0' && c <= '9' {
            n = n * 10 + (c as int) - ('0' as int)
        } else {
            if seen {
                for _ in 0..n { out += "{cur}" }
            }
            cur = c
            seen = true
            n = 0
        }
    }
    if seen {
        for _ in 0..n { out += "{cur}" }
    }
    out
}
fn main() -> !int {
    var inputs = List[str]()
    (mut inputs).push("wwwwoooolf")
    (mut inputs).push("aaaaaaaaaaab")
    for plain in inputs {
        let coded = encode(plain)
        let back = decode(coded)
        print("{plain} -> {coded} -> {back}")
        if back != plain { return 1 }
    }
    0
}
$ lupin ex5-11.lu
wwwwoooolf -> w4o4l1f1 -> wwwwoooolf
aaaaaaaaaaab -> a11b1 -> aaaaaaaaaaab

The two-digit case lives in one line: n = n * 10 + …. A decoder that read “the digit after the letter” instead of accumulating would pass the first input and shred the second — which is why the round trip tests both, and why the exit code carries the verdict: a printed a11b1 looks right to a reader skimming; != does not skim.

Exercise 5-12. §5.1

Exercise 5-12 (fingers · lupin). Histogram the word lengths of a two-line block, bars of # growing sideways, one row per length. A List[int] indexed by length is the whole data structure — grow it to fit as you go. One of your rows prints an empty bar. Which, and why does it print at all?

Solution. ch05/ex5-12.lu:

fn main() -> !int {
    let text = """
        the moon watches the long ridge
        a wolf runs past the frozen creek
        """
    var tally = List[int]()
    for w in text.words() {
        while tally.len < w.len + 1 { (mut tally).push(0) }
        tally[w.len] = tally[w.len] + 1
    }
    for n in 1..tally.len {
        print("{n:>2} {"#".repeat(tally[n])}")
    }
    0
}
$ lupin ex5-12.lu
 1 #
 2 
 3 ###
 4 #####
 5 ##
 6 #
 7 #

Length 2: the block has one one-letter word (a) and words of three letters and up, but nothing two letters long. The row prints because the list was grown by maximum length seen, not by lengths seen — the gap is a real zero, and repeat(0) spells it as an empty bar. A histogram that skipped empty rows would hide the shape it exists to show.

Exercise 5-13. §5.6

Exercise 5-13 (extension · lupin). Write any_index(s, set): the byte index in s of the first character that appears in set, or −1 for none. Probe it with a vowel set against a line that has vowels, one that does not, and the empty string. Why do the last two answers have to be the same, and what would distinguishing them cost your callers?

Solution. ch05/ex5-13.lu:

fn any_index(s: str, set: str) -> int {
    var i = 0
    for c in s.chars() {
        if set.contains("{c}") { return i }
        i += 1
    }
    -1
}
fn main() -> !int {
    print("{any_index("the wolf runs", "aeiou")}")
    print("{any_index("dry glyph", "aeiou")}")
    print("{any_index("", "aeiou")}")
    0
}
$ lupin ex5-13.lu
2
-1
-1

Both mean “no position holds a hit”, and a position is the only thing the function promises. Splitting them (“empty input” versus “searched and missed”) would force every caller to handle a case that changes nothing about what they can do next — the chapter after this one is about rows, and the first lesson there is that a distinction worth a tag is one the caller would branch on. This one is not.

Exercise 5-14. §5.1

Exercise 5-14 (fingers · lupin). A comma-separated ledger, three rows of name,cents. Split each row, table it with format specs, and end with a total row. What does your program do with a row that has no comma, and which single spelling in your source decided that?

Solution. ch05/ex5-14.lu:

fn main() -> !int {
    let ledger = """
        drink,340
        pastry,275
        stew,900
        """
    var total = 0
    for row in ledger.lines() {
        var name = ""
        var cents = 0
        var i = 0
        for field in row.split(",") {
            if i == 0 { name = field }
            if i == 1 { cents = field.to_int() else 0 }
            i += 1
        }
        total += cents
        print("{name:<10}{cents:>6}")
    }
    print("{"total":<10}{total:>6}")
    0
}
$ lupin ex5-14.lu
drink        340
pastry       275
stew         900
total       1515

A commaless row arrives as one field: name takes the whole row, cents keeps its initial 0, and the row costs the total nothing. The deciding spelling is var cents = 0 — the initializer is the missing-field policy. If the right answer for your ledger is “refuse the row instead”, that policy has a chapter of its own next.

Exercise 5-15. §5.6

Exercise 5-15 (extension · lupin). Fold a long line at twelve columns: greedy fill, words never split, each output line as full as the width allows. Return the lines as a List[str] and print them. One comparison in your loop encodes the whole policy — which one, and what single change makes ragged-right into one-word-per-line?

Solution. ch05/ex5-15.lu:

fn fold(text: str, width: int) -> List[str] {
    var out = List[str]()
    var line = ""
    for w in text.words() {
        if line.is_empty() {
            line = w
        } else if line.len + 1 + w.len <= width {
            line = line + " " + w
        } else {
            (mut out).push(line)
            line = w
        }
    }
    if line.is_empty() == false { (mut out).push(line) }
    out
}
fn main() -> !int {
    let text = "the wolf runs the long ridge past the frozen creek at dusk"
    for line in fold(text, 12) {
        print(line)
    }
    0
}
$ lupin ex5-15.lu
the wolf
runs the
long ridge
past the
frozen creek
at dusk

The policy is line.len + 1 + w.len <= width — “does this word, plus its separating space, still fit”. Replace width with 0 (or the test with false) and every word overflows immediately: one word per line. The greedy fill and the degenerate layout are the same loop with one comparison’s verdict flipped, which is the honest way to see that a formatter is a policy wearing a loop.

Exercise 5-16. §5.5

Exercise 5-16 (fingers · lupin). Respell §5.1’s split_at to answer a Row instead of a (str, int), and use it to parse the three rows at the head of the chapter and print them through text. One line of the caller gets shorter and one line of the function gets longer. Name both, and say what each reader gained.

Solution. ch05/ex5-16.lu:

struct Row { kind: str, cents: int }
fn split_at(row: str, i: int) -> Row {
    Row { kind: row[..i], cents: row[i + 1..].to_int() else 0 }
}
fn text(r: Row) -> str { "{r.kind:<10}{r.cents:>5}" }
fn main() -> !int {
    print(text(split_at("espresso,340", 8)))
    print(text(split_at("pastry,275", 6)))
    print(text(split_at("tip,100", 3)))
    0
}
$ lupin ex5-16.lu
espresso    340
pastry      275
tip         100

The caller’s line got shorter: let (name, cents) = split_at(…) is gone, because nothing has to be taken apart before it is used, and the value goes straight into text. The function’s line got longer: the tuple (row[..i], …) became Row { kind: row[..i], cents: … }, two words more. The function’s reader gained nothing they did not already know, since the tuple’s two positions were named by the signature one line up; the caller’s reader gained the two words, because r.cents says what pair.1 made them look up.

Exercise 5-17. §5.5

Exercise 5-17 (extension · lupin). A struct holds a list as easily as an int. Declare Receipt { rows: List[Row] }, build one from the three rows, and write total(r: Receipt) -> int as a plain function with no impl. Print the count of rows and the total. Then read total’s signature and say what it tells a caller about r.rows: does the call change the list, and which character on the line says so?

Solution. ch05/ex5-17.lu:

struct Row { kind: str, cents: int }
struct Receipt { rows: List[Row] }
fn total(r: Receipt) -> int {
    var sum = 0
    for row in r.rows { sum += row.cents }
    sum
}
fn main() -> !int {
    var rows = List[Row]()
    (mut rows).push(Row { kind: "drink", cents: 340 })
    (mut rows).push(Row { kind: "food", cents: 275 })
    (mut rows).push(Row { kind: "gratuity", cents: 100 })
    let receipt = Receipt { rows: rows }
    print("{receipt.rows.len} rows, {total(receipt)} cents")
    0
}
$ lupin ex5-17.lu
3 rows, 715 cents

The call does not change the list, and no character on the line says so: r: Receipt carries no word in front of it, and that absence is the claim. A parameter with nothing written before it is read for the call and handed back whole, which is why main can print receipt.rows.len and call total(receipt) in one line. A function that did change its argument would have to say so at both ends, and chapter 7 is where that word is introduced.

Exercise 5-18. §5.6

Exercise 5-18 (design). The alias Num names seven traits, and a call under [T: Num] is checked against each of them by name. Why does the refusal name the missing trait and never the alias? Say what a reader would lose if it read “f64 is not a Num”, and what the author of a seven-trait alias gains from the compiler never mentioning it.

Solution. The alias has no members and no impls, so “f64 is not a Num” names nothing the reader can write: there is no impl Num for f64 to add, and the sentence sends them to the alias’s definition to work out which of seven traits is the one they lack. Naming Eq instead names the impl that fixes it, and the fix is the same whether the bound was written Eq, Add + Eq, or Num. What the alias’s author gains is that the alias stays a spelling: it can grow an eighth trait, or be split in two, and every refusal it ever produced was already about a trait and not about the name, so nothing a reader learned from a diagnostic goes stale. The one thing an alias is for is saving the reader from writing the list; it would be a poor trade to make them read it back out of an error message.

Exercises 5-9 and 5-10 (the third Draw shape; the cast-a-binding rule) moved to chapter 7 with the material they belong to, as 7-13 and 7-14. The numbers are not reused.

The RPN evaluator that was exercise 5-7 moved to chapter 6 as 6-14, where ? on pop() is the point. Its number is reused below for the grade book, on the maintainer’s call, so that the renumbering stays local.

Chapter 6

Exercise 6-1. §6.2

Exercise 6-1 (fingers · lupin). Write parse so empty input is an error, and give two call sites: one defaulting with else 0, one with else 7. Predict both prints first.

Solution. ch06/ex6-1.lu:

fn parse(s: str) -> int ! {empty} {
    if s.is_empty() { return empty }
    s.to_int() else 0
}
fn main() -> !int {
    let a = parse("42") else 0
    let b = parse("") else 7
    print("a={a} b={b}")
    0
}
$ lupin ex6-1.lu
a=42 b=7
Exercise 6-2. §6.2

Exercise 6-2 (comprehension · lupin). chain calls parse through ?. Predict a and b, and name which row variant b’s handler sees:

fn chain(s: str) -> int ! {empty, NotDigit(Bad)} {
    let v = parse(s)?
    v + 1
}
fn main() -> !int {
    let a = chain("42") else |_| -1
    let b = chain("") else |err| {
        match err {
            empty => -2,
            NotDigit(e) => -3,
        }
    }
    print("a={a} b={b}")
    0
}

Solution: a=43 (parse succeeds, ? unwraps, one is added); b=-2: parse("") returns empty, ? hands it up unchanged, and the handler matches it. The variant crossed one call boundary without wrapping; that is the row composing by union.

$ lupin ex6-2.lu
a=43 b=-2
Exercise 6-3. §6.1

Exercise 6-3 (extension · lupin). Grow the row: add a too_long variant for inputs over four bytes and handle it. What else did you have to change, and what told you?

Solution. ch06/ex6-3.lu (excerpt):

fn parse(s: str) -> int ! {empty, NotDigit(Bad), too_long} {
    if s.is_empty() { return empty }
    if s.len > 4 { return too_long }
    ...
}
...
    let v = parse("40000") else |err| {
        match err {
            too_long => -4,
            NotDigit(e) => 0 - e.at - 3,
            empty => -2,
        }
    }
$ lupin ex6-3.lu
v=-4

The signature grew (rows are spelled, not sprung on callers) and the match grew an arm: exhaustiveness is what tells you, at compile time, that a handler fell behind its row.

Exercise 6-4. §6.1

Exercise 6-4 (comprehension · lupin). The error carries a payload. Predict both printed lines:

fn digit(s: str, i: int) -> int ! {NotDigit(Bad)} {
    let c = s[i..i + 1]
    if c < "0" || c > "9" { return NotDigit(Bad { at: i, found: c }) }
    c.to_int() else 0
}
fn main() -> !int {
    let v = digit("4x", 1) else |err| {
        match err {
            NotDigit(e) => {
                print("bad digit `{e.found}` at byte {e.at}")
                -1
            },
        }
    }
    print("v={v}")
    0
}

Solution: byte 1 of "4x" is x, so the handler prints the payload’s fields, then v=-1, because the handler’s value becomes the expression’s:

$ lupin ex6-4.lu
bad digit `x` at byte 1
v=-1
Exercise 6-5. §6.3

Exercise 6-5 (comprehension · lupin). errdefer runs only on the error path. work(true) succeeds; work(false) fails after the errdefer is registered. Predict all four output lines:

fn work(ok: bool) -> int ! {fail} {
    var r = get(true)?
    errdefer print("cleanup ran")
    let v = get(ok)?
    r.n + v.n
}

Solution: the success path prints nothing extra; the failure path fires the cleanup between the failing ? and the caller’s else:

$ lupin ex6-5.lu
a=2
cleanup ran
b=-1

Exercise 6-6. §6.1

Exercise 6-6 (comprehension · lupin). The row below ends in .., which makes it open: probe may return tags the signature never lists. Predict all three numbers, and say which arm of the match handles probe(-1) and why the program compiles at all when Weird appears nowhere in any signature:

fn probe(n: int) -> int ! {Io(int), ..} {
    if n < 0 { return Weird }
    if n == 0 { return Io(4) }
    n
}
fn code_for(n: int) -> int {
    probe(n) else |err| {
        match err {
            Io(code) => 0 - code,
            _ => 0 - 99,
        }
    }
}

Solution: 7 -4 -99. The rest arm _ handles Weird: an open row tells every caller “there may be more,” so the compiler requires the rest arm, and that arm is what lets upstream add tags without breaking this consumer. The price is symmetric: with .. in the signature, no caller can ever match exhaustively by name.

$ lupin ex6-6.lu
7 -4 -99
Exercise 6-7. §6.2

Exercise 6-7 (extension · lupin). head prints a file’s first n lines, and a file with fewer than n lines is not a crash, it is an answer. Write head(text, n) whose error carries how many lines actually existed, and a caller that asks for 2 lines (succeeds) and 5 lines (handled). Why does the payload belong in the error instead of being printed by head itself?

Solution. ch06/ex6-7.lu:

fn head(text: str, n: int) -> str ! {TooFew(int)} {
    var out = ""
    var got = 0
    for line in text.lines() {
        if got < n {
            out += "{line}\n"
            got += 1
        }
    }
    if got < n { return TooFew(got) }
    out
}
fn main() -> !int {
    let log = """
        alpha
        beta
        gamma
        """
    let two = head(log, 2) else |_| { return 1 }
    print_raw(two)
    let five = head(log, 5) else |err| {
        match err {
            TooFew(have) => {
                print("wanted 5, file has {have}")
                ""
            },
        }
    }
    0
}
$ lupin ex6-7.lu
alpha
beta
wanted 5, file has 3

head does not know whether a short file is a diagnostic-worthy event, a loop terminator, or nothing at all: only its caller does. Carrying the count in the payload moves the fact to the code that owns the policy. A function that prints its own errors has decided the policy for every caller it will ever have.

Exercise 6-8. §6.5

Exercise 6-8 (design). Chapter 6’s parse exposes the row {empty, NotDigit(Bad), too_long}. Suppose parse moves into a library, behind a public API used by fifty programs. Argue both sides: should the public signature keep the three-tag row, or coarsen to a single Invalid tag with the detail inside? Name one concrete caller each design serves better, and what each design costs when a fourth failure mode appears.

Solution (discussion): the wide row serves the caller that dispatches: an editor that jumps the cursor to Bad.at needs NotDigit’s payload, and coarsening would force it to parse the error out of a string. The coarse row serves the caller that reports: a CLI that prints one line per bad input gains nothing from three arms it handles identically, and every tag in a public row is a name the library must keep forever. The fourth failure mode is where the designs separate: the wide row grows, and every exhaustive caller gets a compile error, disruptive and honest. The coarse row absorbs it silently, and the silence is the cost, because the caller who cared cannot find out. Wolf’s .. open row is the middle position: name the tags you commit to, and say out loud that there may be more. Where wolf’s own std faced this, it chose narrow closed rows at leaves and coarsening at module boundaries: the row is part of the API’s promise, and promises are cheapest when made small and kept.

Exercise 6-9. §6.4

Exercise 6-9 (extension · lupin). Three postures toward the input "7x": trap on it, default it to zero, or refuse it out loud. The script hardens one construct at a time:

fn parse_or_zero(s: str) -> int {
    s.to_int() else 0
}
fn parse(s: str) -> int ! {not_a_number} {
    s.to_int() else { return not_a_number }
}

The mark the third posture hands up keeps nothing, so it is a lowercase bare word, by §6.1’s pact: the compiler reads a CapCase mark as a promise that a payload waits inside, and W0603 is what it says when the case and the payload disagree.

Predict all three printed lines of the solution’s main, which calls parse_or_zero("7x") and then parse("7x") with a handler. Then the pointed part: rank the three postures for a program that reads numbers from a config file, and defend last place.

Solution. ch06/ex6-9.lu:

$ lupin ex6-9.lu
0
refused: not a number
-1

Last place is else 0. The trap at least stops the program at the lie; the row at least tells the caller the truth; else 0 silently converts “your config is broken” into “your limit is zero” and ships it. A default is a decision, and else 0 makes it in the one place that cannot know whether zero is safe. The row version moves that decision to the caller, which is the whole hardening arc of this section in one function.

Exercise 6-10. §6.5

Exercise 6-10 (extension · lupin). The wordcount loop, grown by one requirement: count words, and separately count words of four bytes or more. Predict both numbers for the line the wolf runs and the moon watches over, then run.

Solution. ch06/ex6-10.lu:

fn main() -> !int {
    let text = """
        the wolf runs and the moon watches over
        """
    var words = 0
    var long_words = 0
    for w in text.words() {
        words += 1
        if w.len >= 4 { long_words += 1 }
    }
    print("{words} words, {long_words} of length 4+")
    0
}
$ lupin ex6-10.lu
8 words, 5 of length 4+

Five of the eight (wolf, runs, moon, watches, over) reach four bytes. The filter is one if inside the loop the capstone already has, which is the shape of most real requirements: the skeleton absorbs them without growing a second loop. In part 3 this same loop parallelizes by changing one call; the boxed promise stands.

Exercise 6-11. §6.5

Exercise 6-11 (extension · lupin). to_int has no inverse on this shelf, so build one: itoa(n), peeling digits with % 10 and / 10 and prepending. Handle zero and negatives, then close the loop: feed each result back through to_int and make main exit nonzero on any mismatch. Which of your three test values forced a line of code the other two never touch?

Solution. ch06/ex6-11.lu:

fn itoa(n: int) -> str {
    if n == 0 { return "0" }
    var v = n
    var sign = ""
    if n < 0 {
        sign = "-"
        v = 0 - n
    }
    var digits = ""
    while v > 0 {
        digits = "{v % 10}" + digits
        v = v / 10
    }
    "{sign}{digits}"
}
fn main() -> !int {
    var cases = List[int]()
    (mut cases).push(340)
    (mut cases).push(0)
    (mut cases).push(0 - 275)
    for n in cases {
        let s = itoa(n)
        let back = s.to_int() else 999999
        print("{n} -> {s}")
        if back != n { return 1 }
    }
    0
}
$ lupin ex6-11.lu
340 -> 340
0 -> 0
-275 -> -275

Zero forced its own line: the peel loop runs while v > 0, so zero peels no digits and would come out as the empty string — the early return "0" is that case’s whole cost, the same absence 3-10’s binary table met. The negative forced two more (the sign, and 0 - n). Round-tripping through to_int is what promotes “looks right” to “is right”: the else-arm’s 999999 can never equal a real input, so a parse failure fails the run instead of passing by coincidence.

Exercise 6-12. §6.5

Exercise 6-12 (extension · lupin). Exercise 5-11’s decoder answers garbage with garbage: "4w" decodes to nothing, silently. Harden it by refactor: decode(s) -> str ! {empty, bad_run}, where empty input, a count with no letter before it, and a zero-length run are refusals with names. Table four malformed inputs through else |err| and a match. Which refusal required adding a check, and which two fell out of checks the loop already had?

Solution. ch06/ex6-12.lu:

fn decode(s: str) -> str ! {empty, bad_run} {
    if s.is_empty() { return empty }
    var out = ""
    var cur = ' '
    var seen = false
    var n = 0
    for c in s.chars() {
        if c >= '0' && c <= '9' {
            if seen == false { return bad_run }
            n = n * 10 + (c as int) - ('0' as int)
        } else {
            if seen {
                if n == 0 { return bad_run }
                for _ in 0..n { out += "{cur}" }
            }
            cur = c
            seen = true
            n = 0
        }
    }
    if n == 0 { return bad_run }
    for _ in 0..n { out += "{cur}" }
    out
}
fn main() -> !int {
    var cases = List[str]()
    (mut cases).push("w4o4l1f1")
    (mut cases).push("")
    (mut cases).push("4w")
    (mut cases).push("w0")
    (mut cases).push("ab2")
    for coded in cases {
        let plain = decode(coded) else |err| {
            let why = match err {
                empty => "empty",
                bad_run => "bad run",
            }
            print("[{coded}] refused: {why}")
            continue
        }
        print("[{coded}] -> {plain}")
    }
    0
}
$ lupin ex6-12.lu
[w4o4l1f1] -> wwwwoooolf
[] refused: empty
[4w] refused: bad run
[w0] refused: bad run
[ab2] refused: bad run

"4w" needed the new check (if seen == false): the soft decoder simply ignored digits with no letter to bind to. The other two were already half-present: the loop tracked n and seen anyway, so w0 and ab2 (a letter whose run count never arrived) are one n == 0 comparison promoted from “silently emit nothing” to a named refusal. Hardening rarely means new machinery; it means the checks the loop was implicitly making become answers the caller can hold.

Exercise 6-13. §6.5

Exercise 6-13 (extension · lupin). A date validator: parse_date("2026-02-29") should refuse, and say why. Split on -, parse the three fields, and return (int, int, int) ! {bad_shape, bad_month, bad_day} — reusing 4-7’s days_in for the day ceiling, leap rule included. Why is bad_shape checked first, and what happens to your month test if it is not?

Solution. ch06/ex6-13.lu:

fn days_in(month: int, leap: bool) -> int {
    match month {
        2 => if leap { 29 } else { 28 },
        4 => 30,
        6 => 30,
        9 => 30,
        11 => 30,
        _ => 31,
    }
}
fn parse_date(s: str) -> (int, int, int) ! {bad_shape, bad_month, bad_day} {
    var y = 0
    var m = 0
    var d = 0
    var i = 0
    for field in s.split("-") {
        let n = field.to_int() else { return bad_shape }
        if i == 0 { y = n }
        if i == 1 { m = n }
        if i == 2 { d = n }
        i += 1
    }
    if i != 3 { return bad_shape }
    if m < 1 || m > 12 { return bad_month }
    let leap = if y % 400 == 0 { true } else if y % 100 == 0 { false } else { y % 4 == 0 }
    if d < 1 || d > days_in(m, leap) { return bad_day }
    (y, m, d)
}
fn main() -> !int {
    var cases = List[str]()
    (mut cases).push("2026-08-31")
    (mut cases).push("2024-02-29")
    (mut cases).push("2026-02-29")
    (mut cases).push("2026-13-01")
    (mut cases).push("soon")
    for s in cases {
        let (y, m, d) = parse_date(s) else |err| {
            let why = match err {
                bad_shape => "not a date shape",
                bad_month => "no such month",
                bad_day => "no such day",
            }
            print("{s:<12} refused: {why}")
            continue
        }
        print("{s:<12} ok: day {d} of month {m}, {y}")
    }
    0
}
$ lupin ex6-13.lu
2026-08-31   ok: day 31 of month 8, 2026
2024-02-29   ok: day 29 of month 2, 2024
2026-02-29   refused: no such day
2026-13-01   refused: no such month
soon         refused: not a date shape

Shape first because the later tests read m and d, and those variables only mean anything once three numeric fields actually arrived. Skip the shape check and "soon" reaches the month test with m still 0 — refused as bad_month, which is a lie about the input: the caller fixing “no such month” would stare at a string with no month in it. Refusal order is part of a validator’s honesty, not a style choice. (2024-02-29 passing while 2026-02-29 refuses is days_in earning its leap parameter back from 4-7.)

Exercise 6-14. §6.2

Exercise 6-14 (comprehension + extension · lupin). An RPN evaluator is a loop and a stack, and the stack is a List[int]; words() (§2.5) is the tokenizer, so "3 4 + 2 *".words() is the input. Each operator pops twice, and pop answers int ! {none}, so spell it (mut stack).pop()? and give evaluate the row ! {none, parse}, because to_int fails on a token that is not a number. Trace the stack after each token on paper, then run. Then feed it 3 + and 3 x + and say, from the row and not from the code, which call answers each one and where your else |err| prints.

Solution. ch06/ex6-14.lu:

fn evaluate(line: str) -> int ! {none, parse} {
    var stack = List[int]()
    for t in line.words() {
        match t {
            "+" => { let b = (mut stack).pop()?; let a = (mut stack).pop()?; (mut stack).push(a + b) }
            "-" => { let b = (mut stack).pop()?; let a = (mut stack).pop()?; (mut stack).push(a - b) }
            "*" => { let b = (mut stack).pop()?; let a = (mut stack).pop()?; (mut stack).push(a * b) }
            "/" => { let b = (mut stack).pop()?; let a = (mut stack).pop()?; (mut stack).push(a / b) }
            _ => (mut stack).push(t.to_int()?),
        }
    }
    (mut stack).pop()
}
fn main() -> !int {
    print("{evaluate("3 4 + 2 *")}")
    let short = evaluate("3 +") else |err| { print("short stack: {err}"); -1 }
    print("{short}")
    let bad = evaluate("3 x +") else |err| { print("not a number: {err}"); -1 }
    print("{bad}")
    0
}
$ lupin ex6-14.lu
14
short stack: none
-1
not a number: parse
-1

The trace: [3], [3 4], [7], [7 2], [14]. 3 + reaches the operator with one element on the stack, so the second pop()? answers none and hands it up: evaluate ends there, and main’s else |err| prints the tag. 3 x + never reaches the operator: "x" goes to the _ arm, to_int()? answers parse, and the same else prints that. Nothing in evaluate tests stack.len, because the row is doing that check one level up, at the call that can fail, which is where chapter 6 puts it. The tail (mut stack).pop() is the answer and its own row at once: an empty input is none too, from the last line, with no special case.

Exercise 6-15. §6.6

Exercise 6-15 (comprehension · wolf + lupin). Exercise 6-6’s code_for handles probe’s open row through else |err| and a match. Rewrite it as one match over probe(n) itself, with the arms Io(code), v and _. Predict the three numbers, then say what _ covers when v already binds every int, and why the program does not compile without it.

Solution. ch06/ex6-15.lu:

fn probe(n: int) -> int ! {Io(int), ..} {
    if n < 0 { return Weird }
    if n == 0 { return Io(4) }
    n
}
fn code_for(n: int) -> int {
    match probe(n) {
        Io(code) => 0 - code,
        v => v,
        _ => 0 - 99,
    }
}
fn main() -> !int {
    print("{code_for(7)} {code_for(0)} {code_for(-1)}")
    0
}

The numbers are 7 -4 -99, 6-6’s, because the decisions are 6-6’s; only the plumbing moved. probe(7) is the value 7 and takes the v arm; probe(0) is Io(4) and takes the row arm, which binds the payload; probe(-1) is Weird, a tag no signature lists, and takes _. That last arm is what the question is about. v is a value pattern: it covers the whole of the int and no tag at all, because the two halves are covered separately (§6.6). Io(code) covers the one tag the row names. What is left is the .., the tags the row admits without naming, and nothing but _ can cover a tag that has no name. Leave it out and the compiler refuses the match as it refuses any other match with a case uncovered: E0801, the same code the missing no_comma drew in §6.6, pointing at the open row. The directive header records the expected run, and no transcript is printed here because no published tool produces one: the compiler refuses the match before it runs, and the interpreter runs it to 7 -4 Weird, the v arm binding the unnamed tag instead of leaving it to _ (EXERCISES-PENDING.md carries the measurement).

Chapter 7

Exercise 7-1. §7.1

Exercise 7-1 (comprehension · wolf + lupin). Chapter 3’s broken Pack program, with one line added:

struct Pack { lead: str, tail: str }
fn adopt(take w: str) -> str { w }
fn main() -> !int {
    var p = Pack { lead: "ada", tail: "grace" }
    let a = adopt(take p.lead)
    p.lead = "lin"
    let c = p.lead
    print("{a} {c} {p.tail}")
    0
}

Exercise 3-2’s version was rejected with E1001 at let c = p.lead. Predict both tools’ behavior now, and name the sentence in the E1001 diagnostic you saw in chapter 3 that already told you the answer.

Solution: both accept it. Assigning to a moved-from place makes it live again: the diagnostic’s note said exactly that, and this is that note performed. Both implementations run it, and they print the same line, which is the differential doing its ordinary job:

$ lupin ex7-1.lu
ada lin grace

The compiler has one thing to add, and it is about the signature rather than the move: adopt takes a value only to hand it straight back, so W1003 asks whether the take earns itself. The binary is produced anyway and prints ada lin grace.

Exercise 7-2. §7.2

Exercise 7-2 (fingers · lupin). Given struct Wolf { name: str, call: str } and struct Den { alpha: Wolf, beta: Wolf }, draw the ownership tree of a Den before running anything: one box per value, one arrow per field. Then move the deepest leaf out with move and verify, by printing them, that the leaf’s sibling and its cousins are all still usable.

Solution. ch07/ex7-2.lu:

struct Wolf { name: str, call: str }
struct Den { alpha: Wolf, beta: Wolf }
fn main() -> !int {
    var den = Den {
        alpha: Wolf { name: "ada", call: "awoo" },
        beta: Wolf { name: "grace", call: "yip" },
    }
    let promoted = move den.alpha.name
    print("{promoted} leads")
    print("{den.alpha.call} still works")
    print("{den.beta.name} still here")
    0
}
$ lupin ex7-2.lu
ada leads
awoo still works
grace still here

The move emptied exactly one path (den.alpha.name) and the tree’s other four leaves never noticed. (move is the plain-expression spelling; take is the same act written at a call site.)

Exercise 7-3. §7.2

Exercise 7-3 (extension (break-it-on-purpose) · wolf + lupin). Using one struct, one function taking take, and nothing else, write the smallest program that traps use-after-move through a field. Predict the compiler’s E-code and the interpreter’s trap kind before checking both. Why does the exercise say “through a field”: what would be different, and what the same, with a bare local?

Solution. ch07/ex7-3.lu:

struct S { a: str }
fn eat(take w: str) -> str { w }
fn main() -> !int {
    var s = S { a: "x" }
    let t = eat(take s.a)
    let u = s.a
    print("{t} {u}")
    0
}
$ wolf conform-run ./ex7-3.lu
warning[W1003]: `w` is taken, never touched, and returned
 --> ./ex7-3.lu:5:8
  |
5 | fn eat(take w: str) -> str { w }
  |        ^^^^ consumption that consumes nothing
  |
  = note: the caller gives the value up only to receive it back; if callers could reasonably keep
    it, the signature is wrong.
help: drop the `take` and hand back `copy w` (call sites drop theirs and keep their binding; a `read` parameter returned without the `copy` is E1002, #366)
  |
5 | fn eat(w: str) -> str { w }
  |

error[E1001]: `s.a` is used here after its value moved away
 --> ./ex7-3.lu:9:13
  |
8 |     let t = eat(take s.a)
  |                      --- `s.a` moved here
9 |     let u = s.a
  |             ^^^ used after the move
  |
  = note: re-initializing the place (assigning to it) also makes it usable again.
help: to keep the original, copy it at the move
  |
8 |     let t = eat(take copy s.a)
  |

Two diagnostics, and the order is the compiler’s rather than the exercise’s. eat takes a value and hands it straight back, which is §7.2’s W1003 in its own right — a consumption that consumes nothing — and it is reported first because it is found first. The E1001 the exercise is about is the second. Leaving the warning off this page would make the page shorter and the transcript false; a console block here is replayed against the pinned tools byte for byte.

$ lupin ex7-3.lu
ex7-3.lu: trap(use-after-move): `s.a` was moved out and is uninitialized here [mem.tier0.move.2] at 9:13; `s.a` moved here at 8:17
$ echo $?
3

A bare local would produce the same E-code and the same trap kind; the field version is the stronger test because it proves the tracking is per-path, not per-variable: s as a whole is neither dead nor alive, only s.a is dead. Both tools cite the same clause family, [mem.tier0.move.2], which is the differential spine again.

Exercise 7-4. §7.2

Exercise 7-4 (comprehension · lupin). Every field of P is an int. Predict what the second line of main does to a:

struct P { x: int, y: int }
fn main() -> !int {
    let a = P { x: 1, y: 2 }
    let b = a
    print("{a.x} {b.y}")
    0
}

Then change one word so it prints 1 2, and say what the changed program costs that the original did not.

Solution: it moves a, ints and all: the trap blames a.x’s read and points at let b = a. Structs move on assignment whatever they contain; there is no “cheap enough to copy silently” tier for user-defined types. The fix spells the duplication where it happens:

$ lupin ex7-4.lu
ex7-4.lu: trap(use-after-move): `a.x` was moved out and is uninitialized here [mem.tier0.move.2] at 8:13; `a` moved here at 7:13

ch07/ex7-4b.lu changes one word (let b = copy a) and:

$ lupin ex7-4b.lu
1 2

The wrong answer is “ints are Copy, so the struct copies.” Copy is a property of a few built-in types (§7.1’s table), and no struct has it, whatever its fields. For a type you define the rule is per-decision, not per-type: the reader of let b = copy a knows a duplication happened without looking up what P contains. That is also the cost the question asks for: a second P, made at the line that says copy. The original made none, because a move duplicates nothing a reader needs to account for.

Exercise 7-6. §7.4

Exercise 7-6 (extension · wolf + lupin). A bounded buffer: struct Buf { items: List[int], cap: int }. Write push(mut b, x), which adds x and returns the new length, or the error row {full} when b already holds cap items; and grow(mut b, extra), which builds a larger Buf and moves the old list into it. Fill a buffer of two, watch the third push fail, grow it by two, and push again. The cap is yours because the list’s is not: a wolf List grows on its own and exposes no capacity to read or reserve. How many elements does grow copy?

Solution. ch07/ex7-6.lu:

struct Buf { items: List[int], cap: int }
fn push(mut b: Buf, x: int) -> int ! {full} {
    if b.items.len == b.cap { return full }
    (mut b.items).push(x)
    b.items.len
}
fn grow(mut b: Buf, extra: int) {
    let bigger = Buf { items: move b.items, cap: b.cap + extra }
    b = bigger
}
fn main() -> !int {
    var b = Buf { items: List[int](), cap: 2 }
    let first = push(mut b, 10) else -1
    let second = push(mut b, 20) else -1
    let third = push(mut b, 30) else -1
    print("{first} {second} {third}")
    grow(mut b, 2)
    let again = push(mut b, 30) else -1
    print("{again} of {b.cap}: {b.items[0]} {b.items[1]} {b.items[2]}")
    0
}
$ lupin ex7-6.lu
1 2 -1
3 of 4: 10 20 30
$ wolf run ex7-6.lu
1 2 -1
3 of 4: 10 20 30

None. move b.items hands the list to the new Buf whole: the move copies the list’s top-level words (§7.7’s pointer, length and capacity) and no element, and it empties b.items. b = bigger then fills the emptied place, which §7.2 said assignment does, so b is a working name again by the time grow returns. Write copy b.items instead and the program prints the same two lines while duplicating every element to get there. The one word is the whole difference in cost, and it is written where the cost is paid.

push answers full rather than trapping because a full buffer is not a defect in the program; it is a state the caller decides about, which is chapter 6’s rule, and else -1 is the smallest decision. The bound is a field because it has to be: the list underneath keeps its own capacity and grows it without asking, so the only limit a wolf program can hold is one it stores itself.

Exercise 7-7. §7.4

Exercise 7-7 (fingers + spelunking · lupin). Write swap for two ints using mut at both ends, and verify it. Then state the single search you would run over a strange codebase to find every line that can mutate anything, and what property of the language makes the search complete.

Solution. ch07/ex7-7.lu:

fn swap(mut a: int, mut b: int) {
    let t = a
    a = b
    b = t
}
fn main() -> !int {
    var x = 1
    var y = 3
    swap(mut x, mut y)
    print("{x} {y}")
    0
}
$ lupin ex7-7.lu
3 1

The search is grep '(mut ' (X1’s argument, stated as a rule): call-site mut is mandatory, so a call that can write through an argument says so at the call. Add grep 'var ' for locals and the audit is the whole mutation surface: two searches, no false negatives, which is what “required at both ends” buys.

Exercise 7-8. §7.5

Exercise 7-8 (comprehension · wolf + lupin). The simplest possible exclusivity violation is one place claimed twice: bump2(mut n, mut n) where bump2 takes two mut ints. Predict what each tool says, then answer the design question hiding under it: if the call were allowed, what would n be afterward, and why is “it depends on the body” the real reason for the rule?

Solution. ch07/ex7-8.lu:

fn bump2(mut a: int, mut b: int) {
    a += 1
    b += 1
}
fn main() -> !int {
    var n = 0
    bump2(mut n, mut n)
    n
}

wolf rejects, lupin traps, same rule:

$ wolf conform-run ./ex7-8.lu
error[E1002]: `n` cannot go `mut` here: it overlaps `n`, already passed `mut` in this call
  --> ./ex7-8.lu:10:22
   |
10 |     bump2(mut n, mut n)
   |               - `n` is passed `mut` here
   |                      ^ second exclusive claim on the same place
   |
   = note: the same place twice is never disjoint.
$ lupin ex7-8.lu
ex7-8.lu: trap(exclusivity): `n` is accessed as `mut` while `n` is held as `mut`; the paths conflict [mem.tier0.excl.1] at 10:18; `n` held here at 10:11

If allowed, n could be 1 or 2 depending on whether a and b are distinct copies written back in some order or two names for one cell: the body decides, and the caller cannot see the body. Exclusivity makes the answer not depend on the body: two mut claims must be provably disjoint places, so aliasing questions are settled at the call site, which is also what lets the compiler hand noalias facts to the optimizer (§7.7’s subject).

Exercise 7-9. §7.5

Exercise 7-9 (comprehension + spelunking · wolf + lupin). Four call shapes against struct P { a: Q, b: Q }, struct Q { n: int }. In each, f takes both of its arguments mut and writes through both, so its signature is the two argument types with mut on each: shape 1 calls fn f(mut x: Q, mut y: Q), shape 2 fn f(mut u: int, mut v: int). Verdict for each, before checking any:

  1. f(mut p.a, mut p.b)
  2. f(mut p.a.n, mut p.b.n)
  3. f(mut p.a, mut p.a.n)
  4. f(mut p, mut p.b)

Then check yourself against the compiler, and find the sentence in wolf --explain E1002 that decides all four.

Solution: 1 and 2 are legal: disjoint fields, and leaves of disjoint subtrees. 3 and 4 are rejected: in each, one path is a prefix of the other, and a place conflicts with every place inside it. The legal pair, with every parameter written, ch07/ex7-9.lu:

fn f1(mut x: Q, mut y: Q) {
    x.n += 1
    y.n += 1
}
fn f2(mut u: int, mut v: int) {
    u += 10
    v += 10
}
var p = P { a: Q { n: 5 }, b: Q { n: 7 } }
f1(mut p.a, mut p.b)
f2(mut p.a.n, mut p.b.n)
$ lupin ex7-9.lu
16 18
$ wolf run ex7-9.lu
16 18

The other two, ch07/ex7-9b.lu, in one program: f3 takes a Q and an int, f4 a P and a Q, and both write both parameters, so the compiler has nothing to say about them except the overlap:

$ wolf conform-run ./ex7-9b.lu
error[E1002]: `p.a.n` cannot go `mut` here: it overlaps `p.a`, already passed `mut` in this call
  --> ./ex7-9b.lu:17:21
   |
17 |     f3(mut p.a, mut p.a.n)
   |            --- `p.a` is passed `mut` here
   |                     ^^^^^ second exclusive claim on the same place
   |
   = note: `p.a.n` is inside `p.a` — a path and its prefix conflict [mem.model.path.disjoint].
     Disjoint fields (`x.a` with `x.b`) are fine together.

error[E1002]: `p.b` cannot go `mut` here: it overlaps `p`, already passed `mut` in this call
  --> ./ex7-9b.lu:18:19
   |
18 |     f4(mut p, mut p.b)
   |            - `p` is passed `mut` here
   |                   ^^^ second exclusive claim on the same place
   |
   = note: `p.b` is inside `p` — a path and its prefix conflict [mem.model.path.disjoint]. Disjoint
     fields (`x.a` with `x.b`) are fine together.

The compiler reports both calls; the interpreter stops at the first, because it finds the conflict by running into it:

$ lupin ex7-9b.lu
ex7-9b.lu: trap(exclusivity): `p.a.n` is accessed as `mut` while `p.a` is held as `mut`; the paths conflict [mem.model.path.disjoint] at 17:17; `p.a` held here at 17:8

The sentence in wolf --explain E1002 is the second of its first paragraph: “Distinct fields are distinct places — f(mut p.x, mut p.y) is fine — but f(mut p, p.x) is not, because p.x lives inside p.” Shapes 1 and 2 are its first half, and 3 and 4 its second, with the second argument written mut rather than read, which conflicts for the same reason. The clause both tools cite, [mem.model.path.disjoint], is the same rule in one line: two paths conflict iff one is a prefix of the other.

Exercise 7-10. §7.6

Exercise 7-10 (spelunking · wolf). Run wolf --explain E1001 and read all of it. Quote the sentence that licenses re-initialization, the phrase that states field granularity, and the one word in the first paragraph that makes let b = a and f(take a) the same subject.

Solution:

$ wolf --explain E1001
E1001: this value was moved away (or never given one) before this use

In wolf, assignment and argument passing *move* a value: after
`let b = a` or `f(take a)`, the name `a` no longer holds anything —
its value went to the new place, whole. Reading a moved-from (or
never-initialized) name would read nothing, so the checker stops it
here and points at the move it happened in. Moves are field-granular:
moving `s.a` away leaves `s.b` usable, and only the moved path is
off-limits. To keep using the original, make the duplication explicit
where the move happens — `copy a` produces an independent value of
any type — or give the name a new value first: assigning to a
moved-from place makes it live again.

A `mut` parameter is the caller's place, and the caller reads it again
after the call, so it must hold a value at every return of the function
([mem.tier0.mode.mut]). Moving out of it — the whole parameter, a
field, an element, a map value — is fine as long as a value is stored
back on every path before the function returns, the `?` error edge and
an early `return` included. The refusal points at the move that is not
put back on some path.

The license is the last clause: “assigning to a moved-from place makes it live again.” The granularity phrase is “moving s.a away leaves s.b usable.” The one word is “move” itself: the text’s first sentence puts assignment and argument passing under the same verb, which is why chapters 3 and 7 have been describing one mechanism, not two.

Exercise 7-11. §7.6

Exercise 7-11 (design). Rust’s zero-copy parser hands out &str slices of an input buffer it does not own, with lifetimes proving the buffer outlives every slice. Sketch the wolf alternatives (copying the token text, returning byte ranges into a caller-held string, or parsing into memory the caller names) and argue which one a tokenizer library should ship. What does each cost, and who pays it?

Solution (discussion): the range API is the honest default: tokens as (start, end) pairs are plain values, move freely, and cost eight bytes each; the caller pays one indirection (input[t.0..t.1]) at each use, checked. Copying pays allocation per token to buy the simplest caller code; for a config-file parser nobody measures, that is the right trade, and for a log-ingest loop it is not. The freeze design is the interesting one: parse into a region, freeze it, and hand back imm tokens that reference the frozen input: sharing without copies and without lifetimes, at the cost of making the input immutable forever and region-resident from the start; it fits a compiler front end, where the source text never changes after load. The library should ship ranges and let the other two be five-line wrappers, because ranges are the only shape that never dictates the caller’s memory story. What Rust buys with lifetime annotations is making the borrow design default; what wolf buys by refusing them is that no signature in this paragraph mentions anything but values.

Exercise 7-12. §7.7

Exercise 7-12 (fingers · lupin REPL). In the REPL, move a string out of one binding into another with move (a plain = would copy it), then read both, the corpse first. What does the session do that a compiled program cannot, and which clause tag names the reason the trap did not end your session?

Solution. One session:

$ lupin
wolf> let s = "wolf"
wolf> let t = move s
wolf> s
trap(use-after-move): `s` was moved out and is uninitialized here [mem.tier0.move.2] at 0..1
  `s` moved here at 8..14
the session survives the trap; the world is as the fault left it [repl.trap.alive]
wolf> t
wolf : str
wolf> :quit

The session takes the trap and keeps the world ([repl.trap.alive]), so the state a fault left behind is inspectable, which is the REPL’s whole advantage over a crashed process. The value is intact in t: a move is a transfer, never a destruction, and the machine-level story (§7.7) is a memcpy after which the source is forgotten, not zeroed.

Exercise 7-13. §7.8

Exercise 7-13 (extension · lupin). Add a third shape to the Draw example, and then make render count its calls: what has to change, and what does not?

Solution. ch07/ex7-13.lu, and the third impl is one line:

trait Draw {
    fn draw(self) -> str
}
struct Dot { x: int }
struct Ring { r: int }
struct Star { points: int }
impl Draw for Dot { fn draw(self) -> str { "dot at {self.x}" } }
impl Draw for Ring { fn draw(self) -> str { "ring of {self.r}" } }
impl Draw for Star { fn draw(self) -> str { "star of {self.points}" } }
fn render(o: dyn Draw) -> str { o.draw() }
fn main() -> !int {
    let d = Dot { x: 3 }
    let r = Ring { r: 9 }
    let s = Star { points: 5 }
    var calls = 0
    print(render(d as dyn Draw))
    calls = calls + 1
    print(render(r as dyn Draw))
    calls = calls + 1
    print(render(s as dyn Draw))
    calls = calls + 1
    print("{calls} renders")
    0
}
$ lupin ex7-13.lu
dot at 3
ring of 9
star of 5
3 renders

What changed: one struct, one impl, one binding, one call. What did not: render. That is erasure earning its keep: the function that takes dyn Draw never learns how many implementors exist. The counter lives at the call sites, because render has nowhere to keep state. It reads its argument through the pair and owns nothing, which is §7.8’s rule seen from the callee’s side.

Exercise 7-14. §7.8

Exercise 7-14 (design). The cast-a-binding rule exists because the dyn pair points at its operand rather than owning it. What would the language have to invent for Dot { x: 3 } as dyn Draw to be legal, and who would pay for it?

Solution. The temporary needs a home that outlives the expression, so the language would have to invent one: a hidden allocation (a box the reader never wrote), or a compiler-synthesized binding with a lifetime the reader never chose. Both are costs paid silently, and wolf’s temperament is that erasure may change dispatch but never ownership: the pair points at your value, in your frame or your region, and the let home = … the error asks for is the language declining to allocate behind your back. The reader pays one visible line; the alternative is every reader paying an invisible allocation.

Exercises 7-13 and 7-14 were chapter 5’s 5-9 and 5-10 before dyn and the cast-a-binding rule moved into this chapter.

Exercise 7-15. §7.8

Exercise 7-15 (extension · lupin). The longest common subsequence of two line lists is the skeleton every diff tool hangs on. Build the DP table as a List[List[int]] and return its corner. For two three-line “files” of your choosing, compute the answer on paper first, then check it. Note what the signature says about ownership: which of your parameters were moved, and how many annotations did it take to say so?

Solution. ch07/ex7-15.lu (excerpt — the core):

fn lcs_len(a: List[str], b: List[str]) -> int {
    var table = List[List[int]]()
    var i = 0
    while i <= a.len {
        var row = List[int]()
        var j = 0
        while j <= b.len {
            (mut row).push(0)
            j += 1
        }
        (mut table).push(row)
        i += 1
    }
    i = 1
    while i <= a.len {
        var j = 1
        while j <= b.len {
            if a[i - 1] == b[j - 1] {
                table[i][j] = table[i - 1][j - 1] + 1
            } else if table[i - 1][j] >= table[i][j - 1] {
                table[i][j] = table[i - 1][j]
            } else {
                table[i][j] = table[i][j - 1]
            }
            j += 1
        }
        i += 1
    }
    table[a.len][b.len]
}
$ lupin ex7-15.lu
2

With the solution’s two files, “the wolf runs” and “the elk listens” survive and the moon line does not. The signature’s answer: neither parameter was moved. Both lists are borrowed, the caller keeps them, and it took no annotation at all to say so.

Exercise 7-16. §7.8

Exercise 7-16 (comprehension + extension · lupin). Extend 7-15 into a printing diff: walk the finished table backward from the corner, emitting two spaces for common lines, - for deletions, + for additions. Before running, predict the full output for old = wolf / moon / elk and new = wolf / elk / river. Then explain why the walk must go backward.

Solution. ch07/ex7-16.lu (excerpt — the walk):

fn print_diff(a: List[str], b: List[str], table: List[List[int]], i: int, j: int) {
    if i > 0 && j > 0 && a[i - 1] == b[j - 1] {
        print_diff(a, b, table, i - 1, j - 1)
        print("  {a[i - 1]}")
    } else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
        print_diff(a, b, table, i, j - 1)
        print("+ {b[j - 1]}")
    } else if i > 0 {
        print_diff(a, b, table, i - 1, j)
        print("- {a[i - 1]}")
    }
}
$ lupin ex7-16.lu
  the wolf runs
- the moon watches
  the elk listens
+ the river answers

The table’s cell (i, j) only knows the best answer up to that point; which choice produced it is recoverable only by comparing a cell with its neighbors, and the neighbors that explain (i, j) are behind it. The recursion runs to the origin and prints on the way back out, so the output comes out forward.

Exercise 7-17. §7.8

Exercise 7-17 (fingers · lupin). The plane-geometry kata: a Point, a Rect of two points (low corner in, high corner out), and an impl giving Rect three methods — contains(self, p), overlaps(self, o), area(self). Probe the edges: a point on the low edge, a point on the high edge, a rectangle that shares only a corner line. Every method here borrows. How do you know that from the signatures alone?

Solution. ch07/ex7-17.lu:

struct Point { x: int, y: int }
struct Rect { lo: Point, hi: Point }
impl Rect {
    fn contains(self, p: Point) -> bool {
        p.x >= self.lo.x && p.x < self.hi.x && p.y >= self.lo.y && p.y < self.hi.y
    }
    fn overlaps(self, o: Rect) -> bool {
        self.lo.x < o.hi.x && o.lo.x < self.hi.x && self.lo.y < o.hi.y && o.lo.y < self.hi.y
    }
    fn area(self) -> int {
        (self.hi.x - self.lo.x) * (self.hi.y - self.lo.y)
    }
}
fn main() -> !int {
    let den = Rect { lo: Point { x: 0, y: 0 }, hi: Point { x: 4, y: 3 } }
    let ridge = Rect { lo: Point { x: 3, y: 1 }, hi: Point { x: 6, y: 5 } }
    let creek = Rect { lo: Point { x: 4, y: 3 }, hi: Point { x: 7, y: 6 } }
    print("area {den.area()}")
    print("{den.contains(Point { x: 3, y: 2 })} {den.contains(Point { x: 4, y: 2 })}")
    print("{den.overlaps(ridge)} {den.overlaps(creek)}")
    0
}
$ lupin ex7-17.lu
area 12
true false
true false

Half-open on purpose: the low edge is in (>=), the high edge is out (<), so contains at x = 4 in a rect that ends at 4 is false, and two rects that only touch (creek starts exactly where den ends) do not overlap — the same convention every slice in chapter 2 used, because shared edges double-count under closed intervals. The signatures carry no mut, no take: every parameter is the default mode, which chapter 7 defined as borrow-and-read. The absence is the documentation, and it is checkable — add a write to any method body and the compiler names the missing mut at both ends.

Exercise 7-18. §7.8

Exercise 7-18 (extension · lupin). One job, two ownership stories: uppercase every string in a list. Write it consuming — shouted(take xs) returns a new list and the argument is gone — and lending — shout(mut xs) rewrites in place and returns nothing. Run both. Count what each costs at the call site and in allocations, then answer: which one should a library export, and does the other need to exist at all?

Solution. ch07/ex7-18.lu:

fn shouted(take xs: List[str]) -> List[str] {
    var out = List[str]()
    for x in xs {
        (mut out).push(x.upper())
    }
    out
}
fn shout(mut xs: List[str]) {
    var i = 0
    while i < xs.len {
        xs[i] = xs[i].upper()
        i += 1
    }
}
fn main() -> !int {
    var a = List[str]()
    (mut a).push("howl")
    (mut a).push("scratch")
    let b = shouted(take a)
    print("{b[0]} {b[1]}")
    var c = List[str]()
    (mut c).push("howl")
    (mut c).push("scratch")
    shout(mut c)
    print("{c[0]} {c[1]}")
    0
}
$ lupin ex7-18.lu
HOWL SCRATCH
HOWL SCRATCH

The consuming version costs a whole second list and takes the caller’s original away — after take a, using a is the E1001 this chapter opened with. The lending version allocates only the strings it replaces and the caller keeps their binding; the price is one mut at each end, which is a price in candor, not in machinery. Export the lender: the caller who wanted the consuming shape can build it from the lender in two lines (copy, then shout), but the reverse is impossible — a consumed argument cannot be un-eaten. An API should take the least ownership that does the job, precisely so the bigger appetite stays the caller’s decision.

Exercise 7-19. §7.8

Exercise 7-19 (fingers · lupin). The same plane geometry as 7-17, asked of the arms instead of the fields. Write corner(p), which names where a Point sits relative to the axes, and kind(r), which describes a Rect — both as a single match whose arms take the value apart by field name rather than reading p.x and self.lo.y through the value. Two questions when it runs: which arm of corner would become unreachable if you moved it to the top, and why does kind’s second arm need no _ beside it?

Solution. ch07/ex7-19.lu:

struct Point { x: int, y: int }
struct Rect { lo: Point, hi: Point }
fn corner(p: Point) -> str {
    match p {
        Point { x: 0, y: 0 } => "the origin",
        Point { x: 0, .. } => "on the y axis",
        Point { y: 0, .. } => "on the x axis",
        Point { x, y } => "{x} {y}",
    }
}
fn kind(r: Rect) -> str {
    match r {
        Rect { lo: Point { x: 0, y: 0 }, hi: Point { x, y } } => "anchored, {x} by {y}",
        Rect { lo: Point { x: lx, y: ly }, hi: Point { x: hx, y: hy } } =>
            "{hx - lx} by {hy - ly} at {lx} {ly}",
    }
}
fn main() -> !int {
    let den = Rect { lo: Point { x: 0, y: 0 }, hi: Point { x: 4, y: 3 } }
    let ridge = Rect { lo: Point { x: 3, y: 1 }, hi: Point { x: 6, y: 5 } }
    print(corner(den.lo))
    print(corner(ridge.lo))
    print(corner(Point { x: 0, y: 3 }))
    print(kind(den))
    print(kind(ridge))
    0
}
$ lupin ex7-19.lu
the origin
3 1
on the y axis
anchored, 4 by 3
3 by 4 at 3 1

An arm is a conjunction of field tests over the value’s own shape. Point { x: 0, y: 0 } tests both fields against literals; Point { x: 0, .. } tests one and says the rest is deliberately ignored; Point { x, y } tests nothing and binds both under their own names. The comma before .. is not decoration — the field list separates its members with commas and .. is one more member, so Point { x: 0, .. } is the spelling.

Move Point { x, y } to the top of corner and every arm below it dies: an all-binder product covers everything, which is exactly what makes it a catch-all and exactly why it goes last. kind’s second arm needs no _ for the same reason in reverse — it binds all four coordinates and constrains none of them, so it already is the catch-all and the match is exhaustive without one.

The signatures still carry no mut and no take, so 7-17’s answer survives unchanged: every parameter is the default mode, and testing a value is not taking it. An arm that binds a non-Copy piece would move the whole scrutinee; every field here is an int, so nothing moves and den is still readable on the line after.

Exercise 7-20. §7.8

Exercise 7-20 (extension · wolf + lupin). Take 7-16’s walk one step further. Instead of printing, diff(a, b) returns the edits as a List[Edit], with struct Edit { mark: str, line: str }, and the caller prints them and counts the changed lines. Before writing it, decide whether diff lends its two lists or takes them, and say in one sentence what the lines in the result are under each choice. Run it on a three-line config file and a four-line successor of your own.

Solution. ch07/ex7-20.lu (excerpt — the walk and diff; build_table is 7-16’s, unchanged):

struct Edit { mark: str, line: str }
...
fn walk(a: List[str], b: List[str], t: List[List[int]], i: int, j: int, mut out: List[Edit]) {
    if i > 0 && j > 0 && a[i - 1] == b[j - 1] {
        walk(a, b, t, i - 1, j - 1, mut out)
        (mut out).push(Edit { mark: " ", line: a[i - 1] })
    } else if j > 0 && (i == 0 || t[i][j - 1] >= t[i - 1][j]) {
        walk(a, b, t, i, j - 1, mut out)
        (mut out).push(Edit { mark: "+", line: b[j - 1] })
    } else if i > 0 {
        walk(a, b, t, i - 1, j, mut out)
        (mut out).push(Edit { mark: "-", line: a[i - 1] })
    }
}
fn diff(a: List[str], b: List[str]) -> List[Edit] {
    let t = build_table(a, b)
    var out = List[Edit]()
    walk(a, b, t, a.len, b.len, mut out)
    out
}
$ lupin ex7-20.lu
  host = kasumi
- port = 80
+ port = 8080
  user = ada
+ log = on
3 of 5 lines changed; old still has 3
$ wolf run ex7-20.lu
  host = kasumi
- port = 80
+ port = 8080
  user = ada
+ log = on
3 of 5 lines changed; old still has 3

diff lends both lists, and the last line of the run is the reason: the caller still has old afterward, and the caller of a diff usually wants both files next, to print beside the edits or to patch. The price is that every line in the result is a copy of the caller’s. A push copies what it stores unless the argument is written take, and a line read out of a lent list cannot be taken: take a[0] on a parameter with no mode is a compile error and a trap(exclusivity) under lupin, both citing [mem.tier0.mode.read]. A diff(take a, take b) could take each line into its Edit and copy nothing, and the caller would have no files left to compare.

The recursion in walk is 7-16’s, with one change that is this chapter’s whole subject: it cannot print, so it needs somewhere to put the edits, and mut out at both ends is that place. The edits come out forward for the same reason 7-16’s lines did.

Exercise 7-21. §7.8

Exercise 7-21 (extension · wolf + lupin). The last three exercises share one shelf, whose documents now carry a list of their own: struct Doc { title: str, words: int, tags: List[str] } and struct Shelf { docs: List[Doc] }. First, remove(mut s, title): take every document with that title off the shelf and return how many went, or the error row {missing} when none did. Build the shelf’s new list by moving the survivors into it. How many documents does remove copy, and why is the shelf mut rather than take?

Solution. ch07/ex7-21.lu:

struct Doc { title: str, words: int, tags: List[str] }
struct Shelf { docs: List[Doc] }
fn remove(mut s: Shelf, title: str) -> int ! {missing} {
    let old = move s.docs
    var kept = List[Doc]()
    var gone = 0
    for d in old {
        if d.title == title {
            gone += 1
        } else {
            (mut kept).push(take d)
        }
    }
    s.docs = kept
    if gone == 0 { return missing }
    gone
}
fn main() -> !int {
    var shelf = Shelf { docs: List[Doc]() }
    (mut shelf.docs).push(Doc { title: "regions", words: 900, tags: List[str]() })
    (mut shelf.docs).push(Doc { title: "moves", words: 640, tags: List[str]() })
    (mut shelf.docs).push(Doc { title: "regions", words: 1200, tags: List[str]() })
    let n = remove(mut shelf, "regions") else 0
    let m = remove(mut shelf, "lifetimes") else -1
    print("{n} {m} {shelf.docs.len} {shelf.docs[0].title}")
    0
}
$ lupin ex7-21.lu
2 -1 1 moves
$ wolf run ex7-21.lu
2 -1 1 moves

None. move s.docs hands the whole list to old and leaves the shelf’s field empty; each survivor leaves old with take d and lands in kept whole, tags and all; s.docs = kept fills the emptied field again, which is §7.2’s rule for an emptied place. The two documents titled regions are never handed anywhere, and they go when old does, at the end of the call. Leave take off the push and the program prints the same line while copying every survivor, because a container store copies what it is given unless the argument says take.

The shelf is mut because remove changes it and the caller keeps it. A take signature would make every caller give the shelf up and receive a new one back, which is the same program with a longer call site and a window in which the caller has no shelf at all. mut says exactly what happens: the caller’s shelf is written through, in place, and the call site says so.

Exercise 7-22. §7.8

Exercise 7-22 (extension · wolf + lupin). Second, merge(mut into, take from): move every document of from onto the end of into. Why is from taken rather than mut, what may the caller do with its own binding after the call, and what would leaving take off the push inside the loop cost?

Solution. ch07/ex7-22.lu:

struct Doc { title: str, words: int, tags: List[str] }
struct Shelf { docs: List[Doc] }
fn merge(mut into: Shelf, take from: Shelf) {
    for d in from.docs {
        (mut into.docs).push(take d)
    }
}
fn main() -> !int {
    var a = Shelf { docs: List[Doc]() }
    var b = Shelf { docs: List[Doc]() }
    (mut a.docs).push(Doc { title: "regions", words: 900, tags: List[str]() })
    (mut b.docs).push(Doc { title: "moves", words: 640, tags: List[str]() })
    (mut b.docs).push(Doc { title: "tokens", words: 300, tags: List[str]() })
    merge(mut a, take b)
    print("{a.docs.len}: {a.docs[0].title} {a.docs[1].title} {a.docs[2].title}")
    0
}
$ lupin ex7-22.lu
3: regions moves tokens
$ wolf run ex7-22.lu
3: regions moves tokens

merge is the end of b as a shelf, and take is the signature saying so. A mut from would leave the caller holding b after the call, so merge would either copy every document, and both shelves would hold them, or empty b by hand, and the caller would hold an empty shelf that nothing in the signature mentioned. With take, the caller wrote take b at the call and the binding is spent: add a line that reads b.docs.len after the call and the compiler stops it with E1001 at that read, naming take b as the move, while lupin runs up to it and traps use-after-move. The caller may assign b a new shelf and use it again; it may not read the old one.

Because from is merge’s own, so are its documents, and take d moves each one onto into whole, its tags with it. Without the take the program prints the same line and copies every document it moves, tags included, into a list that is about to be dropped anyway: the container store copies unless the argument says otherwise.

Exercise 7-23. §7.8

Exercise 7-23 (comprehension + spelunking · wolf + lupin). Third, longest(s): return the document with the most words, the Doc itself and not its title, from a shelf passed with no mode. Write it the obvious way, ending on s.docs[best], and build it. Read what the compiler says, then wolf --explain E1002, and pick one of the two repairs it offers: which one does a query on a shelf want? Then run the unrepaired program under lupin, and say why §7.3’s longest never met this, and why it would not have met it even returning a Doc, as long as that Doc had no tags.

Solution. ch07/ex7-23.lu (excerpt — the function; main fills a shelf of three and prints the longest title and the shelf’s length):

struct Doc { title: str, words: int, tags: List[str] }
struct Shelf { docs: List[Doc] }
fn longest(s: Shelf) -> Doc {
    var best = 0
    var i = 1
    while i < s.docs.len {
        if s.docs[i].words > s.docs[best].words { best = i }
        i += 1
    }
    s.docs[best]
}
$ wolf conform-run ./ex7-23.lu
error[E1002]: `s` is the caller's value, and it is returned here while the caller still holds it
  --> ./ex7-23.lu:14:5
   |
 7 | fn longest(s: Shelf) -> Doc {
   |            - `s` is declared without a mode — that spells `read`: lent for the call, and still the caller's after it
...
14 |     s.docs[best]
   |     ^^^^^^^^^^^^ this would be a second live path to the value `s` was lent
   |
   = note: a `read` parameter is lent, never given [mem.tier0.mode.read]. Handing it on past the
     call would leave two places able to write one value, with no `shared` spelled anywhere
     [mem.tier0.excl.1]. Hand on an independent value with `copy`, or declare the parameter
     `take` so the caller gives it up (call sites then spell `take`).
help: to hand on an independent value, copy it at the move
   |
14 |     copy s.docs[best]
   |

This is the lend rule, and E1002 is its code because it is exclusivity again, one step removed. The shelf was lent: after the call it is still the caller’s, whole. Returning s.docs[best] would hand the caller a second owner of a document the shelf still holds, and a Doc now carries a List, which is storage two owners could both write, with no shared written anywhere to say so. The explain text’s second paragraph is the general rule, and its last sentence is the answer to the last question: “Values that cannot reach shared storage — a struct of scalars, a str — are not refused: their second path is a copy.” §7.3’s longest copied a title out and returned a str, and a Doc of a title and a words is a struct of a str and an int. The rule first has something to say when the value has a list inside it.

The two repairs are two different claims. take s says the query consumes the shelf, so the caller would give up every document to learn which one is longest; that is the wrong signature for a question. copy at the move says the caller gets an independent document and the shelf is untouched, and it is what ch07/ex7-23b.lu writes:

$ lupin ex7-23b.lu
moves, and the shelf still holds 3
$ wolf run ex7-23b.lu
moves, and the shelf still holds 3

The copy costs the document and its tags list. A caller that only wants to look can skip even that by asking for the position, best itself, and reading shelf.docs[i] in place, the way §7.6’s Tok hands back two integers instead of the text.

The interpreter does not refuse the unrepaired program:

$ lupin ex7-23.lu
moves, and the shelf still holds 3

The lend rule is static, and lupin copies at the crossing, so the program it runs is the one with the copy already in it. That is why the compiler’s verdict is the one this exercise is about: it is the machine that makes the copy a word you write, where a reader can see what it costs.

Chapter 8

Exercise 8-1. §8.1

Exercise 8-1 (comprehension · prose). Three programs you have met or written: (a) a web server handling one request (parse the headers, build a response, send it); (b) a compiler pass (read an AST, produce a transformed AST, discard the scratch); (c) a game loop (each frame computes collision pairs and a display list, then draws). For each, name the group of allocations that share a death, the moment they all die, and the one value (if any) that must survive. No wolf required; the point is that the regions were already there.

Solution: (a) everything parsed and built for the request dies when the response is flushed; the survivor is the response bytes (and any session state, which was never the request’s to own). (b) the scratch and the input AST die at pass end; the survivor is the output AST, which is why pass frameworks so often copy it out of an arena. (c) the collision pairs and display list die at frame end, sixty times a second; the survivor is the game state that feeds the next frame. In every case the granule was “these thousands of objects, together,” and the death was one moment: a shape single-owner trees cannot spell, which is the chapter’s opening argument.

Exercise 8-2. §8.2

Exercise 8-2 (fingers · lupin). Sum the first hundred integers using a list a helper function builds (the helper writing no region code at all) inside region tmp { }. State where fill’s list is allocated, and what happens to it at the closing brace.

Solution. ch08/ex8-2.lu:

fn fill(n: int) -> List[int] {
    var xs = List[int]()
    for i in 0..n { (mut xs).push(i) }
    xs
}
fn main() -> !int {
    var total = 0
    region tmp {
        let xs = fill(100)
        for x in xs { total += x }
    }
    print("{total}")
    0
}
$ lupin ex8-2.lu
4950

fill allocates into the caller’s current region: that is the default, and it is why the helper needed no annotation. At the brace, the region frees wholesale: the list, its buffer, all of it, in one motion. total survives because an int is a value in the frame, not an allocation in the region.

Exercise 8-3. §8.2

Exercise 8-3 (comprehension · wolf). One assignment tries to smuggle a region value past the brace:

struct Node { value: int }
fn main() -> !int {
    var keep = Node { value: 0 }
    region tmp {
        keep = Node { value: 7 }
    }
    if keep.value == 7 { 0 } else { 1 }
}

Predict the compiler’s verdict, and, before reading the diagnostic, list the three code locations you expect it to point at.

Solution: rejected, E1010, and the diagnostic points at exactly the three moments of the story: the region’s creation, the escape, and the free.

$ wolf conform-run ./ex8-3.lu
error[E1010]: `keep` still holds a value allocated in region `tmp` when the region is freed
 --> ./ex8-3.lu:8:9
  |
7 |     region tmp {
  |            --- region `tmp` is created here
8 |         keep = Node { value: 7 }
  |         ^^^^ the value flows out of the region here
  |                ----------------- allocated here, into region `tmp`
9 |     }
  |     - the region is freed here — everything in it is freed wholesale, as one unit
  |
  = note: to keep the value, allocate it where it must live: build it outside the region block, or
    aim the allocation at a longer-lived region explicitly (`let r = region()` … `in r { …
    }`); widening the region block to cover every use also works. When the block's own value
    is what must outlive it, `copy region { … }` copies that value into the enclosing region
    before the free ([mem.region.copyout]). Two keep-alive alternatives change the ownership
    instead: `freeze` the region (immutable forever) or make the value a `shared` cell
    (reference-counted, never dangles).

The checker speaks in allocation, escape, and free: the word “lifetime” appears nowhere, because the region is the lifetime, reified. lupin enforces the same rule at run time and blames the read rather than the write: the value died with its region, and the fault fires where the program finally reaches for it.

$ lupin ex8-3.lu
ex8-3.lu: trap(region-fault): `keep.value` reaches into `tmp` (region #1), which was freed wholesale; the value died with the region [mem.region.intra.2] at 10:8; the region was created here at 7:5
$ echo $?
3

One rule, two moments: the compiler refuses the program before it starts; the interpreter lets it run and faults the exact access that needed the freed data. Which line each tool blames is the difference between “this could dangle” and “this did.”

Exercise 8-4. §8.3

Exercise 8-4 (fingers · lupin REPL). In the REPL: define a one-field struct, create a region with region(rc), allocate one value into it with in r { … }, and look at :regions before and after freeze r. Predict the two state words you will see before you look.

Solution. One session:

$ lupin
wolf> struct Howl { n: int }
defined type `Howl`
wolf> let r = region(rc)
wolf> let h = in r { Howl { n: 3 } }
wolf> :regions
regions:
  #0 `program` arena state=open objects=0
  #1 `-` rc state=suspended objects=1
wolf> let f = freeze r
wolf> :regions
regions:
  #0 `program` arena state=open objects=0
  #1 `-` rc state=frozen objects=1
wolf> h.n
3 : i32
wolf> :quit

The states are suspended and frozen. Creating a region does not open it: the in r { } window did, briefly, and left it suspended with one object inside. freeze is a state of the region, not a property of any binding.

Exercise 8-5. §8.3

Exercise 8-5 (comprehension · lupin). A region is being sent somewhere while a window into it is still open:

fn main() -> !int {
    let ch = channel[region](1)
    let r = region()
    in r {
        var xs = List[int]()
        (mut xs).push(1)
        ch.send(move r)?
        0
    }
}

Predict the event, its trap kind, and the static error code the trap line will mention.

Solution: a region-fault. A region moves as a closed subtree, and this one is open at the send:

$ lupin ex8-5.lu
ex8-5.lu: trap(region-fault): region #1 is open here and cannot be transferred; a region moves as a closed subtree (the compiler's E1005) [mem.region.freeze.3] at 10:17
$ echo $?
3

The trap line names E1005 itself: the interpreter is enforcing at run time the same rule the compiler will reject at compile time, and it cites the compiler’s number so the two tiers stay one story.

Exercise 8-6. §8.4

Exercise 8-6 (fingers · lupin). Build a five-node doubly-linked ring in a pool region: each node points next and prev. Then prove both directions work: walk five steps forward from the head (where do you land?), and two steps backward. Rust folklore says this program requires unsafe or Rc<RefCell<…>>; say in one sentence why wolf’s checker does not object here.

Solution. ch08/ex8-6.lu (excerpt — the core):

struct Node { value: int, next: handle Node, prev: handle Node }
fn main() -> !int {
    region r: pool(Node) {
        var pool = Pool[Node]()
        var hs = List[handle Node]()
        for _ in 0..5 { (mut hs).push((mut pool).reserve()) }
        for i in 0..5 {
            (mut pool).init(hs[i], Node {
                value: (i + 1) * 10,
                next: hs[(i + 1) % 5],
                prev: hs[(i + 4) % 5],
            })
        }
...
$ lupin ex8-6.lu
10 40

Five steps forward from node 1 is node 1 again (it is a ring), and two steps back lands on node 4. The checker does not object because the cycle never crosses the region border: intra-region edges are unrestricted, and the region dies as one unit, so no edge can dangle. The two-phase reserve/init is what closed the cycle without a null in sight.

Exercise 8-7. §8.4

Exercise 8-7 (extension · prose). Grow the ring into the real folklore program: an LRU cache with sentinel head and tail, unlink and push_front as the only two link operations, promotion on get, and eviction of tail.prev at capacity. Trace it by hand: after put a, put b, get a, put c at capacity 2, what does the front-to-back walk print?

Solution (prose): c a. Insertion order is b-then-a reversed by push_front; the get promotes a past b; the eviction takes tail.prev, which the promotion made b; c lands in front. The walk visits head.next to tail. Every link mutation in the program goes through unlink or push_front (four pointer writes and five), which is the entire aliasing surface a reviewer must read.

Exercise 8-8. §8.5

Exercise 8-8 (comprehension · wolf + lupin). A struct type with a strong shared edge back to itself:

struct Node { value: int, next: shared Node }
fn main() -> !int { 0 }

main builds nothing. Predict each tool’s verdict anyway, then explain the asymmetry: which tool is answering “could any program with this type leak,” and which is answering “did this program fault”?

Solution: wolf rejects the type; lupin runs the empty main to exit 0.

$ wolf conform-run ./ex8-8.lu
error[E1006]: `Node` holds a strong `shared` path back to itself
 --> ./ex8-8.lu:4:27
  |
4 | struct Node { value: int, next: shared Node }
  |                           ^^^^ this `shared` edge closes the cycle Node → Node
  |
  = note: strong `shared` references drop their target when the last count drops, so a strong
    cycle would keep itself alive forever — and wolf has no cycle collector
    ([mem.shared.rc.2]). Break the back-edge: make this field `weak Node` (upgrade to reach
    the value without keeping it alive) or `handle Node` (a generational index that faults
    if the target is gone). If the structure is genuinely cyclic, keep the whole graph in
    one region instead — intra-region cycles are safe and freed wholesale
    ([mem.region.intra.1]).
$ lupin ex8-8.lu
$ echo $?
0

This is conservatism, legitimately: the static tier rejects by shape because some program with this type leaks, and refusing the type is the only way to refuse all of those programs at once. The dynamic tier saw no allocation, no cycle, no fault, which is also true. The note is the chapter in miniature: weak, handle, or put the cycle in a region.

Exercise 8-9. §8.5

Exercise 8-9 (comprehension + spelunking · wolf). One write after a freeze:

struct Config { limit: int }
fn main() -> !int {
    var cfg = freeze region { Config { limit: 42 } }
    cfg.limit = 7
    cfg.limit
}

Predict the verdict and, from --explain-level knowledge, the two repairs the note will offer.

Solution:

$ wolf conform-run ./ex8-9.lu
error[E1012]: `cfg.limit` is frozen, so it cannot be assigned through
 --> ./ex8-9.lu:7:5
  |
6 |     var cfg = freeze region { Config { limit: 42 } }
  |               -------------------------------------- the freeze happens here — the promotion to `imm` is deep and permanent
7 |     cfg.limit = 7
  |     ^^^^^^^^^ this needs the data to be mutable
  |
  = note: `freeze` promotes the whole graph to `imm`: shareable from anywhere, forever, and never
    writable again. Build the value completely before freezing it, or keep a mutable copy
    (`copy`) alongside the frozen one.

The repairs: finish building before freezing, or copy a mutable twin. There is no third repair, because there is no unfreeze: freeze is a cadence, not a lock. lupin reaches the same verdict from the other side, trapping region-fault at [mem.region.freeze.1], the clause E1012 enforces statically:

$ lupin ex8-9.lu
ex8-9.lu: trap(region-fault): region #1 is frozen: `imm` data is immutable forever [mem.region.freeze.1] at 7:5
$ echo $?
3
Exercise 8-10. §8.5

Exercise 8-10 (comprehension · lupin). The dynamic half of the same contract: create a pool region, freeze the region value, then call reserve on the pool. Predict the trap kind and the clause tag.

Solution. ch08/ex8-10.lu:

struct Node { value: int }
fn main() -> !int {
    let r = region(pool(Node))
    let p = in r { Pool[Node]() }
    let frozen = freeze r
    let h = p.reserve()
    0
}
$ lupin ex8-10.lu
ex8-10.lu: trap(region-fault): region #1 is frozen: `imm` data is immutable forever [mem.region.freeze.1] at 9:13
$ echo $?
3

region-fault, citing [mem.region.freeze.1], the same clause the E1012 diagnostic enforces statically in 8-9. reserve is a mutation of the region’s interior, and frozen means frozen all the way down.

Exercise 8-11. §8.6

Exercise 8-11 (comprehension · lupin). Two region values, two nested in windows, reads and writes crossing both:

let a = region()
let b = region()
in a {
    var xs = List[int]()
    (mut xs).push(1)
    in b {
        var ys = List[int]()
        (mut ys).push(2)
        total += xs[0] + ys[0]
    }
    (mut xs).push(3)
    total += xs[1]
}

Predict the printed total. Then the antichain question: of the shapes (1) in a { in b { } }, (2) in a { in a { } }, (3) in a { } in a { } (sequential reopen), which are legal? Answer from the rule that region values are affine and windows must be into distinct regions, then check the one the program demonstrates.

Solution: total is 6 (1 + 2, then 3). Shape 1 is legal: distinct regions, both open, provably disjoint because region values are affine (no alias of a can exist to sneak into the inner window). Shape 2 is the one the rule forbids: the same region twice would make the two windows alias. Shape 3 is legal: suspend, then reopen; the REPL showed a region surviving between windows in 8-4.

$ lupin ex8-11.lu
6
Exercise 8-12. §8.7

Exercise 8-12 (comprehension · lupin). A handle is used after its slot is gone:

region r: pool(Node) {
    var pool = Pool[Node]()
    let h = (mut pool).reserve()
    (mut pool).init(h, Node { value: 1 })
    (mut pool).remove(h)
    let v = pool[h].value
    v
}

Predict the trap kind, and be precise about what the trap line will say about generations.

Solution:

$ lupin ex8-12.lu
ex8-12.lu: trap(stale-handle): handle into pool#0 slot 0 carries generation 0, the slot is at generation 1; a stale handle is a deterministic fault in every profile, never UB [mem.shared.handle.2] at 11:17
$ echo $?
3

The handle remembers the generation it was issued at; remove bumped the slot’s generation; the mismatch is the fault. The trap line says it itself: deterministic, in every profile, never UB. A stale handle in wolf is a defined event, which is the entire difference between a handle and a C pointer into a freed arena.

Exercise 8-13. §8.7

Exercise 8-13 (design). Four fields, one decision each: (a) a parent pointer in a tree whose nodes a region owns; (b) an edge in a social graph where nodes are deleted while neighbors hold references; (c) a config blob read by every task for the process’s whole life; (d) a cache entry another subsystem may hold while the cache evicts it. For each: shared, weak, handle, or a plain intra-region edge; and name the failure contract you chose, not only the shape.

Solution (discussion): (a) plain intra-region edge (or handle if nodes are removed individually): the cycle is safe inside the region, and the failure contract is “none: the region dies as one.” (b) handle: deletion must be observable, and the contract is “reads of a dead neighbor fault, deterministically.” A weak would answer “gone” quietly, which a graph traversal can misread as “no edge.” (c) freeze and share the frozen region: read-only forever wants the no-synchronization contract, and freezing is how you buy it. (d) weak inside shared: the cache wants eviction to win. The other subsystem’s read should answer “gone, reload” rather than fault or keep the entry alive; a strong shared would silently defeat the eviction policy. All four choices are made by naming what failure should look like, which is the part the type system holds you to.

Exercise 8-14. §8.8

Exercise 8-14 (spelunking · wolf). Run wolf --explain E1012 and read it against exercise 8-9. Find: the sentence that explains why frozen data needs no locks, the phrase that makes the promotion transitive, and the reason “readable forever” is a performance claim, not only a safety one.

Solution:

$ wolf --explain E1012
E1012: frozen data cannot be written

`freeze` consumes a region and promotes everything in it to `imm`:
deeply immutable, shareable from anywhere — across threads, without
synchronization — and readable forever. That deal is permanent, and
it is why frozen data needs no locks and no lifetimes; a single write
anywhere would break every reader everywhere. This write reaches data
that a `freeze` already promoted (the freeze site is marked). Do the
mutation before freezing — build the value completely, freeze last —
or keep a mutable `copy` alongside the frozen original for the part
that must keep changing.

No-locks: “a single write anywhere would break every reader everywhere”; the absence of writers is what synchronization would otherwise buy. Transitivity: “promotes everything in it”; the deal is per-region, not per-binding. And “readable forever” is a performance claim because a fact the compiler can rely on is a fact the optimizer can spend: loads from imm data can be hoisted, merged, and const-propagated across calls, the aliasing fact C cannot state (§8.8’s refrain), available here because the type system made it unbreakable.

Exercise 8-15. §8.9

Exercise 8-15 (extension · lupin). A text adventure’s world is a cyclic graph: rooms point at each other in four directions, and “north then south” must come home. Build three rooms (den, ridge, river bank) in a pool region, close the cycles with two-phase init, and walk the path north, east, west, south, printing the room at each step. Predict the four lines before running; the fourth is the one that checks your south links are real.

Solution. ch08/ex8-15.lu (excerpt — the walk; the full wiring is on disk):

...
        for step in path {
            if step == "north" { here = pool[here].north } else if step == "south" { here = pool[here].south } else if step == "east" { here = pool[here].east } else { here = pool[here].west }
            print("you are at the {pool[here].name}")
        }
...
$ lupin ex8-15.lu
you are at the ridge
you are at the river bank
you are at the den
you are at the den

The last two lines are both “den”: west from the river bank comes home, and the den’s south is wired to itself: a self-loop is the graph’s honest spelling of “you cannot go that way,” and it costs no optional type. Room graphs are why the region chapter and the adventure genre get along: the whole world dies in one motion when the game ends, cycles and all.

Exercise 8-16. §8.9

Exercise 8-16 (extension · lupin). wc, wolfished: count the lines and words of a multiline block, but store every line in a scratch region while counting; then let the region die and print the counts after it is gone. State what survives the brace and why this program’s memory use at peak is “the text, once,” not “the text, twice.”

Solution. ch08/ex8-16.lu:

fn main() -> !int {
    let text = """
        the wolf runs at dusk
        the moon rises
        the pack answers
        """
    var lines = 0
    var words = 0
    region scratch {
        var stored = List[str]()
        for line in text.lines() { (mut stored).push(line) }
        lines = stored.len
        for line in stored {
            for _ in line.words() { words += 1 }
        }
    }
    print("{lines} lines, {words} words")
    0
}
$ lupin ex8-16.lu
3 lines, 11 words

The counts survive: plain ints in the frame. The stored lines do not, and did not need to: line views into the original text are two-word slices, so the region holds a list of views, not a second copy of the text. Peak memory is the text plus a few dozen bytes of list. At the brace, even those go, wholesale. That is the region answer to a question wc authors in C solve with careful free bookkeeping: put the bookkeeping in the shape, then stop doing it.

Exercise 8-17. §8.9

Exercise 8-17 (extension · lupin). A bounded window: keep only the last three of a stream of nine updates, in a three-slot List[str] that lives in its own region, slots overwritten with next % 3. When the stream ends, read the window oldest-first — (next + i) % 3 — assemble the report, then freeze the region and print. Nine updates arrived and three strings exist. Where did the other six go, and when did each one go?

Solution. ch08/ex8-17.lu:

fn main() -> !int {
    let updates = """
        one
        two
        three
        four
        five
        six
        seven
        eight
        nine
        """
    let window = region()
    var recent = in window { List[str]() }
    for _ in 0..3 { (mut recent).push("") }
    var next = 0
    for line in updates.lines() {
        recent[next % 3] = line
        next += 1
    }
    var report = ""
    for i in 0..3 {
        report += "{recent[(next + i) % 3]} "
    }
    let snapshot = freeze window
    print(report.trim())
    0
}
$ lupin ex8-17.lu
seven eight nine

Each of the six was dropped at the assignment that overwrote its slot: recent[next % 3] = line replaces the slot’s old value, and a replaced value with no other owner is gone then, not at the region’s end. The region bounds the worst case — at no point does the window hold more than three strings plus the one in flight — and the freeze at the end is the §8.5 move: the finished window becomes permanent, readable, and closed to the overwriting that built it. A ring plus a region is “bounded memory” spelled twice, once in index arithmetic and once in the shape.

Exercise 8-18. §8.9

Exercise 8-18 (fingers · lupin). Read one region’s ledger four times: at the open, after pushing a hundred integers, immediately again with nothing in between, and after a hundred more pushes. Print the four relations rather than the four numbers — zero at the open, charged after the first hundred, unchanged between the two adjacent reads, and not lower after the second hundred. One of those four has to be written >= where a reader would expect >. Say which one, and why the strict spelling is a drill that passes on your machine and fails on somebody else’s.

Solution. ch08/ex8-18.lu:

fn main() -> !int {
    var zero = false
    var charged = false
    var never_down = false
    var steady = false
    region counting {
        let open = region_bytes(counting)
        zero = open == 0
        var xs = List[int]()
        for i in 0..100 { (mut xs).push(i) }
        let first = region_bytes(counting)
        charged = first > open
        steady = region_bytes(counting) == first
        for i in 0..100 { (mut xs).push(i) }
        let second = region_bytes(counting)
        never_down = second >= first
    }
    print("zero {zero} | charged {charged} | never down {never_down} | steady {steady}")
    0
}
$ lupin ex8-18.lu
zero true | charged true | never down true | steady true

The last one, never_down. Monotone is what the ledger promises: it does not fall while the region lives. Strictly rising is not promised and is not true — the second hundred pushes charge only if the list has to grow its buffer to hold them, and a list with room to spare charges nothing at all. A drill written against > there passes on the machine it was written on and fails on the next one, which is the same mistake in miniature as writing a cap in payload bytes.

Nothing can make that comparison false. To break it the ledger would have to fall inside the region’s lifetime, and nothing in the language does that — a region gives memory back at its brace and at no other moment. Dropping a value inside the region does not uncharge it; neither does letting the list go out of scope. That is the trade §8.8 named: one free, not one per object, and the price of the one free is that nothing is freed early.

Exercise 8-19. §8.9

Exercise 8-19 (extension · lupin). A cap kata. Measure what building a list of fifty documents charges, uncapped. Then run the same build twice more: once in a region capped at exactly the measured reading, once capped one byte under it. Predict which of the two finishes before you run them, and predict the exit code of the program. Then explain why a cap written as 50 * 24 — fifty documents at a plausible size each — is a bug even when it happens to work on your machine.

Solution. ch08/ex8-19.lu:

struct Doc { title: str, words: int }
fn shelve(n: int) {
    var docs = List[Doc]()
    for i in 0..n { (mut docs).push(Doc { title: "regions", words: i }) }
}
fn main() -> !int {
    let probe = region()
    var measured = 0
    in probe {
        shelve(50)
        measured = region_bytes(probe)
    }
    region exact(cap: measured) {
        shelve(50)
    }
    print("at the measured budget: served")
    region short(cap: measured - 1) {
        shelve(50)
    }
    print("one byte under: served")
    0
}
$ lupin ex8-19.lu
at the measured budget: served
ex8-19.lu: trap(alloc-contract): struct literal `Doc` would charge 64 ledger byte(s) to `short` (region #3), taking it to 5216 — past its 5215-byte cap (5152 already charged) [mem.region.cap.1] at 8:37; `short` (region #3)'s budget is set here at 21:23
$ echo $?
3

The first capped region finishes: a ledger standing exactly at the cap is not a breach. The second dies at the allocation that would have taken it one byte past, and the program’s exit code is the interpreter’s trap status, 3 — not the 0 the last print would have produced, which never runs. A compiled binary ends the same trap with its own documented status; the kind is the contract, and the kind is alloc-contract on both.

50 * 24 is a bug because it is arithmetic in the wrong units. The budget is spent in ledger bytes, and the ledger charges what the implementation allocates: element storage rounded to alignment, the list’s growth history (every buffer the list outgrew is still charged), and the region’s own blocks. The trap line above says 64 ledger byte(s) for one two-field document — read that number against the 24 a reader would have estimated from the fields, and then remember that the number itself is this interpreter’s, not the compiler’s, and not the next release’s. A budget derived from region_bytes is written in the units it will be spent in; a budget derived from sizeof reasoning is a guess that traps on the machine you did not test, or never traps at all, which is worse.

Chapter 9

Exercise 9-1. §9.1

Exercise 9-1 (fingers + spelunking · wolf). The complete unsafe audit of every program in this book’s first eight chapters is one command. Run it from principles/exercises/, report the number, and state what property of the language makes the count trustworthy: what would the same grep miss in C?

Solution:

$ grep -rn "unsafe {" ch01 ch02 ch03 ch04 ch05 ch06 ch07 ch08 | wc -l
0

Zero: parts 1 and 2 up to this chapter never left the safe tier. The count is trustworthy because unsafe is the only entrance: raw pointer operations, foreign calls, and aliasing assertions do not parse outside the block, so the keyword is a complete index of the ring boundary. In C the equivalent grep misses everything, because there is no keyword: every pointer dereference in the program is potentially the audit’s subject, which is to say the audit is the whole program.

Exercise 9-2. §9.2

Exercise 9-2 (fingers · lupin). Your first unsafe block, kept legal: allocate eight bytes from C, set them all to 5, read one back, free, print. Type it, run it, and note the exit code: the point of this exercise is that nothing happens.

Solution. ch09/ex9-2.lu:

import c "stdlib.h"
fn main() -> !int {
    // # Safety: the eight bytes are written before they are read, every
    // access is in bounds, and the allocation is freed exactly once.
    unsafe {
        let p = c.malloc(8) as *u8
        c.memset(p, 5, 8)
        let v = p[3] as int
        c.free(p)
        print("{v}")
    }
    0
}
$ lupin ex9-2.lu
5
$ echo $?
0

Inside the block, C’s rules apply: allocate, use, free, in that order, and the oracle has nothing to say. The block is a contract change, not a crime scene. Note the # Safety: line above it: the compiler warns when it is missing, and it is the sentence a reviewer checks the body against.

Exercise 9-3. §9.2

Exercise 9-3 (comprehension · lupin). One character changes in 9-2: the write is p[8] = 1. The allocation holds eight bytes. Predict the oracle’s finding: its row, and which optimizer license the report will name.

Solution. The report, in full:

$ lupin ex9-3.lu
ex9-3.lu: ub(mem.ub) §7/P3: write of 1 byte(s) at alloc#0[8], which holds 8 [mem.ub] at 11:9; tag created at 9:17
  licenses O3a: `dereferenceable(n)` on known-size accesses; bounds-based alias disproof between distinct allocations
  alloc#0 `c.malloc(8)` 8 byte(s), live, owned by region #0
    tag#0 c.malloc(8)#root Active exposed
$ echo $?
3

Row P3, out of bounds. Read the license line twice: the reason one byte past the end is UB rather than a trap is that the compiler wants to assume accesses stay inside their allocation (dereferenceable, alias disproof between allocations). Every oracle report has this shape: the fault, then the optimization that the rule purchases. UB is not a punishment; it is a price list.

Exercise 9-4. §9.2

Exercise 9-4 (comprehension · lupin). The pointer is laundered through an integer before the read:

let p = c.malloc(8) as *u8
c.memset(p, 5, 8)
let address = p as int
c.free(p)
let q = address as *u8
let v = q[0] as int

An integer survives free untouched. Does the roundtrip save the read? Predict the oracle’s answer and its reasoning.

Solution: no. The integer survives; the permission does not.

$ lupin ex9-4.lu
ex9-4.lu: ub(mem.ub) §7/L2: read through an exposed pointer into alloc#0, which was freed [mem.unsafe.raw.1] at 14:17; tag created at 9:17
  licenses O8: escape analysis / stack promotion without conservatively pinning addresses
  alloc#0 `c.malloc(8)` 8 byte(s), FREED, owned by region #0
    tag#0 c.malloc(8)#root Disabled exposed
$ echo $?
3

The cast back from int reconnects the pointer to the allocation’s exposed tags, but every tag of a freed allocation is Disabled, so no defined execution exists to choose. The license explains who benefits: if an integer roundtrip could resurrect access, the compiler could never promote an allocation to a register, because some integer somewhere might name its address.

Exercise 9-5. §9.3

Exercise 9-5 (fingers + comprehension · lupin). Inject the classic: write, free, read, through one pointer. Run it twice. What is the oracle’s finding, and (the actual question) what is identical between the two runs that would not be identical for a use-after-free in C?

Solution. ch09/ex9-5.lu (excerpt):

...
    unsafe {
        let p = c.malloc(8) as *u8
        p[0] = 7
        c.free(p)
        let v = p[0]
...
$ lupin ex9-5.lu
ex9-5.lu: ub(mem.ub) §7/P1: read through tag#0 (c.malloc(8)#root), which is Disabled at alloc#0[0] [mem.prov.state] at 11:17; tag created at 8:17
  licenses O1: `mut` params lower to `noalias` + `dereferenceable`; unique-tag stores forward without memory checks
  alloc#0 `c.malloc(8)` 8 byte(s), FREED, owned by region #0
    tag#0 c.malloc(8)#root Disabled exposed
$ lupin ex9-5.lu
ex9-5.lu: ub(mem.ub) §7/P1: read through tag#0 (c.malloc(8)#root), which is Disabled at alloc#0[0] [mem.prov.state] at 11:17; tag created at 8:17
  licenses O1: `mut` params lower to `noalias` + `dereferenceable`; unique-tag stores forward without memory checks
  alloc#0 `c.malloc(8)` 8 byte(s), FREED, owned by region #0
    tag#0 c.malloc(8)#root Disabled exposed

Byte-identical reports, twice: same fault, same spans, same tag story. A C use-after-free reads whatever the allocator left there: sometimes 7, sometimes a new object, sometimes a crash, varying with allocator mood and moon phase. The oracle replaces “what happened to be in memory” with “what the rules say about this access,” and rules do not vary between runs. Deterministic faults are what make the escape hatch debuggable. That is §9.3’s pitch, performed.

Exercise 9-6. §9.3

Exercise 9-6 (comprehension · lupin). Allocate 64 bytes with c.malloc and read one of them before anything writes it:

let p = c.malloc(64) as *u8
let v = p[0] as int
c.free(p)

No free-before-use, no bounds problem, no aliasing. Predict whether this is undefined behavior, which row it lands on, and what optimization the row’s license names.

Solution: it is UB, and the row is L1. Reading memory nothing has written is on the list in its own right:

$ lupin ex9-6.lu
ex9-6.lu: ub(mem.ub) §7/L1: read of alloc#0[0], which nothing has written [mem.ub] at 10:17; tag created at 9:17
  licenses O7: moves lower to memcpy-and-forget; dead-store elimination on moved-from places; no zero-init of locals
  alloc#0 `c.malloc(64)` 64 byte(s), live, owned by region #0
    tag#0 c.malloc(64)#root Active exposed
$ echo $?
3

The license is the explanation, as always. If reading uninitialized memory had a defined answer, every local would have to be zeroed, a move could not be a copy-and-forget, and a store to a place that is about to be moved out of could not be deleted. The price of skipping all three is that the bytes malloc hands back are not a value yet. c.calloc is the call that makes them one.

Exercise 9-7. §9.4

Exercise 9-7 (comprehension · lupin). Two programs differ by one line’s position. Both allocate eight C bytes, both cross back to safe code through borrow r from p, the door. In the first, the malloc happens inside in r { }; in the second, outside any window. Predict each verdict before running either, and state the door’s obligation in one sentence.

Solution. ch09/ex9-7a.lu and ch09/ex9-7b.lu:

$ lupin ex9-7a.lu
$ echo $?
0
$ lupin ex9-7b.lu
ex9-7b.lu: ub(mem.ub) §7/P6: `borrow region #1 from` a pointer into alloc#0, which is owned by `program` (region #0) — the obligation is that the allocation lies wholly inside the named region's footprint [mem.unsafe.door] at 12:17; tag created at 10:17
  licenses O6: safe-tier code after the door keeps all safe-tier entitlements (O1–O4) — the door is where trust concentrates
  alloc#0 `c.malloc(8)` 8 byte(s), live, owned by region #0
    tag#0 c.malloc(8)#root Active exposed
$ echo $?
3

The obligation: the allocation must lie wholly inside the named region’s footprint. The first program allocated while r was ambient, so the claim is true; the second claims bytes owned by the program region. The license line is the chapter’s thesis in one clause: after a truthful door, safe code keeps all its entitlements, which is why the door is one narrow place, and why lying to it is the worst lie in the language.

Exercise 9-8. §9.5

Exercise 9-8 (fingers · wolf + lupin). Take §9.5’s pack and spell its second allocation the other way round: c.calloc(1, bytes) instead of c.calloc(bytes, 1). Both allocate the same number of bytes. Run the program under the interpreter, then compile it and run the binary. Report both outputs and say what a difference between them would have meant.

Solution. ch09/ex9-8.lu, and there is no difference:

$ lupin ex9-8.lu
64 bytes out, and not a pointer in sight
$ wolf build ex9-8.lu && ./ex9-8
64 bytes out, and not a pointer in sight

The first line is a model of the C heap; the second is glibc. A difference between them would have meant one of two things, and both are bugs: either a model of calloc disagrees with calloc, or the compiler’s membrane passes the arguments in the wrong order. The history here is not hypothetical: calloc(n, size) is n * size bytes, one of the models once made it n, and this is the shape of program that found it. Running a program two ways and comparing is what “one language, two implementations” is for, not a testing technique bolted on afterward.

Exercise 9-9. §9.6

Exercise 9-9 (comprehension · lupin). A C allocation made while a region was ambient, escaping the region that owned it:

let p = region r {
    let inner = c.malloc(8) as *u8
    c.memset(inner, 5, 8)
    inner
}
let v = p[0] as int

The pointer is a plain integer-like value; it moves out fine. Predict what the read faults with, and why the report will differ from 9-5’s use-after-free.

Solution:

$ lupin ex9-9.lu
ex9-9.lu: ub(mem.ub) §7/P4: read at alloc#0[0], whose owning region #1 was freed wholesale [mem.prov.region] at 14:17; tag created at 10:25
  licenses O3b: one alias-scope domain per region — pointers into distinct regions never alias; O4: regions not open in the current scope yield `invariant.load`
  alloc#0 `c.malloc(8)` 8 byte(s), live, owned by region #1
    tag#0 c.malloc(8)#root Disabled exposed
$ echo $?
3

Row P4, not P1: nobody called free. The region died, and it took the allocation’s permissions with it. Note the report’s strange- looking line: the allocation is still live (its bytes were never individually freed) but its owning region is gone, and that is enough. The rule C code linked into wolf must learn: memory borrowed while a region was ambient is a loan from the region, and the region’s death calls it in, wholesale.

Exercise 9-10. §9.7

Exercise 9-10 (spelunking · lupin). ch09/ex9-10.lu wraps its unsafe block in a #[trusted] function carrying its obligation as a string. Run it. Then answer from the chapter: what two questions about this function does a manifest-and-inventory audit answer that reading the function’s source cannot?

Solution. The program runs like any other:

$ lupin ex9-10.lu
64 bytes out, declared
$ echo $?
0

The two questions are closure and drift. Closure: reading pack tells you what pack does, and tells you nothing about whether anything it calls (or anything its dependencies call) is itself trusted or holds unsafe rings of its own. The inventory answers for the whole package, module by module, which is a question source review answers one file at a time and therefore usually does not. Drift: whether the roster is the same roster it was last release. Review is a memory; the manifest is a diffable fact, and a dependency that grows a trusted module has to grow a manifest line to do it. Neither question is about whether pack is correct: nothing mechanical answers that, which is exactly why the obligation is written in the attribute in English, for a person.

Exercise 9-11. §9.8

Exercise 9-11 (comprehension · prose). Five fragments; place each on the four-tier map (safe values, regions, unsafe raw, the door):

  1. let b = copy a
  2. pool[h].value
  3. p[8] = 1 where p: *u8
  4. borrow r from p
  5. ch.send(move r) where r is a closed region

Solution: 1 is tier-0 safe values, chapter 7’s world, no annotations. 2 is the region tier’s shared edge: a handle read, checked by generation, faulting stale rather than dangling. 3 is the raw tier: legal only inside unsafe, governed by the oracle’s price list. 4 is the door itself, the one construct that moves data up a tier, with its truth obligation. 5 is the region tier’s transfer verb, safe because closed-subtree moves preserve every invariant. Four tiers, one direction of trust: every construct on this list either stays in its tier or crosses at the door, and nothing else crosses at all.

Exercise 9-12. §9.8

Exercise 9-12 (design). A team wraps a 40,000-line C codec behind wolf FFI. Debate the two candidate shapes: (a) one unsafe block per call site, spread through the application; (b) one module owning every unsafe line, exporting twenty safe functions, #[trusted] on the membrane. Which failure modes does each shape optimize for, and what does the twenty-line rule from §9.5 actually buy the reviewer?

Solution (discussion): shape (a) optimizes for nothing except writing speed; its failure mode is that the audit surface is the application, and every new call site is a new review. Shape (b) optimizes for the reviewer: the unsafe ring is one module, the grep from 9-1 returns one path, and the twenty safe functions are the complete list of claims the C code makes about itself. Its failure mode is the membrane becoming a lie: the safe signatures can promise more than the C delivers (a str that is not UTF-8, a buffer length the codec ignores), and #[trusted] marks exactly where that lie would live. The twenty-line rule buys proportionality: a reviewer can hold twenty lines to the standard “I believe each one,” which is the standard unsafe code requires and 40,000 lines cannot meet. The door metaphor closes the argument: doors work because buildings have few of them.

Exercise 9-13. §9.8

Exercise 9-13 (extension (break-it-on-purpose) · lupin). Construct the shortest program you can in which the assertion, not any access, is the undefined behavior: use assume noalias on two pointers that alias. Predict the oracle’s wording: what does it say overlaps what?

Solution. ch09/ex9-13.lu (excerpt):

...
    unsafe {
        let p = c.malloc(8) as *u8
        let q = p
        assume noalias p, q
        p[0] = 1
        q[0] = 2
...
$ lupin ex9-13.lu
ex9-13.lu: ub(mem.ub) §7/P5: `assume noalias` asserts these ranges are disjoint, and alloc#0[0..1) overlaps alloc#0[0..1) — the assertion is false [mem.unsafe.raw.2] at 11:9; tag created at 9:17
  licenses O5: the asserted ranges get `noalias` treatment in Tier-3 code — vectorization/reordering as if proven
  alloc#0 `c.malloc(8)` 8 byte(s), live, owned by region #0
    tag#0 c.malloc(8)#root Active exposed
$ echo $?
3

The range overlaps itself (alloc#0[0..1) against alloc#0[0..1)) because q is p. This is the only assertion-created UB in the raw tier: everything else about *T is unrestricted, but a spoken aliasing promise is kept or it is UB the moment the accesses disagree with it. When C’s restrict comes up, this is the comparison: wolf did not remove the footgun, it made the trigger visible and gave it an oracle.

Exercise 9-14. §9.8

Exercise 9-14 (comprehension · lupin). The subtlest report in the chapter. Take §9.4’s door program and add one line: after let counts = borrow scratch from p, write p[0] = 1 through the raw pointer, and only then read counts[0]. Predict where the fault is reported and what the tag tree at the bottom of the report will have in it that no other report in this chapter has shown.

Solution. ch09/ex9-14.lu:

$ lupin ex9-14.lu
ex9-14.lu: ub(mem.ub) §7/P1: read through tag#1 (`borrow … from`), which is Disabled at alloc#0[0] [mem.prov.state] at 15:17; tag created at 13:22
  licenses O1: `mut` params lower to `noalias` + `dereferenceable`; unique-tag stores forward without memory checks
  alloc#0 `c.malloc(8)` 8 byte(s), live, owned by region #1
    tag#0 c.malloc(8)#root Active exposed
      tag#1 `borrow … from` Disabled|Reserved|Reserved|Reserved|Reserved|Reserved|Reserved|Reserved
$ echo $?
3

Two tags, one indented under the other: this is the first report in the chapter with a tree in it, not a single root. The door minted tag#1 as a child of the allocation’s root tag, and that child is what safe code was handed. Writing through p afterward is a write through a tag that is not an ancestor of tag#1 (foreign, in the model’s word), so tag#1 goes Disabled at the byte that was written, and the read through counts is P1. Notice the per-byte spelling: only byte 0 was written, so only byte 0’s permission died, and the other seven are still Reserved. Permissions are per location, not per pointer.

The general lesson is the reason the door exists. Once safe code holds a value, the raw tier must stop touching those bytes, because everything downstream is entitled to assume nobody is. Reaching around the door is the one thing the door’s license forbids.

Exercise 9-15. §9.8

Exercise 9-15 (fingers · lupin). Put an integer through raw memory and bring it back: malloc sixteen bytes, write the four bytes of 305419896 little-endian by hand (% 256 and / 256 are the only tools you need), read them back, reassemble, free. Print the four bytes and the round-trip verdict, and make main exit nonzero on a mismatch. Before running: 305419896 is 0x12345678 — which byte does p[0] hold, and why is that the low one?

Solution. ch09/ex9-15.lu:

import c "stdlib.h"

fn main() -> !int {
    let n = 305419896
    let p = unsafe {
        // # Safety: sixteen fresh bytes; the cast makes a pointer to
        // memory malloc just handed us.
        c.malloc(16) as *u8
    }
    unsafe {
        // # Safety: indices 0..4 stay inside the allocation.
        p[0] = n % 256
        p[1] = n / 256 % 256
        p[2] = n / 65536 % 256
        p[3] = n / 16777216 % 256
    }
    var b0 = 0
    var b1 = 0
    var b2 = 0
    var b3 = 0
    unsafe {
        // # Safety: reads the four bytes just written.
        b0 = p[0] as int
        b1 = p[1] as int
        b2 = p[2] as int
        b3 = p[3] as int
    }
    let back = b0 + b1 * 256 + b2 * 65536 + b3 * 16777216
    print("{b0} {b1} {b2} {b3}")
    print("{back == n}")
    unsafe {
        // # Safety: allocated above, freed once, not used after.
        c.free(p)
    }
    if back != n { return 1 }
    0
}
$ lupin ex9-15.lu
120 86 52 18
true

p[0] holds 120 — 0x78, the low byte — because the program chose little-endian: n % 256 is arithmetic for “the low eight bits”, and the loop of divisions walks upward from there. Nothing about malloc or the machine decided this; the byte order is wholly in the four write lines, which is the honest way to meet endianness — as a serialization decision some code made, checkable by reading it. The round trip closes over three unsafe blocks, each one operation wide, each with its # Safety: line: the ring is for operations, not for programs.

Chapter 10

Exercise 10-1. §10.1

Exercise 10-1 (fingers · lupin). Type and run your first scope: two children each send a number into a channel, and main adds what it receives after the scope closes. Then swap the two spawn lines and run again. What changed?

Solution. ch10/ex10-1.lu:

fn main() -> !int {
    let ch = channel[int](2)
    var total = 0
    scope s {
        s.spawn(fn() { ch.send(20) })
        s.spawn(fn() { ch.send(22) })
    }
    total += ch.recv() else |_| { return 1 }
    total += ch.recv() else |_| { return 1 }
    print("total={total}")
    0
}
$ lupin ex10-1.lu
total=42

Swapping the spawns changes nothing observable: addition commutes, and the scope’s exit joins both children before the first recv runs, so both values are already in the buffer either way.

Exercise 10-2. §10.1

Exercise 10-2 (comprehension · lupin). Predict the order of the two lines, then say what enforces it: the scheduler, or something stronger:

fn main() -> !int {
    scope s {
        s.spawn(fn() { print("child speaks") })
    }
    print("main speaks")
    0
}

Solution: child speaks first, always. The scope’s closing brace joins every child; main speaks sits after the brace, so it cannot run until the child has finished, under any seed. This is structure, not luck:

$ lupin ex10-2.lu
child speaks
main speaks
$ lupin run ex10-2.lu --seed=2024
child speaks
main speaks

The wrong answer is “the child happened to be scheduled first.” Move the print inside the scope and the order genuinely is the scheduler’s to choose; after the brace, it is not.

Exercise 10-3. §10.1

Exercise 10-3 (comprehension · lupin). A child’s last expression is a value. Predict this program’s exit code, and account for the 42:

fn main() -> !int {
    scope s {
        s.spawn(fn() { 42 })
    }
    7
}

Solution: exit 7. The child’s value is discarded at the join: a scope joins its children for their completion, not their results. A child that has something to say sends it on a channel; the 42 evaporates.

$ lupin ex10-3.lu
$ echo $?
7
Exercise 10-4. §10.2

Exercise 10-4 (extension (break-it-on-purpose) · lupin). Port Go’s classic leak: spawn a receiver on a channel that nobody will ever send to. In Go the goroutine outlives the function, silently, forever. Write the wolf version and predict what happens instead, and at which line.

Solution. ch10/ex10-4.lu:

fn main() -> !int {
    let ch = channel[int](0)
    scope s {
        s.spawn(fn() {
            let v = ch.recv() else |_| { return 0 }
            print("{v}")
        })
    }
    0
}
$ lupin ex10-4.lu
ex10-4.lu: trap(deadlock): every live task is blocked at a runtime-owned blocking point and no timer is pending; blocked-task roster: `main` (task 0), `task@654` (task 1) [conc.deadlock.trap] at 13:5
$ echo $?
3

The scope’s closing brace must join the child; the child is blocked in recv; nothing can unblock it. Where Go leaks quietly, wolf’s structure turns the same mistake into a deadlock the runtime can see: every live task blocked, so the trap fires and names them. The leak is not fixed; it is retired: this program cannot express “and the task lingers on unowned.”

Exercise 10-5. §10.2

Exercise 10-5 (spelunking · lupin). Read exercise 10-4’s trap line clause by clause. What does “no timer is pending” rule out, what is the “blocked-task roster” for, and why does the trap name main itself as blocked?

Solution (prose): the trap’s condition is every live task blocked with nothing left that could wake one. A pending timer (a timeout arm in some select) would eventually fire and unblock somebody, so its absence is part of the proof; the roster is the evidence, one entry per blocked task with its id, which is the list you would otherwise assemble by hand from a hung process’s stacks. main is on the roster because the join at the scope’s brace is itself a blocking point: main is not running the children, it is waiting for them, and waiting tasks are exactly what a deadlock is made of. The clause tag [conc.deadlock.trap] is the spec’s name for the whole rule.

Exercise 10-6. §10.3

Exercise 10-6 (comprehension · lupin). Three children compute through ?; one of them fails. Predict both printed lines, and name the exact point in the source where the error crosses from child to parent:

fn risky(n: int) -> int ! {Torn} {
    if n == 3 { return Torn }
    n * 100
}
fn gather() -> !int {
    let ch = channel[int](4)
    scope s {
        s.spawn(fn() { ch.send(risky(1)?) })
        s.spawn(fn() { ch.send(risky(3)?) })
        s.spawn(fn() { ch.send(risky(2)?) })
    }
    0
}
fn main() -> !int {
    let r = gather() else |_| { print("join surfaced the error"); 7 }
    print("{r}")
    0
}

Solution: join surfaced the error, then 7. The failing child’s ? raises Torn inside the task; the error travels to the scope’s closing brace (the join) and re-raises there, into gather’s own error row, where main’s else handles it. The crossing point is the brace. In Go this error dies in a goroutine unless you built machinery to carry it; here the structure is the machinery.

$ lupin ex10-6.lu
join surfaced the error
7
Exercise 10-7. §10.3

Exercise 10-7 (extension · lupin). Change 10-6 so no child fails (use 1, 2, and 4), then finish the job: close the channel, drain it, and return the sum. Why is it correct to close only after the scope’s closing brace: what has the join already proved by then?

Solution. ch10/ex10-7.lu (excerpt):

fn gather_all() -> !int {
    let ch = channel[int](4)
    scope s {
        s.spawn(fn() { ch.send(risky(1)?) })
        s.spawn(fn() { ch.send(risky(2)?) })
        s.spawn(fn() { ch.send(risky(4)?) })
    }
    ch.close()
    var total = 0
    for v in ch { total += v }
    total
}
$ lupin ex10-7.lu
total=700

After the brace, every child has completed, so every send that will ever happen has happened: close cannot cut anyone off. The join converts “I hope they are done” into a fact you may compute with.

Exercise 10-8. §10.4

Exercise 10-8 (comprehension · lupin). One sibling blocks forever; the other fails immediately. Predict all the output, and answer the pointed part first: does the blocked sibling’s defer run?

fn fail_fast() -> !int { Boom }
fn race_them() -> !int {
    let ch = channel[int](0)
    scope s {
        s.spawn(fn() {
            defer print("sibling cleanup ran")
            let v = ch.recv()?
            v
        })
        s.spawn(fn() { fail_fast() })
    }
    0
}
fn main() -> !int {
    let r = race_them() else |_| { 42 }
    print("{r}")
    0
}

Solution: yes. sibling cleanup ran, then 42. The failing child makes the scope cancel its blocked sibling; cancellation lands at the sibling’s blocking point (recv), and the task unwinds its own defers on the way out. Cancellation is polite. Chapter 14 shows the impolite variant (kill on a proc skips defers by design), and the difference between those two rules is a decided thing, not an accident.

$ lupin ex10-8.lu
sibling cleanup ran
42
Exercise 10-9. §10.4

Exercise 10-9 (extension · lupin). Build a two-stage pipeline: a producer sends 1 through 5 into raw; a transformer squares each into squared; main counts what arrives. Each stage closes the channel it sends on, when its input runs dry. Run it under two seeds. Then answer: which task must close squared, and what goes wrong if main tries to?

Solution. ch10/ex10-9.lu:

fn main() -> !int {
    let raw = channel[int](8)
    let squared = channel[int](8)
    var produced = 0
    scope s {
        s.spawn(fn() {
            for i in 1..=5 { raw.send(i)? }
            raw.close()
        })
        s.spawn(fn() {
            for v in raw { squared.send(v * v)? }
            squared.close()
        })
        for v in squared { produced += 1 }
    }
    print("{produced} values through the pipeline")
    0
}
$ lupin ex10-9.lu
5 values through the pipeline
$ lupin run ex10-9.lu --seed=2024
5 values through the pipeline

Only the transformer knows when the last square has been sent: it learns it from its own for loop ending, which happens when raw closes and drains. If main closed squared, it would be guessing; close is the sender’s verb, and each stage owns exactly one sending side.

Exercise 10-10. §10.4

Exercise 10-10 (design). Go has go f(); wolf deliberately has no detached spawn: a task needs a scope, and the scope must close. Take the other side seriously: name a real program shape that detached spawn serves well, sketch how wolf expresses it, and state what the wolf version pays and what it collects.

Solution (discussion): the honest case for detachment is the fire-and-forget notifier (a metrics ping, a log ship) where the caller genuinely does not want to wait and failure is acceptable. Go spells it in three characters. Wolf makes the lifetime explicit: the ping lives in some scope, a long-lived one owned by the subsystem that cares about pings, with the pattern chapter 11 builds. The payment is real: you must decide whose scope, which is a design question Go let you skip. What it collects: the answer to “can this program exit with work still running” is knowable by reading the scopes, every error has an owner, and the leak of exercise 10-4 is unwritable. Wolf’s position is that “whose is this task” was never optional: Go defers the question to a runtime that cannot answer it, and wolf asks it at the point where you still can.

Chapter 11

Exercise 11-1. §11.1

Exercise 11-1 (fingers · lupin). A function cannot spawn unless somebody hands it a scope. Write launch(s, ch, n) that spawns into a caller’s scope, and a main that calls it three times inside one scope block. The handed-over scope is the entire mechanism: nothing else in the call says “concurrent.”

Solution. ch11/ex11-1.lu:

fn launch(s: Scope, ch: channel[int], n: int) {
    s.spawn(fn() { ch.send(n * 10) })
}
fn main() -> !int {
    let ch = channel[int](3)
    var total = 0
    scope s {
        launch(s, ch, 1)
        launch(s, ch, 2)
        launch(s, ch, 3)
    }
    for _ in 0..3 { total += ch.recv() else |_| { return 1 } }
    print("{total}")
    0
}
$ lupin ex11-1.lu
60
Exercise 11-2. §11.1

Exercise 11-2 (comprehension · lupin). Take 11-1 and change one character: make the channel a rendezvous, channel[int](0). Predict precisely what happens and why: the answer involves which side of the scope’s closing brace the receives sit on.

Solution: deadlock. With no buffer, each child’s send blocks until someone receives; the receives are after the scope’s brace; the brace is a join that waits for the children. Children wait for main, main waits for children, and the trap names all four:

$ lupin ex11-2.lu
ex11-2.lu: trap(deadlock): every live task is blocked at a runtime-owned blocking point and no timer is pending; blocked-task roster: `main` (task 0), `task@890` (task 1), `task@890` (task 2), `task@890` (task 3) [conc.deadlock.trap] at 21:5
$ echo $?
3

The buffered version worked because capacity 3 let every send complete without a receiver. Buffer size here is part of the program’s correctness argument, not a tuning knob.

Exercise 11-3. §11.1

Exercise 11-3 (comprehension · lupin). Using only the text of 11-1’s program, answer: which functions in it are able to spawn tasks, and what single search over a large codebase would find every function with that ability? (Chapter 7 asked the same question about mutation.)

Solution (prose): main can spawn (it owns a scope block) and launch can spawn (it is handed the scope). Nothing else can. The search is for .spawn( plus scope blocks: the spawn surface is exactly the set of call sites that start a task and the set of braces they die at, which is the same audit grep '(mut ' performs for mutation, and like a mode the capability is written at both ends: launch(s: Scope, …) announces it in the signature and launch(s, …) spends it at the call, so a third search, for : Scope, finds every function that can start work in a caller’s brace. A capability you can grep for is a capability you can review.

Exercise 11-4. §11.2

Exercise 11-4 (extension · lupin). Build a worker pool: three workers share one jobs channel and one results channel; main feeds six jobs and closes. Each worker is the same four lines. Why does the pool need no “shut down workers” message?

Solution. ch11/ex11-4.lu:

fn main() -> !int {
    let jobs = channel[int](8)
    let results = channel[int](8)
    scope s {
        for w in 0..3 {
            s.spawn(fn() {
                for j in jobs { results.send(j * j)? }
            })
        }
        for j in 1..=6 { jobs.send(j)? }
        jobs.close()
    }
    results.close()
    var total = 0
    for r in results { total += r }
    print("total={total}")
    0
}
$ lupin ex11-4.lu
total=91

A worker’s loop ends when jobs closes and drains: the close is the shutdown message, broadcast to every receiver at once. The scope’s brace then proves all workers are gone before results is touched. Two channel closes and one join replace the ad-hoc “poison pill” protocols other ecosystems teach.

Exercise 11-5. §11.2

Exercise 11-5 (comprehension + schedule play · lupin). Shrink the pool to two workers and four jobs, and tag each result with the worker that produced it. Before running: is the assignment of jobs to workers part of the program, or part of the schedule? Run under seed 1 and seed 2024 and defend your answer with the outputs.

Solution. ch11/ex11-5.lu (excerpt):

...
    scope s {
        for w in 0..2 {
            s.spawn(fn() {
                for j in jobs { results.send("worker {w} took job {j}")? }
            })
        }
        for j in 1..=4 { jobs.send(j)? }
        jobs.close()
    }
...
$ lupin run ex11-5.lu --seed=1
worker 1 took job 1
worker 1 took job 2
worker 1 took job 3
worker 1 took job 4
$ lupin run ex11-5.lu --seed=2024
worker 0 took job 1
worker 0 took job 2
worker 0 took job 3
worker 0 took job 4

The assignment is the schedule’s: seed 1 lets worker 1 drain the whole queue, seed 2024 hands it to worker 0, and both are conforming runs of the same program. What the program owns is the set of results: four squares would be identical in every schedule, which is exercise 11-4’s sum. Write programs whose meaning lives in what is computed, not in who computed it; the seeds exist to catch you when you have not.

Exercise 11-6. §11.3

Exercise 11-6 (spelunking · lupin REPL). Turn on the trace and run a scope with two children, then read the scheduler’s own account:

$ lupin
wolf> :trace on
trace on: every rule firing is recorded with its clause anchor
wolf> scope s { s.spawn(fn() { print("a") }); s.spawn(fn() { print("b") }) }
a
b
wolf> :trace
     0..0      SchedSpawn [conc.task.spawn] ev#3 spawn `task@63` (task 2) under scope#0 in proc#0
    40..68     Assign [gram.expr.assign] write `s`
    40..68     ProvState [mem.prov.state] write alloc#0[0..1) through tag#2 — tree consistent
    40..68     ProvTag [mem.prov.tag] protector on tag#2 (`t0:0:s`) released: the call's extent ended
     8..70     Block [gram.expr.block] block yields its tail
    31..34     StrInterp [str.interp] f-string
    31..34     EvalStrictOrder [mem.model.order] 1 argument(s) evaluated left to right
    23..37     Block [gram.expr.block] block yields its tail
     0..0      TaskJoin [conc.task.join] ev#4 `main` blocks at scope#0's exit join
     0..0      SchedPark [conc.det.events] ev#5 park `main` (task 0)
     0..0      SchedDecision [conc.det.events] ev#6 schedule `task@33` (task 1), picked 0 of 2 ready
     0..0      TaskName [conc.task.name] ev#7 task `task@33` (task 1) completes: ok
    61..64     StrInterp [str.interp] f-string
    61..64     EvalStrictOrder [mem.model.order] 1 argument(s) evaluated left to right
    53..67     Block [gram.expr.block] block yields its tail
     0..0      SchedDecision [conc.det.events] ev#8 schedule `task@63` (task 2), picked 0 of 1 ready
     0..0      TaskName [conc.task.name] ev#9 task `task@63` (task 2) completes: ok
     0..0      SchedUnpark [conc.det.events] ev#10 unpark `main` (task 0)
     0..0      SchedDecision [conc.det.events] ev#11 schedule `main` (task 0), picked 0 of 1 ready
     0..0      TaskJoin [conc.task.join] ev#12 scope#0 joins: all 2 child(ren) complete
wolf> :quit

From the trace alone, reconstruct the task tree: which tasks exist, who owns them, and in what order they completed.

Solution (prose): three tasks. main is task 0; task@33 (task 1) and task@63 (task 2) were spawned under scope#0, which main owns, inside proc#0. The completion order is written in the TaskName events: task 1 at ev#7, task 2 at ev#9, and then main unparks and the scope joins at ev#12 with “all 2 child(ren) complete.” The dump is not a stack sample; it is the ownership tree the language defined, reported by the machine that enforced it.

Exercise 11-7. §11.3

Exercise 11-7 (comprehension · lupin REPL). In 11-6’s trace, find every SchedDecision line and read its “picked 0 of N ready” suffix. At which event did the scheduler actually have a choice, and what does that tell you about how many different traces this one-line program could produce?

Solution (prose): only ev#6 offered a choice: “picked 0 of 2 ready,” with both children runnable. ev#8 and ev#11 each had one ready task, which is no decision at all. One binary choice, so two inequivalent schedules exist: task 1 first or task 2 first, precisely the two outputs a b and b a. Counting the “of N ready” suffixes is a hand computation of what chapter 17’s --explore computes for real programs; do it once by eye to believe the tool.

Exercise 11-8. §11.3

Exercise 11-8 (design). A library offers fetch_all(urls: List[str]) -> List[Response] and wants to fetch concurrently. Two candidate signatures:

fn fetch_all(urls: List[str]) -> List[Response]
fn fetch_all(s: Scope, urls: List[str]) -> List[Response]

The first hides an internal scope; the second borrows the caller’s. Argue for each: who controls cancellation and lifetime in each design, and which caller is each one honest to?

Solution (discussion): the internal-scope version is honest to the caller who wants a blocking call: when it returns, no task it started survives. The function is externally sequential, concurrency as an implementation detail, nothing to cancel from outside because nothing outlives the call. The handed-over scope is honest to the caller who wants to compose lifetimes: the fetches join when the caller’s scope closes, so the caller can hang ten calls on one scope and cancel the lot by leaving it. But the signature now admits that tasks may outlive the call itself, and every reader of the call site must look up to find the brace those tasks die at. The library rule of thumb wolf’s std follows: take the caller’s scope when the work’s lifetime is legitimately the caller’s decision; keep the scope internal when the function’s contract is “done means done.” The wrong design is the secret third one: an internal scope that detaches work past its own return, which is the chapter 10 leak wearing a signature.

Chapter 12

Exercise 12-1. §12.1

Exercise 12-1 (fingers · lupin). A producer sends four squares and closes; main drains with a for loop. Type it, run it, then delete the ch.close() line and predict what the second run does before you try it.

Solution. ch12/ex12-1.lu:

fn main() -> !int {
    let ch = channel[int](4)
    var total = 0
    scope s {
        s.spawn(fn() {
            for i in 1..=4 { ch.send(i * i)? }
            ch.close()
        })
        for v in ch { total += v }
    }
    print("total={total}")
    0
}
$ lupin ex12-1.lu
total=30

Without the close, main’s for waits forever for a fifth value, the producer is already gone, and the deadlock trap fires. A for over a channel is a loop whose termination condition is someone else’s promise. Close is how that promise is kept.

Exercise 12-2. §12.1

Exercise 12-2 (extension (break-it-on-purpose) · lupin). Using one task and one channel of capacity 1, write the shortest program you can whose second statement never finishes. Predict the trap kind and the roster before running.

Solution. ch12/ex12-2.lu:

fn main() -> !int {
    let ch = channel[int](1)
    ch.send(1)?
    ch.send(2)?
    print("never printed")
    0
}
$ lupin ex12-2.lu
ex12-2.lu: trap(deadlock): every live task is blocked at a runtime-owned blocking point and no timer is pending; blocked-task roster: `main` (task 0) [conc.deadlock.trap] at 15:5
$ echo $?
3

The buffer holds one value; the second send blocks until a receive makes room, and the only task that could receive is the one blocked sending. A deadlock needs a cycle of waiting, not two tasks, and main alone closes a cycle of length one.

Exercise 12-3. §12.2

Exercise 12-3 (comprehension · lupin). Two identical selects; between them, one send. Predict both printed lines:

fn main() -> !int {
    let a = channel[int](1)
    select {
        v from a => { print("got {v}") },
        timeout(5.ms) => { print("timed out") },
    }
    a.send(9)?
    select {
        v from a => { print("got {v}") },
        timeout(5.ms) => { print("timed out") },
    }
    0
}

Solution: timed out, then got 9. A timeout arm is the arm that wins when no other arm can, not a delay. The first select has an empty channel and nothing pending, so the timer is the only way out; the second finds a value ready and the timer never enters into it.

$ lupin ex12-3.lu
timed out
got 9
Exercise 12-4. §12.2

Exercise 12-4 (comprehension + schedule play · lupin). Both channels are ready before the select runs. Write down every output this program is allowed to print, then run it under seed 1 and seed 2024:

fn main() -> !int {
    let a = channel[int](1)
    let b = channel[int](1)
    a.send(1)?
    b.send(2)?
    var got = 0
    select {
        v from a => { got = v },
        v from b => { got = v },
    }
    print("{got}")
    0
}

Solution: 1 and 2 are both conforming: two simultaneously ready arms make the pick a recorded scheduler decision, drawn from the seed.

$ lupin run ex12-4.lu --seed=1
2
$ lupin run ex12-4.lu --seed=2024
1

Each seed replays byte-identically, forever. “Nondeterministic” in wolf means the spec admits more than one outcome; any single seeded run is as reproducible as arithmetic.

Exercise 12-5. §12.2

Exercise 12-5 (spelunking · lupin). Run the explorer over 12-4 and read its report line by line:

$ lupin conform-run ex12-4.lu --explore=8
ex12-4.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 2 distinct — SCHEDULE-DEPENDENT
    exit(0) ×1 stdout=1\n leaks=0 forest=ok — replay: --seed=0
      decision stream: ev:0
    exit(0) ×1 stdout=2\n leaks=0 forest=ok — replay: --seed=4611686018427387905
      decision stream: ev:1
  deadlocks: 0 · races: 0 · max depth: 1 decision(s)
$ echo $?
1

Why “2 schedule(s)” and not eight? What is a decision stream, and why does the tool exit 1 when nothing failed?

Solution (prose): the budget of 8 is a ceiling, not a quota: the program contains exactly one decision with two choices, so the frontier closes after two schedules and max depth: 1 decision(s) says so. The decision stream (ev:0, ev:1) is the schedule written out as the sequence of choices taken; either replays exactly, and each outcome also carries a packed --seed spelling of the same stream. Exit 1 is the differential protocol’s honesty: a schedule-dependent program is a finding (something a test suite should know about) even when every outcome is individually fine. Deterministic-under-every-schedule is the verdict that exits 0, and exercise 12-8 earns it.

Exercise 12-6. §12.3

Exercise 12-6 (extension · lupin). Build a router: one task reads an inbox and forwards each value to an evens or odds sink. main feeds 1 through 8 and then sums both sinks. Mind the closes: who closes what, in what order?

Solution. ch12/ex12-6.lu:

fn main() -> !int {
    let src = channel[int](8)
    let evens = channel[int](8)
    let odds = channel[int](8)
    scope s {
        s.spawn(fn() {
            for v in src {
                if v % 2 == 0 { evens.send(v)? } else { odds.send(v)? }
            }
            evens.close()
            odds.close()
        })
        for i in 1..=8 { src.send(i)? }
        src.close()
    }
    var esum = 0
    var osum = 0
    for v in evens { esum += v }
    for v in odds { osum += v }
    print("evens {esum}, odds {osum}")
    0
}
$ lupin ex12-6.lu
evens 20, odds 16

Close flows downstream: main closes src when the feed ends; the router’s loop ends because of that close, and only then does the router close its two output channels. Each channel is closed by its only sender, and the close order is forced by the data flow: trace it backward from the sums and every close is where it must be.

Exercise 12-7. §12.3

Exercise 12-7 (design). A single task maintains a work list it alone pushes to and pops from. Argue why a channel is the wrong type for that list even though it would work, and name the two properties a channel charges for that this task does not use. When does the answer flip?

Solution (discussion): a channel buys synchronization (safe handoff between tasks) and blocking (a receiver waits for a sender). A single-task work list uses neither: nothing is handed off and waiting on yourself is exercise 12-2’s one-task deadlock wearing work clothes. List push/pop states the actual invariant (one owner, no concurrency) and the type system holds you to it; a channel would advertise a concurrency that does not exist to every future reader. The answer flips at the moment a second task appears: the day the work list is fed by a producer or drained by a pool, the channel’s two costs become exactly the two features you need, and the refactor is chapter 11’s worker pool. Types are claims; make the cheapest claim that is true.

Exercise 12-8. §12.4

Exercise 12-8 (comprehension · lupin). Two tasks acquire the same two mutexes in opposite spellings. Predict the total, and predict what the explorer says about this program, then check both:

fn main() -> !int {
    let a = Mutex(1)
    let b = Mutex(2)
    var total = 0
    scope s {
        s.spawn(fn() {
            when (a, b) { a += 10; b += 10 }
        })
        s.spawn(fn() {
            when (b, a) { b += 100; a += 100 }
        })
    }
    when (a, b) { total = a + b }
    print("{total}")
    0
}

Solution: 223. Both bodies run whole, in some order, on both mutexes: 1+2 plus 110 plus 110. when (b, a) and when (a, b) perform identical acquisitions because when sorts its set into canonical order before taking anything; the spelling order is documentation, not semantics. The explorer confirms there is nothing to find:

$ lupin ex12-8.lu
223
$ lupin conform-run ex12-8.lu --explore=16
ex12-8.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 1 distinct — observably deterministic (every schedule agrees)
    exit(0) ×2 stdout=223\n leaks=0 forest=ok — replay: --seed=0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)

“Observably deterministic (every schedule agrees)” is the verdict exercise 12-5’s program could not earn. Addition commutes; that is doing part of the work here, and the stem’s real lesson is in 12-9.

Exercise 12-9. §12.4

Exercise 12-9 (extension (break-it-on-purpose) · lupin). Now construct the classic deadlock when was designed to kill: task one takes a then b, task two takes b then a, nested. Write it and report what actually happens: at what phase does this program die?

Solution. ch12/ex12-9.lu:

fn main() -> !int {
    let a = Mutex(0)
    let b = Mutex(0)
    scope s {
        s.spawn(fn() { when (a) { when (b) { a += 1 } } })
        s.spawn(fn() { when (b) { when (a) { b += 1 } } })
    }
    0
}
$ lupin ex12-9.lu
ex12-9.lu: E0201: `when` requires at least two operands — it acquires its whole set at once, so name every sync object the body touches in one `when` list [gram.expr.conc] at 14:24
$ echo $?
2

It dies in the parser. The AB-BA deadlock needs incremental acquisition (hold one lock while asking for another) and when’s grammar has no one-lock form to nest: E0201 says take the set whole or do not use when. The bug is not detected; it is unspellable, which is a stronger guarantee than any detector. (Deadlock through channels remains constructible, as exercise 12-2 shows, because waiting for data is a program’s own business; acquiring locks piecemeal was never anything but a bug factory.)

Note: both implementations report E0201, with the same rule in their own words, one at the parse rung and one before the first line runs. The transcript above is lupin’s; §12.4 prints the compiler’s half beside it.

Exercise 12-10. §12.3

Exercise 12-10 (extension · lupin). The worklist as a checker: balanced(line) walks a line’s characters with a List[char] stack — push every opener among ([{, pop and compare on every closer. Table four lines, two of them wolf-shaped code fragments. Three ways to be unbalanced hide in one function; name all three and the line of your program that catches each.

Solution. ch12/ex12-10.lu:

fn balanced(line: str) -> bool {
    var stack = List[char]()
    for c in line.chars() {
        if c == '(' || c == '[' || c == '{' {
            (mut stack).push(c)
        } else if c == ')' || c == ']' || c == '}' {
            if stack.len == 0 { return false }
            let open = stack[stack.len - 1]
            let _ = (mut stack).pop()
            if c == ')' && open != '(' { return false }
            if c == ']' && open != '[' { return false }
            if c == '}' && open != '{' { return false }
        }
    }
    stack.len == 0
}
fn main() -> !int {
    var cases = List[str]()
    (mut cases).push(r"when (a, b) { c[0] }")
    (mut cases).push(r"select { (a from q => { ) }")
    (mut cases).push(r"([{}])")
    (mut cases).push(r"((")
    for line in cases {
        let verdict = if balanced(line) { "ok " } else { "BAD" }
        print("{verdict} {line}")
    }
    0
}
$ lupin ex12-10.lu
ok  when (a, b) { c[0] }
BAD select { (a from q => { ) }
ok  ([{}])
BAD ((

The three failures: a closer with nothing open (stack.len == 0 — the early return false), a closer of the wrong kind (the three comparisons against the popped opener — the second case dies here, ) against {), and openers left over at the end (the final stack.len == 0, which is the whole return expression — the fourth case). The raw-string test data is not decoration: the braces in { c[0] } would be interpolations in an ordinary literal, so the checker’s own input demonstrates §2.2’s reason raw literals exist.

Exercise 12-11. §12.3

Exercise 12-11 (extension · lupin). Two stacks make a compiler front half: convert 3 + 4 * 2 - 6 / 3 from infix to postfix with one operator stack — pop while the stack’s top has precedence at least the incoming operator’s, then push; drain at the end. Feed the result to 5-7’s RPN evaluator in your head to check it. Which single comparison in your loop decides that 3 + 4 * 2 is 11 and not 14?

Solution. ch12/ex12-11.lu:

fn prec(op: str) -> int {
    if op == "*" || op == "/" { 2 } else { 1 }
}
fn is_op(t: str) -> bool {
    t == "+" || t == "-" || t == "*" || t == "/"
}
fn main() -> !int {
    let infix = "3 + 4 * 2 - 6 / 3"
    var ops = List[str]()
    var out = ""
    for t in infix.words() {
        if is_op(t) {
            while ops.len > 0 && prec(ops[ops.len - 1]) >= prec(t) {
                out += "{ops[ops.len - 1]} "
                (mut ops).pop()
            }
            (mut ops).push(t)
        } else {
            out += "{t} "
        }
    }
    while ops.len > 0 {
        out += "{ops[ops.len - 1]} "
        (mut ops).pop()
    }
    print(out.trim())
    0
}
$ lupin ex12-11.lu
3 4 2 * + 6 3 / -

prec(ops[ops.len - 1]) >= prec(t) is the whole grammar. When * arrives with + on the stack, 1 >= 2 is false, so + stays put and * stacks on top — emitted first, which is what binds 4 * 2 before the add. Flip the comparison’s verdict (or make all precedences equal) and the output evaluates left to right: 14. One integer comparison is carrying operator precedence for the whole language, which is the honest size of that famous feature. The >= (rather than >) is left-associativity, checkable on the - … / tail.

Chapter 13

Exercise 13-2. §13.2

Exercise 13-2 (fingers · lupin). Nine numbers, squared and summed, on one core: build the list, square each into a second list, add them up, print the total. Run it and keep the program. The number it prints is the number every divided version of this job has to agree with, and a divided job that does not reproduce its sequential answer is wrong, not faster.

Solution. ch13/ex13-2.lu:

fn main() -> !int {
    var xs = List[int]()
    for i in 1..=9 { (mut xs).push(i) }
    var sq = List[int]()
    for x in xs { (mut sq).push(x * x) }
    var sum = 0
    for v in sq { sum += v }
    print("{sum}")
    0
}
$ lupin ex13-2.lu
285
Exercise 13-3. §13.2

Exercise 13-3 (comprehension · wolf + lupin). Two tasks increment a captured var:

fn main() -> !int {
    var hits = 0
    scope s {
        s.spawn(fn() { hits += 1 })
        s.spawn(fn() { hits += 1 })
    }
    hits
}

Before running it, predict the three fixes the note offers and which two apply here. Then run it under both tools and account for the difference in what they print: the codes and the spans agree, and the amount of output does not. Which tool tells you about the second spawn, and what does the extra warning on it say that W1101 did not?

Solution: the three fixes are a channel (each task sends, one owner adds), a Mutex acquired in a when (for state that is genuinely shared), and a private copy per task combined after the scope joins (for work that divides cleanly). All three apply here, and the third is the shortest: give each task its own total and add the two afterwards. The compiler names all three, once per spawn:

$ wolf conform-run ./ex13-3.lu
error[E1101]: this task writes to `hits`, which it captures from the enclosing function
 --> ./ex13-3.lu:9:24
  |
9 |         s.spawn(fn() { hits += 1 })
  |         --------------------------- the task's closure captures it at this spawn
  |                        ^^^^ tasks cannot mutate captured state
  |
  = note: task captures are copies, `imm` shares, or region moves (D14) — never mutable windows
    onto the parent's locals; two tasks writing one binding is the data race the memory
    model forbids. Three ways out: send results over a `channel` and let one owner mutate;
    guard truly shared state with a `Mutex` acquired in a `when` block; or give each task
    its own copy and combine the results after the scope joins.

warning[W1101]: this write to `hits` stays inside the task
 --> ./ex13-3.lu:9:24
  |
9 |         s.spawn(fn() { hits += 1 })
  |                 ------------------ the closure captured it at spawn
  |                        ^^^^ lands on the task's own copy
  |
  = note: task captures copy (or move); the enclosing binding never sees this assignment. Send the
    result over a channel, or return it through the scope's join.

error[E1101]: this task writes to `hits`, which it captures from the enclosing function
  --> ./ex13-3.lu:10:24
   |
10 |         s.spawn(fn() { hits += 1 })
   |         --------------------------- the task's closure captures it at this spawn
   |                        ^^^^ tasks cannot mutate captured state
   |

warning[W1101]: this write to `hits` stays inside the task
  --> ./ex13-3.lu:10:24
   |
10 |         s.spawn(fn() { hits += 1 })
   |                 ------------------ the closure captured it at spawn
   |                        ^^^^ lands on the task's own copy
   |

warning[W1102]: the closure above captured `hits` by value, so it will not see this assignment
  --> ./ex13-3.lu:10:24
   |
 9 |         s.spawn(fn() { hits += 1 })
   |                 ------------------ captured by value when this was created
10 |         s.spawn(fn() { hits += 1 })
   |                        ^^^^^^^^^ invisible to the closure
   |
   = note: closures copy their captures at creation; create the closure after the last assignment,
     or pass the value as a call argument instead.

The second s.spawn earns the same error at line 10, and two warnings ride along: W1101 (“this write to hits stays inside the task”) and, on the second spawn only, W1102 (“the closure above captured hits by value, so it will not see this assignment”). W1102 is the one the question asks about: it concerns not this closure’s write but the previous closure, which took its copy of hits before this line ran and will never see it. Five diagnostics for one mistake with two instances.

The interpreter refuses the same program, and says it once:

$ lupin ex13-3.lu
ex13-3.lu: E1101: this task writes to `hits`, which it captures from the enclosing function: unsynchronized mutable capture across tasks (D14 — copy, share `imm`, or `move`; a `sync` type mediates shared writes) [conc.task.spawn] at 9:24
$ echo $?
2

Same code, same headline, same span as the compiler’s first error: 318..322 is the hits at line 9. What differs is volume, not verdict: the compiler reports every offending spawn and both warnings, the interpreter reports the first refusal and stops. Neither runs it.

An earlier interpreter ran this program to exit 0: capture by value meant both increments landed on private copies, hits printed as 0, and a silently wrong answer wore a clean exit code. That contrast was the argument for making the capture a compile error. The argument won; the demonstration is gone, and W1101’s note is where the lost-update story now lives.

Exercise 13-4. §13.2

Exercise 13-4 (spelunking · lupin). Two programs differ in one print. Explore both and read the verdicts:

$ lupin conform-run ex13-4a.lu --explore=8
ex13-4a.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 2 distinct — SCHEDULE-DEPENDENT
    exit(0) ×1 stdout=1 then 2, sum 3\n leaks=0 forest=ok — replay: --seed=0
      decision stream: ev:0,0,0
    exit(0) ×1 stdout=2 then 1, sum 3\n leaks=0 forest=ok — replay: --seed=4611686018427387905
      decision stream: ev:1,0,0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)
$ lupin conform-run ex13-4b.lu --explore=8
ex13-4b.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 1 distinct — observably deterministic (every schedule agrees)
    exit(0) ×2 stdout=sum 3\n leaks=0 forest=ok — replay: --seed=0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)

ex13-4a.lu prints arrival order and the sum; ex13-4b.lu prints only the sum. Same tasks, same channel, same schedules. Why do the verdicts differ, and what does that mean for how you design a parallel reduction’s output?

Solution (prose): determinism, as the explorer measures it, is a property of what the program observes about its schedules, not of the schedules themselves. Both programs run the same two interleavings (races: 0: channel operations synchronize; nothing here is a data race); 4a copies the arrival order into stdout, so its two schedules produce two outputs and the verdict is schedule-dependent, while 4b folds the values with +, which commutes, collapsing both schedules into one observable outcome. This is the design rule for par reductions in one sentence: combine with operations whose result does not encode arrival order, and the whole parallel program stays observably deterministic. That is the property --explore exists to certify, and the property 13-1’s par preserves by returning results in input order.

Exercise 13-5. §13.2

Exercise 13-5 (extension · lupin). grep, wolfished: write grep(text, pattern) -> List[str] ! {EmptyPattern} returning the matching lines. Substring search is yours to write with byte slices. Why is the empty pattern an error here, when POSIX grep happily matches it everywhere?

Solution. ch13/ex13-5.lu (excerpt):

fn contains(hay: str, needle: str) -> bool {
    if hay.len < needle.len { return false }
    var i = 0
    while i + needle.len <= hay.len {
        if hay[i..i + needle.len] == needle { return true }
        i += 1
    }
    false
}
fn grep(text: str, pattern: str) -> List[str] ! {EmptyPattern} {
    if pattern.is_empty() { return EmptyPattern }
    var hits = List[str]()
    for line in text.lines() {
        if contains(line, pattern) { (mut hits).push(line) }
    }
    hits
}
$ lupin ex13-5.lu
the wolf runs at dusk
a lone wolf watches
2 match(es)
empty pattern refused

POSIX grep’s empty pattern means “match every line,” a convention a human at a terminal can exploit and a program calling a function almost never intends: it is usually a variable that turned out blank. The row makes the caller say which they meant: handle EmptyPattern with “all lines” if that is truly the wish. An API’s defaults should serve its likeliest accident, not its cleverest use.

Exercise 13-7. §13.2

Exercise 13-7 (comprehension · lupin). One Euler step for two bodies on a line, gravity only, equal masses. Before running: what is v1 + v2 after the step, and is your answer exact or approximate for f64 arithmetic?

Solution. ch13/ex13-7.lu (excerpt):

...
    let r = absf(x2 - x1)
    let a = g / (r * r)
    v1 += a * dt
    v2 -= a * dt
    x1 += v1 * dt
    x2 += v2 * dt
...
$ lupin ex13-7.lu
x1=1 v1=1
x2=9 v2=-1
momentum 0

Exactly zero, and not by luck: both velocity updates add and subtract the same computed value a * dt, and for any f64 value x, the sum x + (0.0 - x) is exactly 0.0. Momentum conservation here is a property of sharing one rounding, not of infinite precision: compute the two accelerations separately with different roundings and the symmetry is gone. That observation is the seed of every reproducible n-body benchmark in Part 4.

Exercise 13-9. §13.2

Exercise 13-9 (extension · lupin). The caesar shift, sequential on purpose: shift(s, k) moves each lowercase letter k places with wraparound (chars, casts, % 26) and passes everything else through. Encode a line with k = 3, decode with the same function, and make main exit nonzero if the round trip misses. Two questions: what number decodes for k = 3, and — the extension — if the shift instead came from the letters of a key word repeating along the message, what would decode have to do that it does not do now?

Solution. ch13/ex13-9.lu:

fn shift(s: str, k: int) -> str {
    var out = ""
    for c in s.chars() {
        if c >= 'a' && c <= 'z' {
            let at = (c as int) - ('a' as int)
            let moved = (at + k) % 26
            out = "{out}{(moved + ('a' as int)) as char}"
        } else {
            out = "{out}{c}"
        }
    }
    out
}
fn main() -> !int {
    let plain = "the wolf runs at dusk"
    let coded = shift(plain, 3)
    let back = shift(coded, 23)
    print(coded)
    print(back)
    if back != plain { return 1 }
    0
}
$ lupin ex13-9.lu
wkh zroi uxqv dw gxvn
the wolf runs at dusk

23 decodes, because 3 + 23 is 26 and % 26 makes 26 the identity — decode is encode with the complementary shift, one function doing both jobs. Under a repeating key the complement is per position: decode must walk the key alongside the message and complement each letter’s own shift, so the one thing it needs that the fixed-shift version lacks is an index — the loop over chars() grows a counter, and everything else survives unchanged. (That per-position version is the Vigenère cipher, and the counter is the whole difference.)

Exercise 13-10. §13.2

Exercise 13-10 (comprehension + extension · lupin). 13-5’s byte scan found lines; this one counts hits. Write count(hay, needle, overlapping) — the same hay[i..i + needle.len] == needle probe — where the flag decides whether a hit advances i by one byte or by the needle’s length. Predict both answers for "aaaa" / "aa" before running. Which convention does a text editor’s find-and-replace need, and what goes wrong under the other one?

Solution. ch13/ex13-10.lu:

fn count(hay: str, needle: str, overlapping: bool) -> int {
    var hits = 0
    var i = 0
    while i + needle.len <= hay.len {
        if hay[i..i + needle.len] == needle {
            hits += 1
            i += if overlapping { 1 } else { needle.len }
        } else {
            i += 1
        }
    }
    hits
}
fn main() -> !int {
    print("{count("aaaa", "aa", true)} {count("aaaa", "aa", false)}")
    print("{count("the wolf the ridge the creek", "the", true)}")
    0
}
$ lupin ex13-10.lu
3 2
3

Overlapping sees hits at offsets 0, 1, 2; non-overlapping consumes two bytes per hit and sees 0 and 2. Find-and-replace needs the non-overlapping convention: each replacement consumes its match, so the next search resumes after it. Under the overlapping count a replace of "aa" in "aaaa" would claim three sites where only two disjoint replacements exist — the second “hit” overlaps bytes the first replacement already rewrote. A count is only meaningful with its consumption rule attached, which is why the flag is in the signature and not in a comment.

Exercise 13-11. §13.2

Exercise 13-11 (extension · lupin). 13-5 searched a str with str slices. Do it a layer down, over bytes, and lend the haystack rather than handing it over: find(hay, needle) -> int takes two List[byte] byte views and answers the first index where the needle starts, or -1. The window comparison belongs in its own function, so the slice happens inside a callee working on a view it does not own. Two questions: what does the caller still hold after the call, and how many bytes does one window cost?

Solution. ch13/ex13-11.lu:

fn matches_at(hay: List[byte], needle: List[byte], i: int) -> bool {
    let window = hay[i..i + needle.len]
    var k = 0
    while k < needle.len {
        if window[k] != needle[k] { return false }
        k += 1
    }
    true
}
fn find(hay: List[byte], needle: List[byte]) -> int {
    if needle.len == 0 { return -1 }
    var i = 0
    while i + needle.len <= hay.len {
        if matches_at(hay, needle, i) { return i }
        i += 1
    }
    -1
}
fn main() -> !int {
    let text = "the wolf runs the long ridge"
    let hay = text.bytes()
    print("{find(hay, "wolf".bytes())}")
    print("{find(hay, "ridge".bytes())}")
    print("{find(hay, "moon".bytes())}")
    print("{hay.len}")
    0
}
$ lupin ex13-11.lu
4
23
-1
28

hay crosses two signatures without a take and without a mut, so both callees borrow it and main still owns it — the last line proves that by reading hay.len after every search has finished with it. The slice inside matches_at is the point: a lent view can be sliced, and the subscript range is the same endpoint surface a str slice takes.

What a window costs is the second half of the answer, and it is not free. hay[i..i + needle.len] is a fresh List of that many elements, so the scan allocates one window per position tried rather than walking the haystack in place. That is the honest price of spelling the comparison as a slice, and it is why a real substring search compares element by element from i without cutting anything out. Write that version too and the loop body loses its allocation entirely; the answer it prints does not change.

Chapter 14

Exercise 14-1. §14.1

Exercise 14-1 (comprehension · lupin). A proc’s function returns 3. Before running, predict both fields of the line this prints:

fn worker() -> int { 3 }
fn main() -> !int {
    let w = spawn proc worker()
    let m = w.monitor()
    select {
        exit(reason) from m => {
            print("normal={reason.is_normal()} killed={reason.is_killed()}")
        },
        timeout(1.s) => { return 1 },
    }
    0
}

Solution: normal=true killed=false. Returning a value, any value, is a normal exit; the value rides along in the reason. Failure is a separate channel of information: an error return or a kill, not a number the supervisor squints at. Erlang made the same cut, and for the same reason: the exit reason is the protocol, the return value is the result.

$ lupin ex14-1.lu
normal=true killed=false
Exercise 14-2. §14.1

Exercise 14-2 (design). A log-search service has three concerns: an HTTP listener, a query planner, and one index shard per disk. Argue where the proc boundaries go. For each boundary you draw, name the failure it isolates and the state that dies with it; for one boundary you chose not to draw, name what shared fate you accepted.

Solution (discussion): the strong answer puts each shard in its own proc: a corrupt index or a crashing decompressor takes down one shard’s regions and nothing else, and the planner degrades to partial results. The listener and planner can share a proc at first: they share fate anyway (no listener, no queries), and splitting them buys isolation only once the planner holds cache state worth keeping through a listener restart. The boundary not drawn is the one between the planner and its per-query scratch, which is a region inside the proc, not a proc: it dies with the query, and promoting it to a proc would turn cheap bulk-frees into protocol. Ownership, failure, and service land on the same line or the design fights itself. That is Armstrong’s argument, applied.

Exercise 14-3. §14.2

Exercise 14-3 (comprehension · lupin). build_then_crash allocates a hundred integers into a region, then returns an error. Predict what the monitor reports and, separately, what happened to the hundred integers; then say which line of code freed them.

Solution. ch14/ex14-3.lu (excerpt):

fn build_then_crash() -> !int {
    let r = region()
    let n = in r {
        var xs = List[int]()
        for i in 0..100 { (mut xs).push(i) }
        xs.len
    }
    Boom
}
$ lupin ex14-3.lu
proc down; its regions are gone

No line of code freed them. The proc’s death is the deallocation: a proc owns its regions, and an abnormal exit frees them wholesale, with no unwinding and no per-object teardown. The monitor’s exit(reason) arrives after the memory is already gone.

Exercise 14-4. §14.2

Exercise 14-4 (comprehension · lupin). sleeper registers a defer and then blocks forever on an empty channel; the owner kills it. Two prints are written in this program: defer-skipped in the proc and released in the owner. Predict which of them appear, and in what order:

fn sleeper() -> int {
    defer print_raw("defer-skipped")
    let ch = channel[int](0)
    let v = ch.recv() else |_| { return 1 }
    v
}
fn main() -> !int {
    let w = spawn proc sleeper()
    let m = w.monitor()
    w.kill()
    select {
        exit(reason) from m => {
            if reason.is_killed() { print_raw("released") } else { print_raw("wrong") }
        },
        timeout(1.s) => { print_raw("timeout") },
    }
    0
}

Solution: only released. A killed proc’s defers do not run. That is the decided rule, not an accident: a kill must be safe to issue against a proc in any state, and running arbitrary cleanup code in a proc that is being destroyed for misbehaving is not safe. The proc’s regions bulk-free instead. Resources that must survive a kill belong to the owner’s side of a channel, or to a supervisor.

$ lupin ex14-4.lu
released
Exercise 14-5. §14.2

Exercise 14-5 (comprehension · lupin). The same shape at task granularity. One sibling blocks on a channel with a defer registered; the other fails. Predict the output. Then state, in one sentence each, why this defer runs when 14-4’s did not:

fn race_them() -> !int {
    let ch = channel[int](0)
    scope s {
        s.spawn(fn() {
            defer print("sibling cleanup ran")
            let v = ch.recv()?
            v
        })
        s.spawn(fn() { fail_fast() })
    }
    0
}

Solution: the cleanup line prints, then the caller’s handler value is used:

$ lupin ex14-5.lu
sibling cleanup ran
caught

Cancellation is cooperative: it arrives at a blocking point in a task that is still healthy, so its defers run. A kill is unilateral: it destroys a proc that may be past cooperating, so they do not. One cleanup contract per failure primitive, and the primitive tells you which one you are holding.

Exercise 14-6. §14.3

Exercise 14-6 (fingers · lupin). Build a counting service: a proc that reads commands from a channel, where 0 means “reply with the total” and any other value adds to it. Drive it with 5, 2, then a report, and print what comes back.

Solution. ch14/ex14-6.lu:

fn counter(cmds: channel[int], replies: channel[int]) -> int {
    var total = 0
    for c in cmds {
        if c == 0 { replies.send(total) else { return total } } else { total += c }
    }
    total
}
fn main() -> !int {
    let cmds = channel[int](8)
    let replies = channel[int](1)
    let w = spawn proc counter(cmds, replies)
    cmds.send(5)?
    cmds.send(2)?
    cmds.send(0)?
    let t = replies.recv() else |_| { return 1 }
    print("total={t}")
    cmds.close()
    0
}
$ lupin ex14-6.lu
total=7

The proc’s for c in cmds loop is the mailbox: commands queue in the channel, the proc serializes them, and the reply channel carries answers back. No lock appears because no state is shared: the total lives in exactly one proc.

Exercise 14-7. §14.3

Exercise 14-7 (extension · lupin). Grow the protocol: -1 resets the counter. Report the total, reset, add 3, and report again. Predict both numbers first, then answer: what ordering guarantee makes your prediction safe, and which chapter taught it?

Solution. ch14/ex14-7.lu (excerpt):

...
    for c in cmds {
        if c == 0 { replies.send(total) else { return total } } else if c == 0 - 1 { total = 0 } else { total += c }
    }
...
$ lupin ex14-7.lu
before=7 after=3

before=7 after=3 is safe to predict because one sender’s sends arrive in order (chapter 12’s happens-before): the reset cannot overtake the first report, and the 3 cannot overtake the reset. The protocol’s integers are starting to strain: a real command set wants a type, which is where rows and enums earn their keep.

Exercise 14-8. §14.3

Exercise 14-8 (design). Erlang mailboxes offer selective receive: a proc can pluck the first message matching a pattern, leaving the rest queued. Wolf’s mailbox is a FIFO channel plus select over multiple channels. State one protocol that selective receive expresses more directly, then argue wolf’s side: what does a skipped-over message cost in Erlang that wolf’s design refuses to pay?

Solution (discussion): call-response over a shared mailbox is the classic selective-receive win: reply matching happens by pattern while unrelated traffic waits. The cost is the unbounded scan: every receive may walk the whole queue, and a proc that never matches some message class leaks queue memory silently; Erlang folklore is full of mailboxes that grew until the node died. Wolf’s answer is one channel per conversation (the reply channel in 14-6 is exactly that), which turns pattern-matching into channel topology the scheduler can see. The queue that would have grown silently becomes a channel you had to declare, with a capacity you chose. Less expressive per receive; every queue in the program has a name and a bound.

Exercise 14-9. §14.3

Exercise 14-9 (comprehension + schedule play · lupin). Two client tasks each send two increments to the counting proc; the scope joins, then main asks for the total. Run it under seeds 0, 1, 5, 9. Predict first: does the total vary with the schedule, and why not? Then name the thing that does vary between those runs even though no output shows it.

Solution. ch14/ex14-9.lu (excerpt — main):

...
    scope s {
        s.spawn(fn() { client(cmds) })
        s.spawn(fn() { client(cmds) })
    }
    cmds.send(0)?
...
$ lupin run ex14-9.lu --seed=0
total=4
$ lupin run ex14-9.lu --seed=1
total=4
$ lupin run ex14-9.lu --seed=5
total=4
$ lupin run ex14-9.lu --seed=9
total=4

The total is schedule-independent: the scope join happens-before the report command, so all four increments are in the mailbox before 0 enters it. What varies is the interleaving of the two clients’ sends (the mailbox’s arrival order), which addition happens to hide, because addition commutes. Replace total += c with an operation that does not commute and the seeds stop agreeing; chapter 17 hunts exactly that program.

Exercise 14-10. §14.3

Exercise 14-10 (extension · lupin). A stockroom proc: its mailbox carries lines like put 40 and take 15, each handled to completion — parse the verb and the number with words(), keep a running count, floor it at zero when a take overdraws. main sends five commands, closes, and learns the final count the way 14-6’s shelver reported its words: riding home in the exit reason. Why does the overdraw rule live in the proc rather than in the senders, and what does that placement buy when a second sender appears?

Solution. ch14/ex14-10.lu:

fn stockroom(cmds: channel[str]) -> int {
    var held = 0
    for c in cmds {
        var verb = ""
        var n = 0
        var i = 0
        for w in c.words() {
            if i == 0 { verb = w }
            if i == 1 { n = w.to_int() else 0 }
            i += 1
        }
        if verb == "put" { held += n }
        if verb == "take" {
            if n > held { held = 0 } else { held -= n }
        }
    }
    held
}
fn main() -> !int {
    let cmds = channel[str](8)
    let keeper = spawn proc stockroom(cmds)
    let m = keeper.monitor()
    cmds.send("put 40")?
    cmds.send("take 15")?
    cmds.send("put 6")?
    cmds.send("take 90")?
    cmds.send("put 12")?
    cmds.close()
    select {
        exit(reason) from m => { print("stockroom closed: {reason}") },
        timeout(1.s) => { print("stuck") },
    }
    0
}
$ lupin ex14-10.lu
stockroom closed: normal(12)

The trace: 40, 25, 31, floored to 0 by the 90, then 12. The overdraw rule lives in the proc because the count does: a sender enforcing “do not take more than is held” would need to ask first, and between its ask and its take another sender can move the count — the check-then-act race every shared ledger invents. Inside the handler the rule is atomic for free, §14.3’s law (one message at a time, to completion) doing the work of a lock. A second sender therefore costs nothing: senders stay ignorant of the invariant, and the one place that owns the count is the one place that defends it.

Chapter 15

Exercise 15-1. §15.1

Exercise 15-1 (comprehension · lupin). Chapter 14 showed a proc returning a value: is_normal() was true. This proc returns an error instead. Predict both fields. There are three possible exit shapes and this line can only show you two booleans:

fn boom() -> !int { Bad }
fn main() -> !int {
    let w = spawn proc boom()
    let m = w.monitor()
    select {
        exit(reason) from m => {
            print("normal={reason.is_normal()} killed={reason.is_killed()}")
        },
        timeout(1.s) => { return 1 },
    }
    0
}

Solution: normal=false killed=false. Both predicates are false because an error return is the third shape: not a normal completion, not a kill, but a failure the proc itself reported. The reason taxonomy is three-valued (normal, killed, errored), and a supervisor that only checks is_normal() treats the last two the same, which is usually what it wants.

$ lupin ex15-1.lu
normal=false killed=false
Exercise 15-2. §15.1

Exercise 15-2 (comprehension · lupin). monitor delivers a message; link shares fate. This program links to a proc that fails, then blocks on an empty channel. Two prints are written. Predict what appears on stdout, and whether the process ends well or badly:

fn boom() -> !int { Bad }
fn main() -> !int {
    let w = spawn proc boom()
    w.link()
    let ch = channel[int](0)
    let v = ch.recv() else |err| { print("recv failed"); return 7 }
    print("got {v}")
    0
}

Solution: nothing appears, and the process dies nonzero. The link propagates the failure into main at its blocking point: no error value arrives at the else handler, because shared fate is not an error return; it is death. The handler that never ran is the lesson: link is for “if it dies, we die,” and code below a link is written in that knowledge. Choose monitor when failure is information; choose link when failure is contagion, and mean it.

The exit NUMBER is deliberately not part of the answer. w.link() called from main couples w to the root supervisor’s domain, and [conc.proc.root] ends the process with “a nonzero, implementation-specified status” when that domain dies abnormally; [conf.trap.exit] tells a conforming tool to compare the outcome class and never the number. The transcript below is lupin’s and says 1; a compiled binary says 121. Both are right, and a reader whose tool prints the other number has not found a bug.

$ lupin ex15-2.lu
$ echo $?
1
Exercise 15-3. §15.1

Exercise 15-3 (design). A pipeline proc feeds a compressor proc, which feeds an uploader proc. For each of the three pairs, choose link or monitor and defend the choice with the failure you are designing for. One of the three answers should be “neither”: which, and what replaces it?

Solution (discussion): compressor–uploader wants link: a compressor with no uploader is doing work nobody will receive, and vice versa. Shared fate matches the data dependency. Pipeline–compressor wants monitor in the supervisor above them, not a link between them: the pipeline can buffer or reroute while a replacement compressor spins up, so failure is information there. The “neither” pair is whichever one you were tempted to wire both directions: a link is already bidirectional, and a link plus a monitor between the same two procs means the fate decision was never actually made. The supervisor tree makes these choices once, at the top, where the restart policy lives. Wiring fate ad hoc between siblings is how systems grow failure paths nobody drew.

Exercise 15-4. §15.2

Exercise 15-4 (fingers · lupin). Build the smallest supervisor: spawn a worker that fails on its first attempt and succeeds on its second; monitor it; on an abnormal exit, print a line and respawn with the next attempt number; stop after three attempts. Run it and keep the output.

Solution. ch15/ex15-4.lu:

fn worker(attempt: int) -> !int {
    if attempt == 1 { return Crash }
    0
}
fn main() -> !int {
    var attempt = 1
    var done = false
    while !done {
        let w = spawn proc worker(attempt)
        let m = w.monitor()
        select {
            exit(reason) from m => {
                if reason.is_normal() { done = true } else {
                    print("attempt {attempt} failed; restarting")
                    attempt += 1
                    if attempt > 3 { return 1 }
                }
            },
            timeout(1.s) => { return 1 },
        }
    }
    print("attempt {attempt} succeeded")
    0
}
$ lupin ex15-4.lu
attempt 1 failed; restarting
attempt 2 succeeded

The whole mechanism is visible: a loop, a monitor, a judgment on the reason, a bounded retry. The stdlib’s supervisor adds policy (restart strategies, intensity limits, child ordering) but no new primitive. You have now built the thing the next section hands you.

Exercise 15-5. §15.2

Exercise 15-5 (comprehension · lupin). Same supervisor, but the worker fails every time. Predict the full output and the exit code before running, including how many times the worker actually runs.

Solution: three failures, a give-up line, exit 1. The budget is the supervisor’s honesty: without it, a deterministic crash loops forever, and “restarting” degrades from recovery into denial. Three runs, not four: the budget check happens after the increment. Predict that off-by-one on paper, not in a pager rotation.

$ lupin ex15-5.lu
attempt 1 failed
attempt 2 failed
attempt 3 failed
giving up after 3 attempts
$ echo $?
1
Exercise 15-6. §15.2

Exercise 15-6 (extension · lupin). Change the worker to fail twice and succeed on the third attempt (the flappy dependency pattern). Predict the output, run it, and then answer: your budget is 3. What single-character change makes this worker’s recovery impossible, and what does the output become?

Solution. ch15/ex15-6.lu (excerpt — the worker):

fn worker(attempt: int) -> !int {
    if attempt < 3 { return Crash }
    0
}
$ lupin ex15-6.lu
attempt 1 failed; restarting
attempt 2 failed; restarting
attempt 3 succeeded

Change the budget check attempt > 3 to attempt > 2 (or the worker’s < 3 to < 4): the third attempt (the one that would have succeeded) is never made, and the output becomes two failures and an exit 1. Restart budgets are a bet about how flappy the world is; this exercise is that bet lost by one.

Exercise 15-7. §15.3

Exercise 15-7 (design). Every proc in wolf lives under the root supervisor; there is no unsupervised spawn. Sketch the supervision tree for the chapter 14 log-search service (listener, planner, one proc per shard), choosing for each internal node: restart the child alone, restart all children, or escalate. Name the failure scenario that made you pick each policy.

Solution (discussion): the shards sit under one internal supervisor with restart-alone: shard failures are independent (one bad disk, one corrupt segment), and restarting siblings would throw away warm caches for no reason. The listener and planner sit under a restart-all node: a planner that died mid-query leaves the listener holding connections whose queries will never answer. Restarting them together resets the pair to a consistent nothing. The shard supervisor escalates only when its restart budget exhausts, at which point the service cannot search, and the root’s policy (restart the world, or die and let the OS supervisor act) is a deployment decision, not a code decision. The tree is the failure design; the procs are only its leaves.

Exercise 15-8. §15.3

Exercise 15-8 (spelunking · corpus). The pinned corpus checks the kill rule with this directive header, from upstream/corpus/conc/proc_kill_defers.lu:

//! check: run(exit=0, stdout="released")

Explain why the stdout= clause (not the exit code) is the part of this header that actually verifies the rule “defers in a killed proc do not run.” What would a conforming-looking run that violates the rule produce, and which field would catch it?

Solution: the program is built so the killed proc’s defer would print defer-skipped, so a run where defers wrongly ran produces defer-skippedreleased (or an interleaving of both), and only the stdout="released" check sees the difference; the exit code is 0 either way. The header is a negative test smuggled into a positive one: the asserted output is exact, so any extra byte from the forbidden defer is a corpus failure. When a rule’s violation is silent in the exit code, pin the channel where it is loud. A directive that checks the wrong observable verifies nothing.

Exercise 15-9. §15.3

Exercise 15-9 (design). A teammate proposes: “monitors are strictly better: a link is a monitor whose handler calls exit, so the language should ship only monitors.” Take the other side using 15-2’s observed behavior: name two properties of link that the monitor-plus-handler encoding does not provide.

Solution (discussion): first, a link works while you are blocked: 15-2’s main died at a recv it would never leave; the encoding’s handler only runs when its select next polls the monitor, so a blocked proc survives exactly when it should not. Second, a link cannot be forgotten on one path: the handler encoding must appear in every select the proc ever blocks in, and the one select that omits the monitor arm is an unsupervised wait. Fate declared once at wiring beats fate re-asserted at every blocking point: the encoding is strictly more expressive and reliably less used. (The full story adds delivery guarantees under simultaneous failure; chapter 17’s scheduler material is where that becomes checkable.)

Chapter 16

Exercise 16-1. §16.1

Exercise 16-1 (comprehension · lupin). The sender builds a list inside a region (two pushes) and sends the region. Predict: does the receiver’s in r2 { … } block run before or after both pushes are visible, and what synchronization made that true?

    s.spawn(fn() {
        let r = region()
        let xs = in r {
            var v = List[int]()
            (mut v).push(41)
            (mut v).push(1)
            v
        }
        ch.send(move r)?
    })
    let r2 = ch.recv() else |_| { return 1 }
    got = in r2 { 42 }

Solution: after. The move send happens-before the receive, so every write into the region before the send is visible to the receiver, and no other synchronization exists or is needed. The channel carried one word (the region), not the data; the ownership is what moved. This is the message-passing litmus from the memory model, and it is the entire safety argument of this chapter in eight lines.

$ lupin ex16-1.lu
received
Exercise 16-2. §16.1

Exercise 16-2 (fingers · lupin). Make the transfer carry real freight: build the two-element list in main, send the region to a receiving task, and have the receiver sum the list it never built. Print the sum from the receiver’s side.

Solution. ch16/ex16-2.lu (excerpt — the receiver):

...
        s.spawn(fn() {
            let r2 = ch.recv() else |_| { return }
            let total = in r2 {
                var t = 0
                for x in xs { t += x }
                t
            }
            print("sum={total}")
        })
        ch.send(move r)?
...
$ lupin ex16-2.lu
sum=42

The receiver iterates a list allocated by someone else, in memory that changed owners, and reads exactly what was written. Nothing was serialized, nothing was copied: the graph moved as a graph. (Erlang gets this safety by deep-copying every message; the next section’s lineup prices that.)

Exercise 16-3. §16.1

Exercise 16-3 (extension (break-it-on-purpose) · lupin). Construct the smallest program in which a sender touches a region after sending it with move. Predict the exact trap kind before running; it is one you met in chapter 7, not a new one.

Solution. ch16/ex16-3.lu:

fn main() -> !int {
    let ch = channel[region](1)
    let r = region()
    let n = in r { 41 }
    ch.send(move r)?
    let m = in r { 1 }
    m
}
$ lupin ex16-3.lu
ex16-3.lu: trap(use-after-move): `r` was moved out and is uninitialized here [mem.tier0.move.2] at 9:16; `r` moved here at 8:13
$ echo $?
3

The trap is use-after-move, citing the same clause family as chapter 7’s moved string, because a region is a value and move into a channel is the same move as take into a function. No new rule was needed to make cross-task transfer safe; the old rule was enough, which is the design working as designed. (The compiler rejects the shape statically, at the send; lupin carries the same lesson dynamically, at the read.)

Exercise 16-4. §16.2

Exercise 16-4 (comprehension · lupin). Ten squares are frozen into table; two tasks each read one entry and send it back. Predict the printed number, and answer precisely: how many copies of the table exist while both tasks read it?

    let table = freeze region {
        var xs = List[int]()
        for i in 0..10 { (mut xs).push(i * i) }
        xs
    }
    scope s {
        s.spawn(fn() { ch.send(table[3]) })
        s.spawn(fn() { ch.send(table[4]) })
    }

Solution: 25 (9 + 16), and one copy: the frozen original. Both tasks hold references into the same immutable memory; freeze happens-before every cross-task read, and immutability makes concurrent reading trivially safe. Where move gives the data one owner at a time, freeze gives it no owner that can write: two different ways to make “who else is touching this?” unaskable.

$ lupin ex16-4.lu
25
Exercise 16-5. §16.2

Exercise 16-5 (design). For each payload, choose move or freeze and defend it in one sentence: (a) a parsed configuration read by every worker for the process lifetime; (b) a request’s parse tree handed from parser to executor; (c) a routing table rebuilt every 30 seconds and read constantly; (d) a 2 GB index segment consulted by eight shards.

Solution (discussion): (a) freeze: many readers, no writer, forever; the definitional freeze. (b) move: exactly one consumer, which will mutate and then discard it; freezing would forbid the executor its annotations. (c) the interesting one: each rebuild is built mutable in a fresh region, then frozen and published; readers of the old table keep reading it until they pick up the new one. Freeze means immutable-per-edition, not one-forever. (d) freeze, and the size is the argument: eight copies is 16 GB and one shared frozen segment is 2, with no lock on the read path. All four answers mutate privately, then either hand the data over whole or make it untouchable; wolf gives no third verb, on purpose.

Exercise 16-6. §16.3

Exercise 16-6 (design). The same workload (a producer builds a million-node tree, a consumer walks it) in four systems: Erlang (copying send), Go (send a pointer), Rust (Arc<Mutex<Tree>>), wolf (ch.send(move r)). For each, name what the transfer costs at the moment of send, and what it costs the receiver to be safe while reading. One of the four pays at a different time than the others: which?

Solution (discussion): Erlang pays at send (a deep copy of a million nodes) and the receiver is then perfectly safe reading its private copy; safety was purchased in one large cash payment. Go pays nothing at send and the receiver is safe only by convention: nothing stops the producer from mutating the shared tree, and the race detector, not the compiler, is the enforcement. Rust’s Arc<Mutex> pays a little at send (refcount) and then pays on every read: lock traffic on a tree that will never be written again, unless the design graduates to Arc<Tree> frozen-by-construction, which is Rust spelling wolf’s answer manually. Wolf pays one word at send and nothing at read; the compiler’s move check is the whole bill, paid at compile time. The odd one out is wolf: three systems pay at runtime in copies, faith, or locks; wolf pays before the program runs. The honest caveat: Erlang’s copy buys process isolation across machines with the same semantics, which no move can. Distribution is where the copy stops looking expensive.

Exercise 16-7. §16.3

Exercise 16-7 (extension · lupin). A maze is a graph, and a graph is a region’s favorite payload. Carve a 5×5 maze with a seeded generator (per-cell wall bitmasks: 1=N 2=E 4=S 8=W, depth-first carve, a small linear-congruential step for direction choice), building the wall table inside a region. Send the region to a solver task; the solver breadth-first-searches it in place and prints the shortest-path distance from corner to corner. Seed 1: run it. Before you do, answer: how many times is the wall table copied between carver and solver?

Solution. ch16/ex16-7.lu (excerpt — the transfer; carve and solve are in the file):

fn main() -> !int {
    let ch = channel[region](1)
    let r = region()
    let walls = in r { carve(5, 5, 1) }
    scope s {
        s.spawn(fn() {
            let r2 = ch.recv() else |_| { return }
            let d = in r2 { solve(walls, 5, 5) }
            print("distance={d}")
        })
        ch.send(move r)?
    }
    0
}
$ lupin ex16-7.lu
distance=18

Zero copies. The carver allocated the table, the stack, and the visited set into one region; the solver’s BFS queue and distance table could live there too. One move later the solver owns all of it, and the answer (18 steps for seed 1) comes out of memory the solver never allocated.

Exercise 16-8. §16.3

Exercise 16-8 (comprehension + schedule play · lupin). Change the carve seed to 2 and run the program three times, including once under lupin run … --seed=7. Predict: which of the two seeds in play changes the printed distance, and which cannot, and why does this program print the same distance under every scheduler seed?

Solution. ch16/ex16-8.lu (one line differs from 16-7):

$ lupin ex16-8.lu
distance=10
$ lupin ex16-8.lu
distance=10
$ lupin run ex16-8.lu --seed=7
distance=10

The carve seed changes the maze: seed 2 happens to carve a more direct route, distance 10. The scheduler seed cannot change anything: the program has one send, one receive, and a join; every schedule orders them the same way, so the output is schedule-independent by construction. Determinism you can argue from the program’s shape beats determinism you observed in three runs; chapter 17 is about the programs where you cannot argue it.

Exercise 16-9. §16.3

Exercise 16-9 (comprehension · wolf + lupin). This program declares a channel of bare List[int] (not Copy, not imm, not a region, not sync):

fn main() -> !int {
    let ch = channel[List[int]](1)
    0
}

Predict the verdict this program earns and the rule behind it, and explain why each of the four admitted payload classes is safe where a bare List is not. Then say what the note’s fifth entry — a struct, enum or tuple whose every field is one of the four — adds that the four alone do not.

Solution: the verdict is fail(E1102), and the note names the four base classes and the composite rule that closes over them:

$ wolf conform-run ./ex16-9.lu
error[E1102]: `List[int]` cannot be sent through a channel
 --> ./ex16-9.lu:7:22
  |
7 |     let ch = channel[List[int]](1)
  |                      ^^^^^^^^^ not a sendable payload type
  |
  = note: channel payloads must be `Copy` data, `imm` data, a region value (the send is its affine
    move), a `sync` type, or a struct, enum or tuple whose every field is one of those
    ([conc.chan.type], [conc.chan.payload]) — sending anything else would give two tasks one
    mutable value. D14's verbs are the ways out: `move` the data into a region and send the
    region, `freeze` it into shareable `imm` data, or guard it with a `Mutex`.

Each admitted class removes one half of the race. Copy data: the receiver gets its own bits, so there is no shared location. imm data: there is a shared location and nobody may write it. A region value: the send is a move, so exactly one task owns it at any instant. A sync type: the sharing is real and the coordination is the type’s own job. A bare List is none of these: sending it would give two tasks live access to one mutable buffer with no coordination, which is chapter 13’s store-buffer program wearing a channel as a disguise.

The fifth entry adds no fifth reason, and that is its point. A struct, enum or tuple is sendable exactly when every field is — [conc.chan.payload], beside [conc.chan.type] in the note — so the rule is closed under composition rather than restated for each shape, and a record of two ints needs no ceremony to cross a channel. It is also the entry that makes the other four load-bearing: one non-sendable field anywhere in the tree refuses the whole value, which is why the four classes above are worth being able to name.

Note that the rejection is a property of the declaration: no send appears in the program, and none is needed. The type of the channel is already the claim, and both machines decline it there — the compiler with the report above, the interpreter with its one-line spelling of the same code, clause, and span:

$ lupin ex16-9.lu
ex16-9.lu: E1102: `List[int]` cannot be sent through a channel: a payload must be `Copy`, `imm`, a moved region, or a `sync` type — a bare region-interior `List` is none of those. Send the region instead [conc.chan.type] at 7:22
$ echo $?
2

The corpus carries the same expectation in conc/chan_unsendable.lu.

Exercise 16-10. §16.3

Exercise 16-10 (extension · lupin). Seven wolves in a ring; every third one still standing leaves, until one remains. Build the ring as a list of alive-flags in its own region, run the elimination in main with % doing the wraparound, then hand the finished region to a reader task with one send(move …) — 16-7’s shape — which finds the survivor in place and prints the seat. Compute the answer on paper first. How many times was the ring copied between builder and reader, and what would the copy count be in a language that made message passing mean copying?

Solution. ch16/ex16-10.lu:

fn eliminate(mut alive: List[int], every: int, seats: int) {
    var living = seats
    var cur = 0
    var counted = 0
    while living > 1 {
        if alive[cur] == 1 {
            counted += 1
            if counted == every {
                alive[cur] = 0
                living -= 1
                counted = 0
            }
        }
        cur = (cur + 1) % seats
    }
}
fn last_seat(alive: List[int], seats: int) -> int {
    var seat = 0
    for i in 0..seats {
        if alive[i] == 1 { seat = i + 1 }
    }
    seat
}
fn main() -> !int {
    let ch = channel[region](1)
    let r = region()
    var alive = in r { List[int]() }
    for _ in 0..7 { (mut alive).push(1) }
    eliminate(mut alive, 3, 7)
    scope s {
        s.spawn(fn() {
            let r2 = ch.recv() else |_| { return }
            let seat = in r2 { last_seat(alive, 7) }
            print("seat {seat} survives")
        })
        ch.send(move r)?
    }
    0
}
$ lupin ex16-10.lu
seat 4 survives

Seat 4 (the paper walk: seats 3, 6, 2, 7, 5, 1 leave, in that order). Zero copies: the flags were allocated in r, mutated in place by the builder, and the move handed the reader the same memory — last_seat reads flags it never allocated, 16-7’s zero-copy claim on a different graph. Copy-passing (Erlang’s choice, the honest lineup’s first row) would copy the ring once per message: one copy here, but one per handoff in general, which for a pipeline of n stages is n copies of state that only ever has one interested party at a time. The elimination runs before the send on purpose: the builder finishes its writes, then ownership moves — the same “mutate, then share” order freeze enforces for the many-reader case.

Chapter 17

Exercise 17-1. §17.1

Exercise 17-1 (comprehension · lupin). Two tasks each deposit 50 into a balance that starts at 0, through a get-then-set protocol with the store loop in main. There is no shared mutable capture, no Mutex, nothing chapter 13 would reject. Predict the balance under the default FIFO schedule, and state what the correct answer would be if deposits never interfered:

fn deposit(getreq: channel[int], getrep: channel[int], setch: channel[int]) {
    getreq.send(1) else { return }
    let v = getrep.recv() else |_| { return }
    setch.send(v + 50) else { return }
}
fn main() -> !int {
    let getreq = channel[int](0)
    let getrep = channel[int](0)
    let setch = channel[int](0)
    var balance = 0
    scope s {
        s.spawn(fn() { deposit(getreq, getrep, setch) })
        s.spawn(fn() { deposit(getreq, getrep, setch) })
        var served = 0
        while served < 4 {
            select {
                _ from getreq => { getrep.send(balance) },
                v from setch => { balance = v },
            }
            served += 1
        }
    }
    print("balance={balance}")
    0
}

Solution: the correct total is 100; FIFO prints 50. Both tasks get 0 before either set arrives, so both compute 0 + 50 and the second set overwrites the first: the lost update. Every individual message is delivered exactly once, in order, race-free; the composite operation get-then-set is what was never atomic. This is an ordering bug, not a data race, and no type system that admits channels can reject it. The book says so plainly, because the pitch for chapter 13 was narrower than folklore remembers.

$ lupin ex17-1.lu
balance=50
Exercise 17-2. §17.1

Exercise 17-2 (comprehension (schedule play) · lupin). Hunt it: run 17-1 under seeds 0 through 5. Record each balance. Which seeds produce the correct answer, and what had to happen in the schedule for 100 to come out?

Solution:

$ lupin run ex17-1.lu --seed=0
balance=50
$ lupin run ex17-1.lu --seed=1
balance=100
$ lupin run ex17-1.lu --seed=2
balance=50
$ lupin run ex17-1.lu --seed=3
balance=50
$ lupin run ex17-1.lu --seed=4
balance=100
$ lupin run ex17-1.lu --seed=5
balance=100

Seeds 1, 4, and 5 serialize the deposits: one task’s set reaches the store before the other task’s get, so the second deposit reads 50 and writes 100. The failing outcome is the common one here, not a rare one, which inverts the usual heisenbug story: in production this bug would look like a test that occasionally passes.

The explorer agrees, and says which schedules do it:

$ lupin conform-run ex17-1.lu --explore=500
ex17-1.lu: explored 20 schedule(s) in 20 execution(s) (DPOR; 0 slept, 6 pruned), frontier closed
  outcomes: 2 distinct — SCHEDULE-DEPENDENT
    exit(0) ×16 stdout=balance=50\n leaks=0 forest=ok — replay: --seed=0
      decision stream: ev:0,0,0,0,0,0,0,0,0,0,0,0
    exit(0) ×4 stdout=balance=100\n leaks=0 forest=ok — replay: --seed=4611686018427387910
      decision stream: ev:0,1,1,0,0,0,0,0,0,0,0
  deadlocks: 0 · races: 0 · max depth: 12 decision(s)
$ echo $?
1

(The frontier claim is what §17.2 rests its argument on, and it is checked: an earlier interpreter release reported this program observably deterministic while a seeded run printed the wrong balance, and that disagreement was filed against the interpreter rather than written around here.)

Exercise 17-3. §17.2

Exercise 17-3 (fingers · lupin). Two sends race into one channel; main prints the arrival order. Run it twice with --seed=0, once with --seed=3, and once with --schedule=ev:0,0,0. Before running: which pairs of those four runs are guaranteed to match?

    scope s {
        s.spawn(fn() { ch.send(1) })
        s.spawn(fn() { ch.send(2) })
    }
    let a = ch.recv() else |_| { return 1 }
    let b = ch.recv() else |_| { return 1 }
    print("{a}{b}")

Solution: the two --seed=0 runs must match: a seed selects the whole decision stream, and the same seed replays byte-identically. The ev:0,0,0 run matches them here because that stream is the one seed 0 selects. --seed=3 carries no guarantee relative to the others; it happens to choose the other order:

$ lupin run ex17-3.lu --seed=0
12
$ lupin run ex17-3.lu --seed=0
12
$ lupin run ex17-3.lu --seed=3
21
$ lupin run ex17-3.lu --schedule=ev:0,0,0
12

A schedule is a value. Two runs disagree only if their schedules disagree, and a schedule you can name is a schedule you can rerun. That is the entire mechanism this chapter’s debugging story stands on.

Exercise 17-4. §17.2

Exercise 17-4 (comprehension · lupin). The explorer prints the two schedules of 17-3 as decision streams ev:0,0,0 and ev:1,0,0. Three decisions, but only the first digit ever differs. What is the first decision choosing between, and why are the remaining two decisions no longer choices once it is made?

Solution: the first decision picks which spawned task runs at the first scheduling point: task one’s send or task two’s send fires first. After that, the program has no freedom left: the other send is the only runnable step, and the two receives in main drain the channel in arrival order. A decision stream records choices, not events; a program’s concurrency is measured by how many entries in that stream could have gone otherwise, and this program has exactly one.

Exercise 17-5. §17.2

Exercise 17-5 (spelunking · lupin). Run lupin conform-run ex17-3.lu --explore=64 and read the report back: explain explored 2 schedule(s), DPOR, frontier closed, SCHEDULE-DEPENDENT, the per-outcome replay: seeds, and the process exit code.

Solution. The run:

$ lupin conform-run ex17-3.lu --explore=64
ex17-3.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 2 distinct — SCHEDULE-DEPENDENT
    exit(0) ×1 stdout=12\n leaks=0 forest=ok — replay: --seed=0
      decision stream: ev:0,0,0
    exit(0) ×1 stdout=21\n leaks=0 forest=ok — replay: --seed=4611686018427387905
      decision stream: ev:1,0,0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)
$ echo $?
1

explored 2 schedule(s): the program has two inequivalent orderings, and both were run. DPOR is dynamic partial-order reduction, the algorithm that knew the other interleavings were equivalent to these two, so 2 executions covered the space a naive search would have enumerated. frontier closed: no reachable schedule was left untried within the budget. SCHEDULE-DEPENDENT: the outcomes differ across schedules, a finding, which is why the exit code is 1 even though every individual run exited 0. Each outcome carries a replay: seed: the finding arrives with its own reproduction command, which is the difference between a bug report and an anecdote.

Exercise 17-7. §17.3

Exercise 17-7 (comprehension · lupin). Rerun 17-3’s exploration with --explore-preemptions=0. Predict what the report will claim about determinism before you run it, then reconcile the claim with 17-5’s.

Solution:

$ lupin conform-run ex17-3.lu --explore=64 --explore-preemptions=0
ex17-3.lu: explored 1 schedule(s) in 1 execution(s) (DPOR; 0 slept, 0 pruned), frontier OPEN
  note: preemption bound 0 skipped alternatives; frontier open
  outcomes: 1 distinct — observably deterministic (every schedule agrees)
    exit(0) ×1 stdout=12\n leaks=0 forest=ok — replay: --seed=0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)
$ echo $?
0

With zero preemptions allowed, only the FIFO schedule is explored, and the report says “observably deterministic (every schedule agrees)”: true over the schedules it looked at, and wrong about the program, as 17-5 proved. The tool is honest about the gap: frontier OPEN and the note: line say the search was cut short, and the exit code is 0 only because no finding was reached. Read exploration reports the way you read benchmarks: the verdict is conditional on the budget line, and “frontier open” is the condition talking.

Exercise 17-8. §17.3

Exercise 17-8 (design). List three behaviors of a real concurrent service that seeded schedule exploration, as this chapter defines it, cannot find; for each, name the tool or practice that owns it instead. The chapter’s own scope-honesty section (§17.4) claims v1 promises less than folklore expects; your answer is that claim, made concrete.

Solution (discussion): first, value nondeterminism (a hash seed, a random backoff, an id from the OS): exploration permutes scheduling decisions, not data, so property tests and fuzzing own that axis. Second, real time (a timeout that fires only when a peer takes 30 actual seconds, kernel-buffer pressure, the network): the deterministic scheduler virtualizes time, so what it validates is your handling of a timeout, never the calibration of one; load tests own the calibration. Third, anything past the FFI membrane: a C library’s internal threads and its file-descriptor games are invisible to a scheduler that only sees wolf’s blocking points; the audit boundary of chapter 9 and the C library’s own test suite own that. Exploration proves ordering properties over the events it can see and permute. That sentence, with both clauses stressed, is §17.4’s whole content.

Exercise 17-9. §17.3

Exercise 17-9 (extension (break-it-on-purpose) · lupin). Construct a deadlock from two tasks and two rendezvous channels, each task receiving first and sending second. Predict the trap’s roster before running: how many tasks does it name, and why is the answer three when you wrote two?

Solution. ch17/ex17-9.lu:

fn main() -> !int {
    let a = channel[int](0)
    let b = channel[int](0)
    scope s {
        s.spawn(fn() {
            let x = a.recv() else |_| { return }
            b.send(x) else { return }
        })
        s.spawn(fn() {
            let y = b.recv() else |_| { return }
            a.send(y) else { return }
        })
    }
    0
}
$ lupin ex17-9.lu
ex17-9.lu: trap(deadlock): every live task is blocked at a runtime-owned blocking point and no timer is pending; blocked-task roster: `main` (task 0), `task@689` (task 1), `task@810` (task 2) [conc.deadlock.trap] at 14:5
$ echo $?
3

Three, because main is blocked too, at the scope join, waiting for children who are waiting for each other. The roster is the trap’s gift: it names every task and where it blocked, which is the state a production deadlock never hands you. The trap fires because every live task is blocked with no timer pending: a quiet program and a deadlocked one differ in exactly that clause, and the runtime can tell them apart. (Compare 12.4: when (a, b) exists so lock-order deadlocks cannot be written; this exercise built the channel-order equivalent by hand, on purpose.)

Chapter 18

Exercise 18-1. §18.1

Exercise 18-1 (comprehension · wolf). One binding keeps this program out of compile time:

comptime fn double(n: int) -> int {
    n + n
}
fn main() -> !int {
    let x = 21
    const Y = double(x)
    if Y == 42 { 0 } else { 1 }
}

Predict the compiler’s verdict, and name the one-character change that fixes the program. What may an argument to a comptime fn be?

Solution: let x is a runtime value, and a comptime fn cannot receive one. The fix is const x = 21. Arguments must be literals, consts, types, or results of other comptime calls; the diagnostic recites the list:

$ wolf conform-run ./ex18-1.lu
error[E0705]: `x` is a runtime value, so this cannot evaluate at compile time
 --> ./ex18-1.lu:9:22
  |
9 |     const Y = double(x)
  |               --------- while evaluating `main`, entered here
  |                      ^ must be comptime-known
  |
  = note: a `comptime fn` runs during compilation: every argument must be a literal, a `const`, a
    type, or the result of another comptime call.
Exercise 18-2. §18.1

Exercise 18-2 (comprehension · wolf). Chapter 3 taught you what 2147483647 + 1 does at runtime. Predict what it does inside a comptime fn, and predict the decision the diagnostic cites:

comptime fn brim() -> i32 {
    let big: i32 = 2147483647
    big + 1
}

Solution: the same rule, moved earlier: what would trap at runtime is a compile error at comptime, and the diagnostic cites X3, the one checked-arithmetic semantics for every profile and now every phase:

$ wolf conform-run ./ex18-2.lu
error[E0706]: this `+` on `i32` faults at compile time: 2147483647 + 1 leaves `i32`'s range
 --> ./ex18-2.lu:6:5
  |
6 |     big + 1
  |     ^^^^^^^ checked arithmetic, comptime included
...
9 |     const B = brim()
  |               ------ while evaluating `brim`, entered here
  |               ------ while evaluating `main`, entered here
  |
  = note: checked arithmetic has one semantics everywhere: what would trap at runtime is an error
    at comptime — intended wraparound is spelled `wrapping[T]`, never a mode.
Exercise 18-4. §18.2

Exercise 18-4 (comprehension · wolf). size_of(Vec2) for a struct of two f64 fields is 16 on every target wolf supports. Predict the verdict of const S = size_of(Vec2) anyway, and then explain why a number that obvious is refused at comptime.

Solution: E0708. Vec2 has the native layout, which belongs to the code generator, and the checker refuses to promise a number another phase owns. The obviousness is the trap: field reordering, padding, and target ABIs make a native layout a codegen fact, and a comptime that guessed would have to be right forever:

$ wolf conform-run ./ex18-4.lu
error[E0708]: the layout of `Vec2` is not resolved until codegen lays it out
 --> ./ex18-4.lu:9:15
  |
9 |     const S = size_of(Vec2)
  |               ^^^^^^^^^^^^^ unresolved until codegen
  |               ------------- while evaluating `main`, entered here
  |
  = note: `size_of`, `align_of` and `offset_of` answer at comptime for scalars and `#[repr(c)]`
    structs, whose layout the clause fixes ([abi.layout.query]); any other type has the
    native layout, which the code generator chooses ([abi.native.layout]) — mark the struct
    `#[repr(c)]` to make its layout a fact.

The note names the way out. Put #[repr(c)] above struct Vec2 and the layout stops being the backend’s choice: it is the C layout the specification fixes, the same const answers 16, and the program exits 0 on the compiler. The number was never the problem; who owned it was.

Exercise 18-6. §18.4

Exercise 18-6 (spelunking · wolf). Run wolf --explain E0701 and read the entry in full. It names two distinct reasons a comptime capability can be refused. Name both, and sort these refusals under them: a clock read, a network fetch, an environment variable.

Solution: the two reasons are confinement (compiling a package must never act on or read the machine that compiles it) and determinism (the same program and target must produce bit-identical comptime results on every host). The clock is determinism: two identical builds must not observe different times. The network fetch is confinement: the entry’s own example is that wolf add must never mean arbitrary code runs with your credentials. The environment variable is both, and the catalog files it under confinement: it reads the compiling machine, and it also varies host to host. From the real entry:

$ wolf --explain E0701
E0701: comptime code reached for ambient IO

Comptime evaluation is hermetically sandboxed (D33): no filesystem, no
network, no environment variables, no clock, no randomness, no FFI —
the intrinsics available at compile time are an explicit allowlist,
and nothing ambient is on it. Each refusal names its category and its
reason: confinement (compiling a package must never act on or read
the machine that compiles it — `wolf add` must never mean arbitrary
code runs with your credentials) or determinism (the same program and
target must produce bit-identical comptime results on every host).
Compute the value at runtime instead; file contents belong in
*declared build inputs* through the package manifest, never in an
evaluator capability.
Exercise 18-7. §18.4

Exercise 18-7 (comprehension · wolf). Five expression tiles. Sort each onto the comptime side of the boundary or the runtime side before running anything: 6 * 7; a function from a type to a type; a file read; a clock read; a network fetch. Then check the three you sorted as refused, with three one-line programs. Do the three diagnostics give the same reason?

Solution: arithmetic and type-to-type functions are admitted: pure computation over values the compiler already holds. The file read, the clock read, and the network fetch are refused, all as E0701, but not for one reason; each refusal names its own:

$ wolf conform-run ./ex18-7a.lu
error[E0701]: `read_text` reaches the filesystem, which comptime code can never touch
 --> ./ex18-7a.lu:5:5
  |
5 |     read_text(path)
  |     ^^^^^^^^^^^^^^^ ambient IO at compile time
...
8 |     const BANNER = embed("banner.txt")
  |                    ------------------- while evaluating `embed`, entered here
  |                    ------------------- while evaluating `main`, entered here
  |
  = note: why it is refused — confinement: a build must not read the machine it runs on — and the
    same source would compile differently on different machines.
  = note: the comptime sandbox is hermetic (D33): the intrinsics available at compile time are an
    explicit allowlist, and nothing ambient is on it. Compute this value at runtime instead;
    file contents belong in declared build inputs through the package manifest, never in an
    evaluator capability.

$ wolf conform-run ./ex18-7b.lu
error[E0701]: `clock_ms` reaches the clock, which comptime code can never touch
 --> ./ex18-7b.lu:5:5
  |
5 |     clock_ms()
  |     ^^^^^^^^^^ ambient IO at compile time
...
8 |     const STAMP = build_stamp()
  |                   ------------- while evaluating `build_stamp`, entered here
  |                   ------------- while evaluating `main`, entered here
  |
  = note: why it is refused — determinism: two identical builds must not observe different times.
  = note: the comptime sandbox is hermetic (D33): the intrinsics available at compile time are an
    explicit allowlist, and nothing ambient is on it. Compute this value at runtime instead;
    file contents belong in declared build inputs through the package manifest, never in an
    evaluator capability.

$ wolf conform-run ./ex18-7c.lu
error[E0701]: `net_fetch` reaches the network, which comptime code can never touch
 --> ./ex18-7c.lu:5:5
  |
5 |     net_fetch(url)
  |     ^^^^^^^^^^^^^^ ambient IO at compile time
...
8 |     const SCHEMA = fetch_schema("https://example.test/schema.json")
  |                    ------------------------------------------------ while evaluating `fetch_schema`, entered here
  |                    ------------------------------------------------ while evaluating `main`, entered here
  |
  = note: why it is refused — confinement: `wolf add` must never mean arbitrary code talks to the
    network with your credentials.
  = note: the comptime sandbox is hermetic (D33): the intrinsics available at compile time are an
    explicit allowlist, and nothing ambient is on it. Compute this value at runtime instead;
    file contents belong in declared build inputs through the package manifest, never in an
    evaluator capability.

Three runs, and almost all of it is the same three times: the same ambient IO at compile time label, the same two-frame evaluation trace back to the const, and the same hermetic-sandbox note (D33) closing each report. The one line that differs is the one the question asks about — = note: why it is refused — and it gives a different reason each time: confinement, determinism, confinement again but of a second kind.

Exercise 18-8. §18.4

Exercise 18-8 (comprehension · wolf). A reader decides budgets are noise and writes #[budget(fuel = 0)] to turn the meter off. Predict what the compiler does with a trivial call under that attribute: a comptime fn that returns 10 and computes nothing.

Solution: the rejection is about the attribute, not the workload. Budgets are raised, never removed; there is no spelling that disables one, and the trivial body never gets a chance to demonstrate its innocence:

$ wolf conform-run ./ex18-8.lu
error[E0709]: a comptime budget cannot be turned off — `fuel = 0` would disable the limit
 --> ./ex18-8.lu:8:14
  |
8 |     #[budget(fuel = 0)]
  |              ^^^^^^^^ budgets are raised, never removed
  |
  = note: the sandbox guarantee (D33) includes bounded evaluation: every budget has a default, a
    per-site override, and a hard ceiling — there is no spelling that removes one.
Exercise 18-9. §18.4

Exercise 18-9 (comprehension · wolf). Two runaway programs, two different budgets. Before running, match each to the resource it exhausts and the E-code it earns:

// program A
comptime fn dive(n: int) -> int {
    dive(n + 1)
}
// program B
comptime fn spin() -> int {
    while true {}
    0
}

Solution: A recurses, so it hits the depth budget (E0704, 256 call frames); B loops in one frame, so it burns fuel (E0702, a step count). Both diagnostics end with the same shape of help (raise the budget at the use site) because the compiler cannot tell a runaway from a computation that is merely large; only you can:

$ wolf conform-run ./ex18-9a.lu
error[E0704]: comptime evaluation recursed past 256 call frames
 --> ./ex18-9a.lu:5:5
  |
5 |     dive(n + 1)
  |     ^^^^^^^^^^^ the call that went over the limit
  |     ----------- while evaluating `dive` — 254 recursive frames
...
8 |     const D = dive(0)
  |               ------- while evaluating `dive`, entered here
  |               ------- while evaluating `main`, entered here
  |
  = note: call depth is a resource limit, not a host stack: deep recursion is refused with this
    report instead of crashing the compiler (D33).
help: raise the budget here: `#[budget(depth = 512)]`
  |
8 |     #[budget(depth = 512)]
  |

$ wolf conform-run ./ex18-9b.lu
error[E0702]: comptime evaluation ran out of fuel after 1000000 steps
 --> ./ex18-9b.lu:9:15
  |
9 |     const N = spin()
  |               ^^^^^^ evaluation stopped here
  |               ------ while evaluating `spin`, entered here
  |               ------ while evaluating `main`, entered here
  |
  = note: fuel bounds how long the compiler will evaluate before concluding the computation is
    runaway — a build can be slow, never hung (D33).
help: raise the budget here: `#[budget(fuel = 2000000)]`
  |
9 |     #[budget(fuel = 2000000)]
  |
Exercise 18-10. §18.4

Exercise 18-10 (extension (break-it-on-purpose) · wolf). Earn E0703 (the heap budget) using only a while loop and a var, without tripping fuel first. (You will need to grant fuel to get there.)

Solution. ch18/ex18-10.lu: grant a large fuel budget so the loop lives long enough to exhaust the 65536-cell comptime heap instead:

comptime fn flood() -> int {
    var n = 0
    while n < 100000000 {
        n = n + 1
    }
    n
}
fn main() -> !int {
    #[budget(fuel = 100000000)]
    const N = flood()
    if N == 0 { 1 } else { 0 }
}
$ wolf conform-run ./ex18-10.lu
error[E0703]: comptime evaluation exceeded its heap budget of 65536 cells
  --> ./ex18-10.lu:13:15
   |
13 |     const N = flood()
   |               ^^^^^^^ the allocation that went over happened here
   |               ------- while evaluating `flood`, entered here
   |               ------- while evaluating `main`, entered here
   |
   = note: the comptime heap is capped so evaluation cannot exhaust the machine compiling the
     program (D33); most overruns are unbounded value growth in a loop.
help: raise the budget here: `#[budget(heap = 131072)]`
   |
12 |     #[budget(heap = 131072)]
   |

The order of the two limits is the lesson: budgets are independent meters, and the first one exhausted names the failure.

Exercise 18-12. §18.4

Exercise 18-12 (design). The sandbox refuses a file read (E0701) but the catalog entry points at declared build inputs through the package manifest instead. Draw the line between the two designs: what exactly does declaring an input buy that an ambient read does not have? Name the failure the ambient read permits in each of: caching, cross-machine reproducibility, and auditing a dependency you did not write.

Solution (discussion): a declared input is part of the build’s identity: it is hashed, so the cache can key on it; it is listed, so another machine can be handed the same bytes; it is visible, so an auditor reads the manifest instead of the evaluator’s traffic. The ambient read defeats each in turn: a cache cannot know the file mattered, so it serves stale artifacts; a second machine has a different file or none, so the “same” build diverges; and an auditor must now treat every comptime expression as a potential filesystem probe, which is the exact posture chapter 24 spends a chapter dismantling. The refusal is not a missing feature; it is the load-bearing wall of the caching, reproducibility, and audit stories, and the package manifest is where the need is threaded instead of through the evaluator. What is read can be data; that it was read must be declaration.

Exercise 18-13. §18.3

Exercise 18-13 (spelunking · wolf). Misspell a format spec on purpose — {total:>9.2z} — and read the grammar out of the error. From the diagnostic alone: which one letter fixes this spec for a money column, and which two letters would print the same number in bases the chapter has not used yet?

Solution. ch18/ex18-13.lu (broken on purpose):

$ wolf conform-run ./ex18-13.lu
error[E0412]: `z` has no place in a format spec — the grammar is `[[fill]align][+][0][width][.precision][type]` with type one of `b o x X e E f`
 --> ./ex18-13.lu:7:18
  |
7 |     print("{total:>9.2z}")
  |                  ^^^^^^ in this format spec
  |

The fix is f: fixed-point, which with .2 is the money column ({total:>9.2f}). The two unused letters are b and o — binary and octal — sitting in the grammar beside the x/X hex the string chapter did print. The diagnostic is the section’s claim performed: the f-string compiled to checked calls, so a bad spec is a compile error with the whole grammar in it, not a runtime surprise — and the grammar line answers questions the chapter never got to.

Exercise 18-14. §18.3

Exercise 18-14 (comprehension · wolf). Four Buf[…] parameter and return pairs: Buf[N + 1] with Buf[1 + N]; Buf[N + 2 - 1] with Buf[1 + N]; Buf[2 + 2] with Buf[4]; Buf[N * 2] with Buf[2 * N]. Predict which pairs the checker equates on its own and which one needs a witness, then check the odd one out and read which of the three steps the note says it fell past.

Solution. ch18/ex18-14.lu (the * pair, broken on purpose; the first three are the chapter’s own shuffle/widen/closed, all accepted):

$ wolf conform-run ./ex18-14.lu
error[E0707]: `Buf[N * 2]` and `Buf[2 * N]` may be equal, but proving it needs a witness
 --> ./ex18-14.lu:6:51
  |
6 | fn double[N: type](b: Buf[N * 2]) -> Buf[2 * N] { b }
  |                                   ------------- the return type is declared here
  |                                                   ^ these const expressions differ beyond linear arithmetic
  |
  = note: const-expression equality is decided in three steps, and the line is fixed: (1) closed
    expressions evaluate and compare by value; (2) `+`/`-` arithmetic over generic
    parameters compares by ring normalization, so `N + 1` equals `1 + N`; (3) anything
    beyond — `*`, `/`, `%`, shifts, bit operators — needs an explicit witness. This pair
    sits at step 3.
  = note: state the equality where the reader can see it: a comptime `assert` on the sizes
    involved, or rewrite both spellings into the same `+`/`-` form.

Closed values (step 1) equate 2 + 2 with 4; ring normalization (step 2) equates both +-shaped pairs. The * pair is mathematics any reader can do and the checker will not, because the line between “normalized” and “proved” is fixed where the chapter said it is: multiplication is step 3, witness territory, however trivial the instance. A checker that did easy multiplications would have a boundary nobody could state.

Exercise 18-15. §18.4

Exercise 18-15 (extension · wolf). Roman numerals, both ways, folded: write to_roman(n) and from_roman(s) as comptime fns (the value table as a pair of indexed helpers, subtractive pairs and all), and a witness round_trips(n) whose assert proves from_roman(to_roman(n)) == n at compile time. Fold a year each way into consts and print them. What does the witness buy that printing both values does not, and which famous wrong numeral — IIII — does your from_roman quietly accept, and why is that fine here?

Solution. ch18/ex18-15.lu (excerpt — the table helpers are if-chains over an index, glyph(at)/worth(at)):

comptime fn to_roman(n: int) -> str {
    var out = ""
    var rest = n
    var at = 0
    while at < 13 {
        while rest >= worth(at) {
            out = "{out}{glyph(at)}"
            rest -= worth(at)
        }
        at += 1
    }
    out
}
...
comptime fn from_roman(s: str) -> int {
    var total = 0
    var i = 0
    while i < s.len {
        let here = one(s, i)
        let next = if i + 1 < s.len { one(s, i + 1) } else { 0 }
        total += if here < next { 0 - here } else { here }
        i += 1
    }
    total
}
comptime fn round_trips(n: int) -> bool {
    assert(from_roman(to_roman(n)) == n)
    true
}
fn main() -> !int {
    const YEAR = to_roman(1994)
    const BACK = from_roman("MMXXVI")
    const WITNESS = round_trips(3888)
    print("{YEAR} {BACK}")
    if WITNESS { 0 } else { 1 }
}
$ wolf run ex18-15.lu
MCMXCIV 2026

The witness buys a build-breaking claim: printed values need a reader to check them, but a failed comptime assert is E0710 and no binary exists — 3888 (MMMDCCCLXXXVIII, the longest numeral under 4000) round-trips or the program is refused. IIII is accepted because from_roman implements the subtractive reading rule (a smaller value before a larger one subtracts), which maps every well-formed numeral correctly and some malformed ones charitably; rejecting non-canonical spellings is a validator’s job, and the witness only claims the round trip from to_roman’s canonical output. Stating exactly what the assert proves — no more — is most of what writing one teaches.

Chapter 19

Exercise 19-1. §19.1

Exercise 19-1 (fingers · wolf + lupin). Type the scanner and change the line to "the pack hunts at dusk", with the doubled and tripled spaces. Predict the count on paper from the two-state rule, then build it with --release, run it, and run it under lupin.

Solution. ch19/ex19-1.lu. Five: a word starts at each space-to-nonspace edge, and stretching a gap adds no edges. The scanner counts transitions, not spaces, which is why the doubled and tripled runs change nothing:

$ wolf build --release ex19-1.lu
$ ./ex19-1
5 words
$ lupin ex19-1.lu
5 words

Both tiers, one answer, which is §19.1’s first claim exercised: the flag may change everything about how the binary is made and nothing about what it means.

Exercise 19-2. §19.2

Exercise 19-2 (comprehension · prose). A release of the compiler makes one benchmark kernel 11 percent faster, and the same release moves that kernel’s handed-to-LLVM instruction count from 266 to 309. Explain how both numbers can be progress, and name the transformation shape from this section that produces exactly this signature.

Solution: loop versioning. The middle end emitted a guarded fast copy of the loop (its bounds checks proven and folded) beside the original slow copy that keeps every check, plus the guard chain that picks between them at the loop’s door. Two loop bodies and a guard are more instructions than one loop body; the hot path through them is shorter. The ratio measures work delivered to the backend, not time, and this kernel spent 43 instructions to buy 11 percent; the numbers are the stencil kernel’s own, from the loss ledger’s entry for the versioning landing. The general lesson: any gate on a proxy metric needs a story for the cases where the proxy and the goal move apart, which is why the ratio is a ratchet with a paper trail rather than an objective function.

Exercise 19-3. §19.3

Exercise 19-3 (comprehension · prose). Five timings of one benchmark, in nanoseconds: 1000, 1010, 990, 1005, 2400. Compute the mean and the median; state which one a careful report trusts and what the outlier most plausibly was. Then compute the median absolute deviation and say what it does with these five numbers that a standard deviation would not.

Solution: mean 1281, median 1005. The report trusts the median, and the 2400 was most plausibly the machine, not the program: a scheduler preemption, a cache gone cold, a thermal step. The median absolute deviation takes each sample’s distance from the median (5, 5, 15, 0, 1395) and takes the median of those: 5. One wild sample barely moves it, where a standard deviation would be dominated by that sample’s square. Robust statistics are the choice to measure the program instead of the machine’s weather, which is also why the ledger’s nightly numbers are medians and why §19.3 says a delta means nothing except against its own spread.

Exercise 19-4. §19.3

Exercise 19-4 (comprehension · prose). Classify each loss into one of this section’s four kinds, and name the fix path: (a) a kernel adds numbers that arrive over a socket, and the additions stay checked; (b) the compiler proves a parameter’s buffer disjoint from every other, and the loop still reloads it after each store; (c) a kernel would tie C if the language allowed reading one byte past the end of a buffer when the read provably lands in the same page.

Solution: (a) is “the fact does not exist,” and its fix path is honesty, not code: no sound analysis can bound values the program did not compute, the checks are the promise, and if the price crosses the gate’s line the entry goes to the exceptions file with the cost measured (chapter 21 prints exactly this kernel’s entry). (b) is “the fact went unspent”: the proof exists and the instruction stream shows it not arriving, so the fix is in the pipeline that carries facts to the backend, with this kernel as the regression test. (c) is “the win was renounced”: the read past the end is undefined behavior wolf refuses regardless of page arithmetic, so the gap is a price, written down, never a bug filed on the checker for being right.

Exercise 19-5. §19.3

Exercise 19-5 (design). A colleague’s slide says “our runtime is 8 percent faster than C.” Using this chapter’s last paragraph, list the three pieces of provenance the claim needs before it is a measurement, and state what the sentence quietly becomes as each one is removed.

Solution (discussion): it needs the suite (what programs, against which C, compiled how), the date with its noise discipline (medians over how many runs, on what machine, against what spread), and the commit (what exactly was measured). Remove the commit and it is a claim about nothing in particular: nobody can re-measure it, so it cannot be wrong, which is worse than being wrong. Remove the date and it is folklore: true once, quoted forever, immune to the compiler release that invalidated it. Remove the suite and it is advertising: “faster than C” with the workload chosen after the fact. The chapter’s standard (a dated, gated, commit-pinned ledger line) is what remains when a sentence like the slide’s is required to survive cross-examination.

Chapter 20

Exercise 20-1. §20.1

Exercise 20-1 (fingers · lupin). Change the line to "a" and predict the printed value by hand before running: the seed is 7, the multiplier 31, and "a" is one byte, 97. Then explain why this function’s signature would be a lie under plain i32.

Solution. ch20/ex20-1.lu. By hand: 7 × 31 = 217, plus 97 is 314, no wrap on one byte:

$ lupin ex20-1.lu
314

Under plain i32 the signature would promise checked arithmetic and the body would need it unchecked: a longer input overflows within a handful of bytes, by design, and the checked spelling traps exactly there. wrapping[i32] is the renegotiation written where the compiler can hold it and a caller can read it. One byte happens to stay under the ceiling either way; the signature is about every input, not the lucky ones.

Exercise 20-2. §20.2

Exercise 20-2 (comprehension · prose). The ledger’s history for 2026-08-22 contains three HOLDS lines: 03:53 (counted, consecutive 1), 04:11 (eighteen minutes later), and 06:30 (139 minutes after the counted hold). The next holds land 2026-08-23 at 03:36 and 2026-08-24 at 03:39. State the consecutive count after each of the five lines, and name the rule that decides each step.

Solution: 1, 1, 1, 2, 3. The 03:53 hold is counted; 04:11 and 06:30 land inside the twelve-hour window that separates counted holds, so each records with the count unchanged (two samples of one thermal state are one sample, however many timestamps they wear). The 2026-08-23 hold is 23.7 hours after the counted one and advances the count to 2; 2026-08-24 advances it to 3, the declaration threshold, at which the tool prints a banner and a human decides. The rule doing all the work is the tick rule: a second reading counts only when a night has actually passed, and the ledger, not the enthusiasm, judges what a night is.

Exercise 20-3. §20.3

Exercise 20-3 (comprehension · prose). The emitted-IR ratio’s original design target is half, and the recorded gate is a ceiling at the measured value instead of at the target. State what a hard gate at the unmet target would do to every pull request, what a team learns to do with a gate that is always red, and what the ratchet preserves that a red wall of shame would not.

Solution: a hard gate at 0.5 against a measured 0.58 fails every commit, including the ones that improve the number, and tells none of them anything new. A team with a permanently red gate learns the only lesson a permanently red gate teaches: to stop reading it, and then to route around it, at which point the gate guards nothing. The ratchet preserves the two things a gate is for: regressions still fail (the ceiling is the measured value, so any backsliding is a red that means something), and progress is banked (each earned improvement tightens the ceiling behind it). The unmet target stays recorded beside the ratchet, which keeps the ambition without spending the signal.

Exercise 20-4. §20.4

Exercise 20-4 (design). A teammate proposes an exception entry: your image-decoding kernel would match C if it could read eight bytes past the end of the input buffer, since the read provably stays in the same page. Draft the entry’s five fields, then rule on it: does this qualify for the exceptions file, and if it does, what revisit condition keeps it honest? Then rule on a second candidate: a kernel that loses because the standard library lacks a bulk-copy method.

Solution (discussion): the first candidate qualifies, and the entry writes itself. Kernel: the decoder. Root cause: the vectorized tail wants an over-read; every in-bounds spelling costs a scalar epilogue. Decision that renounced the win: out-of-bounds reads are undefined behavior wolf refuses, page arithmetic notwithstanding (the language has no “probably mapped” memory class). Measured cost: the A/B against the over-reading C, with the date and host. Revisit condition: a masked-load lowering or a padded-buffer idiom that gets the tail without the over-read, at which point the entry expires. The second candidate does not qualify: a missing library method is a bug with an owner, not a renounced win, and parking it in the exceptions file would spend a capped slot to make a to-do item stop looking like one. It goes to the loss ledger, classified, with the kernel as the regression test for the method’s arrival.

Chapter 21

Exercise 21-1. §21.1

Exercise 21-1 (comprehension · prose). Here is saxpy in C:

void saxpy(double a, const double *xs, double *ys, size_t n) {
    for (size_t i = 0; i < n; i++) ys[i] = a * xs[i] + ys[i];
}

Without restrict, name the specific possibility the C compiler must plan for, and the optimization it therefore hesitates on. Then state what a wolf compiler knows about saxpy(a, xs, mut ys) from the signature alone, and who did the work of establishing it.

Solution: the C compiler must assume xs and ys may overlap: a store through ys[i] could change some later xs[j], so reordering and vectorizing the loads requires either a runtime overlap check or giving up the transform. restrict is the programmer promising disjointness, unchecked: get it wrong and the program is undefined. In wolf, mut ys is an exclusive claim and xs a shared read (chapter 7’s rule), so disjointness is a fact the type system already proved at every call site. Same fact, different laborer: C trusts the programmer’s word; wolf makes the caller demonstrate it, once, at compile time.

Exercise 21-2. §21.1

Exercise 21-2 (fingers · lupin). Type the wolf saxpy and run it: five elements, a = 2.0, ys all tens. Predict both printed values first.

Solution. ch21/ex21-2.lu (excerpt):

fn saxpy(a: f64, xs: List[f64], mut ys: List[f64]) {
    var i = 0
    while i < xs.len {
        ys[i] = a * xs[i] + ys[i]
        i += 1
    }
}
$ lupin ex21-2.lu
12 20

2·1 + 10 and 2·5 + 10, printed the way a whole-valued f64 prints: shortest round-trip, so 12 rather than 12.0. The kernel is deliberately the same one as 21-1: what runs here is the semantics, on both machines and on the compiler’s release tier alike. The suite §21.4 cites gates this same shape against naive clang -O3; the numbers on the page stay CI’s.

Exercise 21-3. §21.2

Exercise 21-3 (comprehension · prose). A request handler builds a parse tree of 10,000 nodes, reads it, and discards it. Count the allocator interactions (calls into allocate and free machinery) for (a) malloc discipline with individual free, (b) malloc discipline with one arena library, (c) a wolf region. Then name the cost in (c) that did not disappear and where it went.

Solution: (a) 20,000: every node allocated and freed retail. (b) on the order of a few dozen: the arena grabs slabs and frees them wholesale; nodes are pointer bumps, which is the arena’s entire trick. (c) matches (b) at runtime (bump allocation, one wholesale free at region end) with the checking moved to compile time: the guarantee that no node pointer outlives the region is the region checker’s proof, not a code review’s hope. What did not disappear: the proof obligation. C’s arena has the same lifetime rule and enforces it with discipline; wolf’s region has it as a type fact. The allocator math is identical (chapter 8 said so), and the difference is who catches the escapee.

Exercise 21-5. §21.4

Exercise 21-5 (comprehension · lupin). The bill and the payout in one program: sum_to(n) adds 1,000,000 to an i32 accumulator n times. Predict both calls’ fates (sum_to(2000), then sum_to(3000)) with the arithmetic that decides them.

Solution: 2000 × 1,000,000 = 2.0 × 10⁹ fits under i32’s 2,147,483,647 ceiling; 3000 × 1,000,000 crosses it at iteration 2148:

$ lupin ex21-5.lu
2000000000
ex21-5.lu: trap(overflow): `+` produced 2148000000, outside `i32` — checked arithmetic traps in every profile (X3); spell intended overflow `wrapping[i32]` [arith.checked] at 9:9
$ echo $?
3

Every one of those two million additions carried the check that made the last one honest. What the check costs after optimization is a measured number with a date on it. §21.4 prints that number from CI’s own ledger, not from an assertion here; the checked-adds exception in the suite’s gate is the same cost made explicit.

Exercise 21-6. §21.4

Exercise 21-6 (spelunking · lupin). From exercise 21-5’s trap line alone: name the decision id it cites, the clause tag it enforces, and the documented spelling for the program that wanted wraparound. Then state, in one sentence, why this trap firing “in every profile” is the chapter’s honesty rather than the chapter’s embarrassment.

Solution: X3 is the decision; [arith.checked] the clause; wrapping[i32] the intended-overflow spelling. All three are printed in the line itself. The one sentence: a language claiming to beat C while quietly disabling its own safety checks in release builds would be rigging the race, and X3 is wolf agreeing to be benchmarked with the checks on.

Exercise 21-9. §21.4

Exercise 21-9 (comprehension · prose). “Beats naive C, and the claim is a falsifiable CI gate” is a sentence with a specific engineering content. Name the three artifacts that must exist for the claim to be falsifiable rather than promotional, and for each say whether this edition already prints it.

Solution: three artifacts. A pinned, public benchmark suite: the kernels, their C twins, and the gate that reads them (this edition prints its verdict line in §21.4, with the repository path and the date). A variance discipline that can call a delta noise: medians, mean absolute deviation, a symmetric gate (chapter 19 teaches the discipline; the instrument that would put it in your hands is not part of this edition’s toolchain). A dated, regenerated record wired to CI so the claim expires when the world changes: the colophon’s toolchain pin and the ledger line §21.4 quotes, which names its commit and its night. Remove any one and the sentence degrades to advertising: no suite and it is unmeasured, no variance gate and it is cherry-picked, no date and it is folklore.

Chapter 22

Exercise 22-1. §22.1

Exercise 22-1 (fingers · lupin). Build the two-module project: an entry file and a stats/ directory exporting mean, with a private total helper the entry never sees. Run it. Then move total into a second file inside stats/ and state what changes for the entry file.

Solution. ch22/metrics/:

// metrics/main.lu
use stats

fn main() -> !int {
    var widths = List[int]()
    (mut widths).push(4)
    (mut widths).push(6)
    (mut widths).push(8)
    print("mean {stats.mean(widths)} of {stats.count(widths)}")
    0
}
$ lupin metrics/main.lu
mean 6 of 3

Moving total to another file inside stats/ changes nothing anywhere, because files are invisible to importers: the module is the directory, use stats names it whole, and the split is a private reorganization. That non-event is the design.

Exercise 22-2. §22.1

Exercise 22-2 (comprehension · lupin). vault/keys.lu defines pub fn count(), pub fn loaded(), and private fn secrets() and fn total(). The entry calls vault.total(). Predict the diagnostic (including whether it says the name does not exist) and the exit code.

Solution: E0304, exit 2, and the diagnostic is precise about existence: the name is there and visibility is the objection. A resolver that pretended otherwise would send you hunting a typo that is not one:

$ lupin leak/main.lu
leak/main.lu: E0304: `total` exists in `vault`, but it is private; only `pub`/`pub(pkg)` items are visible across modules (D32) [mod.vis.private] at 7:19
$ echo $?
2
Exercise 22-3. §22.1

Exercise 22-3 (comprehension · lupin). twice/main.lu and its sibling twice/extra.lu each define fn describe(). Neither file imports the other. Predict the verdict, and say why “neither imports the other” is a trap in the question.

Solution: E0302, “file boundaries create no scopes.” The trap is thinking imports are involved at all: sibling files are not two scopes that could shadow, they are one module with one namespace, and the second definition is a duplicate wherever it sits:

$ lupin twice/main.lu
twice/main.lu: E0302: the name `describe` is defined twice in this module (defined again in `twice/main.lu`); file boundaries create no scopes (D32) — two separate programs sharing a directory each mark themselves `//! member: false` (D59) [mod.dup] at 4:4
Exercise 22-4. §22.1

Exercise 22-4 (comprehension · lupin). The entry imports tools and never mentions it again. Predict: warning or error, and what the diagnostic offers about the fix.

Solution: a hard error, E0305, and the diagnostic notes the fix is machine-applicable: deleting the line. Wolf takes the Go position with Go’s justification: an unused import is a dependency edge that slows every build and means nothing, and a warning would be a request:

$ lupin unused/main.lu
unused/main.lu: E0305: the import `tools` is never used in `unused/main.lu`; an unused import is a hard error (D32), and deleting the line is machine-applicable [mod.use.unused] at 4:5
Exercise 22-5. §22.2

Exercise 22-5 (comprehension + extension · lupin). In ch22/tangle/, store imports index to log entries and index imports store to validate them: each import has a reason, which is how real cycles are born. Predict the diagnostic. Then perform the interface-extraction refactor in a copy: move the shared vocabulary into a third module neither imports from, and run the result.

Solution. Before, the cycle drawn whole:

$ lupin tangle/main.lu
tangle/main.lu: E0303: this import completes a cycle: `store` → `index` → `store` (in `tangle/index/index.lu`); imports between modules must form a DAG (D32) [mod.cycle] at 3:48

After, ch22/untangled/ adds kinds/, which imports nothing; index now consumes kinds.in_batch instead of calling back into store, and the arrows form a DAG:

$ lupin untangled/main.lu
stored 0

The refactor’s discipline: the extracted module holds what both sides needed from each other and nothing else. If kinds starts importing things, the tangle is reassembling under a new name.

Exercise 22-6. §22.2

Exercise 22-6 (comprehension · prose). A library refactor splits one 900-line module file into four files in the same directory, moves nothing across module boundaries, and changes no pub markers. List everything that changes for the library’s importers, then name the artifact from §22.2 that would prove your answer mechanically.

Solution: nothing changes. The import path names the directory, the module’s namespace is the union of its files, and the pub surface is untouched. The proof artifact is the module’s export hash, which wolf interface prints: a digest over the pub surface alone, which the split leaves bit-identical. Run it before and after and compare the export_hash line; a private helper moving between files does not appear in the items list, so it cannot appear in the number. A refactor you can prove invisible is a refactor you can make on a Friday.

Exercise 22-7. §22.3

Exercise 22-7 (comprehension · wolf). The init() idiom §22.3 retires: a plugin system where each module’s init() registers a handler into a global table at startup, in whatever order the linker felt like. Write the comptime replacement for four handlers, with a witness that fails the build if one goes missing, and say what became of the ordering question.

Solution. ex22-7.lu, run by the compiler because a comptime fn is the compiler’s to evaluate:

struct Ingest  { rows: int }
struct Report  { rows: int }
struct Purge   { rows: int }
struct Reindex { rows: int }

comptime fn handlers(a: type, b: type, c: type, d: type) -> str {
    "{typeinfo(a).name} {typeinfo(b).name} {typeinfo(c).name} {typeinfo(d).name}"
}

comptime fn expect_four(a: type, b: type, c: type, d: type) -> bool {
    assert(handlers(a, b, c, d).len == 27)
    true
}

fn main() -> !int {
    const HANDLERS = handlers(Ingest, Report, Purge, Reindex)
    const CHECKED = expect_four(Ingest, Report, Purge, Reindex)
    print("{HANDLERS}")
    if CHECKED { 0 } else { 1 }
}
$ wolf run ex22-7.lu
Ingest Report Purge Reindex

What builds the table: handlers, during compilation. When: before the program exists. What became of the ordering question: it was deleted, not answered. There is no phase in which two registrations could race, no link order to depend on, and two builds of this file produce the same table byte for byte, the determinism the sandbox exists to protect (chapter 18). Drop Reindex from either call and the witness fails the build with E0710 rather than leaving you a table that is quietly one handler short, which is the second thing init() never gave you.

Exercise 22-8. §22.3

Exercise 22-8 (design). Import cycles are errors (D32). A colleague argues the compiler should permit cycles and merely warn, citing a large codebase where breaking them means touching forty files. Argue wolf’s side using what the rule buys, then concede the strongest point on the other side and answer it.

Solution (discussion): the buy is threefold. Builds: a DAG gives every module a finish order, so compilation parallelizes and incremental builds have a frontier; a cycle collapses that into a single unit that rebuilds together forever. Comprehension: a DAG means “what does this depend on” has an answer that terminates; in a cycle, everything depends on everything, and the forty files were already one file wearing forty names. Interfaces: E0303 forced this chapter’s refactor to name the shared vocabulary (kinds), and named seams are where documentation, testing, and ownership attach. The strongest counter is real: retrofitting a DAG onto a tangled codebase is expensive, and a warning would let teams migrate gradually. The answer is the one wolf gives everywhere: gradual enforcement of a structural rule converts it into folklore. The warned-about cycle outlives its excuse, new code grows onto it, and the migration never happens. The forty-file cost is paid once; the cycle’s cost is paid on every build and every read, indefinitely, by people who did not create it.

Exercise 22-9. §22.3

Exercise 22-9 (extension · lupin). The word counter, split along its seam: a tokens/ module that turns text into words and knows nothing about counting, a tally/ module that counts a List[str] and knows nothing about lines, and an entry that owns the text and the report loop. Build it in ch22/wordcount/ and run it. Then defend the seam: why does tokens return a List[str] instead of taking tally as an import and counting as it splits?

Solution. ch22/wordcount/ — the entry:

// wordcount/main.lu
use tokens
use tally

fn main() -> !int {
    let log = """
        the wolf runs and the moon watches
        the wolf sleeps
        """
    let words = tokens.split_words(log)
    let pairs = tally.count(words)
    var i = 0
    while i < pairs.0.len {
        print("{pairs.1[i]:>3} {pairs.0[i]}")
        i += 1
    }
    0
}
$ lupin wordcount/main.lu
  3 the
  2 wolf
  1 runs
  1 and
  1 moon
  1 watches
  1 sleeps

The List[str] between them is the whole contract, and its poverty is the point: tokens importing tally would weld the two jobs into one module wearing two directories — counting-while-splitting cannot be reused to split without counting, and the import edge would put tally in every future importer’s build graph whether they count or not. A seam earns its module boundary exactly when the value crossing it is duller than either side’s insides. (tally returns two parallel lists for 5-6’s reason: first-seen order is data a Map forgets.)

Exercise 22-10. §22.3

Exercise 22-10 (extension · lupin). A calculator whose arithmetic lives behind a seam: an ops/ module exporting one function, apply(op, a, b) -> int ! {BadOp, DivZero}, and an entry that parses a b op lines and reports. Build it in ch22/calc/, with 9 0 / among the inputs. The row crosses the module boundary — what does the entry know about why a line was refused, and what would it take to start caring?

Solution. ch22/calc/ — the module:

// calc/ops/ops.lu
pub fn apply(op: str, a: int, b: int) -> int ! {BadOp, DivZero} {
    if op == "+" { return a + b }
    if op == "-" { return a - b }
    if op == "*" { return a * b }
    if op == "/" {
        if b == 0 { return DivZero }
        return a / b
    }
    BadOp
}
$ lupin calc/main.lu
7 3 - = 4
9 0 /: refused
6 7 * = 42

The entry’s handler is else |err| { … } with no match: it knows a line was refused and prints so, and the tag’s identity dies unread in err. That is a legitimate posture — chapter 6’s coarse consumer — and the row’s two tags are still load-bearing, because the day the entry wants 9 0 /: division by zero instead, the change is one match in the handler and nothing in ops: the information was already crossing the seam, typed, waiting for a caller that cares. An error row in a pub signature is the module promising its callers room to grow into.

Exercise 22-11. §22.1

Exercise 22-11 (fingers · lupin). Two scratch programs, one directory, on purpose: sum.lu totals three numbers, widest.lu finds the longest of four words, and each is a whole program with its own main. Mark each //! member: false and run both by name. Then remove the marker from one of them and run the other. Why does the diagnostic land on the program that kept its marker?

Solution. ch22/scratch/, both files opening with the marker:

// scratch/sum.lu (first lines)
//! member: false
fn main() -> !int {
$ lupin scratch/sum.lu
sum 15
$ lupin scratch/widest.lu
widest marmot

With widest.lu’s marker removed, running sum.lu refuses:

$ lupin sum.lu
sum.lu: E0302: the name `main` is defined twice in this module (defined again in `./widest.lu`); file boundaries create no scopes (D32) — two separate programs sharing a directory each mark themselves `//! member: false` (D59) [mod.dup] at 1:4

The marker is a property of the file, not of the invocation: an unmarked sibling defaults to membership in whatever module the entry anchors, so the bare widest.lu joins sum.lu’s module and brings its main along. The diagnostic lands on the marked program because that is the program you ran — its module is where the collision happened. E0302’s hint says “each mark themselves” with “each” doing real work: standalone-ness is declared per file, never inferred from a neighbor’s declaration.

Exercise 22-12. §22.1

Exercise 22-12 (comprehension + extension · lupin). In ch22/clash/, the labels module is two files — upper.lu and banner.lu — and both define pub fn title. Predict the diagnostic before running (22-3 is the same law; what differs here?). Then fix it in a copy without deleting either file, and say what the fix’s choices were.

Solution. Before:

$ lupin clash/main.lu
clash/main.lu: E0302: the name `title` is defined twice in this module (defined again in `clash/labels/upper.lu`); file boundaries create no scopes (D32) — two separate programs sharing a directory each mark themselves `//! member: false` (D59) [mod.dup] at 3:14

What differs from 22-3 is only where the union happens: these two files are members of a named module rather than the entry’s own, and the module’s namespace is still the union of its files, so the second title is a duplicate wherever it sits. The member: false hint in the message is a red herring here, on purpose — these files are not two programs, they are two halves of one module with one name too many. After, ch22/unclashed/ renames banner.lu’s function to banner:

$ lupin unclashed/main.lu
== wolf ==
** wolf **

The choices were exactly two, because the collision is one name with two owners: rename one function (taken), or merge the two spellings into one file under one title with a parameter. Moving banner.lu to another directory is not a fix — it is a new module, and callers would have to know which spelling lives where, which is the coupling the rename avoids.

Exercise 22-13. §22.2

Exercise 22-13 (spelunking · wolf). 22-6 argued from the export hash; now hold it in your hands. Run wolf interface on 22-9’s tokens module, add a private helper to the file, and run it again. Two things to explain from the output: why the hash did not move, and why no W0313 stands beside it. Then delete the module’s /// lines and run it a third time — say what changed and what did not.

Solution. Both runs, one item and one number between them:

$ wolf interface ./wordcount/tokens/tokens.lu
module pkg :: (root)
  wolfi v0 · toolchain 0.2.29 · edition v1
  export_hash 05a012a2ca47c85fc47f13e7e2c80930951ae8c59a84d1631ca8844e71669f3c
  pkg_hash    05a012a2ca47c85fc47f13e7e2c80930951ae8c59a84d1631ca8844e71669f3c
  deps: (none)
  items:
    [0] pub split_words — fn split_words(text: str) -> prelude.List[str] · regions (-) -> ρ_caller

After fn spare() -> int { 0 } is appended, the output is byte-identical: the hash digests the pub surface alone, spare is private, and a private item is invisible to the number for the same reason it is invisible to importers. (The doc comments on the pub items do not move the hash either — contracts travel with the interface, but the digest is over the signatures.) Nothing warns, because split_words carries its /// contract; delete the /// lines and W0313 arrives on standard error — “exported, but undocumented”, with the note that an item not worth documenting is rarely worth exporting — while both hashes stay byte for byte what they were. That pair is the whole lesson: the warning and the hash are the same doctrine at two strengths, and they are checking different halves of it. The module’s public face is a contract; the hash makes its shape checkable and moves only when the shape moves; the doc comment is where its meaning goes, and nothing but a warning can ask for that.

Exercise 22-14. §22.3

Exercise 22-14 (design). 22-9’s tokens exports one function; its index_of helper in tally is private; 22-10’s ops exports apply and nothing else. State the rule these three choices follow, then argue against the tempting alternative: why not export the helpers too, since a future caller might want them? Name what every pub costs its module under 22-6’s hash, and when the answer flips.

Solution (discussion): the rule is the interface chapter’s — export what callers need to do their job, keep everything whose shape you might change. Every pub widens the export hash: it becomes a signature importers may depend on, a name W0313 wants documented, a row in every future “can we change this?” conversation, and — under 22-6’s proof — a thing whose alteration is visible in the number. The speculative export costs all of that now against a caller who may never arrive; and when that caller does arrive, promoting a private helper to pub is a one-line diff whose hash change tells the truth about what happened. The answer flips when the helper is the product — a utility module whose whole reason is its helpers — and the honest test is whether you can write the /// contract W0313 asks for without the word “internal” in it.

Chapter 23

Exercise 23-1. §23.1

Exercise 23-1 (comprehension · prose). The manifest §23.1 prints, which is a real file in the book’s fixtures:

pkg {
    name:    "den/logsearch",
    version: "0.1.0",
    edition: "1",

    deps: {
        rows: { path: "../rows" },
    },
}

Answer from the file alone: what may this package do to the machine that builds it, what may it do to the machine that runs it, and which of those two answers required reading anything other than this file?

Solution: to the building machine, nothing beyond compilation, and that answer needed no reading at all: the manifest is data, there is no script section to audit, and D33 means no other file can smuggle one in. To the running machine, the manifest declares no capabilities, which is the empty set, so no net, no fs, no env; chapter 24 covers what enforces that. The second answer needed one more file, and only one: ../rows’s own manifest, because effective capabilities are the union over the graph and a dependency may declare what you did not. wolf audit reads that union for you, which is the mechanical version of this exercise.

Exercise 23-5. §23.3

Exercise 23-5 (comprehension · prose). An upstream author force-pushes tag v1.4.0 of den/rows so the same version name now serves different bytes. Walk the failure: what does your wolf.sum notice, what does the build do about it, and what would you have to run to tell wolf the change was intended? Then answer the pointed one: what does wolf.sum not protect, and who is exposed to that gap?

Solution: the ledger recorded a b3: digest over the dependency’s source tree the last time a human fetched it. The next build re-derives that digest, finds a different number, and refuses with E1506: “the bits on disk are not the bits the ledger witnessed”. Nothing is guessed and nothing is trusted except the hash: the tag is a name, the digest is the content, and the build compares content, which is why a moved tag is a broken build rather than a silent substitution. Telling wolf the change was intended is one command, wolf update, and the point of making it a command is that it produces a reviewable diff: the new digest lands in wolf.sum where a human signs off on it.

The gap: wolf.sum is a memory, so it protects only what it has already seen. The first-ever fetcher of a poisoned version has no recorded digest to compare against: they hash the malicious bytes and record them as the truth. That is the boundary of what a per-project witness can do, not a flaw in the ledger, and closing it needs a record the whole world shares rather than one your project keeps. Content addresses and an append-only record of published versions are the designed answer; the guarantee a reader can rely on is the narrower one: bits cannot change under you without the build saying so.

Exercise 23-6. §23.1

Exercise 23-6 (spelunking · wolf). Run wolf tree and then wolf why on a project with one dependency, and then run wolf why for a name that is not in the graph. Report all three exit codes, and say what the third one is for.

Solution. Real runs, in the shelf fixture §23.1 prints:

$ wolf tree --dir app
capability tree (I13)
den/logsearch 0.1.0 (root) caps=[]
└── rows 1.4.0 caps=[]
effective: []
$ echo $?
0
$ wolf why rows --dir app
den/logsearch (root) -> rows 1.4.0
$ echo $?
0
$ wolf why ghost --dir app
wolf why: `ghost` is not in the resolved graph
$ echo $?
1

wolf why for an absent name is a finding, not a usage error, and exit 1 is what a finding gets. That makes both directions scriptable: wolf why X succeeding means X is in your build, and failing means it is not, which is a one-line check in a pipeline, not a grep over a tree.

Exercise 23-8. §23.3

Exercise 23-8 (design). Wolf’s determinism has a price: your users do not receive a dependency’s bug fixes until you raise a minimum by hand. A colleague calls this a security liability and wants ranges back. Take wolf’s side without dodging the liability: name the mechanism that answers it and the reason auto-upgrading is the wrong layer for the answer.

Solution (discussion): concede the fact. When versions move only because a human moved them, a fix you have not asked for is a fix you do not have. The mechanism that answers it is advisory tooling above the resolver: an audit that reads your build list against a vulnerability feed and tells you which minimums to raise turns upgrades into reviewed, attributable diffs (one line in wolf.pkg, one entry in review history). Auto-upgrade answers at the wrong layer because it converts every upstream publish into an unreviewed change to your shipped artifact: the same channel that delivers a fix delivers a compromise (chapter 24’s event-stream is the canonical case: the malicious version arrived as a routine compatible upgrade nobody read). Determinism does not slow the urgent fix; raising a minimum is one edit. What it removes is the silent path, and silent is what the attacker was renting.

Chapter 24

Exercise 24-1. §24.1

Exercise 24-1 (spelunking · prose). The event-stream incident (2018), from §24.1’s sourcing: a maintainer handed a popular npm package to a volunteer, who shipped a version whose install-time and runtime code targeted a specific downstream wallet application. List the three legs the attack stood on (distribution, execution, and concealment) and for each leg, name the wolf mechanism from this part of the book that removes it or forces it into the open, with one sentence on the residue each mechanism cannot remove.

Solution: distribution. A compatible-version publish flowed automatically to downstreams; MVS (chapter 23) removes the automatic part, since a new version reaches you when you raise a minimum, as a reviewable diff. Residue: you can still raise it without reading. Execution. Install scripts ran arbitrary code on every fetching machine; the covenant deletes the phase (D33: no scripts), and the comptime sandbox refuses ambient reach at build time (24-4, 24-5 below). Residue: runtime malice in code you call remains possible, which is what capabilities are for. Concealment. The payload hid in a minified transitive dep nobody read; capability manifests plus wolf audit (see 24-6) make “this dep now wants net” a surfaced diff instead of archaeology. Residue: a malicious payload within already-granted capabilities. Together the three residues are the covenant’s shape: the attack surface shrinks to the part a human must still review, and that part becomes small enough to review.

Exercise 24-2. §24.1

Exercise 24-2 (comprehension · prose). Left-pad (2016) took thousands of builds down without executing a byte of anyone’s code. State what kind of failure it was, why the comptime sandbox is irrelevant to it (the pointed half of the question), and which chapter-23 artifacts answer it instead.

Solution: an availability failure. A published name was withdrawn and every build that resolved it fresh broke. The sandbox is irrelevant because nothing malicious ran; no amount of execution policy helps when the failure is absence. The answers are chapter 23’s: the immutable registry and transparency log (a published version cannot be unpublished into a lie), the local module cache, and vendoring for the paranoid tail. Distinguishing “code I must not trust” from “infrastructure I must not depend on” is the exercise; conflating them is how teams buy a sandbox and still go down on a Tuesday.

Exercise 24-3. §24.1

Exercise 24-3 (comprehension · prose). build.rs is the mechanism §24.1’s third paragraph indicts. Name three legitimate jobs build scripts do in the Rust ecosystem, and for each, the covenant- compatible replacement this part of the book offers. Then name the job for which the honest answer is “v1 cannot vendor that” (§24.4’s subject).

Solution: for code generated from a schema, comptime evaluation over the schema as a declared build input (chapter 18’s refusal note points there, and the package manifest is where the declaration lives). For platform facts, target metadata in the manifest and comptime conditionals over the declared target, not probes of the build host. For compiling and linking a bundled C library, the declarative recipe layer for the common shapes, and pre-built artifacts through the membrane (chapter 9) for the rest. The honest residue: the autotools-shaped dependency whose build is itself a Turing-complete configuration program. ./configure logic cannot be declared, only executed, and v1 refuses to execute it. That refusal is priced in §24.4, not hidden.

Exercise 24-4. §24.2

Exercise 24-4 (comprehension · wolf). The dependency that phones home at build time, spelled as directly as wolf’s syntax allows. Predict the E-code and which of the catalog’s two refusal reasons the diagnostic will cite (they differ by capability, as chapter 18 sorted them):

comptime fn latest_ad() -> str {
    net_fetch("https://deps.example.test/banner")
}

Solution: E0701, and the confinement reason. The diagnostic’s first note says so in those words, and names the scenario outright: wolf add must never mean arbitrary code talks to the network with your credentials. It arrives from the type checker, at the call inside the comptime fn, with a second span at the const that entered the evaluation. The sample is ex24-4.lu and CI checks it as a fail(E0701) with a reviewed snapshot, so the exact text is verified even where it is not printed.

The rejection lands before anything runs, which is what the covenant promises.

Exercise 24-5. §24.2

Exercise 24-5 (comprehension · wolf). The build step that reads your CI secrets: env_var("CI_DEPLOY_TOKEN") inside a comptime fn. Predict the refusal reason this one cites, and note before running that 18-6 filed environment reads under one reason, while the diagnostic gives this capability a compound answer.

Solution. The environment gets both barrels: the note reads “determinism and confinement”, because environment contents differ per machine and may hold secrets. That is the compound answer the stem warns about: chapter 18’s exercise 18-6 asks a reader to sort a clock read, a network fetch, and an environment read under the catalog’s two categories, and the catalog files the environment under confinement while the diagnostic for this capability names both. Both are right about different things: the category is what the rule is for, and the note is why this particular intrinsic is refused. The sample is ex24-5.lu, checked as a fail(E0701) with its own snapshot.

The scenario this kills is the one event-stream normalized: code you did not read, running at build time, in possession of what your CI knows. Here that code does not get to run at all.

Exercise 24-6. §24.3

Exercise 24-6 (comprehension · wolf). Take a project with one dependency whose manifest declares no capabilities, record the world with wolf update, then edit the dependency’s manifest to declare net and run wolf audit --ci. Report the exit code and the line that produced it. Then say which artifact held the previous answer, and what would have happened if that artifact had been refreshed first.

Solution. The walkthrough, in a project shaped like §23.1’s:

$ wolf update --dir app
wolf update: wolf.sum refreshed (1 entry)
$ wolf audit --ci --dir app
capability tree (I13)
den/logsearch 0.1.0 (root) caps=[]
└── regex 2.2.0 caps=[net]
effective: [net]
  net: regex — declared (nothing in its code reaches it)
wolf audit: `regex` ACQUIRES capability `net` (was not in wolf.sum)
wolf audit: capability acquisition detected — refusing (--ci)
$ echo $?
1

Exit 1, from the ACQUIRES line: a text-matching library now wants the network, and --ci treats acquisition as a finding rather than news. The artifact holding the previous answer is wolf.sum: its last field is the capability set, recorded the last time a human accepted the world. That is why the ordering in the tool is not an accident: wolf audit reports the diff before any verb rewrites the ledger, so refreshing first destroys the evidence. Run wolf update before you audit and the ledger now says caps=net, the diff is empty, the gate passes, and the only trace of the change is a line in a file you did not read. The gate is only a gate while the ledger is behind you.

The question the failing build forces is not “is this library malicious” (that has no answer you can reach) but “what, concretely, will you do with net”. The upgrade will have a plausible reply ready, and the most plausible one (“it downloads updated Unicode tables”) is exactly the one to refuse: it moves data acquisition from publish time, where the tables are baked into a hashed artifact anyone can audit, to run time on your machines with your network.

Exercise 24-7. §24.4

Exercise 24-7 (design). Pick the strongest real case against the covenant: a widely-needed C library whose build is a thicket of ./configure feature detection (the class §24.4 names as what v1 cannot vendor). The maintainer of a wolf wrapper asks for “one escape hatch: a sandboxed build script, network off, fs jailed to the package directory.” Argue the refusal, then state what the covenant’s answer costs this maintainer in practice and why the line holds anyway.

Solution (discussion): the sandboxed-script hatch fails on three grounds. Precedent: the moment one package may run a jailed script, every audit answer degrades from “packages cannot run build code” to “packages cannot run build code except the ones that can,” and the exception list becomes the attack surface. Fidelity: a jail tight enough to keep the covenant’s promises (no host probes, no environment reads) breaks ./configure anyway, because host-probing is that program’s entire method; the hatch would be both dangerous and useless. Determinism: feature detection’s output depends on the build host by design, which is the reproducibility hole D33 closed. The real cost lands on the maintainer: they must pre-build artifacts per target (chapter 9’s membrane consumes them), or translate the feature matrix into declared target metadata by hand. That is genuine, unglamorous work, borne by the few. The line holds because the alternative distributes a worse cost to everyone else: every consumer of every package re-auditing what build-time execution might do. The covenant prices the pain onto the package that has the exotic build, and keeps the ecosystem’s default trustable by reading a manifest.

Exercise 24-8. §24.3

Exercise 24-8 (comprehension · prose). “It’s only a dev-dependency”: a teammate waves through a test-helper package whose new version adds comptime code, on the grounds that it ships nothing to production. Locate the two errors, using this chapter and one fact from chapter 18.

Solution: error one. Build-time compromise does not care about shipping: comptime code in any dependency evaluates inside your build, on your CI, where the credentials live (the sandbox is what stands between them, and 24-5 showed it holding, but the audit question “why does a test helper now need comptime at all” remains a human’s to ask). Error two. Test code runs with your project’s full runtime capabilities every time CI executes the suite, and its output gates merges; code that can fake a green check ships things to production without ever being in the artifact. The chapter-18 fact that anchors both: comptime is ordinary wolf evaluated by the compiler, so there is no “harmless phase,” only phases with different blast radii. Dev-dependencies are dependencies; the qualifier names their schedule, not their trust level.

Chapter 26

Exercise 26-1. §26.5

Exercise 26-1 (fingers · wolf). Build count as printed and run it. Then put a tab in the middle of one.txt’s first line and predict all three numbers before running it again.

Solution. ex26-1.lu, which is the chapter’s tally with the tab in place of a space. Predict: nothing moves. A tab is a word separator in exactly the way a space is (both are arms of the same match pattern), and it is one byte, as a space is:

$ lupin ex26-1.lu
       2       6      31 one.txt

Two lines, six words, thirty-one bytes: the same three numbers §26.1 prints. The prediction is the exercise: 32 | 9 | 10 is one arm, so the three characters are one concept, and a reader who has understood that arm knows the answer without running anything.

Exercise 26-2. §26.5

Exercise 26-2 (comprehension · lupin). tally counts a word every time it crosses from between to inside. Predict lines, words, and bytes for the text "a b\n\nc" (two spaces, a blank line, no trailing newline) and name which of the three people get wrong.

Solution. ex26-2.lu. The answer is 2 3 7:

$ lupin ex26-2.lu
       2       3       7 -

Seven bytes: a, two spaces, b, two newlines, c. Three words: the two spaces are one gap, not two, because the second one finds inword already false. And two lines, which is the number people get wrong: there are three rows of text and only two newlines, and this counter counts newlines. wc does the same thing for the same reason, and the reason is that a “line” without a terminator is a judgment call while a newline is a byte.

Exercise 26-3. §26.5

Exercise 26-3 (extension · wolf). Give count a bytes-only mode: a second row function that prints the byte column alone, and a bool at the top of main that chooses between them. Then say what the same option costs in the C twin, and count the lines.

Solution. ex26-3.lu. The wolf side is four lines: a bytes_row function and one if at the call site.

$ lupin ex26-3.lu
      31 one.txt

The C twin costs about the same for the printing (a second row-like function is four lines there too) and then costs more for the choosing, because the flag has to reach main’s loop from wherever it was decided. In our twin the choice is a local int, so it is one more declaration and one more if: call it six lines against four. The win is small because most of the difference in this chapter’s totals is not in the parts that print things.

Exercise 26-4. §26.5

Exercise 26-4 (comprehension · wolf). Narrow count_file’s row to Tally ! {not_found, denied} and predict the diagnostic’s code and the tags it names, before running it. Then write the full row out by hand and check that it and -> !Tally accept the same program.

Solution. ex26-4.lu. The code is E0602, and the tags it names are the two that are left: io and utf8. The prediction to get right is that the diagnostic reports what is missing rather than what is present, and that it prints the corrected row for you:

$ wolf conform-run ./ex26-4.lu
error[E0602]: this can also fail with `io`, `utf8`, which `count_file`'s row does not include
 --> ./ex26-4.lu:7:16
  |
6 | fn count_file(name: str) -> Tally ! {not_found, denied} {
  |                          ------------------------------ the receiving row is declared here
7 |     let text = fs_read_text(name)?
  |                ^^^^^^^^^^^^^^^^^^^ the missing tags escape here
  |
  = note: rows compose by union: `?` re-tags errors into the wider row by injection — there is no
    conversion to write, only tags to admit.
help: extend the row with `io`, `utf8`
  |
6 | fn count_file(name: str) -> Tally ! {not_found, denied, io, utf8} {
  |

Write the four tags out and the program compiles; -> !Tally compiles the same program, because the inferred row is the union the help: line printed. The choice between them is documentation, not semantics: spell the row when the set is part of your interface, and infer it when the function is internal and the caller is going to widen it again anyway.

Exercise 26-5. §26.5

Exercise 26-5 (spelunking · wolf). Add a third name that does not exist, run the program, and read the exit status. Then read the E0602 note above in full and explain, in two sentences, why the C’s -1 needs a convention and a row does not.

Solution: the run reports the missing name on standard error, prints the rows for the two files that opened, prints the total, and exits 1:

$ wolf build count.lu && ./count
       2       6      31 one.txt
       1       1       6 two.txt
count: cannot open gone.txt
       3       7      37 total
$ echo $?
1

The complaint lands between the last file row and the total, which is where the name was in the list: the two streams are separate but they are written in program order.

The two sentences: -1 needs a convention because it is an ordinary value of the return type, so the only thing that makes it mean “failure” is an agreement between the author of count and the author of main, an agreement no part of the program states and nothing checks. A row needs no convention because the failure is not a value of the success type at all: Tally ! {…} is a different type from Tally, the compiler will not let a caller read one as the other, and E0602 is that rule being enforced across a call boundary rather than remembered across one.

Exercise 26-6. §26.5

Exercise 26-6 (design). count reads each file whole. Sketch the version that does not: fs_open, a loop of fs_read over fixed-size chunks, and a state machine that survives across chunk boundaries. Name the one thing that gets harder, and say whether you would pay one call to avoid it.

Solution (discussion): the shape is let fd = fs_open(name)?, then a loop of fs_read(fd, 8192) until it answers the eof tag, feeding each chunk to a tally that takes the running Tally and the inword flag as parameters and returns both, because the machine’s job is to carry state across bytes and the bytes now arrive in batches. defer fs_close(fd) on the line after the open, so the descriptor closes on every path out.

The thing that gets harder is not the state machine. It is the chunk boundary: fs_read hands back a str, a str is UTF-8, and a fixed-size read can land in the middle of a multi-byte code point. The whole-file read never has that problem because a file is a whole document; a chunked reader either has to be handed bytes, not text, or has to keep the tail of each chunk until the next one completes it.

Would we pay one call to avoid it? For count, honestly, yes. Write the chapter’s version first; write the chunked one the day somebody points a 40-gigabyte file at it. The general answer is the one §26.5 gives: know which of the two you copied.

Chapter 27

Exercise 27-1. §27.5

Exercise 27-1 (fingers · lupin). Add % to the dispatch. Then predict what your arm does for 7 0 % before you run it, and say whether you had to write anything the / arm did not already show you.

Solution. ex27-1.lu (excerpt). Two edits: 37 joins is_operator’s list of bytes, and a 37 => arm joins the match with the same zero guard the 47 arm has:

...
    b == 43 || b == 45 || b == 42 || b == 47 || b == 37
...
                37 => {
                    if b == 0 { return DivZero }
                    a % b
                },
...
$ lupin ex27-1.lu
2
error: division by zero

17 5 % is 2. 7 0 % returns DivZero, and the prediction to get right is that it has to be written: a modulo by zero is the same defined fault division by zero is, so the guard is not optional, and nothing about % made a new kind of problem. That is the answer to the second half: no, the / arm showed you everything. Adding an operator to this calculator costs one byte in is_operator and one arm.

Exercise 27-2. §27.5

Exercise 27-2 (comprehension · lupin). eval returns Empty for both an underflowing operator and an expression that leaves two values on the stack. Predict the output for the three lines 3 +, 3 4, and 3 4 + 5, and then argue whether one tag for two situations is the same mistake §27.1 accused the C of making.

Solution. ex27-2.lu. All three are Empty:

$ lupin ex27-2.lu
error: the stack does not hold two operands
error: the stack does not hold two operands
error: the stack does not hold two operands

3 + underflows: one operand, two wanted. 3 4 and 3 4 + 5 both finish with two values on the stack, so stack.len != 1 fires.

Is that §27.1’s mistake? No. The C’s fault is that 0.0 from a failed pop is indistinguishable from a successful answer: the error and the success share a representation, and a caller who forgets to look cannot tell them apart. Here the error is a tag: a caller cannot read it as a number, cannot forget it, and cannot get an answer out of a failed line. The complaint against this program is a smaller one: the message is imprecise, because one tag is doing two jobs and its text has to cover both. That is a wording problem with a wording fix, and 27-3 is the fix.

Exercise 27-3. §27.5

Exercise 27-3 (extension · lupin). Give Empty a payload: which operator ran out of operands, and how many it found. You will have to change the row, the two returns, and one match arm. Say what told you each one.

Solution. ex27-3.lu. A second payload struct, Short { op: str, found: int }, and Empty becomes Empty(Short) in eval’s row:

$ lupin ex27-3.lu
error: `+` wanted two operands and found 1
error: `end of line` wanted two operands and found 2
5

What told you each edit: the row, because the tag’s spelling changed and a row lists spellings; the returns, because return Empty no longer typechecks once Empty carries a payload, and the compiler names each one; and the match arm, because Empty => no longer binds anything the handler can print and Empty(s) => does. Three edits, all three demanded by a type, which is the reason the row is written down.

Note the last-line case borrowing the word op for "end of line". That is honest but slightly forced, and it is a real design question: two different failures are still sharing a tag. Splitting them into Underflow(Short) and Leftover(int) is one more row entry and one more arm, and it is the version to write if this were a real calculator.

Exercise 27-4. §27.5

Exercise 27-4 (comprehension · lupin). Feed it 007 and -0 and - 3. Predict all three results before running, then explain which of the three is handled by strip_prefix and which by words().

Solution. ex27-4.lu:

$ lupin ex27-4.lu
7
0
error: the stack does not hold two operands

007 is 7: the digit loop multiplies by ten and adds, and leading zeros add nothing. -0 is 0: strip_prefix("-") takes the sign, the body 0 parses to zero, and 0 - 0 is 0 (there is no negative zero in int, which is one of the quiet advantages of not being double). - 3 is the interesting one: words() cut it into two tokens, so the - is an operator with nothing under it, and the answer is Empty.

So: strip_prefix handles the sign that is attached, and words() decides what “attached” means. That division is why the wolf column needs no ungetch, and this exercise is the smallest program that shows it.

Exercise 27-5. §27.5

Exercise 27-5 (spelunking · the C twin). Take a census of broken in rpn.c: count the places that can set it and the places that read it, and write down the line numbers. Then take the same census of the wolf column’s failure surface: where a tag can be produced, and where one is handled. Say what the two ratios tell you.

Solution. In samples/contrast/rpn.c, broken is declared at line 35 and raised at five sites: 43 (push overflow), 53 (pop underflow), 69 (ungetch overflow), 130 (division by zero) and 146 (unknown command). Three of them are in functions that have no other way to report anything. It is read once, at line 136, in the '\n' arm, and cleared once at 142 so the next line starts fresh. Five raises, one read, and the read is more than eighty lines and three functions away from the furthest raise.

In the wolf column, a tag is produced at nine returns across number and eval, and every one of them arrives in exactly one place: the match err in main. Nine produce sites, one handle site, no reset: there is no state to clear, the failure having been the value.

The ratios look similar and they are not the same fact. The C’s one read is a choice: it is where the author decided to look, and the compiler would have been equally happy with none. The wolf column’s one handler is a requirement: eval returns int ! {…}, so main cannot get an int out of it without either handling the row or propagating it, and if the row grows a tag the handler stops compiling. One read by convention, one read by type.

Exercise 27-6. §27.5

Exercise 27-6 (extension · lupin). Add two stack words that are not operators: dup duplicates the top value, swap exchanges the top two. Neither touches number or the match. Predict what 7 2 swap - evaluates to before you run it.

Solution. ex27-6.lu. Two branches ahead of the !is_operator(tok) test, each with the length guard the operators already use:

$ lupin ex27-6.lu
9
-5

3 dup * is 9. 7 2 swap - is -5, and the prediction to get right is the direction: swap leaves 2 under 7, so the subtraction is 2 - 7. Neither word needed a new tag, a new payload, or a change to the dispatch, because a stack word that only moves values around cannot fail in a way the Empty tag does not already cover.

Exercise 27-7. §27.5

Exercise 27-7 (design). Wolf’s stack holds int and K&R’s holds double. Argue the other choice: what would the wolf column have to give up to work in f64, what would it gain, and where in this chapter would the text have to change?

Solution (discussion): what it gains is obvious and real: 7 2 / becomes 3.5, and a calculator that cannot divide is a strange calculator. What it gives up is number. The digit loop that ends n * 10 + (b - 48) produces an int and there is no conversion from that int to an f64, so a floating-point rpn cannot reach its operands through the same scan: it needs a parser that builds a mantissa and an exponent, or a library function that turns text into a float. That is a substantially bigger number: a fractional part, an optional exponent, and a decision about what to do with 1e400.

The text that would change: §27.2’s paragraph about the stack’s type; §27.3’s claim that ten lines of digit loop is a fair trade; and §27.4’s closing accounting, which currently says the int costs exactly one division. The comparison with getop’s 28 lines would also get closer, because most of what makes getop long is that it is a number parser, not a tokenizer.

The defensible position is the one the chapter takes, stated as a choice rather than as a virtue: this calculator does integer arithmetic, and if you want fractions you are writing the parser.

Exercise 27-8. §27.5

Exercise 27-8 (design). Sketch the REPL: read a line, evaluate it, print the answer, and stop at end of input. read_line() returns str ! {eof, io, utf8}, so name the loop’s exit condition, and then say what the calculator would have to remember between lines for x 3 + to mean anything, and what shape that memory wants to be.

Solution (discussion): the loop is let line = read_line() else |_| { break } inside a while true, and the exit condition is the eof tag: “input ended” is a failure tag here, not a sentinel line or a null. It is the §27.1 argument arriving from the other direction: end of input is not an empty string, so it cannot be confused with one.

For x 3 + to mean anything the calculator needs a name-to-value table that outlives a line. The shape that wants is a Map[str, int] threaded through eval as a mut parameter: one more parameter, one more branch in the token loop (a token that is neither an operator nor a number is a variable), and one more tag (Unbound(Bad)) for a name nobody has assigned. Assignment wants a syntax decision: x 3 ! in the K&R style, or a leading let, or = as an operator that pops a value and a name.

The honest note about this toolchain: Map is a name the prelude knows and neither implementation carries a signature for, so the table above is a sketch rather than a program. An association list (two parallel Lists and a linear scan) is what a reader can build with the chapters so far, and for a calculator’s variables a linear scan over a dozen names is not the part that runs slowly.

Chapter 28

Exercise 28-1. §28.5

Exercise 28-1 (fingers · lupin). Build the tree as printed and run it. Then add a word that sorts before moon and one that sorts after wolf, and predict where each appears in the output before you run it.

Solution. ex28-1.lu, with zebra apple added to the text. The prediction: apple first and zebra last, whatever order they arrive in.

$ lupin ex28-1.lu
   1 apple
   1 moon
   1 runs
   3 the
   1 watches
   2 wolf
   1 zebra

zebra was read before apple and prints after it: the in-order walk reports the tree’s shape, and the shape came from strcmp-style comparison, not from arrival. apple became the left-most leaf and zebra the right-most, and neither insertion touched a node it did not have to.

Exercise 28-2. §28.5

Exercise 28-2 (comprehension · lupin). add compares with w < n.word on str. Predict the order of Wolf, wolf, WOLF, and wolfs in the output without the .lower() call, and say which two of the four end up as one node once .lower() is back.

Solution. ex28-2.lu. Four distinct words, four nodes, ordered by bytes:

$ lupin ex28-2.lu
   1 WOLF
   1 Wolf
   1 wolf
   1 wolfs

WOLF before Wolf before wolf because str compares byte by byte and ASCII puts every capital before every lowercase letter (O is 79, o is 111). wolf before wolfs because a prefix sorts before what extends it. Put .lower() back and three of the four collapse into one node with a count of 3, and wolfs stays where it is: it is a different word in every case folding.

Byte order is not alphabetical order, and chapter 2 says so plainly. lower() is enough for ASCII words and it is not a collation.

Exercise 28-3. §28.5

Exercise 28-3 (extension · lupin). Add a -n mode: print the words in descending order of count instead of alphabetically, with ties broken alphabetically. The tree is already sorted by word, so the shape of the answer is a second pass. Say what you collect on the first pass, and what it costs in lines.

Solution. ex28-3.lu. The first pass is collect, an in-order walk that pushes each node’s word and count onto two parallel Lists, so it arrives already sorted alphabetically, which makes the tie-breaking free. The second pass is a selection walk: next_after finds the largest remaining (count, word) after the last one printed, which needs no mutation of either list.

$ lupin ex28-3.lu
   3 the
   2 wolf
   1 moon
   1 runs
   1 watches

The cost is 35 lines of code: six for collect, sixteen for next_after, and thirteen for the driving loop and its two lists. That is a real number and it is bigger than it should be. The reason: the obvious implementation marks each printed entry in a List[bool] and skips the marked ones, and writing a single element of a list is not something this toolchain does, so the selection carries a cursor (the last pair printed) instead of a mark. The cursor version is arguably the better program anyway, since it allocates nothing per iteration, but it is not the one a reader would reach for first.

Exercise 28-4. §28.5

Exercise 28-4 (comprehension · lupin). Delete the is_empty() guard in walk and predict the exact failure: which trap kind, which exit code, and at which of the two walk calls. Then check it.

Solution. ex28-4.lu, with walk(n.left[0]) unguarded. The prediction: a bounds trap, exit 3, at the left call, and it fires on the first leaf the walk reaches, before anything is printed at all.

$ lupin ex28-4.lu
ex28-4.lu: trap(bounds): index 0 is outside a collection of 0 element(s) [mem.ub.defined] at 29:10
$ echo $?
3

The C twin’s equivalent mistake (dropping if (p != NULL) from treeprint) dereferences a null pointer, which is undefined behavior: it may segfault, it may print garbage, and on some targets it may appear to work. Here it is a defined fault with a kind, a clause tag, and a byte span, and it happens at the same place every time. That difference is the book’s subject, in a two-line function.

Exercise 28-5. §28.5

Exercise 28-5 (spelunking · the C twin). Count, in wordtree.c, every line that would disappear if malloc could not fail, and then every line that would disappear if the program never had to free. Give both numbers and say which of the two the region brace replaced.

Solution. Counting code lines only, in samples/contrast/wordtree.c:

If malloc could not fail: 16 lines go. addtree’s two null tests with their nomem sets, early returns and the half-built free(p) come to 10 (lines 50–59); the nomem declaration is 1 (line 39); main’s out-of-memory branch is 5 (lines 153–157). What survives is talloc, dupstr, and the two assignments that call them, because the allocation still has to happen.

If the program never had to free: 10 lines go. treefree is 9 (lines 85–93) and its call in main is 1 (line 160). Note that the free(p) inside addtree is counted in the first number rather than this one; it is there because the failure path has to clean up, not because the program ends.

Both together are 26 of the file’s 115 code lines, and talloc and dupstr (12 more) exist only because the memory has to come from somewhere nameable.

Which did the brace replace? The second, exactly: region words { … } is treefree, and the ten lines are the ten it replaced. The first sixteen were not replaced by anything: they were removed by the fact that a wolf allocation failure is not a value a program is handed, which is a different argument and a stronger one. The brace is the visible half. The invisible half is bigger.

Exercise 28-6. §28.5

Exercise 28-6 (extension · lupin). Take the tree’s census: write nodes and depth and print both after the walk. Then multiply the node count by two and say what that number is in the C column, and what it is in the wolf one.

Solution. ex28-6.lu. Two recursions in the same shape as walk:

$ lupin ex28-6.lu
   1 moon
   1 runs
   3 the
   1 watches
   2 wolf
5 nodes, depth 3

Five nodes, depth 3: the tree is not balanced and nothing balances it, which is true of K&R’s as well; feed either program a sorted word list and you get a linked list with a tree’s memory layout.

Ten is the number. In the C column it is the count of malloc calls (two per node: one for the struct tnode, one for dupstr’s copy of the text) and therefore also the count of free calls treefree has to make, in the right order. In the wolf column ten is not a count of anything the program does: the nodes and their strings are allocations in the region, the region frees once, and the only place the number ten appears is in an exercise about the other column.

Exercise 28-7. §28.5

Exercise 28-7 (design). K&R’s addtree returns the new subtree root; wolf’s add takes mut n and returns nothing. Both are answers to “how does a recursive insert report where the tree went.” Argue which is easier to get wrong, and then say what the wolf version does about the empty tree that the C version does not have to.

Solution (discussion): addtree’s convention is p = addtree(p, w) at every call site, including the two recursive ones (p->left = addtree(p->left, w) and its mirror). It goes wrong in one specific way, and the way is silent: write addtree(p->left, w) without the assignment and the program compiles, runs, and quietly loses every word that would have become a new left child. Nothing in C’s type system objects to a discarded return value.

add’s convention cannot be dropped, because there is no return value to drop. Passing mut n.left[0] is a claim on that place, spelled at the call site, and the mutation lands where the caller can see it was going to. What it costs is that the claim is visible: mut at every recursive call, and a reader who wants the mutation surface of this program greps for one word. That is X1’s whole argument, and this is a program small enough to check it on.

What the wolf version has to do about the empty tree: addtree handles it for free, because NULL is a struct tnode * and the recursion’s base case and the empty-tree case are the same case. add cannot be handed a Node that does not exist, so main carries forest, a List[Node] holding nothing or one thing, and the first word is a push rather than an add. That is three lines of main the C does not need, and the same three lines are what make the children need no null, so the language is charging once for something the C column pays for at every dereference.

Chapter 30

Exercise 30-1. §30.5

Exercise 30-1 (fingers · wolf). Build both programs and run them against the same two files with three patterns of your own. Then run wc -l on both and write down, in one sentence, what you would tell a colleague who proposed the parallel one for a log directory of four files.

Solution. No new program; the two are samples/projects/seqgrep/seqgrep.lu and samples/projects/pargrep/pargrep.lu, both printed in the chapter.

$ wc -l samples/projects/seqgrep/seqgrep.lu samples/projects/pargrep/pargrep.lu
  57 samples/projects/seqgrep/seqgrep.lu
  97 samples/projects/pargrep/pargrep.lu

The sentence: for four files, ship the sequential one. The parallel version costs forty lines and thirty-seven of them are the fan-out being written out by hand, so the version to argue about is the one that takes its shard count from the input, and that is not the version on the page.

Exercise 30-2. §30.5

Exercise 30-2 (comprehension · wolf). hits and done are both channel[int](0). Give hits a buffer (channel[int](64)) and predict what happens before you run it. Then run the binary twenty times and count the report lines each time. Two questions: what can done.send(1) do now that it could not do before, and which line of the collector is the one that loses the hits?

Solution. One character of the program changes and it stops working. Twenty runs of the modified binary, counting report lines:

$ for i in $(seq 1 20); do ./pargrep | wc -l; done | sort -n | uniq -c
     11 1
      2 2
      7 4

Four is the correct answer and it came up seven times.

What done.send(1) can do now: finish first. With hits unbuffered, a task’s send blocks until the collector has taken the value, so everything a task found is already in found by the time its completion is sent. Buffered, the send returns immediately, and a task can queue four hits and then report itself done while all four are still in the channel.

The line that loses them is while live > 0. The collector stops the moment the fourth completion arrives, and anything still sitting in the buffer is never received. The report then walks the input and finds found missing most of it.

This program is not in this directory. A sample whose output changes between runs cannot be a CI sample, and a book that shipped one would be teaching its own alarm to cry wolf. The measurement above is what the exercise is for; the program is three seconds of your own editing.

Exercise 30-3. §30.5

Exercise 30-3 (comprehension · wolf). Delete freeze from the needles binding, leaving let needles = pattern.split("|"). Predict whether the program still compiles before you try it. Then explain, in two sentences, what the four tasks are allowed to do with needles in each version.

Solution. ex30-3.lu. It compiles, and it runs correctly:

$ wolf build ex30-3.lu && ./ex30-3
a.log:1: 06:12 the wolf runs
a.log:3: 07:02 the wolf howls
b.log:2: 08:52 the wolf sleeps
b.log:4: 09:44 the wolf wakes

The prediction most readers write down is “it will not compile”, and the useful part of this exercise is being wrong about it. needles is bound with let and never written, so the four tasks read a value nobody mutates and the program is correct: by inspection, and only by inspection.

The two sentences. With freeze, the tasks read an imm graph: nothing anywhere can write it, the compiler knows that, and adding a write somewhere else in the program is a compile error rather than a race (§30.4’s E1012 is that error). Without freeze, the tasks read ordinary data the current text happens not to write, and the guarantee is a property of that text, not of the type. That distinction is Part 3’s subject.

Exercise 30-4. §30.5

Exercise 30-4 (extension · wolf). Instrument the collector: print each index as it arrives, before pushing it. Run the binary twenty times and count the distinct arrival orders you see; then confirm that the four report lines never move. Which of the two outputs would you put in a test?

Solution. ex30-4.lu, one print added to the hits arm.

$ wolf build ex30-4.lu && ./ex30-4
arrive 0
arrive 2
arrive 5
arrive 7
a.log:1: 06:12 the wolf runs
a.log:3: 07:02 the wolf howls
b.log:2: 08:52 the wolf sleeps
b.log:4: 09:44 the wolf wakes

That is one run. Over twenty, the four arrive lines came out in five different orders (0 2 5 7 most often, then 0 2 7 5, 0 5 2 7, 0 5 7 2, 0 7 2 5), and the four report lines hashed to one value twenty times out of twenty. Which one goes in a test is therefore not a matter of taste: the report is a property of the program, the arrival order is a property of the afternoon.

Index 0 arrives first on nearly every run; do not over-read it. The first shard’s first line matches, and its task has the shortest path to a rendezvous. That is a bias, not a guarantee, and a test that assumed it would fail on a loaded machine.

Exercise 30-5. §30.5

Exercise 30-5 (extension · wolf). Make the report order-dependent on purpose: delete both channels and the collector, and have each task print its own matches directly. Run the binary twenty times and hash the output. You will get more damage than you predicted. Say what the extra damage is, and then say what you have broken in terms of §30.5’s rule rather than in terms of tasks.

Solution. ex30-5.lu. Twenty runs, twenty distinct outputs. Here is one:

$ ./ex30-5
a.loga.log:1: 06:12 the wolf runs
:3: 07:02 the wolf howls
b.log:4: b.log:2: 08:52 the wolf sleeps
09:44 the wolf wakes

The extra damage is that the lines are not merely out of order, they are torn: a.log from one task and :3: 07:02 … from another arrived in the same line of output. print is not atomic across tasks, and four tasks writing one stream interleave inside a line as readily as between two.

In §30.5’s terms: the four report lines used to be a fact about the program, and now they are a fact about the run. Nothing was added to the program to break this; something was removed. The collector was not overhead; it was the single owner of the output, and a single owner is what made the output reproducible. The general form of the rule is that the last stage of a concurrent pipeline should be sequential, and the cheapest way to obey it is to let exactly one task print.

Exercise 30-6. §30.5

Exercise 30-6 (spelunking · wolf). Read the E1012 note in §30.4 in full. It offers two ways out: build the value completely before freezing, or keep a mutable copy alongside. Say which one pargrep uses and what the other one would cost in a four-task program.

Solution. pargrep uses the first, twice, and the shape is visible in both freeze region { … } blocks: everything the value will ever contain is pushed inside the block, and the block’s last expression is the finished value. The pattern list is built by split in one call; the table is accumulated by a loop over the files and then handed out as a Table. Neither is touched again.

The second way out (keeping a mutable copy beside the frozen one) is a correct answer to a different question, and in a four-task program it is usually the wrong one. The copy is not shareable, so it cannot cross into a task, so the only place it can be used is the parent; and the moment what the parent has and what the tasks have can differ, the program has a consistency question it did not have before. The cost is not the memory but the noun: “the frozen table” stops naming a single thing.

Exercise 30-7. §30.5

Exercise 30-7 (design). The shard count is a constant. Sketch the version that takes it from the input: what the ranges become, what the collector’s live counter becomes, and what pargrep would need from the language to spell the fan-out in one loop instead of four spawns. Then say whether four shards on two files was ever the right number.

Solution. The arithmetic is the easy third of it. With w shards, the kth range is (n * k) / w to (n * (k + 1)) / w, which is the same integer division §30.1 already does and needs no boundary variables at all; live starts at w instead of 4; and the report loop does not change, because it never knew how many tasks there were. The whole difference is that three let bs become one expression evaluated inside a loop.

What the language has to give is the loop: for k in 0..w { s.spawn(fn() { … }) }, with each closure capturing its own k. That is one construct, and everything else in the sketch is already spelled.

Whether four was ever right: no, and the honest reason is that nobody chose it for this input. Four is a plausible default for a machine and a poor one for two files of four lines, where a single task would finish before a second one started. The number a real version wants is the smaller of the worker count and the input’s shard count, with a floor of one, and the program cannot ask for the first of those two either. A tool that shards should take -j from the command line, the way make does, which makes this exercise’s answer one more argument for the loop.

Chapter 33

Exercise 33-1. §33.1

Exercise 33-1 (fingers · lupin). Change the cold open’s request from "regions" to a title the shelf does not hold, predict the printed line before you run it, then run it. Then move net_close(door) above net_accept and say, in one sentence, which of the two programs on this page you have written.

Solution. ex33-1.lu.

$ lupin ex33-1.lu
shelves: unknown

The prediction to make is that nothing fails. words_in walks the shelf and returns "unknown" when it falls off the end, so a miss is a string the caller receives like any other answer, and the row machinery is never reached. That is a design decision worth naming: a shelf that knows its whole contents can answer “not here” as data, and only a lookup that might fail for a reason the caller could act on deserves a row.

The second half writes the other program on the page. Closing the door before accepting is §33.1’s second block: the listener is gone, the caller’s dial has nowhere to land, and the answer is refused. The two programs differ by the order of two lines.

Exercise 33-2. §33.2

Exercise 33-2 (comprehension · lupin). Delete the net_accept line and the conn variable from the program above, leaving the fourth net_wait in place. Predict the fourth line before you run it, then run it, and explain the answer using one sentence of this section.

Solution. ex33-2.lu.

$ lupin ex33-2.lu
nobody there: 0 ready
someone there: 1 ready, and it is the door: true
looking took nothing: 1 still ready
still nobody took it: 1 ready

The fourth line is 1 ready, not 0. The sentence that explains it is the section’s third rule: asking consumes nothing. In the chapter’s version the count drops to zero because net_accept took the connection and the door genuinely has nothing pending; here nothing was taken, so the door is still holding it and every wait says so. Readiness reports the state of the socket, not the history of your looking, which is why a loop that forgets to accept spins at full speed instead of blocking — the wait keeps returning immediately, correctly, forever.

Exercise 33-3. §33.3

Exercise 33-3 (extension · lupin). The loop above never lets go of a connection. Give it the other half: when net_read answers the closed row, close that handle and remove it from open instead of writing back. Then say why the program cannot learn that a peer has gone without reading it.

Solution. ex33-3.lu.

$ lupin ex33-3.lu
served 2, dropped 2

Two changes and one helper. The read grows an else that sets a flag rather than propagating, the branch under it closes the handle and rebuilds open without it, and without is the three-line filter that does the rebuilding. The loop’s exit condition moves from “two served” to “two dropped”, which is the honest one for a server: a connection is finished when its peer says so, not when you have answered enough.

Why the read is unavoidable: readiness is the only thing the wait reports, and a connection whose peer has closed is ready. It will be named by every net_wait from now until something reads it, because there genuinely is something to read — the end of the stream. The read is what turns that into the closed row, and the close is what takes the handle out of the set. A loop that watches without reading gets a wait that returns instantly every time and a process at full cpu with nothing to show for it.

Exercise 33-4. §33.4

Exercise 33-4 (comprehension · lupin). net_wait declares one row, io, and one of the things it means is a set that could never become ready. Write the four-line program that produces it — an empty list and a deadline that never expires — and predict the exit code before you run it. Then say what a program that meant to sleep should have called instead.

Solution. ex33-4.lu.

$ lupin ex33-4.lu
error: io
$ echo $?
1

There is no handle that could become ready and no clock that could end the wait, so the call cannot answer and cannot honestly park: it takes its one row. Propagated with ? from main, a row is the documented process outcome — the tag on stdout and exit 1 — which is chapter 6’s rule and not a network rule.

The program that meant to sleep calls time_sleep_ms. It is worth noticing what the alternative designs would have cost: a wait that returned an empty answer here would be indistinguishable from a healthy timeout, and a wait that parked forever would be a hang with no name on it. The row is the only answer that tells the caller which mistake it made.

Exercise 33-5. §33.4

Exercise 33-5 (spelunking · prose). §33.4 says a lost accept race is not an error row. Read [os.net.accept] in the specification and write down the two candidate rows it rejects and the one sentence of reasoning that rejects each. Then say which of the two would have been the more tempting mistake.

Solution (discussion): the two candidates are a new row of its own and the existing timeout.

A new row is rejected because a lost race carries nothing: from the program’s side it is indistinguishable from the connection never having arrived, which is the same thing one hand alone already meets when a peer aborts between the wake and the take. A row would therefore make net_accept(l)? fail on a stranger’s reset — a program with no budget armed would start reporting an error it can do nothing about.

timeout is rejected because it would be a lie about a clock: a program that armed no deadline would be told its deadline expired.

timeout is the more tempting mistake, and by some distance. It costs no new vocabulary, it is already in the call’s row, and it is what the hand eventually answers anyway when the budget does run out — so a hurried implementation reaches for it and the difference only shows on the path where no budget was armed at all. The rejected reasoning is worth keeping because it generalizes: a row is a claim, and a call should not make a claim it cannot support.

Exercise 33-6. §33.4

Exercise 33-6 (design). A configuration file says workers auto. Write down the rule your program follows: what it starts when os_cpus answers, what it starts when os_cpus answers io, and what it logs in each case. Then defend the number you chose for the second case against the two obvious alternatives.

Solution (discussion): when os_cpus answers n, start n workers and log the number and where it came from — “starting 4 workers (os_cpus)” — because the number is a decision the operator will want to audit against the container they think they gave you.

When it answers io, start one worker and log that the count was not learned: “starting 1 worker (core count unavailable: io)”. One, plus a line that says the count is a fallback rather than a measurement.

The two alternatives are worse in different directions. Refusing to start turns a question the program could not answer into an outage, and the program’s job is to serve; a machine whose core count is unreadable can still answer requests. Guessing a plausible number — four, eight, whatever the last machine had — is worse still, because it looks like a measurement in the log and will be believed. One worker is honest: it is the smallest thing that works, it cannot oversubscribe a container, and the log line tells the operator exactly which knob to set by hand. This is also why os_cpus has a row instead of quietly answering 1 — a silent 1 would have made this decision for you, and made it invisible.

Exercise 33-7. §33.4

Exercise 33-7 (extension · lupin). §33.3’s loop answers one question per connection and forgets it. Make the shelf answer two: keep serving a connection until its read answers closed, and have each caller ask twice before hanging up. Which of the loop’s data structures had to change, and which did not?

Solution. ex33-7.lu.

$ lupin ex33-7.lu
answered 4 questions on 2 connections

Nothing had to change. open is the same list, the set is built the same way, and the body of the loop is the same three branches: the door is accepted, a readable connection is read and answered, a closed one is dropped. What changed is a counter — the loop leaves when two connections have closed rather than when two questions have been answered — and that is the point of the exercise. A loop written around readiness is already a loop that serves a conversation; it was only the exit condition that assumed one question.

The callers are tasks here so that one process can hold both ends of two conversations at once. That is a page-fitting convenience and not part of the lesson: net_wait neither parks a task nor disturbs the scheduler, so the loop is unchanged whether the callers are tasks beside it or machines somewhere else.