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:
f(mut p.a, mut p.b)f(mut p.a.n, mut p.b.n)f(mut p.a, mut p.a.n)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):
let b = copy apool[h].valuep[8] = 1wherep: *u8borrow r from pch.send(move r)whereris 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.