A unified textbook covering CS1 through knowledge representation
September 6, 2026 — Reviewed working edition; Cascade integration deferred
© 2026 Vlad Evenhar.
Created and designed by Vlad Evenhar. Implemented by AI agents.
A unified textbook for the Axioma programming
language — covering a learning path from 1 + 2 through data
structures and knowledge representation. The title plays on Felleisen,
Findler, Flatt, and Krishnamurthi’s How to Design Programs
(HtDP, MIT Press, 2018, 2nd ed.), whose design-recipe pedagogy the early
chapters follow faithfully (all prose, examples, and exercises here are
original) — but the book’s scope grows past traditional HtDP to cover
data structures, knowledge representation, and a separately maintained
AxiomaCascade application route.
A textbook in the HtDP tradition: design recipes drive everything; data definitions come before code; examples become tests; the structure of the data shapes the structure of the program. The book is organized into Volumes:
It is not an Axioma reference manual — that already exists at ../../manual/Axioma Manual.md. This book is a learning path. The manual is the look-up index.
This is a reviewed working edition, checked against
the interpreter at revision 19a9398d6 (September 6, 2026).
The language is still evolving. The current pass repairs older examples
and covers the recent numeric, collection, control-flow, type-contract,
metaprogramming and discovery changes. It also corrects mathematical
explanations, documented outputs and prerequisites.
The automated suite inventories chapter and solution fences and exercise starters. It executes the declared standalone and contextual examples, compares documented output, and checks intentional failures. Exercise holes, foreign code and external integrations have explicit classifications. A successful test run does not mean every unfinished exercise was solved or every function’s possible inputs were tested.
Deferred route: Cascade product integration, the MCP bridge and associated external-service examples require their own repository and environment review. Those chapters remain marked as drafts. Chapter 53 documents a current SQL NULL limitation instead of presenting its failing example as successful. See CURRENCY_AUDIT.md for scope, corrections and remaining boundaries. This is not yet a final print edition.
Two audiences, depending on entry point:
The book ships in three formats, all generated from the same markdown sources:
.md file in this directory is readable
on its own.HtDKP-in-Axioma.html, single-file with table of
contents.HtDKP-in-Axioma.pdf, growing with each
chapter.After editing any markdown source, regenerate the HTML and PDF with:
./build.shRequires pandoc, LuaLaTeX, STIX Two Text/Math, and the
fallback fonts named in fonts-fallback.tex. The build uses
LuaLaTeX so Unicode symbols in code remain readable; see
build.sh for the platform font requirements.
Use the main sequence when learning programming from the beginning. The optional extensions can wait until the canonical form feels familiar. Chapters sometimes reuse earlier helpers; load the named prelude rather than treating every code fence as an independent file. Independent alternatives and language-mode changes are identified in the text.
A first programming course. Start with Chapters 1–9, then abstraction in 10–15. Continue with state and data modeling in 16–21. Return to Chapter 22’s interpreter and macro material after writing several small programs.
Axioma as an additional language. Start with the numeric distinctions in 1–2, calls and receivers in 3, collections and matrices in 6, and higher-order functions/broadcasting in 10. Then study binding and mutation in 16, Concepts and schemas in 18–19, and logic in 20–21. Appendix E is the working companion for inspecting values and diagnosing errors. Familiarity with another language does not settle Axioma’s indexing, exact-number or scope rules.
Optional application routes. For data structures, read 23–27 alongside algorithm analysis in 35–37. For stateful systems, read 38–39 and the stack chapter 41. Knowledge representation in 28–33 and SQL in 53 build on the logic chapters. Read the Hindley–Milner addendum 54 as a separate language mode. The Cascade route can be skipped entirely without losing the core course.
Each chapter follows the same shape:
You’ll need a running Axioma installation. Throughout the book we assume you’re using the beginner subset:
axioma --learn # interactive single-shot tutor
axioma --recipe # six-stage Design Recipe wizard
axioma --language axioma/beginner ex.ax # restricts to canonical forms
axioma --typecheck ex.ax # static type check before runIf you write #language axioma/beginner at the top of a
file, the beginner restrictions apply automatically for that file. Most
exercises in this book are written that way. Other real dialects show up
later, each as a language, not a flag on the host:
#language axioma/knowledge-core (monotonic
Horn proof-core)#language axioma/rpn (additive Forth
return stack; infix still runs)#language axioma/hm (closed Hindley–Milner
island; println is out, expect is in,
untypable programs exit 1)axioma/core and axioma/functional are known
names with no AST gate — they run as host. This book does not teach
them. AppA lists the full roster. Bare #language axioma is
not a name.
From the repository root, make test-textbook builds the
current interpreter and runs the source-linked checks. The manifest in
verification/examples.json records each code block’s role
and the case that exercises runnable material. Editing code requires
reviewing its expected behavior, not merely replacing a hash.
Contributor instructions require a textbook impact decision alongside
the Manual, quick reference, website and editor for language
changes.
make textbook creates HTML, PDF and a build receipt. The
commit check compares those hashes with the staged sources; CI repeats
the example checks. This keeps missing updates visible while leaving
explanatory quality to editorial review.
The generated table of contents links directly to the chapters. The repository also keeps an editorial outline; its draft-status tracker is not repeated in the reader edition. A quick map:
| Part | Chapters | Focus |
|---|---|---|
| I. Fixed-Size Data | 1–5 | Arithmetic, variables, functions, conditionals, booleans |
| II. Arbitrary-Size Data | 6–9 | Lists, recursion, trees, mutual recursion |
| III. Abstraction | 10–12 | Higher-order functions, lambdas, locals |
| IV. Intertwined Data | 13–14 | Mutually recursive data, generative recursion |
| V. Accumulators & State | 15–17 | Mutation, stateful objects, streams |
| VI. Axioma additions | 18–22, 54 | Concepts, enums + subranges, multi-valued logic, logic programming, the meta-circular evaluator; Ch.54 is a later addendum — the closed Hindley–Milner island |
| Part | Chapters | Focus |
|---|---|---|
| VII. Data Structures | 23–27 | Linked lists, stacks/queues/deques, hash tables, balanced trees, graphs |
| X. Knowledge Representation (pilots drafted) | 28–33 | Graphs landscape (Sowa/Peirce/Context); Conceptual Spaces (Gärdenfors); Gödelization; Lojban-in-Axioma; NSM (Wierzbicka); Schank’s CD |
| VIII. Algorithm Analysis (planned, ch. nums TBD) | — | Big-O, sorting showcase, graph algorithms |
| IX. Stateful Systems (planned, ch. nums TBD) | — | State machines, event loops, the Cascade reactor |
| XI. Symbolic + Neural (planned, ch. nums TBD) | — | Stack programming, MCP bridge, neuro-symbolic patterns |
| Part | Chapters | Focus |
|---|---|---|
| XII. Cascade Foundations | 34 | Architecture map; the 4 layers; where Axioma sits; CLI walkthrough |
| XIII. Cascade Knowledge Graph (planned) | 43–45 | SQLite schema, ingest pipeline, truth-value lifecycle |
| XIV. Cascade Reasoning (planned) | 46–48 | NetworkX + Axioma; writing custom .ax rules |
| XV. Operating Cascade (planned) | 49–52 | Agent swarm, scheduled tasks, scaling |
Volume I complete (Ch. 1–22) + Volume II drafted (Ch. 23–42) + Volume III complete (Ch. 43–52) + Ch. 53 (the SQL surface) + Ch. 54 (the closed Hindley–Milner island, a Volume I addendum). Chapters 1–27 and 35–52 have full prose, exercises, runnable scaffolds, and per-chapter solutions. Chapters 28–33 are pilot drafts (concept overview + worked example + 2–3 inline exercises); each can be promoted to a full chapter in a later session. Appendices A, B, C (auto-assembled from the per-chapter solutions files), D, E, and F are complete.
#language axioma/hm
(closed Hindley–Milner island)..ax Rules; the Agent Swarm; Reactor
Internals; Operating Cascade at Scale. Ch. 53 adds the SQL surface. Ch.
54 is the closed Hindley–Milner island (read after Ch. 19).This is an original textbook inspired by HtDP, not a translation or a chapter-by-chapter adaptation. It adopts the design recipe and data-driven progression, then develops its own Axioma examples, exercises, and knowledge-programming material.
HtDP itself is freely available online at https://htdp.org/ and is the canonical reference for the underlying pedagogy. Anyone reading this book seriously should also read the original.
The textbook prose is © 2026 Vlad Evenhar, available under the same license as the rest of the Axioma project. HtDP itself is © Felleisen et al.; we neither copy nor displace it.
What this chapter is. Your first encounter with Axioma. By the end you can use it as a calculator, you know what kinds of numbers it handles, and you’ve seen your first type error — Axioma telling you that something doesn’t fit.
A note on faithfulness to HtDP. The original How to Design Programs introduces several things in its first chapter that we’ll defer here: an image library (circles, squares, overlays), strings, and the design recipe itself. We delay those to keep the first three chapters lower-friction. The full design recipe lands in Chapter 4 — early enough to drive the rest of the book.
A calculator lets you type 2 + 3 and shows you
5. Axioma can do the same — and a lot more — through what’s
called the REPL (read – evaluate – print – loop). Open
a terminal and start it:
$ axioma
Axioma programming language and environment v0.9
Calculemus! (Let us calculate) - Leibniz.
Type ':help' for commands, ':exit' to quit
axioma>The axioma> prompt is asking what you’d like to
compute. Try typing 2 + 3 and pressing Enter:
axioma> 2 + 3
5
axioma>
That’s the whole loop: it read your line,
evaluated the expression 2 + 3,
printed the result 5, and
looped back to the prompt for another. We’re going to
live in this REPL for the rest of the chapter.
Axioma’s REPL can detect when an expression looks incomplete
— for example, if you type a single line that has an open parenthesis
but never closes it. When that happens, the prompt changes from
axioma> to ... to tell you “I’m waiting for
more”:
axioma> (2 + 3
... ) * 4
20
axioma>
You can submit a half-finished line by hitting Enter on an empty
... line. If you ever see ... and don’t
remember why, that’s the escape hatch: a blank line on the
... prompt either submits what you have (if it’s now
complete) or returns to axioma> empty-handed.
For every REPL example in this book, we’ll show the prompt that’s asking, so you can match what’s on your screen.
Beginner mode. Throughout this book we’ll usually invoke Axioma in the beginner subset — a restricted version that allows exactly one way of doing each thing, so you don’t get distracted by alternatives:
$ axioma --language axioma/beginnerOr, equivalently, you can put
#language axioma/beginnerat the top of any script file. The REPL prompt looks the same, but Axioma will refuse some of its more advanced features. That’s intentional — we’ll unlock them chapter by chapter.
println and the trailing dotThe REPL prints every result automatically. Script files
don’t — type 2 + 3 in a script and run it, and you’ll see
nothing on screen. Axioma gives you two ways to make a value
visible:
println(2 + 3) # the function call: prints 5
2 + 3 . # the trailing-dot sigil: also prints 5The trailing . (read it as “show me this”) is Axioma’s
inspect-print operator. It works exactly the way the REPL does
— evaluate the expression, then print the result — but inside a script
file. Compare:
# Verbose script style
x: 5
y: 10
println(x)
println(y)
println(x + y)
# Same thing, terser
x: 5 .
y: 10 .
x + y .Both print 5, 10, 15. The dot
version reads almost like a Prolog or Smalltalk dialect — every
“sentence” ends with a period, and the period asks Axioma to show you
what just happened.
The dot works at the top level of a script, and also inside
the bodies of loops, conditionals, and functions — so it’s
particularly handy when you want to peek at intermediate values without
cluttering your code with println calls:
total: 0
i: 1
while (i <= 5) [
i . # show me i each iteration
total = total + i
i = i + 1
]
total . # show me the final totalThat prints 1 2 3 4 5 15 — the loop variable for each
iteration, then the final running total.
For most of this book we’ll use println(...) because
it’s explicit and works the same way in every language you’ve used; the
trailing dot is here so you can recognize it when you see it, and use it
yourself once you find it more comfortable.
In the REPL transcripts in this book, binding acknowledgements and
the none returned by output commands are omitted to keep
attention on the value being discussed. Strings displayed as values keep
their quotes; println and printf print the
text itself. Error excerpts retain the meaning but may omit the source
frame and hint.
You can type whole numbers and Axioma understands them as integers:
axioma> 42
42
axioma> -7
-7
axioma> 1000000
1000000
Or numbers with a decimal point — those are floats (short for “floating-point”):
axioma> 3.14159
3.14159
axioma> -0.5
-0.5
Floats also accept scientific notation: write
1.5e6 to mean “1.5 times ten to the sixth power”
(1,500,000), or 3.14e-2 to mean “3.14 times ten to the
negative second” (0.0314):
axioma> 1.5e6
1.5e6
axioma> 1e9
1.0e9
axioma> 3.14e-2
0.0314
Notice that 3.14e-2 prints back as 0.0314 —
scientific notation is just a way of writing a number, not a
separate kind of number. Axioma reads 3.14e-2 and
0.0314 as the very same float.
(For very small or very large magnitudes Axioma
keeps the scientific form when printing back — 3.4e-10 is
easier to read than a string of ten zeros. The switch happens around one
ten-thousandth: 0.0314 stays decimal, but
0.00000000034 prints as 3.4e-10.)
You’ll see floats whenever you write a number with a decimal point or
an exponent — and whenever one enters a computation, because floats are
contagious. (An all-integer division that comes out uneven gives you an
exact fraction instead — 7 / 2 is the rational
7/2, not a float; §1.5 tells that story.)
printf and stringfprintln shows you a value the way Axioma chooses to.
Most of the time that is exactly what you want. But sometimes you want
to decide the layout yourself — for instance, to see
0.00000000034 written out in full instead of as
3.4e-10. For that, Axioma gives you a format
string.
printf (“print formatted”) takes a format string
followed by the values to drop into it. The format string contains
slots — a % followed by a letter — and
each slot is filled, in order, by one of the values:
axioma> printf("%d items cost $%.2f\n", 3, 7.5)
3 items cost $7.50
The common slots:
| Slot | Fills with | Example | Prints |
|---|---|---|---|
%d |
an integer | printf("%d", 42) |
42 |
%f |
a float | printf("%f", 1.5) |
1.500000 |
%.2f |
a float, 2 decimal places | printf("%.2f", 1.5) |
1.50 |
%s |
a string | printf("%s", "hi") |
hi |
%t |
a boolean | printf("%t", true) |
true |
%% |
a literal percent sign | printf("100%%") |
100% |
Two things to remember. First, printf does
not add a newline of its own — write \n in
the format string wherever you want a line to end. Second,
%.Nf lets you ask for exactly N digits after
the decimal point. That is the answer to the puzzle from the last
section:
axioma> printf("%.11f\n", 3.4e-10)
0.00000000034
The value never changed — 3.4e-10 and
0.00000000034 are the same float. printf just
let us choose the long spelling. You can also pad a number to a fixed
width, which is handy for lining up columns: %05d prints an
integer in 5 columns, padded with zeros (00042).
stringf (“string formatted”) takes the same
format strings and the same slots, but instead of printing, it
hands the finished text back to you as a value:
label: stringf("item-%d", 7) # label is now the string "item-7"
println(label + "!") # prints: item-7!Use printf when you just want to show something on
screen; use stringf when you want to keep the
formatted text — to store it in a variable, join it with another string,
or return it from a function. They are the same tool, differing only in
where the result goes.
If you come from Lua, Ruby, Java, or C#, the name you already know
for this is format — and Axioma accepts it as an exact
alias of stringf: format("item-%d", 7) also
returns "item-7" (it never prints, just like
string.format in Lua). Printing stays
printf.
Axioma has the same operators as your calculator, with the same symbols:
| Symbol | Meaning | Example |
|---|---|---|
+ |
addition | 2 + 3 = 5 |
- |
subtraction | 10 - 4 = 6 |
* |
multiplication | 7 * 6 = 42 |
/ |
division | 12 / 3 = 4 |
Plus two that calculators don’t always have:
| Symbol | Meaning | Example |
|---|---|---|
% |
modulo — the remainder after division | 7 % 2 = 1 |
^ |
exponent — raise to a power | 2 ^ 10 = 1024 |
Try each of these in the REPL:
axioma> 7 % 2
1
axioma> 7 % 3
1
axioma> 100 % 7
2
axioma> 2 ^ 10
1024
axioma> 10 ^ 6
1000000
Modulo is how you check whether a number is even
(n % 2 == 0) or how you wrap an hour around the clock
(hour % 24). We’ll use it a lot.
Mathematics gives multiplication and division priority over addition and subtraction. Axioma agrees:
axioma> 2 + 3 * 4
14
axioma> (2 + 3) * 4
20
In the first line, 3 * 4 happens first, giving
2 + 12 = 14. In the second, the parentheses force the
addition first, giving 5 * 4 = 20.
The full precedence (high → low, paraphrased from the manual):
^ (exponent)- (negation)*, /, %+, -When in doubt, parenthesize.
Associativity. Like most math textbooks, Axioma treats the exponent as right-associative:
2^3^2means2^(3^2) = 512, not(2^3)^2 = 64. Every other arithmetic operator (+,-,*,/) is left-associative, as usual. If you want the left grouping, write(2 ^ 3) ^ 2explicitly. This appears in Exercise 1.3.Negation vs. exponent. Unary
-binds looser than^, so-2^2means-(2^2) = -4, not(-2)^2 = 4— again the math-textbook reading. To square a negative base, parenthesize:(-2)^2.
Here’s the first thing that might surprise you:
axioma> 7 / 2
7/2
7 / 2 should be 3.5, right? Axioma is
telling us 7/2. What happened?
When both operands are integers, Axioma keeps division exact: the answer is a rational number — a true fraction, not a rounded decimal. When the division comes out even, you get a plain integer back; when it doesn’t, you get the exact fraction:
axioma> 6 / 2
3
axioma> 7 / 2
7/2
axioma> type(7 / 2)
Rational
Addition, subtraction, multiplication, and division of rational numbers remain exact when all operands are exact:
axioma> 1/3 + 1/6
1/2
axioma> 7/2 + 1/2
4
axioma> 7/2 == 3.5
true
This is the same design choice HtDP’s teaching languages
make: Racket’s (/ 7 2) is the exact rational
7/2 too. Exact answers stay exact until you decide
to leave the world of fractions. When you want the decimal, bring a
float into the expression — or convert at the end:
axioma> 7.0 / 2
3.5
axioma> 7 / 2.0
3.5
axioma> float(7 / 2)
3.5
For these arithmetic operations, mixing a Float with an Integer or
Rational chooses approximate floating-point arithmetic. Other operations
have their own result rules: for example, sqrt(2) produces
a Float even though its input is an Integer.
fdivSometimes the required output type is part of the task: you need a
floating-point result even when both inputs are exact integers. Use
fdiv, either as a function or between its operands:
println(fdiv(1, 8)) # 0.125
println(1 fdiv 8) # 0.125
println(type(fdiv(1, 8))) # Float
println(1 / 8) # 1/8The ordinary slash keeps an exact integer or rational quotient when
its operands are exact. fdiv explicitly chooses a Float. A
zero divisor raises an error in either form. The interpreter supports
fdiv; VM support is deferred and unsupported uses
refuse.
div and ÷To find how many whole groups fit, use div. Its glyph
spelling is ÷:
println(7 div 2) # 3
println(7 ÷ 2) # 3
println(7.0 div 2.0) # 3.0
println(-7 div 3) # -3
println(-7.0 div 3.0) # -3.0Both spellings round the quotient down, toward
negative infinity. For example, negative seven divided by three is
between -3 and -2; its floor is -3. Integer inputs give an Integer;
Float inputs give a whole-valued Float, which is why the third result
prints as 3.0.
// starts a line comment in Axioma. It
is not an operator: 7 // 2 evaluates the expression
7 and ignores the rest of that line. Use div
or ÷ when you mean floor division.
% and modYou met % (modulo) in §1.3. It also has a keyword
spelling, mod:
axioma> 100 % 7
2
axioma> 100 mod 7
2
axioma> 100 modulo 7
2
All three give 2. modulo is just a longer
alias for mod — it exists because some people find the
longhand more readable.
The pair div (or ÷) and % (or
mod) are related by a useful identity: for any integers
a and nonzero b,
(a div b) * b + (a % b) == a
That is, quotient times divisor plus remainder equals dividend — the same fact you learned in grade-school long division. Try it:
axioma> (100 div 7) * 7 + (100 % 7)
100
divmod, quotient,
remainderSometimes you want both the quotient and the remainder. You could
write 100 div 7 and 100 % 7 separately, but
that does the division twice. Axioma gives you a combined builtin:
axioma> divmod(100, 7)
(14, 2)
divmod returns a tuple — a pair of
values bundled together. You can pull them apart with index access
([1] is the first item, [2] is the
second):
axioma> r: divmod(100, 7)
axioma> r[1]
14
axioma> r[2]
2
If you only want one half, the prefix forms quotient and
remainder exist and read as English:
axioma> quotient(100, 7)
14
axioma> remainder(100, 7)
2
These are exact synonyms for 100 div 7 and
100 % 7 respectively — pick the form that reads best in the
sentence you’re writing.
An exact input does not make every operation exact. Rational
arithmetic such as 1/3 + 1/6 keeps a fraction; a
transcendental function such as sin returns an
approximation. Complex numbers extend the numeric domain:
im names the imaginary unit, and multiplication remains
explicit.
println(1/3 + 1/6) # 1/2
println(sin(1/2)) # 0.479425538604203
println((1 + im)^2) # 2.0 * im
println((1 + im)^2 is Complex) # trueThe printed 2.0 * im is valid Axioma syntax. It records
the Float component used by the complex calculation; it is not a new
kind of imaginary unit. These examples distinguish three questions: what
value was computed, what type represents it, and whether the result is
exact. For a first reading, ordinary integers and fractions are enough;
return here when a problem actually needs complex arithmetic.
Try this in the REPL:
axioma> "hello" / 2
ERROR at <stdin>:1:9: unknown operator: STRING / INTEGER
Axioma stopped you. The reason: division isn’t defined between a
string (the quoted text "hello") and an integer
(2). Axioma calls this an error because there’s no
sensible answer.
The error identifies an operation that is not defined for these inputs. Check the values and the operation before changing the program.
A subtle warning. Axioma is permissive about some mixed-type arithmetic.
"hello" + 1produces"hello1"(it converts the integer to a string and concatenates).2 * "hi"produces"hihi"(repetition, Python-style). These are not errors — they’re features. Division is the one that has no string interpretation, so it’s the one that errors. We’ll see more of this in Chapter 2.
You probably know the formula: a circle’s area is π times the radius squared. Let’s compute the area of a circle with radius 5.
Step 1: write down what you know. Radius = 5. π ≈ 3.14159.
Step 2: translate to Axioma.
axioma> 3.14159 * 5 ^ 2
78.53975
That’s it. Axioma evaluated 5 ^ 2 first (= 25), then
multiplied by 3.14159, giving 78.53975. About
78.5 square units.
A small refinement. If you want a more precise answer, use more digits of π:
axioma> 3.141592653589793 * 5 ^ 2
78.53981633974483
A peek ahead. Typing π’s digits every time is tedious. In Chapter 2 you’ll learn to give names to values (
pi: 3.14159) so you can writepi * 5 ^ 2instead. In Chapter 3 you’ll learn to package the whole computation into a function (circle_area(5)).
Try each of these. After each, predict what Axioma will say before running it; then check. The predict-then-check habit is the most important habit you’ll build this term.
Compute the circumference of a circle with radius 7 (the formula is 2 × π × r). Use 3.14159 for π.
What does Axioma give you for 2 ^ 16? What about
2 ^ 32? What about 2 ^ 64? Try them and see
when (if ever) Axioma stops giving you exact answers.
Predict each answer before typing it:
(2 + 3) * (4 - 1)10 - 2 * 32 ^ 3 ^ 2 (this one is sneaky)(2 ^ 3) ^ 2100 / 7100 % 7100.0 / 7100 div 7100 fdiv 7100 mod 7100.0 div 7.0-7.0 div 3.0 (this one is also sneaky — recall the
floor rule)Modulo trick. For each of these, predict whether the answer is
0, and check (% and mod are
interchangeable — try both spellings):
12 % 312 mod 412 % 52 ^ 10 mod 7What do all the zero results have in common with
12?
Type-error exploration. Without looking at section 1.6, try to predict which of these will error and which won’t:
"5" + 35 + "3"5 - "3"5 * "3"5 / "3""hi" * 4"hi" / 4"hi" - "bye"What rule (if any) can you guess about when Axioma errors and when it just gives you something?
Find an expression that uses all four arithmetic
operators (+, -, *,
/) AND modulo (%) AND an exponent
(^), evaluates without error, and produces an answer
between 0 and 100. There are many possible
answers — find one of yours.
(After you find one, find a second one that’s structurally different from the first.)
Why did we start with arithmetic? Two reasons.
One, every computer program — every spreadsheet formula, every video-game scoreboard, every weather model — is at its core calculation on data. Arithmetic is the smallest possible example of that. If you understand it, you understand the seed from which the rest grows.
Two, arithmetic lets us meet the REPL — the read-evaluate-print loop — without anything else getting in the way. The REPL is where you’ll do most of your exploration in this book. Every chapter starts in the REPL; only after you’ve explored an idea interactively do we move to writing files.
In the next chapter we’ll learn to name values you’ve computed, so a long expression becomes a short name and your work compounds.
Looking ahead — what you’ll be able to do by Chapter 5:
- Define your own functions (
func)- Make decisions with
if … then … else …- Use the Design Recipe — the six-step process that turns “I need a program for X” into actual working code
By Chapter 9 you’ll have built a small interpreter for arithmetic expressions, written in Axioma. The arithmetic you typed in Chapter 1 will literally be the data your Chapter 9 program processes.
A one-line index for future lookup (the full reference is in the manual):
axioma or
axioma --language axioma/beginner42, -7,
10000003.14159, 1.5e6+, -, *,
/, %, ^, div (or
÷), fdiv, mod (or
modulo, both alias %)quotient(a, b),
remainder(a, b), divmod(a, b) → (q, r)( )printf(fmt, ...) and
stringf(fmt, ...) (alias format) with
%d / %f / %.Nf / %s
/ %t / %% slotsERROR at <stdin>:line:col: …Solutions to selected exercises: Chapter 1 · Solutions in Appendix C. (Repo file: exercises/solutions/ch01_solutions.md.)
What this chapter is. Naming values. Once you can give a number (or a string, or any value) a name, you stop retyping it and start thinking with it.
Chapter 1 ended with the area of a circle:
axioma> 3.141592653589793 * 5 ^ 2
78.53981633974483
That works, but two things bother us. First, the digits of π are
tedious to retype. Second, the 5 is the radius —
but nothing in the expression says that. A reader has to guess.
Both problems disappear when we name things:
axioma> pi: 3.141592653589793
axioma> radius: 5
axioma> pi * radius ^ 2
78.53981633974483
The expression now reads aloud the way we’d say it: pi times radius squared. Names are how programs become explanations.
:The declaration form is:
<name>: <expression>
Three things happen when Axioma sees this:
axioma> x: 5
axioma> x
5
axioma> x + 3
8
axioma> y: x * 2
axioma> y
10
You can use names inside other declarations — that’s how
y: x * 2 works. Axioma had already bound x to
5, so the right-hand side reduced to
5 * 2 = 10 before binding y.
This name: value shape — read aloud as “name
gets value” — is the preferred Axioma idiom. It’s short, it
scans well, and it puts the name first (which is what your eye looks
for). You’ll see it everywhere in this book.
radius, Radius,
and RADIUS are three different variables.:
and = accept a name of any case — the right-hand side
decides (a concept { ... } RHS makes a Concept; any other
value makes a variable, so B = {2,4,6} is a perfectly good
set). We keep the lowercase-for-values style throughout the book for
readability.These names are fine: pi, radius,
total_so_far, n, is_done. These
will error or surprise you: 2pi (starts with a digit),
total-so-far (the - is the subtraction
operator).
So far we’ve been typing one line at a time at the REPL. That’s great for exploring, but a real program is more than one line. You’ll want to put your code in a file and run it from your shell.
Create a file called hello.ax:
#language axioma/beginner
pi: 3.141592653589793
radius: 5
area: pi * radius ^ 2
println("Area for radius", radius, "=", area)Run it:
$ axioma hello.ax
Area for radius 5 = 78.53981633974483Three new things appeared:
#language axioma/beginner at the top
of the file. This pins the file to the beginner subset. Like
--language axioma/beginner from Chapter 1, but written
inside the file so anyone running it gets the same
restrictions.println is a built-in that prints its
arguments separated by spaces, then ends the line. Spaces inside a
string remain part of that string. We use it because a script doesn’t
auto-print results the way the REPL does."hello",
"naïve", and "∀ x. P(x)" all just work. For
the few characters that resist direct typing — line breaks, tabs, or
rare codepoints — Axioma uses the universal escape forms
"\n" (newline), "\t" (tab), "\\"
(literal backslash), and "\u{2203}" (the Unicode codepoint
U+2203, which is ∃). The full table lives in Appendix A. We won’t need them in this chapter.Everything else is what you already know.
What happens if you bind the same name twice?
axioma> x: 5
axioma> x: 10
axioma> x
10
Axioma lets it happen — silently. x: value means “bind
x to this value”: it creates the name if it’s new and
updates it if it already exists. There is no separate declaration step,
and no warning. The math-style spelling does exactly the same thing:
axioma> x: 5
axioma> x = 10
axioma> x
10
x = 10 is a synonym of
x: 10 — the bind-or-update behavior is identical. It’s
there so a line that reads like an update (or like textbook
mathematics, B = {2, 4, 6}) can be written that way.
Because rebinding is silent, the discipline has to live in
you, not in the interpreter. In the beginner mindset, every
name is bound exactly once and never changes; a name that gets rebound
mid-program is almost always a bug-in-waiting — either it’s really
Chapter 16’s topic (deliberate mutation) arriving early, or it’s a
second value that deserves a second name (total_after_tax,
not a new total). For this chapter and the next several:
one name, one value.
In some languages you choose between several binding keywords —
let pi = 3.14159 in ML, Scheme, or JavaScript;
pi := 3.14159 in Pascal, Ada, or Go; val /
var in Scala or Kotlin. Axioma deliberately ships with
one canonical value form:
pi: 3.14159Three reasons:
:).radius :: Integer: 5 reads naturally as “radius (of type
Integer) gets 5.” We don’t use type annotations in Part I, but they show
up in Part VI.The textbook spelling pi = 3.14159 is the
same operator (find-or-update), kept so math examples
can read unaltered. There is no separate “assignment only”
:=, and no val — the immutable binding is
let NAME = … itself, read exactly as in a proof: it
declares a fresh binding that shadows an outer name, and the
name then stays fixed. Its mutable twin is var NAME = …,
and a program-wide constant is const NAME = … — tools for
later chapters, not for Part I.
Part I only needs name: value. The full model shows up
when you write functions, loops, and closures. One table (full detail:
Axioma Manual §3, “Binding model at a glance”):
| Kind | Spelling | Idea |
|---|---|---|
| Value bind / update | x: 5 or x = 5 |
Find-or-update in this function frame |
| Function definition | f(x) = … |
Parenthesized head → definition, not a bare value bind |
| Binder | foreach x in …, parameters |
Fresh name; shadows outer x |
| Fresh declaration, immutable | let x = 5 |
A new binding that shadows an outer x;
never updates, and then stays fixed (the let of a
proof) |
| Fresh declaration, mutable | var x = 5 (let mut /
let mutable) |
let minus the fixedness — a fresh binding you may keep
writing |
| Typed hole | let x :: Integer = _ |
Declare now, fill once; reading early errors (Ch.16). A refillable
hole is var x :: Integer = _ |
| Identity empty | let xs :: Array = default |
Start as empty container — not for Integer zeros (Ch.16) |
| Outer write | rebind x: … |
Update a name that lives outside this frame (nearest cell; never declares) |
| Module write | global x = … |
Julia’s file / module body write; may declare; honors
:: |
| Immutable name | const LIMIT = 10 |
A program-level, top-level-only commitment: cannot reassign or shadow the name |
So: beginners learn one form (:). The
keywords mark directions the default doesn’t take — let
fixes fresh, var varies fresh, rebind writes
the nearest outer cell, global writes this file (Julia) —
(Chapter 16 / the manual) — and := stays gone. The
_ / default forms are not
needed until you deliberately leave a name unfilled or start a container
empty; Chapter 16 explains why Integer is not silently
0.
Old code may use other forms. Earlier versions of Axioma accepted
let pi = 3.14159andpi := 3.14159as synonyms of the canonical bind; both were retired in May 2026.:=stays a syntax error with a hint pointing atx: value. Theletspelling returned in August 2026 with different semantics:let pi = 3.14now parses, but it declares a fresh binding (shadowing any outerpi) rather than updating one. For a top-level constant in old example code the result is the same value; inside functions and loops the difference is real — see the fresh-declaration row above and the manual’s §3.The fix is mechanical: replace
let X = VwithX: V, and replaceX := VwithX: V.
For the rest of this book we use name: value
almost exclusively. The = spelling appears in
math-style lines and in later chapters; treat it as the same bind/update
as :, not as a second language.
Persistence (advanced — skip on first read). If you want a variable to survive between REPL sessions or between script runs — useful when you’re slowly building up a knowledge base — the canonical form for that is
declare/persist x = 42(using=, not:). This is the one place where:doesn’t suffice, because the REBOL:binder deliberately has no refinement slot. Most code never needs persistence, so don’t worry about it now — Part VI covers it.
Say we have a circle of radius 5. We want to compute:
2 * radius),2 * pi * radius),pi * radius * radius),Without names, that’s four sprawling expressions. With names, it’s clean:
#language axioma/beginner
pi: 3.14159
radius: 5
diameter: 2 * radius
circumference: 2 * pi * radius
area: pi * radius * radius
println("radius =", radius)
println("diameter =", diameter)
println("circumference =", circumference)
println("area =", area)Run it. You get:
radius = 5
diameter = 10
circumference = 31.4159
area = 78.53975
Why not just
r: 5? It works — but silently steps on a built-in. Axioma starts every session withralready bound to a module (the bridge to the R statistical language), andr: 5simply shadows it: no warning, and the module is unreachable for the rest of your session. That’s the flip side of §2.4’s silent rebinding — nothing protects an existing name, not even a built-in, from being rebound. Habit to build: pick names that say what they mean (radius) rather than single-letter abbreviations. The full word also reads better when someone else (or future-you) opens the file.
Notice that we computed area using a name
(pi) that we’d computed earlier. Names
compose. This is the seed of the next chapter, where we’ll
learn to compose entire computations.
A whole-valued Float keeps a decimal marker when printed. The values
32 and 32.0 compare equal numerically, but
their types differ:
println(32) # 32
println(32.0) # 32.0
println(type(32)) # Integer
println(type(32.0)) # Float
println(32 == 32.0) # true
println(32.0 / 1.0) # 32.0Use type(value) when you want to inspect the type
directly. Adding a fraction does not prove what the original type was:
an Integer can also participate in a calculation whose result is a
Float.
Float output keeps enough significant digits to recover the stored
value when read back. It is not necessarily the short decimal you would
write by hand. To present a rounded amount, use Chapter 1’s
printf or stringf; formatting the output does
not change the stored value.
Use formatting when the presentation needs rounding. It does
not change the stored number. Ordinary strings interpolate
${expression}; an f-prefixed string uses
{expression} and optional format specifications.
price: 12.345
customer: "Ada"
println(f"Total: {price:.2f}") # Total: 12.35
println(f"{42:05d}") # 00042
println(f"{{{customer}}}") # {Ada}
println(rf"C:\reports\{customer}") # C:\reports\Ada
println(price) # 12.345The r in rf preserves backslashes; the
f still evaluates fields. Double braces produce literal
braces. Expressions in fields run once, from left to right. Formatting
is Axioma syntax, not a call into Python; not every Python format
extension is accepted. When a colon is part of the expression itself,
put that expression in parentheses.
Axioma’s Time follows a duration-like convention. Hours
can exceed 23, overflow carries into the next unit, and negative values
are useful. It is not a validator for a clock face. Two integer fields
mean hours and minutes; three fields include seconds. The special
two-field form with a fractional second field means minutes and
seconds.
println(23:61) # 24:01
println(23:59:61) # 24:00:01
println(-1:30) # -1:30
println(1:02.5) # 0:01:02.5
println(1:30 - 0:40) # 0:50
println(1:00 * 2.5) # 2:30
println(0:01 / 0:03) # 1/3
println(float(1:30)) # 5400.0Adding a number to a Time adds seconds. A ratio of two Times is
numeric; dividing a Time by a number produces a Time. Storage has
nanosecond resolution, so this is not arbitrary-precision fractional
time. time(seconds) constructs a Time explicitly. The
separate datetime package offers DateTime and
Duration; a Duration converts back with
time(duration_value). Calendar dates and time zones are
separate questions from this relative-time arithmetic.
Set r: 7 and compute the four quantities in §2.6:
diameter, circumference, area, and the area of a circle one unit larger
(radius 8). Print each with a clear label.
Distance between two points. Bind x1: 0,
y1: 0, x2: 3, y2: 4. The
Pythagorean theorem says the distance is
sqrt((x2-x1)^2 + (y2-y1)^2). Compute it.
Hint. Axioma has a built-in
sqrt. Use it:sqrt(25)returns the Float5.0.
Predict first. The points
(0,0)and(3,4)form a famous right triangle. What should the answer be?
Body Mass Index (BMI). BMI = weight / height², where
weight is in kg and height is in meters. Compute the BMI of someone who
weighs 70 kg and is 1.75 m tall. The answer should be about 22.86.
Hint. You can write the division in one line (
bmi: weight / (height * height)), or give the denominator a name first — the named version reads better:height_squared: height * height bmi: weight / height_squared
Predict, then check. What does each print? (You’ll need to think about types — see §2.7.)
a: 7
b: 2
println(a / b)
println(a + 0.0)
println((a + 0.0) / b)Silent rebinding. Type this at the REPL exactly:
axioma> n: 1
axioma> n: n + 1
What does n end up being? Predict before
checking. Axioma does this without any warning — why might that be
dangerous in a long program?
Pick a real-world calculation you’ve done in the last week — a tip on
a restaurant bill, the area of a wall you want to paint, the time
remaining on a charging laptop. Write it as a short script with
well-named bindings, ending in a println of the answer.
We did two things this chapter.
One, we gave values names. Names let us reuse work
(the pi we bound once is the pi we used three
times) and they let us say what we mean (the
radius is named radius, not
5).
Two, we moved from one-line REPL exploration to multi-line script files. The shape of all your programs from here on is the same: declare some inputs at the top, declare derived quantities below that, print the answer at the end.
In Chapter 3 we’ll take the next step: when a calculation is shaped like a recipe — input goes in, answer comes out — we’ll learn how to package the recipe into a function, so we can run it on many inputs without rewriting it.
Looking ahead — what you’ll be able to do by Chapter 5:
- Define your own functions (
func)- Make decisions with
if … then … else …- Apply the Design Recipe to turn any problem statement into working code
<name>: <expr> — declaration (the single
canonical form)<name> = <new value> — assignment (we’ll
come back to this in Ch.16)declare/persist <name> = <expr> —
declaration that survives between sessions (advanced; covered in Part
VI)println built-in"text in double quotes"sqrt(x) — square root (also: abs,
min, max, in the manual)#language axioma/beginner pragma at the top of a
filex: v and x = v both
create-or-update a nameSolutions to selected exercises: Chapter 2 · Solutions in Appendix C. (Repo file: exercises/solutions/ch02_solutions.md.)
What this chapter is. When a computation reads “input goes in, answer comes out”, that computation is a function. Naming a function is even more powerful than naming a value: a value works once, but a function works on any input you give it.
Chapter 2 taught us to name values. But look at this script:
#language axioma/beginner
pi: 3.14159
radius_a: 5
area_a: pi * radius_a * radius_a
radius_b: 7
area_b: pi * radius_b * radius_b
radius_c: 12
area_c: pi * radius_c * radius_cWe computed the area of three circles, and we copy-pasted the formula three times. That’s a clear sign we should name the formula itself. Then we can use the name three times and write the formula exactly once.
The thing we want to name is a recipe: “given a radius, compute the area”. In Axioma, that’s a function.
The shape:
<name>: func(<parameter>) [ <body> ]
Read it left-to-right: “name gets the function that
takes parameter and computes body.”
#language axioma/beginner
pi: 3.14159
area: func(radius) [pi * radius * radius]
println(area(5))
println(area(7))
println(area(12))Output:
78.53975
153.93791
452.38896
We defined area once. We called it
three times. Each call passes a different radius. The
function does the same thing in every case — it multiplies
pi by that radius squared.
func(radius) [pi * radius * radius]func — keyword saying “what follows is a function
definition”.(radius) — the parameter list. This one has a single
parameter named radius. The parameter is a
placeholder — it gets bound to whatever value is passed
in.[pi * radius * radius] — the body,
between square brackets. The body is an expression; its value is what
the function returns.When you write area(5): 1. Axioma evaluates
5. 2. It binds radius to 5 inside
the function’s body. 3. It evaluates the body —
pi * 5 * 5 = 78.53975. 4. That value is the result of the
call.
The radius binding only exists inside the body;
once the function returns, it’s gone. Functions have their own little
namespace.
Most useful functions take more than one input. The parameter list is comma-separated:
#language axioma/beginner
distance: func(x1, y1, x2, y2) [
dx: x2 - x1
dy: y2 - y1
sqrt(dx ^ 2 + dy ^ 2)
]
println(distance(0, 0, 3, 4))
println(distance(1, 1, 4, 5))Output:
5.0
5.0
Two things to notice.
One, the body is now several lines. When a
body has multiple statements, only the value of the last one is
the return value. The intermediate dx: ... and
dy: ... are setup; the final sqrt(...) is the
answer.
Two, rather than writing
sqrt((x2-x1)^2 + (y2-y1)^2) in one line — which works — we
extract dx and dy as names. Each line does one
thing, and the intermediate quantities get meaningful names. That’s just
better style.
The formula: F = C × 9/5 + 32.
#language axioma/beginner
c_to_f: func(c) [
scaled: c * 9.0 / 5.0
scaled + 32.0
]
println(c_to_f(0))
println(c_to_f(100))
println(c_to_f(-40))
println(c_to_f(37))Output:
32.0
212.0
-40.0
98.6
The c * 9.0 / 5.0 uses 9.0
and 5.0 instead of 9 and
5 so the answer comes back as a decimal. Nothing
would be wrong with c * 9 / 5 — Axioma’s division
is exact, so 100 * 9 / 5 is 180 and
37 * 9 / 5 is the exact rational 333/5 (which
is 66.6) — but a temperature that prints as 333/5
reads like a math worksheet, not a thermometer. A float literal anywhere
in the expression keeps the whole result in decimal-land. Habit: for
measurement-flavored functions, write the fraction as
9.0 / 5.0.
The -40 line is famous — it’s the only temperature at
which Celsius and Fahrenheit agree. Try predicting that before
running.
A function bound to a name is just another value. You can pass it around like any other:
#language axioma/beginner
double: func(x) [x * 2]
triple: func(x) [x * 3]
same_as_double: double
println(same_as_double(7))Output:
14
The line same_as_double: double doesn’t call
double — it just binds another name to the same function.
From then on, both names refer to the same recipe.
This is a small thing in Chapter 3. In Chapter 10 it
becomes the foundation of higher-order functions — functions
that take functions as inputs and return them as outputs. That’s what
unlocks patterns like map and filter.
Looking ahead. In the full Axioma language there’s also a shorter syntax for one-line functions:
double: lambda x => x * 2The
lambdaform is disabled in beginner mode. We’ll unlock it in Chapter 11. For now,func(x) [body]is the only form, and that’s intentional — one tool, one job.
The textbook spelling. There is one more form, and unlike
lambdait does work in beginner mode — because it is simply how mathematics writes a definition:double(x) = 2 * xThat is exactly
double: func(x) [x * 2]— same function, same everything, just written the way you would write it on paper. It also takes the pattern and guard forms you will meet in Chapters 5 and 8:fact(0) = 1 # one clause per case … fact(n) = n * fact(n - 1) # … tried in order sign(x) when x > 0 = 1 # a guard sits before the `=` sign(x) = 0We will keep writing
name: func(args) [body]through this book. It is the form that makes “a function is a value you bind to a name” visible on the page, and that idea is doing real work in §3.5 and again in Chapter 10. Reach for the equation form when you are transcribing mathematics and want the page to match the source. Appendix A has the full rules.
Calling a function is so simple it’s easy to miss. To call
f with arguments a and b, write
f(a, b). The parentheses are required, and they go
immediately after the function’s name (no space — well, spaces
are tolerated by the parser, but the convention is no space).
If you write f without parentheses, you’re not calling
it — you’re referring to the function itself, as a value.
axioma> double: func(x) [x * 2]
axioma> double
func (x) [x * 2]
axioma> double(5)
10
The first double printed the function object
(the recipe). The second printed 10 — the result of
applying the recipe to 5.
The full version is Chapter 4. Here’s the spoiler: every function you write follows the same six steps, and doing them in order is the single biggest skill-builder in this book.
area: Float -> Float)area(5) = 78.53975
before you write the code. Multiple examples.Watch this happen for our Celsius converter:
# Data: Celsius temperature (Float)
# Signature: c_to_f: Float -> Float
# Examples:
# c_to_f(0) = 32
# c_to_f(100) = 212
# c_to_f(-40) = -40
# Template:
# c_to_f: func(c) [
# ... c ... # some expression in c
# ]
# Body:
c_to_f: func(c) [
c * 9.0 / 5.0 + 32.0
]
# Tests:
println(c_to_f(0)) # expect 32
println(c_to_f(100)) # expect 212
println(c_to_f(-40)) # expect -40That’s the recipe applied informally. Chapter 4 makes it the primary way you think about programming. The point isn’t bureaucracy. The point is: most “bugs” are really unfinished thinking, and the recipe forces you to finish the thinking before the code.
Axioma even includes a guided wizard for the recipe. Try it:
$ axioma --recipeIt walks you through the six steps for whichever function you choose.
Write circle_area and call it on radii 1, 5, and 100.
(Use pi = 3.14159.)
Write f_to_c — Fahrenheit to Celsius. The formula is
C = (F - 32) × 5/9. Test it on:
f_to_c(32) should be 0f_to_c(212) should be 100f_to_c(98.6) should be about 37Tip. Same as §3.4: use
5.0/9.0to keep the arithmetic in floats.
Write bmi(weight, height) using the formula from
Exercise 2.3. Then call it on three people:
bmi(70.0, 1.75)bmi(85.0, 1.80)bmi(55.0, 1.65)Write a function tip(bill, percent) that takes a bill
amount and a tip percentage (e.g., 20) and returns the
dollar amount of the tip. Test it on:
tip(50.0, 20) should be 10tip(100.0, 15) should be 15tip(42.50, 18) should be about 7.65Write a function is_even(n) that returns
true if n is even and false
otherwise. (Hint: n % 2 == 0.) Test it on a few integers.
This is a sneak preview of Chapter 5.
Pick a calculation you do often (or imagine you might): converting a recipe between metric and imperial units, computing your monthly salary from an hourly rate, computing how many hours of streaming you can do per day on a metered internet plan. Whatever it is, package it as a function with sensible parameter names, and call it twice with different inputs.
A default belongs to the function’s interface: callers may omit that
argument. Both the equation form and func can express
defaults.
scale_number(x, factor = 2) = x * factor
println(scale_number(3)) # 6
println(scale_number(3, 4)) # 12
println(scale_number(3, factor: 5)) # 15Choose a default that makes sense for the problem, and test both
omission and an explicit value. A named argument uses
name: value at the call.
After Chapter 6 introduces arrays, a collection can be placed before a dot. For a user function, the receiver becomes the first argument and all supplied arguments participate in one call, including overload choice.
xs: [1, 2, 3]
scale_values(values, factor = 2) = values .* factor
println(xs.scale_values(3)) # [3, 6, 9]
println(scale_values(xs, 3)) # [3, 6, 9]
println(xs.length) # 3
println(xs.length()) # 3This is a call rule, not a stored method added to each array.
Built-in collection methods have a receiver-position table:
xs.map(f) means map(f, xs), while a user
scale_values receives xs first. Existing
properties take precedence; a callable field keeps its own call
behavior. To call a function returned by another call, make both calls
explicit: maker(xs)(argument). A dot is not a request for
broadcasting: that is the separate f.(xs) form taught in
Chapter 10.
A refinement selects an optional behavior explicitly. This example uses the full language and can be saved as a separate file:
combine: func(a, b, /times amount) [
if times then (a + b) * amount else a + b
]
println(combine(1, 2)) # 3
println(combine/times(1, 2, amount: 10)) # 30The declaration names the switch and its argument; an arbitrary suffix does not invent a behavior. User refinements remain an interpreter feature; the VM refuses unsupported refinement calls.
A function bundles a recipe. We learned three things:
One, the syntax:
name: func(params) [body]. The body is an expression; its
value is what the function returns.
Two, multi-parameter functions; multi-line bodies; the fact that the last line of a multi-line body is the return value.
Three, functions are values — they can be bound to names just like numbers and strings. (We didn’t use this much yet. Chapter 10 will return to it.)
In Chapter 4 we’ll slow down and learn the Design Recipe — the six-step process that turns “I need a function for X” into actual working code, every time.
Looking ahead — what you’ll be able to do by Chapter 5:
- Apply the Design Recipe end-to-end
- Make decisions inside a function with
if … then … else …- Build small functions that combine arithmetic and conditional logic
<name>: func(<params>) [ <body> ] —
function definition<name>(<args>) — function callsqrt(x) — square root from the standard libraryaxioma --recipe — the interactive Design Recipe
wizardSolutions to selected exercises: Chapter 3 · Solutions in Appendix C. (Repo file: exercises/solutions/ch03_solutions.md.)
What this chapter is. The single most important idea in this book. Not a feature of Axioma — a habit — that turns “I need a program for X” into a tested implementation. Six steps. Do them in order.
Most beginners — and a lot of professionals — sit down at a blank screen and start typing the body of a function. They have a vague sense of what it should do. They guess at the body. They run it. It fails. They edit. They run again. They debug. They edit. Eventually something works, sort of.
This is exhausting, and the resulting code is hard to trust. Most “bugs” are really unfinished thinking — the programmer never quite sat down and said exactly what the function takes and exactly what it returns. The recipe forces you to finish the thinking before you write the body.
Six steps. Each one builds on the previous one. With practice the sequence becomes a habit. It will not eliminate errors; it gives you a way to locate them and revise your design.
The six steps:
# name: InputType -> OutputType.name(input) = output lines, before writing any
code.Stages 1–3 happen before you write any code. Stage 4 is mechanical. Stage 5 is “the actual work” — which is now small, because every other stage has prepared you. Stage 6 confirms.
We’ll work the recipe for f_to_c (Fahrenheit to Celsius)
from Chapter 3 — but this time, every step is written down.
A Celsius (or Fahrenheit) temperature is a Float. Possibly negative (it gets cold).
That’s it. One sentence. Sometimes the data definition is more complex (a list of integers, a tree of strings), but for now it’s a single primitive type.
Write the type of the function as a comment, in the form
# fname: InputType -> OutputType
# f_to_c: Float -> FloatFor multi-parameter functions, the input types are space-separated:
# distance: Float Float Float Float -> FloatThe arrow -> is the universal “produces” symbol —
input goes on the left, output on the right. It’s borrowed from
mathematicians’ notation for functions (f : ℝ → ℝ).
Write concrete input/output pairs. Two minimum. Three is better. Pick:
# Examples:
# f_to_c(32) = 0 (freezing point — easy case)
# f_to_c(212) = 100 (boiling point — easy case)
# f_to_c(98.6) = 37 (body temperature — representative)
# f_to_c(-40) = -40 (the famous fixed point — edge)Notice: we wrote the expected answer for each one. Where did
the answers come from? From the formula C = (F - 32) × 5/9,
computed by hand or in our head. The point is to commit to what the
answer should be — so when the function is wrong, we’ll catch
it.
Why this matters. “Examples first” looks like extra work. It isn’t. It saves time three times over. First, writing examples forces you to clarify what the function actually does — many vague specifications collapse here. Second, the examples become your tests in stage 6. Third, the worked examples often suggest the formula. If you couldn’t compute
f_to_c(32) = 0by hand, you probably don’t yet know the formula well enough to write it as code.
The template is the shape of the body, determined by the shape of the data. For a function over a single primitive, the template is boring:
# Template:
# f_to_c: func(f) [
# ... f ...
# ]That’s it. The body uses f somehow.
This stage feels silly for primitives. Don’t skip it anyway. The template will save your life in Chapter 7, where the data is a list and the template has two cases (empty / non-empty), and in Chapter 8 where the data is a tree.
For now, the template just says “the body is an expression in
f.”
Now — and only now — write the actual code.
f_to_c: func(f) [
(f - 32) * 5.0 / 9.0
]We wrote 5.0/9.0 (not 5/9) for float
division, as in Chapter 3.
Run the examples and check they match.
println(f_to_c(32)) # 0.0
println(f_to_c(212)) # 100.0
println(f_to_c(98.6)) # 37.0
println(f_to_c(-40)) # -40.0Run the file. All four match. Done.
The full file, in order, looks like:
#language axioma/beginner
# Data: a Celsius or Fahrenheit temperature is a Float.
# Signature: f_to_c: Float -> Float
# Examples:
# f_to_c(32) = 0
# f_to_c(212) = 100
# f_to_c(98.6) = 37
# f_to_c(-40) = -40
# Template:
# f_to_c: func(f) [
# ... f ...
# ]
# Body:
f_to_c: func(f) [
(f - 32) * 5.0 / 9.0
]
# Tests:
println(f_to_c(32)) # 0.0
println(f_to_c(212)) # 100.0
println(f_to_c(98.6)) # 37.0
println(f_to_c(-40)) # -40.0This is the shape of every function file from here on. The comments aren’t decoration — they’re the recipe, written down, so a future reader can see your thinking. They also help you when you return to your own code a week later and have no idea what you were trying to do.
Notice what Stage 6 made us do: re-type each Stage-3 example as a
println and eyeball the output. Axioma can do the
checking for you. Inside a /**md … */ documentation block
(or any .md file), open a fence with the info string
axioma doctest, and every line of the form
expr # → value becomes a checked
assertion — the examples are the tests, in the exact
shape you wrote them in Stage 3:
f_to_c: func(f) [ (f - 32) * 5.0 / 9.0 ]
f_to_c(32) # → 0.0
f_to_c(212) # -> 100.0
f_to_c(98.6) # → 37.0
f_to_c(-40) # -> -40.0
f_to_c("cold") # raises: unknown operatorTwo spellings of the arrow are equivalent: the glyph # →
(the convention used throughout this book) and the plain-keyboard ASCII
# -> — both lines above are checked the same way. A line
expr # raises: substr asserts that the expression
errors with a message containing substr, and any
other line (like the f_to_c definition) is setup, run for
effect. Run them with:
$ axioma --doctest myfile.ax # ok: / not ok: per example, exit 1 on any missor the Playground’s ✓ Doctest button. If a doctest
fails, the recipe’s diagnosis applies unchanged: the bug is in the body,
the template, or the data definition — and now a machine tells you
which example broke instead of you re-reading
println output.
Axioma ships with an interactive wizard that walks you through the six stages. Try it:
$ axioma --recipeIt asks for your function name, then offers prompts for each stage in order. At each stage you type your answer; the wizard saves it and moves on. At the end it writes a starter file with everything in place — you only have to fill in the body.
The wizard is especially helpful when you’re stuck. If you can’t get past stage 3 (examples), you don’t understand the problem yet. Walk away, think, come back. The recipe is also a diagnostic.
There are also --recipe show to list the five worked
recipes built into the wizard, and
--recipe replay <name> to step through one
end-to-end. The five built-in recipes (chosen to match this book)
are:
circle_area — single-parameter arithmeticf_to_c — domain-specific formulabmi — multi-parameter arithmetic with intermediate
namesis_even — Boolean-returning function (preview of
Chapter 5)tip — money mathUse them as crib sheets. The wizard never writes the body for you — it writes the scaffolding and asks you to finish.
tip_totalLet’s design a function that takes a bill amount and a tip percentage and returns the total you owe (bill + tip).
A bill amount and a tip percentage are both Floats. The result is a Float (the total to pay).
# tip_total: Float Float -> FloatPick three cases:
# Examples:
# tip_total(100.0, 0) = 100 (zero tip — edge)
# tip_total(100.0, 20) = 120 (clean numbers — easy)
# tip_total(42.50, 18) = 50.15 (realistic — representative)We computed
42.50 + 42.50 * 18 / 100 = 42.50 + 7.65 = 50.15 by
hand.
# Template:
# tip_total: func(bill, percent) [
# ... bill ... percent ...
# ]The body uses both parameters somehow.
tip_total: func(bill, percent) [
tip: bill * percent / 100
bill + tip
]println(tip_total(100.0, 0)) # expect 100
println(tip_total(100.0, 20)) # expect 120
println(tip_total(42.50, 18)) # expect 50.15Run it. All three match. Done.
Notice the pattern. Stage 5 was easy because stages 1–3 already told us what the function had to do. The body has zero guesswork left in it. That’s the payoff.
The recipe can fail at any stage. Each kind of failure is informative.
(some primitives) -> (some primitive).For each of these, do all six stages. Don’t skip stages even when they feel obvious. The point is to build the habit.
tripleDesign a function that triples its argument. Inputs are integers.
discountDesign a function discount(price, percent_off) that
returns the discounted price. For example,
discount(100.0, 25) should be 75.
dollars_to_centsDesign a function dollars_to_cents(dollars) that
converts a dollar amount (Float) to integer cents.
dollars_to_cents(1.50) should be 150.
Hint: think about what Axioma’s * does to a Float,
and whether you want the result as an Integer.
rectangle_perimeterDesign a function that computes the perimeter of a rectangle given
its width and height. (Perimeter =
2 * (width + height).)
is_positiveDesign a Boolean-returning function is_positive(n).
Returns true when n > 0, false
otherwise. (This is a preview of Chapter 5.)
Pick a calculation you’ve done in the last week that wasn’t already a function. Apply the recipe to it from scratch — write down all six stages, then run it.
The Design Recipe is the most important habit in this book. Everything that follows — recursion, higher-order functions, mutual recursion, generative recursion — gets easier when you have the recipe, and much harder when you don’t.
What the recipe really gives you is structure for thinking:
By the end of Chapter 9 you’ll have used the recipe on dozens of functions and stopped thinking of it as a chore. By the end of the book, you’ll do it in your head, and you’ll do it faster than the people who insist they “don’t need to plan.”
In Chapter 5 we’ll add conditionals to your vocabulary —
if then else — and we’ll redo the recipe for functions that
have to make decisions.
Looking ahead — what you’ll be able to do by Chapter 5:
- Apply the Design Recipe even when the function has multiple cases (e.g., one answer when
n > 0, another otherwise)- Use Boolean values and Boolean-returning functions as building blocks
# comment for the signature, examples, and
templateaxioma --recipe — the interactive Design Recipe
wizardaxioma --recipe show — list the five worked
recipesaxioma --recipe replay <name> — step through a
built-in recipe end-to-endSolutions to selected exercises: Chapter 4 · Solutions in Appendix C. (Repo file: exercises/solutions/ch04_solutions.md.)
What this chapter is. How a function makes decisions. Up to here, our functions did the same thing for every input. Now we’ll ask them to inspect the input and pick a branch.
true and
falseThere is a type called Boolean (named after the 19th-century logician George Boole). It has exactly two values:
axioma> true
true
axioma> false
false
That’s the whole type. No other values are Booleans. (Some languages
use 1 and 0, or T and
nil. Axioma uses words.)
You already produced Booleans without realizing in Chapters 1 and 3:
axioma> 5 > 3
true
axioma> 5 == 4
false
axioma> 7 % 2 == 0
false
Comparison operators take numbers (or other values) and
return Booleans. The comparison 5 > 3 is asking
“is 5 greater than 3?” — true or
false.
| Operator | Meaning | Example |
|---|---|---|
== |
equal to | 5 == 5 → true |
!= |
not equal to | 5 != 4 → true |
< |
less than | 3 < 5 → true |
> |
greater than | 5 > 3 → true |
<= |
less than or equal | 5 <= 5 → true |
>= |
greater than or equal | 5 >= 6 → false |
The double == is intentional. Single = is
for assignment (as in x: 5); double
== is for comparison. Most beginners accidentally
write = once when they meant ==, get a strange
error, and learn the lesson.
axioma> 7 == 7
true
axioma> 7 != 8
true
axioma> 100 >= 100
true
axioma> 100 > 100
false
Comparisons work on numbers, and == and !=
also work on strings and Booleans:
axioma> "apple" == "apple"
true
axioma> "apple" == "banana"
false
axioma> true == true
true
(< and > on strings is not currently
supported in Axioma — we’ve logged it as a gap for a future version.
Sorting strings is a Part-II topic anyway.)
A comparison produces a Boolean. So does a predicate function — a function whose whole job is to answer a yes/no question. By long Lisp/Scheme tradition (the one this book follows) their names end in a question mark, which Axioma reads as part of the name:
#language axioma/beginner
zero?(0) # true even?(4) # true odd?(3) # true
positive?(5) # true negative?(-2) # true
empty?([]) # true empty?("") # truezero?(n) is clearer than n == 0, and
even?(n) says what you mean better than
n mod 2 == 0 — reach for the predicate when one exists.
You’ll use these constantly in conditionals and (Chapter 7) as the
base-case test in recursion.
and, or, not,
xorCompose Booleans into bigger Booleans with and,
or, not, and xor.
| Operator | Meaning | Example |
|---|---|---|
and |
both must be true | true and false → false |
or |
at least one must be true | true or false → true |
not |
flips true ↔︎ false | not true → false |
xor |
exactly one must be true | true xor true → false |
axioma> true and true
true
axioma> true and false
false
axioma> false or true
true
axioma> not (5 > 3)
false
axioma> true xor false
true
axioma> true xor true
false
Watch the difference between or and xor.
Plain or is inclusive: it is true when at least
one side is true — including when both are. xor
(exclusive or) is true when exactly one side is true —
the “soup or salad, not both” reading that English
often intends. When a menu, a coin flip, or a light switch is involved,
xor is usually the operator you mean.
These read like English. “Is n between 1 and
10?” becomes:
axioma> n: 7
axioma> n >= 1 and n <= 10
true
“Is the year a leap year?” (we’ll do this carefully in §5.7)
becomes a slightly longer combination of and and
or.
if ... then ... else ...The main decision tool:
if <boolean-expression> then
<expression-when-true>
else
<expression-when-false>
Two flavors of use.
axioma> n: 5
axioma> label: if n > 0 then "positive" else "non-positive"
axioma> label
"positive"
The whole if ... then ... else ... is an
expression — it has a value. We can bind that value to a name,
return it from a function, or pass it to println.
n: 5
if n > 0 then
println("n is positive")
else
println("n is not positive")Same form, but here we don’t capture the result — we use the chosen
branch for its side effect (the println). The
whole construct is still technically an expression; we just don’t use
the value.
thenSome other languages let you write
if cond { ... } else { ... } with curly braces or
parentheses and no then keyword. Axioma requires
then. The reason is to make the structure visible
at a glance — the words if, then,
else read aloud as the structure they encode. Beginner mode
keeps this; the full Axioma language allows alternate forms, but we
won’t reach for them in this book.
else if for
multiple branchesSometimes there are more than two cases. Chain
else if:
#language axioma/beginner
letter_grade: func(score) [
if score >= 90 then "A"
else if score >= 80 then "B"
else if score >= 70 then "C"
else if score >= 60 then "D"
else "F"
]
println(letter_grade(95))
println(letter_grade(85))
println(letter_grade(72))
println(letter_grade(50))Output:
A
B
C
F
The cascade is read top-to-bottom: the first condition
that’s true wins. So letter_grade(95) doesn’t
accidentally trigger the score >= 80 branch — once the
first condition matches, the others are skipped.
Convention. Lay out the conditions in decreasing order of threshold (90, 80, 70, 60), and the cases read like a table from top to bottom. You’d usually align the
else ifs for visual rhythm, as above.
Suppose a shipping company charges by weight:
| Weight (kg) | Cost |
|---|---|
| ≤ 1 | $5 |
| 1 to 5 | $8 |
| 5 to 20 | $15 |
| > 20 | $25 |
Apply the Design Recipe.
#language axioma/beginner
# Data: weight in kilograms (Float)
# Signature: shipping: Float -> Float
# Examples:
# shipping(0.5) = 5 (small)
# shipping(3) = 8 (medium)
# shipping(15) = 15 (large)
# shipping(30) = 25 (very large)
# Template:
# shipping: func(weight) [
# if ... weight ... then ...
# else if ... weight ... then ...
# else if ... weight ... then ...
# else ...
# ]
# Body:
shipping: func(weight) [
if weight <= 1 then 5.0
else if weight <= 5 then 8.0
else if weight <= 20 then 15.0
else 25.0
]
# Tests:
println(shipping(0.5)) # 5.0
println(shipping(3)) # 8.0
println(shipping(15)) # 15.0
println(shipping(30)) # 25.0Notice that we test the boundary values — and you
should choose extras like shipping(1) and
shipping(5) to confirm which side of the boundary they’re
on. (Our <= chose “1 kg is in the small tier,” “5 kg is
in the medium tier.”)
Why do leap years exist at all? A calendar year is 365 days, but the Earth takes about 365.25 days to circle the Sun — so about every four years we add an extra day (February 29th) to let the calendar catch up. That was the old Julian calendar’s whole rule: every fourth year. But a day every four years is a tiny bit too much catching up, so our modern Gregorian calendar skips a few. (The skips keep the average year at 365.2425 days — almost exactly the Earth’s pace.)
Think of leap years as a club with three door rules, where the last rule that applies to you wins:
year % 4 == 0) is a leap year.To judge a year, find the last rule that talks about it: 2024 only matches Rule 1, so it’s a leap year. 1900 gets caught by Rule 2 — not a leap year. 2000 goes all the way to Rule 3 — a leap year. And 2023 matches no rule at all, so it’s an ordinary year.
Translate the rule into Booleans:
#language axioma/beginner
# Data: a year (Integer)
# Signature: is_leap: Integer -> Boolean
# Examples:
# is_leap(2000) = true (divisible by 400)
# is_leap(2024) = true (div by 4, not by 100)
# is_leap(1900) = false (div by 100, not by 400)
# is_leap(2023) = false (not div by 4)
# is_leap(2100) = false (div by 100, not by 400)
# Body:
is_leap: func(year) [
(year % 4 == 0 and year % 100 != 0) or year % 400 == 0
]
# Tests:
println(is_leap(2000)) # true
println(is_leap(2024)) # true
println(is_leap(1900)) # false
println(is_leap(2023)) # false
println(is_leap(2100)) # falseThe body reads as: “(divisible by 4 and not divisible by 100) or divisible by 400.”
A teaching trick. When the natural-English specification has the words and, or, not, you’ll usually translate them directly to Axioma’s
and,or,not. The hard part is figuring out which English connectives are which. The phrase “except when” in the leap-year rule is and not — “is leap” and “not the century exception”. And “unless it’s also divisible by 400” opens a second route to qualifying — an or.
Axioma has a whole second style of programming — relations and rules — that you’ll meet properly in Chapter 21. It’s a natural fit for rules-with-exceptions, so here’s a taste. Take an everyday rule with the same shape as the leap-year rule: you can play outside on a weekend — except when it’s raining — unless you’ve packed your raincoat.
relation weekend(d)
relation rainy(d)
relation has_raincoat(d)
assert weekend("saturday") # a dry Saturday
assert weekend("sunday") # a rainy Sunday - raincoat packed!
assert rainy("sunday")
assert has_raincoat("sunday")
assert rainy("monday") # rainy Monday - a school day anyway
# Door 1: a weekend day with no rain
can_play(D) whenever weekend(D) and not rainy(D)
# Door 2: rain can't stop a raincoat
can_play(D) whenever weekend(D) and rainy(D) and has_raincoat(D)
println({D | D <- can_play(D)}) # {"saturday", "sunday"}Read each whenever line as “…is true in every case
where…” — the rule connective is literally the word
whenever, so the code says exactly what the English says.
(Axioma also accepts if here, and Prolog readers can write
can_play(D) :- weekend(D) and not rainy(D) — the same
connective in Prolog dress.) There are two doors into the
can_play club, and a day only needs one of them to be open.
Notice how the English “except when it’s raining” became
and not rainy(D) on Door 1, and the “unless” clause became
a door of its own. The leap-year rule fits the same two-door shape:
relation year(y)
assert year(1900)
assert year(2000)
assert year(2023)
assert year(2024)
assert year(2100)
# Door 1: divisible by 4 - except when divisible by 100
leap(Y) whenever year(Y) and Y % 4 == 0 and Y % 100 != 0
# Door 2: divisible by 400 - the exception to the exception
leap(Y) whenever year(Y) and Y % 400 == 0
println({Y | Y <- leap(Y)}) # {2000, 2024}This is the same logic as is_leap above — “(divisible by
4 and not divisible by 100) or divisible by 400” —
just wearing different clothes: the or became two separate
doors, and the and not became a guard on Door 1.
#language axioma/beginner
safe_div: func(a, b) [
if b == 0 then "undefined"
else a / b
]
println(safe_div(10, 2)) # 5
println(safe_div(10, 0)) # undefinedNotice: safe_div returns either a number
or a string. The signature is technically
Integer Integer -> Integer | String. We’ll formalize the
“or” of types in Chapters 8 and 18 — for now, just notice that the
function has two kinds of result.
#language axioma/beginner
absolute: func(n) [
if n < 0 then -n
else n
]
println(absolute(7)) # 7
println(absolute(-3)) # 3
println(absolute(0)) # 0(Axioma also has a built-in abs — but writing your own
version is a fine exercise.)
#language axioma/beginner
sign_label: func(n) [
if n > 0 then "positive"
else if n < 0 then "negative"
else "zero"
]
println(sign_label(5)) # positive
println(sign_label(-3)) # negative
println(sign_label(0)) # zeroA pitfall to avoid. Some words in Axioma are reserved —
classify,match,axiom,postulate, and a few others can’t be used as variable names. If you tryclassify: ..., you’ll get a “reserved keyword” error. The fix: rename. The convention in this book is to add a noun-like suffix:sign_label,bucket_for,category_of. Reading aloud-ability tends to improve as a side effect.
In the full language (a separate file without the beginner pragma),
condition ? yes : no has the same purpose as
if condition then yes else no. Only the selected branch is
evaluated.
denominator: 0
answer: denominator == 0 ? none : 10 / denominator
println(answer) # none
println(true ? "yes" : "no") # yesThe unselected division does not run. Use the longer if
form when branches need explanation or several statements; nested
ternaries can hide the decision structure you are trying to teach.
Apply the Design Recipe to each. Don’t skip examples.
max_of_twoWrite max_of_two(a, b) that returns the larger of two
numbers. Examples: max_of_two(3, 7) = 7,
max_of_two(5, 5) = 5,
max_of_two(-2, -8) = -2.
temperature_categoryWrite temperature_category(c) (Celsius) that
returns:
"freezing" when c <= 0"cold" when 0 < c <= 15"comfortable" when 15 < c <= 25"hot" when c > 25is_in_rangeWrite is_in_range(n, low, high) that returns
true if low <= n <= high.
tip_smartModify Exercise 3.4’s tip so that:
bill < 10, the tip is a flat $2percent of the bill, as before.Show three examples.
quadrantWrite quadrant(x, y) that returns "I",
"II", "III", or "IV" based on
which quadrant of the Cartesian plane (x, y) is in.
Quadrant I is both positive; Quadrant II is x<0, y>0;
III is both negative; IV is x>0, y<0. (What about
points on the axes — x=0 or y=0?
Decide before writing code. Document your decision in the
examples.)
Pick a real-world decision you’ve made recently — what to wear given
the weather, whether to bring an umbrella, whether to take a cab — and
encode it as a function with if/else if/else. Make sure all
branches are reachable (every combination of inputs lands in
some branch).
Two related ideas this chapter:
Booleans — a tiny type with exactly two values — let
us capture the answer to a yes/no question. Every comparison
operator (<, >, ==, etc.)
produces a Boolean.
if ... then ... else ... — the main
decision form. The whole construct is an expression (returns a
value) that picks between two alternatives. Chain it with
else if when there are more than two cases.
You now have everything you need to write Part I’s worth of single-value functions. The next chapters open the door to structures — collections of values, like lists and trees — and that’s where the language earns its keep.
In Chapter 6 we’ll meet arrays (the Axioma word for what HtDP calls lists) and start writing functions that work on collections, not just on single numbers.
Looking ahead — what you’ll be able to do by Chapter 9:
- Define your own data shapes with Concepts
- Process arbitrarily-long lists with recursion
- Build a small interpreter for arithmetic expressions, written in Axioma, processing trees of
+,-,*,/nodes
true, false==, !=,
<, >, <=,
>=and, or,
not, xorif <bool> then <expr> else <expr> as
an expressionelse if cascadesclassify)Solutions to selected exercises: Chapter 5 · Solutions in Appendix C. (Repo file: exercises/solutions/ch05_solutions.md.)
What this chapter is. Up to here, every function we wrote took a fixed number of inputs: one temperature, two points, three arguments at most. Now we’ll handle many at once — a list of grades, a sequence of measurements, all the words in a sentence. The data shape we’ll use is called an array.
Suppose you want a function sum_three(a, b, c) that adds
three numbers:
sum_three: func(a, b, c) [a + b + c]
println(sum_three(1, 2, 3)) # 6Easy. But what about sum_five? Five parameters.
sum_ten? Ten parameters. Sum of however-many numbers
the user gives you? You can’t write sum_n_for_any_n
with this style — the parameter list has to be fixed at the time you
write the function.
This is the wall that arbitrary-size data breaks through. We package “many things” into a single value — an array — and pass one parameter that happens to be the array.
total: func(numbers) [
...
]
println(total([1, 2, 3])) # 6
println(total([1, 2, 3, 4, 5])) # 15
println(total([42])) # 42
println(total([])) # 0That ... body is what Chapter 7 will fill in. This
chapter is about the shape — how to make arrays, how to look
inside them, how to take them apart.
The literal form is comma-separated values inside square brackets:
axioma> [10, 20, 30]
[10, 20, 30]
axioma> [true, false, true]
[true, false, true]
axioma> ["red", "green", "blue"]
["red", "green", "blue"]
Axioma arrays are heterogeneous — different element types are allowed:
axioma> [1, "hello", true]
[1, "hello", true]
You usually don’t mix types in practice (it makes the function that processes the array more painful), but the language permits it.
The empty array is []:
axioma> []
[]
axioma> len([])
0
len, first, restThree primitive operations let us inspect an array. They’re enough for everything in this part of the book.
| Operation | What it does | Example |
|---|---|---|
len(arr) |
how many elements | len([10, 20, 30]) → 3 |
first(arr) |
the first element | first([10, 20, 30]) → 10 |
rest(arr) |
everything except the first | rest([10, 20, 30]) → [20, 30] |
axioma> nums: [10, 20, 30, 40]
axioma> len(nums)
4
axioma> first(nums)
10
axioma> rest(nums)
[20, 30, 40]
axioma> first(rest(nums))
20
The pair first / rest is the same idea as
Lisp’s car / cdr (if you’ve heard those terms)
or “head” / “tail” in other languages. HtDP teaches recursion in terms
of cons / first / rest; we’ll use
Axioma’s first and rest and play the same
game.
Notice that first(nums) returns the element
itself (the integer 10), while
rest(nums) returns a smaller array. That asymmetry
is exactly what makes recursion on arrays work —
rest(rest(...)) keeps shrinking the array until eventually
it’s empty.
You can also fetch elements by position, using square brackets:
axioma> nums[1]
10
axioma> nums[2]
20
axioma> nums[4]
40
Axioma indexes from 1, not from 0. This is the same convention as Lua, Julia, Pascal, COBOL, and standard mathematical notation. Most other languages (C, Python, JavaScript) use 0. If you’re coming from one of those, the first few weeks will feel uncomfortable; after that it stops mattering.
Out-of-bounds access is an error, not a silent surprise:
axioma> nums[0]
ERROR: index out of bounds: 0 (indices start at 1)
axioma> nums[99]
ERROR: index out of bounds: 99
We’ll prefer first / rest over
nums[i] for most of Part II, because recursion on the
structure — not on an index counter — is the design recipe’s
natural fit.
Two arrays glue together with +:
axioma> [1, 2, 3] + [4, 5]
[1, 2, 3, 4, 5]
axioma> [] + [99]
[99]
axioma> [10] + []
[10]
You can also use push(arr, x) to add one element at the
end:
axioma> push([1, 2, 3], 4)
[1, 2, 3, 4]
push grows the original Array in place and returns that
same Array. This is mutation, a topic we study in
Chapter 16. If you want a new Array while preserving the input, use
concatenation instead.
axioma> original: [1, 2, 3]
axioma> push(original, 99)
[1, 2, 3, 99]
axioma> original
[1, 2, 3, 99]
axioma> combined: original + [100]
axioma> original
[1, 2, 3, 99]
axioma> combined
[1, 2, 3, 99, 100]
In ordinary positions — bindings, arguments, +-chains —
the single-element form [x] means exactly what you’d guess:
a one-element array.
axioma> a: 5
axioma> single: [a]
axioma> single
[5]
axioma> type(single)
Array
axioma> [1] + [2, 3]
[1, 2, 3]
But brackets in Axioma do two jobs: they hold array literals
and they delimit blocks of code — you’ve been
using the second job all along, as function bodies
(func(x) [x * 2]). In positions where the parser expects a
block — an if/else branch, a
function body — a bare [x] is read as a
one-statement block whose value is x, not as an array (the
block reading is what lets a branch hold several statements):
axioma> f: func(x) [ if x then ["yes"] else ["no"] ]
axioma> f(true)
"yes"
axioma> type(f(true))
String
The branch produced the string "yes", because
["yes"] there was a block. The spelling that always means
“one-element array”, in every position, is a trailing
comma:
axioma> g: func(x) [ if x then ["yes",] else ["no",] ]
axioma> g(true)
["yes"]
axioma> type(g(true))
Array
[x,] is the canonical singleton spelling. (You may still
meet the older workaround push([], x) in the wild — it
works, but [x,] has replaced it.)
“In every position” is worth checking rather than taking on trust, because a function body is a block just as much as a branch is:
axioma> h: func(x) [x,]
axioma> h(3)
[3]
axioma> type(h(3))
Array
One gotcha while you are here. A bare comma run inside a body is not an array — it comes back as a tuple:
axioma> k: func(x) [x, 1]
axioma> k(3)
(3, 1)
axioma> type(k(3))
Tuple
That trips people who expect [x, 1] to mean one thing
everywhere. It doesn’t, and the reason is the same block-vs-array split
as above: a body is a sequence of statements, so its
commas build a sequence of values, while an if branch and
the right-hand side of a plain binding are expression
positions, where commas separate array elements. (In a body that
sequence is doing real work: it is Axioma’s Pop-11-style postfix
notation, where func f() [2, 3, +] returns 5.
With no operator in it, as here, it simply hands back the values.)
The fix is the one you already know. Add the trailing comma and a body gives you the array too:
axioma> m: func(x) [x, 1,]
axioma> m(3)
[3, 1]
So the rule to carry forward is short: if you mean an array, write the trailing comma. It is the one spelling that never changes meaning with position.
Suppose scores is an array of test scores. We want the
first three — say, to highlight in a podium graphic.
1. Data. An array of at least three numbers.
scores: [88, 72, 95, 60, 81]2. Signature.
# top_three: Array -> Array3. Examples.
# top_three([88, 72, 95, 60, 81]) = [88, 72, 95]
# top_three([10, 20, 30]) = [10, 20, 30]4. Template. Pluck three elements out by index.
5. Body.
top_three: func(arr) [
[arr[1], arr[2], arr[3]]
]6. Tests.
println(top_three([88, 72, 95, 60, 81])) # [88, 72, 95]
println(top_three([10, 20, 30])) # [10, 20, 30]This works — but it has an obvious shortcoming. What if the array has
fewer than three elements? Then arr[3] is out of bounds and
the program crashes. A proper version would check
len(arr) >= 3 first and return something sensible
otherwise. That’s the kind of defensive coding Chapter 7 will set up
cleanly.
Every function that processes an array has to handle two cases:
len(arr) == 0 (or
equivalently arr == []).Why? Because first(arr) and rest(arr)
require a non-empty array. If you call them on []
they fail. So the very first thing your function does must be: “Is the
array empty?”
describe: func(arr) [
if len(arr) == 0 then "empty"
else "non-empty"
]
println(describe([])) # empty
println(describe([1, 2, 3])) # non-emptyThat two-case structure is the template for almost every list-processing function in Part II. Chapter 7 will turn it into a recipe.
We’ve widened the type system from one value per input to
a container with many values. The cost is small — three new
operations to memorize (len, first,
rest) plus one syntax (brackets) — and the payoff is
enormous: from now on, no function we write has a hard-coded number of
inputs.
The pattern for Chapter 7 is already visible:
first(arr), recur on
rest(arr), combine the two.That’s it. That’s recursion on lists. The whole next chapter is worked examples of that one shape.
A range stores a rule for producing values. An array stores the values. This matters when the sequence is large, or you only need its beginning.
println(collect(1..5)) # [1, 2, 3, 4, 5]
println(collect(5..1 by 2)) # [5, 3, 1]
println(collect(0.0..1.0 by 0.25)) # [0.0, 0.25, 0.5, 0.75, 1.0]
println(collect(0.0..<0.3 by 0.1)) # [0.0, 0.1, 0.2]
println(collect(5..1..2)) # []
println(first(1.., 4)) # [1, 2, 3, 4].. includes an endpoint when the step reaches it;
..< excludes it. With by, the step is a
positive magnitude and the bounds determine direction. In
start..end..step, the step is signed; a direction that does
not approach the end gives an empty range. A zero step is an error.
Float ranges use a rounding-aware sample grid, rather than promising
exact decimal arithmetic. Ordinary scalar Float indexing remains
distinct from slicing: an array can accept a whole-valued scalar index
such as 1.0, but a Float range is not an array slice
index.
collect(range) returns an Array, as does
array(range). collect emphasizes consuming an
iterable; when given a one-shot generator it exhausts it. Constructing
or indexing a large range stays compact, while collecting it allocates
storage and is subject to a materialization limit. An open-ended range
cannot be collected in full: request a bounded prefix.
Try it. Predict the difference between
collect(1..8 by 3) and collect(1..<7 by 3),
then run both. Which endpoint can appear?
A nested Array and a Matrix solve different problems. Use a Matrix
when rows and columns are part of the mathematical meaning. Axioma
preserves Integer and Rational matrix elements for exact arithmetic; it
does not silently turn every entry into a Float. This optional preview
includes a for loop: use a separate full-language file,
without the beginner pragma.
m: matrix([[1, 2, 3], [4, 5, 6]])
println(shape(m)) # [2, 3]
println(m[2, 3]) # 6
println(m[1, 2] is Integer) # true
println(m[:]) # [1, 2, 3, 4, 5, 6]
println(shape(m[1, :])) # [1, 3]
println(shape(m[:, 2])) # [2, 1]
for cell in m [println(cell)] # prints 1 through 6, in that order
fractions: matrix([[1/3, 1/6]])
println(fractions[1, 1] + fractions[1, 2]) # 1/2
println(float(m)[1, 1] is Float) # trueIndices start at one. Linear access and direct iteration use
row-major order: finish one row before starting the
next. Row and column slices remain matrices, retaining their axes. Use
m[:] when a flat Array is the intended result.
len(m) is deliberately refused: ask shape(m)
for dimensions, or len(m[:]) when you specifically mean the
count of flattened cells. The latter materializes an array.
Explicit float(m) opts into approximate cells for a
numerical algorithm. An exact representation does not imply that every
linear-algebra routine has an exact implementation. Tensor operations
and broadcasting build on the same need to specify axes; Chapter 10
introduces that next layer.
Try it. Build a two-by-two matrix containing
1/3. Compare the type of that cell before and after
float(m), and explain why the conversion is a choice rather
than a formatting change.
Reminder: every solution file in this chapter should start with
#language axioma/beginnerso you stay in the safe subset.
count_zerosWrite a function count_zeros(arr) that returns how many
elements of arr equal 0. Use if,
comparison, and the operations from this chapter. Don’t
use recursion yet — write a version that works for arrays of length 0,
1, 2, or 3 only, by explicit case-analysis on len(arr).
Test cases:
count_zeros([]) → 0count_zeros([0]) → 1count_zeros([5]) → 0count_zeros([0, 0, 5]) → 2is_shortWrite is_short(arr) that returns true when
arr has fewer than 3 elements, false
otherwise.
is_short([]) → trueis_short([1, 2]) → trueis_short([1, 2, 3]) → falseswap_first_lastGiven an array of exactly 3 elements, return a new array with the first and last swapped.
swap_first_last([1, 2, 3]) →
[3, 2, 1]swap_first_last(["a", "b", "c"]) →
["c", "b", "a"](Use indexing. Don’t worry about the general case.)
second_or_zeroReturn the second element of the array, or 0 if the
array has fewer than 2 elements.
second_or_zero([]) → 0second_or_zero([10]) → 0second_or_zero([10, 20]) → 20second_or_zero([10, 20, 30]) → 20glue_with_separatorGiven two arrays a and b, return a single
array that has all of a, then a separator element, then all
of b. The separator value is up to you (use a string like
"-").
glue("abc", [1, 2], [3, 4]) … design your own
signature.(This exercise is intentionally a little open. Pick a separator, write down the signature first, then implement.)
Pick any array, say [14, 7, 22, 7, 100, 7].
Write three functions that each compute one statistic from it:
The point is to wire up three tiny functions and have them all
operate on the same array. Bonus: write a fourth function
describe(arr) that returns a String
summarizing all three.
Next chapter — recursion turns “the first element plus a smaller problem” into a complete algorithm.
Solutions to selected exercises: Chapter 6 · Solutions in Appendix C. (Repo file: exercises/solutions/ch06_solutions.md.)
What this chapter is. The most important chapter in Part II. Recursion is not a trick and it is not “loops wearing a costume.” It is the way you write a function whose input can be any size, once you can name a smallest case and a strictly smaller case. Master the two-case pattern here and the rest of the book — trees, mutually recursive data, generative algorithms, even logic rules — is the same idea on a new shape.
A to-do list is either empty, or it is one item plus a to-do list. That sentence is legal. It is also slightly startling the first time you take it seriously, because the word “to-do list” appears on both sides. That is not a bug in the sentence. It is the sentence telling you the shape of the data.
Open a to-do list.
A function that processes a to-do list has the same two sentences inside it. That is recursion: a function that solves a problem by solving a strictly smaller problem of the same kind, then combining. The empty list is how the process stops. The smaller list is how it continues.
Three questions, in this order, write every recursive function you will need for the next several chapters:
Write the base case first. Always. A recursive function with no base case is a picture frame that contains a picture frame that contains a picture frame, forever. Axioma will not hang your machine; it will stop you with a clear error. But the function is still wrong. The base case is the off switch.
A useful mental habit, borrowed from the way experienced functional programmers actually work: assume the function already works for every smaller input, and only write what this call has to add. You are not “thinking about all the calls at once.” You are writing one step, plus the promise that the same step works one size down.
The beginner form is the same func you have been writing
since Chapter 3. There is no extra keyword. If the body mentions the
name being defined, the function is recursive.
#language axioma/beginner
sum_list: func(arr) [
if len(arr) == 0 then 0
else first(arr) + sum_list(rest(arr))
]
println(sum_list([1, 2, 3])) # 6Three things that look like they should work, and one of them does not.
| Spelling | Recurses? | Notes |
|---|---|---|
sum_list: func(arr) [ … sum_list(…) … ] |
yes | Canonical. Use this. |
sum_list = (arr) => [ … sum_list(…) … ] |
yes | Same idea; arrow form. Chapter 11. |
func sum_list([]) [0] plus a second clause |
yes | Pattern clauses; see §7.12. |
let sum_list = func(arr) [ … sum_list(…) … ] |
no | let is a fresh cell. The body closes over the
old environment, which does not yet contain
sum_list. |
Python, JavaScript, and Java all let a function call itself by name
with no extra keyword — def f(…): … f(…) ,
function f(…) { f(…); },
int f(…) { return f(…); }. Axioma’s func and
= work the same way. let is the exception: it
is a fresh cell whose body closes over the environment from
before the binding, so let f = func() [f()] does
not see f. There is no rec
keyword to opt in. Use : or = (find-or-update)
or a func name clause when the body has to call itself.
Bodies are square brackets, [ … ]. Curly braces
{ … } are sets and dictionaries, not function bodies. That
is a different pitfall, and it is just as loud.
Axioma indexes from 1, not from 0. s[1]
is the first character of a string; s[len(s)] and
s[-1] are both the last. Slices are
inclusive on both ends: s[2: n - 1] drops
the first character and the last. That is the same convention as Julia
(s[2:end-1]) and Lua (s:sub(2, #s-1)). Python
(s[1:-1]) and JavaScript
(s.slice(1, s.length - 1)) are 0-based and drop the end
index. Write the Axioma bounds on paper once before you type them.
Every function that processes an array follows the same template. It is the to-do-list sentence, compiled:
f: func(arr) [
if len(arr) == 0 then
<some base-case answer>
else
<combine first(arr) with f(rest(arr))>
]
Two branches: empty (the base case) and non-empty (the recursive step). The empty branch returns a value immediately — nothing left to recur on. The non-empty branch peels off the first element, recurs on the rest, and combines.
That is it. The rest of this chapter instantiates that template — for sum, count, last, membership, max, palindromes, early-stop scans, and a helper that walks by index. Each time, the only things that change are “what to return when empty” and “how to combine this piece with the recursive result.”
This is what How to Design Programs calls structural recursion: the recursion follows the shape of the data. The data is “either empty, or a first element plus a rest”; the function is “either return a base value, or combine first with the recursive result on rest.” You do not invent the decomposition. The data definition already did.
sum_listProblem. Given an array of numbers, return their sum.
1. Data. An array of numbers (which may be empty).
2. Signature.
# sum_list: Array -> Number3. Examples.
# sum_list([]) = 0
# sum_list([42]) = 42
# sum_list([1, 2, 3]) = 6
# sum_list([10, -3, 5]) = 12The empty example is not optional. It is the base case, written as a test.
4. Template. The empty/non-empty split:
sum_list: func(arr) [
if len(arr) == 0 then
...
else
... first(arr) ... sum_list(rest(arr)) ...
]
5. Body. Fill in the holes:
0.first(arr) is one number,
sum_list(rest(arr)) is the sum of the rest. Add them.#language axioma/beginner
sum_list: func(arr) [
if len(arr) == 0 then 0
else first(arr) + sum_list(rest(arr))
]
println(sum_list([])) # 0
println(sum_list([42])) # 42
println(sum_list([1, 2, 3])) # 6
println(sum_list([10, -3, 5])) # 12All four match. Move on with confidence.
Trace sum_list([1, 2, 3]) by substituting, the way you
substitute in algebra. Do not try to hold the whole call stack in your
head as a movie. Replace one call at a time:
sum_list([1, 2, 3])
= 1 + sum_list([2, 3])
= 1 + (2 + sum_list([3]))
= 1 + (2 + (3 + sum_list([])))
= 1 + (2 + (3 + 0))
= 1 + (2 + 3)
= 1 + 5
= 6
Each call peels one element. After three peels the array is empty —
the base case fires — and the pending additions run on the way back.
Those pending 1 +, 2 +, 3 + are
real work waiting on the stack. We will come back to them in §7.11.
If a recursive function you wrote is “almost right,” this kind of trace is the fastest debugger you have. Pick the smallest example that fails. Substitute by hand until the substitution disagrees with what you meant. The bug is on that line.
count_listProblem. Given an array, return the number of
elements. (len already does this — we reimplement it so the
pattern is the only thing in view.)
#language axioma/beginner
# count_list: Array -> Integer
# count_list([]) = 0
# count_list([42]) = 1
# count_list([1, 2, 3]) = 3
count_list: func(arr) [
if len(arr) == 0 then 0
else 1 + count_list(rest(arr))
]
println(count_list([])) # 0
println(count_list([42])) # 1
println(count_list([1, 2, 3])) # 3The body changed from first(arr) + recur to
1 + recur. We did not look at the element itself — we just
counted it. The shape of the function is identical to
sum_list.
That is the whole skill. Name the combining rule; the template does the rest.
last_elementProblem. Return the last element of an array. The
empty case has no last element. Use none — Axioma’s absence
value, the one you get from a total read that found nothing.
(om is a different bottom, an undetermined value. They are
not interchangeable. Empty-array “there isn’t one” is
none.)
#language axioma/beginner
# last_element: Array -> Any-or-none
# last_element([]) = none
# last_element([42]) = 42
# last_element([1, 2, 3]) = 3
last_element: func(arr) [
if len(arr) == 0 then none
else if len(arr) == 1 then first(arr)
else last_element(rest(arr))
]
println(last_element([])) # none
println(last_element([42])) # 42
println(last_element([1, 2, 3])) # 3This version has three branches:
noneThe two-case template grew a third case because the data has two flavors of non-empty — singleton and longer. That is a normal extension when the problem demands it. The recursive call still receives a strictly smaller array, so we still terminate.
(Axioma also has a built-in last(arr). The exercise is
in seeing the pattern, not in replacing the primitive.)
has_valueProblem. Does the array contain a given value?
(contains is a reserved word — a builtin that tests
membership on several collection types. We cannot bind that name, so the
recursive version gets its own.)
#language axioma/beginner
# has_value: Array Any -> Boolean
# has_value([], 5) = false
# has_value([1, 2, 3], 2) = true
# has_value([1, 2, 3], 99) = false
# has_value(["a", "b"], "b") = true
has_value: func(arr, target) [
if len(arr) == 0 then false
else if first(arr) == target then true
else has_value(rest(arr), target)
]
println(has_value([], 5)) # false
println(has_value([1, 2, 3], 2)) # true
println(has_value([1, 2, 3], 99)) # false
println(has_value(["a", "b"], "b")) # trueLook at the structure:
false (nothing here matches
anything).true.
Stop. Do not look at the rest.That middle branch is early exit. Recursion is not
obliged to visit every element. map and reduce
(Chapter 10) always do. When the problem is “walk until you know the
answer,” recursion — or a loop with break, later — is the
honest shape. has_value([1, 2, 3], 1) never even looks at
2 or 3.
Boolean-returning functions on lists almost always have this shape: an empty-array default, a test on the front, and a recur-on-the-rest for everything else.
largestProblem. Return the largest number in an array. Like
last_element, the empty case has no sensible numeric answer
— none.
#language axioma/beginner
# largest: Array -> Number-or-none
# largest([]) = none
# largest([7]) = 7
# largest([1, 99, 3]) = 99
# largest([-5, -10, -2]) = -2
largest: func(arr) [
if len(arr) == 0 then none
else if len(arr) == 1 then first(arr)
else [
rest_largest: largest(rest(arr))
if first(arr) > rest_largest then first(arr) else rest_largest
]
]
println(largest([])) # none
println(largest([7])) # 7
println(largest([1, 99, 3])) # 99
println(largest([-5, -10, -2])) # -2Three new tricks here:
Local binding inside a function body.
rest_largest: largest(rest(arr)) names the recursive result
so we can refer to it twice without calling largest twice
(which would re-compute the whole tail). Chapter 12 will make this a
habit; for now it is a peek at local definitions.
The block expression [ ... ] as an
else branch. When an else arm has
more than one thing in it (here: a local binding and an
if-expression), wrap them in [ ... ]. Same
block syntax as a function body — a sequence whose value is the last
expression. Without the block, the parser does not know where the
multi-line else is supposed to end.
The two-arm comparison. Once we have
first(arr) and rest_largest, the answer is
whichever is larger. A simple if. Chapter 5; now tucked
into a list-recursion.
A combining rule you will meet constantly:
max(this, recur). min, and,
or, +, + on arrays — all the same
skeleton.
Lists are not the only thing that shrinks. A string is either short enough to be obviously the same forwards and backwards, or it is a first character, a last character, and a strictly shorter string in the middle.
Problem. Is s a palindrome — the same
backwards as forwards? civic is. cynic is not.
radar is. A single letter is. The empty string is
(vacuously: there is nothing to mismatch).
Walk it the way you actually check a word on paper:
radar — first and last match
(r/r). Look at ada.ada — first and last match
(a/a). Look at d.d — one letter. Yes.And the failing case:
cynic — first and last match
(c/c). Look at yni.yni — y and i differ.
No. Stop. Do not look at n.That is the three questions:
false; if
they match, the answer is the answer for the middle.#language axioma/beginner
# is_palindrome: String -> Boolean
# is_palindrome("") = true
# is_palindrome("a") = true
# is_palindrome("civic") = true
# is_palindrome("cynic") = false
# is_palindrome("radar") = true
# is_palindrome("runner") = false
is_palindrome: func(s) [
n: length(s)
if n <= 1 then true
else if s[1] != s[n] then false
else is_palindrome(s[2: n - 1])
]
println(is_palindrome("")) # true
println(is_palindrome("a")) # true
println(is_palindrome("civic")) # true
println(is_palindrome("cynic")) # false
println(is_palindrome("radar")) # true
println(is_palindrome("runner")) # falseIndex arithmetic, slowly, because this is where a 0-based habit (Python, JavaScript, Java, C) disagrees with Axioma’s 1-based inclusive slices.
s[1] is
the first character; s[n] is the last. (s[-1]
is also the last. Negative indices count from the end, the way a single
xs[-1] already did in Chapter 6.)s[2: n - 1] is inclusive on
both ends. For civic (n is 5) that is
s[2: 4], which is "ivi". Then
"v". Then we stop. Julia writes the same window
s[2:end-1]; Lua writes s:sub(2, #s-1).s[1:-1] and JavaScript’s
s.slice(1, s.length - 1) land on the same characters —
"ivi" — but the numbers are 0-based and the end is
exclusive. Copy those numbers into Axioma and you drop the
wrong characters. A slice that overshoots becomes "", empty
is a palindrome, and "cynic" answers true: a
wrong answer at exit 0.rest does not work on strings.
first("radar") happens to return "r", but
rest("radar") is an error: rest wants an
array, tuple, set, or list. On a string, drop the first character with
s[2:] (or s[2..]), and drop both ends with
s[2: n - 1] or the equivalent s[2..-2].
When the substitution-by-hand feels too quiet, print the argument as you go. This is the same function with a narrator:
#language axioma/beginner
is_palindrome_logged: func(s) [
n: length(s)
println("seeing if '" + s + "'")
if n <= 1 then [
println("length " + n + " — yes")
true
] else if s[1] != s[n] then [
println("mismatch " + s[1] + " vs " + s[n] + " — no")
false
] else
is_palindrome_logged(s[2: n - 1])
]
is_palindrome_logged("civic")
is_palindrome_logged("cynic")seeing if 'civic'
seeing if 'ivi'
seeing if 'v'
length 1 — yes
true
seeing if 'cynic'
seeing if 'yni'
mismatch y vs i — no
false
Take the printlns out when you are done. They are a
diagnostic, not part of the answer.
Each slice builds a new string. For a 50,000-character palindrome that is a lot of copying for a question that only needed two characters per step. The fix is a helper that carries two indices and never allocates a middle:
#language axioma/beginner
is_palindrome2: func(s) [
go: func(start, finish) [
if start >= finish then true
else if s[start] != s[finish] then false
else go(start + 1, finish - 1)
]
go(1, length(s))
]
println(is_palindrome2("civic")) # true
println(is_palindrome2("cynic")) # false
println(is_palindrome2("redivider")) # trueThe public function takes one argument. The inner go
takes the moving bounds. Callers never see start and
finish. That wrapper-plus-helper shape is how you keep a
simple interface when the recursion needs extra state. Chapter 12 treats
helpers as a design step; you just used one because the problem asked
for it.
Notice that go is bound with : inside the
body, so it can call itself.
let go = func(start, finish) [ … go(…) … ] would not.
(There is also the one-liner s == reverse(s). Use it in
production. We are not practicing reverse right now; we are
practicing shrinking an input from both ends.)
sum_until_negativemap and reduce visit every element. Some
problems should not. Sum the numbers in an array up to but not
including the first negative. If there is no negative, sum all
of them. If the first element is already negative, the sum is
0.
Two base cases: empty array, and “the front is negative.” The recursive case adds the front and continues.
#language axioma/beginner
# sum_until_negative: Array Number -> Number
# sum_until_negative([], 0) = 0
# sum_until_negative([-1, 2, 3], 0) = 0
# sum_until_negative([1, 2, 3], 0) = 6
# sum_until_negative([1, 2, 3, -9, 10], 0) = 6
sum_until_negative: func(items, total) [
if len(items) == 0 then total
else if first(items) < 0 then total
else sum_until_negative(rest(items), total + first(items))
]
println(sum_until_negative([], 0)) # 0
println(sum_until_negative([-1, 2, 3], 0)) # 0
println(sum_until_negative([1, 2, 3], 0)) # 6
println(sum_until_negative([1, 2, 3, -9, 10], 0)) # 6The running total is an accumulator:
the answer so far, carried forward, so that when we stop we already have
it. The recursive call is the last thing the function does —
there is no pending + waiting for the return. That is the
shape Chapter 15 names and exploits. You do not need the name yet. You
need the picture: work happens on the way down, and the base
case returns the finished answer.
Why not reduce? Because reduce has no
polite way to say “stop now, ignore the rest.” You can fake it with a
flag inside the combining function, and then you have written a worse
recursive function. When the algorithm is “walk until a condition,”
write the walk.
Look back at sum_list:
sum_list([1, 2, 3])
= 1 + sum_list([2, 3])
= 1 + (2 + sum_list([3]))
= 1 + (2 + (3 + 0))
The + is pending during the recursive
call. The interpreter has to remember “when that returns, add 1.” For
three elements that is harmless. For three million it is three million
pending additions.
The interpreter guards nested evaluation. This build reports a limit
of 50,000 active Eval frames (recursion_limit() reports the
number). Non-tail recursion of a long array trips a catchable error:
ERROR: recursion limit exceeded (max Eval depth 50000): possible
infinite recursion or excessively deep nesting
Use a small bounded example to study the result:
axioma> make_range: func(n) [ [i | i <- [1..n]] ]
axioma> sum_list(make_range(100))
5050
The maximum safe input also depends on the function and available resources; a source-level recursive call can use several Eval frames. Avoid treating the limit as a supported array size. Chapter 15 rewrites the same function so the recursive call is in tail position — nothing pending after it — and Axioma can reuse stack space for supported tail calls. The work and allocation costs of the body still matter.
A tiny pair makes the difference visible. Repeat a string
n times.
Tail version — the recursive call is the answer:
#language axioma/beginner
repeat_str: func(s, acc, n) [
if n == 0 then acc
else repeat_str(s, acc + s, n - 1)
]
println(repeat_str("go", "", 3)) # gogogoTrace: ("go", "", 3) → ("go", "go", 2) →
("go", "gogo", 1) → ("go", "gogogo", 0) →
"gogogo". No pending concatenations. The accumulator
is the result.
Non-tail version — concatenation waits for the recursive result:
#language axioma/beginner
repeat_str2: func(s, n) [
if n == 0 then ""
else s + repeat_str2(s, n - 1)
]
println(repeat_str2("go", 3)) # gogogoTrace: to know "go" + repeat_str2("go", 2) we must first
know repeat_str2("go", 2), which must first know
repeat_str2("go", 1), which must first know
repeat_str2("go", 0) = "", and only then do
the concatenations wind back: "go", "gogo",
"gogogo". Same answer. Pending work on every frame.
Most textbooks open recursion with factorial, which is the non-tail
shape (n * fact(n - 1)). It matches one mathematical
definition and it makes recursion feel like a magic trick you
have to unwind. Palindromes and repeat_str finish at the
bottom: once you hit the base case, you have the answer. That is the
process you want in your hands first. Factorial’s winding-back is real,
and you just saw it in sum_list and
repeat_str2. It is not the only process, and it is not the
one Axioma can run at unbounded depth.
For this chapter: do not worry about the ceiling. Coursework lists are small. Know that it exists, that tail-position self-recursion (and mutual tail-recursion) reuse a frame, and that Chapter 15 is where we start writing the tail form on purpose.
The template with if len(arr) == 0 is the one to write
until it is muscle memory. Axioma also lets the parameter
pattern carry the empty/non-empty split:
#language axioma/beginner
func sum_list3([]) [0]
func sum_list3([h | t]) [h + sum_list3(t)]
println(sum_list3([1, 2, 3, 4])) # 10Two clauses, one name. The first clause is the base case.
The second names the head h and the tail t
without calling first and rest. It is the same
function. Read it when you see it; write the if form until
the recipe is automatic.
The same [h | t] syntax constructs on
the way back out. Doubling every element:
#language axioma/beginner
double_each: func(arr) [
if len(arr) == 0 then []
else [2 * first(arr) | double_each(rest(arr))]
]
println(double_each([1, 2, 3])) # [2, 4, 6][head | tail] on the right-hand side means “this
element, then that array.” It is how you build an array of the same
length as the input. push appends at the end, so
push(double_each(rest(arr)), 2 * first(arr)) would reverse
the result if you are not paying attention. Put the new head on the
left.
Helpers that walk by index (1-based) are the other common spelling. Collect the positions of the short words:
#language axioma/beginner
keep_indices: func(arr, pred) [
helper: func(position, acc) [
if position > len(arr) then acc
else if pred(arr[position]) then
helper(position + 1, acc + [position])
else
helper(position + 1, acc)
]
helper(1, [])
]
is_short: func(s) [length(s) < 6]
words: ["cow", "aardvark", "squirrel", "fish", "snake", "capybara"]
println(keep_indices(words, is_short)) # [1, 4, 5]cow, fish, snake sit at
positions 1, 4, and 5 — not 0, 3, 4. Anyone arriving from a 0-based
language (Python, JavaScript, Java, C) will write
helper(0, []) once. The error is
index out of bounds: 0. Start at 1; stop when
position > len(arr).
These are the failures that show up in real code reviews. Each one is a wrong answer or a loud error, not a style preference.
| What you wrote | What goes wrong | The fix |
|---|---|---|
| No base case, or a base case the input never hits | recursion limit exceeded |
Write the smallest input first. Check that every recursive call is
strictly smaller by a measure you can name (length,
n - 1, finish - start). |
is_palindrome(str) inside
is_palindrome(s) |
Infinite recursion: you passed the original, not the rest | Recur on s[2: n - 1] (or rest(arr), or
n - 1). The argument must change. |
let f = func() [f()] |
The body cannot see f |
Use f: func() [f()] or f = …. There is no
rec keyword. |
let rec f = … |
SyntaxError: unexpected token '=' |
There is no rec keyword. func is already
recursive. |
{ … } as a function body |
{} is the empty set; a block is [ … ] |
Square brackets. |
s[0], helper(0, …) |
index out of bounds: 0 |
Indices start at 1. The last character is s[n] or
s[-1]. |
s[1: n - 1] meaning “drop both ends” |
You kept the first character | Drop-both-ends is s[2: n - 1] or s[2..-2].
Inclusive slices. |
rest(s) on a string |
Error: rest is not for strings |
s[2:] drops the first character. |
largest(rest(arr)) written twice |
Correct, but exponential in a tree and twice-as-slow on a list | Bind once: r: largest(rest(arr)). |
reverse(rest(arr)) + [first(arr)] on a long array |
Quadratic copies (+ copies arrays) |
Fine for this chapter. Chapter 15’s accumulator makes it linear. Or
call the builtin reverse. |
| Recursing on the whole input “to be safe” | The input does not shrink | If you cannot point at a smaller value, it is not a recursive step. |
Using recursion because you have heard it is “more functional,” on a
full-list map |
A worse map |
Chapter 10. map / filter /
reduce / {x | x <- xs} when you really do
visit every element. |
A recursive function is wrong in exactly two ways: it does not stop, or it stops with the wrong combination. The trace in §7.4 distinguishes them. If the substitution never reaches a base case, it does not stop. If it reaches a base case and the values coming back are wrong, the combining rule is wrong.
Recursion is the right tool when:
It is the wrong first tool when:
map (Chapter 10), or a comprehension
{2 * x | x <- arr}.filter.reduce.while.
That still works (Chapter 16). Recursion is not a moral improvement on a
loop. It is a better fit for recursive data.Industry practice is blunt about this. Linear scans over arrays, in
production systems, are loops or library HOFs. Recursion earns its keep
on trees, nested documents, grammars, and “walk until”
— the shapes where the data definition is already recursive. Chapter 8
is trees. That is where people who were unimpressed by
sum_list suddenly need this chapter.
Look back at the bodies:
sum_list: first(arr) + sum_list(rest(arr))
count_list: 1 + count_list(rest(arr))
last_element: [base] / first(arr) / last_element(rest(arr))
has_value: first(arr) == target / has_value(rest(arr), target)
largest: max(first(arr), largest(rest(arr)))
is_palindrome: ends match, then is_palindrome(middle)
sum_until_negative: stop, or total + first and continue
What varies is the combining rule (and where you are allowed to stop). What stays the same is the shape: smallest case, smaller case, combine. If you can name those three, you can write the function.
The three questions again, because they are the whole chapter:
Chapter 8 asks the same three questions of a binary tree. The data definition changes. The skill does not.
count_zeros (the recursive version)Rewrite Exercise 6.1 to handle arrays of any length using
recursion. Signature: Array -> Integer.
count_zeros([]) → 0count_zeros([0]) → 1count_zeros([1, 0, 2, 0, 0]) → 3count_zeros([1, 2, 3]) → 0smallestBy analogy with largest. Empty array returns
none; otherwise the minimum value.
smallest([]) → nonesmallest([7]) → 7smallest([3, 1, 4, 1, 5, 9]) → 1sum_of_squaresSum the squares of the elements.
sum_of_squares([]) → 0sum_of_squares([3]) → 9sum_of_squares([1, 2, 3]) → 14count_aboveHow many elements are strictly greater than
threshold?
count_above([], 10) → 0count_above([5, 12, 8, 99], 10) → 2count_above([1, 2, 3], 100) → 0double_eachReturn a new array with every element doubled. (This produces a new array of the same length — the recursive result is an array, not a number.)
double_each([]) → []double_each([1]) → [2]double_each([1, 2, 3]) → [2, 4, 6]Hint: [2 * first(arr) | double_each(rest(arr))] — the
cons form, which puts the new head on the front of the
recursive result. It reads the same way [h | t] reads when
you destructure, and because the whole thing is one bracket there is no
singleton to spell and no block-vs-array question to worry about
(contrast §6.6). Mind the order: the doubled head goes on the
left. (push(...) appends at the end, so the
push-based spelling needs the arguments the other way around:
push will not preserve order if you build head-first.)
reverseReturn the array in reverse order.
reverse_arr([]) → []reverse_arr([42]) → [42]reverse_arr([1, 2, 3]) → [3, 2, 1]Two design choices to make:
first(arr) go in the recursive
answer — at the front or at the end?+? push? the
cons form?Pick one, write down examples, then implement. (A linear-time version waits for Chapter 15. A correct quadratic version is a complete answer to this exercise.)
is_palindromeWrite is_palindrome(s) for strings, using the
shrink-both- ends recursion from §7.9. Empty and single-character
strings are palindromes. Do not call reverse.
is_palindrome("") → trueis_palindrome("a") → trueis_palindrome("civic") → trueis_palindrome("cynic") → falseis_palindrome("redivider") → trueis_palindrome("runner") → falseThen write is_palindrome2 that uses a nested helper with
two indices and never slices. Both must agree on the examples above. (Do
not name the helper check — that word is reserved.
go is fine.)
take_while and drop_whiletake_while(arr, pred) returns the longest
prefix of arr whose elements all satisfy
pred, and stops at the first failure — even if a later
element would have passed. drop_while(arr, pred) returns
whatever take_while did not take: the rest of the array
starting at that first failure. If every element passes,
take_while returns the whole array and
drop_while returns [].
data: [2, 6, 42, 5, 7, 20, 3]
is_even: func(n) [n % 2 == 0]
# take_while(data, is_even) → [2, 6, 42]
# drop_while(data, is_even) → [5, 7, 20, 3]20 is even and it is not in the
take_while result. The walk stopped at 5.
Next chapter — the same three questions applied to a new data shape: binary trees, built from Concepts.
Solutions to selected exercises: Chapter 7 · Solutions in Appendix C. (Repo file: exercises/solutions/ch07_solutions.md.)
What this chapter is. A list is a flat sequence: every element is followed by zero or one “next.” A tree is branching: every node can have several children. We’ll model trees in Axioma using Concepts — Axioma’s record/structure mechanism — and apply the same structural-recursion recipe from Chapter 7 to a new data shape.
A list of grades is a fine shape for “all the test scores in order.”
But for a family tree, or a file system, or the
arithmetic expression (1 + 2) * (3 - 4), a
sequence isn’t enough — each node has multiple sub-structures. We need a
shape where one node can hold both a left and a right.
We’ll build that shape now. The data type we’ll define here — binary tree, with a value plus a left and right child — is the running example. In Chapter 9 we’ll generalize.
Axioma’s mechanism for “a thing with several named fields” is the Concept. Concepts are Axioma’s analog of:
| Other language | Mechanism |
|---|---|
| Racket / Scheme | (define-struct posn (x y)) |
| Haskell / ML | data Node = Node Int Tree Tree |
| C / Go | struct Node { int v; Node *l; Node *r; } |
| Python | class Node: ... (or @dataclass) |
| Java | class Node { int v; Node l; Node r; } |
In Axioma, the syntax is natural-language style:
concept Node # declare Node as a kind of Concept
Node has value: 0 # Node has a field called 'value', default 0
Node has left: none # ... and 'left', default none
Node has right: none # ... and 'right', default noneFour lines, four sentences. concept Node declares the
type; has clauses declare fields with default values. The
defaults matter — if you don’t give a value when constructing, the field
takes the default.
To make an actual node, use the indefinite-article form
a Node:
axioma> n5: a Node {value: 5}
axioma> n5.value
5
axioma> n5.left
none
axioma> n5.right
none
We provided only value; the other two defaulted to
none. To make a node with children, pass them
explicitly:
leaf4: a Node {value: 4}
leaf7: a Node {value: 7}
n5: a Node {value: 5, left: leaf4, right: leaf7}That builds this little tree:
5
/ \
4 7
We can read fields with dot notation:
axioma> n5.value
5
axioma> n5.left.value
4
axioma> n5.right.value
7
(Chapter 1 of the Axioma manual covers Concepts in more depth. Here we use just enough for the tree shape.)
A Tree is one of: -
none— the empty tree - aNodeinstance with avalue, aleft(which is a Tree), and aright(which is a Tree)
That two-case definition is the data definition — and once again it has two cases. Empty/non-empty for lists in Chapter 7. Empty/ node for trees here. The recipe says: one branch in every function for each case.
tree_sizeProblem. Count how many nodes are in a tree.
1. Data. A Tree as defined above.
2. Signature.
# tree_size: Tree -> Integer3. Examples.
# tree_size(none) = 0
# tree_size(a Node {value: 1}) = 1
# tree_size( 5
# / \
# 4 7
# ) = 3
4. Template. Two cases (empty vs. Node) and two recursive calls in the non-empty branch — one for each child.
tree_size: func(t) [
if t == none then
...
else
... tree_size(t.left) ... tree_size(t.right) ...
]5. Body.
tree_size: func(t) [
if t == none then 0
else 1 + tree_size(t.left) + tree_size(t.right)
]6. Tests.
leaf4: a Node {value: 4}
leaf7: a Node {value: 7}
n5: a Node {value: 5, left: leaf4, right: leaf7}
println(tree_size(none)) # 0
println(tree_size(leaf4)) # 1
println(tree_size(n5)) # 3Notice the shape: same two-branch if, same “do
something, then combine recursive results” — but now we have
two recursive calls instead of one. Trees are structurally
richer than lists, and the recursion mirrors that.
tree_depthProblem. Return the depth of the deepest leaf below the root. A leaf-only tree (one node) has depth 1. The empty tree has depth 0.
# tree_depth: Tree -> Integer
tree_depth: func(t) [
if t == none then 0
else [
dl: tree_depth(t.left)
dr: tree_depth(t.right)
1 + (if dl > dr then dl else dr)
]
]
println(tree_depth(none)) # 0
println(tree_depth(leaf4)) # 1
println(tree_depth(n5)) # 2We use a [ ... ] block in the else branch
(per Chapter 7’s trick) to bind the two recursive results to names, then
take the larger one and add 1 for this node.
tree_containsProblem. Does the tree contain a node with this value?
# tree_contains: Tree Any -> Boolean
tree_contains: func(t, target) [
if t == none then false
else if t.value == target then true
else tree_contains(t.left, target) or tree_contains(t.right, target)
]
println(tree_contains(n5, 7)) # true
println(tree_contains(n5, 99)) # false
println(tree_contains(none, 1)) # falseThe three branches: empty (false), match (true), neither (check both
subtrees and or the results). Compare to the list
contains in §7.5 — same shape, with an extra
or because we now have two sub-problems instead of
one.
mirrorProblem. Produce a tree that’s a horizontal mirror image of the input — left becomes right, recursively, at every node.
# mirror: Tree -> Tree
mirror: func(t) [
if t == none then none
else a Node {value: t.value, left: mirror(t.right), right: mirror(t.left)}
]The empty tree mirrors to itself (still empty). A non-empty node
mirrors to a new node with the same value but with
the left and right children swapped (and each recursively
mirrored).
m: mirror(n5)
println(m.left.value) # 7 (was on the right)
println(m.right.value) # 4 (was on the left)This is the first function we’ve written that constructs a
new tree (as opposed to computing a number or boolean from a tree). The
shape is the same — two-branch recursion — but the recursive combiner
builds a new a Node instead of doing arithmetic.
Just like for lists in Chapter 7, every tree-processing function follows the same template:
f: func(t) [
if t == none then
<base-case answer>
else
<combine t.value with f(t.left) and f(t.right)>
]What changes from function to function:
none —
0 for size/sum, false for contains,
none for mirror, [] for traversal.t.value, f(t.left),
and f(t.right) — add them, or them,
build a new node, append the traversals.That’s the whole pattern. Internalize it and you can write any tree-processing function in one sitting.
We just transferred a habit — structural recursion — from one data shape (list) to another (tree). The skill generalizes: every recursive data type comes with a matching recursion template. Chapter 9 makes the generalization official: mutually recursive data shapes, where two (or more) Concepts refer to each other.
The Concept system is doing more than syntactic sugar here. By naming
a record type (Node), we get a vocabulary for our data that
the rest of the program can rely on. Compare with HtDP’s
define-struct posn (x y) — Axioma’s Concept is
the same idea with natural-language syntax and a few extra features
(defaults, inheritance) we’ll meet in Chapter 18.
You’ll need this preamble at the top of every solution:
#language axioma/beginner
concept Node
Node has value: 0
Node has left: none
Node has right: none(And probably a small fixed tree to test against.)
tree_sumReturn the sum of all value fields in the tree.
tree_sum(none) → 0
tree_sum(a Node {value: 7}) →
7
For the tree
3
/ \
1 5
/ \
4 7
tree_sum → 20.
count_leavesA leaf is a non-none node whose
left and right are both none.
Count the leaves.
count_leaves(none) → 0count_leaves(a Node {value: 7}) → 1count_leaves on the picture above → 3
(leaves are 1, 4, 7).tree_maxReturn the largest value in the tree, or
none for an empty tree.
Hint: for the empty subtree case inside the recursion, you
need to return a value that the comparison will lose to. One easy trick:
a separate helper tree_max_helper(t, best) that carries a
running maximum. Or have tree_max itself handle empty/leaf/
internal as three branches.
tree_max(none) → nonetree_max on the picture above → 7.tree_minBy analogy with tree_max. Smallest value,
or none for empty.
inorderReturn an array of values in inorder — left
subtree, then this node, then right subtree.
Hint: cons this node onto the right subtree, then glue the left one
in front — inorder(t.left) + [t.value | inorder(t.right)].
The cons form carries the singleton for you, so there is no
trailing-comma spelling to remember. (The three-way concatenation
inorder(t.left) + [t.value,] + inorder(t.right) also works;
the trailing comma makes the Array intention explicit. A bare
[t.value] also works mid-chain; the block reading matters
at the start of a branch or function body (§6.6).)
inorder(none) → []inorder on the picture above →
[1, 3, 4, 5, 7].tree_height_balancedReturn true if for every node in the tree, the
depths of the left and right subtrees differ by at most 1.
This is harder than it sounds. Spend time on the data definition and
examples before writing code. Consider what “balanced” means
for none, for a leaf, for a node whose subtrees are
themselves balanced but of different depths.
Next chapter — what if Node referenced
Tree and Tree referenced Node?
Mutually recursive data shapes, and the mutually recursive functions
that process them.
Solutions to selected exercises: Chapter 8 · Solutions in Appendix C. (Repo file: exercises/solutions/ch08_solutions.md.)
What this chapter is. Sometimes two data shapes refer to each other. A Person belongs to a Household; a Household contains Persons. Or: an Expression is either a literal or an operator with sub-expressions. When the data shapes cross- reference, the functions that process them have to do the same — they become mutually recursive.
Up to here every recursive data type referred only to itself — a tree’s children are trees, an array’s tail is an array. Now imagine modeling a small household:
A Person has a name and (sometimes) a household they belong to.
A Household has an address and an array of people.
The arrow goes both ways: Person → Household → Person. That’s mutual recursion at the data level.
In Axioma:
concept Person
Person has name: ""
Person has household: none
concept Household
Household has address: ""
Household has members: []Now we can build a small instance:
h: a Household {address: "123 Main", members: [a Person {name: "Alice"}, a Person {name: "Bob"}]}
alice: a Person {name: "Alice", household: h}
bob: a Person {name: "Bob", household: h}The household contains member records whose household
slot remains none. The separate alice and
bob records point to that household. These are finite views
of the same people, not a cyclic object graph. Keeping the
representation finite makes the stopping case explicit; Chapter 27
handles graphs using separate vertices, edges, and visited sets.
Now we can walk the data either direction:
axioma> alice.name
"Alice"
axioma> alice.household.address
"123 Main"
axioma> alice.household.members[1].name
"Alice"
axioma> alice.household.members[2].name
"Bob"
First, two cooperating functions:
# person_household_size: Person -> Integer
# household_size: Household -> Integer
person_household_size: func(p) [
if p == none then 0
else household_size(p.household)
]
household_size: func(h) [
if h == none then 0
else len(h.members)
]
println(person_household_size(alice)) # 2
println(person_household_size(bob)) # 2
println(household_size(h)) # 2These functions follow one link, but are not mutually
recursive: person_household_size calls
household_size, which returns without calling back. Mutual
recursion needs a cycle in the call graph, not a cycle in the
data. The next example demonstrates that distinction.
A note on call-order.
person_household_sizecallshousehold_size, but we definedperson_household_sizefirst. In Axioma that works because function bodies are looked up at call time, not at definition time. By the time we callperson_household_size(alice), both functions exist.
Time for a richer example. We’ll model arithmetic expressions like
(1 + 2) * (3 - 4). The data definition is:
An Expression is one of: - a Literal number, or - an Operator application — a kind (
"+","-","*","/") plus a left Expression and a right Expression.
That’s not purely mutual recursion in the textbook sense (Operator points to Expression, but Literal doesn’t) — it’s variant recursion: one type with two flavors, and one flavor recurses. We’ll use two Concepts to model the two flavors.
concept Lit
Lit has value: 0
concept Op
Op has kind: "+"
Op has lhs: none
Op has rhs: noneTo build (1 + 2) * (3 - 4):
e1: a Lit {value: 1}
e2: a Lit {value: 2}
plus: an Op {kind: "+", lhs: e1, rhs: e2}
e3: a Lit {value: 3}
e4: a Lit {value: 4}
minus: an Op {kind: "-", lhs: e3, rhs: e4}
expr: an Op {kind: "*", lhs: plus, rhs: minus}Now the evaluator. The data has two flavors; the evaluator has two branches:
eval_expr: func(e) [
if e is Lit then e.value
else if e is Op then eval_op(e)
else none
]
eval_op: func(e) [
l: eval_expr(e.lhs)
r: eval_expr(e.rhs)
if e.kind == "+" then l + r
else if e.kind == "-" then l - r
else if e.kind == "*" then l * r
else if e.kind == "/" then l / r
else none
]
println(eval_expr(e1)) # 1
println(eval_expr(plus)) # 3
println(eval_expr(minus)) # -1
println(eval_expr(expr)) # -3Two new ideas at work:
The is test. e is Lit
returns true if e was built with
a Lit. We dispatch on the kind of object, not on a
field. (Note that is only tests — declaring the
concept is concept Person, as in Chapter 9’s data
definitions.)
Two functions that call each other.
eval_expr dispatches an operator to eval_op;
eval_op calls eval_expr on each child. This is
mutual recursion. Every cycle moves to smaller sub-expressions, and a
literal stops the recursion. The same evaluator could be written as a
single recursive function, but splitting dispatch from operator
evaluation exposes the call graph.
This is, in miniature, an interpreter — a program that takes data describing a computation and runs that computation. Chapter 22 builds it out into a full meta-circular evaluator for Axioma itself.
When data shapes A and B point at each other, the functions that process them point at each other too:
process_a: func(a) [
... process_b(a.field_of_type_B) ...
]
process_b: func(b) [
... process_a(b.field_of_type_A) ...
]Each function gets one branch per shape of its argument, and the recursive calls hop between types as needed.
When data has variants (one type with several flavors), one function with one branch per variant does the job — or you split into one function per variant. Either is fine.
We started Part II with arrays — the simplest arbitrary-size data shape. We ended with mutual recursion — the most general. The through-line is the design recipe unchanged:
That recipe has now been applied to four different data shapes: fixed-size atoms (Part I), arrays (Chapter 7), trees (Chapter 8), and mutually-recursive concept graphs (this chapter). Every new data shape you ever meet — JSON, ASTs, file systems, scene graphs — you process the same way.
You’re done with Part II. The rest of the book is about abstraction (factoring out the parts of these patterns that don’t change so you can reuse them), stateful programming (when mutation is the right tool), and Axioma-specific extensions (Concepts vs. sum types, enums, multi-valued logic, logic programming, and the meta-circular evaluator).
name_in_householdGiven a Person, return an array of the names of all members of their household.
alice: ["Alice", "Bob"]household is none:
[]count_total_peopleGiven an array of Households, return the total number of people across all of them.
count_total_people([]) → 0count_total_people([h1, h2]) → sum of
len(h1.members) + len(h2.members)expr_depthAdd an expr_depth(e) function that returns the depth of
the expression tree. A Lit has depth 1; an Op
has depth 1 + max(depth(lhs), depth(rhs)).
expr_depth(a Lit {value: 7}) → 1expr_depth((1 + 2)) → 2expr_depth((1 + 2) * (3 - 4)) → 3count_opsCount the number of Op nodes in an expression (i.e., the
number of binary operators).
count_ops(a Lit {value: 7}) → 0count_ops((1 + 2)) → 1count_ops((1 + 2) * (3 - 4)) → 3add_one_to_litsBuild a new expression in which every Lit has
its value increased by 1, but the operator structure is
unchanged.
add_one_to_lits(a Lit {value: 7}) →
a Lit {value: 8}add_one_to_lits((1 + 2)) → (2 + 3)Hint: this is the “build a new tree” pattern from §8.8 applied to the two-variant Expression shape.
Add support for a new operator of your choice — perhaps
"%" (modulo), "^" (exponent), or
"max" (the larger of two sub-results). What does the data
definition gain? What changes in the evaluator?
Or — a harder twist — add a unary operator like
"neg" (numeric negation) by introducing a third Concept
UnaryOp and updating the evaluator to dispatch on three
flavors instead of two.
End of Part II. Part III turns these patterns into
reusable abstractions: map, filter,
reduce, lambda functions, and local definitions.
Solutions to selected exercises: Chapter 9 · Solutions in Appendix C. (Repo file: exercises/solutions/ch09_solutions.md.)
What this chapter is. Part II ended with five recursive functions that all look the same. This chapter names what’s common, hands you three pre-written tools —
map,filter,reduce— and shifts your job from “write the recursion” to “describe the transformation.” Broadcasting then extends a scalar rule to compatible collections, and fusion connects those rules without intermediate arrays.
At the end of Chapter 7 we listed the bodies of
sum_list, count_list, largest,
contains, and last_element side by side. They
all had the same shape — peel off first(arr), recur on
rest(arr), combine. Only the combining rule
changed.
In Chapter 7 §7.8 I called that structural recursion. Now we’re going to take the next step: pull the recursion itself out into a reusable function, and pass in the combining rule as a parameter.
That parameter is another function. A function whose argument is a
function is called a higher-order function — and
writing them is the single biggest leap in your power as a programmer
since you learned if.
mapThe pattern. “Take every element of an array, do something to it, return a new array of the results.”
We saw this in §7.5 exercise double_each: take
[1, 2, 3], double each one, get [2, 4, 6]. We
could also halve each one, or square each one, or convert each one to a
string. The something changes; the for-every-element
part doesn’t.
The for-every-element part has a name: map.
#language axioma/beginner
nums: [1, 2, 3, 4, 5]
double: func(x) [x * 2]
println(map(double, nums)) # [2, 4, 6, 8, 10]Read that aloud: “map the function double
over the array nums.” map is a function that
takes two arguments — a function and an array — and returns a
new array.
The function we pass in doesn’t have to be defined first; we can write it inline:
#language axioma/beginner
println(map(func(x) [x * x], [1, 2, 3, 4, 5]))
# [1, 4, 9, 16, 25]Why this is worth learning. Before map,
double_each was seven lines of recursion. After
map, it’s one line. The recursion is still
happening — map does it internally — but you don’t
have to write it yourself.
filterThe pattern. “Keep the elements that satisfy a test; throw the rest away.”
Exercise 7.4 was count_above(arr, threshold) — count how
many elements exceed a threshold. A close cousin: list the
elements that exceed the threshold. The test changes (>10, even,
prime, non-empty…); the keep-the-passing-ones part doesn’t.
The keep-the-passing-ones part has a name: filter.
#language axioma/beginner
nums: [3, 1, 4, 1, 5, 9, 2, 6]
is_big: func(x) [x > 4]
println(filter(is_big, nums)) # [5, 9, 6]
println(filter(func(x) [x % 2 == 0], nums)) # [4, 2, 6]The function you pass to filter must return a Boolean —
true to keep the element, false to drop it. By
convention such a function is called a predicate, and
predicate names usually read like a yes/no question:
is_big, is_even, is_negative.
reduceThe pattern. “Walk through the array, combining elements with a running total.”
sum_list did this with +.
largest did it with max. We could imagine
product_list with *, concat_all
with + on strings, all_true with
and, and so on. The combining operation changes; the
walk-and-accumulate part doesn’t.
The walk-and-accumulate part has a name: reduce.
#language axioma/beginner
nums: [1, 2, 3, 4, 5]
add: func(acc, x) [acc + x]
times: func(acc, x) [acc * x]
println(reduce(add, 0, nums)) # 15 (1+2+3+4+5)
println(reduce(times, 1, nums)) # 120 (1*2*3*4*5)reduce takes three arguments: the combining
function, a starting value (the accumulator — what the
running total should be before any element is seen), and the array.
The combining function takes two arguments: the running total so far, and the next element. It returns the new running total.
Two common pitfalls:
reduce(add, 0, ...) gives the right sum because 0 is the
identity for addition (adding 0 doesn’t change a number). For
* the identity is 1, not 0. For string concatenation it’s
"". For max it’s the smallest possible value
(which is awkward — see below).+, *, max). Others do —
try reduce(func(a, b) [a - b], 0, [1, 2, 3]): you get
-6, not -4 or 4, and which one
you wanted depends on whether you’re folding from the left or the
right.reduce in Axioma folds from the left: it
computes ((0 + 1) + 2) + 3.
When the direction matters, you can say it explicitly. Axioma has the
Scheme-faithful folds foldl and foldr (with
fold_left / fold_right spellings too):
#language axioma/beginner
foldl(func(acc, x) [acc - x], 0, [1, 2, 3]) # -6 — ((0-1)-2)-3, same as reduce
foldr(func(x, acc) [x - acc], 0, [1, 2, 3]) # 2 — 1-(2-(3-0))foldl calls its function fn(acc, elem) (the
accumulator first, like reduce); foldr calls
fn(elem, acc) and works from the right end inward. Two more
relatives in the same family: append_map(fn, xs) maps a
collection-returning function and concatenates the results (Scheme’s
append-map; also spelled flatmap /
mapcat), and for_each(fn, xs) runs
fn for its side effects and returns nothing.
mean in three linesProblem. Compute the average of an array of numbers.
1. Data. A non-empty array of numbers.
2. Signature.
# mean: Array -> Number3. Examples.
# mean([4]) = 4
# mean([1, 2, 3]) = 2
# mean([10, 20, 30, 40]) = 254-5. Template / body. Sum the array, divide by length:
#language axioma/beginner
mean: func(arr) [
reduce(func(a, b) [a + b], 0, arr) / len(arr)
]6. Tests.
println(mean([4])) # 4
println(mean([1, 2, 3])) # 2
println(mean([10, 20, 30, 40])) # 25Three lines. Compare with the equivalent recursive version (probably
ten lines, with a helper for sum). The recursion is hidden inside
reduce; we just told it what to do with each
element.
The real power emerges when you chain them. Here’s “average of the even numbers larger than 10”:
#language axioma/beginner
nums: [3, 11, 14, 17, 22, 8, 25, 30]
above_10: filter(func(x) [x > 10], nums)
evens: filter(func(x) [x % 2 == 0], above_10)
total: reduce(func(a, b) [a + b], 0, evens)
answer: total / len(evens)
println(answer) # 22 (14 + 22 + 30 = 66, 66 / 3 = 22)Each line is one transformation. The variables
(above_10, evens, total) name the
intermediate result — they’re not strictly necessary, but they
document the steps.
In Chapter 12 we’ll learn how to make those intermediate names local to a single computation, so they don’t clutter the top level. For now, just appreciate that each step is a one-liner.
There is a second way to build a pipeline: instead of naming each intermediate result, name the transformation and glue the pieces together into a single reusable function. That is composition, and it gets its own treatment in §10.10.
map, filter, and reduce are
not magic. They’re recursive functions you could have written yourself.
Here is map in full — same structural-recursion template
you saw in Chapter 7:
#language axioma/beginner
my_map: func(f, arr) [
if len(arr) == 0 then []
else [f(first(arr)) | my_map(f, rest(arr))]
]
println(my_map(func(x) [x * 2], [1, 2, 3])) # [2, 4, 6]The cons expression
[f(first(arr)) | my_map(f, rest(arr))] places the
transformed first value before the recursively transformed tail. An
equivalent concatenation is
[f(first(arr)),] + my_map(f, rest(arr)). The comma in that
alternative distinguishes the one-element Array from a branch block
(§6.6).
Two HtDP-classic HOFs not built into Axioma are any? and
all?: “does any element satisfy this predicate?”
and “do all elements satisfy it?” Defining them is a great
exercise in HOF composition (and in fact you’ll do exactly that as
Exercise 10.5 below):
#language axioma/beginner
any_of: func(pred, arr) [
if len(arr) == 0 then false
else if pred(first(arr)) then true
else any_of(pred, rest(arr))
]
println(any_of(func(x) [x < 0], [3, 1, -1, 4])) # true
println(any_of(func(x) [x < 0], [3, 1, 4])) # falseIn Part II your task was: “tell the computer step by step how to walk through the data.” In this chapter your task became: “tell the computer what to do with each element — it’ll handle the walking.”
That shift has a name in computer science: declarative vs. imperative. Imperative code says how; declarative code says what. Higher-order functions don’t make declarative code mandatory — they just make it easy.
You haven’t lost any expressive power. The recursive version is
still there (you wrote my_map in §10.7). What
you’ve gained is the ability to name and reuse the common
shape.
There’s a sibling notation worth flagging before Part IV. Axioma has a second collection type alongside arrays: the set. A set is written with curly braces; it has no order and no duplicates:
arr: [1, 2, 3, 2, 1] # array — order matters, duplicates kept
println(arr) # [1, 2, 3, 2, 1]
s: {1, 2, 3, 2, 1} # set — unordered, duplicates removed
println(s) # {1, 2, 3}Use an array when order matters or repetition is meaningful (a sequence of test scores, a queue of pending tasks). Use a set when you only care which values are present (the set of users that logged in today, the words that appear in a document).
Sets have their own operators: union,
intersection, difference, and the membership
test in:
a: {1, 2, 3}
b: {3, 4, 5}
println(a union b) # {1, 2, 3, 4, 5}
println(a intersection b) # {3}
println(a difference b) # {1, 2}
println(2 in a) # true
println(7 in a) # false(For the mathematically inclined: ∪, ∩, and
∈ work as synonyms.)
Both arrays and sets have a comprehension form. The
shape [ expr | name <- source ] builds a new array by
stepping name through source and collecting
the value of expr:
sqs: [x * x | x <- [1, 2, 3, 4]]
println(sqs) # [1, 4, 9, 16]Swap the brackets for braces and you get a set comprehension — same shape, deduplicated result:
sqs_set: {x * x | x <- [1, 2, 3, 4]}
println(sqs_set) # {1, 4, 9, 16} (a set)A comprehension can carry filters too — extra clauses after the generator. Each filter is a Boolean expression; only elements that satisfy all filters survive:
evens_squared: [x * x | x <- [1, 2, 3, 4, 5], x % 2 == 0]
println(evens_squared) # [4, 16]That’s the same as
map(func(x) [x * x], filter(func(x) [x % 2 == 0], [1, 2, 3, 4, 5]))
— and many programmers find the comprehension easier to read.
If you learned set-builder notation from a math text, you can write
it the way the book printed it: the colon separator and membership-style
binder both work — {x : x ∈ u, x > 1} means exactly
{x | x <- u, x > 1}. (When x is already
bound, x in s keeps its membership-test meaning, so filters
like {x | x <- s, x in t} still read as
intersection.)
Comprehensions are a declarative shorthand for the
filter-then-map patterns you just learned.
They appear heavily in Part VI — Chapter 21’s logic-programming queries
are set comprehensions over the fact base:
# Preview from Ch.21: who are John's grandchildren?
grandkids: {Z | Y <- parent("John", Y), Z <- parent(Y, Z)}You don’t need to internalise comprehensions to finish Part III —
map/filter/reduce cover the same
territory and read slightly differently. But know that the notation
exists, and that every set comprehension can be rewritten with
filter and map, and vice versa. They’re
the same idea, with different ergonomic trade-offs.
If you’ve used Python, the shape
[x*2 for x in xs if x > 0] will feel native. Axioma
accepts it as-is — the for/in/if
keywords are aliases for <-/, in the
comprehension clause list, and the pipe | becomes optional
when you use them:
# Axioma pipe form (mathematical, set-builder-flavored)
doubled: [x * 2 | x <- [1, 2, 3, 4, 5], x > 0]
# Python pipe-less form — same result, paste-from-Python friendly
doubled: [x * 2 for x in [1, 2, 3, 4, 5] if x > 0]Both produce [2, 4, 6, 8, 10]. The two forms can be
mixed in the same program — pick whichever reads better for the
expression at hand. Mathematical-leaning code tends toward the pipe
form; data-processing code tends toward the
for/if form. There is no semantic
difference.
The same dual surface applies to set comprehensions:
xs: [1, 2, 3, 4, 5]
{x * 2 | x <- xs, x > 0} # Axioma pipe form
{x * 2 for x in xs if x > 0} # Python pipe-less formSometimes you want to compute a value, test it, and
then use it in the output expression — all in one
comprehension. Python 3.8 introduced the walrus operator :=
for this; Axioma uses the same : it uses everywhere else
for binding:
xs: [1, 2, 3, 4, 5]
# Walrus binding inside a comprehension clause — same `:` as ordinary binding
{y | x <- xs, y: x * x, y > 4} # → {9, 16, 25}The binding is iteration-local: y
doesn’t leak outside the comprehension. (Python’s walrus does
leak, which has surprised a generation of Python programmers — Axioma
deliberately diverges here.)
When do you reach for a walrus? When the same expression appears in both the filter and the output:
# A small, pure calculation used in both forms
f: func(x) [x - 2]
# Awkward — `f(x)` may run twice for a retained element
{f(x) | x <- xs, f(x) > 0}
# Better — `f(x)` runs once, named `y`
{y | x <- xs, y: f(x), y > 0}Axioma’s hashes use curly braces with key: value pairs
(you’ll meet them properly in Chapter 14). Comprehensions can build them
too — the only change is that the output expression is a key-value pair
instead of a single value:
xs: [1, 2, 3, 4, 5]
# Axioma pipe form
squares: {x: x * x | x <- xs}
# {1:1, 2:4, 3:9, 4:16, 5:25}
# Python pipe-less form
squares: {x: x * x for x in xs}
# same resultFilters and walrus bindings work as expected:
big_squares: {x: y | x <- xs, y: x * x, y > 4}
# {3:9, 4:16, 5:25}The result is a hash; you index it with squares[3]
(which is 9) or squares.3.
When you wrap a comprehension in parens instead of brackets or braces, Axioma builds a generator: a lazy iterator that produces elements one at a time on demand, instead of eagerly materialising them all up front.
xs: [1, 2, 3, 4, 5]
# Eager — builds the full array immediately
arr: [x * 2 | x <- xs] # [2, 4, 6, 8, 10]
# Lazy — builds a generator that produces 2, then 4, then …
gen: (x * 2 | x <- xs) # <generator>
gen: (x * 2 for x in xs) # Python form works tooYou consume a generator with these four built-ins:
| Builtin | Effect |
|---|---|
force(gen) |
Pull everything into an array. |
gen_take(n, gen) |
Pull the first n elements; rest stay in the gen. |
gen_drop(n, gen) |
Advance past n elements (mutates, returns gen). |
gen_next(gen) |
Pull one element; returns Ω when exhausted. |
Why does laziness matter? Because the source can be
infinite. Axioma exposes a few classical infinite sets
via the infinite_set builtin — "naturals",
"integers", "primes", "evens",
"odds", "fibonacci". Wrapped in a lazy
comprehension they become pull-driven streams:
# Naturals: 1, 2, 3, 4, … (genuinely infinite)
nats: (n | n <- infinite_set("naturals"))
# Take just the first 10 — runs in constant time, no overflow
first_ten: gen_take(10, nats) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]Eager comprehension over an infinite source would never finish. Lazy generators give you the syntactic convenience of comprehensions for problems that cannot be solved eagerly.
A common pattern: filter expensively, take a few:
# Find the first 5 prime numbers > 100 (without computing them all)
big_primes: (p | p <- infinite_set("primes"), p > 100)
first_five: gen_take(5, big_primes)
# [101, 103, 107, 109, 113]Note. The bare names
naturals,integers,primesin Axioma refer to finite samples (the first 100 naturals, etc.) — handy for quick exploration but not infinite. Reach forinfinite_set("…")when you want the genuinely-unbounded form.
A lazy generator is not restricted to one source and one filter. Everything you can write in a bracket comprehension works inside the parens too — multiple generators, walrus bindings, tuple destructuring:
# Multiple generators — a lazy Cartesian product
pairs: ((x, y) | x <- [1, 2], y <- [10, 20])
force(pairs) # [(1,10), (1,20), (2,10), (2,20)]
# Walrus binding inside the lazy clause list
squares: (s | x <- [1, 2, 3, 4], s: x * x)
force(squares) # [1, 4, 9, 16]
# First-generator tuple destructure
labelled: (n + len(s) | (n, s) <- [(1, "a"), (2, "b")])The chain stays lazy all the way through.
gen_take(2, pairs) pulls only as much of the product as it
needs — the outer loop advances exactly when the inner one is exhausted,
never sooner. A two-generator stream over two infinite sources is still
perfectly safe to take from.
The source of any generator clause can be a concept extent or a relational query, not just a list — the same shapes you met for eager comprehensions:
concept Planet
Planet has name: ""
earth: a Planet {name: "Earth"}
gen: (p.name | p <- Planet)
println(force(gen)) # ["Earth"]
relation planet_parent(adult, child)
planet_parent("John", "Mary")
kids: (c | c <- planet_parent("John", c))
println(force(kids)) # ["Mary"]limit and
offsetTwo clauses borrowed from SQL cap the output of any
comprehension, eager or lazy. limit N keeps at most
N rows; offset N skips the first
N:
[x | x <- naturals, limit 5] # [1, 2, 3, 4, 5]
[x | x <- naturals, offset 6, limit 3] # [7, 8, 9]
# On a lazy generator, limit bounds total pulls — so a
# capped stream over an infinite source terminates:
g: (n | n <- infinite_set("naturals"), limit 4)
force(g) # [1, 2, 3, 4]limit and offset are soft keywords
— they are still ordinary identifiers you can use as variable or field
names everywhere else; the parser only reads them as clause keywords
when they open a comprehension clause.
Python’s comprehensions are excellent at what they do. Axioma covers the same surface (you can paste Python comprehensions directly into Axioma code) and then keeps going. Here are the features that only Axioma has:
| Feature | Python | Axioma |
|---|---|---|
| List comprehensions | ✅ | ✅ |
| Set comprehensions | ✅ | ✅ |
| Dict comprehensions | ✅ | ✅ |
Filters with if |
✅ | ✅ |
| Multiple generators (Cartesian) | ✅ | ✅ |
| Walrus binding | ✅ | ✅ (×3 forms) |
| Lazy generator expressions | ✅ | ✅ (full clause surface) |
limit / offset row
caps |
❌ | ✅ |
Mathematical pipe form | |
❌ | ✅ |
| Iteration-local walrus scope | leaks | ✅ |
Multi-filter folds with and |
✅ | ✅ |
Bare-concept iterables <- C |
❌ | ✅ |
is-comprehensions |
❌ | ✅ |
| Relational fact queries | ❌ | ✅ |
| Cross-relation unification | ❌ | ✅ |
| Variable-chain unification | ❌ | ✅ |
Tag-filter
@theorem/@axiom |
❌ | ✅ |
Implicit _1, _2
binders |
❌ | ✅ |
The last six rows are the interesting ones — they’re features that are not built into Python’s comprehensions. Python libraries can provide related facilities; Axioma integrates them into its language. They’re previewed in Chapter 21 (logic programming) and put to serious work in Chapters 24, 36, and 42. Here is a small, self-contained preview:
concept Country
Country has name: ""
example_country: a Country {name: "Exampleland"}
println({c.name | c <- Country}) # {"Exampleland"}
relation preview_parent(child, adult)
relation preview_grandparent(child, gp)
preview_parent("Alice", "Mary")
preview_parent("Mary", "John")
preview_grandparent(C,G) whenever preview_parent(C,P) and preview_parent(P,G)
println({X | preview_parent(X,Y) and preview_grandparent(X,Z)}) # {"Alice"}
println({X @theorem | X <- preview_grandparent(X, _)}) # {"Alice"}Concepts receive a fuller treatment in Chapter 18. But the
comprehension syntax you’ve learned here will carry you the whole way:
the same {expr | source, condition} shape works over
arrays, sets, concept extents, fact stores, and lazy streams without
changing surface.
Aside — comprehensions in other languages. SQL has comprehensions in disguise (
SELECT ... FROM ... WHERE ...), Haskell has them with multiple generators and guards, F# has them as sequence expressions, LINQ has them in C# and VB. Each language tweaks the syntax (yield,from/select,for/if), but the underlying idea — describe what you want, not how to build it — is the same everywhere. Axioma’s contribution is making the same notation work over both data collections and a logical fact base.
A useful exercise: pick one simple question and ask how many surface forms answer it. Take “is there a person named Mike in this set?”. The collection is
concept Person
Person has name: ""
mike, alice: a Person
mike.name: "Mike"
alice.name: "Alice"
persons: {mike, alice}and the predicate is x.name == "Mike". Here are
seventeen working forms that all return
true:
# --- Quantifier family ---
exists x in persons | x.name == "Mike" # English-natural
∃ x in persons | x.name == "Mike" # Unicode
# --- Set-comprehension family (matching set is non-empty) ---
len({x | x <- persons, x.name == "Mike"}) > 0 # cardinality test
{x | x <- persons, x.name == "Mike"} != {} # non-empty test
not ({x | x <- persons, x.name == "Mike"} == {}) # De Morgan flip
len({p for p in persons if p.name == "Mike"}) > 0 # Python pipe-less
{p for p in persons if p.name == "Mike"} != {} # Python pipe-less
# --- List-comprehension family (indicator sum > 0) ---
sum([1 | p <- persons, p.name == "Mike"]) > 0 # probability-style
sum([1 for p in persons if p.name == "Mike"]) > 0 # Python
len([1 for p in persons if p.name == "Mike"]) > 0 # Python count
# --- Higher-order ---
len(filter(func(p) [p.name == "Mike"], persons)) > 0
reduce(func(acc, p) [acc or p.name == "Mike"], false, persons)
# --- Walrus binding (compute name once) ---
{x | x <- persons, n: x.name, n == "Mike"} != {}
# --- Lazy generator ---
g: (x | x <- persons, x.name == "Mike")
len(force(g)) > 0 # materialize then check
len(gen_take(1, (x | x <- persons, x.name == "Mike"))) > 0 # SHORT-CIRCUIT
# --- Hash destructure (different source shape) ---
hashes: [{name: "Mike"}, {name: "Alice"}]
len({n | {name: n} <- hashes, n == "Mike"}) > 0
# --- Negated universal — ∃x.P(x) ≡ ¬∀x.¬P(x) ---
not (forall x in persons | x.name != "Mike")Plus the related-but-distinct membership test, which presupposes you already have the witness:
mike in persons # "is mike one of them?"Why do all these exist? Because each one fits a different
reader’s mental model. Math readers parse the pipe form
naturally. Python programmers see for x in xs if P(x) as
familiar territory. SQL writers think in
having/limit. Functional programmers reach for
reduce. Probability and measure-theory readers see the
indicator sum. Logic programmers want the <- from
Prolog.
These forms express the same question, but do not all use the same execution strategy. Choose the clearest form, then consider how much of the input it consumes and whether it builds an intermediate collection.
Short-circuit note. Quantifier forms
(exists/∃) and
gen_take(1, lazy_gen) stop at the first match. Eager
comprehension forms scan the whole collection. For large inputs and
cheap predicates, the short-circuit forms can be orders of magnitude
faster — at zero readability cost.
This pattern (one question, many surface forms) repeats throughout
Axioma: classification (is, ancestor walk, intensional
class membership), property access (dot, possessive, hash bracket),
concept declaration (block, has, inheritance,
defines). The textbook section §10.9 just made it visible
for comprehensions; you’ll meet the same shape elsewhere.
Back in §10.6 you built a pipeline out of named intermediate results:
above_10: filter(func(x) [x > 10], nums)
evens: filter(func(x) [x % 2 == 0], above_10)Each name holds a value — the result of one step. That
works, but notice what you cannot do with it: you cannot hand the
pipeline to map, store it in a dict, or run it on a
different list tomorrow. The pipeline exists only while those variables
do.
Composition fixes that by naming the transformation instead of the result.
Two functions, wired end to end. The output of the first becomes the input of the second, and the pair behaves as a single new function:
#language axioma/beginner
add_one: func(x) [x + 1]
times_ten: func(x) [x * 10]
both: add_one >> times_ten # a NEW function — nothing has run yet
println(both(3)) # 40 (3 + 1 = 4, then 4 * 10 = 40)Read >> as “and then”.
add_one >> times_ten is “add one, and then multiply
by ten”. Nothing is computed until you call it.
The crucial part: both is an ordinary value. You can map
it, store it, pass it, or compose it again.
println(map(both, [1, 2, 3])) # [20, 30, 40]Compare these two definitions of the same thing:
#language axioma/beginner
strip_it: func(s) [trim(s)]
shrink_it: func(s) [lower(s)]
# pointed — names the argument s
normalize_pointed: func(s) [ lower(trim(s)) ]
# point-free — never mentions s at all
normalize: strip_it >> shrink_it
println(normalize(" Hello ")) # hello
println(normalize_pointed(" Hello ")) # helloBoth compute the same thing. The difference is structure: in the point-free version the pipeline is assembled from named stages, so each stage can be tested and reused on its own. In the pointed version the stages are buried inside a nest of calls.
The name “point-free” comes from mathematics, where an argument is called a point. Point-free means argument-free.
Axioma writes composition four ways. They differ only in reading order:
#language axioma/beginner
f: func(x) [x + 1]
g: func(x) [x * 10]
println(compose(f, g)(3)) # 40 named form: "f, then g"
println((f >> g)(3)) # 40 infix: "f and then g"
println((g << f)(3)) # 40 infix: "g after f"
println((g ∘ f)(3)) # 40 glyph: "g after f"The first two read left to right, like a shell
pipeline or a recipe. The last two read right to left,
the way mathematics has written composition for over a century:
g ∘ f is spoken “g after f”, and the function nearest the
argument runs first.
Both directions are correct; they are the same operation read from opposite ends. Axioma ships both rather than forcing you to translate in your head — but notice the argument order flips:
f >> g is the same function as g << f
If you only ever want one, use >>. It reads in the
order things happen, which is what most people want most of the
time.
∘is typed as\circin most editors, but you never have to.<<is the same operator in plain ASCII, and`circworks too.
The result of composing is itself composable, without limit:
#language axioma/beginner
f: func(x) [x + 1]
g: func(x) [x * 10]
h: func(x) [x - 2]
step1: f >> g # a function
step2: step1 >> h # composing a composition
println(step2(3)) # 38This is why composition scales where nesting does not.
h(g(f(x))) grows a parenthesis every time;
f >> g >> h grows a word.
Because compose() with no arguments is the
identity — the function that returns its input unchanged — a
list of stages can be folded into a single function, whatever its
length:
#language axioma/beginner
f: func(x) [x + 1]
g: func(x) [x * 10]
stages: [f, g]
built: reduce(func(acc, s) [compose(acc, s)], compose(), stages)
println(built(3)) # 40
empty: reduce(func(acc, s) [compose(acc, s)], compose(), [])
println(empty(3)) # 3 — no stages, so nothing happensThat last line is the point of having an identity: the empty pipeline is not an error, it is the pipeline that does nothing.
convergeComposition builds a chain: one value in, one value out, one stage at a time. Some computations need a branch instead. A mean, for instance, needs the sum and the count of the same list, divided:
#language axioma/beginner
total: func(xs) [reduce(func(a, b) [a + b], 0, xs)]
count_of: func(xs) [len(xs)]
divide: func(a, b) [a / b]
mean: converge(divide, [total, count_of])
println(mean([2, 4, 6, 8])) # 5converge(combiner, [f, g]) feeds the
same input to f and to g,
then hands both results to combiner. Read it as “fork, then
rejoin”. Without it you would have to name the list just to mention it
twice.
onThe other shape a chain can’t express: applying a preparation step to both arguments of a two-argument function.
#language axioma/beginner
length_of: func(s) [len(s)]
longer: on(func(a, b) [a > b], length_of)
println(longer("hello", "hi")) # true — compares by lengthon(cmp, key)(a, b) is cmp(key(a), key(b)).
Composition cannot do this: cmp ∘ key would feed one result
to a function expecting two.
(f >> g)(3) # 40 — compose, then call
f >> g(3) # ERROR — reads as f >> (g(3)), composing with a numberParenthesise the composition before calling it, exactly as you would on paper.
|> is not the same
thingYou will see |> used for pipelines too, and it is
worth keeping them apart:
3 |> f |> g # 40 — a computation. There is a 3 in it.
f >> g # a FUNCTION. Reusable, mappable, storable.|> pushes a value through stages, right now.
>> builds a function out of stages, for
later. Same reading direction, different product. Use |>
when you have the value in hand; use >> when you want
to keep the pipeline.
| Want | Write |
|---|---|
| a function that does A then B | A >> B |
| the same, mathematical order | B << A or B ∘ A |
| the same, as a call | compose(A, B) |
| run a value through stages now | x |> A |> B |
| a do-nothing function | compose() |
| fork and rejoin | converge(combine, [A, B]) |
| compare by a key | on(cmp, key) |
The full treatment — including where composition came from and how
Axioma compares with other languages — is in
resources/docs/claude/FUNCTION_COMPOSITION.md.
Suppose you already know how to double one number. How do
you apply that rule to every number in an array? Section 10.2 used
map. Broadcasting gives an ordinary
function an elementwise call form, written with a dot immediately before
the parentheses:
#language axioma/beginner
double: func(x) [2 * x]
nums: [1, 2, 3]
println(double.(nums)) # [2, 4, 6]
println(map(double, nums)) # [2, 4, 6]
println(nums.map(double)) # [2, 4, 6]double still describes one number. The call
double.(nums) applies it at each position and builds an
array of answers. The input array is unchanged. For this single-array
example, all three expressions give the same result.
nums.map(double) is a collection-method spelling of
map(double, nums); its dot is followed by a
name, whereas a broadcast dot is followed by
parentheses. Neither operation means “change
nums.”
The important extension beyond this example is that broadcasting can coordinate several arguments and reuse a scalar across all positions. It is useful for numerical sampling, unit conversion, and transformations of records or strings too.
A sensor produces readings. To calibrate one reading, multiply it by a gain, then add an offset. Follow the design recipe:
calibrate : Number × Number × Number → Number.(2, 3, 1) should produce
7; (0, 3, 1) should produce
1.#language axioma/beginner
calibrate: func(reading, gain, offset) [reading * gain + offset]
expect("one reading", calibrate(2, 3, 1), 7)
expect("zero reading", calibrate(0, 3, 1), 1)
readings: [0, 1, 2]
println(calibrate.(readings, 3, 1)) # [1, 4, 7]
println(calibrate.(readings, [2, 3, 4], 1)) # [1, 4, 9]
println(calibrate.(readings, [3], 1)) # [1, 4, 7]
println(calibrate.([], 3, 1)) # []In the first call, the gain and offset are scalars:
each reading uses the same values. The second call supplies one gain per
reading. The third supplies a singleton array, an array
of length one; its one element is reused at every position. Axioma does
not first allocate a repeated array such as [3, 3, 3] to do
this. The final result is a new array.
For one-dimensional inputs, lengths must match or one must be one. Two arrays of lengths three and two do not get silently truncated, cycled, or padded. The call fails with a shape error before any elementwise call begins. An empty compatible result makes no calls to the scalar function.
For an ordinary function-call spelling, write
broadcast(calibrate, readings, 3, 1). It computes the same
values here. We will shortly see why its evaluation boundary differs
from nested dotted syntax.
A broadcast input that is an Array, Tuple, List, or finite Range has one axis. Strings and dictionaries are scalar values. A nested array is still an array of elements; broadcasting does not recursively flatten it or treat its inner arrays as axes:
#language axioma/beginner
println(length.(["red", "blue"])) # [3, 4]
println(length.([[1, 2], [3]])) # [2, 1]
println(length.("red")) # 3
println(abs.(-2..2)) # [2, 1, 0, 1, 2]The second expression calls length on each inner array.
The third has only a scalar argument, so it calls length
once and returns a scalar. You choose the level at which a function
works by choosing what its arguments contain.
Broadcasting eagerly creates its result, with a limit of one million output elements. For the unbounded generators from §10.9, first select a finite portion and collect it; broadcasting is not a way to consume an infinite sequence.
For familiar arithmetic, dotted operators provide a compact form:
#language axioma/beginner
nums: [1, 2, 3]
println(nums .+ 10) # [11, 12, 13]
println(nums .* 2) # [2, 4, 6]
println(nums .^ 2) # [1, 4, 9]
println(nums .+ [10, 20, 30]) # [11, 22, 33]Keep the dot: ordinary array + concatenates, as Chapter
6 explained. Here .+ adds numbers at corresponding
positions.
An important current distinction: dotted arithmetic
on Arrays supports numeric scalars and equal-length arrays, but not
singleton-array expansion. With add: func(a, b) [a + b],
add.([1, 2, 3], [10]) works; [1, 2, 3] .+ [10]
fails. Function broadcasting has the more general shape rules. Do not
assume that replacing a dotted function with a dotted operator preserves
every accepted shape.
Suppose you want to square every number and then increment it:
#language axioma/beginner
square: func(x) [x * x]
increment: func(x) [x + 1]
nums: [1, 2, 3]
println(increment.(square.(nums))) # [2, 5, 10]
println(increment.(nums .^ 2)) # [2, 5, 10]These connected dotted expressions use loop fusion.
For the first element, Axioma computes square(1) and then
increment(1); for the second, square(2) and
then increment(4); and so on. Only the final result array
is created. There is no intermediate array [1, 4, 9]
between the two functions.
Fusion saves intermediate collections and their allocation work. It
does not promise that every expression runs faster; measure when speed
matters. It also does not turn a reduction such as
sum(increment.(square.(nums))) into that same loop: the
dotted part produces its array, and sum then consumes
it.
Bindings and ordinary calls provide an explicit way to finish one stage before starting the next:
#language axioma/beginner
square: func(x) [x * x]
increment: func(x) [x + 1]
nums: [1, 2, 3]
squares: square.(nums)
println(increment.(squares)) # [2, 5, 10]
println(increment.(broadcast(square, nums))) # [2, 5, 10]
println(nums |> square.(_) |> increment.(_)) # [2, 5, 10]All three forms materialize the squared array before incrementing it. Ordinary pipe stages are boundaries too. The underscore is the pipe’s argument placeholder: it receives the whole array, then the dotted call applies its function element by element. Without a placeholder, the pipe adds the value as the last argument, just as in §10.10. It does not search inside nested calls for a placeholder.
For pure functions such as square, fusion and separate
stages give the same values. Functions can also print, draw random
numbers, or update state. Then the order is observable. Predict the
output before running this example:
#language axioma/beginner
inside: func(x) [println("inside", x); x + 10]
outside: func(x) [println("outside", x); x * 2]
println(outside.(inside.([1, 2])))
# inside 1
# outside 11
# inside 2
# outside 12
# [22, 24]If you instead bind middle: inside.([1, 2]) and then
evaluate outside.(middle), both inside calls
finish first. Use that explicit intermediate when your intent is “finish
all of stage one, then start stage two.”
Source expressions and function expressions are evaluated once. The scalar operations inside a fused expression run at each final output position. An inner operation on a singleton can therefore run more than once as its axis expands; its result is not automatically cached. Shape errors are caught before those scalar calls. A callback error stops evaluation without retry or rollback of effects that already happened.
The same mechanism supports values with explicit dimensions:
#language axioma/beginner
add: func(a, b) [a + b]
row: matrix([[1, 2]])
column: matrix([[10], [20]])
answer: add.(row, column)
println(shape(answer)) # [2, 2]
println(answer[:]) # [11, 12, 21, 22]
println(add.(answer, [100, 200])[:]) # [111, 212, 121, 222]Here the shapes [1, 2] and [2, 1] expand to
[2, 2]. Axes align from the right: an
array of length two matches the final axis, the columns in this example.
The walk is row-major, agreeing with matrix linear indexing. The nested
arrays passed to matrix are construction data; it is the
resulting Matrix, not an ordinary nested array, that has two broadcast
axes.
A Matrix input produces a Matrix result; a Tensor input takes precedence and produces a Tensor. Ordinary sequences produce an Array. Matrix cells preserve exact Integer and Rational values. Tensor storage remains floating-point and rejects silent precision loss. Only the final container is constructed in a fused chain, so intermediate scalar values are not forced through an intermediate Matrix or Tensor conversion. These numeric-container details matter when choosing a materialization boundary; the manual’s “Function broadcasting” section is the reference for them.
Execution mode: these examples use the interpreter,
including axioma/beginner. Function broadcasting and
connected dotted expressions requiring fusion are not implemented in the
VM; the VM explicitly rejects them. This does not remove its support for
standalone dotted arithmetic.
Choose the tool that describes the task. Use
map for a simple transformation of one sequence,
broadcasting to combine scalar rules across compatible shapes, and
reduce to combine many values into one. Connected dots
fuse; ordinary calls, bindings, and pipe stages let you preserve a
deliberate boundary.
triple_eachUse map to triple every element of an array. One
line.
triple_each([]) → []triple_each([1, 2, 3]) → [3, 6, 9]keep_positiveUse filter to return only the positive elements (>
0).
keep_positive([]) → []keep_positive([1, -2, 3, 0]) → [1, 3]Note: 0 is not positive.
productUse reduce to multiply all elements of an array. Watch
the starting value — what’s the identity for *?
product([]) → 1product([4]) → 4product([2, 3, 5]) → 30sum_of_squares with HOFsRe-do Exercise 7.3, but this time use map and
reduce in combination. The structure is “square each, then
sum.” Two function calls, no if in sight.
sum_of_squares([]) → 0sum_of_squares([3]) → 9sum_of_squares([1, 2, 3]) → 14all_ofDefine all_of(pred, arr) — true exactly
when every element satisfies pred. (The dual of
any_of from §10.7.)
all_of(func(x) [x > 0], []) → true
(vacuously!)all_of(func(x) [x > 0], [1, 2, 3]) →
trueall_of(func(x) [x > 0], [1, -1, 3]) →
falseYes, the empty case really is true. Think about what
“every element of nothing satisfies P” should mean — there are zero
violators, so the claim holds. This is called vacuous
truth and shows up in mathematical logic too.
count_ifWrite count_if(pred, arr) — how many elements satisfy
pred?
count_if(func(x) [x > 10], []) → 0count_if(func(x) [x > 10], [3, 12, 8, 99]) →
2Two designs are possible:
count_above from
Exercise 7.4 but take the predicate as a parameter).filter then
len.Implement both. Compare the two solutions side by side. Which one is easier to read? Easier to change if the requirements shift slightly? You’ll meet this kind of trade-off — direct recursion vs. HOF composition — many times. Neither one always wins.
Rewrite each of these three functions as a one-line comprehension. No
map, no filter, no recursion — just the
comprehension surface.
doubles_of([1, 2, 3]) → [2, 4, 6]positives_of([1, -2, 3, -4]) → [1, 3]squared_positives([1, -2, 3, -4]) →
[1, 9]For the third one, write it two ways: first with nested
filter/map, then as a comprehension with a
filter clause. Time how long each took you to write. Read both back a
week from now — which one will be easier to remember?
Each of the three Python comprehensions below has an Axioma equivalent. Write each Axioma form in two styles: pipe form and Python pipe-less form. Both should produce the same result.
# Python
[x ** 2 for x in range(1, 6)]
{c.upper() for c in "hello"}
{w: len(w) for w in ["red", "green", "blue"]}For the first one in Axioma, use 1..5 as the range. For
the second, iterate over "hello" and apply
upper(c) to each character. An explicit array
["h", "e", "l", "l", "o"] works too.
Bonus: write the first one a third way, using
map over the range. Compare all three. Which reads
best?
The function cube_root(n) is expensive — say it takes a
millisecond per call. You want all integers in 1..20 whose
cube root is greater than 2, paired with that cube root.
A naive comprehension:
{(n, cube_root(n)) | n <- 1..20, cube_root(n) > 2}…calls cube_root for every candidate and again for each
selected result. Rewrite it using a walrus binding
(r: cube_root(n)) so cube_root(n) runs only
once per candidate.
You have an array of names. Build a hash mapping each name to its length:
name_lengths(["Anna", "Bob"]) →
{"Anna": 4, "Bob": 3}name_lengths([]) → an empty hash. It prints as
{}, but don’t write it that way in source — a bare
{} is the empty set; the empty-hash literal is
dict().Write it as a dict comprehension — both the pipe form and the Python
form. Then write the same function using reduce
over an empty hash (seed the fold with dict(), not
{}), accumulating one key per iteration. Which version is
shorter? Which is easier to explain to someone who hasn’t seen
comprehensions before?
infinite_set("naturals") is the genuinely-infinite
sequence 1, 2, 3, 4, …. You cannot materialise it — that would never
finish. But you can build a lazy generator over it and take a
finite prefix:
For each task, write the comprehension as
(expr | n <- infinite_set("naturals"), filter) and then
call gen_take(7, gen) or gen_take(3, gen) on
the result. Notice that the generator does not get pulled at all until
you call gen_take — that’s the laziness paying off.
Open the comparison table in §10.9 above. Pick one row whose Python column is ❌ — a feature Python doesn’t have. Write a one-paragraph explanation of why Python doesn’t have it.
You will need to think about what Python’s data model is and what Axioma’s data model adds on top. Some rows are easier than others — the “Mathematical pipe form” row is a pure syntax choice; the “Relational fact queries” row reflects a deeper language design decision.
There is no single correct answer. The exercise is to articulate the trade-off in your own words.
Write calibrate(reading, gain, offset) from §10.11, then
use a single dotted call for each task. Keep the function itself
scalar.
[0, 1, 2] with gain 3 and offset
1: [1, 4, 7].[2, 3, 4] instead:
[1, 4, 9].[3]:
[1, 4, 7].[].Explain why gains [2, 3] cannot be matched to three
readings. Should the function run on any reading before that shape
error?
Starter:
exercises/ch10/ex_10_13_broadcast_calibration.ax.
Given square: func(x) [x * x] and
increment: func(x) [x + 1], write two versions that produce
[2, 5, 10] from [1, 2, 3]:
Then use reduce to total the first result, giving
17. Which expressions allocate the squared array? Does
adding the ordinary reduce call fuse the reduction into the
dotted walk?
Starter:
exercises/ch10/ex_10_14_broadcast_fusion.ax.
Use the printing inside and outside
functions from §10.11. Predict, then run, all three forms:
outside.(inside.([1, 2]))outside.(broadcast(inside, [1, 2]))[1, 2] |> inside.(_) |> outside.(_)Record the printed order and the final array separately. Explain which version you would choose if every inner operation must finish before any outer operation starts. What would you do if an expensive inner operation on a singleton should run once and its result should then be reused across a larger output?
Starter: exercises/ch10/ex_10_15_broadcast_order.ax.
Next chapter — we drop the requirement to
name every function. Anonymous functions, written
lambda x => x + 1, let us inline tiny one-off
computations without the ceremony of func(x) [x + 1]. It’s
a small syntactic shift with surprisingly large stylistic
consequences.
Solutions to selected exercises: Chapter 10 · Solutions in Appendix C. (Repo file: exercises/solutions/ch10_solutions.md.)
What this chapter is. A small syntactic upgrade. We stop giving a name to every one-off function. The result reads better, especially inside
map/filter/reducecalls.Heads up: this is the one chapter where we leave the
axioma/beginnersubset. The newlambda x => exprform is not allowed in beginner mode — and that’s deliberate, because the beginner subset wants you to practise naming functions first. Now that you can, we relax the rule.
Look back at the examples from Chapter 10. They’re full of disposable functions:
#language axioma/all
nums: [3, 1, 4, 1, 5, 9, 2, 6]
is_big: func(x) [x > 4]
println(filter(is_big, nums))The name is_big is used at one call site. Naming it is
optional: filter(func(x) [x > 4], nums) already passes
an anonymous function. The lambda form is a shorter spelling for the
same idea.
Most languages — Python, JavaScript, Scheme, Haskell, anything in the ML family — let you write the function inline without naming it. Axioma’s name for the unnamed-function syntax is lambda, after the Greek letter from Alonzo Church’s 1936 λ-calculus.
nums: [3, 1, 4, 1, 5, 9, 2, 6]
println(filter(lambda x => x > 4, nums)) # [5, 9, 6]Same result, no intermediate variable. The
lambda x => x > 4 is the function — it has
no name, but you can pass it anyway.
Use #language axioma/all for this chapter. If you
combine snippets into one file, remove an earlier beginner pragma rather
than expecting a blank line or a new code fence to change the language
mode. Beginner mode forbids lambda (it nudges you toward
naming). Once you graduate from naming-every-function (as you have, by
finishing Chapter 10), you’re allowed to drop the pragma and use
lambda.
Axioma’s lambda syntax has three forms:
# One argument, no parens needed:
lambda x => x * 2
# Multiple arguments, parens required:
lambda (x, y) => x + y
# Zero arguments — empty parens:
lambda () => "hello"For comparison, here are the equivalent func forms
(which do work in beginner mode):
func(x) [x * 2]
func(x, y) [x + y]
func() ["hello"]Pick whichever reads better in context. The general rule:
map(lambda x => x * 2, ...) is shorter
and doesn’t visually nest brackets.func —
area: func(r) [3.14159 * r * r] and then call
area(5), area(10). The name is the
documentation.sort_by_keyProblem. Sort a list of records (think: an array of
{name, age} hash literals) by a key the caller
specifies.
Axioma has sorting builtins (sort, sort_by)
— but building the machinery ourselves is the point of this section:
we’ll implement insertion sort, then parameterize it on the
comparison.
1. Data. An array of comparable items (numbers, strings, or hashes with a numeric/string field).
2. Signature.
# sort_by: (Func[A, A -> Boolean], Array[A]) -> Array[A]The first parameter is a function — specifically, a
two-argument function that returns true if its first
argument should come before its second.
3. Examples.
# sort_by(lambda (a, b) => a < b, [3, 1, 4, 1, 5])
# = [1, 1, 3, 4, 5]
# sort_by(lambda (a, b) => a > b, [3, 1, 4, 1, 5])
# = [5, 4, 3, 1, 1] (reversed)4–5. Template / body. Insertion sort: walk the input, inserting each item into its sorted position in the accumulator.
insert_sorted: func(less, x, sorted) [
if len(sorted) == 0 then [x,]
else if less(x, first(sorted)) then [x | sorted]
else [first(sorted) | insert_sorted(less, x, rest(sorted))]
]
sort_by: func(less, arr) [
if len(arr) == 0 then []
else insert_sorted(less, first(arr), sort_by(less, rest(arr)))
](The trailing-comma singletons [x,] are the §6.6
spelling — these sit at the start of branches, where a bare
[x] would be read as a block.)
(We call our helper insert_sorted, not
insert. insert is a reserved built-in name in
Axioma — it inserts facts into the logic-programming knowledge base.
Trying to redefine it here would silently shadow that builtin. The
convention “if it errors with
must be a string relation name, your name clashes with a
builtin” is one of those papercuts every Axioma programmer learns
once.)
6. Tests.
println(sort_by(lambda (a, b) => a < b, [3, 1, 4, 1, 5]))
# [1, 1, 3, 4, 5]
println(sort_by(lambda (a, b) => a > b, [3, 1, 4, 1, 5]))
# [5, 4, 3, 1, 1]That’s the whole point of accepting less as an
argument: one implementation, two behaviours. Want descending? Flip the
comparison. Want sort-by-second-coordinate? Pass
lambda (a, b) => a[2] < b[2]. Want
sort-by-string-length? Pass
lambda (a, b) => len(a) < len(b). No rewrite
needed.
Lambda isn’t universally better. Three cases where naming wins:
Reuse. If the function is used more than once, name
it. is_positive: func(x) [x > 0] and then
filter(is_positive, arr1) and
filter(is_positive, arr2). The name documents intent;
copy-pasting the lambda hides the duplication.
Complexity. If the body has more than one line, name
it. A five-line lambda buried inside a reduce call is
unreadable. Pull it out:
# DON'T:
reduce(lambda (acc, x) => [if x > 100 then acc + x else acc], 0, nums)
# DO:
add_if_big: func(acc, x) [
if x > 100 then acc + x else acc
]
reduce(add_if_big, 0, nums)(The first version is also a syntax trap — putting an if
in a lambda body without brackets can confuse the parser. Named
func bodies have explicit [...] and don’t
suffer this.)
Recursion. A lambda can’t easily refer to itself by name (there is no name). If your function needs to call itself, give it a name with a binding:
# Won't work — lambda has no name to recurse with:
# map(lambda n => if n <= 1 then 1 else n * RECURSE(n-1), [1,2,3,4,5])
# Works:
fact: func(n) [ if n <= 1 then 1 else n * fact(n-1) ]
map(fact, [1, 2, 3, 4, 5]) # [1, 2, 6, 24, 120]A lambda doesn’t only carry its code — it also carries any variables from the enclosing scope. That packaged-together pair of code and captured environment is called a closure.
make_multiplier: func(k) [
lambda x => x * k
]
times7: make_multiplier(7)
times11: make_multiplier(11)
println(times7(3)) # 21
println(times11(3)) # 33
println(times7(3)) # 21 (still 7 — closures don't leak)Each call to make_multiplier produces a new
closure that remembers its own k. They don’t interfere.
This is one of the deepest ideas in programming: a function can carry
data with it, without being a class with instance
variables.
We’ll use closures in Chapter 12 for cleaner local helpers, and Chapter 17 (Streams) leans on them to build lazy infinite sequences.
You haven’t gained any new computational power in this chapter. Every
lambda is equivalent to a func bound to a name. What you’ve
gained is fluency. Code that uses HOFs is now short enough to
take in at a glance:
# Adult ages from a list of people, sorted descending
people: [{age: 17}, {age: 32}, {age: 21}]
adult_ages: map(lambda p => p.age,
filter(lambda p => p.age >= 18, people))
println(sort_by(lambda (a, b) => a > b, adult_ages)) # [32, 21]Read that aloud — “sort, filtering for adults, of the ages of people.” The structure of the prose matches the structure of the code, line by line. That’s the prize.
Take your solution to Exercise 10.1 (triple_each) and
rewrite the inline function as a lambda. Same for 10.2 and
10.3. Don’t introduce any named functions.
negate_allUse map and a lambda to negate every element of a
list.
negate_all([]) → []negate_all([1, -2, 3]) → [-1, 2, -3]split_byWrite split_by(pred, arr) that returns a tuple
(yes_arr, no_arr): the elements that satisfy
pred and the elements that don’t. (One pass through the
array — don’t call filter twice.)
split_by(lambda x => x > 0, []) →
([], [])split_by(lambda x => x > 0, [1,-2,3,-4]) →
([1, 3], [-2, -4])Hint: recur on rest(arr) and let the recursive call
return the pair (yes_arr, no_arr); then decide which half
first(arr) belongs in. Tuple syntax: (a, b);
tuple access is 1-based, like arrays: t[1],
t[2].
(We call it split_by instead of the conventional name
partition because partition is a reserved
keyword in Axioma — it builds disjoint concept hierarchies. Same
papercut as insert in §11.3.)
compose yourselfAxioma already ships compose, plus the
>> / << / ∘ operators
(see §10.10). Build one anyway — under a different name, so you don’t
shadow the builtin — because writing it is how you see that composition
is just a lambda returning a lambda.
Define after(f, g) as a function that returns a new
function: “first apply f, then apply
g.”
after(lambda x => x + 1, lambda x => x * 2)(5) →
12 (5 + 1 = 6, then 6 * 2 = 12)Implement after as a one-line lambda whose body is a
lambda.
Then check your work against the builtin — they should agree, because they are the same function:
add1: lambda x => x + 1
dbl: lambda x => x * 2
after(add1, dbl)(5) # your version
compose(add1, dbl)(5) # the builtin — 12
(add1 >> dbl)(5) # the operator — 12Note the direction. compose(f, g) and
f >> g apply f first,
reading left to right. The mathematician’s g ∘ f also
applies f first, but writes g on the left — so
f >> g and g << f are the same
function with the arguments swapped. Getting that flip right is most of
the exercise.
sort_descending in one lineReuse the sort_by from §11.3. Define
sort_descending = sort_by(lambda (a, b) => a > b, ...).
Try to write it as a function that takes only an array (i.e., apply
sort_by partially):
sort_descending([3, 1, 4, 1, 5]) →
[5, 4, 3, 1, 1]Hint:
sort_descending: lambda arr => sort_by(... , arr).
pipeMany functional languages have a “pipe” operator (in F# it’s
|>, in Elixir it’s the same). It’s syntactic sugar for
“feed the result of the left expression into the function on the right.”
Define a function pipe(x, fs) that takes a value
x and an array of functions, and applies them
left-to-right:
pipe(5, [lambda x => x + 1,
lambda x => x * 2,
lambda x => x - 3])
= (((5 + 1) * 2) - 3)
= 9
Implement this with reduce. Then write a small test that
chains four or five lambdas — and try to read the result aloud.
Does pipeline-style code feel different from nested function calls?
Next chapter — we still have one cleanup left. Code that uses HOFs heavily tends to accumulate tiny helper functions at the top of every file. Chapter 12 shows how to localize them: define them inside the function that uses them, where they’re hidden from the rest of the program.
Solutions to selected exercises: Chapter 11 · Solutions in Appendix C. (Repo file: exercises/solutions/ch11_solutions.md.)
What this chapter is. A scope-management upgrade. We move helper functions and intermediate values inside the function that uses them. The change is small, but it ends Part III with code that is genuinely modular — each function carries its own private workshop.
Back in
axioma/beginnermode for this chapter — locals are beginner-safe.
At the end of Chapter 10, our pipeline looked like this:
#language axioma/beginner
nums: [3, 11, 14, 17, 22, 8, 25, 30]
above_10: filter(func(x) [x > 10], nums)
evens: filter(func(x) [x % 2 == 0], above_10)
total: reduce(func(a, b) [a + b], 0, evens)
answer: total / len(evens)
println(answer)Three of those bindings — above_10, evens,
total — are intermediate steps of one computation.
They have no use outside of computing answer, yet they live
at the top level, visible to every other piece of code that loads this
file.
That’s a smell. The HtDP design rule says: the scope of a name should be no larger than its use. Names that are only needed inside one function should live inside that function.
You’ve actually been using locals informally since Chapter 7; this chapter just names the pattern. A function body in Axioma can have its own local bindings, separated by semicolons, with the final expression being the function’s return value:
#language axioma/beginner
pipeline: func(nums) [
above_10: filter(func(x) [x > 10], nums);
evens: filter(func(x) [x % 2 == 0], above_10);
total: reduce(func(a, b) [a + b], 0, evens);
total / len(evens)
]
println(pipeline([3, 11, 14, 17, 22, 8, 25, 30])) # 22above_10, evens, total are
invisible outside of pipeline. Try to reference
them at the top level and you get an “undefined word” error. They’re
born when pipeline is called, used to compute the answer,
and discarded when pipeline returns.
That’s lexical scoping: the region of the source code where a name is visible. Locals are visible only inside the brackets they’re declared in.
Recall the BMI exercise from Chapter 3:
# Naive version — exposes h_squared at top level
h_squared: 1.75 * 1.75 # used only inside bmi!
bmi: func(weight, height) [ weight / (height * height) ]The h_squared precomputation only made sense as part of
computing bmi. With locals, we hide it:
#language axioma/beginner
bmi: func(weight_kg, height_m) [
h_squared: height_m * height_m;
weight_kg / h_squared
]
println(bmi(70, 1.75)) # 22.857...Now h_squared is computed once per call (not redundantly
twice as height_m * height_m * weight_kg-style code would
do), and it doesn’t pollute the top level.
This is HtDP’s third design recipe payoff (after “examples catch errors early” and “data shape drives template”): locals let you give meaningful names to intermediate results without paying a global namespace cost.
Now we’re computing two statistics, and they share work. The population standard deviation is the square root of the mean squared distance from the mean. The local binding computes that mean just once. Both functions require a non-empty numeric array:
#language axioma/beginner
mean: func(arr) [
reduce(func(a, b) [a + b], 0.0, arr) / len(arr)
]
stddev: func(arr) [
m: mean(arr);
diffs: map(func(x) [(x - m) * (x - m)], arr);
variance: reduce(func(a, b) [a + b], 0.0, diffs) / len(arr);
sqrt(variance)
]
println(mean([4, 8, 6, 5, 3, 7])) # 5.5
println(stddev([4, 8, 6, 5, 3, 7])) # 1.707825127659933(Why 0.0 instead of 0 as the
reduce seed? Display, not correctness: with 0
the arithmetic stays exact, and the mean comes out as the exact rational
11/2 rather than 5.5 — the right value, in
fraction form. Seeding with 0.0 keeps the running total a
float, so the statistics print as the decimals you expect from numeric
code.)
Two things to notice:
m is the local mean — captured by the
map lambda inside stddev. That lambda
closes over m (Chapter 11’s closure idea applied
to a local). Without closures, we’d have to pass m as a
parameter to a helper, and the map API doesn’t support
that.diffs and variance are
local — they exist for the duration of one stddev
call and then vanish. If you load this file and ask the REPL for
diffs, you’ll get “undefined word.”So far the local definitions have been values (numbers, arrays). But
func(...) is itself a value — so you can define local
functions, and that’s where this technique really earns its keep:
#language axioma/beginner
max_in_each_row: func(matrix) [
row_max: func(row) [
if len(row) == 0 then 0
else if len(row) == 1 then first(row)
else [
rest_max: row_max(rest(row));
if first(row) > rest_max then first(row) else rest_max
]
];
map(row_max, matrix)
]
println(max_in_each_row([[1, 4, 2], [9, 3, 5], [7, 8, 0]]))
# [4, 9, 8]row_max is a private helper. It’s recursive (it calls
itself), and it lives entirely inside max_in_each_row. No
other code can use it, no other code can shadow it, and the name
“row_max” doesn’t appear at the top level.
This is the same pattern HtDP calls a local helper. In a larger program you’d have many such helpers — each function keeps its own toolbox.
Sometimes a helper is genuinely useful in more than one place. In that case, pull it back out to the top level — but give it a clearly general name and a comment, so it doesn’t look like a leftover scaffold:
#language axioma/beginner
# Reusable: returns the maximum of a non-empty numeric array.
max_of: func(arr) [
if len(arr) == 0 then none
else if len(arr) == 1 then first(arr)
else [
r: max_of(rest(arr));
if first(arr) > r then first(arr) else r
]
]
max_in_each_row: func(matrix) [
map(max_of, matrix)
]The rule is: start local, promote when there’s a second caller. Don’t pre-emptively make everything top-level “in case someone uses it later.” That clutters the namespace.
order, return the last one”
You’ve been using [...] brackets to delimit function
bodies, and you may have noticed they’re more flexible than that. Inside
any expression context, [stmt; stmt; expr] runs the
statements in order and returns the final expression’s value.
We used this in §12.5’s row_max helper:
else [
rest_max: row_max(rest(row));
if first(row) > rest_max then first(row) else rest_max
]The whole [...] is the else branch. It
first computes rest_max, then evaluates the if
to decide which value to return. Without the brackets, you couldn’t have
a local binding inside an if arm.
This bracket form has a name: a block expression. It’s the local-scope equivalent of REPL multi-line input.
Look back at Parts I, II, and III as a whole:
Each step reduced what the rest of your program has to know about a function’s internals. By the end of Chapter 12, a function can present a simple input-output contract on the outside while doing arbitrarily complex helper work inside.
That’s modularity. It’s the single most important property of a large codebase: each piece should be replaceable without the rest having to change. HOFs + closures + locals are the tools that make modularity possible.
Part IV puts this to work on harder data — mutually recursive records, generative recursion (the algorithmic kind, not the data-shape kind). Bring all three tools.
bmi_categoryCombine BMI with a category label (“underweight”, “normal”, “overweight”, “obese”). The thresholds are 18.5, 25, 30. Make the BMI computation a local — the caller shouldn’t be able to see it.
bmi_category(50, 1.75) → "underweight"
(BMI ≈ 16.3)bmi_category(70, 1.75) → "normal" (BMI ≈
22.9)bmi_category(85, 1.75) → "overweight" (BMI
≈ 27.8)bmi_category(100, 1.75) → "obese" (BMI ≈
32.7)triangle_area via Heron’s formulaHeron: with sides a, b, c and semi-perimeter
s = (a+b+c)/2, the area is
sqrt(s*(s-a)*(s-b)*(s-c)). Compute s once as a
local; reference it three times in the body.
triangle_area(3, 4, 5) → 6.0 (the classic
3-4-5 right triangle)triangle_area(5, 5, 6) → 12.0running_average with a local helperGiven an array [a, b, c, d, …], return the array of
running averages: [a, (a+b)/2, (a+b+c)/3, …]. Use a local
helper function that takes an index i and computes the
average of the first i elements.
running_average([]) → []running_average([4]) → [4]running_average([2, 4, 6]) →
[2, 3, 4]Hint: running_average([2, 4, 6]) should call the local
helper three times: at i=1, i=2, i=3.
Take the pipeline from §10.6 (average of even numbers > 10). Wrap
it in a function mean_of_big_evens(nums) whose body uses
locals for every intermediate value. The top-level code should have
nothing but mean_of_big_evens: ... and a few
println tests.
count_words_per_lineGiven an array of strings (each string is a “line”), return an array
of integers (the number of words per line, where a word is a maximal
non-space run). Use a local helper function
word_count(s). (Hint: split(s, " ") splits a
string on spaces; filter out empty strings to handle multiple
spaces.)
count_words_per_line([]) → []count_words_per_line(["hello world", "the", "a b c d"])
→ [2, 1, 4]Write make_counter() that returns a function.
Each time you call that function, it should return the next integer (1,
2, 3, …). Hint: think about what the closure captures.
c: make_counter()
c() # 1
c() # 2
c() # 3
d: make_counter()
d() # 1 (independent counter)
c() # 4 (c's count continues)
Two questions for after you have it working:
make_counter breaks that rule,
and why can’t a counter work without breaking it? (Axioma will run the
code either way — the discipline is a habit the book teaches, not a
parser restriction.)Welcome to the edge of Part IV — this is the first place where “a function is a black box” stops being entirely true. Functions that close over mutable state remember things between calls. We revisit this in Chapter 16 (Mutation).
End of Part III. You can now: parameterize over functions (Ch.10), inline anonymous computations (Ch.11), hide intermediate state (Ch.12). Part IV puts these to work on intertwined data — networks, expression trees, anything where the data definition has cross-references.
Solutions to selected exercises: Chapter 12 · Solutions in Appendix C. (Repo file: exercises/solutions/ch12_solutions.md.)
What this chapter is. Chapter 9 introduced functions that call each other across two data shapes (Person/Household, Lit/Op). This chapter zooms out: what if the data definitions themselves cross-reference, and the cross-reference is the point? We meet reified relationships — turning the edge between two things into a thing of its own — and we use them to model the first networks in this book: a social graph, a road map, a bill-of-materials.
The Person/Household pair from Chapter 9 already crossed the divide once: a Person knew its Household and a Household knew its Persons. That’s enough when a relationship has nothing extra to say — “this person belongs to this household” is just the fact of belonging, no metadata attached.
But real domains are messier. A social network’s edge isn’t “these two are friends,” it’s “these two have been friends since 2019, on a scale of acquaintance-to-best-friend, primarily through work.” A road’s edge isn’t “city A connects to city B,” it’s “city A to B is 95 miles, costs $4 in tolls, takes 90 minutes in rush hour.” A bill-of-materials edge isn’t “this assembly uses this part,” it’s “this assembly uses 4 of this part, sourced from supplier X, at $2.50 each.”
When the edge has attributes, you need a concept for the edge. We’ll call this pattern reifying the relationship — making the relationship a first-class object instead of just an implicit pointer.
Reify (Latin res, “thing”) — “make a thing of.” A relationship goes from being implicit (a field pointing at another object) to explicit (its own object with its own fields).
We start with people and friendships. The friendship records how long and how close; it has fields, so it’s an object.
#language axioma/beginner
concept Person
Person has name: ""
Person has age: 0
concept Friendship
Friendship has source: none
Friendship has target: none
Friendship has years: 0
Friendship has closeness: 0 # 1=acquaintance, 5=best friendWe have three concepts: two “endpoint” concepts (just Person
for now — friendship is symmetric) and one “edge” concept (Friendship).
The edge points at both endpoints via source and
target.
Why two fields, not one? A symmetric relation (
friendship) might seem like it should have a single{partyA, partyB}set. We usesource/targetbecause Axioma’s Concepts don’t have built-in set-typed slots — and because in practice, even “symmetric” relationships have a direction in storage (someone initiated the friendship, the data was entered in some order). For asymmetric edges (a follows-on-social-media relation, a parent-of relation, road from A to B), source/target is the natural fit.
Build a small graph:
alice: a Person {name: "Alice", age: 30}
bob: a Person {name: "Bob", age: 25}
carol: a Person {name: "Carol", age: 35}
dave: a Person {name: "Dave", age: 28}
f1: a Friendship {source: alice, target: bob, years: 5, closeness: 4}
f2: a Friendship {source: alice, target: carol, years: 2, closeness: 3}
f3: a Friendship {source: bob, target: carol, years: 3, closeness: 5}
f4: a Friendship {source: carol, target: dave, years: 1, closeness: 2}
people: [alice, bob, carol, dave]
friendships: [f1, f2, f3, f4]Now the questions. Each is one helper function — and the helpers all use the same recipe: walk the array of friendships, filter or transform, return the answer.
A friendship f involves p if
f.source == p or f.target == p:
friendships_of: func(p) [
filter(func(f) [f.source == p or f.target == p], friendships)
]
println(len(friendships_of(alice))) # 2 (f1, f2)
println(len(friendships_of(carol))) # 3 (f2, f3, f4)Given a person p and a friendship f they’re
in, the other party is f.target if p
is the source, otherwise f.source:
other_party: func(p, f) [
if f.source == p then f.target else f.source
]
println(other_party(alice, f1).name) # Bob
println(other_party(bob, f1).name) # Alicedirect_friends: func(p) [
map(func(f) [other_party(p, f).name], friendships_of(p))
]
println(direct_friends(alice)) # ["Bob", "Carol"]
println(direct_friends(carol)) # ["Alice", "Bob", "Dave"]Three small, well-named helpers and we already have a queryable
graph. The composition friendships_of → other_party → name
is the same recipe from §10.6 — pipeline of stages — applied to a new
data shape.
A second pattern: directed edges with numeric weights.
#language axioma/beginner
concept City
City has name: ""
concept Road
Road has src: none
Road has dst: none
Road has miles: 0
nyc: a City {name: "NYC"}
phl: a City {name: "Philadelphia"}
dc: a City {name: "DC"}
bos: a City {name: "Boston"}
roads: [
a Road {src: nyc, dst: phl, miles: 95},
a Road {src: phl, dst: dc, miles: 140},
a Road {src: nyc, dst: bos, miles: 215},
a Road {src: dc, dst: nyc, miles: 225} # one-way back
]Notice the field names: src and dst —
not source and target. Both
source and from collide with reserved or
heavily-overloaded identifiers in some contexts (the words
from and to are reserved keywords in Axioma’s
from..to range syntax). Naming edge endpoints is a place
where readability and keyword-avoidance tug at each other; conventions
like src/dst or
head/tail are battle-tested compromises.
outgoing: func(c) [
filter(func(r) [r.src == c], roads)
]
println(len(outgoing(nyc))) # 2 (phl, bos)
println(len(outgoing(dc))) # 1 (nyc)
println(len(outgoing(bos))) # 0neighbors: func(c) [
map(func(r) [r.dst], outgoing(c))
]
println(map(func(c) [c.name], neighbors(nyc))) # ["Philadelphia", "Boston"]This is where structural recursion on the list of roads
meets a search through the graph. A reachability check
is intuitively “is there a path from src to
dst using any number of hops?”. The natural recursion isn’t
on a list — it’s on the graph, and the graph can have cycles,
so we need a termination story (the subject of Ch.14).
A short-but-honest version uses a fuel parameter — a hop budget that decrements each step. When fuel reaches zero, we give up. This is bounded search, not full reachability, but it’s correct for graphs with paths shorter than the bound.
reach_via: func(s, d, fuel) [
if s == d then true
else if fuel == 0 then false
else [
nbrs: neighbors(s);
any_reach(nbrs, d, fuel - 1)
]
]
any_reach: func(arr, d, fuel) [
if len(arr) == 0 then false
else if reach_via(first(arr), d, fuel) then true
else any_reach(rest(arr), d, fuel)
]
println(reach_via(nyc, dc, 10)) # true (nyc -> phl -> dc)
println(reach_via(bos, nyc, 10)) # false (bos is a dead end)
println(reach_via(dc, bos, 10)) # true (dc -> nyc -> bos)Two mutually recursive functions: reach_via walks one
step forward, then asks any_reach to check the neighbors;
any_reach walks the neighbor list calling
reach_via on each. The pattern is exactly the
Person/Household pattern from §9.2 — but the data isn’t a tree, it’s
a graph. The fuel parameter is what keeps the recursion
well-founded; without it a cycle (nyc → bos → nyc → bos …)
would loop forever.
Chapter 14 will name this kind of recursion — generative, because the recursive subproblem isn’t a piece of the input but something we make up (the smaller fuel budget) — and develop the termination argument formally.
Third pattern: a hierarchical decomposition where some pieces are atomic (a screw, a wire) and others are composite (an assembly that contains other parts, which may themselves be assemblies). The data shape:
A Part is one of:
- a BasicPart with a name and a weight, or
- an Assembly with a name and an array of Parts.
This is exactly the variant recursion pattern from
§9.3 — one logical type (Part) with two flavors. The Assembly’s
parts field is an array of Parts, which can include other
Assemblies, so the recursion goes down arbitrarily deep.
#language axioma/beginner
concept BasicPart
BasicPart has name: ""
BasicPart has weight: 0
concept Assembly
Assembly has name: ""
Assembly has parts: []
wheel: an Assembly {
name: "wheel",
parts: [
a BasicPart {name: "rim", weight: 500},
a BasicPart {name: "tire", weight: 700},
a BasicPart {name: "spokes", weight: 200}
]
}
frame: a BasicPart {name: "frame", weight: 3000}
bike: an Assembly {
name: "bicycle",
parts: [frame, wheel, wheel]
}The function: total weight of a Part. Two recursions hopping between
each other — weight_of dispatches on the variant;
sum_weights walks the parts array.
weight_of: func(p) [
if p is BasicPart then p.weight
else if p is Assembly then sum_weights(p.parts)
else 0
]
sum_weights: func(arr) [
if len(arr) == 0 then 0
else weight_of(first(arr)) + sum_weights(rest(arr))
]
println(weight_of(frame)) # 3000
println(weight_of(wheel)) # 500 + 700 + 200 = 1400
println(weight_of(bike)) # 3000 + 1400 + 1400 = 5800This is the same mutual-recursion shape as the family-tree and expression-tree examples from Chapter 9, but with one twist: the recursion goes through an array of Parts, not through a fixed left/right pair. The pattern array of recursive things + list recursion + variant dispatch compose cleanly because each piece handles exactly one concern.
We can now see the design space:
| Edges have no metadata | Edges have metadata | |
|---|---|---|
| Tree (no cycles) | Ch.8 — direct field pointers | Ch.13 §13.4 — Bill of Materials |
| Graph (possibly cyclic) | Pointer-set per node | Ch.13 §13.2-3 — reified Friendship/Road |
The textbook climbed the table column by column:
parts array.The bottom-left is rare in practice (if your edges have no attributes, you might as well attach them to nodes), but appears in classical graph theory.
The recipe stays the same; the data definition gets a new clause.
Data definition: When concept A points at concept B and vice versa, write both definitions and underline the cross-references. If an edge has metadata, give it its own concept.
Function template: For each concept A, write a function that branches on A’s possible shapes (atoms vs. structures). Wherever the data points at B, the function calls B’s processor — even if you haven’t written it yet. Bidirectional reference begets bidirectional function calls.
Body: Fill in each branch. Recurse through cross-references exactly the way the data structure points. If the graph has cycles, introduce a fuel parameter or a visited-set accumulator (Ch.15).
Tests: At least one test per shape × per concept. A network with three nodes is enough to exercise most branches.
This is the recipe HtDP calls mutually-recursive data. It’s the last leg of the structural-recursion ladder. Chapter 14 moves to the other kind of recursion — algorithmic, not structural — and Chapter 15 brings in accumulators to handle graphs cleanly.
Look back across Parts II–IV:
Every domain you’ll encounter for the rest of your career — file systems, scene graphs, dependency graphs, AST passes, spreadsheet calc engines, query plans, routing tables, social networks, organizational charts, scientific entity-relation diagrams — is one of these shapes. The recipe doesn’t change.
The big chapter ahead — Ch.14 — is your first encounter with recursion that isn’t about walking a data shape. Quicksort doesn’t have a “list” to recur on in the usual sense; it partitions and recurs on its own outputs. Mergesort splits the list down the middle, again not “rest of list.” Binary search halves the search range. These algorithms generate their subproblems out of thin air, and you have to argue that the subproblems are smaller — the termination argument — instead of getting that argument for free from the data shape.
mutual_friendsGiven two People p and q, return an array
of names of people who are friends with both p and
q. (Build on direct_friends from §13.2.)
mutual_friends(alice, bob) →
["Carol"]mutual_friends(alice, dave) →
["Carol"]mutual_friends(carol, alice) →
["Bob"]friendship_years_totalSum the years field across all friendships involving a
given person. (Hint: this is friendships_of(p) from §13.2,
then reduce over years.)
friendship_years_total(alice) → 7 (5 +
2)friendship_years_total(carol) → 6 (2 + 3 +
1)friendship_years_total(dave) → 1total_route_milesGiven an array of cities representing a planned route
([nyc, phl, dc, nyc]), sum the miles on each consecutive
leg. Use the roads array from §13.3 to look up each leg’s
distance. If a leg has no direct road, return -1 for the
whole route.
total_route_miles([nyc, phl]) → 95total_route_miles([nyc, phl, dc]) →
235total_route_miles([nyc, phl, dc, nyc]) →
460total_route_miles([bos, nyc]) → -1 (no
bos→nyc road)count_partsFor a Part, return the total number of BasicPart leaves (ignoring intermediate Assemblies).
count_parts(frame) → 1count_parts(wheel) → 3count_parts(bike) → 1 + 3 + 3 = 7flatten_partsReturn a flat array of every BasicPart inside a Part, in left-to-right order.
flatten_parts(frame) → [frame]flatten_parts(wheel) → [rim, tire, spokes]
(3 BasicParts)len(flatten_parts(bike)) → 7Then verify: sum_of_weights(flatten_parts(bike)) equals
weight_of(bike). (Pick any name for the helper.)
route_existsUse the road network from §13.3 to write a
route_exists(src, dst) function that returns
true if a route exists from src to
dst (any number of hops). Adapt the reach_via
/ any_reach pair from the chapter; you’ll need to pick a
fuel budget.
route_exists(nyc, dc) → trueroute_exists(bos, dc) → falseroute_exists(dc, bos) → trueThen think: what would go wrong if you forgot the fuel parameter? What would go wrong if you set fuel too small? This is the new pedagogical hurdle of Chapter 14 — convincing yourself a recursion terminates when the data shape isn’t giving you the answer for free.
End of Chapter 13. Chapter 14 takes the leap from structural recursion (the kind you’ve been doing — recurring on pieces of the input) to generative recursion (recurring on problems you make up). Quicksort, mergesort, and binary search await.
Solutions to selected exercises: Chapter 13 · Solutions in Appendix C. (Repo file: exercises/solutions/ch13_solutions.md.)
What this chapter is. Every recursion you’ve written so far has been structural — the recursive call worked on a piece of the input (the tail of a list, a subtree, the other half of a mutually recursive pair). The data shape told you what the subproblem was. This chapter changes that. In generative recursion, the subproblem is something you make up: the smaller half of a sorted partition, a halved search range, a reduced numeric quantity. The data doesn’t tell you what to do; you tell yourself what smaller problem to solve next.
The new pedagogical hurdle is the termination argument. With structural recursion, termination came for free: every recursive call shrank the input, and the empty input was the base case. With generative recursion, you have to show that the subproblem is smaller — by some measure, by some amount, reliably — or your program loops forever.
Compare two recursions you already know how to write:
# Structural — sum a list
sum_list: func(arr) [
if len(arr) == 0 then 0
else first(arr) + sum_list(rest(arr))
]The recursive call is sum_list(rest(arr)). The argument
is a piece of arr — specifically, all but the
first element. There’s a one-to-one correspondence between the data
shape (the list) and the recursion (the recursive call). The base case
(empty list) is the smallest possible piece of the data
shape.
Now look at this:
# Generative — Euclid's GCD
gcd: func(a, b) [
if b == 0 then a
else gcd(b, a % b)
]The recursive call is gcd(b, a % b). Neither
b nor a % b is “a piece of” a or
b in any structural sense. They’re derived — newly
computed from a and b by a formula. The base
case (b == 0) is a property of the numbers, not a
piece of any data structure.
That’s the difference. Structural recursion: the recursion is guided by the input’s shape. Generative recursion: the recursion is guided by an algorithmic idea, and the input gets transformed into the subproblem.
Why does it matter? Two reasons:
Templates work differently. Structural recursion has a template you can derive from the data definition (Ch.4). Generative recursion doesn’t — every algorithm is its own creative act.
Termination isn’t free. Structural recursion always terminates because the input shrinks toward the base case. Generative recursion can loop forever if the “make a smaller problem” step doesn’t actually make it smaller.
We’ll see four worked examples. Each one introduces a new algorithmic idea, and each one needs its own termination argument.
We started with this one. The full setup:
#language axioma/beginner
gcd: func(a, b) [
if b == 0 then a
else gcd(b, a % b)
]
println(gcd(48, 18)) # 6
println(gcd(7, 13)) # 1
println(gcd(100, 75)) # 25
println(gcd(15, 0)) # 15The algorithmic idea: any common divisor of
a and b is also a divisor of
a % b (the remainder when a is divided by
b). And any common divisor of b and
a % b is also a divisor of a. So
gcd(a, b) = gcd(b, a % b) — the GCD is preserved under
the “swap and take remainder” transformation. We can keep doing it
until one operand reaches 0; then the answer is the
other.
The termination argument:
b) is a non-negative integer.a % b < b (the remainder is
strictly less than the divisor).0
in finitely many steps.That’s the shape of every generative-recursion termination argument:
pick a quantity that decreases, show it can’t go on forever. For GCD the
quantity is b; for the next example, it’ll be the size of
an array.
The most famous generative recursion in computer science.
The algorithmic idea: to sort an array, pick any
element (the “pivot”). Everything less than the pivot goes in the left
pile; everything greater-or-equal goes in the right pile. Recursively
sort each pile. Splice them back together:
sorted_left + [pivot] + sorted_right.
#language axioma/beginner
qsort: func(arr) [
if len(arr) == 0 then []
else if len(arr) == 1 then arr
else [
p: first(arr);
tail: rest(arr);
smaller: filter(func(x) [x < p], tail);
bigger: filter(func(x) [x >= p], tail);
qsort(smaller) + [p | qsort(bigger)]
]
]
println(qsort([])) # []
println(qsort([42])) # [42]
println(qsort([3, 1, 4, 1, 5, 9, 2, 6])) # [1, 1, 2, 3, 4, 5, 6, 9]Where’s the generative step? smaller
and bigger are new arrays we made up from
tail. They aren’t pieces of the input in the
structural sense — we computed them by filtering. We even chose the
partition strategy (split on the first element; we could have
chosen the middle, the median, a random element). Every choice changes
the shape of the recursion.
The termination argument:
smaller and bigger are subsets of
tail.tail has length len(arr) - 1, so each of
smaller and bigger has length at most
len(arr) - 1.len == 0 and len == 1) are
reached in at most len(arr) levels of recursion.The argument relies on smaller and bigger
being strictly smaller than arr. The
choice to use tail (not arr) for filtering is
what makes that strict. Filter on arr itself — including
the pivot in the left or right pile — and you risk an infinite loop.
(Worst case: all elements equal the pivot, so every level recurses on
the same array.)
Note on the [p,] glue. The trailing
comma is doing real work: [p,] is the unambiguous
one-element array (§6.6), so it splices a single value between the two
sorted halves. (You may also meet the older spelling
push([], p) in existing code — it builds the same
singleton.)
Quicksort’s recursion depth depends on the pivot. With a well-chosen pivot (close to the median), each recursive call roughly halves the array, and the algorithm runs in O(n log n) time. With a poorly-chosen pivot (always the smallest or largest element of the remaining range), one side is empty and the other side is everything but the pivot — O(n²). Choosing the pivot well is one of those Real Engineering Problems that an introductory port doesn’t dwell on, but it’s good to know it exists.
A different generative idea. Instead of partitioning by value, split by position: the first half goes one way, the second half the other. Sort each half recursively. Then merge.
#language axioma/beginner
# take_n / drop_n — `take` is a reserved name, hence the suffix
take_n: func(arr, n) [
if n == 0 then []
else if len(arr) == 0 then []
else push([], first(arr)) + take_n(rest(arr), n - 1)
]
drop_n: func(arr, n) [
if n == 0 then arr
else if len(arr) == 0 then []
else drop_n(rest(arr), n - 1)
]
# Merge two sorted arrays
merge: func(a, b) [
if len(a) == 0 then b
else if len(b) == 0 then a
else [
fa: first(a);
fb: first(b);
if fa <= fb then push([], fa) + merge(rest(a), b)
else push([], fb) + merge(a, rest(b))
]
]
# The driver
msort: func(arr) [
n: len(arr);
if n <= 1 then arr
else [
mid: n div 2;
left: msort(take_n(arr, mid));
right: msort(drop_n(arr, mid));
merge(left, right)
]
]
println(msort([])) # []
println(msort([42])) # [42]
println(msort([3, 1, 4, 1, 5, 9, 2, 6])) # [1, 1, 2, 3, 4, 5, 6, 9]The algorithmic idea: divide the array in half (by position), sort each half, then merge the sorted halves in linear time.
Two generative steps:
take_n(arr, mid) and drop_n(arr, mid) —
position-based splits. We generate the two subarrays
from the input.merge(left, right) is itself structural on the
two subarrays (it’s a list-recursion on both at once) — so mergesort
uses generative recursion at the top level but relies on a
structural helper inside. Mixing the two patterns is common.The termination argument for msort:
n div 2 rounds down (integer-part division — plain
/ would give the exact fraction n/2 for odd
n, which is no use as a length). For
n >= 2, mid = n div 2 is at least
1 and at most n - 1.take_n(arr, mid) has length
mid >= 1, strictly less than n.drop_n(arr, mid) has length
n - mid >= 1, strictly less than n.log2(n): roughly
log2(8) = 3 for an 8-element array,
log2(1024) = 10 for 1024 elements.The termination argument for merge is structural:
each recursive call drops the first element of either a or
b. The combined length len(a) + len(b)
strictly decreases. Reaches zero when both arrays are empty.
| Property | Quicksort | Mergesort |
|---|---|---|
| Split strategy | by value (partition around pivot) | by position (halve in the middle) |
| Combine strategy | concatenate (trivial) | merge (linear scan) |
| Best case time | O(n log n) | O(n log n) |
| Worst case time | O(n²) | O(n log n) |
| Extra space | in-place possible | needs auxiliary array |
| Stability | not stable | stable |
Both are “divide-and-conquer.” Both split, recurse, combine. They just make different choices at each step. Real-world standard libraries pick mergesort (or hybrid mergesort/quicksort variants like Timsort) for stability and worst-case predictability; quicksort is famously the fastest on average with a tuned pivot.
The other classic generative recursion. Search a sorted array for a target. At each step, look at the middle; if it matches, return its index; if the target is smaller, recurse on the left half; if larger, recurse on the right half.
#language axioma/beginner
bsearch_in: func(arr, target, lo, hi) [
if lo > hi then 0
else [
mid: (lo + hi) div 2;
v: arr[mid];
if v == target then mid
else if target < v then bsearch_in(arr, target, lo, mid - 1)
else bsearch_in(arr, target, mid + 1, hi)
]
]
bsearch: func(arr, target) [
bsearch_in(arr, target, 1, len(arr))
]
nums: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
println(bsearch(nums, 7)) # 4
println(bsearch(nums, 1)) # 1
println(bsearch(nums, 19)) # 10
println(bsearch(nums, 4)) # 0 (not found)
println(bsearch(nums, 0)) # 0
println(bsearch(nums, 20)) # 0
println(bsearch([], 5)) # 0The algorithmic idea: at each step, the target either is the middle element, or it’s in the left half, or it’s in the right half. Each step eliminates half the remaining range.
The generative step: lo..mid-1 and
mid+1..hi are derived ranges. They aren’t
structural pieces of arr — they’re coordinate
transformations on the search bounds.
The termination argument:
hi - lo + 1. Initially it’s
len(arr).(hi - lo + 1) / 2, strictly less.0 (when lo > hi)
in at most log2(len(arr)) + 1 steps. That’s about
7 steps for a 100-element array, 10 for 1000,
20 for a million.Notice the convention: we return 0 to mean “not found.”
That’s because Axioma arrays are 1-indexed, so 0 is never a
valid index. (If we used -1, callers would have to remember
the sentinel; 0 is more natural in this dialect.)
Comparing the four:
generative_func: func(problem) [
if is_trivially_solvable(problem) then solve_directly(problem)
else [
smaller_problem: generate_subproblem(problem);
answer: generative_func(smaller_problem);
combine(problem, answer)
]
]
You need to provide:
b == 0. For quicksort, length 0 or 1. For binary
search, when lo > hi. (For mergesort, also length 0 or
1.)(b, a % b). For
quicksort, smaller and bigger. For binary
search, the half-range that could contain the target.The first three are how you write the function. The fourth is how you trust it.
The classic mistake: a “smaller” subproblem that isn’t.
# WRONG: this loops forever
qsort_bad: func(arr) [
if len(arr) == 0 then []
else if len(arr) == 1 then arr
else [
p: first(arr);
# BUG: filtering arr, not tail. Pivot stays in one of the partitions.
smaller: filter(func(x) [x < p], arr);
bigger: filter(func(x) [x >= p], arr);
qsort_bad(smaller) + push([], p) + qsort_bad(bigger)
]
]If arr = [3, 1, 2], then p = 3,
smaller = [1, 2] (good, shrinks),
bigger = [3]. Recursing on [3] hits the
len == 1 base case. Fine.
But if arr = [3, 3, 3], then p = 3,
smaller = [], bigger = [3, 3, 3]. We recur on
[3, 3, 3] — the same array we started with.
Infinite loop. (In Axioma, the stack runs out and the process
aborts.)
The fix is the correct version from §14.3: filter on
tail = rest(arr) instead of on arr. That
guarantees both partitions are strictly smaller than the input — the
termination argument requires it.
Always write the termination argument before you trust a generative recursion. If you can’t sketch the strictly-decreasing measure, you don’t yet have a working algorithm.
Structural recursion and generative recursion are the two pillars of recursive programming.
Most of Part V (accumulators, mutation, streams) revisits recursion from a third angle — iterative recursion, where the recursive call replaces a loop. Part VI’s Chapter 22 (the meta-circular evaluator) brings all three together in a single program that interprets itself.
Welcome to the end of Part IV. You can now design programs over:
That’s the structural-recursion-as-pedagogy half of HtDP, ported end-to-end. The rest of the book is about state, streams, and the Axioma-specific superpowers (concepts, MVL, logic programming, metacircularity).
powerImplement integer exponentiation using generative recursion. The
naive structural recursion is
power(b, n) = b * power(b, n-1) (with
power(b, 0) = 1); that’s n multiplications. The
generative fast power uses the identity:
n is even,
power(b, n) = power(b * b, n / 2)n is odd,
power(b, n) = b * power(b, n - 1)That’s O(log n) multiplications.
power(2, 10) → 1024power(3, 0) → 1power(2, 16) → 65536power(5, 3) → 125Termination question (write this out as a comment in your solution): by what measure does the recursion shrink? Why is that measure guaranteed to reach zero?
nat_log2nat_log2(n) returns the largest integer k
such that 2^k <= n. Equivalently, the number of times
you can halve n before reaching 1 (or below).
Use generative recursion.
nat_log2(1) → 0nat_log2(2) → 1nat_log2(7) → 2 (2² = 4, still ≤ 7; 2³ =
8, too big)nat_log2(1024) → 10Termination question: why is the recursion well-founded?
hanoiThe Tower of Hanoi: three pegs ("A", "B",
"C"), n disks on the source peg in size order
(largest at bottom). Move all disks to the destination peg using the
third as via, never placing a larger disk on a smaller one. Return an
array of (from_peg, to_peg) moves in execution order.
hanoi: func(n, src, dst, via) [
if n == 0 then []
else hanoi(n - 1, src, via, dst)
+ push([], (src, dst))
+ hanoi(n - 1, via, dst, src)
]The above is the canonical solution — verify it:
hanoi(1, "A", "C", "B") →
[("A", "C")]hanoi(2, "A", "C", "B") →
[("A", "B"), ("A", "C"), ("B", "C")]len(hanoi(3, "A", "C", "B")) → 7len(hanoi(5, "A", "C", "B")) → 31Then explain in a comment (a) why this is generative recursion (what’s the subproblem?) and (b) the termination argument.
bsearch_countModify bsearch from §14.5 to also return the number of
comparisons made. Return a tuple (index, comparisons). For
a 1000-element array, comparisons should be at most
10 (log2(1000) ≈ 9.97).
bsearch_count([1,3,5,7,9], 5) → (3, 1)
(mid hit on first try)bsearch_count([1,3,5,7,9], 1) → (1, ?)
(you compute)bsearch_count([1,3,5,7,9], 11) → (0, ?)
(not found; you compute)merge_threeGeneralize merge from §14.4 to merge three
sorted arrays at once. (Hint: one approach is
merge(a, merge(b, c)). The native three-way
version is harder — pick the smallest of three first-elements at each
step.) Implement the native version and verify against the chained
version on a few examples.
merge_three([1, 4, 7], [2, 5, 8], [3, 6, 9]) →
[1, 2, 3, 4, 5, 6, 7, 8, 9]merge_three([], [1, 2], [3, 4]) →
[1, 2, 3, 4]merge_three([], [], []) → []is_perfect_squareWrite is_perfect_square(n) that returns
true if n is a perfect square
(0, 1, 4, 9, 16, …), false otherwise. Use
generative recursion: a binary-search-style narrowing of the range
[0, n] looking for k such that
k * k == n.
is_perfect_square(0) → trueis_perfect_square(1) → trueis_perfect_square(16) → trueis_perfect_square(17) → falseis_perfect_square(144) → trueis_perfect_square(143) → falseThen think:
sqrt(n) * sqrt(n) == n approach
unreliable for large n? (Hint:
floating-point.)End of Part IV. Part V brings accumulators — a way to restructure recursive functions so they use constant stack space — mutation, and a first look at streams. The recipe continues to apply; the techniques get a little less pure.
Solutions to selected exercises: Chapter 14 · Solutions in Appendix C. (Repo file: exercises/solutions/ch14_solutions.md.)
What this chapter is. A style of recursion, not a new data shape. You’ve been writing natural recursion since Chapter 7 — recur on the tail, combine the result with the head, return. This chapter introduces accumulator recursion — carry the running answer forward in an extra parameter, so the work happens before the recursive call instead of after. The same problems get a different structure, and the new structure has two big advantages: it uses less stack space (when the host supports tail calls) and it builds up the answer in a single forward pass.
Chapter 7’s sum_list:
#language axioma/beginner
sum_list: func(arr) [
if len(arr) == 0 then 0
else first(arr) + sum_list(rest(arr))
]The recursive call sum_list(rest(arr)) happens, returns,
and then we add first(arr) to its result. The
addition is pending during the whole recursive descent
— the interpreter remembers, in each stack frame, “after the recursive
call comes back, add the head.”
For a 5-element array, the call stack looks like:
sum_list([1,2,3,4,5])
1 + sum_list([2,3,4,5])
2 + sum_list([3,4,5])
3 + sum_list([4,5])
4 + sum_list([5])
5 + sum_list([])
0 (base case)
5 + 0 = 5
4 + 5 = 9
3 + 9 = 12
2 + 12 = 14
1 + 14 = 15
Five pending additions live on the stack at the deepest point. For a 5-million-element array, that’s five million pending additions — and on Axioma’s runtime, non-tail recursion like this stops with a clean recursion-limit error at a depth in the low tens of thousands (the interpreter guards nested evaluation at 50,000 steps, and each recursion level costs a few of them). Real Racket and many other Lisps optimize away the stack usage when the shape is tail-recursive — and so does Axioma: a self-recursive tail call reuses its frame and runs at any depth.
Now the accumulator version:
#language axioma/beginner
sum_acc: func(arr, acc) [
if len(arr) == 0 then acc
else sum_acc(rest(arr), acc + first(arr))
]
sum_list_acc: func(arr) [ sum_acc(arr, 0) ]
println(sum_list_acc([1, 2, 3, 4, 5])) # 15Two functions: an outer wrapper that supplies the initial accumulator
(0), and an inner worker that carries the running total.
The call stack looks like:
sum_list_acc([1,2,3,4,5])
sum_acc([1,2,3,4,5], 0)
sum_acc([2,3,4,5], 1)
sum_acc([3,4,5], 3)
sum_acc([4,5], 6)
sum_acc([5], 10)
sum_acc([], 15)
15
15
15
15
15
15
15
Each call’s pending work is nothing — it just returns whatever the next call returns. That’s the tail call position: the recursive call is the very last thing the function does.
The accumulator now carries all the computational state. The stack carries only the call chain.
Take any natural recursion that combines results with +,
*, list-cons, or any other associative operation:
natural_func: func(input) [
if is_base(input) then base_value
else combine(head(input), natural_func(rest(input)))
]
Mechanically rewrite it as:
acc_func: func(input, acc) [
if is_base(input) then acc
else acc_func(rest(input), combine(acc, head(input)))
]
wrapper: func(input) [ acc_func(input, base_value) ]
Three steps:
acc parameter to the worker.base_value was in the natural
version.The combine operation has to be associative for this to give
the same answer in either order (because the natural version combines
right-to-left and the accumulator combines left-to-right). Addition,
multiplication, concatenation, set union,
min/max — all associative, all safe to
convert. Subtraction, division, and “first non-zero element” are not
associative; they need extra care.
Reversing is the textbook example, because the two styles produce visibly different recursive shapes.
#language axioma/beginner
rev_nat: func(arr) [
if len(arr) == 0 then []
else rev_nat(rest(arr)) + push([], first(arr))
]
println(rev_nat([1, 2, 3, 4, 5])) # [5, 4, 3, 2, 1]The recursive call returns the reverse of rest(arr).
Then we append the head at the end — because the head was the
first element of the original, it must end up last in
the reversal.
What’s the cost? The + operation here is array
concatenation, which in Axioma is O(n) (it walks the
left array to copy it). At each of n levels, we do
O(n) work. Total cost: O(n²). For a
1000-element list, that’s a million operations — still fast, but you can
feel it for 10000.
#language axioma/beginner
rev_acc: func(arr, acc) [
if len(arr) == 0 then acc
else rev_acc(rest(arr), push([], first(arr)) + acc)
]
rev_fast: func(arr) [ rev_acc(arr, []) ]
println(rev_fast([1, 2, 3, 4, 5])) # [5, 4, 3, 2, 1]We prepend each head to a growing-at-the-front
accumulator. After the first step the accumulator is [1];
after the second, [2, 1]; after the fifth,
[5, 4, 3, 2, 1] — already reversed. Each step does
constant work (push([], first) + acc is one
prepend); total cost is O(n).
A useful mental image. Natural reverse is “build the answer from the back forward, one level of recursion per element.” Accumulator reverse is “walk forward through the input, prepending each element to a growing reversed prefix.” The accumulator reverse builds the answer itself as it goes; the natural reverse builds the plan for the answer and only assembles it on the way back up.
Factorial is the classical introduction to recursion. With one input (an integer) it’s the simplest possible accumulator target.
#language axioma/beginner
fact_nat: func(n) [
if n == 0 then 1
else n * fact_nat(n - 1)
]
println(fact_nat(10)) # 3628800
fact_acc: func(n, acc) [
if n == 0 then acc
else fact_acc(n - 1, n * acc)
]
fact_fast: func(n) [ fact_acc(n, 1) ]
println(fact_fast(10)) # 3628800Both give 3628800. The natural version stacks
n * (n - 1) * (n - 2) * ... * (2 * (1 * 1)) to compute
right-to-left; the accumulator version computes
((((1 * n) * (n-1)) * (n-2)) * ... * 1) left-to-right.
Since multiplication is commutative and associative, the two
products are equal.
The big input numbers expose the stack issue — and the payoff:
fact_nat(100000) → recursion limit exceeded (max Eval depth 50000)
— a clean, catchable error: every pending `n *`
holds its frame open, and 100,000 of them blow
past the interpreter's non-tail ceiling
fact_fast(100000) → runs. The recursive call is in tail position, so
Axioma reuses the frame — and the answer comes
back as an exact 456,574-digit integer.
That’s the accumulator payoff made concrete. The natural version
must remember 100,000 pending multiplications; the accumulator
version carries the answer-so-far in an argument, so nothing is pending
and the frame can be reused. Axioma’s self-tail-call optimization gives
fact_acc exactly the R5RS-Scheme behavior this chapter is
teaching: constant stack space at any depth. The ~50,000-frame ceiling
still exists, but it applies only to recursion that isn’t a
self-tail-call — the natural version above, or mutual recursion between
two functions (the optimization is per-function).
A second reason to prefer accumulator style. When you can think of your computation as “walk forward, remember the answer so far, finish when the input runs out,” your code often reads more like a loop. That’s why accumulators are the bridge from functional to imperative thinking — Chapter 16 will show how
whileloops are just accumulator recursions in disguise.
A pattern with a non-trivial combiner. Find the largest element. Naive accumulator: track the maximum-so-far.
#language axioma/beginner
# Helper: max of two
maxi: func(a, b) [ if a > b then a else b ]
# Accumulator version — stable for any non-empty array
max_acc: func(arr, acc) [
if len(arr) == 0 then acc
else max_acc(rest(arr), maxi(acc, first(arr)))
]
max_of: func(arr) [
if len(arr) == 0 then none
else max_acc(rest(arr), first(arr))
]
println(max_of([3, 1, 4, 1, 5, 9, 2, 6])) # 9
println(max_of([42])) # 42
println(max_of([])) # noneThe empty-array case needs the wrapper because there’s no
sensible accumulator seed for “the maximum of an empty list.” We
can’t seed with 0 (what if the array’s values are all
negative?), and we can’t seed with -infinity because Axioma
doesn’t expose that. So the wrapper handles empty-array specially and
otherwise seeds the accumulator with the first element,
recurring over the rest.
Two design patterns to notice:
max_of(arr); only max_of knows
about the accumulator. This is the same wrapper-pattern from §15.1–4 but
with an interesting (non-base) seed.first(arr)” trick. When
there’s no meaningful zero for the operation, the first element of the
input doubles as the initial accumulator and is consumed by the
wrapper’s rest-then-recur step.Sometimes one accumulator isn’t enough. To compute the mean of an array in a single pass, you need both the running sum and the running count:
#language axioma/beginner
mean_acc: func(arr, sum, count) [
if len(arr) == 0 then sum / count
else mean_acc(rest(arr), sum + first(arr), count + 1)
]
mean_of: func(arr) [
if len(arr) == 0 then 0
else mean_acc(arr, 0.0, 0)
]
println(mean_of([1.0, 2.0, 3.0, 4.0, 5.0])) # 3.0
println(mean_of([4.0, 8.0, 6.0, 5.0, 3.0, 7.0])) # 5.5Two accumulators (sum and count) carried in
parallel. The combination of accumulators turns out to be the
single-most-common shape in algorithm design: most “streaming
statistics” (mean, variance, min, max, median approximation,
count-distinct) are accumulator recursions with 2–4 carried values. Real
numerical libraries spell this “online algorithm” or “single-pass
statistic”; you’ve just written one in nine lines.
The 0.0 seed for the sum is the same float-promotion
trick from Ch.12 — without it the statistics stay in exact arithmetic,
so an uneven mean comes back as a fraction (16 / 5 is the
rational 16/5, not 3.2). Right value, wrong
outfit for a streaming-stats report.
Some recursions can’t be easily expressed accumulator-style:
The rule of thumb: if the natural version writes
combine(head, recur(rest)) with an associative
combine, the accumulator version exists and is
mechanical. If the recursive structure is fundamentally
tree-shaped or generative, the accumulator may not help — and that’s
fine. You’ve still got natural recursion for those cases.
For “sum the elements of a list,” we now have:
reduce HOF (Ch.10) — abstract,
declarative, hides the recursion entirely. Internally
tail-recursive.The accumulator version is implementation-shaped: it’s how
reduce is implemented under the hood. The natural version
is specification-shaped: it reads as “the sum of a list is the
head plus the sum of the rest.” Both are correct; they emphasize
different things.
By Part V’s end, we’ll have a fourth idiom — explicit
mutation with a while loop (Ch.16) — that abandons
recursion entirely and just uses a mutable counter. Each shift trades
some property for another (clarity for speed, abstraction for control).
Mature programmers reach for whichever shape fits the problem.
sum_accWrite sum_acc(arr, acc) and wrap it as
sum(arr). Verify it agrees with the natural
sum_nat(arr) on three small inputs.
sum([]) → 0sum([7]) → 7sum([1, 2, 3, 4, 5]) → 15prod_accSame pattern, multiplication. Seed the accumulator with
1.
prod([]) → 1prod([7]) → 7prod([2, 3, 4]) → 24prod([1, 2, 3, 4, 5]) → 120Why does seeding with 0 not work?
len_accRe-derive len (which Axioma already provides as a
builtin) in accumulator style. Seed with 0, increment by
1 per element. Compare your version’s output to
len’s.
my_len([]) → 0my_len([a, b, c]) → 3count_evens_accGiven an array of integers, return the count of even numbers. Use a single accumulator.
count_evens([]) → 0count_evens([1, 3, 5]) → 0count_evens([2, 4, 6]) → 3count_evens([1, 2, 3, 4, 5, 6]) → 3sum_and_countWalk an array once and return a tuple (sum, count). Use
two accumulators carried in parallel. Then verify
mean = sum / count agrees with §15.6’s
mean_of.
sum_and_count([]) → (0, 0)sum_and_count([10, 20, 30]) → (60, 3)sum_and_count([1.0, 2.0, 3.0, 4.0]) →
(10.0, 4)running_sumsGiven [a, b, c, d, …], return an array of running totals
[a, a+b, a+b+c, a+b+c+d, …]. Use accumulator-style
recursion that carries the current running total and builds
the output array as it walks.
running_sums([]) → []running_sums([7]) → [7]running_sums([1, 2, 3, 4]) →
[1, 3, 6, 10]Hint: you’ll need two accumulators — the running total and
the output array being built. This is the natural lead-in to Chapter
16’s while-loop, which uses the same two-counter pattern
with mutation instead of recursion.
End of Chapter 15. Chapter 16 takes the accumulator idea one step further: instead of passing the accumulator as a function argument, mutate a local variable inside a loop. Same computation, different mechanism, different ergonomics.
Solutions to selected exercises: Chapter 15 · Solutions in Appendix C. (Repo file: exercises/solutions/ch15_solutions.md.)
What this chapter is. The other half of the functional/imperative divide. Up to here we’ve treated variables as names for values — once bound, a name’s value never changes. This chapter introduces mutation: a name can be re-bound to a different value over time. Combined with
while-loops, mutation gives you the iterative style of programming familiar from C, Java, Python, JavaScript — the same one your non-Axioma friends learn from day one.Mutation is a sharp tool. It lets you write a constant-stack loop where natural recursion would overflow, build state machines whose whole job is to change, and model objects with private memory. It also lets you create bugs that no amount of unit testing will find. Used carefully it’s indispensable; used carelessly it’s how production systems turn unmaintainable. We’ll do both.
You’ve seen bindings before, in Chapter 2:
#language axioma/beginner
x: 5
println(x) # 5What’s new is the bare assignment form — same name, no declaration colon:
#language axioma/beginner
x: 5
x = 10
println(x) # 10x = 10 is not a re-declaration; it’s an
update of the existing binding. At file scope the name
x still refers to the same storage cell, now holding
10 instead of 5. Inside a function, the search
stops at that function frame; the next subsection explains explicit
outer writes.
Three rules:
x: value (REBOL bind) is the canonical declaration — it
introduces the variable, and updates it if it already exists.x = value is a synonym: since June 2026 it
also finds or updates — it declares the name if absent and updates it if
present, for any name and case. (That is what lets textbook math like
B = {2, 4, 6} read verbatim.): when you mean “introduce this” and
= when you mean “update this” (or for math/textbook style)
— but mechanically they are interchangeable.(The x: 5 then x = x + 1 pattern parsed
cleanly in beginner mode all along — Ch.12’s make_counter
quietly used it. We’re just calling it out by name now.)
rebind and globalA body assignment (total = total + x inside a function)
stops at that function. It will not update a name that
lives outside. Two keywords spell the reach, and they are
not the same write:
| Spelling | Where it lands | Declares if missing? |
|---|---|---|
rebind total = total + x |
the nearest existing cell | no — a typo is an error |
global total = total + x |
this file (or this nested module
body) |
yes, if the RHS needs no prior read |
On a file-level accumulator they agree — that cell is both nearest
and the module. They diverge when an enclosing function has the same
name: rebind updates the enclosing local;
global skips it (Julia). A :: on the module
cell is honored either way (global counted = counted + 1
after counted :: Integer = 0 converts or refuses like any
other write). Full detail: Axioma Manual §3.
_), empty containers (default), and
zerosSometimes you want a name before you know its first
real value — TypeScript’s let x: number; then
x = 5, or Go’s var n int which silently starts
at 0. Axioma separates three ideas that those languages
often blur.
| Intent | Spelling | Can you read it immediately? |
|---|---|---|
| Hole — fill in later | let n :: Integer = _ |
No — reading errors |
| Empty container | let xs :: Array = default |
Yes — it is [] |
| You mean zero / empty string | let n :: Integer = 0 |
Yes — you wrote 0 |
let name :: Type = _The hole and default examples are host Axioma, so they
declare #language axioma/all. Bare
#language axioma (no /subset) is not a dialect
name — the checker lists the eight known ones and refuses the file.
#language axioma/all
var lifespan :: Integer | "ongoing" | "uncertain" = _
# println(lifespan) # ERROR: uninitialized
lifespan = 89
println(lifespan) # 89
lifespan = "ongoing"
println(lifespan) # ongoingRules worth memorizing:
let or var, a
:: type, and the bare marker
_. A let hole takes exactly
one write — the fill — and is immutable from then on; a
slot you mean to keep refilling (like lifespan above) is a
var hole.none,
not om, and not
0. It is binding state: “this name is not
ready.”= (or :) assignment fills
the same cell (and, for a let hole, seals it).--typecheck, using the name before assignment is
flagged on straight-line code and on if/else
only when both arms assign. An assignment that happens only
inside a while does not count for
code after the loop (the loop might run zero times). Runtime
still errors if you read early.let name :: Type = default#language axioma/all
let xs :: Array = default
println(len(xs)) # 0 — safe; xs is a real empty arraydefault is allowed only for types that already have an
honest identity empty — the same group as nullary
constructors:
Array, Set, Tuple,
Dictionary, Bytes, Bag,
Stack, Graph.
default → 0let n :: Integer = default # ERROR
let n :: Integer = 0 # Ok — you asked for zero
let n :: Integer = _ # Ok — hole; assign before useZero is ordinary data. If the language filled every “unset” integer
with 0, a forgotten assignment would look like a correct
program that multiplies by zero or compares equal to zero — a silent
wrong answer. Axioma’s rule matches its constructor surface:
containers may start empty; scalars do not get silent
zeros. You may always write = 0,
= "", or = false when that is what you
mean.
let a = none # absence (a value)
let b = om # undetermined (a value); a == b is false
let c :: Integer = _ # hole (not a value)
let d :: Array = default # empty array (a value)Use _ only for “not ready yet,” default
only for “start empty container,” and none/om
when you mean one of Axioma’s two bottoms.
Full reference: Axioma Manual § Fresh declarations with
let (Uninitialized slots and identity defaults); everyday
patterns in Axioma Pragmatics.
while loopMutation pairs with iteration. Axioma’s while loop runs
a body as long as a condition is true:
#language axioma/beginner
i: 1
total: 0
while (i <= 10) [
total = total + i;
i = i + 1
]
println(total) # 55
println(i) # 11This is identical to:
#language axioma/beginner
sum_acc: func(i, total, limit) [
if i > limit then total
else sum_acc(i + 1, total + i, limit)
]
sum_to: func(limit) [ sum_acc(1, 0, limit) ]
println(sum_to(10)) # 55The while loop is the accumulator recursion from Chapter
15 written as iteration. Three pieces map across:
| Recursion | Loop |
|---|---|
| accumulator parameter | mutable local |
recursive call with +1 |
i = i + 1 and re-test |
| base-case return | exit when condition false |
Every while loop has an equivalent
accumulator-recursion; every accumulator-recursion has an equivalent
while loop. Which one you reach for is a style
choice: an accumulator recursion whose call is in tail position runs in
constant stack space (Ch. 15’s self-tail-call optimization), just like
the loop.
Beginner-mode pragma. The
axioma/beginnersubset allows bothx: …andx = newval. The early chapters of this book stuck to no mutation as a teaching choice — every chapter through 15 worked without ever using persistent state as a design technique. That discipline was for clarity, not language enforcement. Starting with this chapter, mutation is on the table.
A counter is the smallest interesting use of mutation. We met it already in Ch.12’s open exercise:
#language axioma/beginner
make_counter: func() [
n: 0;
func() [
rebind n = n + 1;
n
]
]
c: make_counter()
v1: c()
v2: c()
v3: c()
println(v1) # 1
println(v2) # 2
println(v3) # 3
# A second counter has its own n
second_counter: make_counter()
w1: second_counter()
println(w1) # 1
v4: c()
println(v4) # 4 (c's count continues independently)The local n is captured by the inner
func() [...]. When the outer make_counter
returns, the inner function still holds a reference to
n. Mutating n inside the inner function
mutates that captured cell. Every call to
make_counter() produces a new n cell,
so different counters don’t share state.
This is the closure with mutable state pattern. It’s
how JavaScript implements every object that has private fields, how
Smalltalk does instance variables, how Lua does objects without an
object keyword. The combination — function that closes
over a mutable local — is an object in
everything-but-name.
A state machine is a system whose behavior depends on which state it’s currently in. The canonical example: a turnstile. Two states (locked, unlocked) and two events (coin, push) with this transition table:
| Current state | Event | Next state | Side effect |
|---|---|---|---|
| locked | coin | unlocked | — |
| locked | push | locked | — (refused entry) |
| unlocked | coin | unlocked | — (no-op) |
| unlocked | push | locked | increment count |
In code:
#language axioma/beginner
state: "locked"
people: 0
coin: func() [
if state == "locked" then [rebind state = "unlocked"]
else [rebind state = state]
]
push_t: func() [
if state == "unlocked" then [
rebind state = "locked";
rebind people = people + 1
]
else [rebind state = state]
]
coin(); push_t() # unlock, walk through → 1 person
coin(); push_t() # unlock, walk through → 2
push_t() # refused (already locked) → still 2
coin(); push_t() # unlock, walk through → 3
println(state) # locked
println(people) # 3Two mutable variables (state, people) hold
the machine’s memory. Two functions (coin,
push_t) read the current memory, decide what to do, and
update.
Why the
else [state = state]no-op? Axioma’sifis an expression — every branch must produce a value. When we have nothing meaningful to do in theelse, a no-op assignment satisfies the parser. A real-language alternative would be a separatewhen cond [...]statement (no else branch), but that’s an intermediate-tier feature outside the beginner subset.
State machines are everywhere: UI widgets (a button is
hover/pressed/disabled), network protocols
(SYN-SENT, ESTABLISHED,
CLOSE-WAIT in TCP), parsers (lexer states for a tokenizer),
game logic (player alive/dead/respawning), business rules (an
order is placed/paid/shipped/delivered). Two mutable variables
and a small function per event is enough for surprisingly complex
behavior.
A practical mutation pattern: instead of building a list via recursion, append to a growing array in a loop.
#language axioma/beginner
# Build the array [1*1, 2*2, …, n*n] using a mutating accumulator
squares_to: func(n) [
i: 1;
acc: [];
while (i <= n) [
acc = acc + [i * i,];
i = i + 1
];
acc
]
println(squares_to(5)) # [1, 4, 9, 16, 25]acc starts empty and grows by one element per iteration.
The [i * i,] builds a one-element array (the trailing-comma
singleton from §6.6); acc + … concatenates.
A cost note. Each
acc = acc + [v,]does anO(n)array concatenation —+always builds a new array — so the loop totalsO(n²). The true mutating grow is the bare statementpush(acc, i * i): it extendsaccin place at amortizedO(1), and so does the functional shape (map(func(i) [i * i], range(1, n)),O(n)overall). For smallnall three are fast. The lesson: mutation doesn’t automatically make code faster. The cost is in the operations you choose, not the imperative-vs-functional surface.
Three cases where reaching for mutation is the clearer choice, not just an optimization:
State machines (§16.4). The whole point
is that memory changes over time. Threading the state through a
stateless accumulator is possible but contorted; explicit
state = newstate reads like the spec (“on event X,
transition from A to B”).
Long-running loops over external input — reading a file, polling a sensor, processing a network connection. You can’t pre-compute the input; you receive it incrementally. A loop with mutable variables matches the streaming structure.
Performance-critical inner loops where the constant-factor cost of allocating new closure accumulators is measurable. (Rarely a concern at this level; comes up in numerical code and game engines.)
Three cases where mutation makes code worse:
Pure computations. Computing the area of a
triangle? Use a fresh binding. There’s no state to track; nothing
changes over time. Reaching for bind; assign; assign here
just adds noise. Functional purity makes pure code easy to
read.
Shared mutable state in concurrent code. Multiple threads / coroutines / actors reading and writing the same mutable variable is the #1 source of bugs in real-world systems. Axioma doesn’t (yet) expose concurrency primitives, but when it does, the rule will be: avoid sharing mutable state if you possibly can.
“Save typing by mutating in place.” Re-assigning a variable to mean a different thing inside the same function is cheap to write and expensive to read. Reviewers (including future-you) have to track the variable’s value at every line. Use a new binding with a meaningful name; the variable cost is small, the clarity gain is large.
The rule of thumb: mutation is a tool for modeling time and change. If the concept you’re modeling changes over time (a counter, a state machine, a UI widget, a game character’s HP), mutation is right. If the concept is timeless (a math function, a data transformation, a predicate), a binding is right.
Surveying everything we’ve taught about variables:
x: value
— names a value; the value never changes through the lifetime of the
binding. Most of the textbook.x = newvalue — the binding stays, but the value it holds
changes over time.Each is correct in its place. Pure binding is the default because it’s the easiest to reason about. Accumulator recursion is the bridge: it gives you the evolving-value feel without leaving the functional discipline. Mutation is the imperative tool: when you genuinely need a value to change, just change it.
The HtDP discipline — “data definition drives function template; never mutate; build new things instead of modifying existing things” — buys you safety. Mutation trades some of that safety for power. Real-world programmers use both, and knowing when is half of being a good programmer.
The regimes of §16.8 come to a point in Axioma’s two sequence types, because they split exactly along the divide this chapter is about.
An Array is a place. It has identity over time: two names can refer to the same array, and a mutation through either name is visible through both. Places are what imperatives act on, so an array answers command sentences:
place: [1, 2, 3]
same_place: place # same_place names the SAME place
place push 4 # a command: "grow, in place"
println(same_place) # [1, 2, 3, 4] — same_place saw itA List is a value. It has no identity over
time — like the number 5, it simply is its content. You
never change a value; you compute new values from it:
l: list([1, 2, 3])
m: cons(0, l) # a NEW value; l is untouched
println(l) # list(1, 2, 3)
println(m) # list(0, 1, 2, 3)cons is O(1) precisely because nothing ever
mutates: m simply points at l as its shared
tail, and that sharing is safe only while cells never change. This is
also why a list refuses command sentences —
l push 3 is an error that points you back at
cons. If lists could be commanded, growing one would
silently grow every list sharing its spine.
The rule, which the language itself enforces: a place takes
imperatives; a value takes expressions. Reach for the Array
when your program is about change over time — this chapter’s territory.
Reach for the List when you want Part I–IV’s guarantees back while still
growing data: sharing without fear, old versions surviving for free, and
equational reading (m == cons(0, l) stays true forever,
because nothing can edit either side).
Array mutation changes the shared container. Rebinding a name changes which value that name denotes. Test aliases to see the difference.
items: [10, 20, 30]
shared_items: items
removed: delete items[2]
println(removed) # 20
println(shared_items) # [10, 30]
items[1:1] = [1, 2]
extend(items, [40, 50])
println(items) # [1, 2, 30, 40, 50]
println(shift(items)) # 1
unshift(items, 0)
println(splice!(items, 2)) # 2
println(items) # [0, 30, 40, 50]delete returns the removed value; remove_at
returns the edited array. Slice replacement may change length.
Out-of-bounds element writes do not grow an array: use an insertion or
extension operation. reverse(a) makes a copy;
reverse!(a) changes the array. Mutation verbs such as
push already mutate and do not gain a push!
alias. The name splice! avoids conflicting with
metaprogramming’s splice.
Use the full language for this extension: remove the beginner pragma from the file or start a separate example. Several generators in one header walk a Cartesian product. The rightmost generator varies fastest; a later generator can depend on an earlier one.
visits: []
for row in 1..2, col in 1..3 [push(visits, (row, col))]
println(visits) # [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3)]
selected: []
'search: for row in 1..3 [
for col in 1..3 [
if row == 2 and col == 2 then break 'search
push(selected, (row, col))
]
]
println(selected) # [(1, 1), (1, 2), (1, 3), (2, 1)]A combined header is one loop: its unlabelled
break exits the product. Two nested loop statements have
separate exits; an unlabelled break exits only the
innermost. A label makes the intended target explicit.
continue 'search advances the labelled loop to its next
iteration. Matrix iteration already walks cells; use row/column index
loops when the axes themselves are part of the task.
try(expr) captures an expression’s failure as an Error
value. The multiline
try/catch/finally/end
form adds handlers and cleanup.
finished: false
result: try
1 / 0
catch problem is DivByZero
"cannot divide by zero"
finally
rebind finished = true
end
println(result) # cannot divide by zero
println(finished) # trueThe selected handler supplies the result. Cleanup runs on success and
failure; its normal value does not replace the result. A cleanup failure
can replace a pending result, so cleanup needs care too.
rebind here explicitly updates the outer cell. An unmatched
captured error remains an Error value; use raise(problem)
when it should propagate to a caller. Selected catches test the Error’s
type, not a substring of its message. This end-form remains
interpreter-only; the VM refuses it.
sum_with_loopRe-implement sum_list from Ch.7 using a
while-loop with two mutable variables (i for
index, total for running sum). Verify it matches §15.1’s
recursive sum_list on three inputs.
sum_with_loop([]) → 0sum_with_loop([7]) → 7sum_with_loop([1, 2, 3, 4, 5]) → 15make_bank_accountWrite make_bank_account(start_balance) that returns a
function. Each call to that function takes an amount and applies a
deposit (positive) or withdrawal (negative) to the account, returning
the new balance. Withdrawals that would overdraw the account return the
unchanged balance instead.
acc: make_bank_account(100)
v1: acc(50) # 150
v2: acc(-30) # 120
v3: acc(-200) # 120 (refused, would overdraw)
v4: acc(0) # 120 (query balance)
traffic_lightModel a 3-state traffic light: "red" →
"green" → "yellow" → "red" → … .
Write make_traffic_light() that returns a function. Each
call advances to the next state and returns the new state.
light: make_traffic_light()
println(light()) # green (started red, advanced)
println(light()) # yellow
println(light()) # red
println(light()) # green
Hint: a local state variable plus an
if/else if/else cascade in the inner function.
count_passingA turnstile records how many people walk through. Use the §16.4
turnstile but expose passing_count() as a function that
returns the current count without changing the state.
coin(); push(); passing_count() → 1coin(); push(); coin(); push(); passing_count() →
3push(); passing_count() → 0 (no coin yet,
refused)find_first_with_loopGiven an array and a predicate, return the first element for which
the predicate is true, or none if none qualifies. Use a
while loop with a mutable result variable.
find_first_with_loop([1, 3, 5, 7], func(x) [x > 4])
→ 5find_first_with_loop([1, 3, 5], func(x) [x > 100]) →
nonefind_first_with_loop([], func(x) [true]) →
noneCompare your loop version against the recursive
find_first from earlier chapters. Which is easier to read?
Which is easier to verify correct?
Model a single cell in Conway’s Game of Life. The cell has a
mutable state (true = alive, false = dead).
Each generation, the cell takes an integer argument
neighbors (count of living neighbors) and updates its state
by the classical rules:
Write make_cell(initial_alive) returning a function
step that takes neighbors, mutates the cell,
and returns the new state.
c: make_cell(true)
println(c(2)) # true (lives)
println(c(1)) # false (dies)
println(c(3)) # true (born again)
println(c(0)) # false (dies)
Then think: how would you orchestrate many cells in a grid? What’s the minimum state each cell needs? The answers shape how you’d build a full game-of-life simulator.
End of Chapter 16. Chapter 17 closes Part V with a brief look at streams — values that produce their elements one at a time, on demand, without ever materializing the whole sequence. Streams and mutation together let you model genuinely infinite data.
Solutions to selected exercises: Chapter 16 · Solutions in Appendix C. (Repo file: exercises/solutions/ch16_solutions.md.)
What this chapter is. A small introduction to a big topic: lazy sequences. A stream is a sequence that generates its elements on demand — you can take the first ten naturals without materializing all the naturals. Streams let you express genuinely infinite data: the natural numbers, the prime numbers, the Fibonacci sequence — as values you can pass around and compose, even though no finite memory can hold them. The cost is a small amount of ceremony: every stream element is wrapped in a thunk (a zero-argument function that, when called, produces the next piece).
Axioma does have built-in lazy forms — the generator expression
(x | x <- src, …), driven byforce,gen_take, andgen_next; andlazy e, which is the thunk itself as a first-class value (§17.8) — but this chapter builds streams by hand out of pairs and closures, exactly how SICP and HtDP build them out of cons cells. Seeing the mechanism is the teaching purpose; in production code, reach for the built-ins.
A stream is a 2-tuple:
stream = (head, tail_thunk)
= ( the_first_element,
a_function_that_when_called_returns_the_rest_stream )
The crucial idea: the rest isn’t stored — it’s a
recipe for producing the rest. Calling the tail thunk
evaluates the recipe and yields another
(head, tail_thunk) pair. No matter how long the conceptual
sequence is, only two pieces of data live in memory at any
moment: the current head and a single thunk.
In Axioma:
#language axioma/beginner
# A stream of natural numbers starting from k
nats_from: func(k) [
(k, func() [nats_from(k + 1)])
]nats_from(1) returns (1, <function>).
The function, when called, returns (2, <function>).
The pattern continues indefinitely — the recipe for “the next
natural” is captured in the closure; no actual numbers beyond the
current head are stored.
Three operations are enough to do interesting work.
stream_head and
stream_tail#language axioma/beginner
stream_head: func(s) [ s[1] ]
stream_tail: func(s) [ s[2]() ]stream_head reads the first element directly;
stream_tail calls the thunk to get the next
stream. Tuple indexing is 1-based (Ch.6).
take_stream(n, s)
— extract n elements as an array#language axioma/beginner
take_stream: func(n, s) [
if n == 0 then []
else [
h: stream_head(s);
t: stream_tail(s);
push([], h) + take_stream(n - 1, t)
]
]A recursion that walks n levels of the stream, peeling
off heads as it goes. The stream itself is unbounded, but we
stop after n peels.
nats_from: func(k) [ (k, func() [nats_from(k + 1)]) ]
stream_head: func(s) [ s[1] ]
stream_tail: func(s) [ s[2]() ]
take_stream: func(n, s) [
if n == 0 then []
else [
h: stream_head(s);
t: stream_tail(s);
push([], h) + take_stream(n - 1, t)
]
]
println(take_stream(10, nats_from(1))) # [1, 2, ..., 10]
println(take_stream(5, nats_from(100))) # [100, 101, ..., 104]nats_from is defined to be infinite.
take_stream is the “sampling” tool that lets us look at any
finite prefix.
Streams support the same algebra as arrays — map,
filter, reduce. But the operations work
lazily: applying map to an infinite stream
produces another infinite stream; no work happens until somebody asks
for elements.
#language axioma/beginner
map_stream: func(f, s) [
(f(stream_head(s)), func() [map_stream(f, stream_tail(s))])
]
filter_stream: func(pred, s) [
h: stream_head(s);
if pred(h) then (h, func() [filter_stream(pred, stream_tail(s))])
else filter_stream(pred, stream_tail(s))
]Notice the shape. map_stream returns a new
pair: a new head (f applied to the original head) and a
new thunk that recursively maps when called.
filter_stream does the same, but if the head doesn’t
satisfy the predicate, it keeps advancing through the source stream
until it finds one that does (then returns that element as the
new head).
Compose them:
nats: nats_from(1)
squares: map_stream(func(x) [x * x], nats)
println(take_stream(10, squares)) # [1, 4, 9, ..., 100]
# Evens via filter
evens: filter_stream(func(x) [x % 2 == 0], nats)
println(take_stream(10, evens)) # [2, 4, ..., 20]
# Compose: squares of evens
even_squares: map_stream(func(x) [x * x], evens)
println(take_stream(5, even_squares)) # [4, 16, 36, 64, 100]Three infinite streams (nats, squares,
evens, even_squares) compose with operations
you’ve seen before (map, filter). The lazy
structure makes infinite sequences first-class — they fit in
named variables, get passed to functions, get transformed.
The poster child for lazy sequences. The Fibonacci sequence is defined by a two-state recurrence (each term is the sum of the previous two). A stream-based version writes the definition directly:
#language axioma/beginner
fib_pair: func(a, b) [
(a, func() [fib_pair(b, a + b)])
]
fibs: fib_pair(0, 1)
println(take_stream(10, fibs)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
println(take_stream(20, fibs))fib_pair(a, b) says “the next Fibonacci is
a; after that, the stream continues with
fib_pair(b, a + b).” The (b, a + b)
is the recurrence — every step the pair (a, b)
rolls forward to (b, a + b). The stream-thunk evaluates the
recurrence on demand.
A “find the first Fibonacci greater than 1000” query becomes:
#language axioma/beginner
fib_pair: func(a, b) [ (a, func() [fib_pair(b, a + b)]) ]
stream_head: func(s) [ s[1] ]
stream_tail: func(s) [ s[2]() ]
first_gt: func(s, lim) [
h: stream_head(s);
if h > lim then h
else first_gt(stream_tail(s), lim)
]
fibs: fib_pair(0, 1)
println(first_gt(fibs, 1000)) # 1597We never materialize the infinite stream of Fibonacci numbers; we only walk the prefix needed to answer the question. That’s the whole point of lazy evaluation — pay only for what you ask for.
There’s a second formulation that doesn’t use pairs at all — just closures over mutable state, à la JavaScript or Python generators:
#language axioma/beginner
make_counter_gen: func(start) [
n: start;
func() [
v: n;
rebind n = n + 1;
v
]
]
gen: make_counter_gen(10)
v1: gen()
v2: gen()
v3: gen()
println(v1) # 10
println(v2) # 11
println(v3) # 12The “generator” is a closure that, when called, yields the next value and updates its internal counter. No pair, no thunk — just mutation hidden inside a closure.
The two styles trade off:
| Property | Pair-based stream | Generator-as-closure |
|---|---|---|
| Pure (no mutation) | yes | no — updates captured state with rebind |
Composable (map, filter) |
yes — lazy chain | hard — each call advances |
| Re-iterable from start | yes — re-call take_stream(n, s) |
no — generator is consumed |
| Familiar to imperative programmers | less | more |
Pair-based wins on composition; generator wins on ergonomics inside a loop. SICP / HtDP teach the pair-based version because it’s the one that fits into the design recipe. Real-world Axioma you’d reach for whichever matches the ambient style of the surrounding code.
Three idioms for “a sequence of values”:
| Form | Materialized? | Lazy? | Size limit | Composes with map/filter? |
|---|---|---|---|---|
Array ([1, 2, 3]) |
yes — all in memory | no | bounded by RAM | yes (eager) |
| Stream (pair + thunk) | only head + thunk | yes | unbounded | yes (lazy) |
| Generator (closure) | only current state | yes | unbounded | awkward |
Each has a place. Arrays are great when you have a finite known-size collection and need random access. Streams are great when the sequence is conceptually infinite but you only need a prefix — primes, naturals, Fibonacci, time-stamped events. Generators are great for adapting external sources (file readers, network streams) into the iteration style.
When this chapter was first drafted, Axioma had no native
lazy-sequence type and this section flagged the gap (G-10 in the
inventory): Haskell writes [1..] for the infinite list of
naturals; Axioma’s closest thing was a bounded [1..n].
The gap has since closed, with almost exactly the wished-for
spelling. The loop examples in this section use the full language;
remove the beginner pragma in a combined file. 1.. is a
first-class open-ended range — lazy and infinite — and
a lazy (parenthesized) comprehension streams it on demand:
nats: 1.. # the naturals, as a value
squares: (n * n | n <- nats) # lazy comprehension — nothing runs yet
first(squares, 5) # → [1, 4, 9, 16, 25]
first_big: 0 # search past a threshold: loop + break
for n in nats [
if n * n > 1000 then [
first_big = n * n
break
]
]
first_big # → 1024Membership stays O(1) (10^9 in nats → true
without iterating), zip(1.., xs) numbers a list, and every
materializer (sum, len,
array, an eager comprehension) refuses an open range with a
catchable error rather than hanging. See the manual’s §4 Ranges
section for the full surface (direction, ..<,
by steps).
So when do you still hand-roll the (head, thunk) stream
of this chapter? When the sequence isn’t arithmetic — primes,
Fibonacci, sensor readings. An open range gives you the integers for
free; the pair-and-thunk construction (or
infinite_set("primes")) remains the tool for everything
whose next element needs computing from the last. That is
exactly the lesson of this chapter, and it survives the feature:
laziness is a technique, not a type.
One large slice of “computing from the last” has since become a
builtin. When each element is a function of exactly the previous
one, iterate(f, x) is the whole construction — the
unbounded sequence x, f(x), f(f(x)), … as a lazy stream, no
pair and no thunk:
powers: iterate(func(v) [v * 2], 1) # 1, 2, 4, 8, 16, …
first(powers, 5) # → [1, 2, 4, 8, 16]
for i in iterate(func(v) [v * 2], 1) [ # a loop counter whose
if i >= 20 then [ break ] # step is any function,
println(i) # and which is still
] # scoped to the loopA lazy stream — an open range, a lazy comprehension, or an
iterate — is a loop source, walked one
element at a time and never materialized. take_while bounds
one without a break:
take_while(func(x) [x < 20], iterate(func(v) [v * 2], 1)) # → [1, 2, 4, 8, 16]A stream is walked once: stop partway and the next loop resumes where you left off, but a stream already drained raises a catchable error rather than quietly running zero iterations.
What this does not cover directly is the case where
the next element needs more than the previous one — Fibonacci needs the
previous two, primes need every prime so far. The standard
trick is to iterate over a richer state
and project the answer out of it:
fib_states: iterate(func(p) [(p[2], p[1] + p[2])], (0, 1))
first(fib_states, 8) # → [(0,1), (1,1), (1,2), (2,3), (3,5), (5,8), (8,13), (13,21)]
map(func(p) [p[1]], first(fib_states, 8)) # → [0, 1, 1, 2, 3, 5, 8, 13]Note what that costs: the stream’s elements are no longer the
sequence you wanted, and you pay a map to recover it. When
the state can’t be squeezed into a fixed-size tuple — primes, which need
every prime found so far — the pair-and-thunk construction of this
chapter is still the tool. The lesson holds: the builtins cover the
common shapes, and the technique covers the rest.
lazy and force§17.7 was about the language growing a built-in for the sequence. It has since grown one for the other half of the construction too — the thunk, the piece §17.1 hand-rolls.
A thunk in this chapter is a zero-argument function you call to get
the rest: func() [nats_from(k + 1)], invoked with
(). lazy e is that same idea as a
value, and force runs it:
t: lazy (6 * 7) # nothing has run yet
force(t) # → 42 computed now…
force(t) # → 42 …and cached, not recomputedSo the stream constructor loses the closure ceremony, and
stream_tail becomes a force instead of a
call:
nats_from: func(k) [ (k, lazy nats_from(k + 1)) ]
stream_head: func(s) [ s[1] ]
stream_tail: func(s) [ force(s[2]) ]
s: nats_from(1)
stream_head(s) # → 1
stream_head(stream_tail(s)) # → 2
stream_head(stream_tail(stream_tail(s))) # → 3The two thunks are not interchangeable, and the gap is the classic
one. A hand-rolled func() thunk re-runs its body on
every call. A lazy thunk runs it
once and caches the result:
hand_runs: 0
hand_work: func() [
rebind hand_runs = hand_runs + 1
"computed"
]
hand: func() [hand_work()] # hand-rolled thunk
hand()
hand()
println(hand_runs) # → 2 recomputed every call
lazy_runs: 0
lazy_work: func() [
rebind lazy_runs = lazy_runs + 1
"computed"
]
t: lazy lazy_work() # built-in thunk
force(t)
force(t)
println(lazy_runs) # → 1 computed once, then cachedThis is exactly the distinction SICP draws in §3.5.1, where
delay starts as a bare (lambda () exp) and is
then wrapped in memo-proc to stop streams recomputing their
own tails. lazy is the memoized version, built in.
It is not a theoretical concern — §17.4’s own example walks
fibs twice:
tails: 0
fib_pair: func(a, b) [
(a, lazy [ rebind tails = tails + 1
fib_pair(b, a + b) ])
]
stream_head: func(s) [ s[1] ]
stream_tail: func(s) [ force(s[2]) ]
take_stream: func(n, s) [
if n == 0 then []
else [
h: stream_head(s);
t: stream_tail(s);
push([], h) + take_stream(n - 1, t)
]
]
fibs: fib_pair(0, 1)
take_stream(10, fibs)
take_stream(20, fibs)
tails # → 20 with `lazy`
# 30 with the hand-rolled `func()` thunk —
# the first ten tails computed a second timeTwenty tails exist; the hand-rolled version computes thirty. The ten extra are the prefix the second walk re-derives from scratch. On this stream that is a constant-factor waste; on a stream whose tail is expensive, or one walked many times, it is the difference between a usable structure and an unusable one.
Hand-rolling stays the right way to learn the mechanism —
that is why this chapter does it, and why stream_tail’s
s[2]() is worth having typed at least once. For code you
intend to keep, lazy says what you mean, and it memoizes
for free. And the deeper point of §17.7 survives here unchanged: the
built-in did not replace the technique, it named it.
Part V’s arc:
The thread: each chapter loosens one constraint of the pure functional core. Accumulators loosen “the answer comes out on the way back up” — it can come out the way down. Mutation loosens “names are fixed” — they can change. Streams loosen “all values are computed eagerly” — some values aren’t computed until you ask.
Loosened constraints give you new tools, not new rules. A program that uses mutation everywhere is harder to reason about than one that uses mutation only where it’s appropriate. A program that lazy-evaluates everything is harder to debug than one that materializes intermediate results. Good code picks the right tool for each spot and uses pure idioms by default.
You now have the entire imperative/functional toolbox. Part VI takes a different direction: Axioma-specific features that don’t have HtDP equivalents — Concepts as sum-type alternatives, enums + subranges, multi-valued logic, logic programming, the meta-circular evaluator. Each chapter explores one corner of Axioma that no Lisp / Python / Java textbook would cover.
integers_fromWrite integers_from(start, step) that yields an infinite
arithmetic progression starting at start, advancing by
step each time.
take_stream(5, integers_from(0, 1)) →
[0, 1, 2, 3, 4]take_stream(5, integers_from(10, 5)) →
[10, 15, 20, 25, 30]take_stream(5, integers_from(0, -1)) →
[0, -1, -2, -3, -4]take_whileImplement take_while(pred, s) that returns an
array of prefix elements of s for which
pred is true, stopping at the first failing element.
take_while(func(x) [x < 5], nats_from(1)) →
[1, 2, 3, 4]take_while(func(x) [x > 0], integers_from(10, -1)) →
[10, 9, 8, …, 1](Hint: this is bounded by the first false; for an infinite stream that has no false element, this would loop. The exercise assumes a finite false-prefix.)
zip_streamsCombine two streams element-wise into a stream of tuples.
take_stream(5, zip_streams(nats_from(1), nats_from(100)))
→ [(1, 100), (2, 101), (3, 102), (4, 103), (5, 104)]stream_of_squaresBuild the stream of squares 1, 4, 9, 16, … by composing
map_stream with nats_from(1). Then verify the
first five match [1, 4, 9, 16, 25].
every_kthGiven a stream s and an integer k, return a
new stream that yields every k-th element of s
(the first, the (k+1)-th, the (2k+1)-th,
…).
take_stream(5, every_kth(nats_from(1), 2)) →
[1, 3, 5, 7, 9]take_stream(5, every_kth(nats_from(1), 5)) →
[1, 6, 11, 16, 21]A classic stream computation: build the stream of primes by sieving the naturals. Algorithm:
2) is the first prime.2 from the rest.3) is the next
prime.3.In code, this is one recursive function on streams. Each step yields one prime and constructs the “sieved” remainder as a new stream.
sieve: func(s) [
p: stream_head(s);
(p, func() [
sieve(filter_stream(func(x) [x % p != 0], stream_tail(s)))
])
]
primes: sieve(integers_from(2, 1))
println(take_stream(10, primes)) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]Verify the above. Then think:
n
primes this way? (Hint: each prime sets up a new filter-thunk; primes
near the top of the list filter through many earlier filters.)End of Part V. You now have:
That’s the full programming-with-data toolbox. Part VI takes a left turn into Axioma’s distinctive features: how its Concepts replace HtDP’s structures and sum types; how its type system handles enums and subranges; how its multi-valued logic captures uncertainty directly; how its logic programming engine answers queries about facts; and finally, how all of it is itself defined in Axioma — the meta-circular evaluator that ties the whole language back to itself.
Solutions to selected exercises: Chapter 17 · Solutions in Appendix C. (Repo file: exercises/solutions/ch17_solutions.md.)
What this chapter is. Part VI starts with a meta chapter — about a design choice Axioma made that most teaching languages don’t. HtDP introduces structures (
define-struct posn (x y)) and variant data (a value is one of several flavors) as the two pillars of arbitrary-size data. Axioma uses neither directly. Instead it has a single first-class Concept system that does the work of both. This chapter reads the Concept system back against HtDP’s design — what Concepts replace, what they add, and how Axioma’sis/extends/partitiontriad maps to ML’sdata Foo = Bar | Bazand Java’s class hierarchies.A pragma note. Part VI’s examples leave the
axioma/beginnersubset. Theis,partition, andrangesdeclarations introduced in this chapter and the next are intermediate-tier features — the beginner subset rejects them with an educational hint. The code blocks in Chapters 18–22 don’t carry the#language axioma/beginnerpragma. Run them in the default Axioma surface, where every language feature is available.
HtDP’s data design has two moves:
Structures package multiple values into one composite value with named fields:
(define-struct posn (x y))
(make-posn 3 4) ; → a posn with x=3, y=4
(posn-x p) ; → 3
(posn? p) ; → #t (predicate)
Variant data says “a value can be one of several shapes,” each with its own data definition:
;; A LightState is one of:
;; - 'red
;; - 'green
;; - 'yellow
The full HtDP recipe walks a function over both: an outer
cond dispatches on which variant you’ve got, and
inner (posn-x p)-style accessors pull fields out of each
variant’s structure.
Other languages do the same job differently:
data types with multiple constructors (for variants).The two pillars (composition + variation) are fundamental; every language has some answer.
A Concept in Axioma is a type. You declare
one with concept Foo, list its slots with has,
and that’s the type’s structure. You make instances with
a Foo {...} (or an Foo {...} when the name
starts with a vowel sound). So far this is just “Concepts replace
structs”:
concept Posn
Posn has x: 0
Posn has y: 0
p: a Posn {x: 3, y: 4}
println(p.x) # 3
println(p is Posn) # trueWhere Concepts go beyond structs is inheritance.
Concepts can declare is another Concept — and the parent’s
fields, the parent’s is-test, and any later relations the
parent participates in, all transfer:
concept Animal
Animal has name: ""
Mammal extends Animal
Mammal has fur: true
Bird extends Animal
Bird has wings: 2
dog: a Mammal {name: "Rex", fur: true}
robin: a Bird {name: "Robin", wings: 2}
println(dog is Mammal) # true
println(dog is Animal) # true
println(robin is Bird) # true
println(robin is Animal) # true
println(robin is Mammal) # falseMammal is Animal says “every Mammal is also an Animal” —
the inclusion relation at the type level. The
is test honors the relationship transitively.
That gives you variant data for free: an Animal-walking function dispatches on which kind of Animal it has:
sound: func(a) [
if a is Mammal then "growl/bark/etc"
else if a is Bird then "chirp"
else "unknown"
]
println(sound(dog)) # growl/bark/etc
println(sound(robin)) # chirpdata, the direct ML/Haskell analogueUpdate. When this chapter was first drafted, Axioma had no direct equivalent of ML’s
data Foo = Bar | Baz— the paragraphs above were making the case that Concepts +partitioncover the same ground by other means. That’s still true, and still worth understanding. But Axioma has since grown an actual second pillar: a nativedatadeclaration, closed constructor patterns inmatch, and--typecheckexhaustiveness — the literal ML/Haskell mechanism, not just an analogue of it. This section introduces it directly; §18.6 below revisits the trade-offs now that both tools genuinely exist side by side.
data Shape = Circle(Float) | Rect(Float, Float) | DotThis is data, not concept — no separate
extends/partition pair needed.
Shape is a type with exactly three constructors: two carry
positional fields (Circle one Float, Rect
two), one is a bare singleton (Dot, no parentheses).
Constructing and inspecting values:
c: Circle(2.0)
d: Dot
type(c) # → "Shape"
c is Shape # → true
tag(c) # → "Circle"
Circle(2.0) == Circle(2.0) # → true (structural equality)match dispatches on the constructor directly — no
is-cascade:
area: func(s) [
match s with
| Circle(r) => 3.14159 * r * r
| Rect(w, h) => w * h
| Dot => 0
]
area(Circle(5.0)) # → 78.53975
area(Rect(4.0, 6.0)) # → 24
area(Dot) # → 0Side by side with the HtDP recipe:
| HtDP / Racket | Axioma Concepts (§18.2) | Axioma data (this section) |
|---|---|---|
(define-struct circle (radius)) |
Circle extends Shape; Circle has radius |
Circle(Float) — a constructor alternative |
| “Shape is one of …” | partition (declared separately) |
The |-separated list IS the “one of” |
(cond [(circle? s) ...] ...) |
if s is Circle then ... else if ... |
match s with | Circle(r) => ... |
Missing a variant in cond |
Silent (unless partition + a checker) |
--typecheck catches it (next) |
The payoff HtDP’s own comment-only “Shape is one of” notation always wanted but never got: machine-checked exhaustiveness.
r: match Circle(1.0) with
| Circle(r) => r
| Rect(w, h) => w * h
# --typecheck →
# "non-exhaustive match on Shape: missing constructor(s) Dot
# (unmatched values fall through to `none`)"Add the missing arm (or an explicit _ catch-all) and the
warning clears. A redundant arm — a tag an earlier
unguarded arm already covers — is caught too:
match Circle(1.0) with
| Circle(r) => r
| Circle(_) => 0.0
| _ => 0.0
# --typecheck → "unreachable match arm: constructor 'Circle' already
# matched by an earlier arm"Both diagnostics are static-only — normal execution
(no --typecheck flag) is completely unaffected either way,
and an unmatched value at runtime still falls through cleanly to
none (Chapter 5’s “total by construction”
match), never a crash.
Two more built-in sum types ship pre-declared, so no
data line is needed to use them:
# Built-in shape: Result = Ok(value) | Err(error)
# Built-in shape: Option = Absent | Some(value)
parse: func(s) [ if s == "5" then Ok(5) else Err("bad input") ]
match parse("5") with | Ok(v) => v | Err(e) => 0 # → 5Result/Option also compose with a dedicated
railway operator, |?>, that threads a
value through a chain of steps and short-circuits the moment one
produces Absent/Err — a direct answer to the
“propagate failure through several steps” problem this book has so far
only handled with try/otherwise (Chapter 16)
or explicit if cascades:
half: func(n) [Some(n / 2.0)]
double: func(n) [n * 2]
Some(10) |?> half |?> double # → 10.0 (both stages ran)
Absent |?> half |?> double # → Absent (short-circuits at the first step)The Animal example above has two known subtypes, but nothing
forbids someone from declaring a third (Reptile is Animal)
later. The sound function would silently return
"unknown" for reptiles — a bug if the spec said “every
Animal must produce a sound.”
Axioma’s partition declaration locks the parent’s subtypes down:
concept Animal
Animal has name: ""
Mammal extends Animal
Mammal has fur: true
Bird extends Animal
Bird has wings: 2
Fish extends Animal
Fish has gills: true
Animal partition Mammal, Bird, FishAfter partition, the system knows that every
Animal is exactly one of Mammal, Bird,
Fish. This is the same machinery Haskell uses for its
data types:
data Animal = Mammal | Bird | Fish— but with the structure-of-each-variant baked in by the preceding
is/has declarations.
The partition unlocks two things:
--typecheck
does now catch this — but via the sibling mechanism from §18.3,
not via partition directly: a match over a
data-declared type missing a constructor arm is a static
error. A partition
if ... is A then ... else if ... is B then ... cascade
(this section’s own idiom) is not yet checked the same
way. Reach for data + match (§18.3)
specifically when you want the exhaustiveness guarantee;
partition alone gives you the runtime
disjoint/exhaustive-over-extent queries below, not a compile-time
missing-arm warning.Animal partition Mammal, Bird, Fish and knows
the design space is closed. New variants require an explicit partition
update, not a silent slide.HtDP’s bread-and-butter exercise: define Shape as one of
Circle / Rectangle / Triangle, write an area function. In
Racket / BSL:
(define-struct circle (radius))
(define-struct rectangle (width height))
(define-struct triangle (base height))
;; A Shape is one of:
;; - (make-circle r)
;; - (make-rectangle w h)
;; - (make-triangle b h)
(define (area s)
(cond [(circle? s) (* 3.14159 (sqr (circle-radius s)))]
[(rectangle? s) (* (rectangle-width s) (rectangle-height s))]
[(triangle? s) (* 0.5 (triangle-base s) (triangle-height s))]))
In a fresh Axioma file (these Concept names are an
alternative to the data Shape constructors above):
concept Shape
Circle extends Shape
Circle has radius: 0.0
Rectangle extends Shape
Rectangle has width: 0.0
Rectangle has height: 0.0
Triangle extends Shape
Triangle has base: 0.0
Triangle has height: 0.0
Shape partition Circle, Rectangle, Triangle
area: func(s) [
if s is Circle then 3.14159 * s.radius * s.radius
else if s is Rectangle then s.width * s.height
else if s is Triangle then 0.5 * s.base * s.height
else 0.0
]
c: a Circle {radius: 5.0}
r: a Rectangle {width: 4.0, height: 6.0}
t: a Triangle {base: 3.0, height: 4.0}
println(area(c)) # 78.53975
println(area(r)) # 24.0
println(area(t)) # 6.0Side-by-side mapping:
| HtDP / Racket | Axioma |
|---|---|
(define-struct foo (a b)) |
concept Foo; Foo has … |
(make-foo 1 2) |
a Foo {a: 1, b: 2} |
(foo-a x) |
x.a |
(foo? x) |
x is Foo |
“Shape is one of …” + cond |
partition + if … is … |
| Data definition (comment) | partition (machine-checked) |
The Axioma version says more than the HtDP comment did —
partition is enforced by the system. The HtDP comment was
just documentation; it could lie.
data, now that both existWith §18.3’s data in hand, this is no longer “what do
Concepts give up compared to ML” — it’s “which of Axioma’s two tools
fits this variant set.” Compared directly:
Concepts (extends + partition)
gain:
Animal
subtype doesn’t require recompiling code that uses Animal.
(data variants are closed — every constructor is listed at
the data line, full stop.)data constructors
are independent; each lists its own fields from scratch — there’s no
“Rect inherits Shape’s fields” relationship.)data types are
fixed once declared.data sum types (§18.3) gain:
--typecheck catches a missing constructor arm in
match directly (§18.3) — no separate partition
declaration to remember, no still-open gap (contrast with
partition + if-cascade, which is not
checked — see the note in §18.4). This is the genuine, machine-enforced
version of what HtDP’s “Shape is one of …” comment always claimed but
never verified.partition + is-dispatch has
no equivalent for.Circle(2.0) constructs by position;
match | Circle(r) => ... destructures by position in the
same motion. Concepts always go through named has slots and
.field access — more verbose for a small, closed shape like
a 2D point or a parse result.The decision rule. If the variant set is genuinely
closed and you want the type-checker to prove every case is handled — a
small calculator’s Expression, a protocol message, a
parser’s token kind, Option/Result themselves
— reach for data + match. If the domain is
open-world and will keep growing new sub-categories over the program’s
life — a real-world taxonomy, an evolving product catalog, anything
where “we’ll add a new kind next quarter” is expected — reach for
Concepts + extends, with partition layered on
only where a snapshot of the current variant set needs to be
exhaustive-checked at runtime (via
is_partitioned/partition_gap, not
--typecheck).
Concepts compose to arbitrary depth. A small biological taxonomy:
concept LivingThing
LivingThing has name: ""
Animal extends LivingThing
Animal has legs: 0
Vertebrate extends Animal
Vertebrate has has_spine: true
Mammal extends Vertebrate
Mammal has fur_color: ""
Dog extends Mammal
Dog has breed: ""
rex: a Dog {name: "Rex", legs: 4, has_spine: true, fur_color: "brown", breed: "Labrador"}
# Every level of the hierarchy responds true
println(rex is Dog) # true
println(rex is Mammal) # true
println(rex is Vertebrate) # true
println(rex is Animal) # true
println(rex is LivingThing) # true
# And only the relevant fields exist
println(rex.breed) # Labrador
println(rex.fur_color) # brown
println(rex.legs) # 4
println(rex.name) # RexEach is declaration adds a slot; the instance
construction has to provide values for every slot up the chain. The
hierarchy is the same shape biologists use to classify real organisms —
and exactly what Linnaeus drew on paper in 1735. The vocabulary works
because the concept itself (parent/child types) is older than
any programming language.
The HtDP world is one where the language designer hands you two
primitives (define-struct, variant cond) and
you build the world from them. Every interesting type is one of those
two — or, in real Racket, a more powerful object system bolted on
top.
Axioma’s answer turned out to be two mechanisms, not
one — and that’s a deliberate departure from “one mechanism to rule them
all.” The concept-of-concept system (§18.2) handles open-world
composition-and-classification: inheritance, partition, runtime
extensibility, all in one vocabulary that also covers biological
taxonomies, organizational charts, and software class hierarchies. The
data sum type (§18.3) handles closed-world variant
data with the one guarantee Concepts structurally can’t give you for
free: a compiler that proves every case is handled. Rather than force
one tool to do both jobs — which is exactly the tension §18.6 walks
through — Axioma ships both and lets the shape of the problem
pick the tool.
That’s the bet this chapter has been making the case for:
give the reader two well-differentiated tools instead of one
overloaded one, and trust them to reach for the right one. The
remaining chapters of Part VI will lean on both — multi-valued logic
(Ch.20) and logic programming (Ch.21) read naturally with Concepts; the
meta-circular evaluator (Ch.22) is the canonical
data-shaped problem (an Expression is closed,
and you want the compiler to catch a missing case in
eval).
Concepts model named kinds; dictionary schemas describe required fields. A schema can refer to itself, so a tree can be represented as records without defining a Concept for each node.
type TreeRecord = {value :: Integer, children :: Array of TreeRecord}
let tree :: TreeRecord = {
value: 1,
children: [{value: 2, children: []}]
}
println(tree.children[1].value) # 2
println(tree is TreeRecord) # true
println({value: "bad", children: []} is TreeRecord) # falseThe recursive reference constrains every reachable child. A declared
schema also survives supported field writes: validating construction is
not permission to store a wrong type later. Optional fields use
?, as in next_node? :: TreeRecord. Recursive
schema declarations are an interpreter feature; do not infer VM support
from a similar record example.
An absent field and a missing measurement are different.
note? permits the field to be absent;
Float | Na permits a present cell holding na.
Neither is the same as none or an IEEE floating-point
NaN.
type SampleRow = {reading :: Float | Na, quality :: Integer, note? :: String}
let sample :: SampleRow = {reading: na, quality: 7}
let another_name = sample
another_name.reading = 12.5
sample.note = "checked"
println(sample.reading) # 12.5
println(delete sample["note"]) # checked
println(sample.note) # none
let revised = {sample with quality: 9}
println(sample.quality) # 7
println(revised.quality) # 9
measurements: dataframe(
{reading: [12.5, na], quality: [7, 8]},
{schema: SampleRow}
)
println(measurements is DataFrame) # trueThe first update is visible through both names. The record-update
form creates an independent record. Writing a String to
sample.reading or removing required quality is
refused; field references and indexed writes must preserve the same
contract.
A typed DataFrame checks columns against its row schema and copies
input column arrays. Filtering, sorting, and supported column
transformations preserve or project the schema. df_mutate
returns a new validated table. CSV reading can apply the same schema,
but missing-marker choices and column names must match the file. The
current typed table surface covers scalar cells and unions; nested
mutable cells are explicitly refused.
perimeterAdd a perimeter(s) function that computes the perimeter
of a Shape. For Triangle, assume an equilateral triangle so the
perimeter is 3 * base.
perimeter(a Circle {radius: 5.0}) → ~31.42
(2πr)perimeter(a Rectangle {width: 3.0, height: 4.0}) →
14.0perimeter(a Triangle {base: 5.0, height: 4.33}) →
15.0LightStateModel HtDP’s LightState as a Concept hierarchy:
concept LightState
RedLight extends LightState
GreenLight extends LightState
YellowLight extends LightState
LightState partition RedLight, GreenLight, YellowLight
Then write next_light(s) that returns the next state
(RedLight → GreenLight →
YellowLight → RedLight). Build it from
is dispatches; return new objects.
Model a small taxonomy: Animal, Vertebrate / Invertebrate (partition
Animal), Mammal / Bird / Fish / Reptile / Amphibian (partition
Vertebrate). Make at least one instance per leaf type. Then write
is_warm_blooded(a) that returns true for
Mammals and Birds, false for everything else.
expr_eval revisitedConvert the Chapter 9 Lit/Op expression
evaluator to use explicit Expression partition Lit, Op.
Verify the same test cases still work.
count_shapes_by_typeGiven an array of Shapes, return a tuple
(num_circles, num_rectangles, num_triangles). Use
accumulator-style recursion (Ch.15) walking the array, with three
accumulators.
count_shapes_by_type([c, r, t, c, c]) →
(3, 1, 1)count_shapes_by_type([]) → (0, 0, 0)Pick a domain you know (cards in a deck, vehicles in traffic, musical notes in a scale, file system entries, GUI widgets, events in a log) and design a Concept hierarchy for it. Include:
is.partition that closes the variant set.Show that is-dispatch and inheritance compose to make
your design concise. Compare against how you’d write it in your favorite
other language; note what’s easier and what’s harder.
data LightStateRedo Exercise 18.2 with data instead of Concepts +
partition:
data LightState = RedLight | GreenLight | YellowLight
Write next_light(s) with match instead of
an is-cascade (RedLight →
GreenLight → YellowLight →
RedLight). Give the parameter an explicit type
annotation —
next_light: func(s :: LightState) [ ... ] — this matters:
--typecheck’s exhaustiveness check reads a static
shadow-type model, not full inference, so an unannotated
parameter’s type is unknown to it and the check is silently skipped (no
warning either way — not even a false pass). With the annotation in
place, deliberately delete one arm and run
axioma --typecheck on the file — confirm you get a
non-exhaustive match warning naming the missing
constructor. Put the arm back, run --typecheck again,
confirm it’s clean. Then remove the :: LightState
annotation with the arm still missing and re-run
--typecheck once more — confirm the warning disappears,
demonstrating the annotation requirement for yourself. Compare against
your Exercise 18.2 solution: which version would you rather maintain,
and why?
End of Chapter 18. Chapter 19 introduces
enumerations (named-finite-set types) and
subranges (numeric types restricted to a range), with
--typecheck enforcement. Together with this chapter’s
Concepts and data sum types, those give Axioma the
strongest invariant-by-construction story of any teaching
language — open-world classification, closed-world exhaustiveness, and
restricted-range numerics, each with the tool built for the job.
Solutions to selected exercises: Chapter 18 · Solutions in Appendix C. (Repo file: exercises/solutions/ch18_solutions.md.)
What this chapter is. Two type-system tools that narrow the set of values a name can hold. An enum is a finite, ordered, named set of values — the days of the week, the suits of a deck, the states of a traffic light. A subrange is a restricted numeric or enum type — “an integer between 0 and 9,” “any weekday but not the weekend.” Both let you express what’s allowed as part of the type itself, so violations can be caught by static checking (via
--typecheck) rather than at run time. The pedagogical bet: encode invariants in types, not in runtime checks.
Without enums, a “day of the week” gets modeled as a string:
# No enum — just strings
today: "Wednesday"
if today == "Saturday" or today == "Sunday" then [
println("Weekend!")
] else [
println("Workday.")
]Three problems:
"Wedensday" (sic)
compiles fine; only failing tests would catch it."Funday" isn’t legal.The Pascal / Ada / Modula tradition fixed this fifty years ago with enumerated types. Axioma picks up the same tool.
enumerates declarationDay enumerates Mon, Tue, Wed, Thu, Fri, Sat, SunThat single line creates:
Day with exactly seven legal values.Mon, Tue, …, Sun)
bound as a top-level name.today == Mon).Mon < Tue < Wed < ….A function that takes a Day cannot be called
with a random string:
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
is_weekend: func(d) [
d == Sat or d == Sun
]
println(is_weekend(Mon)) # false
println(is_weekend(Sat)) # trueIf you accidentally call is_weekend("Sat"), the result
is false (because the string "Sat"
isn’t equal to the value Sat). That’s still
imperfect — a true type system would reject the call. But
combined with --typecheck (next section), the compiler
catches the typo.
ranges declarationA subrange is a restricted integer type:
Digit ranges 0..9
d :: Digit: 5
println(d) # 5
bad :: Digit: 99 # ERROR at runtimeRun that last line and Axioma errors:
type error: variable 'bad' expects type Digit, got INTEGER (value: 99)
The 99 doesn’t fit in Digit’s 0..9 range,
so the binding is rejected. (Compare: bare bad: 99 would
accept anything; the :: Digit annotation is the gate.)
Subranges are useful whenever an integer’s legal range is
narrower than Integer: a die roll (1..6), an hour of the
day (0..23), an array index of a known-size array (1..N), an ASCII code
(0..127), a percentage (0..100), an HTTP status code (100..599).
Encoding the range as a type makes the constraint explicit and reusable. The evaluator checks annotated bindings; the static checker can diagnose some violations before execution. A type annotation is not a promise that every invalid value will be caught statically.
extends X in a..bThe pattern that makes the system really shine: an enum subtype whose values are a contiguous interval of the parent.
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
Workday extends Day in Mon..FriWorkday is now a new type whose values are
exactly Mon, Tue, Wed, Thu, Fri — not
Sat or Sun.
w :: Workday: Tue # OK
println(w) # Tue
bad :: Workday: Sat # ERROR: type error: variable 'bad' …A function whose parameter is annotated Workday
cannot receive Sat. The compiler / runtime
guarantees the parameter is a weekday. The function body
doesn’t need a if d == Sat or d == Sun then error(...)
guard — the type is the guard.
This is the pattern Pascal called subrange of an enum; it’s
the same pattern modern languages with refinement types (Liquid Haskell,
F) implement at the type-theory level. Axioma’s version is
operationally simple — at runtime, every write to a
Workday-typed variable checks against the interval — but
the expressive payoff* is the same: encode the invariant in the
type.
--typecheck pre-pass — invariants verified before
runningThe chapter wouldn’t be complete without showing the static checker.
Axioma has a --typecheck (alias --check) flag
that walks a script before running it and reports type-error
violations:
# day_logic.ax
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
today :: Day: "Wednesday" # bug — string, not Day
next_day :: Day: Mon + 1 # bug — arithmetic on DayRun:
$ axioma --typecheck day_logic.ax
day_logic.ax: line 3: type error: variable 'today' declared as Day,
assigned value of type String
Hint: change the annotation, or the value's literal form, so they agree
day_logic.ax: 1 type error(s) found.The script never runs. Both errors are caught before a
single line executes. For a real program with hundreds of annotated
bindings and function signatures, --typecheck is the
difference between “find the bug by running tests” and “find the bug at
compile time.”
A calendar has events with a day, a start hour, and
a duration. Hours range over 0..23; durations over 1..24. Days
are enum members; weekday-only events use Workday.
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
Workday extends Day in Mon..Fri
Hour ranges 0..23
Duration ranges 1..24
concept Event
Event has day: Mon # default value
Event has start: 9
Event has duration: 1
Event has title: ""
# A function whose parameter is annotated Workday gets
# parser-level help (and runtime enforcement).
schedule_meeting: func(d, h, dur, title) [
an Event {day: d, start: h, duration: dur, title: title}
]
m1: schedule_meeting(Mon, 9, 1, "Stand-up")
m2: schedule_meeting(Wed, 14, 2, "Planning")
println(m1.title)
println(m2.start)Then a query:
events: [m1, m2]
get_workday_events: func(arr) [
filter(func(e) [e.day != Sat and e.day != Sun], arr)
]
println(len(get_workday_events(events))) # 2The Workday-typed parameter is the documentation
and the runtime guarantee. The Hour ranges 0..23
makes schedule_meeting(Mon, 25, 1, "Bad") a runtime error
(you’ll get
type error: variable expected type Hour, got Integer value 25).
Compare to alternatives:
| Approach | Pros | Cons |
|---|---|---|
Plain strings ("Mon", "Tue") |
familiar | typos, no exhaustiveness, no ordering |
Integers (1 = Mon, 2 = Tue) |
compact, ordered | meaning isn’t in the value |
Constants (MON: 1; TUE: 2; …) |
named | no type protection — MON is just an Int |
| Enum class hierarchy (Java style) | OO-clean | heavy, lots of boilerplate |
Axioma’s enumerates |
named, ordered, exhaustive, typed | one new declaration form to learn |
| Axioma’s subrange | bounded numerics by type | one more |
Axioma’s extends X in … |
refinement type without proofs | one more |
The Axioma triple costs three lines of declaration to learn, buys you machine-checked invariants for huge swaths of real-world code. The Pascal tradition (Wirth, 1970) showed that this style works for teaching programming; Ada took it further; Modula-3 refined it. Axioma’s port revives the tradition for a 2026 audience.
The deep idea from this chapter: the best way to handle a class of bugs is to make them impossible to write. Not “catch them with tests,” not “detect them with assertions” — make the type system refuse to compile the broken program.
This is the invariant-by-construction principle. Every field
declared with a type annotation, every function whose parameters are
annotated, every --typecheck run, removes a class of
possible bugs from your code. Real-world software that takes this
seriously (Ada in aerospace, Rust in operating systems, OCaml in
finance) achieves fewer production bugs because the types do the
work.
You’ve now seen the four big tools in this tradition:
is makes type
subset relations explicit.The next three chapters move out of the structural-type territory entirely. Chapter 20 introduces multi-valued logic — what if truth itself isn’t binary? Chapter 21 introduces logic programming — what if the function calls were queries instead of computations? Chapter 22 closes with the meta-circular evaluator — an Axioma interpreter written in Axioma, tying every previous chapter back together.
A later addendum — Chapter 54 — returns to types
from the other direction. Chapter 19 is “you write the type, the checker
verifies.” #language axioma/hm is “the checker reads the
body and refuses the file if it has no principal type.” Host
--infer on axioma/all is a lens that skips;
the island is a language. Read 54 after this chapter if that is the next
question you have. It is numbered 54 so Chapters 20–53 stay put.
A repeated type parameter connects fields within one constructor call. An upper bound restricts which runtime type that parameter may choose.
data NumericPoint[T of Number] = MakePoint(x:: T, y:: T)
println(MakePoint(1, 2) is NumericPoint) # true
println(MakePoint(1.0, 2.0) is NumericPoint) # true
println(try(MakePoint(1, 2.0)) is Error) # true
println(try(MakePoint("a", "b")) is Error) # trueThere is no automatic promotion: both direct occurrences of
T must agree. Each constructor call chooses afresh.
Number includes Complex; Real is not a seeded
type. The old data-head requires Number spelling has been
replaced by T of Number; function requires:
contracts have their own role and remain supported.
This is not a fully specialized generic type system. Runtime values
erase the chosen parameter type, and nested Array of T,
dependent T of U, and NumericPoint[Float]
annotations are not supported. Use explicit schemas for nested data and
test construction as well as later mutation. These data declarations are
interpreter-only.
Suit
enumDeclare Suit enumerates Hearts, Diamonds, Clubs, Spades.
Write is_red(s) returning true for
Hearts/Diamonds, false otherwise. Then
is_minor(s) returning true for Diamonds and
Clubs (the “minor” suits in some games).
is_red(Hearts) → trueis_red(Spades) → falseis_minor(Diamonds) → trueis_minor(Hearts) → falseDigit subrange and safe_divUse Digit ranges 0..9. Write
safe_div(a :: Digit, b :: Digit) that returns
a / b if b != 0, else 0. Verify
that the annotation rejects out-of-range arguments:
safe_div(8, 2) → 4safe_div(7, 0) → 0safe_div(15, 3) → runtime type error (15 not
in 0..9)Workday and schedule_checkWith Day enumerates Mon, …, Sun and
Workday is Day in Mon..Fri, write
schedule_check(d :: Workday, title) that returns the string
"Scheduled: <title> on <d>".
schedule_check(Tue, "Meeting") →
"Scheduled: Meeting on Tue"schedule_check(Sat, "Meeting") → runtime type
errorHour and is_business_hoursDeclare Hour ranges 0..23. Write
is_business_hours(h :: Hour) returning true
for 9 ≤ h < 17, false otherwise. Verify the type guard:
is_business_hours(10) → trueis_business_hours(20) → falseis_business_hours(-1) → runtime type errorLock state with enum + counterCombine a Lock with
LockState enumerates Locked, Unlocked, Jammed and a numeric
attempts :: Integer counter. Each try_open
call increments attempts; if attempts > 3, the lock
transitions to Jammed. Return the new state.
lock: make_lock()
lock("wrong") # attempts=1, still Locked
lock("wrong") # attempts=2, still Locked
lock("wrong") # attempts=3, still Locked
lock("wrong") # attempts=4, becomes Jammed
(Combines Ch.16’s mutation pattern with this chapter’s enums.)
Grade and HonorsDesign a small academic grading system.
Grade enumerates Fail, Poor, Avg, Good, TopPassingGrade extends Grade in Poor..TopHonorsGrade extends Grade in Good..TopWrite letter_to_gpa(g :: Grade) returning
0.0..4.0. Write is_passing(g :: Grade)
returning Boolean. Write qualifies_for_honors(g :: Grade)
returning Boolean.
Then consider: can qualifies_for_honors be
declared with parameter type HonorsGrade instead of
Grade? What should it be? The right design is one
where you don’t have to ask “is this grade passing?” because the type
proves it.
End of Chapter 19. Chapter 20 introduces multi-valued logic — the next stage of type-as-invariant pedagogy. Boolean logic is just one of many; Axioma supports Kleene three-valued, Łukasiewicz fuzzy, and Belnap four-valued logics as first-class. We’ll see what they’re for.
Solutions to selected exercises: Chapter 19 · Solutions in Appendix C. (Repo file: exercises/solutions/ch19_solutions.md.)
What this chapter is. Classical Boolean logic — true and false, nothing in between — is a clean and useful system, but it’s not the only one. The world contains missing data, partial measurements, probabilities, and outright contradictions, and a logic that pretends those don’t exist has to lie about them. This chapter introduces three of the logics Axioma ships as first-class citizens: Kleene K3 (true / false / unknown), Łukasiewicz L3 + Lₙ (real-valued “shades of true” in [0, 1]), and Belnap B4 (true / false / both / neither — the paraconsistent logic that survives contradictions instead of exploding on them). Their truth values are literally first-class: the forms Axioma prints (
?ᵏ,½ł,⊤⊥ᵇ) are valid source, usable anywhere a value goes — includingmatchpatterns. (Two more logics, intuitionistic Gödel G3 and Priest’s LP, appear in the manual §9 and the non-classical-logic book passes.) The pedagogical bet: “truth” is a design choice, not a physical law.
A standard Boolean test:
lab_result: true
symptom: false
if lab_result and symptom then [
println("Diagnose disease X")
] else [
println("Rule out disease X")
]Now suppose the lab hasn’t run yet. What’s lab_result?
Pretending it’s false is a lie — we don’t know.
Pretending it’s true is also a lie. The correct
answer is “we don’t know,” and Boolean logic has nowhere to put
that.
A second case. Two witnesses give contradictory testimony: witness A says the suspect was at the scene, witness B says the suspect was elsewhere. A Boolean variable can hold one or the other, not both. But the evidence base genuinely contains both claims, and downstream reasoning needs to know that.
A third case. Asking “is this medical test positive?” when the test returns a probability between 0.0 and 1.0. Boolean has to threshold somewhere — usually 0.5 — and discards the nuance.
Three different kinds of “not-quite-Boolean”:
| Kind | What it captures | Logic |
|---|---|---|
| Missing value | “Don’t know yet” | Kleene K3 |
| Partial truth | “Probably / mostly” | Łukasiewicz L3 |
| Contradiction | “Both true and false simultaneously” | Belnap B4 |
Axioma supports all three. The Boolean operators (and,
or, not, implies) auto-dispatch
on the operand type, so you write the same expression and Axioma picks
the right truth table.
Kleene’s three-valued logic adds one new value to Boolean:
unknown. In Axioma, om (or the Greek
letter Ω) is the unknown value:
lab: om # haven't run the test yet
symptom: true
println(lab) # Ω
println(lab and symptom) # Ω (unknown and anything could be either)
println(lab or symptom) # true (true or anything is true)
println(not lab) # Ω (negation of unknown is still unknown)K3 also has typed literals — ⊤ᵏ ⊥ᵏ ?ᵏ
(constructor form kleene("true") etc.) — which run the same
tables but keep the Kleene tag on results (?ᵏ and ⊤ᵏ →
?ᵏ) and, unlike om, branch like the truth
values they are: if ?ᵏ … takes the else-branch
(unknown is not designated), while bare om keeps its SETL
truthiness. ?ᵏ == om is still true.
The K3 truth tables:
a |
b |
a and b |
a or b |
not a |
|---|---|---|---|---|
| true | true | true | true | false |
| true | false | false | true | false |
| true | Ω | Ω | true | false |
| false | false | false | false | true |
| false | Ω | false | Ω | true |
| Ω | Ω | Ω | Ω | Ω |
Read the table by recognizing the pattern: an output is
true or false only when the unknown values
don’t matter. If a or b has at least one
true, the result is true regardless of what
b actually is. If a and b has at least one
false, the result is false regardless.
Otherwise: Ω propagates.
A worked example. Diagnose-or-rule-out from §20.1, but with K3:
lab: om # haven't run
symptom: true
diagnose: lab and symptom
if diagnose == true then [
println("Diagnose disease X")
] else if diagnose == false then [
println("Rule out disease X")
] else [
println("Order more tests — diagnosis still unknown")
]The third branch is the new option K3 gives you. Without it you’d be forced to misclassify the case.
Sometimes the question isn’t “is the lab test back?” but “how confident are we?” Łukasiewicz logic admits values in the continuous interval [0, 1]:
lukasiewicz(0.0) — certainly false (the literal
⊥ł)lukasiewicz(1.0) — certainly true (the literal
⊤ł)lukasiewicz(0.5) — exactly half-true (the literal
½ł)lukasiewicz(0.85) — strongly true (non-canonical values
need the constructor)high_confidence: lukasiewicz(0.85)
medium_confidence: lukasiewicz(0.5)
low_confidence: lukasiewicz(0.15)
println(high_confidence) # ½ł-style display
println(high_confidence and medium_confidence)
println(high_confidence or low_confidence)
println(not medium_confidence)The L3 rules:
| Operation | Formula |
|---|---|
a and b |
min(a, b) |
a or b |
max(a, b) |
not a |
1 - a |
a implies b |
min(1, 1 - a + b) |
So 0.6 and 0.8 = 0.6,
0.6 or 0.8 = 0.8, not 0.6 =
0.4, 0.6 implies 0.8 =
min(1, 0.4 + 0.8) = 1.0.
When does L3 reduce to ordinary Boolean? When you restrict to
0.0 and 1.0 only. Boolean is the “two-point”
subset of L3. Same operators, same conclusions.
Pedagogical caveat. Łukasiewicz is not the
same as probability. lukasiewicz(0.5) does not mean “50%
chance of being true”; it means “the truth value itself is exactly in
the middle.” Probability theory has its own operators (Bayes’ rule,
marginalization, etc.) that don’t match the min/max algebra above. The
two systems answer different questions — don’t mix them up.
The fourth logic adds two new values to the classical pair:
true — known truefalse — known falseboth — known both true and false
(contradiction)neither — known neither (no evidence at
all)witness_a: belnap("true") # A says it's true (the literal ⊤ᵇ)
witness_b: belnap("false") # B says it's false (the literal ⊥ᵇ)
contested: ⊤⊥ᵇ # we record the contradiction — the GLUT
no_evidence: ?ᵇ # we record the absence — the GAP(Priest’s names for the two new values — the glut and the
gap — are first-class too: Belnap.glut /
Belnap.gap, and the digraphs `glut /
`gap type them in plain ASCII. Belnap.values
is the whole domain, [⊤ᵇ, ⊥ᵇ, ⊤⊥ᵇ, ?ᵇ].)
Why does both get its own value? Classical Boolean
can’t hold contradictory testimony as data. If you assign one
variable both true and false, you’ve committed
to one or thrown the other away. Belnap gives you a named value
for “we have evidence both ways” — a fact downstream code can act
on.
The B4 value space:
both T-evidence AND F-evidence
true T-evidence only
false F-evidence only
neither no evidence either way
Axioma’s operators are truth-order — they treat
true as the “largest” value and false as the
“smallest,” with both and neither sitting in
between:
a or b — true is absorbing (T or x = T),
false is identity (F or x = x),
both or both = both, both or neither = both,
neither or neither = neither.a and b — false is absorbing (F and x =
F), true is identity (T and x = x),
true and both = both,
true and neither = neither,
both and neither = false.not a — swap true ↔︎ false;
both and neither are fixed points (negating a
contradiction is still a contradiction; negating “no evidence” still
leaves no evidence).Probe these yourself — Exercise 20.3 includes a truth-table generator that you can adapt to print all sixteen B4 cells.
How does both actually arise in your
code? Four ways:
⊤⊥ᵇ (or belnap("both")). You have external
evidence that both T and F are claimed.b4_join(witness_a, witness_b) (infix ⊕): the
knowledge-order join builds the glut for you when
sources conflict. This is the honest way to merge testimony — see
§20.7.set_truth("parent", "alice", "bob", "both"). The rule
system propagates the both value through any defeasible
derivations (set_truth_combine ⊕-merges instead of
overwriting).both
operand — once both is in your data, the operators
preserve it correctly: ⊤ᵇ and ⊤⊥ᵇ is ⊤⊥ᵇ,
⊤⊥ᵇ or ⊥ᵇ is ⊤⊥ᵇ.Note that belnap("true") or belnap("false") is
true, not both. The
truth-order disjunction “wins” with truth — it structurally
cannot build a glut from classical inputs. Recording a
contradiction is a knowledge-order act: construct
⊤⊥ᵇ explicitly, or let b4_join (⊕) infer it
from the differing testimony.
Branching on B4 values. A typed truth value behaves
like one in control flow: if v then … else … takes the
then-branch exactly when v is designated —
⊤ᵇ or the glut ⊤⊥ᵇ (a glut is “true-enough to
act on”; designated(v) is the same test as a function). The
gap ?ᵇ and ⊥ᵇ take the else-branch. For the
four-way case split, the literals are match patterns:
describe: func(v) [
match v with
| ⊤ᵇ => "known true"
| ⊥ᵇ => "known false"
| ⊤⊥ᵇ => "contested"
| ?ᵇ => "no evidence"
]The paraconsistent payoff. In Boolean logic,
false and true = false, but a single
contradictory assertion (x = true; x = false) makes the
whole system explode — “from a contradiction, anything follows”
(ex falso quodlibet). Belnap is
paraconsistent: contradictions stay local. You
can keep the contradictory fact, reason around it, and the rest of your
conclusions remain valid.
This matters for real data: legal cases, debate transcripts, medical-evidence aggregation, web-scraped knowledge bases — sources contradict each other constantly, and a logic that crashes on the first inconsistency is unusable.
Stage 1 — data definition. Decide what your data is. This shapes the choice of logic:
| Your data has… | Use logic |
|---|---|
| Clean true/false only | Boolean |
| Missing values | Kleene K3 |
| Confidence levels | Łukasiewicz L3 |
| Contradictions to preserve | Belnap B4 |
Stage 2 — signature. Write the type:
# Boolean version
is_diagnosable: func(lab_result, symptom) [...]
# K3 version (with comment noting it)
# is_diagnosable: lab_result :: Kleene, symptom :: Kleene -> KleeneAxioma doesn’t yet enforce these annotations as types, but writing them documents the intent.
Stage 3 — examples. Write at least one example per “interesting” combination. For K3 with two inputs, that’s nine cases (3 × 3). For B4, sixteen (4 × 4). The exercises below show how to do this without writing all sixteen rows each time.
Stage 4 — template. For predicates over MVL inputs, the template is the same as Boolean: combine the inputs with operators. The operators do the dispatch for you. You rarely need a separate function per logic.
Stage 5 — body. Same as Boolean, but mind the new
output values. A condition that’s “true” in Boolean might be
Ω or both in MVL.
Stage 6 — tests. Test every truth value combination
that matters for your function. For K3, test at least one case
with om. For B4, test at least one case each with
both and neither.
Suppose a small trial measures four things per patient:
lab_positive, symptom_present,
family_history, age_above_50. Each can be
true, false, or om (not yet
measured).
A patient enters treatment if:
The lab is positive AND (a symptom is present OR family history is positive) — but only if the patient is over 50.
should_treat: func(lab, symptom, history, over_50) [
lab and (symptom or history) and over_50
]
# Full data
println(should_treat(true, true, false, true)) # true
println(should_treat(false, true, true, true)) # false
# Missing lab
println(should_treat(om, true, false, true)) # Ω
# Missing history is fine if symptom is true
println(should_treat(true, true, om, true)) # true
# Missing history fatal if symptom is false
println(should_treat(true, false, om, true)) # Ω
# Under-50 always rules out regardless of unknowns
println(should_treat(om, om, om, false)) # falseThe function is a one-liner. The K3 operators handle the “unknown”
propagation. Reading the test output is the interesting part:
any case where the answer’s actually decidable returns
true/false, otherwise it correctly says
Ω.
That’s the pedagogical payoff. The same function works for classical Boolean and for K3 — the operators dispatch on type. You don’t write two functions; you write one and feed it different inputs.
Two judges score a debate on a single proposition. We want a single B4 value that records what they said collectively — either consensus, contradiction, or no ruling at all.
The natural or doesn’t help here:
belnap("true") or belnap("false") is
belnap("true") in Axioma (truth-disjunction absorbs to
true). What we want is a consensus combiner that
produces both exactly when the two inputs disagree — and
B4’s knowledge order ships it as b4_join
(infix ⊕, “accept everything every source says”):
judge_a: belnap("true")
judge_b: belnap("false")
combined: b4_join(judge_a, judge_b) # or: judge_a ⊕ judge_b
println(combined) # ⊤⊥ᵇ — conflict surfaces as the glut
verdict_describe: func(v) [
match v with
| ⊤ᵇ => "Consensus yes"
| ⊥ᵇ => "Consensus no"
| ⊤⊥ᵇ => "Judges disagree"
| ?ᵇ => "No judge ruled"
]
println(verdict_describe(combined)) # "Judges disagree"
println(verdict_describe(b4_join(?ᵇ, ⊤ᵇ))) # "Consensus yes" — no-opinion is ⊕-identityUnder the hood, b4_join is a five-case table you could
write yourself (Exercise 20.4 has you do exactly that, then check your
answer against the builtin): equal inputs pass through, ?ᵇ
is the identity, and any genuine T-vs-F conflict — or an existing glut —
yields ⊤⊥ᵇ. Its dual b4_meet (⊗, “consensus”:
keep only what all sources agree on) maps the same conflict to
?ᵇ instead.
The point of B4 is not that Axioma’s or
magically detects contradictions — it’s that B4 gives you a
value space with a named cell for “evidence both ways”
and the pair of combination operators that live on it, which
downstream code can dispatch on (the match above). Compare
to a Boolean approach: you’d have to add a side-channel boolean called
disagreement_flag and remember to check it at every site
that handles the verdict. Belnap folds the flag into the
value.
definitely_passA test has three Kleene-valued inputs: submitted,
passed_grade, attendance_ok. Each is
true, false, or om. Write
definitely_pass(submitted, passed_grade, attendance_ok)
returning a Kleene value that is:
true if all three are
true,false if any of them is
false,om otherwise.Test with these six cases (compute the expected output before running):
println(definitely_pass(true, true, true)) # ?
println(definitely_pass(true, false, true)) # ?
println(definitely_pass(true, om, true)) # ?
println(definitely_pass(om, om, om)) # ?
println(definitely_pass(om, false, om)) # ?
println(definitely_pass(false, om, true)) # ?Hint: this is exactly
submitted and passed_grade and attendance_ok — the K3
operator does the dispatch.
confidence_scoreThree sub-scores in Łukasiewicz [0, 1] capture how confident we are in a candidate’s technical, communication, and teamwork skills:
tech: lukasiewicz(...)comm: lukasiewicz(...)team: lukasiewicz(...)Hire if all three are at least 0.7. Reject if any one is at most 0.3. Otherwise “interview again.”
Write hiring_decision(tech, comm, team) returning the
string "hire", "reject", or
"interview again".
Hint: Łukasiewicz values don’t support
</>= directly — those operators belong
to the numeric algebra, not the L3 truth algebra. But the
algebra itself gives you a clean test. Compute
tech and comm and team (the L3 and is
min). If the meet equals lukasiewicz(0.7)
or higher, you’ve cleared the bar. To express “or higher” in
L3, use the fact that a or b is max(a, b): if
(meet) or lukasiewicz(0.7) == meet, then
meet >= 0.7.
Equivalently, accept the Łukasiewicz inputs but also accept raw
floats as a fallback API: write a helper
at_least(v, threshold) that returns
(v or lukasiewicz(threshold)) == v.
Write k3_truth_table(name, op) where op is
a two-argument function and name is its display name. The
function should print a 3×3 truth table for op:
a b a name b
true true true
true false false
true om om
false true false
false false false
false om false
om true om
om false false
om om om
(That’s the K3 and table — your function should produce
it when called as
k3_truth_table("and", func(a, b) [a and b]).)
belnap_aggregate (consensus combiner)Write a consensus combiner over three Belnap witnesses:
belnap_aggregate: func(w1, w2, w3) [...]Each wi is a Belnap value (belnap("true"),
belnap("false"), belnap("both"), or
belnap("neither")). The result should satisfy:
true and one says false (or
vice-versa), return both — the witnesses contradict each
other.neither (no opinion) and the rest agree on
one truth value, return that truth value (no-opinion witnesses don’t
change the consensus).both, return both
(existing contradiction propagates).Then write verdict_describe(v) that prints:
"Consensus yes" if the result is
belnap("true")"Consensus no" if the result is
belnap("false")"Disagreement" if the result is
belnap("both")"No evidence" if the result is
belnap("neither")Hint: build it from a two-argument helper
combine_two(a, b) that handles the four cases above; then
belnap_aggregate(w1, w2, w3) = combine_two(combine_two(w1, w2), w3).
Check your answer against the builtin: your combine_two
should agree with b4_join on all sixteen input pairs, and
your three-witness aggregate with the variadic
b4_join(w1, w2, w3). Write the comparison loop over the
domain rather than sixteen hand-written cases (bind it first —
dom: Belnap.values — since foreach wants a
simple iterable expression):
dom: Belnap.values
foreach a in dom [
foreach b in dom [
expect("agrees with ⊕", combine_two(a, b), b4_join(a, b))
]
]meet_all and join_allWrite two functions:
meet_all(values) — given a non-empty array of
Łukasiewicz values, return the meet (the L3 and
reduced across the array — that is, the minimum truth value).join_all(values) — same but using join (the L3
or — the maximum).Examples:
v: [lukasiewicz(0.5), lukasiewicz(0.8), lukasiewicz(0.2)]
println(meet_all(v)) # 0.2ł
println(join_all(v)) # 0.8łHint: this is reduce from Chapter 10, with the L3
operator as the combiner. The L3 and is the meet;
the L3 or is the join. You don’t need to extract
any floats — the operators do everything for you.
You’re aggregating diagnostic input from four sources for a suspected disease:
lab_result: Kleene (true / false / unknown)symptom_severity: Łukasiewicz [0, 1]expert_a_opinion: Belnapexpert_b_opinion: BelnapWrite
combined_diagnosis(lab, severity, opinion_a, opinion_b)
that returns a string describing the overall picture:
"Treat" — lab true, severity ≥ 0.7, experts agree on
true"Don't treat" — lab false, severity ≤ 0.3, experts
agree on false"Expert disagreement" — expert join is
both"Need more data" — lab is om or severity
unclear"Order tests" — neither expert ruledYou’ll have to mix the three logics. Decide for yourself which combinations the function should resolve and which it should pass through. Document your design choice as a comment above the function.
Three reasons, in increasing order of importance.
One. Real-world data is missing all the time. Survey
responses skipped, sensors offline, lab tests not yet run. A language
that pretends those don’t exist forces students into two equally-bad
habits — fake values (sentinel constants like -1) or
scattered null-checks. K3 gives them the right tool: a logical value
that means exactly “I don’t know.”
Two. Real-world data is sometimes uncertain. Confidence scores, model outputs, expert opinions on a sliding scale. Łukasiewicz gives an algebra for combining them without collapsing back to threshold-Boolean prematurely.
Three. Real-world data is sometimes contradictory. Witnesses disagree; sources conflict; legacy systems and new systems hold incompatible facts. Belnap is the only classical-extension logic that doesn’t melt down when this happens. Students who learn it first — before being trained to assume cleanly-Boolean data — write more robust code their whole career.
The pedagogical bet stands: truth is a design choice. Choose your logic, and the operators come along for free.
The next chapter steps up one level, from the value of a fact to the inference of facts from rules — Prolog-style logic programming.
“The truth is rarely pure and never simple.” — Oscar Wilde. He was right, and now Axioma agrees.
Solutions to selected exercises: Chapter 20 · Solutions in Appendix C. (Repo file: exercises/solutions/ch20_solutions.md.)
What this chapter is. A logic program is a collection of facts and rules, with answers extracted via queries. Instead of writing how to compute, you write what is true, and the system figures out the rest. The tradition runs from Prolog (1972) through Datalog and modern logic engines like ErgoAI; Axioma’s logic subsystem inherits from all three. The pedagogical bet: declarative knowledge deserves a different programming style than computation. You won’t replace functions with rules — you’ll add rules to your toolkit for the cases where they’re a better fit.
A genealogy program. You want to know “who are John’s grandchildren?” In functions, you write:
children_of: func(p) [...] # returns array
grandchildren_of: func(p) [
kids: children_of(p)
nested: map(children_of, kids)
flatten(nested)
]You write children_of once. Then
grandchildren_of. Then great_grandchildren_of.
Then siblings_of. Then cousins_of. Each one is
a separate function, each one has to re-walk the family
tree, each one needs careful handling of duplicates, missing data,
and infinite loops.
The relational view is different:
relation parent(child, parent)
# or the short reserved alias: rel parent(child, parent)
parent("Mary", "John")
parent("Tom", "John")
parent("Alice", "Mary")
parent("Bob", "Mary")Five facts. Nothing computational. Then you query:
# Who are John's children?
{C | parent(C, "John")} # {"Mary", "Tom"}
# Who are John's grandchildren?
{G | parent(G, P) and parent(P, "John")} # {"Alice", "Bob"}
# Who are siblings?
{(A, B) | parent(A, X), parent(B, X), A != B}The structure of the data drove the queries. You never wrote
grandchildren_of — the query expression said what you
wanted, and the engine figured out how. That’s the relational
style.
relation / rel declarationBefore you can store facts, declare the relation
(relation and rel are the same reserved word —
two spellings):
relation parent(child, parent)
rel age(person, years) # short form — same as `relation age(...)`
relation friend(a, b)That’s like declaring a database schema. After the declaration, the
relation’s name (like parent) becomes a predicate
you can populate with facts:
parent("Mary", "John")
parent("Tom", "John")
parent("Alice", "Mary")
parent("Bob", "Mary")Each line is not a function call — it’s an assertion. The fact is stored in the knowledge base, indexed for query.
Each fact has a grounding: by default, raw
parent("Mary", "John") is a datum (no
derivation, no justification). You can promote facts to
axioms (most-trusted) or write them as
postulates (tentative claims) using refinement syntax —
but for CS1 the default works fine.
You met the comprehension surface in §10.9 — array
comprehensions, set comprehensions, filters, walrus bindings, dict
comprehensions, lazy generators. The same notation works for
logic-programming queries; the only thing that changes is what sits on
the right of <-. There, instead of an array or a set,
you write a relational call like
parent(X, Y), and the comprehension becomes a query against
the fact store.
The basic shape:
{output_pattern | V <- relation(args), V <- relation(args), ...}The body is a comma-separated list of generators,
each of the form V <- relation(args), where
V is a fresh variable (capitalized
identifier) and relation(args) is a query pattern with
literals, underscores, or other variables in its argument positions.
# All parents of Mary
{P | P <- parent("Mary", P)} # {"John"}
# All known parents (any direction)
{P | P <- parent(_, P)} # underscore = "don't care"
# Pair-flat output (no <- generator needed for full-tuple)
{(C, P) | parent(C, P)} # all pairs
# Multi-generator (variable shared across generators)
{G | P <- parent("Alice", P), G <- parent(P, G)} # Alice's grandparentsThe underscore _ in an argument position means “any
value matches, don’t bind.” It’s the difference between “I want to
use the parent’s name later” (P) and “I just want
to know that a parent exists” (_).
For more complex predicates that combine relations or apply arithmetic filters, the recommended idiom is define a rule and then query the derived relation. We’ll see that pattern in §21.5.
In the query body, any identifier starting with a capital letter acts as a logic variable — a name that the engine fills in to make facts match. Lower-case identifiers (and quoted strings) are constants.
A single capital letter (X, Y,
Z, P) is the Prolog-traditional way.
Multi-letter capitalized names (Child, Parent)
work too:
{(Child, Parent) | parent(Child, Parent)} # same as {(C, P) | ...}Note: the single-letter quantifier shorthands
A (∀) and E (∃) were retired in June
2026, so A and E are now ordinary logic
variables — use any capital you like (A, E,
X, Y, Z, …). Quantify with the
words forall / exists or the glyphs
∀ / ∃.
When a variable appears in multiple positions of a query, the engine unifies the bindings — that is, both positions must end up with the same value:
# A grandparent: someone who is parent of a parent
{G | parent(P, G) and parent(C, P)}
# Here P appears twice — both occurrences must match the same person.That’s the heart of logic programming: multi-position unification is what lets relational queries do work that imperative code would need explicit nested loops for.
Sometimes the same query pattern shows up over and over. You can name it as a rule:
relation grandparent(child, gp)
# Whenever there's a parent-of-parent chain, infer grandparent
grandparent(C, G) whenever parent(C, P) and parent(P, G)The rule connective is the word whenever — the code now
says exactly what the comment says, and that word choice is doing
logical work: English “whenever” means “in every case where,”
which is precisely what a rule head claims. This is the primary
spelling. Also first-class: head if body (same clause;
gated on an uppercase logic-var argument because if doubles
as the postfix conditional), and the operator twins <==
(legacy <=) and Prolog’s :- in arrow or
Prolog dress. Once the rule is in scope, queries can use
grandparent like any other relation:
# Who are Alice's grandparents?
{G | grandparent("Alice", G)} # {"John"}
# Whose grandparent is John?
{C | grandparent(C, "John")} # {"Alice", "Bob"}The engine evaluates the rule body lazily — at query time, it walks the parent facts, finds matches, and produces the answers. You wrote no computation. You wrote a truth condition, and the engine inferred the answers.
Rules can also chain through themselves. The classical Prolog/Datalog idiom of two clauses for one relation — a base case plus a recursive case — works exactly as in Datalog: clauses for the same head accumulate, and the engine iterates them to a fixpoint. Transitive closure is the canonical example (§21.9 runs one on real data):
relation reachable(a, b)
reachable(X, Y) whenever edge(X, Y)
reachable(X, Y) whenever edge(X, Z) and reachable(Z, Y)For the single-step case, one rule is enough:
relation grandparent(child, gp)
grandparent(X, G) whenever parent(X, P) and parent(P, G)A common gotcha — X != Y in rule
bodies. Equality and inequality between rule variables
aren’t enforced by the rule engine the way they would be in classical
Prolog. Writing
sibling(X, Y) <= parent(X, P) and parent(Y, P) and X != Y
produces all pairs including the self-pairs
(X, X). The clean idiom is rule then
post-filter:
relation sibling_raw(x, y)
sibling_raw(X, Y) whenever parent(X, P) and parent(Y, P)
# Post-filter: drop the self-pairs
all_sibs: {p | p <- {(X, Y) | sibling_raw(X, Y)}, p[1] != p[2]}That’s the convention this chapter will follow for any “inequality-on-variables” filter.
Some inference is strict — “always follows”: plain
head whenever body (also head if body;
operator twin <= / <==). Other inference
is typical — “usually follows, but with exceptions” —
and you write it with exactly that word:
typically head whenever body (synonym
normally; marker spelling rule~ head if body,
read the tilde as “roughly”; operator twin <~~).
relation bird(x)
relation flies(x)
relation penguin(x)
bird("robin")
bird("tweety")
bird("opus")
penguin("opus")
# Defeasible rule: birds typically fly — written as said
typically flies(X) whenever bird(X)
# Query — who flies?
{X | flies(X)} # {"opus", "robin", "tweety"} — all three derived defeasiblyThe rule typically flies(X) whenever bird(X) (≡
rule~ flies(X) if bird(X) ≡
flies(X) <~~ bird(X)) produces
conjectures (weaker than theorems). To handle the
exception — penguins don’t fly — we explicitly cancel
the conclusion:
cancel("flies", "opus")
{X | flies(X)} # without filter — still includes opus
{X @conjecture | flies(X)} # only un-canceled conjecturesThe @conjecture tag is a filter — only
facts at that grounding level pass through. The default
{X | flies(X)} includes canceled facts (with their
cancellation noted), so you can audit which defeasible rules
were overridden.
Compare this to two alternatives:
is_flyer — every species
needs a separate Boolean stored fact. The “default flying” pattern is
implicit in your data.can_fly(species) with hardcoded
cases — the code structure encodes the inference, not the
data.The defeasible rule says the rule is the default, the cancellations are the exceptions, and that’s exactly what the cognitive model looks like for most natural-world knowledge.
The rules above all use <= (backward chaining) —
Axioma starts from the query and works backward through rule
bodies to find supporting facts.
There’s a dual operator, ==> (forward chaining),
which says “whenever this body matches, derive the head”:
# Forward: every parent-of-parent chain forces a grandparent fact
parent(X, Y) and parent(Y, Z) ==> grandparent(X, Z)Same semantics, opposite direction. Forward chaining is what production rule systems (CLIPS, Drools) do — every time the rule body matches a fact pattern, the head fact gets added to the knowledge base.
The defeasible forward variant ~~> works
analogously:
bird(X) ~~> flies(X)For Axioma’s purposes, the four operators are interchangeable for simple queries — the engine picks whichever evaluation strategy is cheaper. The difference matters for side effects (forward chaining can trigger automatic notifications) and for performance (forward is better for many-fact, few-query workloads; backward for the opposite).
For CS1, use head whenever body for “rule that says when
something is true” and typically head whenever body for
“rule with exceptions.”
The recipe takes a different shape here.
Stage 1 — data declaration. Decide what relations exist. That’s the schema. Each relation has a name and an arity (number of arguments). Capitalize positions you’ll often query into (it makes the query patterns readable).
Stage 2 — fact base. Write the ground facts. These are the data of the logic program — the leaves of the inference tree. Comments explaining the data are encouraged (“These are the actual measured parent-child relationships in the 1850 census…”).
Stage 3 — rules. Add rules for derived
relationships. For each rule, ask: is it always true
(plain whenever) or usually true
(typically … whenever)? Write the strict version first;
only defeasible if you know there are real exceptions to handle.
Stage 4 — queries. Express the question you want answered as a set comprehension. Variables for what you want, ground values for what you know, underscores for what doesn’t matter.
Stage 5 — verification. Print the result; verify by hand-walking the facts. If the answer is wrong, the bug is either in the facts (data error), the rule (logic error), or the query (asking the wrong question). Walking through each in turn isolates the bug.
A small university catalog:
relation course(code, title)
relation prereq(course, requires)
course("CS101", "Intro to Programming")
course("CS201", "Data Structures")
course("CS301", "Algorithms")
course("CS401", "Compilers")
course("MATH101", "Calculus I")
course("MATH201", "Discrete Math")
prereq("CS201", "CS101")
prereq("CS201", "MATH101")
prereq("CS301", "CS201")
prereq("CS301", "MATH201")
prereq("CS401", "CS301")
prereq("MATH201", "MATH101")Direct queries:
# What are the direct prereqs of CS401?
{R | prereq("CS401", R)} # {"CS301"}
# What courses require CS101 directly?
{C | prereq(C, "CS101")} # {"CS201"}
# All prereq pairs
{(C, R) | prereq(C, R)}Transitive query — what does CS401 eventually require?
The classic Prolog/Datalog idiom — a base clause plus a recursive clause — works verbatim: clauses for the same head accumulate, and the engine iterates them to a fixpoint:
relation eventually_requires(course, requires)
eventually_requires(C, R) whenever prereq(C, R)
eventually_requires(C, R) whenever prereq(C, P) and eventually_requires(P, R)
{R | R <- eventually_requires("CS401", R)}
# {"CS101", "CS201", "CS301", "MATH101", "MATH201"}When you’d rather see each depth on its own — while debugging, or when the maximum depth is known and small — explicit multi-hop comprehension chains remain a fine alternative:
hop1: {R | R <- prereq("CS401", R)}
hop2: {R | P <- prereq("CS401", P), R <- prereq(P, R)}
hop3: {R | P <- prereq("CS401", P), Q <- prereq(P, Q), R <- prereq(Q, R)}
all_eventual: hop1 ∪ hop2 ∪ hop3
println(all_eventual)
# {"CS301", "CS201", "CS101", "MATH101", "MATH201"}Each hop is a self-contained query you can inspect on its own; the
union (∪) combines the depth-1, depth-2, and depth-3
results.
Defeasible query — what’s the typical term to take CS401?
relation nominal(course, term) # catalog data: each course's nominal term
nominal("CS101", 1)
nominal("CS201", 2)
nominal("CS301", 3)
nominal("CS401", 4)
relation typical_term(course, term)
# Courses are typically taken in their nominal term
typically typical_term(C, T) whenever nominal(C, T)
# But students with credit for CS101 from high school skip ahead
cancel("typical_term", "CS101", 1)
typical_term("CS101", 0) # they got credit before collegeThe defeasible rule gives the baseline. Cancellation + explicit fact handles the exception.
Three pedagogical claims.
One — relational thinking transfers. Once students grasp relations + queries + rules, they have the conceptual model for SQL, for graph databases (Neo4j), for knowledge graphs (RDF / SPARQL), for inference engines (CLIPS, Drools), and for modern AI systems’ fact stores. The vocabulary is common; only the syntax varies.
Two — declarative style is debuggable in a different
way. A buggy function fails during computation. A
buggy logic program produces wrong query results. The debugging
tools differ: instead of print statements through a call
stack, you ask “which facts contradict?”, “which rule fired
unexpectedly?”, “what proof leads to this conclusion?” Axioma’s
why and proof operators are the debugging
analog:
why grandparent("Alice", "John")
# Explanation for: grandparent(Alice, John)
# This is a theorem derived by strict inference from:
# - parent("Alice", "Mary") [datum]
# - parent("Mary", "John") [datum]The reasoning is legible. You can read why a fact was derived, fact by fact.
Three — defeasibility is the right model for real knowledge. Most things we know about the world are typical, not absolute. Birds fly (mostly). Mammals give birth (mostly). Coffee shops are open in the morning (mostly). A programming language that forces every assertion to be either absolute or not-asserted-at-all loses an enormous chunk of how humans actually reason. Defeasible rules + cancellation capture exactly the right shape.
#language axioma/knowledge-core fenceChapter 21 so far is host logic programming:
relation, bare facts, whenever, defeasible
typically, retract, comprehensions, and
ordinary func sitting next to the rules. That is
axioma/all (this book’s default once you leave
beginner).
#language axioma/knowledge-core is a monotonic
proof-core. It is a language, not a lens. The file may contain
concepts, entities, frame slots, assert/axiom, strict Horn
rules, and derive/prove/query. It
may not contain general computation. The point is the
same honesty move as Chapter 54: if a skip would be the wrong answer,
shrink the language until the bad program cannot run.
A family-tree kernel that is in the core — notice there is
no relation line. The schema is the asserted atoms plus the
rule:
#language axioma/knowledge-core
assert/axiom parent("Mary", "John")
assert/axiom parent("Alice", "Mary")
grandparent(G, A) whenever parent(G, P) and parent(P, A)
thesis: derive/result grandparent("Alice", "John")
println(thesis.status) # derived
println(thesis.grounding) # theoremwhenever is still the chapter’s primary rule spelling.
derive/result returns a structured answer
(status, grounding,
justification) instead of a silent “maybe it fired.”
What the fence refuses, with the diagnostic you will actually see:
| You write | The core says |
|---|---|
func(x) [x] |
function literal is not available — facts and rules,
not computation |
1 + 2 |
operator + is not available — use relation calls,
is, frames, and |
retract parent("Mary", "John") |
retract is not available — retraction is
non-monotonic |
typically flies(X) whenever bird(X) |
defeasible rule is not available — completeness is
targeted at the strict Horn fragment; use <== /
whenever |
a while loop |
loop is not available |
relation parent(...) is a host schema form. The core
does not take it: write assert/axiom parent(...) and let
the atom be the schema. (The current diagnostic names an internal node
type — treat it as “this declaration is not in the core,” and use
assert/axiom.)
Two nearby names, three jobs:
| Name | What it actually does |
|---|---|
axioma/all |
this chapter: relation, defeasible rules,
retract, func |
axioma/knowledge |
mild — some helper names are refused
(insert → write assert/axiom);
retract still runs |
axioma/knowledge-core |
AST allowlist — the proof-core in this section |
axioma/knowledge is not a silent alias of the core. If
you need the fence, write knowledge-core.
Stay on axioma/all for the rest of this chapter’s
exercises (they use relation and, in 21.4, defeasible
rules). Step onto the core when the point of the file is that
it is a Horn theory.
A recursive relation query can exceed an implementation limit before
it finishes. Returning an empty set in that situation would falsely
report that no answer exists. Axioma instead returns
IncompleteReasoningError, which is also an
Error.
relation reached(n)
assert reached(0)
reached(N) whenever reached(M) and N == M + 1 and N <= 100
query_result: try({N | N <- reached(N)})
println(query_result is IncompleteReasoningError) # true on this revision
println(query_result is Error) # trueThis deliberately exceeds the current eager derivation-pass budget on its first query. It demonstrates a resource boundary, not an infinite mathematical set. Some positive facts may remain cached; a later query can make further progress. Negation must not interpret an incomplete positive search as proof of absence. The exact budgets are implementation details: handle the error or redesign the query rather than relying on a specific limit as part of your algorithm.
The same distinction matters in applications: no answer found after a completed search, search incomplete, and claim disproved are three different outcomes. A checked theorem has a further trust boundary; Appendix E explains why its certified conclusion cannot be changed by mutating an ordinary input record.
Declare the relations parent(child, parent) and
married(a, b). Populate with at least six parent facts and
three married facts that describe a family of your choosing (real or
fictional). Then write four queries:
{C | C <- parent(C, "name")}).(X, Y) tuples
using {(X, Y) | married(X, Y)}.sibling_raw(x, y) rule that holds
when x and y share a parent. Add the rule with
<=, then query
{p | p <- {(X, Y) | sibling_raw(X, Y)}, p[1] != p[2]} to
get just the proper-sibling pairs.Using your family-tree base from 21.1, define the
grandparent(grandchild, gp) relation as a strict rule. Then
query:
Use the catalog from §21.9. Then write queries for:
hop1,
hop2, hop3 as in §21.9 and union them with
∪.{C | C <- course(C, _)}, then filter out any
C that appears in the left position of prereq.
Use the comprehension:
{c | c <- all_courses, not (c in has_prereq)}.Declare:
relation bird(x)
relation penguin(x)
relation ostrich(x)
relation flies(x)Add facts for at least five birds, one penguin, and one ostrich.
Write the defeasible rule “birds fly.” Then cancel the
conclusions for the penguin and the ostrich. Query the remaining flyers
using the @conjecture tag and verify the right ones
survive.
Declare friend(a, b) and add at least eight friendship
facts (use names like “alice”, “bob”, “carol”, etc.). Friendships are
symmetric — if you write friend("alice", "bob"),
also write friend("bob", "alice") (Axioma doesn’t
infer symmetry automatically).
Then write a query for friend-of-friend
recommendations: all X such that some person
P is friends with both “alice” and X, where
X is not already alice’s friend and
X != "alice". Print the list of recommended new friends for
“alice.”
Build a small genealogical knowledge base for a fictional family of
your choosing — at least twelve people, at least three
generations. Include relations for parent,
married, gender, birth_year.
Then implement (as rules + queries) at least four derived relationships of your choice. Suggestions:
sibling(X, Y) — same parentaunt_or_uncle(A, N) — sibling of a parentcousin(X, Y) — children of siblingsdescendant_of(X, A) — recursive parent chainancestor_of(X, A) — the inversestep_parent(X, Sp) — married to one’s parent without
being a biological parentWrite at least one query for each derived relation and verify by hand against the fact base.
Rewrite the grandparent kernel from §21.5 as
#language axioma/knowledge-core: no relation
line, assert/axiom for the two parent facts, a
whenever rule, derive/result for
grandparent("Alice", "John"). Print status and
grounding. Then, in a second file (or a commented
block you do not run), try retract or
typically and record the diagnostic.
Three signs the relational style is the right fit:
Sign one — your data is “things in relationships.” Genealogies, social networks, organization charts, knowledge graphs, dependency chains — anything where the structure between the items matters more than the items themselves.
Sign two — you find yourself writing many similar functions. Five different “find all X” functions that all walk the same graph differently are a hint that you’ve implemented a relational query as imperative code. A logic program lets the structure be the program.
Sign three — exceptions matter. If you’re constantly catching edge cases (“but not the penguins…”), defeasible rules give you a clean separation: the rule is the default, the cancellations are the data exceptions, and both are explicit.
Three signs to stay with functions:
A real program uses both. Functions for computations, relations for relationships. The two compose well — a function can call a query, a query can use a function as a filter condition. Axioma supports the full mix.
The next chapter steps up one final level — implementing a small Axioma interpreter in Axioma itself, the meta-circular evaluator.
“In logic programming, the program is the specification and the proof is the execution.” — Robert Kowalski, 1985.
Solutions to selected exercises: Chapter 21 · Solutions in Appendix C. (Repo file: exercises/solutions/ch21_solutions.md.)
What this chapter is. A meta-circular evaluator is an interpreter for a language, written in that same language. We’re going to build a tiny Axioma interpreter in Axioma itself. The tradition runs from Lisp’s
apply(1960) through SICP’s evaluator (1985) through every modern language that exposes its parser as a library. Axioma exposesparse,ast_eval,headof,argsof,fullform, andquote— everything needed to read code, inspect it as data, and evaluate it. The pedagogical bet: once you’ve written an interpreter for a language, you understand that language at a level no other exercise can teach.
This is the capstone chapter. It ties together Chapter 8 (trees as Concepts), Chapter 11 (lambdas as values), Chapter 12 (local environments), and the design recipe writ large.
When you write 2 + 3 in Axioma, two things are
happening:
For most of this book you’ve only seen step (2) — you write code, it runs. This chapter pulls back the curtain on step (1). Axioma exposes the AST as a first-class value:
ast1: parse("2 + 3")
println(ast1) # <AST: (2 + 3)>
println(ast_type(ast1)) # InfixExpression
println(headof(ast1)) # +
println(argsof(ast1)) # ["2", "3"]parse converts a string of code into an AST.
ast_type tells you what kind of node.
headof gives the operator/function. argsof
gives the operands.
You can also capture an AST without parsing — using
quote:
ast2: quote(2 + 3) # same thing, no string
println(fullform(ast2)) # +(2, 3)fullform renders the AST in Mathematica-style prefix
form, which makes the structure obvious: an
InfixExpression is really a function call
+(2, 3).
And you can run an AST:
println(ast_eval(parse("2 + 3"))) # 5That round-trip — parse a string into an AST, then evaluate the AST — is the heart of every interpreter ever written. You’ve just done it in two lines of Axioma.
We’ll build an evaluator for a subset of Axioma:
42,
3.14x, y+, -,
*, /if cond then a else blambda x => x * 2, f(3)That’s enough to express factorials, list-walks (in a recursive language with pairs), and most of what we’ve done in the first half of this book.
The full Axioma evaluator handles concepts, comprehensions, relations, mutation, MVL, and a hundred other things. Our subset is the lambda-calculus + arithmetic core. Real Axioma is built on the same skeleton, with more cases in the dispatch.
We’ll use Chapter 8’s idiom — model AST nodes as Concepts:
concept NumNode
NumNode has value: 0
concept VarNode
VarNode has name: ""
concept OpNode
OpNode has op: ""
OpNode has left: 0
OpNode has right: 0
concept IfNode
IfNode has condition: 0
IfNode has then_branch: 0
IfNode has else_branch: 0
concept LambdaNode
LambdaNode has param: ""
LambdaNode has body: 0
concept AppNode
AppNode has callee: 0
AppNode has arg: 0Six concepts. Each one captures one kind of AST node. An
arithmetic expression (2 + 3) * 4 is a tree:
OpNode("*")
/ \
OpNode("+") NumNode(4)
/ \
NumNode(2) NumNode(3)
In Axioma:
n2: a NumNode { value: 2 }
n3: a NumNode { value: 3 }
n4: a NumNode { value: 4 }
add: an OpNode { op: "+", left: n2, right: n3 }
mul: an OpNode { op: "*", left: add, right: n4 }That AST corresponds to (2 + 3) * 4. Reading the tree
from the root tells you the structure: a multiplication of an addition
with a number.
The simplest evaluator: just numeric expressions, no variables, no functions.
eval_expr: func(node) [
if node is NumNode then node.value
else if node is OpNode then [
l: eval_expr(node.left)
r: eval_expr(node.right)
if node.op == "+" then l + r
else if node.op == "-" then l - r
else if node.op == "*" then l * r
else if node.op == "/" then l / r
else 0
]
else 0
]
println(eval_expr(mul)) # (2 + 3) * 4 = 20Read the dispatch carefully:
NumNode, return its value.OpNode, recursively
evaluate both children, then apply the operator.That’s the structural recursion pattern from Chapter 7 applied to a tree. Each Concept type gets one case; the recursion mirrors the data shape.
Now add variables. A variable looks up its value in an environment — a list of name/value pairs.
# Environment: an array of (name, value) tuples
empty_env: []
# Look up: walk the list, return value of first match
env_lookup: func(ev, nm) [
if len(ev) == 0 then 0
else if ev[1][1] == nm then ev[1][2]
else env_lookup(ev[2..len(ev)], nm)
]
# Extend: cons a new binding onto the front
env_extend: func(ev, nm, item_value) [
[(nm, item_value)] + ev
]
# Build an env: x = 10, y = 20
e0: empty_env
e1: env_extend(e0, "x", 10)
e2: env_extend(e1, "y", 20)
println(env_lookup(e2, "x")) # 10
println(env_lookup(e2, "y")) # 20
println(env_lookup(e2, "z")) # 0 (default — not found)The evaluator threads the environment through every recursive call:
eval_expr: func(node, ev) [
if node is NumNode then node.value
else if node is VarNode then env_lookup(ev, node.name)
else if node is OpNode then [
l: eval_expr(node.left, ev)
r: eval_expr(node.right, ev)
if node.op == "+" then l + r
else if node.op == "-" then l - r
else if node.op == "*" then l * r
else if node.op == "/" then l / r
else 0
]
else 0
]
# x + 5, with env { x → 10 }
nx: a VarNode { name: "x" }
n5: a NumNode { value: 5 }
expr: an OpNode { op: "+", left: nx, right: n5 }
println(eval_expr(expr, e2)) # 10 + 5 = 15The variable x gets looked up at evaluation
time, in the environment passed by the caller. That’s how scoping works
under the hood — every function in a real evaluator threads an
environment through every recursive call.
if cond then a else b evaluates cond first,
then only one of a or b based on the
result:
eval_expr: func(node, ev) [
if node is NumNode then node.value
else if node is VarNode then env_lookup(ev, node.name)
else if node is OpNode then [
l: eval_expr(node.left, ev)
r: eval_expr(node.right, ev)
if node.op == "+" then l + r
else if node.op == "-" then l - r
else if node.op == "*" then l * r
else if node.op == "/" then l / r
else if node.op == "<" then if l < r then 1 else 0
else if node.op == "==" then if l == r then 1 else 0
else 0
]
else if node is IfNode then [
c: eval_expr(node.condition, ev)
if c != 0 then eval_expr(node.then_branch, ev)
else eval_expr(node.else_branch, ev)
]
else 0
]Notice we don’t evaluate both branches up front —
the order matters. If we did
a: eval_expr(node.then_branch, ev); b: eval_expr(node.else_branch, ev)
and then chose between them, we’d evaluate dead code. In a pure subset
that doesn’t matter; in a real language with side effects, it absolutely
does.
We also added < and == as ops, using
0 and 1 as stand-ins for booleans (this is how
C represents booleans; classical Lisp evaluators do the same).
Test:
# if (x < 10) then 100 else 200, with x = 5
cmp: an OpNode { op: "<", left: a VarNode { name: "x" }, right: a NumNode { value: 10 } }
then_b: a NumNode { value: 100 }
else_b: a NumNode { value: 200 }
ifn: an IfNode { condition: cmp, then_branch: then_b, else_branch: else_b }
println(eval_expr(ifn, e1)) # x=10, so x<10 is false → 200A lambda is a function value. In an interpreter, it’s represented as a closure — the lambda’s body AST plus the environment that was active when the lambda was created:
concept ClosureValue
ClosureValue has param: ""
ClosureValue has body: 0
ClosureValue has captured_env: 0
eval_expr: func(node, ev) [
if node is NumNode then node.value
else if node is VarNode then env_lookup(ev, node.name)
else if node is OpNode then [
l: eval_expr(node.left, ev)
r: eval_expr(node.right, ev)
if node.op == "+" then l + r
else if node.op == "-" then l - r
else if node.op == "*" then l * r
else if node.op == "/" then l / r
else 0
]
else if node is IfNode then [
c: eval_expr(node.condition, ev)
if c != 0 then eval_expr(node.then_branch, ev)
else eval_expr(node.else_branch, ev)
]
else if node is LambdaNode then
a ClosureValue { param: node.param, body: node.body, captured_env: ev }
else if node is AppNode then [
fval: eval_expr(node.callee, ev)
aval: eval_expr(node.arg, ev)
if fval is ClosureValue then [
new_env: env_extend(fval.captured_env, fval.param, aval)
eval_expr(fval.body, new_env)
] else 0
]
else 0
]Two new cases:
LambdaNode — when we evaluate a
lambda, we don’t run its body. We package up
the body, the param name, and the current environment into a
ClosureValue.AppNode — when we evaluate a function
call (fn arg), we evaluate fn first
(yielding a closure), then the arg, then extend the closure’s
captured env with the param bound to the arg value, and run the
body in that extended env.The line “extend the closure’s captured env” is where lexical scoping comes from. A lambda remembers the env it was defined in, not the env in which it’s called. That’s the secret behind closures.
Test — the classic “make adder” pattern:
# (lambda x. (lambda y. x + y)) 3 → closure that adds 3 to its arg
# Apply that to 4 → 3 + 4 = 7
# Build inner: x + y (where x and y are variables looked up at runtime)
body_inner: an OpNode {
op: "+",
left: a VarNode { name: "x" },
right: a VarNode { name: "y" }
}
# Outer lambda: lambda x. (lambda y. x + y)
lam_inner: a LambdaNode { param: "y", body: body_inner }
lam_outer: a LambdaNode { param: "x", body: lam_inner }
# Apply outer to 3: yields closure capturing x = 3
app1: an AppNode { callee: lam_outer, arg: a NumNode { value: 3 } }
# Apply that closure to 4: yields 7
app2: an AppNode { callee: app1, arg: a NumNode { value: 4 } }
println(eval_expr(app2, empty_env)) # 7That’s a curried make_adder in our tiny interpreter. The
inner lambda captures x = 3 at the moment the outer lambda
is applied; when we then apply the inner lambda to 4, it sees both
x (from its captured env) and y (just bound to
4) — and computes 7.
The Design Recipe scales:
Stage 1 — data definition. The AST: one Concept per node type. This is the grammar of the language you’re interpreting.
Stage 2 — signature.
eval_expr: func(node, ev) -> Value — takes an AST node +
environment, returns a value.
Stage 3 — examples. For each AST node type, an example: a small expression whose value you know, with the environment if needed.
Stage 4 — template. Match the data: one
else if node is NodeType per Concept. Same shape as Chapter
8’s tree walks, but with more branches.
Stage 5 — body. For each case, do the work:
If, Lambda,
App) → custom dispatch.Stage 6 — tests. A test per node type plus a few compound tests that exercise multiple node types together.
The interpreter is just a function over a tree-shaped data type. Same recipe. Bigger data definition.
A macro receives argument syntax and returns an AST. Its expansion happens during evaluator preparation, before ordinary statements run; it is not ordinary function application and not a parser rewrite.
macro unless(test, body) quasiquote(
if not unquote(test) then unquote(body) else none
)
unless(false, println("runs")) # runs
unless(true, println("does not run")) # no output
println(macroexpand(unless(false, 7)) is AST) # truequasiquote builds a syntax template;
unquote inserts argument syntax. The macro body must return
an AST. Wrapping a macro body in an ordinary array literal changes its
result type and is not an interchangeable function-body spelling. By
contrast, a block inside quasiquote is part of the
code being generated.
A macro should not overwrite an unrelated variable in the caller. Axioma renames template-introduced bindings for each expansion.
macro twice(expression) quasiquote([temporary: unquote(expression); temporary + temporary])
temporary: 100
println(twice(5)) # 10
println(temporary) # 100
println(twice(twice(3))) # 12Here the input expression is evaluated once, then its value is reused. Spliced argument names retain the caller’s meaning. Free names written in the template resolve in the macro’s definition context. This makes three different origins visible: caller argument, definition helper, and expansion-local temporary.
macroexpand recursively exposes the prepared code,
including fresh names. A freshly parsed AST has not yet undergone that
preparation. Do not expect a macro body to read an earlier ordinary
runtime binding from the same file: expansion precedes execution of
those statements. Macros are currently an interpreter feature; the VM
explicitly refuses unsupported macro compilation. Start with functions
unless syntax itself must be transformed.
Take the evaluator from §22.6 (the version without lambdas) and add
support for node.op == "-" if it isn’t already there. Then
build the AST for (10 - 3) * 2 and verify it evaluates to
14.
Add a new Concept LetNode with slots name,
value, and body. Semantically, the
object-language form bind x = e1 in e2 should evaluate
e1 in the current env, then evaluate e2 in the
env extended with x bound to that value.
Extend eval_expr to handle LetNode. Then
build the AST for nested local bindings equivalent to
bind x = 3 in bind y = 4 in x + y and verify it evaluates
to 7.
Write pretty(node) that converts an AST back into a
human-readable string. Cases:
NumNode { value: n } → str(n)VarNode { name: nm } → nmOpNode { op: o, left: l, right: r } →
"(" + pretty(l) + " " + o + " " + pretty(r) + ")"IfNode { cond, then_branch, else_branch } →
"if " + pretty(cond) + " then " + pretty(then_branch) + " else " + pretty(else_branch)Then for the AST (2 + 3) * 4 you built in §22.3,
pretty(mul) should produce
"((2 + 3) * 4)".
parse + ast_eval to round-tripWrite a function run(s) that takes a string of code,
parses it with Axioma’s built-in parse, evaluates it with
ast_eval, and returns the value.
println(run("2 + 3")) # 5
println(run("(7 - 3) * 5")) # 20
println(run("if 1 then 100 else 200")) # 100This isn’t using our hand-written evaluator — it’s using the real Axioma evaluator, via the AST-as-data interface. Compare and contrast with the hand-written one.
headof and argsofWrite describe(s) that takes a string of code, parses
it, and prints the head and args of the parsed AST. Examples:
describe("2 + 3")
# head: +
# args: ["2", "3"]
describe("sqrt(16)")
# head: sqrt
# args: ["16"]
describe("if true then 1 else 2")
# head: If
# args: ["true", "1", "2"]Pick a small domain — Boolean expressions, a tiny stack machine, a four-function calculator with memory, anything you want — and design:
eval_expr per Concept).Document your design choices. There’s no single right answer — the point is that you can design your own language now, because you understand what an interpreter does.
Three reasons.
One — you understand evaluation. Once you’ve written
eval_expr for a subset of a language, you know
what “evaluation” means. Not in the textbook sense, in the operational
sense. You know what an environment is. You know what a closure is. You
know why lexical scoping isn’t some arbitrary rule — it’s the direct
consequence of how the evaluator threads environments. The mystery is
gone.
Two — you understand the design recipe at the largest scale. The recipe says: data shape determines program shape. An interpreter is the case where the data is itself a program. The recipe still applies. The dispatch over the AST is structural recursion, just bigger.
Three — you can extend any language. Most “languages” you’ll use professionally — DSLs, query languages, config formats — turn out to be ASTs walked by an evaluator. JSON config files. SQL. CSS. They’re all this same pattern. After this chapter, you don’t see them as opaque tools; you see their structure. That’s the gift this course was leading toward.
The textbook is over. The language is yours.
“The art of programming is the art of organizing complexity, of mastering the multitude and avoiding its bastard chaos as effectively as possible.” — Edsger Dijkstra, 1972. The meta-circular evaluator is one place where the multitude really does get mastered — in 50 lines of code that is the language it describes.
Solutions to selected exercises: Chapter 22 · Solutions in Appendix C. (Repo file: exercises/solutions/ch22_solutions.md.)
Volume II picks up where Volume I leaves off. Volume I gave you the design-recipe vocabulary and Axioma’s signature features. Volume II shows how to use them to build the standard CS2 data-structure curriculum — lists, stacks, queues, hash tables, trees, graphs — and (in chapters to come) extends past that curriculum into algorithm analysis, stateful systems, knowledge representation, and the neuro-symbolic frontier.
Chapter numbering continues from Volume I (Volume II starts at Chapter 23).
What this chapter is. Your first “self-built” data structure. Axioma’s arrays are wonderful — fast, indexable, familiar — but they’re not the only way to model a sequence. A linked list is a sequence built out of links — each element holds its value and a reference to the rest. This chapter shows you how to build one from Concepts, how the operations look in code, and why you’d ever do this when arrays already exist. The answer turns out to be more interesting than it sounds.
Nothing! Axioma arrays are excellent for most jobs. They give you O(1) random access, they’re built in, and Chapter 6 already taught you how to use them.
But arrays have hidden tradeoffs that show up at the edges:
| Operation | Array cost | Linked list cost |
|---|---|---|
arr[i] (random access) |
O(1) | O(n) |
| Append to end | O(1) amortized | O(n) (or O(1) with tail pointer) |
| Prepend to front | O(n) (shift everything) | O(1) |
| Insert in middle | O(n) | O(1) if you have the link |
| Length | O(1) (stored) | O(n) (walk to count) |
Two operations stand out: prepend and insert-in-middle are O(n) for arrays and O(1) for linked lists. If your program spends most of its time adding things to the front of a sequence (a stack), or splicing items into a sequence at known positions (an editor’s text buffer), a linked list is genuinely faster.
The deeper reason to learn linked lists: they’re your first recursively-defined data structure built by hand. Every balanced tree, every graph, every parse tree in Chapter 22 is built the same way — Concepts pointing to other Concepts. Master linked lists, and the rest of the data-structures curriculum unfolds from the same template.
A linked list is either:
That’s the entire data definition. Two cases, recursive. Encoded as Concepts:
concept Empty # the empty-list marker
concept Cons # a cell with a value + rest
Cons has head: 0 # the value at this position
Cons has tail: 0 # the rest of the listThe names head and tail are classical (Lisp
1960 → ML → Haskell → many others). Some traditions use car
and cdr (from the original IBM-704 register names); we’ll
stick with the modern names.
A three-element list [1, 2, 3] represented this way:
Cons(1, Cons(2, Cons(3, Empty)))
Or drawn as boxes-and-arrows:
┌───┬───┐ ┌───┬───┐ ┌───┬───┐ ┌───────┐
│ 1 │ • │──→ │ 2 │ • │──→ │ 3 │ • │──→ │ Empty │
└───┴───┘ └───┴───┘ └───┴───┘ └───────┘
Each box is a Concept instance. The arrows are references —
tail slot in one cell pointing at the next cell.
concept Empty
concept Cons
Cons has head: 0
Cons has tail: 0
nil: an Empty {}
l3: a Cons { head: 3, tail: nil }
l2: a Cons { head: 2, tail: l3 }
l1: a Cons { head: 1, tail: l2 }
# l1 now represents the list [1, 2, 3]
println(l1.head) # 1
println(l1.tail.head) # 2
println(l1.tail.tail.head) # 3Building from the right (the tail) is the natural order — each new
cell’s tail slot needs the already-built rest of
the list. A helper makes this less painful:
list_of: func(arr) [
result: nil
i: len(arr)
while (i >= 1) [
result = a Cons { head: arr[i], tail: result }
i = i - 1
]
result
]
my_list: list_of([1, 2, 3, 4, 5])
println(my_list.head) # 1We walk the array right-to-left so each cons
happens after its tail is already built. That gives us the same left-
to-right order in the resulting list as in the input array.
Every list operation follows the same template:
walk_template: func(lst) [
if lst is Empty then BASE_CASE
else COMBINE(lst.head, walk_template(lst.tail))
]Two cases (Empty vs. Cons), one recursive call. Identical to Chapter
7’s recursion-on-arrays template, except the recursive call is
fn(lst.tail) instead of
fn(arr[2..len(arr)]).
length: func(lst) [
if lst is Empty then 0
else 1 + length(lst.tail)
]
println(length(my_list)) # 5
println(length(nil)) # 0Each cell contributes 1; the empty list contributes 0. The total is the count of cells.
sum: func(lst) [
if lst is Empty then 0
else lst.head + sum(lst.tail)
]
println(sum(my_list)) # 1 + 2 + 3 + 4 + 5 = 15Identity element 0 (for +); combine via addition.
largest: func(lst) [
if lst is Empty then 0 # caller guarantees non-empty list, but 0 is safe
else if lst.tail is Empty then lst.head
else [
r: largest(lst.tail)
if lst.head > r then lst.head else r
]
]
println(largest(my_list)) # 5Two structural sub-cases for non-empty: “exactly one element” and “more than one.” The recursive call gives us the largest of the rest; we compare with the current head.
Converting a linked list back to an Axioma array is itself a walk over the list. The iterative form is the cleanest:
list_to_array: func(lst) [
result: []
cur: lst
while (not (cur is Empty)) [
result = result + [cur.head,]
cur = cur.tail
]
result
]
println(list_to_array(my_list)) # [1, 2, 3, 4, 5](The trailing comma is §6.6’s singleton rule at work: in block
positions a bare [x] reads as a one-statement block, so
[x,] is the spelling that means “one-element array”
everywhere. You may also meet the older [] + [x] wrapper in
existing code — same effect.)
Concatenate two linked lists. The recursion is on the first list — when we reach Empty, we hand off the second list as-is.
append_lists: func(a, b) [
if a is Empty then b
else a Cons { head: a.head, tail: append_lists(a.tail, b) }
]
l_a: list_of([1, 2, 3])
l_b: list_of([4, 5])
l_c: append_lists(l_a, l_b)
println(list_to_array(l_c)) # [1, 2, 3, 4, 5]Notice the cost. append_lists makes a
new cons cell for every element of the first list (a recursive
call per cell). That’s O(length(a)). The second list is shared
— its cells aren’t copied. That’s structural sharing in action.
Reverse a list. The naive structural recursion
(reverse(tail) + [head]) is O(n²) because each
+ re-walks the partial result. The accumulator version
(Chapter 15 territory) is O(n):
reverse: func(lst) [
reverse_helper(lst, nil)
]
reverse_helper: func(lst, acc) [
if lst is Empty then acc
else reverse_helper(lst.tail, a Cons { head: lst.head, tail: acc })
]
lr: reverse(my_list)
println(list_to_array(lr)) # [5, 4, 3, 2, 1]The accumulator acc collects the reversed prefix; the
recursive call moves one cell from the input list to the front of
acc. After we’ve walked the entire input, acc
holds the full reversal.
Apply a function to every element. The structural recursion walks the list and rebuilds it with each element transformed:
map_list: func(f, lst) [
if lst is Empty then nil
else a Cons { head: f(lst.head), tail: map_list(f, lst.tail) }
]
doubled: map_list(func(x) [x * 2], my_list)
println(list_to_array(doubled)) # [2, 4, 6, 8, 10]The function is passed as a value (Chapter 10’s higher-order toolkit)
and applied at each cell. Compare with array map — same
conceptual shape, just operating on linked structure instead of indexed
storage.
Here’s a subtle but important property of linked lists:
l: list_of([1, 2, 3, 4, 5])
r: reverse(l)After these two lines, l still points to its
original cells. reverse built brand-new cells for the
reversed result; it didn’t mutate the original. That’s because every
function in this chapter builds new cells, never modifies existing
ones.
Arrays in some languages (Python, JavaScript) get mutated in place
when you do arr.reverse(). That can cause subtle bugs —
calling .reverse() on a list passed to your function
also reverses the caller’s list. Linked lists in this chapter
never have that problem. They’re immutable by
construction.
This idea — functions return new values rather than modifying old ones — is the seed of functional data-structure design (Okasaki 1998, the source for the two-stack queue pattern you’ll see in Chapter 24). Immutable linked lists are the simplest case.
For 90% of CS2 work, Axioma arrays are better than linked lists. They’re built in, indexable in O(1), and require no custom Concepts.
The cases where linked lists win:
Most production Axioma code uses arrays for sequences. Most books about Axioma still teach linked lists.
nthWrite nth(lst, n) — return the element at position
n (1-indexed) in the linked list lst.
Examples:
l: list_of([10, 20, 30, 40, 50])
println(nth(l, 1)) # 10
println(nth(l, 3)) # 30
println(nth(l, 5)) # 50
println(nth(l, 6)) # 0 (default for out-of-bounds; caller's responsibility)Hint: recurse on n and lst
simultaneously — at each step, either we’ve reached
n == 1 and return the head, or we recurse with
n - 1 on the tail.
What’s the cost of nth? Compare with arr[i]
on an Axioma array.
containsWrite list_contains(lst, x) — return true
if x appears anywhere in lst,
false otherwise.
l: list_of([1, 2, 3, 4, 5])
println(list_contains(l, 3)) # true
println(list_contains(l, 99)) # false
println(list_contains(nil, 7)) # falsefilter_listWrite the linked-list analog of filter (Chapter 10).
Given a predicate pred and a list lst, return
a new list containing only the elements for which pred(x)
returns true.
l: list_of([1, 2, 3, 4, 5, 6, 7, 8])
evens: filter_list(func(x) [x % 2 == 0], l)
println(list_to_array(evens)) # [2, 4, 6, 8]Hint: structural recursion. For each cell, either include it
(cons head onto recursive result) or skip it
(return recursive result).
fold_listWrite the linked-list analog of reduce. Given a binary
function op, a seed value acc, and a list
lst, return the result of folding op over the
list:
l: list_of([1, 2, 3, 4, 5])
println(fold_list(func(a, b) [a + b], 0, l)) # 15
println(fold_list(func(a, b) [a * b], 1, l)) # 120Hint: this is the most general list-walk function.
length, sum, largest, and many
others can be written as specializations of fold_list.
zip_listsGiven two linked lists a and b, return a
single linked list whose elements are the (a[i], b[i])
tuples. When the shorter list runs out, stop.
l_a: list_of([1, 2, 3])
l_b: list_of(["a", "b", "c", "d"])
z: zip_lists(l_a, l_b)
println(list_to_array(z)) # [(1, "a"), (2, "b"), (3, "c")]Hint: recurse on both lists. The base case is “either list is Empty.”
Write insert_at(lst, n, x) — return a new
linked list that is lst with x inserted at
position n (the new element ends up at position
n; original elements at n and after shift to
n+1, etc.). The original lst is unchanged.
l: list_of([1, 2, 3, 4])
l2: insert_at(l, 2, 99)
println(list_to_array(l2)) # [1, 99, 2, 3, 4]
println(list_to_array(l)) # [1, 2, 3, 4] — original unchangedThe interesting part: how much of the original list does
l2 share with l? Trace it and explain in
a comment. This is the seed of persistent data structures.
You’ll rarely use linked lists in production Axioma code. You’ll constantly use the structural-recursion template you just internalized:
walk_template: func(structure) [
if structure is BaseCaseConcept then BASE_VALUE
else COMBINE(structure.slot, walk_template(structure.other_slot))
]That template is the engine for every Concept-based data structure in the rest of this book. Trees: same template, two recursive children. Graphs: same template, plus a “visited” guard. Parsed expressions in Chapter 22’s evaluator: same template. Conceptual graphs in Chapter 35: same template.
You haven’t just learned a data structure. You’ve learned how to build every data structure.
The next chapter ports stacks and queues — the simplest abstract data types — and shows how the same operations can have multiple implementations with different costs.
“Lists are the original objects.” — Many places. The insight is that a list of items is the most natural recursive data: something at the front, and the rest of the list. Once you see that shape, you’ll see it everywhere.
Solutions to selected exercises: Chapter 23 · Solutions in Appendix C. (Repo file: exercises/solutions/ch23_solutions.md.)
What this chapter is. Three of the smallest, oldest, most useful data structures — and three good ways to learn the most important idea in CS2: an abstract data type (ADT) is an interface, not a representation. The same stack operations (
push,pop,top,empty?) can be backed by an Axioma array, a linked list of Concepts, or the global interpreter stack. The user of the stack doesn’t care which — they get the same behavior, possibly at different costs. The same goes for queues and deques.
Most data structures have two layers:
push, pop, top,
empty?.The key insight: clients work against the interface, not the representation. Once you commit to “this is a stack,” the consumer code doesn’t care how it’s implemented — switch representations later and nothing breaks.
This chapter shows the same three ADTs (Stack, Queue, Deque) implemented in multiple ways, with measured complexity contrasts.
A stack is LIFO — last in, first out. Operations:
| Operation | Effect |
|---|---|
push(s, x) |
Add x on top |
pop(s) |
Remove and return the top |
top(s) |
Return the top without removing |
empty?(s) |
Is the stack empty? |
The Chapter 23 linked list IS a stack:
concept Empty
concept Cons
Cons has head: 0
Cons has tail: 0
stack_empty: an Empty {}
stack_push: func(s, x) [
a Cons { head: x, tail: s }
]
stack_top: func(s) [
if s is Empty then 0
else s.head
]
stack_pop: func(s) [
if s is Empty then s
else s.tail
]
stack_is_empty: func(s) [
s is Empty
]All four operations are O(1). The “current top”
lives at the front of the linked list, where push and
pop both take constant time.
s1: stack_push(stack_empty, 10)
s2: stack_push(s1, 20)
s3: stack_push(s2, 30)
println(stack_top(s3)) # 30
s4: stack_pop(s3)
println(stack_top(s4)) # 20Note: pop returns a new stack with the top
removed; the old stack is unchanged. Persistent, just like the linked
lists from Chapter 23.
A stack-by-array uses the end of the array as the top:
arr_push: func(s, x) [
s + ([] + [x])
]
arr_top: func(s) [
if len(s) == 0 then 0
else s[len(s)]
]
arr_pop: func(s) [
if len(s) == 0 then s
else s[1..(len(s) - 1)]
]
arr_is_empty: func(s) [
len(s) == 0
]
a1: arr_push([], 10)
a2: arr_push(a1, 20)
a3: arr_push(a2, 30)
println(arr_top(a3)) # 30
println(arr_pop(a3)) # [10, 20]Cost. Each arr_push builds a
new array; the underlying + concatenation is O(n).
Same for pop — slicing copies. So the array stack
is O(n) per operation, not O(1). For most practical uses, the
Concept-based stack is faster and simpler.
(Production Axioma code has two better tools: mutable arrays push/pop
in place at true O(1) — Chapter 16 territory — and the native
List is exactly the persistent structure this section
hand-builds: cons(x, l) is O(1) prepend with the tail
shared, first/rest are the O(1)
reads, and nothing ever copies. What arr_push teaches by
construction, List provides as a type.)
Axioma has a built-in global stack used by Pop-11/Forth operations. We won’t teach it deeply (it’s Chapter 39 material), but it exists, and stack-based programming has a long, productive history.
The pedagogical choice for the rest of this chapter: Concept-based stacks (Representation 1). Constant-time operations, immutable, and the recursive structure is the heart of every later data structure.
A queue is FIFO — first in, first out. Operations:
| Operation | Effect |
|---|---|
enqueue(q, x) |
Add x at the back |
dequeue(q) |
Remove and return the front |
front(q) |
Return the front without removing |
empty?(q) |
Is the queue empty? |
The challenge: both operations need to be O(1), but a queue has two active ends. A naive linked list gives O(1) at the front and O(n) at the back, or vice versa. We need both ends to be fast.
Use two stacks, in and
out. New items get pushed onto in. To dequeue,
pop from out; if out is empty, flip
in onto out (reversing it).
concept Queue
Queue has in_stack: 0 # incoming items, newest on top
Queue has out_stack: 0 # outgoing items, oldest on top
queue_empty: a Queue { in_stack: stack_empty, out_stack: stack_empty }
queue_enqueue: func(q, x) [
a Queue {
in_stack: stack_push(q.in_stack, x),
out_stack: q.out_stack
}
]
queue_dequeue: func(q) [
if not stack_is_empty(q.out_stack) then [
# Fast path: out has items
new_out: stack_pop(q.out_stack)
a Queue { in_stack: q.in_stack, out_stack: new_out }
] else if stack_is_empty(q.in_stack) then [
# Both empty: no-op
q
] else [
# Out is empty, in has stuff: flip in onto out, then pop
flipped: stack_reverse(q.in_stack)
new_out: stack_pop(flipped)
a Queue { in_stack: stack_empty, out_stack: new_out }
]
]
queue_front: func(q) [
if not stack_is_empty(q.out_stack) then stack_top(q.out_stack)
else if stack_is_empty(q.in_stack) then 0
else stack_top(stack_reverse(q.in_stack))
]
queue_is_empty: func(q) [
stack_is_empty(q.in_stack) and stack_is_empty(q.out_stack)
]We need stack_reverse:
stack_reverse: func(s) [
stack_reverse_helper(s, stack_empty)
]
stack_reverse_helper: func(s, acc) [
if s is Empty then acc
else stack_reverse_helper(s.tail, stack_push(acc, s.head))
]Cost. enqueue is always O(1).
dequeue is O(1) amortized — most calls are O(1),
but the occasional “flip” is O(n). Averaged over many calls, each
operation is O(1).
Why this is beautiful. Two stacks, each O(1) on their own end, working together to give us a queue with O(1) amortized both ways. No mutation needed, no doubly-linked structure, no auxiliary pointers. The whole thing fits in 20 lines.
This trick is due to Chris Okasaki (1998), who popularized functional data-structure design. The version above is the simplest case.
q1: queue_enqueue(queue_empty, 1)
q2: queue_enqueue(q1, 2)
q3: queue_enqueue(q2, 3)
println(queue_front(q3)) # 1
q4: queue_dequeue(q3)
println(queue_front(q4)) # 2A deque (double-ended queue) supports push and pop at both ends:
| Operation | Effect |
|---|---|
push_front(d, x) |
Add x to the front |
push_back(d, x) |
Add x to the back |
pop_front(d) |
Remove and return from the front |
pop_back(d) |
Remove and return from the back |
The two-stack trick generalizes:
concept Deque
Deque has front_stack: 0
Deque has back_stack: 0
deque_empty: a Deque { front_stack: stack_empty, back_stack: stack_empty }
deque_push_front: func(d, x) [
a Deque { front_stack: stack_push(d.front_stack, x), back_stack: d.back_stack }
]
deque_push_back: func(d, x) [
a Deque { front_stack: d.front_stack, back_stack: stack_push(d.back_stack, x) }
]pop_front and pop_back are dual to the
queue’s dequeue — pop from your own stack, or flip the
other if yours is empty:
deque_pop_front: func(d) [
if not stack_is_empty(d.front_stack) then [
a Deque { front_stack: stack_pop(d.front_stack), back_stack: d.back_stack }
] else if stack_is_empty(d.back_stack) then d
else [
flipped: stack_reverse(d.back_stack)
a Deque { front_stack: stack_pop(flipped), back_stack: stack_empty }
]
]
deque_pop_back: func(d) [
if not stack_is_empty(d.back_stack) then [
a Deque { front_stack: d.front_stack, back_stack: stack_pop(d.back_stack) }
] else if stack_is_empty(d.front_stack) then d
else [
flipped: stack_reverse(d.front_stack)
a Deque { front_stack: stack_empty, back_stack: stack_pop(flipped) }
]
]Same O(1) amortized cost on both ends, same immutability, same elegant lack of mutation.
In practice, mature implementations balance the two stacks periodically (every time one gets much larger than the other) so that single-side patterns don’t degrade. The balancing version is the real Okasaki deque; ours is the pedagogical simplification.
When designing an ADT:
Stage 1 — interface. List the operations clients will call. Stack: push/pop/top/empty?. Queue: enqueue/dequeue/front/empty?. Five-or-fewer operations is usually right.
Stage 2 — invariants. What must be true of every state? “The stack always has at least the elements pushed minus those popped, in LIFO order.” Spelling out invariants makes the implementation correctness obvious.
Stage 3 — representation. Pick the underlying data: linked list of Concepts? Array? Two stacks? Each choice has cost implications.
Stage 4 — implement and time. Write the operations, then measure. Empirical timing on inputs of size 100, 1000, 10000 reveals whether your “O(1)” is really O(1) in practice.
Stage 5 — test against the interface. A good test exercises only the interface — never reaches into the representation. That way the same tests run unchanged when you swap implementations.
Write balanced(s) — given a string s of
parentheses ((, ), [,
], {, }), return
true if they’re properly balanced.
println(balanced("(())")) # true
println(balanced("([{}])")) # true
println(balanced("(()")) # false (unclosed)
println(balanced(")")) # false (unopened)
println(balanced("([)]")) # false (interleaved)Hint: walk the string. For each open paren, push onto a stack. For each close paren, pop and check the match. At the end, the stack must be empty.
Write reverse_via_stack(arr) that uses an explicit Stack
ADT to reverse an array. Push every element of the input onto a stack,
then pop them all back into a new array.
println(reverse_via_stack([1, 2, 3, 4, 5]))
# [5, 4, 3, 2, 1]Compare with the recursive reverse from Chapter 23. Same
asymptotic cost (O(n)), but the imperative style with an
explicit stack is sometimes clearer for iterative algorithms.
Write queue_length(q) — return the number of elements in
the queue.
q: queue_enqueue(queue_enqueue(queue_enqueue(queue_empty, 1), 2), 3)
println(queue_length(q)) # 3
q4: queue_dequeue(q)
println(queue_length(q4)) # 2Hint: length of in_stack plus
length of out_stack.
Write is_palindrome(arr) — given an array, return
true if its elements read the same forwards and backwards.
Use a deque: push every element onto the deque (back), then pop from
both ends and compare until you reach the middle.
println(is_palindrome([1, 2, 3, 2, 1])) # true
println(is_palindrome([1, 2, 3, 4, 5])) # false
println(is_palindrome(["a", "b", "a"])) # true
println(is_palindrome([])) # true (vacuously)Write rpn(tokens) — evaluate a
reverse-Polish-notation expression given as an array of tokens.
Numbers go onto a stack; operators pop two numbers, compute, and push
the result. Support +, -, *,
/.
println(rpn([1, 2, "+"])) # 3 (= 1 + 2)
println(rpn([1, 2, "+", 3, "*"])) # 9 (= (1 + 2) * 3)
println(rpn([5, 1, 2, "+", 4, "*", "+", 3, "-"])) # 14Hint: walk the tokens. If a token is a number, push it. If it’s an operator, pop two, apply, push the result. After all tokens, the stack should contain exactly one value — the answer.
Model a browser’s tab history as a deque. Operations:
visit(url) — go to a new URL (push onto back).back() — go to the previous URL (pop back, return new
current).forward() — go forward after a back (we’ll need a
second deque for the forward stack — this is the two-deque
pattern).current() — return the current URL.Document your choice of representation. Real browsers do this with stacks rather than deques; explain why a deque (your choice) is also valid.
The biggest lesson of this chapter is not the data structures themselves. It’s the discipline of separating interface from representation.
When you design a piece of code that uses a queue, you should be able to specify just:
“I need a queue: a thing where I can
enqueueitems at the back anddequeueitems from the front.”
And then plug in any implementation that satisfies that contract. Today maybe you use the two-stack queue. Next month you discover a measurement that shows the doubly-linked list is faster for your workload. You swap representations without touching the calling code.
This is the seed of encapsulation — the idea that underpins object-oriented programming, modules, libraries, microservices, and APIs. Every “good API” is just an ADT with a stable interface and swappable implementation underneath.
The next chapter brings hash tables — a single representation (array of buckets) that supports a much richer interface than stacks or queues.
“Programs must be written for people to read, and only incidentally for machines to execute.” — Hal Abelson, in the preface to Structure and Interpretation of Computer Programs. ADTs are the discipline that makes that possible: clients read the interface, not the implementation.
Solutions to selected exercises: Chapter 24 · Solutions in Appendix C. (Repo file: exercises/solutions/ch24_solutions.md.)
What this chapter is. A hash table answers a question that lists and stacks can’t answer well: given a key, find the value, fast. The trick is hashing — turning the key into an array index in O(1). The chapter shows the built-in
dict()/ hash-literal syntax, opens the lid on how hashing actually works, builds a hand-rolled hash table from Concepts so you understand the trade-offs, and ends with a tour of collision resolution — the part of hash-table design that determines how robust your implementation really is.
So far we can:
Neither gives us the third option we want most often:
Given a key (a string, a number, anything), find the associated value, in constant time, regardless of how many entries are stored.
That’s a hash table — also called a dictionary, map, or associative array depending on the language. It’s the data structure behind every database index, every spell- checker, every compiler’s symbol table, every cached web request, and every modern programming language’s “object” or “dictionary” type.
dict() and hash literalsAxioma gives you hash tables two ways:
# The function form
d: dict()
d["alice"] = 30
d["bob"] = 25
println(d["alice"]) # 30
# The literal form
ages: {"alice": 30, "bob": 25, "carol": 22}
println(ages["bob"]) # 25
println(ages.alice) # 30 — dot access also works for string-keyed hashesBoth produce a Dictionary. Keys can be values, including
numbers; string keys are a convenient special case. The dot-access
shortcut (ages.alice) works for a string key spelled as a
valid identifier.
Cost. Insertion and lookup are O(1) on average. That’s the entire selling point.
Why build our own? The built-in hash already does everything this chapter needs — literals read fine and post-creation mutation (
h["a"] = 1) works. But the point of the chapter is to see what’s under the hood, so from §25.4 onward we hand-roll a Concept-based hash table: buckets, hashing, collision handling, the works.
A Pair represents two associated values. It is distinct from a Tuple, although the dictionary constructor can consume either representation.
labels: dict([1 -> "one", 2 -> "two"])
println(labels[2]) # two
println(labels is Dictionary) # true
println((1 -> "one") is Pair) # true
println((1, "one") is Tuple) # true
println(dictionary([(3, "three")])[3]) # threeThe arrow can also introduce a function. A bare
identifier followed by -> is a lambda
parameter, as in x -> 2*x. To make a Pair whose left
member is the value of a variable, use the explicit constructor:
pair(key, value). Parenthesizing a name still permits the
lambda reading. Do not infer meaning from the arrow alone. Numeric and
other supported keys compare by value; dot notation is only the
convenient string-key spelling. Mutable compound keys require special
care: changing the value used as a key makes the intended lookup
contract hard to reason about. Prefer stable, simple keys in
introductory programs.
The trick: take the key, compute a number, use that number as an array index.
"alice" ──hash──→ 4827361 ──% N──→ bucket #3
Three pieces:
In Axioma’s built-in dict, all three are hidden — you
get the interface, the implementation is internal. For
learning, we’ll build our own.
Start with the data definition:
concept HashBucket
HashBucket has key: ""
HashBucket has value: 0
HashBucket has nxt: 0 # for chaining — see §25.6
concept HashTable
HashTable has buckets: 0 # array of `HashBucket`s or `nil`
HashTable has size: 0 # number of buckets
HashTable has count: 0 # number of stored entriesEach slot in the buckets array is either
nil (empty) or a HashBucket Concept holding a
key, a value, and a nxt pointer for chained collisions.
A trivial hash function for strings (the djb2 hash, much- used and easy to remember):
djb2_hash: func(s) [
h: 5381
i: 1
while (i <= len(s)) [
code: ord(s[i..i])
h = h * 33 + code
i = i + 1
]
if h < 0 then -h else h # ensure positive
]
println(djb2_hash("alice")) # some large positive number
println(djb2_hash("bob")) # different large positive numberProperties of a good hash function:
djb2 is not the strongest hash function in the world, but it’s fast, simple, and good enough for teaching. Production code uses SipHash, MurmurHash, or xxHash.
nil_bucket: 0 # we'll use 0 as the "empty slot" sentinel
make_table: func(size) [
buckets: []
i: 1
while (i <= size) [
buckets = buckets + ([] + [nil_bucket])
i = i + 1
]
a HashTable { buckets: buckets, size: size, count: 0 }
]
put_simple: func(table, key, item_value) [
idx: djb2_hash(key) % table.size + 1
new_bucket: a HashBucket { key: key, value: item_value, nxt: nil_bucket }
table.buckets[idx] = new_bucket
a HashTable {
buckets: table.buckets,
size: table.size,
count: table.count + 1
}
]
get_simple: func(table, key) [
idx: djb2_hash(key) % table.size + 1
bucket: table.buckets[idx]
if bucket == nil_bucket then 0
else if bucket.key == key then bucket.value
else 0
]This works as long as no two keys collide. In practice, collisions are inevitable — the birthday paradox says that in a table of 100 buckets, you’ll see your first collision around the 13th distinct key. We need a real collision strategy.
The simplest collision resolution: chain colliding entries together in a linked list per bucket.
put_chained: func(table, key, item_value) [
idx: djb2_hash(key) % table.size + 1
existing: table.buckets[idx]
new_bucket: a HashBucket {
key: key,
value: item_value,
nxt: existing
}
table.buckets[idx] = new_bucket
a HashTable {
buckets: table.buckets,
size: table.size,
count: table.count + 1
}
]
get_chained: func(table, key) [
idx: djb2_hash(key) % table.size + 1
bucket: table.buckets[idx]
while_chain_lookup(bucket, key)
]
while_chain_lookup: func(bucket, key) [
if bucket == nil_bucket then 0
else if bucket.key == key then bucket.value
else while_chain_lookup(bucket.nxt, key)
]When inserting, push the new HashBucket onto the head of
the chain (O(1)). When looking up, walk the chain (O(chain length)). If
the table is sized so that the load factor (count /
size) stays around 0.75, the average chain length is roughly 1,
and lookups are O(1) on average.
When the load factor gets too high, the table needs to rehash into a larger bucket array. We won’t cover rehashing here — the basic algorithm is “make a new double-sized table, walk every chain, re-insert each entry.”
An alternative: store everything in the bucket array itself, with no chains. If a collision happens, probe to the nxt available slot.
hash(key) % N = 7
bucket 7 occupied → probe bucket 8
bucket 8 occupied → probe bucket 9
bucket 9 free → store here
Linear probing is the simplest version (just increment). Better strategies include quadratic probing (probe by 1, 4, 9, 16…) and double hashing (use a second hash to determine the probe step). Open addressing is faster when the load factor is low (≤ 0.7) — better cache locality, no pointer chasing — but degrades sharply as the table fills.
For most teaching purposes, separate chaining is the simpler and more forgiving choice. Production hash tables often use open addressing because of the cache benefit.
The hash function in §25.4 only handles strings. For a key that’s a
Concept (e.g., a Point), you’d need:
concept Point
Point has x: 0
Point has y: 0
point_hash: func(p) [
djb2_hash(str(p.x) + "," + str(p.y))
]The trick is to produce a canonical string representation of the Concept and hash that. Same Concept → same string → same hash. The string-then-hash pattern is general — every language that supports “user-defined hashable objects” uses some variant of it.
A classic use case: a function that dispatches on a string key, with O(1) lookup. Without a hash, you’d write:
# Without hash — O(n) dispatch via if/else cascade
dispatch_slow: func(cmd) [
if cmd == "open" then "Opening..."
else if cmd == "close" then "Closing..."
else if cmd == "list" then "Listing..."
else if cmd == "kill" then "Killing..."
else "Unknown command"
]With a hash, the dispatch becomes:
cmd_table: {
"open": "Opening...",
"close": "Closing...",
"list": "Listing...",
"kill": "Killing..."
}
dispatch_fast: func(cmd) [
if cmd in cmd_table then cmd_table[cmd]
else "Unknown command"
]
println(dispatch_fast("close")) # Closing...
println(dispatch_fast("xyz")) # Unknown commandSame behavior. For 4 commands, the cascade is fine. For many commands, a hash table avoids a linear chain of string comparisons. The actual speedup depends on hashing costs and the workload; it is not equal to the number of commands.
This pattern — replace cascades with table lookup — is one of the most common refactorings in performance work.
Write word_freq(words) — given an array of words, return
a hash table mapping each unique word to its count.
words: ["the", "cat", "sat", "on", "the", "mat", "and", "the", "rat"]
freq: word_freq(words)
println(freq["the"]) # 3
println(freq["cat"]) # 1
println(freq["dog"]) # 0 (or some default)Hint: walk the array. For each word, set
freq[word] = (freq[word] or 0) + 1. Or: check
word in freq and dispatch.
Write a hash function for a Point Concept (slots
x and y, both integers). Verify that:
hash_point: func(p) [ ?? ]
p1: a Point { x: 3, y: 7 }
p2: a Point { x: 3, y: 7 } # equal coords
p3: a Point { x: 7, y: 3 } # swapped — should be different
println(hash_point(p1) == hash_point(p2)) # true
println(hash_point(p1) == hash_point(p3)) # false (almost certainly)Write djb2_hash(s) from scratch. Then hash these 10
words and check whether the resulting hash values modulo 100 are
reasonably uniformly distributed (no bucket should hold more than 3 of
them):
words: ["the", "of", "and", "a", "to", "in", "is", "you", "that", "it"]
buckets: make_table(100)
# ... walk words, hash, count per bucket ...Build a small chain-based hash table from scratch (using the
HashBucket / HashTable Concepts from §25.4).
Implement put, get, and contains.
Insert 5 key-value pairs and verify they all round-trip.
t: make_table(8)
t1: put_chained(t, "a", 1)
t2: put_chained(t1, "b", 2)
println(get_chained(t2, "a")) # 1
println(get_chained(t2, "b")) # 2
println(get_chained(t2, "c")) # 0 (default for not-found)Take this if/else cascade and rewrite it as a hash-table dispatch:
day_to_number: func(d) [
if d == "Mon" then 1
else if d == "Tue" then 2
else if d == "Wed" then 3
else if d == "Thu" then 4
else if d == "Fri" then 5
else if d == "Sat" then 6
else if d == "Sun" then 7
else 0
]Then time both versions on 10,000 random calls. Which is faster? By how much?
Write top_n_words(words, n) — given an array of words
and an integer n, return the n most frequent
words sorted by count (descending).
speech: [... ~1000 words ...]
top10: top_n_words(speech, 10)
println(top10)This combines Ex 25.1 (frequency count) with a sort. There are several reasonable implementations — comment on which you chose and why.
Hash tables are unreasonably effective. They turn a class of problems (key-value lookup) from O(n) to O(1) with a small constant overhead. That single transformation has shaped every major piece of software written in the last 50 years:
For Axioma specifically, hash tables underpin the environment system
(Chapter 12’s local bindings), the KB fact store, and the
dict() builtin you use directly.
The nxt chapter takes the other fundamental dictionary strategy — balanced trees — and shows how they give you ordered keys at the price of O(log n) per operation. When you need sorted iteration over your keys, hash tables fail and balanced trees win.
“Algorithms + Data Structures = Programs.” — Niklaus Wirth, 1976. The hash table is one of the half-dozen data structures that turn the equation from impossible to tractable.
Solutions to selected exercises: Chapter 25 · Solutions in Appendix C. (Repo file: exercises/solutions/ch25_solutions.md.)
What this chapter is. A hash table gives O(1) lookup but no ordering. A sorted array gives O(log n) lookup if you never insert, but O(n) per insertion. A binary search tree combines them — O(log n) lookup and insertion if it stays balanced. This chapter builds a plain BST, demonstrates why insertion order can wreck it, and sketches the red-black tree fix.
You sometimes need:
Hash tables fail the third (unordered iteration). Sorted arrays fail the second (insertion shifts the tail).
A binary search tree (BST) gives you all three at O(log n). Each node holds a key, a left child (smaller keys), and a right child (larger keys). Look-up walks one side or the other; insertion walks to a leaf and adds.
5
/ \
3 8
/ \ \
1 4 9
Search for 4: 4 < 5 → go left. 4 > 3 → go right. Found. Search for 7: 7 > 5 → right. 7 < 8 → left. Not found (empty).
concept TreeEmpty
concept TreeNode
TreeNode has key: 0
TreeNode has value: 0
TreeNode has left: 0
TreeNode has right: 0
tree_empty: a TreeEmpty {}
tree_insert: func(t, k, v) [
if t is TreeEmpty then a TreeNode { key: k, value: v, left: tree_empty, right: tree_empty }
else if k < t.key then a TreeNode { key: t.key, value: t.value, left: tree_insert(t.left, k, v), right: t.right }
else if k > t.key then a TreeNode { key: t.key, value: t.value, left: t.left, right: tree_insert(t.right, k, v) }
else a TreeNode { key: t.key, value: v, left: t.left, right: t.right } # update
]
tree_get: func(t, k) [
if t is TreeEmpty then 0
else if k < t.key then tree_get(t.left, k)
else if k > t.key then tree_get(t.right, k)
else t.value
]
tree_contains: func(t, k) [
if t is TreeEmpty then false
else if k < t.key then tree_contains(t.left, k)
else if k > t.key then tree_contains(t.right, k)
else true
]Three-case structural recursion. Smaller-than → left. Larger-than → right. Equal → found.
t1: tree_insert(tree_empty, 5, "five")
t2: tree_insert(t1, 3, "three")
t3: tree_insert(t2, 8, "eight")
t4: tree_insert(t3, 1, "one")
t5: tree_insert(t4, 4, "four")
println(tree_get(t5, 4)) # "four"
println(tree_contains(t5, 7)) # falseWalking a BST in in-order (left, then root, then right) visits keys in ascending sorted order:
tree_inorder: func(t) [
if t is TreeEmpty then []
else tree_inorder(t.left) + ([] + [t.key]) + tree_inorder(t.right)
]
println(tree_inorder(t5)) # [1, 3, 4, 5, 8]This is tree sort: insert every element into a BST, then in-order-walk to get them sorted. O(n log n) when the tree is balanced. Less practical than mergesort (Chapter 29) but a beautiful application of the structure.
What happens if you insert keys in sorted order?
bad: tree_insert(tree_insert(tree_insert(tree_insert(tree_insert(tree_empty, 1, "a"), 2, "b"), 3, "c"), 4, "d"), 5, "e")
println(tree_inorder(bad)) # [1, 2, 3, 4, 5]The keys come out sorted, but the tree itself is a right-leaning chain:
1
\
2
\
3
\
4
\
5
Searching for 5 walks all 5 levels — O(n), not O(log n).
Insertion order fully sorted gives worst-case BST
behavior. Reverse-sorted is symmetric and equally bad.
For a BST to deliver its promised O(log n), the tree must be balanced — the depth of the deepest leaf must be ≤ 1.5 × depth of the shallowest, give or take a constant. Achieving balance for arbitrary insertion orders requires self-balancing trees.
A red-black tree is a BST with extra invariants attached to each node (each is “red” or “black”) that collectively bound the tree’s worst-case height to O(log n). On every insert and delete, the tree is locally rotated and recolored to keep the invariants.
The five red-black invariants:
Insert and delete each take O(log n), do at most O(1) rotations and O(log n) recolorings.
Implementing a full red-black tree is substantial — usually ~100 lines in any language, including Axioma. It’s a Chapter 26 exercise in CS2 textbooks, not Chapter 26 prose. The exercises at the end of this chapter walk through the pieces.
The takeaway: the tree’s invariants do the work. Once the invariants are maintained, lookup, insert, and delete are all O(log n) without any explicit rebalancing logic in the calling code.
Write tree_height(t) — the number of edges on the
longest path from root to leaf. A single-node tree has height 0; the
empty tree has height -1 (or you can use 0; document your choice).
t: tree_insert(tree_insert(tree_insert(tree_empty, 5, "a"), 3, "b"), 8, "c")
println(tree_height(t)) # 1Write tree_min(t) and tree_max(t) — return
the smallest and largest keys. In a BST, the smallest is the leftmost
node; the largest is the rightmost.
t: tree_insert(tree_insert(tree_insert(tree_insert(tree_empty, 5, "a"), 3, "b"), 8, "c"), 1, "d")
println(tree_min(t)) # 1
println(tree_max(t)) # 8Write tree_size(t) — the number of key-value pairs
stored.
Write tree_range(t, lo, hi) — return an array of all
keys in [lo, hi] in sorted order. Use the BST property to
prune the recursion: if the current node’s key is less
than lo, only recurse right; if greater than
hi, only recurse left.
t: tree_insert(tree_insert(tree_insert(tree_insert(tree_insert(tree_empty, 5, "a"), 3, "b"), 8, "c"), 1, "d"), 7, "e")
println(tree_range(t, 3, 7)) # [3, 5, 7]Write is_balanced(t) — return true if the
absolute difference of left and right subtree heights is ≤ 1 at every
node, false otherwise.
# Build a skewed tree by sorted insertion
skewed: tree_insert(tree_insert(tree_insert(tree_empty, 1, "a"), 2, "b"), 3, "c")
println(is_balanced(skewed)) # false (degenerate chain)
# Insert in a balance-friendly order
balanced_t: tree_insert(tree_insert(tree_insert(tree_empty, 2, "a"), 1, "b"), 3, "c")
println(is_balanced(balanced_t)) # trueA rotation is the local rearrangement that keeps a
tree balanced. Implement rotate_right(t) and
rotate_left(t):
Y X
/ \ / \
X c -→ a Y
/ \ / \
a b b c
rotate_right
rotate_right: func(t) [ ?? ]
rotate_left: func(t) [ ?? ]Both should return a new tree (no mutation). Verify that in-order traversal of the rotated tree equals the in-order traversal of the original. (Rotation preserves order.)
This is the building block of red-black, AVL, and splay trees. With rotation in hand, you can implement any self-balancing variant.
Three takeaways:
The next chapter shifts from trees in isolation to graphs — where edges no longer just go “down” from parent to child, but anywhere.
“Many small simple bug-free programs can be combined to form a large bug-free program.” — Dijkstra, 1972. Trees are the canonical “small simple bug-free program” — a recursive structure where each node only sees its immediate children, never the global shape.
Solutions to selected exercises: Chapter 26 · Solutions in Appendix C. (Repo file: exercises/solutions/ch26_solutions.md.)
What this chapter is. A graph is nodes + edges — the most general data structure in the standard CS2 curriculum. Trees are graphs without cycles; lists are graphs where each node has one successor. Graphs underpin social networks, road maps, the web, every dependency system, and every knowledge graph. The chapter shows two representations — the classical adjacency-list form and Axioma’s strength, the reified-edge form — and walks through breadth-first and depth-first traversals.
Take a graph of five cities with roads between them:
Boston ─ NewYork ─ Philadelphia
│ │
Hartford DC
Representation 1 — adjacency list. A hash table from each node to the list of its neighbors:
road_adj: {
"Boston": ["NewYork"],
"NewYork": ["Boston", "Philadelphia", "Hartford"],
"Philadelphia": ["NewYork", "DC"],
"Hartford": ["NewYork"],
"DC": ["Philadelphia"]
}Compact, fast neighbor lookup. The standard choice in most CS2 textbooks.
Representation 2 — reified Edge Concepts. Each edge is its own first-class Concept:
concept Road
Road has source: ""
Road has target: ""
Road has miles: 0
r1: a Road { source: "Boston", target: "NewYork", miles: 215 }
r2: a Road { source: "NewYork", target: "Philadelphia", miles: 95 }
r3: a Road { source: "NewYork", target: "Hartford", miles: 117 }
r4: a Road { source: "Philadelphia", target: "DC", miles: 140 }
roads: [r1, r2, r3, r4]Each Road is a Concept instance you can pass to
functions, store in lists, query against. Edges become
first-class citizens.
Why bother? Three reasons:
parent, friend, prereq from Book
1 were already reified relationships — the Concept-and-slots view is the
same idea, factored differently.The textbook uses reified edges as the primary representation. Adjacency lists appear when the algorithm specifically benefits from O(1) neighbor lookup.
Given an array of Road Concepts, finding a node’s neighbors is a filter-then-extract:
neighbors_of: func(roads, node) [
result: []
i: 1
while (i <= len(roads)) [
if roads[i].source == node then [
result = result + ([] + [roads[i].target])
]
if roads[i].target == node then [
result = result + ([] + [roads[i].source])
]
i = i + 1
]
result
]
println(neighbors_of(roads, "NewYork"))
# ["Boston", "Philadelphia", "Hartford"]We check both ends because the example treats roads as
undirected. For directed graphs, drop the second
if.
Cost. neighbors_of is O(E) — it walks
every edge. For small graphs this is fine; for large ones, build an
adjacency-list index from the edge array once.
The classical shortest-path algorithm for unweighted graphs. From a start node, visit nodes level by level — all distance-1 neighbors, then distance-2, then distance-3, and so on.
The shape: a queue of “to-visit” nodes, and a set of “already-visited” markers.
bfs_distances: func(roads, start) [
# Initialize: distances dict starts with {start: 0}, queue with [start]
distances: {}
distances = put_t(make_table(64), start, 0)
queue: [start]
while (len(queue) > 0) [
node: queue[1]
queue = queue[2..len(queue)]
nbrs: neighbors_of(roads, node)
i: 1
while (i <= len(nbrs)) [
n: nbrs[i]
if not contains_t(distances, n) then [
distances = put_t(distances, n, get_t(distances, node) + 1)
queue = queue + ([] + [n])
]
i = i + 1
]
]
distances
](Load the complete hash-table prelude at the start of Chapter 25’s solutions, which defines
make_table, put_t, get_t, and
contains_t. The simplified table in the chapter itself uses
different names.)
d: bfs_distances(roads, "Boston")
println(get_t(d, "Boston")) # 0
println(get_t(d, "NewYork")) # 1
println(get_t(d, "Philadelphia")) # 2
println(get_t(d, "DC")) # 3Cost. With adjacency lists and a queue supporting
constant-time operations, BFS is O(V + E). Our readable
edge-list version scans all roads to find each node’s neighbors and
rebuilds its queue Array, so that bound does not describe this
implementation. Hash-table load also matters.
BFS is the algorithm behind: shortest path in unweighted graphs, “six degrees of separation,” friend-of-friend suggestions (Chapter 21 Ex 21.5 was essentially BFS at depth 2), web-crawler frontier expansion.
Same shape, but use a stack instead of a queue. DFS goes deep first, backtracks when it hits a dead end:
dfs_visit: func(roads, start) [
visited: make_table(64)
visited = put_t(visited, start, 1)
stk: [start]
order: []
while (len(stk) > 0) [
node: stk[len(stk)]
stk = stk[1..(len(stk) - 1)]
order = order + ([] + [node])
nbrs: neighbors_of(roads, node)
i: 1
while (i <= len(nbrs)) [
n: nbrs[i]
if not contains_t(visited, n) then [
visited = put_t(visited, n, 1)
stk = stk + ([] + [n])
]
i = i + 1
]
]
order
]DFS visits in a different order than BFS — it goes as deep as it can before backing up.
Cost. The usual O(V + E) bound assumes
adjacency lists and efficient stack and visited-set operations. This
teaching version instead scans the edge list and rebuilds Arrays;
account for those costs separately.
DFS is the algorithm behind: cycle detection, topological sort, strongly-connected-component analysis, maze solving.
A graph has a cycle if you can leave a node and return to it without retracing an edge. The simplest cycle check uses DFS plus a “currently on the path” marker:
has_cycle: func(roads) [
nodes: all_nodes(roads)
visited: make_table(64)
result: false
i: 1
while (i <= len(nodes) and not result) [
if not contains_t(visited, nodes[i]) then [
if cycle_dfs(roads, nodes[i], "", visited) then [result = true]
]
i = i + 1
]
result
](all_nodes walks every Road and collects the union of
sources and targets; cycle_dfs is the per-component DFS
with parent tracking. Full implementation in the solutions.)
For the road graph above, has_cycle returns
false — it’s a tree (well, almost; the chain
Boston-NY-Phil-DC plus the spur NY-Hartford has no cycles). Adding a
road from DC back to Boston would create
one.
Recall Chapter 21’s course-prerequisite graph. The same data modeled with reified edges:
concept Prereq
Prereq has course: ""
Prereq has requires: ""
prereqs: [
a Prereq { course: "CS201", requires: "CS101" },
a Prereq { course: "CS201", requires: "MATH101" },
a Prereq { course: "CS301", requires: "CS201" },
a Prereq { course: "CS301", requires: "MATH201" },
a Prereq { course: "CS401", requires: "CS301" },
a Prereq { course: "MATH201", requires: "MATH101" }
]Topological sort — order the courses so prerequisites come before dependents. The standard algorithm: repeatedly pick a course with no unfulfilled prerequisites, output it, remove its outgoing edges.
The implementation lives in the exercises. The key insight: the same graph, two views — Chapter 21 used facts and rules, this chapter uses reified edges and explicit traversal. Both are valid; pick the one that matches your debugging style.
Write all_nodes(roads) — return an array of all unique
node names referenced as either source or target of any edge.
println(all_nodes(roads))
# ["Boston", "NewYork", "Philadelphia", "Hartford", "DC"]
# (order may vary)Write has_path(roads, src, dst) — return
true if there’s any sequence of edges from src
to dst. Use BFS.
println(has_path(roads, "Boston", "DC")) # true
println(has_path(roads, "DC", "Boston")) # true (undirected!)
println(has_path(roads, "Boston", "Mars")) # falseEach Road has a miles slot. Write
total_miles(roads) — sum of every road’s mileage.
println(total_miles(roads)) # 567 = 215 + 95 + 117 + 140Implement topo_sort(prereqs) — given an array of
Prereq edges, return an array of course names in dependency
order (every course comes after all its prerequisites).
println(topo_sort(prereqs))
# ["CS101", "MATH101", "CS201", "MATH201", "CS301", "CS401"]
# (one of several valid orderings — your output may differ)Hint: repeatedly pick a course whose prerequisites are all already in the output, append it, remove its outgoing edges from consideration. Stop when no courses remain or when no course is pickable (in which case there’s a cycle).
Implement has_cycle(roads) — return true if
the (undirected) graph has any cycle. Use DFS with parent tracking.
println(has_cycle(roads)) # false — it's a tree
with_cycle: roads + ([] + [a Road { source: "DC", target: "Boston", miles: 600 }])
println(has_cycle(with_cycle)) # trueImplement shortest_path(roads, src, dst) using
Dijkstra’s algorithm — given weighted Road edges, find the route with
minimum total miles from src to
dst.
println(shortest_path(roads, "Boston", "DC"))
# ["Boston", "NewYork", "Philadelphia", "DC"]Document your choice of priority-queue representation (simple linear scan? Concept-based heap?) and analyze the time complexity.
The whole chapter rests on one insight: almost every interesting data structure is a graph in disguise. Trees are acyclic graphs. Linked lists are graphs where each node has one outgoing edge. Family trees, web pages, file systems, dependency graphs, social networks, knowledge bases — all the same shape.
Once you can express data as nodes + edges and traverse it with BFS or DFS, you have a generic vocabulary for half of all algorithm problems. The other half is sorting and arithmetic.
For Axioma specifically, the reified-edge representation is the bridge between data-structure code (this chapter) and knowledge-representation code (Chapters 34–38). Once an edge is a first-class Concept, it can carry truth values, provenance, defeasibility — everything Book 1 Chapter 21 taught about logic programs.
The next chapter steps back from data structures and introduces the language of cost — Big-O notation and empirical complexity measurement, the tools that let you compare data-structure choices objectively.
“In graph problems, the structure is usually more important than the algorithm.” — Tarjan, paraphrased from a talk. The choice of representation (adjacency list vs. reified edges) often matters more than which BFS or DFS variant you use to walk it.
Solutions to selected exercises: Chapter 27 · Solutions in Appendix C. (Repo file: exercises/solutions/ch27_solutions.md.)
What this chapter is. Chapter 27 taught you plain mathematical graphs — vertices, edges, BFS, DFS, the classic data-structures repertoire. That’s one graph kind. Axioma also supports three other graph formalisms, each engineered to answer a question that a plain graph can’t. Sowa’s Conceptual Graphs add typed concepts and n-ary relations. Peirce’s Existential Graphs add diagrammatic logic — propositions as inscriptions on a sheet, negation as enclosure. McCarthy-style Context Graphs add multi-context truth — the same proposition can be true in one context and false in another, with Belnap B4 truth values per context. By the end of this chapter you will know all four formalisms, the question each is best at, and how to call them from Axioma.
| Kind | Inventor | Best for |
|---|---|---|
| Plain graphs | Euler 1736; graph theory | Networks, paths, connectivity, centrality — Chapter 27 |
| Conceptual Graphs | John Sowa, 1976 | Semantic networks with typed concepts, n-ary relations, and the FOL bridge |
| Existential Graphs | C. S. Peirce, 1896 | Diagrammatic logic — propositions on a sheet, negation as enclosure, the five rules of inference as visual moves |
| Context Graphs | McCarthy ist(c,p); MCS; CKR |
Multi-context reasoning with truth-per-context, including Belnap B4 — beliefs, sources, hypotheses, temporal slices |
These four formalisms live in Axioma side-by-side because they answer different questions. Don’t pick by the shape of the diagram; pick by the question you need to answer.
The remainder of this chapter takes each formalism in turn. We cover the theory in just enough depth to use it, then move directly into Axioma code that you can run.
Chapter 27 already drove this home, but for completeness of the
taxonomy: a plain graph is a pair (V, E)
where V is a set of vertices and E is a set of
edges between them. Edges can be directed or undirected, weighted or
unweighted, and a vertex can be any value your language allows.
Axioma’s graph() and digraph() builtins
give you a plain graph; reified Edge concepts let you store
metadata per edge. Plain graphs are the right answer when your question
is structural — shortest path, centrality,
connected components, cycles, topological sort. None of those
questions need types on the vertices or truth values on the edges.
You’d reach for a plain graph for:
You’d reach for one of the other three formalisms when your question is semantic or epistemic — what the nodes mean, what’s believed about them, or whether their meaning depends on a context.
John Sowa’s conceptual graphs (CGs) are the modern form of the semantic network tradition that goes back to Quillian’s 1968 thesis. The signature commitment is that nodes come in two kinds — concept nodes and relation nodes — and the graph is bipartite: concepts only connect to relations, not to other concepts.
A concept node is a typed entity. It has a
type (a concept name like Person,
Animal, Country) and an optional
referent — the specific individual being referred to.
A relation node is also typed. It has a
type (the relation name like Loves,
MotherOf, Owns) and an arity
— the number of concept-positions it connects. Crucially, relations are
n-ary, not just binary. The relation
Between(x, y, z) (“x is between y and z”) has arity 3 —
that’s not expressible as chained binary relations without losing
information.
Concept nodes connect to relation nodes through argument
positions. If Loves has arity 2, then it has
position-0 (the lover) and position-1 (the loved one). The arc from
concept to relation carries the position — that’s how
Loves(John, Mary) is distinguished from
Loves(Mary, John) in the graph.
Axioma supports Sowa’s bracket notation directly.
Concept nodes are written [Type: "referent"], relation
nodes are written (RelationType), and arcs between them are
written →. The whole graph is wrapped in
<<< and >>>.
g: <<<[Person: "John"] → (Loves) → [Person: "Mary"]>>>
println("Concepts:", g show concepts)
println("Relations:", g show relations)
println("Arcs:", g show arcs)Running this produces:
Concepts: [{id: "...", referent: "John", referent_type: "individual", type: "Person"},
{id: "...", referent: "Mary", referent_type: "individual", type: "Person"}]
Relations: [{arity: 2, id: "...", type: "Loves"}]
Arcs: [{from: "...john-id...", position: 0, to: "...loves-id..."},
{from: "...mary-id...", position: 1, to: "...loves-id..."}]
The g show concepts / g show relations /
g show arcs queries are Axioma’s natural-language
introspection form for CGs. They return arrays of records — easy to
iterate, easy to dump as JSON, easy to feed into the next processing
stage.
Bracket notation distinguishes:
[Person: "John"]
— refer to a specific named individual.[Animal] (no colon)
— refer to some member of the type, unspecified.This matters because the meaning of a CG is different in the
two cases. [Animal] → (IsA) → [Mammal] is a
universal statement (“animals are mammals” — or more strictly,
“there exists an animal-that-is-a-mammal”).
[Animal: "Fido"] → (IsA) → [Mammal] is a
particular statement (“Fido is a mammal”).
universal: <<<[Animal] → (IsA) → [Mammal]>>>
particular: <<<[Animal: "Fido"] → (IsA) → [Mammal]>>>CGs naturally express chains of related concepts:
tax_chain: <<<[Cat: "Yojo"] → (IsA) → [Mammal] → (IsA) → [Animal]>>>
println("Concepts:", tax_chain show concepts)Three concepts (Cat: "Yojo", Mammal,
Animal), two relation instances (both IsA,
arity 2). Yojo is a Cat, Cat is a Mammal, Mammal is an Animal — and the
graph makes the chain visible as structure, not as
inference.
(Note: taxonomy and chain are both reserved
words in Axioma — they live in the concept-formation vocabulary — so
this chapter uses neutral names like tax_chain when binding
a CG variable.)
A small kinship graph:
family: <<<[Person: "Alice"] → (MotherOf) → [Person: "Bob"]>>>
println("Concepts:", family show concepts)
println("Relations:", family show relations)
println("Arcs:", family show arcs)This says, in one line: Alice is the mother of Bob.
To express more relationships, build more graphs and combine them. The CG formalism doesn’t require you to cram everything into one graph — it’s natural to keep each fact as a small graph and let the relation-store do the indexing.
Every CG has a corresponding first-order logic formula. Sowa’s original 1976 paper proves the formalisms are expressively equivalent — they’re two notations for the same logical content. The translation is mechanical:
| CG | FOL |
|---|---|
[Person: "John"] |
Person(John) |
[Animal] |
∃x. Animal(x) |
[Person: "J"] → (Loves) → [Person: "M"] |
Person(J) ∧ Person(M) ∧ Loves(J, M) |
[Animal] → (IsA) → [Mammal] |
∃x. ∃y. Animal(x) ∧ Mammal(y) ∧ IsA(x, y) |
Because of this bridge, the rules you wrote in Chapter 21
(<=, <~~, ==>,
~~>) can reason over CG-derived facts just as readily as
over relation-form facts. The CG is a prettier surface for the
same underlying logical content.
Reach for a CG when:
If your question is structural (“shortest path”), use a plain graph. If your question is “is this proposition true in some context but not another?”, use a context graph (§28.5).
In 1896, Charles Sanders Peirce invented a diagrammatic notation for logic that he called existential graphs. Peirce’s claim — vindicated by 20th-century logicians — was that EGs are not just an alternative to algebraic logic but a better one for revealing what implication means. His student Frederik Stjernfelt called EGs “the most elegant logical system ever devised.”
In Peirce’s system, the work surface is a literal sheet of paper — the Sheet of Assertion. Anything written on the sheet is asserted to be true. Two statements written side-by-side are conjoined — the sheet expresses logical AND by adjacency.
sheet: eg_alpha("Two facts on one sheet")
p: eg_predicate("Sunny", 0, 100.0, 100.0)
q: eg_predicate("Warm", 0, 300.0, 100.0)
eg_add_predicate(sheet, p)
eg_add_predicate(sheet, q)
println(eg_format(sheet))This asserts Sunny AND Warm — two predicates living on the same sheet at the same nesting depth.
Peirce’s most striking move: negation is an enclosed
oval. To assert not P, draw a closed curve (a
cut) around the predicate P. Anything inside a cut
is denied by the sheet.
sheet2: eg_alpha("Not raining")
rain: eg_predicate("Raining", 0, 100.0, 100.0)
eg_add_predicate(sheet2, rain)
cut: eg_cut(80.0, 80.0, 150.0, 50.0)
eg_add_cut(sheet2, cut)
println(eg_format(sheet2))A predicate Raining inside a cut asserts not
Raining. The cut is the negation operator made visible.
Double negation cancels. A predicate enclosed in two nested cuts is back to being asserted. Peirce called this the double-cut elimination rule — one of the five basic inference rules for EGs.
The propositional fragment (just predicates and cuts) is called Alpha. To get full first-order logic, Peirce added lines of identity — heavy lines connecting two predicate-arguments that share an individual.
In Axioma, beta graphs are constructed with eg_beta:
beta: eg_beta("All humans are mortal")
h: eg_predicate("Human", 1, 100.0, 200.0)
m: eg_predicate("Mortal", 1, 300.0, 200.0)
eg_add_predicate(beta, h)
eg_add_predicate(beta, m)
println(eg_format(beta))A line connecting the argument of Human(x) to the
argument of Mortal(x) says: the x in Human is the same
x as in Mortal. The line is Peirce’s way of expressing
variable-sharing without writing a variable letter.
Peirce went on to add dashed cuts for modal operators — necessity, possibility, knowledge, belief. This level is called Gamma.
gamma: eg_gamma("Modal logic")
p: eg_predicate("Lives_underwater", 1, 200.0, 200.0)
eg_add_predicate(gamma, p)
modal_cut: eg_cut(180.0, 180.0, 180.0, 50.0, "dashed")
eg_add_cut(gamma, modal_cut)
println(eg_format(gamma))A dashed cut wraps a modal claim: this graph reads “it is possible that something lives underwater” (or under a different convention, “necessarily…”). The exact modal reading depends on the cut style and the surrounding context.
Modus ponens — from P and P→Q, infer Q — looks dramatic in
EG notation because implication is not a primitive. In
EG, P → Q is expressed as ¬(P ∧ ¬Q), which is
a cut containing P and an inscribed cut around Q.
mp: eg_alpha("Modus ponens: if P then Q")
p_pred: eg_predicate("P", 0, 100.0, 100.0)
q_pred: eg_predicate("Q", 0, 300.0, 100.0)
# The structure is: outer cut around (P + inner cut around Q)
outer_cut: eg_cut(50.0, 50.0, 400.0, 100.0)
inner_cut: eg_cut(250.0, 80.0, 120.0, 50.0)
eg_add_predicate(mp, p_pred)
eg_add_predicate(mp, q_pred)
eg_add_cut(mp, outer_cut)
eg_add_cut(mp, inner_cut)
println(eg_format(mp))Reading: “not (P and not-Q)” — equivalently, “if P then Q”. Every implication in EG is a double-cut structure.
Peirce’s complete inference system for EGs has only five rules. Each is a visual transformation:
| Rule | What it does |
|---|---|
| Insertion | At an odd depth, insert anything. (Logic on negative areas allows weakening.) |
| Erasure | At an even depth, erase anything. (Logic on positive areas allows weakening from premise to conclusion.) |
| Iteration | Copy a graph element into a nested area. (Reuse a premise inside a deeper context.) |
| Deiteration | Erase an iterated copy. (The inverse of iteration.) |
| Double-cut | Two concentric cuts with nothing between them can be added or removed. (Double negation cancels.) |
Axioma’s eg_insert_cut, eg_erase_cut, and
eg_iterate implement three of these directly. Together they
make EGs not just a notation but an executable logic — you can
perform inference by visual transformation, which is what makes Peirce’s
system so striking.
Reach for an EG when:
If your reasoning needs to plug into Axioma’s strict- and-defeasible rule engine, use the relation/rule form from Chapter 21 — EGs are a parallel logic surface, not a feeder for the main rule pipeline.
A context graph is John McCarthy’s
ist(c, p) formalism made first-class: every assertion lives
inside a named context, and the same proposition can carry
different truth values in different contexts. Context graphs are the
workhorse of multi-perspective reasoning in Axioma and the foundation of
Cascade’s source- provenance layer.
ist(c, p)McCarthy proposed ist(c, p) as a primitive that reads
“in context c, proposition p is true.” This
single primitive turns out to be enough to express:
ist(alice_believes, P) says
Alice believes P, without committing the system itself to P.ist(cnn_reports, P) vs.
ist(reuters_reports, P) lets two news sources disagree
without contradicting the system.ist(under_hypothesis_H, P) lets you reason
hypothetically.ist(year_1990, P)
vs. ist(year_2025, P) lets the same proposition change
truth value over time.ist(c, p).g: context_graph("worldmodel", "World Model")
fiction: context_create(g, "fiction", "Fiction", "domain", "b4")
reality: context_create(g, "reality", "Reality", "domain", "b4")The four positional arguments to context_create are:
temporal,
epistemic, source, hypothetical,
domainb4 (Belnap),
kleene, luka (Łukasiewicz), or
boolEach context can therefore carry its own logic system. Use B4 when truth values can be paraconsistent. Use Kleene K3 when “unknown” is meaningful. Use Łukasiewicz L3 when you need continuous truth.
context_assert(g, "fiction", "sherlock", "lives_at_baker_street", "true", 1.0)
context_assert(g, "reality", "sherlock", "lives_at_baker_street", "false", 0.99)The signature of context_assert is:
context_assert(graph, ctx_id, element_id, fact, truth, confidence)
Where truth is one of "true",
"false", "both" (B4 paraconsistent), or
"neither" (B4 gappy), and confidence is a
float in [0, 1].
So we’ve recorded that Sherlock lives at Baker Street is true in fiction and false in reality. No contradiction — different contexts.
Contexts form a directed acyclic graph. A child context inherits its parent’s assertions; this is what makes multi-context reasoning scale beyond hand-curated tables.
g: context_graph("hier", "Intelligence Hierarchy")
global_ctx: context_create(g, "global", "Global", "domain", "b4")
western: context_create(g, "western", "Western Sources", "epistemic", "b4")
eastern: context_create(g, "eastern", "Eastern Sources", "epistemic", "b4")
cia: context_create(g, "cia", "CIA", "epistemic", "b4")
mossad: context_create(g, "mossad", "Mossad", "epistemic", "b4")
context_set_parent(g, "western", "global")
context_set_parent(g, "eastern", "global")
context_set_parent(g, "cia", "western")
context_set_parent(g, "mossad", "eastern")The graph now has a tree:
global
├── western
│ └── cia
└── eastern
└── mossad
When you context_project a context, you get its own
assertions plus all inherited assertions from its
ancestors:
context_assert(g, "global", "iran", "nuclear program active", "true", 0.95)
context_assert(g, "western", "iran", "breakout < 6 months", "true", 0.70)
context_assert(g, "cia", "iran", "enrichment at 60%", "true", 0.90)
context_assert(g, "mossad", "iran", "enrichment at 60%", "true", 0.95)
context_assert(g, "mossad", "iran", "breakout < 6 months", "false", 0.85)
println(context_project(g, "cia"))This prints the CIA’s full picture — its own assertions
(enrichment at 60%), Western’s
(breakout < 6 months), and Global’s
(nuclear program active). No need to merge by hand.
Two complementary operations move facts between contexts:
context_lift(g, from, to, element, fact)
— promotes a fact upward. Use when one analyst’s assessment
becomes authoritative for everyone.context_lower(g, from, to) —
propagates parent defaults to a child. Use when you want a
child context to inherit explicitly (so subsequent edits to the parent
don’t affect the child).context_lift(g, "mossad", "global", "iran", "enrichment at 60%")
println(context_project(g, "global"))
# Now Mossad's enrichment assessment is global.
lowered: context_lower(g, "global", "eastern")
println("Lowered " + str(lowered) + " facts to eastern context")What is the true truth of a proposition that’s claimed in
multiple contexts with different values? Axioma’s
context_resolve collapses across contexts using Belnap’s
knowledge join:
println(context_resolve(g, "iran", "breakout < 6 months"))
# Western says TRUE @0.70, Mossad says FALSE @0.85
# → B both (85% confidence)A true and a false from two contexts
resolve to both (the Belnap paraconsistent value).
Resolution preserves the disagreement rather than averaging it
away — that’s the right behavior for intelligence fusion, where “two
sources disagree” is itself a fact you want to act on.
The other resolution strategies — credulous
(highest-confidence wins), skeptical (any disagreement →
unknown), prioritized (defer to a specific context) — are
configurable via the context_resolve extended-form
signature.
Two further operations support multi-context workflows:
context_diff(g, c1, c2) — returns
three lists: facts only in c1, only in c2, and
conflicting (same fact, different truth values).context_merge(g, c1, c2, new_id, strategy)
— creates a new context combining c1 and c2.
The strategy is one of "union",
"intersection", "priority", or
"b4_join".Use context_diff for audit trails — what
changed between analyst reports? Use context_merge for
synthesis — what’s the union of CIA and Mossad’s
assessments?
Reach for a context graph when:
If you don’t need multi-context reasoning, plain graphs and CGs are simpler. Context graphs are the answer when the question is “according to whom?”
The taxonomy resolves to a four-row decision table:
| Need | Use |
|---|---|
| Shortest path / centrality / clustering / cycles | Plain graphs (Chapter 27) |
| Typed concepts + n-ary relations + FOL bridge | Sowa Conceptual Graphs |
| Diagrammatic logic + negation as enclosure | Peirce Existential Graphs |
| Multi-context truth + beliefs / sources / hypotheses | Context Graphs |
These are complementary, not competing. A real knowledge-representation system uses several of them side-by-side. Cascade — Volume III — uses plain graphs for traversal and centrality, CGs for typed entity-relation extraction from natural language, and context graphs for source provenance.
A few patterns appear repeatedly in practice:
Plain + Context. Run BFS over a plain graph; record the truth values of the edges in a context graph. Each context represents one analyst’s view; the plain graph carries the structure.
CG + Context. A CG captures the structure of a claim (“Acme acquired Beta on date D for amount A”); the context graph records whether the claim is true per source. CGs supply the schema; contexts supply the truth.
EG + Cascade reasoning. Use EGs for teaching the formal logic that underlies Cascade’s reasoning rules; use Cascade’s actual rule engine to do the inference at scale.
The four graph kinds are layers — pick the layer that matches the question, and let other layers contribute what they’re best at.
Build a Sowa CG family representing Mary is the
mother of Alice, using concepts of type Person and a
relation MotherOf. Print its concepts, relations, and
arcs.
Solution:
family: <<<[Person: "Mary"] → (MotherOf) → [Person: "Alice"]>>>
println("Concepts:", family show concepts)
println("Relations:", family show relations)
println("Arcs:", family show arcs)Expected (IDs vary):
Concepts: [{id: "...", referent: "Mary", referent_type: "individual", type: "Person"},
{id: "...", referent: "Alice", referent_type: "individual", type: "Person"}]
Relations: [{arity: 2, id: "...", type: "MotherOf"}]
Arcs: [{from: "<mary>", position: 0, to: "<motherof>"},
{from: "<alice>", position: 1, to: "<motherof>"}]
Build a CG showing the chain Yojo → IsA → Cat → IsA → Mammal → IsA → Animal. Count how many concepts and relations it has.
Solution:
tax_chain: <<<[Cat: "Yojo"] → (IsA) → [Mammal] → (IsA) → [Animal]>>>
chain_concepts: tax_chain show concepts
chain_relations: tax_chain show relations
println("Concepts:", len(chain_concepts))
println("Relations:", len(chain_relations))Expected: 3 concepts (Yojo, Mammal,
Animal — note that Cat is the type of
the Yojo concept, not a concept itself), 2 relation instances (both
IsA).
Build an alpha EG asserting not P. Use one predicate and one cut. Print the graph.
Solution:
neg: eg_alpha("Not P")
p: eg_predicate("P", 0, 100.0, 100.0)
eg_add_predicate(neg, p)
cut: eg_cut(80.0, 80.0, 100.0, 50.0)
eg_add_cut(neg, cut)
println(eg_format(neg))Expected: a graph with 1 predicate P/0 and 1 cut. The
cut around the predicate is the negation operator. The formatted output
shows the predicate at one depth and the cut at the same depth, which
together encode “not P.”
Build an alpha EG that encodes P → Q using only
predicates and cuts (no implication primitive). Use two predicates and
two cuts.
Solution:
mp: eg_alpha("If P then Q")
p_pred: eg_predicate("P", 0, 100.0, 100.0)
q_pred: eg_predicate("Q", 0, 300.0, 100.0)
outer_cut: eg_cut(50.0, 50.0, 400.0, 100.0)
inner_cut: eg_cut(250.0, 80.0, 120.0, 50.0)
eg_add_predicate(mp, p_pred)
eg_add_predicate(mp, q_pred)
eg_add_cut(mp, outer_cut)
eg_add_cut(mp, inner_cut)
println(eg_format(mp))The structure encodes ¬(P ∧ ¬Q), which is
P → Q. The outer cut means “not (the conjunction inside)”;
the inner cut around Q means “not Q”; their combination
means “not (P and not-Q)” — which is exactly material implication.
Build a context graph with two contexts (fiction and
reality). Assert that Sherlock lives at Baker
Street is true in fiction and false in
reality. Project each context and print what it sees.
Solution:
g: context_graph("sherlock_graph", "Sherlock World Model")
fic: context_create(g, "fiction", "Fiction", "domain", "b4")
rea: context_create(g, "reality", "Reality", "domain", "b4")
context_assert(g, "fiction", "sherlock", "lives_at_baker_street", "true", 1.0)
context_assert(g, "reality", "sherlock", "lives_at_baker_street", "false", 0.99)
println("Fiction view:")
println(context_project(g, "fiction"))
println("Reality view:")
println(context_project(g, "reality"))Expected: fiction shows the proposition as true, reality
shows it as false. Both are simultaneously recorded;
neither overrides the other.
Extend Exercise 28.5: add a third context
(literary_critic) that asserts the proposition as
both (paraconsistent — the critic sees Sherlock as
fictionally-living and historically-not-living at the same
time). Then call context_resolve over all three and
report the result.
Solution:
g: context_graph("sherlock_three", "Three Views")
fic: context_create(g, "fiction", "Fiction", "domain", "b4")
rea: context_create(g, "reality", "Reality", "domain", "b4")
crit: context_create(g, "literary_critic", "Critic", "domain", "b4")
context_assert(g, "fiction", "sherlock", "lives_at_baker_street", "true", 1.0)
context_assert(g, "reality", "sherlock", "lives_at_baker_street", "false", 0.99)
context_assert(g, "literary_critic", "sherlock", "lives_at_baker_street", "both", 0.85)
println("Resolution:")
println(context_resolve(g, "sherlock", "lives_at_baker_street"))Expected: the resolution returns a Belnap B both value, reflecting that the proposition is simultaneously true (in fiction) and false (in reality). The B4 knowledge join is the principled way to combine contradictory truth values without collapsing them to “unknown” or picking one arbitrarily.
The four-kind taxonomy is, in the end, a recognition that “graph” is a family of formalisms, each tuned to a different question. Internalize three things.
One — plain graphs do structure, not semantics. A plain graph tells you how things are connected, not what they mean. The moment your question becomes “what kind of entity is this?” or “is this true?”, you’ve left plain-graph territory.
Two — Sowa CGs are the FOL bridge. When you need typed concepts and n-ary relations, CGs give you a clean surface that maps mechanically to FOL. Anything you can write as a CG, you can prove about with the rule engine from Chapter 21.
Three — context graphs are the source of truth- per-perspective. The single hardest engineering problem in knowledge representation is whose truth? Context graphs answer it explicitly. Every fact has a context; resolution across contexts is principled (B4 knowledge join) and preserves disagreement.
Peirce’s existential graphs are the outlier of the four — they’re not for storing knowledge but for reasoning visually about logic itself. They’re the right tool when you’re teaching, when you need to think through an inference move on a whiteboard, or when you want to show non-mathematicians what “implication” really means.
The next chapter takes a parallel turn: when knowledge is geometric rather than relational, you reach for conceptual spaces (Chapter 29) — Gärdenfors’s formalism for prototypes, similarity, and convex regions in quality dimensions. Different geometry, same idea: the right structure for the right question.
“The graph is not the territory.” — Borrowed from Korzybski. Different graph formalisms map different parts of the territory. Axioma lets you keep the territory; the choice of formalism is the choice of map.
Solutions to selected exercises: Chapter 28 · Solutions in Appendix C. (Repo file: exercises/solutions/ch28_solutions.md.)
What this chapter is. Peter Gärdenfors’s conceptual spaces are a geometric alternative to the graph-based KR you saw in Chapter 28. Where Sowa’s CGs ask which entity relates to which, Gärdenfors asks where each entity sits in a continuous space of qualities. The payoff is that similarity becomes distance and categories become regions — both intuitions that graphs handle awkwardly. Axioma exposes ~15 builtins for creating quality dimensions, anchoring prototypes, defining convex regions, and asking distance and membership questions. This chapter walks the API, the theory, and the cases where graph thinking can’t answer what spatial thinking answers in one query.
Gärdenfors’s 2000 book Conceptual Spaces: The Geometry of Thought argued that human concepts have gradient structure that propositional and graph-based representations can’t capture. Three observations drove the framework:
Gärdenfors’s solution: model meaning as geometry. A concept lives in a quality-dimension space. Colors live in HSV (hue, saturation, value). Sounds live in (pitch, loudness, timbre). Tastes live in (sweet, sour, bitter, salty, umami). A category is a convex region of that space, centered on a prototype. Similarity is distance. Membership is degree of containment.
This is not a metaphor: the framework is operational, and Axioma exposes it as a working API with normalized metrics, circular dimensions, fuzzy membership, and interpolation along the manifold.
Axioma’s conceptual-spaces vocabulary is a functional
surface, not a natural-language one. (The
ColorSpace extends ConceptualSpace form from the pilot
draft does not actually work — see §29.10 for the gap.) You build a
space step-by-step with builtin calls:
color: conceptual_space("Color", "perceptual HSV space")
add_dimension(color, "Hue", 0, 360, "circular", true)
add_dimension(color, "Saturation", 0, 100)
add_dimension(color, "Value", 0, 100)
add_prototype(color, "Red", [0, 100, 50], "salience", 1.0)
add_prototype(color, "Green", [120, 100, 50], "salience", 1.0)
add_prototype(color, "Blue", [240, 100, 50], "salience", 1.0)
add_prototype(color, "Yellow", [60, 100, 50], "salience", 0.9)Three subtleties hidden in those eight lines:
add_dimension and
add_prototype take keyword options after the
required positional args. "circular", true and
"salience", 0.9 are key/value pairs. Passing a bare 5th
positional (e.g. add_dimension(..., true)) is
silently ignored — no error, just the default. This is
a UX trap; see the notes after each table below.conceptual_space(name, desc) constructs a
value, not a Concept. You can’t use dot notation
(color.dimensions errors). Use the *_of
builtins instead: dimensions_of(color),
prototypes_of(color), etc.The 15 working builtins:
| Builtin | Signature | Returns |
|---|---|---|
conceptual_space(name, [desc]) |
Construct a new space | *ConceptualSpace |
add_dimension(s, name, min, max, [kw...]) |
Add a quality dimension | Null (mutates) |
add_prototype(s, name, coords, [kw...]) |
Anchor a prototype point | Null (mutates) |
add_region(s, name, type, definition) |
Add a named region | Null (mutates) |
dimensions_of(s) |
Array of dimensions | Array<Dimension> |
prototypes_of(s) |
Array of prototypes | Array<Prototype> |
regions_of(s) |
Array of regions | Array<Region> |
space_to_concepts(s) |
All prototypes as records | Array<Record> |
distance(s, p1, p2) |
Normalized Euclidean (or circular) | Float |
nearest_prototype(s, point) |
Name of closest prototype | String |
in_prototype(s, point, name) |
Boolean membership (radius-checked) | Boolean |
prototype_membership(s, point, name) |
Fuzzy membership in [0, 1] | Float |
interpolate(s, p1, p2, t) |
Linear interp at parameter t∈[0,1] | Array |
interpolate_path(s, [points], steps) |
Multi-point smooth path | Array of arrays |
between(s, point, p1, p2) |
Is point between p1 and p2? | Boolean |
visualize_space(s) |
ASCII summary string | String |
space_stats(s) |
Record with counts + metric | Record |
Keyword args on add_dimension:
"circular", true (wrap-around metric),
"unit", "kg" (display only), "weight", 0.5
(importance multiplier), "normalized", true (rescale to
[0,1] internally).
Keyword args on add_prototype:
"salience", 0.9 (importance multiplier in distance),
"radius", 20 (extent — turns point into a ball).
A quality dimension is one axis of variation along which two stimuli can differ. Wavelength is a dimension. Pitch is. Sweetness is. Spatial position is.
The empirical content of the framework lies in the claim that real dimensions come in small natural bundles:
The dimensions in each bundle are not independent in any useful psychological sense — moving along one is psychologically distinct from moving along another, and distance has to reflect that.
Axioma represents this with a per-dimension weight
(salience multiplier) and circular flag:
mood: conceptual_space("Mood", "Russell circumplex")
add_dimension(mood, "Valence", -100, 100, "weight", 1.0)
add_dimension(mood, "Arousal", -100, 100, "weight", 1.0)
add_prototype(mood, "Excited", [60, 80]) # high val, high aro
add_prototype(mood, "Calm", [60, -40]) # high val, low aro
add_prototype(mood, "Angry", [-60, 80]) # low val, high aro
add_prototype(mood, "Sad", [-50, -50]) # low val, low aro
println(nearest_prototype(mood, [70, 70])) # → "Excited"
println(nearest_prototype(mood, [-70, -20])) # → "Sad"A circular dimension makes distance wrap modularly:
println(distance(color, [0, 100, 50], [350, 100, 50]))
# → 0.0278 — very near (hue wraps around through 360)
println(distance(color, [0, 100, 50], [180, 100, 50]))
# → 0.5 — antipode on the hue circle, maximum hue distanceWithout "circular", true the distance would have been
350/360 ≈ 0.97 for the first pair — wrong, because
350° and 0° are both red. The flag is load-bearing.
A prototype is a named point anchoring a category. It records:
[0, 1] (how much weight
this prototype carries in nearest-prototype voting),Prototype theory comes from Eleanor Rosch’s 1970s work on basic-level categories. The empirical observation:
Gärdenfors’s bet: those judgments are distance from
prototype. Axioma’s prototype_membership computes exactly
that, returning a fuzzy value in [0, 1]:
salmon: [15, 70, 70] # pinkish color
println(prototype_membership(color, salmon, "Red")) # approximately 0.81
println(prototype_membership(color, salmon, "Yellow")) # approximately 0.80
println(prototype_membership(color, salmon, "Green")) # 0.7650993858356716Membership decays smoothly with distance, so two prototypes can both have partial claim on a point — the mathematical model of fuzzy categorization.
Some categories aren’t a single point. Warm colors isn’t a prototype — it’s a region covering reds, oranges, yellows. Edible isn’t centered at one taste — it covers a wide convex slice of the taste manifold.
add_region records a named region with a definition:
cs: conceptual_space("Color2D", "")
add_dimension(cs, "Hue", 0, 360, "circular", true)
add_dimension(cs, "Saturation", 0, 100)
# A convex region: hue in 30°-90°, saturation > 50
add_region(cs, "Yellowish", "convex", [[30, 90], [50, 100]])
# A radial region: ball of radius 50 around a point
add_region(cs, "NearRed", "ball", [0, 100])
println(regions_of(cs))
# → [Region(Yellowish: convex), Region(NearRed: ball)]The type argument is a string tag
("convex", "ball"). The
definition is a list whose interpretation depends on the
type. Caveat: region semantics are not yet fully
load-bearing in queries — regions_of returns them, but
there’s no in_region builtin yet (the closest analogue is
in_prototype with radius set, which uses a
ball not a polytope).
The metric on a conceptual space is normalized Euclidean:
where n is the number of dimensions, w_i is
the salience weight on dimension i, and the sum is taken over
all dimensions — with the special rule that circular dimensions
use min(|p - q|, range - |p - q|) in place of the bare
difference.
Why normalized. Spaces with different numbers of
dimensions become comparable. A distance of 0.5 in a 2D
space and a distance of 0.5 in a 5D space mean roughly the
same thing — both are “half-range”.
Why domains matter. The original Conceptual
Spaces book distinguishes integral dimensions from separable ones.
Axioma now exposes that distinction with add_domain:
dimensions grouped into a quality domain are measured inside the domain
first, then domain distances are combined by weighted sum. A space with
no declared domains keeps the old flat Euclidean default.
Why space_similarity() has an explicit
name. Axioma keeps concept-level
similarity(concept1, concept2) for prototype/ontology
comparisons, and uses space_similarity(space, p1, p2) for
coordinate geometry. The same split applies to
typicality(concept) versus
space_typicality(space, point).
Two builtins that have no graph-based analogue:
interpolate(s, p1, p2, t) — gives you
the point at parameter t ∈ [0, 1] on the straight line from p1
to p2. t = 0 returns p1, t = 1 returns p2, t =
0.5 the midpoint.
mid: interpolate(color, [0, 100, 50], [120, 100, 50], 0.5)
println(mid) # → [60, 100, 50] — green-yellow midpoint
println(nearest_prototype(color, mid)) # → "Yellow"The point between Red and Green is Yellow. That’s a content claim about the perceptual manifold. It’s true because hue is circular and we set the prototypes at the canonical RGB primaries — geometry expresses the empirical fact.
between(s, point, p1, p2) — Boolean: is
point on or near the line from p1 to
p2?
println(between(color, [60, 100, 50], [0, 100, 50], [120, 100, 50]))
# → true (Yellow IS between Red and Green)
println(between(color, [240, 100, 50], [0, 100, 50], [120, 100, 50]))
# → false (Blue is not between Red and Green)interpolate_path generalizes to
multi-point sequences, useful for animation or smooth scrubbing through
a gradient.
Build a 3D taste space, anchor four prototypes, then classify novel points:
taste: conceptual_space("Taste", "primary tastes")
add_dimension(taste, "Sweet", 0, 100)
add_dimension(taste, "Sour", 0, 100)
add_dimension(taste, "Bitter", 0, 100)
add_prototype(taste, "Lemon", [15, 95, 5], "salience", 1.0)
add_prototype(taste, "Coffee", [10, 5, 90], "salience", 1.0)
add_prototype(taste, "Honey", [95, 5, 5], "salience", 1.0)
add_prototype(taste, "DarkChoc", [50, 10, 70], "salience", 0.8)
# A mystery item with moderate sweet, low sour, high bitter
mystery: [40, 15, 75]
println(nearest_prototype(taste, mystery)) # → "DarkChoc"
println(prototype_membership(taste, mystery, "DarkChoc")) # 0.9317314234233945
println(prototype_membership(taste, mystery, "Coffee")) # 0.8170356137292041
println(prototype_membership(taste, mystery, "Lemon")) # 0.5323383419690717
# A blend point on the line from Lemon to Honey at 30%
lh_blend: interpolate(taste, [15, 95, 5], [95, 5, 5], 0.3)
println(lh_blend) # [39.0, 68.0, 5.0]
println(nearest_prototype(taste, lh_blend)) # → "Lemon" stillNotice how one query answers what graph KR would need a custom rule for: “which named category is this point most like?” The geometric formalism gives the answer directly.
Four gaps worth knowing about:
ColorSpace create /
extends ConceptualSpace /
has dimension: Hue from 0 to 360, circular. None of
those actually work today — the ConceptualSpace-as-Concept path errors
out. Use the functional API throughout.add_region and regions_of work, but there’s no
in_region builtin. Use
in_prototype(s, p, name) with radius set for
ball regions; convex polytopes have no query yet.add_dimension and add_prototype switch to
keyword args after the required positionals. Passing
add_prototype(s, name, coords, 0.9) silently uses salience
1.0, not 0.9. You must write "salience", 0.9.in_prototype returns over-generously.
The radius is normalized by sqrt(n_dimensions), which means
a radius of 20 in a 2D space with [0,100] axes effectively covers the
whole space. If you want crisp ball regions, use
prototype_membership > threshold instead.These are documentation gaps and UX gaps, not
theory gaps — the underlying math is sound, the API just hasn’t been
polished. Treat the functional core (distance,
space_similarity, nearest_prototype,
prototype_membership, interpolate,
between) as production-ready and the surface around it as a
work in progress.
| Question | Spaces win | Graphs win |
|---|---|---|
| “X is-a Y?” | — | ✓ subclass chains |
| “X relates to Y how?” | — | ✓ labeled edges |
| “How similar is X to Y?” | ✓ distance | hard |
| “Which prototype is X closest to?” | ✓ nearest_prototype | needs custom code |
| “Is X a typical member of Y?” | ✓ prototype_membership | hard |
| “Generate a smooth transition X → Y” | ✓ interpolate | impossible |
| “Is Z between X and Y in some quality?” | ✓ between | impossible |
| “Which N items cluster?” | ✓ k-means on coords | needs external metric |
Use both. Chapter 28 showed Sowa CGs for structural KR. This chapter shows conceptual spaces for gradient KR. Production systems like Cascade often run both representations side by side: a graph for “Tesla is a kind of Car” and a conceptual space for “this customer’s preference vector is closest to Cluster #4”.
Construct a 2D conceptual space Mood with dimensions
Valence ∈ [-100, 100] and
Arousal ∈ [-100, 100]. Add four prototypes: Excited (60,
80), Calm (60, -40), Angry (-60, 80), Sad (-50, -50). Classify three
novel points: (70, 70), (0, 0), (-30, 60). Print the nearest prototype
and the membership scores against all four.
Build a 1D Hue space with one dimension
Hue ∈ [0, 360] circular. Add prototype
Red at [0]. Compute the distance from Red to
each of [10], [180], [350],
[359]. Then build a second 1D space with the same
prototypes but Hue not circular and compute the
same four distances. Print both sets, side by side. Explain the
difference in one sentence.
In the taste space from §29.9, generate the path from Lemon to Coffee
via interpolate_path([Lemon, DarkChoc, Coffee], 5). Print
each waypoint and its nearest_prototype. Does the sequence
move smoothly through DarkChoc as the midpoint?
Build a 3D color space (HSV) with prototypes Red, Green, Blue at the
canonical positions and "radius", 30 each. For ten points
spaced evenly along the hue circle (0°, 36°, 72°, …, 324°), print which
prototypes claim it (membership > 0.7). Some points should have
no claim; some should have multiple.
In a 2D space with dimensions X and Y, both
[0, 100], add two prototypes: Heavy at
[10, 10] with "salience", 1.0 and
Light at [10, 90] with
"salience", 0.3. Classify the point [10, 50].
Then swap the saliences and reclassify the same point. The nearest
prototype should change. Explain why — what exactly does salience do to
the metric?
Pick a domain you know well — programming languages, music genres, food cuisines, political stances, customer segments. Sketch:
Then ask the framework three classification questions of real items in your domain. Reflect: where do the prototypes feel right? Where do they feel arbitrary? Where would a latent (data-driven) dimension serve better than a human-named one?
Solutions to selected exercises: Chapter 29 · Solutions in Appendix C. (Repo file: exercises/solutions/ch29_solutions.md.)
conceptual_space,
add_dimension, add_prototype,
add_region, plus query builtins distance,
nearest_prototype, prototype_membership,
interpolate, between.prototype_membership) is the
cognitively realistic alternative to crisp set inclusion.The next pilot to promote — Ch.31 Lojban-in-Axioma — returns to relational/predicate KR with named places (selbri) and a bridge to FOL. After that, Volume III continues with Cascade-specific chapters that combine graphs and spaces with neural signals.
“To be is to be the value of a bound variable.” — Quine. Gärdenfors’s reply: to be is also to sit somewhere in a quality space. Both ontologies pay rent.
What this chapter is. Gödelization is the trick Kurt Gödel used in his 1931 incompleteness proofs: encode a piece of syntax — a formula, a proof, an entire program — as a single large integer, in a way that the encoding and decoding are themselves computable arithmetic. The trick is the bridge between symbolic reasoning (“is this proof valid?”) and arithmetic reasoning (“does this number satisfy this arithmetic predicate?”). It underlies the incompleteness theorems, the halting problem, the recursion theorem, quine programs, and modern zero-knowledge proofs. This chapter covers the trick, the mathematical move it enables, and the working Axioma surface (
encodeanddecode) you can use today.
Every program is, ultimately, a sequence of symbols. Every symbol can be assigned a unique integer code. Once you have a way to combine a sequence of integers into a single integer — reversibly — you have a way to encode any program as one big number.
Gödel’s original 1931 method used prime-factor
encoding: assign each symbol an integer code, then encode the
sequence [c₁, c₂, c₃, …, cₙ] as
2^c₁ · 3^c₂ · 5^c₃ · … · pₙ^cₙ
where pₙ is the n-th prime. By the fundamental
theorem of arithmetic, this product is unique, so the sequence can be
recovered by repeated division — no information is lost. The cost is
staggering integer growth (even a 10-symbol program produces a number
with hundreds of digits), but the principle is what
matters.
Modern variants use base-k representation or pairing
functions (Cantor’s ⟨x, y⟩ = ½(x + y)(x + y + 1) + y) that
grow more slowly. Axioma’s encode builtin uses an internal
prime-power variant backed by Go’s big.Int
arbitrary-precision integers — so encodings stay reversible no matter
how large.
Two reasons.
First — arithmetic predicates can talk about syntax.
Once a program is a number, you can ask arithmetic questions about that
number — “is N a valid encoding?”, “is N the encoding of a
program that halts on input M?” — and these questions are about
integers. They live in the same mathematical universe as
2 + 2 = 4. That’s the bridge.
Second — the encoding-and-decoding operation is itself
computable. If you can write a primitive-recursive function
decode(N) that recovers the original symbols from
N, then any arithmetic system rich enough to
express primitive recursion can, in principle, talk about programs
internally. That’s what makes the self-reference moves
work.
The Gödelization layer in Axioma is partially shipped. The minimal API that works in this build:
| Builtin | Works? | Purpose |
|---|---|---|
encode(s) |
✅ | Encode a string-of-Axioma-source as a Gödel number |
decode(g) |
✅ | Decode a Gödel number back to source |
demonstrateIncompleteness() |
✅ | Pedagogical text walkthrough of Gödel’s proof |
The following are registered but currently unreachable or unstable in the user surface:
| Builtin | Works? | Why not |
|---|---|---|
isGödel(g) / gödelValue(g) |
❌ | The ö character is rejected by the user-script
lexer |
selfEncode() |
❌ | Hangs (encoding pipeline loops on the canned self-reference string) |
diagonalize(s) |
❌ | Integer-parse error on the resulting big number |
createFormalSystem(name) |
❌ | Hangs |
provable(stmt) |
❌ | Hangs |
createGodelSentence([sys]) |
❌ | Hangs (depends on createFormalSystem) |
proveIncompleteness([sys]) |
❌ | Hangs |
searchForProof(stmt, depth) |
❌ | Hangs |
The chapter treats the working surface concretely and the broken surface conceptually — when the build is fixed, the chapter’s exercises will gain runnable solutions for the formal-system half of the material.
encode and decodeTwo primitives, one inverse pair.
gn: encode("x + 1")
println(decode(gn)) # "x + 1" — round-tripsA few invariants you can rely on.
println(decode(encode("hello")) == "hello") # true
println(decode(encode("42")) == "42") # true
println(decode(encode("x"))) # "x"For any string s that encode accepts, the
round-trip decode(encode(s)) recovers s.
encode
acceptsencode parses its input as Axioma syntax before
encoding. Valid Axioma expressions encode cleanly:
encode("x") # ok
encode("42") # ok
encode("x + 1") # ok
encode("hello") # ok — bare identifier counts
encode("true") # okStrings that aren’t well-formed Axioma may not encode:
encode("encode(\"x\")")
# → ERROR: parse errors: expected '=>' or '->' or '→' …This is a deliberate design choice — Gödelization is, at heart, encoding well-formed syntax, not arbitrary byte sequences. The encoder doubles as a syntax check.
gn_x: encode("x")
gn_y: encode("y")
println(decode(gn_x) == decode(gn_y)) # falseTwo different strings produce different Gödel numbers (modulo any AST-normalization the encoder does — see below). The inverse property — that two Gödel numbers are “equal” iff they encode the same expression — is what makes Gödelization useful as a hash with provenance.
encode parses its input as a syntax check, but
encodes the literal bytes of the source — whitespace, comments,
formatting all preserved. So two equivalent-but-differently- formatted
strings produce different Gödel numbers:
g1: encode("x + 1")
g2: encode("x + 1")
println(decode(g1)) # "x + 1"
println(decode(g2)) # "x + 1" — extra spaces preserved
println(decode(g1) == decode(g2)) # falseThe parse step is purely a validity guard (“is this parseable as Axioma?”); the encoding itself is over the byte stream. If you wanted AST-level fingerprinting (spaces ignored), you’d parse, render the canonical form, then encode — a two-step recipe the chapter doesn’t build but the principles support.
Cantor’s diagonal argument (1891) proved that there are more real numbers than natural numbers by listing a hypothetical enumeration of reals, then constructing a new real that differs from the n-th listed real in the n-th decimal place. The new real can’t be on the list — contradicting the hypothesis that the list was complete.
Gödel’s 1931 paper Über formal unentscheidbare Sätze (On formally undecidable propositions) ported Cantor’s diagonal to formal systems. The translation:
| Cantor | Gödel |
|---|---|
| Enumerate real numbers | Enumerate formulas via Gödel numbering |
| Diagonal disagrees with the n-th | G: “the formula with Gödel number n is not provable” |
| New real isn’t on the list | G is true but not in the provable set |
The translation requires that the formal system be rich enough to talk about its own Gödel numbers — i.e., it must contain enough arithmetic to define primitive recursive functions over the integers. Peano Arithmetic qualifies. Robinson Arithmetic qualifies. Anything weaker might not.
Once you can talk about your own encoding, the diagonal move produces a sentence that says of itself that it’s unprovable. By case analysis:
Either way, the system can’t be both consistent and complete. Mathematics cannot be totally mechanized.
You can see Axioma’s pedagogical narration of this move:
println(demonstrateIncompleteness())The output walks through the three steps — create a formal system, construct the Gödel sentence, prove incompleteness — and concludes with the canonical implication: “Any consistent formal system is incomplete.” Use it as a teaching aid.
Turing’s 1936 On Computable Numbers used the same diagonalization-over-Gödel-numbers move to prove the halting problem undecidable. The argument:
halts(P, I) that correctly
decides whether program P halts on input
I.halts is
really halts(g_P, g_I) — a predicate over integers.diag(g_P) = if halts(g_P, g_P) then loop else stop.
Diagonalize: ask diag about itself, on its own Gödel
number.diag(g_diag) halts, then
halts(g_diag, g_diag) says it loops. Contradiction.diag(g_diag) loops, then
halts(g_diag, g_diag) says it halts. Contradiction.The diagonalization works because Gödel numbering lets a program take another program as an integer argument. The trick is the same as Cantor’s, applied to programs-as-data instead of reals-as-digits.
This is why the halting problem can’t be solved by any algorithm. The undecidability isn’t a limit of human cleverness — it’s a structural consequence of the same move that powers all of programming-language theory.
Stephen Kleene’s 1938 recursion theorem says that every
computable transformation f of program-encodings has a
fixed point — a program P such that
f(g_P) encodes a program that behaves identically to
P itself. The proof uses Gödel numbering plus a
double-substitution trick.
Practical consequence: self-referential programs are mechanical, not magic. Once you have Gödel numbering, you can build:
The recursion theorem is why the meta-circular evaluator can interpret itself. The fixed-point argument shows that a meaningful self-interpreter must exist; Gödel numbering shows you how to construct it.
A quine is a program that prints its own source code. The classical recipe, paraphrased:
s: "s: ?; println(s with ? replaced by quoted-s)"
println(s with ? replaced by quoted-s)
The trick is the self-substitution. With Gödel numbering, quines become straightforward: a program that asks for its own Gödel number, decodes it, and prints the result.
Conceptual sketch (the Axioma
selfEncode builtin that would power this is not yet
reliable):
# Conceptual — selfEncode is not yet reliable in the current build
me: selfEncode()
my_source: decode(me)
println(my_source)Once selfEncode is fixed, this is a 3-line quine. The
mechanism is what’s important: a quine is just a fixed point of
the function “given a program-as-number, return the source-as-string.”
Gödelization makes that fixed point computable.
In the meantime, you can build quines the classical way by hand, using string manipulation:
# A working quine via string manipulation (no Gödelization)
q: "q: ; println(q)"
# Print q with the literal value of q substituted into the placeholder
# (Exercise — left as Exercise 30.4)Either route works. Gödelization is the theoretical route; classical quoting is the practical route. They illustrate the same fixed-point principle.
Three places it’s used internally — each behind the scenes, none yet exposed to user code in this build:
GödelNumberObject type and
godelEncode/godelDecode helpers in
evaluator/builtins.go.proof builtin walks the derivation chain that produced a
conclusion. In principle that chain can be Gödel-encoded into a single
compact integer for cryptographic commitment; the Cascade roadmap calls
for this in the knowledge-graph-export path.parse_axioma returns an AST, which can be Gödel-encoded for
cross-process commitment. The MCP protocol passes AST shapes (not Gödel
numbers) today; the encoder is reserved as a future optimization.You won’t usually call encode/decode
directly in CS1 / CS2 code. The presence of the primitives makes
meta-arithmetic reasoning possible when you do need it, and the
theoretical content of Gödelization is foundational to
understanding what Cascade does at the provenance and reflection
layers.
Several of the formal-system builtins (selfEncode,
isGödel, diagonalize, the
createFormalSystem / provable chain) are not
reliable in the current build — §30.3’s table is the honest
inventory.
The right way to teach Gödelization is the theoretical
treatment in §§30.4–30.7. The right way to demonstrate it
concretely is the encode/decode working pair
in §30.3 plus the demonstrateIncompleteness pedagogical
text in §30.4. Anything more — running an actual incompleteness proof
end-to-end, building a quine via selfEncode, diagonalizing
programmatically — is on the roadmap once the formal-system pipeline is
fixed.
Show that decode(encode(s)) == s for at least four
different valid Axioma expressions. Use println to display
each round-trip.
Solution:
println(decode(encode("42")) == "42") # true
println(decode(encode("x")) == "x") # true
println(decode(encode("x + 1")) == "x + 1") # true
println(decode(encode("hello")) == "hello") # trueAll four print true. The encoder normalizes whitespace
via the AST, but for these examples the canonical and input forms
agree.
Encode two different expressions. Show via decode
comparison that they produce different Gödel numbers (comparing
decodings is the user-script-friendly way to check, since the Gödel
number itself is a big.Int that doesn’t expose easily to
user code).
Solution:
gn_x: encode("x")
gn_y: encode("y")
println(decode(gn_x) == decode(gn_y)) # false
println(decode(gn_x)) # "x"
println(decode(gn_y)) # "y"Different expressions decode to different strings, so their encodings must be distinct.
Show that encode preserves whitespace by encoding two
syntactically equivalent expressions with different spacing. Compare the
decoded outputs.
Solution:
g1: encode("x + 1")
g2: encode("x + 1") # extra whitespace
println(decode(g1)) # "x + 1"
println(decode(g2)) # "x + 1" — extra spaces preserved
println(decode(g1) == decode(g2)) # falseencode parses its input for validity —
"x + 1" must be syntactically meaningful — but the
encoding itself is byte-level. The two expressions produce
different Gödel numbers because their byte representations
differ.
If you wanted whitespace-insensitive fingerprinting, you’d
canonicalize before encoding (parse → re-render in a canonical style →
encode). That’s a two-step recipe; encode alone does only
step 3.
Demonstrate the substitution move at the heart of every
quine, using replace. We won’t build a perfect quine here
(getting the escaping byte-exact across all levels of indirection is
famously fiddly — see below), but we’ll make the pattern
concrete: a string with a placeholder, where the placeholder is replaced
by the string’s own quoted form.
Solution:
q: "q: X; println(X)"
quoted_q: "\"q: X; println(X)\""
# Replace the placeholder X with the quoted form of the template
prog: replace(q, "X", quoted_q)
println(prog)This prints:
q: "q: X; println(X)"; println("q: X; println(X)")
The pattern is right — q plus a
println of q’s quoted form — but notice that
the output of running this program would not be the input:
running the printed program prints "q: X; println(X)", not
the full program we just emitted. A genuine quine requires two-level
substitution (the template substitutes itself into both its own
definition and the print statement).
The Lisp form makes the recipe transparent because of how it represents code-as-data:
((lambda (q) (list q (list 'quote q)))
'(lambda (q) (list q (list 'quote q))))
Building a byte-exact Axioma quine is a worthwhile follow- up
exercise; the path is to think of the program as a pair (template,
data) where data is the template itself, and the run
prints (template, quote(data)).
(replace(s, from, to) is Axioma’s standard
string-replace builtin; you can confirm with
replace("aXa", "X", "b") producing "aba".)
Without using Axioma at all — on paper — encode the single-character
string “P” using the classical prime-power method. Assume the symbol “P”
has code 16 (the position of P in the Latin
alphabet). What is the Gödel number?
Solution:
A single symbol with code c encodes as 2^c
(one symbol, the first prime is 2). So
P → 2^16 = 65536.
The point of doing this by hand is to feel how fast the numbers
grow. A two-symbol string "AB" with codes
(1, 2) encodes as 2^1 · 3^2 = 18. A six-symbol
string encodes to a multi-billion-digit number under the classical
scheme. Axioma’s variant grows more slowly but the phenomenon
is the same.
In your own words, give three modern applications of Gödelization. The applications can be in computer science, mathematics, philosophy, or AI safety.
Sample answers:
Gödel’s trick from 1931 is, in retrospect, one of the half-dozen moves that underwrite all of computer science.
One — syntax is data. That’s the move. Anything you can write down as a string of symbols can be a number, and arithmetic operations on that number correspond to syntactic operations on the original string. The encoding is just bookkeeping; the idea is that the universe of formal systems and the universe of arithmetic are the same universe, viewed differently.
Two — diagonalization is universal. Cantor used it on reals. Gödel used it on formulas. Turing used it on programs. Russell used it on sets. Every limitative result in mathematics and computer science is, at its core, this same trick: enumerate a class of objects, construct one that disagrees with each listed object, conclude that the class wasn’t fully listable. Gödel numbering is what makes the trick applicable to syntactic objects.
Three — the move is older than computer science but designed computer science. Gödel didn’t have computers. Turing didn’t have computers when he proved the halting problem. The encoding-syntax-as-numbers move is prior to computation; it’s what enabled the formal definition of computation that came after. Modern computer science is, in a deep sense, the industrial application of a 1931 mathematical trick.
The next chapter (Ch.31, Lojban-in-Axioma) takes a completely different turn: instead of encoding syntax as numbers, it encodes natural-language relations as syntactically unambiguous predicates with named places. Both directions — making syntax computable, making language unambiguous — are part of the same broader project: bridging the formal and the natural.
“This statement cannot be proved.” — Gödel’s Liar-paraphrase. The astonishing part isn’t that there are unprovable truths; it’s that every sufficiently rich system contains its own undecidability. Gödel numbers are how you make that fact mechanical.
Solutions to selected exercises: Chapter 30 · Solutions in Appendix C. (Repo file: exercises/solutions/ch30_solutions.md.)
What this chapter is. Lojban is the most ambitious constructed language in modern linguistics: a ~1300-word vocabulary engineered so that every sentence has exactly one syntactic parse and exactly one first-order-logic translation. Axioma adopts Lojban’s signature mechanism — the selbri with named places — as the
define relationform. This single syntactic idea pays off three ways: it documents argument ordering as part of the schema, it produces machine- checkable relational predicates, and it composes with Volume I’s Horn-rule logic-programming machinery from Chapter 21. This chapter walks the theory, the API, and the spots where the framework strains in production KR.
In 1955 James Cooke Brown proposed Loglan as a test bed for the Sapir-Whorf hypothesis: a language whose grammar was first-order logic, so that thinking in it would prove or disprove that language shapes cognition. Lojban (1987) is the descendant — a community-maintained language with:
The Sapir-Whorf experiment never produced clean results, but the engineering goal is independently valuable: a language that cannot be misparsed is a language you can run inference over without a natural-language model in the loop. That’s a kingdom in modern KR work.
The signature feature is the selbri — a predicate word that carries a fixed list of argument slots called places, each with a known semantic role.
Lojban’s gismu dunda (“gives”) has three places:
| Place | Role | Example filler |
|---|---|---|
| x1 | giver | “John” |
| x2 | gift | “book” |
| x3 | recipient | “Mary” |
Every assertion using dunda fills those three slots in
the same order. Compare to English:
Three surface forms, three different orderings of the same three roles. In Lojban there’s one surface form:
John dunda le cukta Mary(John gives the book to Mary)
And one FOL formula: dunda(John, book, Mary).
That predictability is what Axioma adopts.
define relationAxioma exposes Lojban’s named-places idea through the
define relation statement. The syntax has
four variants of increasing detail:
# Bare — argument names only
relation parent(p, c)
# With type annotations
relation age(p :: String, y :: Integer)
# With semantic roles (Lojban places)
relation gives(g -> "giver", x -> "gift", r -> "recipient")
# With both types and roles
relation cite(paper :: String -> "paper",
author :: String -> "author")The -> "role" form is the Lojban analog: it labels
each argument position with its semantic role, lifting place
structure into the language. Once defined, the relation is
first-class:
gives("John", "book", "Mary")
gives("Alice", "flower", "Bob")
gives("Carol", "book", "Dave")
# Query: who gave a book?
println({X | X <- gives(X, "book", _)})
# → {"Carol", "John"}
# Query: what did Alice give?
println({Y | Y <- gives("Alice", Y, _)})
# → {"flower"}
# Query: who received a book?
println({Z | Z <- gives(_, "book", Z)})
# → {"Dave", "Mary"}
# All three at once
println({(g, gift, r) | gives(g, gift, r)})
# → {("Alice", "flower", "Bob"),
# ("Carol", "book", "Dave"),
# ("John", "book", "Mary")}The wildcard _ matches any value. The pattern-variable
X binds the value at that position and propagates through
the comprehension.
Arity is strictly checked:
relation gives(g -> "giver", x -> "gift", r -> "recipient")
gives("John", "book", "Mary") # ok — 3 args
gives("John", "book") # ERROR — expects 3, got 2Types, by contrast, are documentation only at runtime:
relation age(p :: String -> "person", y :: Integer -> "age")
age("Alice", 30)
age("Bob", "thirty") # passes — no runtime type check
println({(P, Y) | age(P, Y)})
# → {("Alice", 30), ("Bob", "thirty")}This is a deliberate choice: the relational subsystem lets
you mix value shapes during exploratory KR work, then use
--typecheck (or a downstream linter) to enforce conformance
when you ship. For the Lojban analogy: the places contract for what
makes sense; the runtime tells the user, not the typechecker, when
something looks wrong.
The big payoff of choosing Lojban-style places is that relations defined this way are immediate premises for the Horn-rule machinery you learned in Ch.21. Strict and defeasible rules compose unchanged:
relation parent(p -> "parent", c -> "child")
parent("Alice", "Bob")
parent("Bob", "Carol")
parent("Carol", "Dave")
# Strict derivation — grandparent
grandparent(X, Z) <= parent(X, Y) and parent(Y, Z)
# Recursive derivation — ancestor
ancestor(X, Y) <= parent(X, Y)
ancestor(X, Z) <= parent(X, Y) and ancestor(Y, Z)
println({Y | Y <- ancestor("Alice", Y)})
# → {"Bob", "Carol", "Dave"} — all transitive descendants
println({X | X <- ancestor(X, "Dave")})
# → {"Alice", "Bob", "Carol"} — all ancestorsThe rule body parent(X, Y) and ancestor(Y, Z) is a
Lojban-style logical conjunction: each predicate is a selbri
call with its named places, and the shared variable Y
enforces unification across the two predicates. This is
FOL. No translation step. The relational subsystem is the
language’s FOL bridge.
Axioma includes a deeper Lojban surface in
grammar/ lojban.go that lets you read actual
Lojban-flavored prose:
s: <<John loves Mary>> # constrained-language literal
println(words_of(s)) # → ["John", "loves", "Mary"]
lj: cl_translate(s, "lojban")
println(lj) # → <<John loves Mary>> [lojban]The constrained-language literal <<...>> is
a typed container that knows what natural-language mode it’s
in. cl_translate switches modes. The deeper grammar parser
(parse_grammar) extracts bridi — Lojban
sentences with a selbri and zero or more sumti
(arguments).
Caveat: the grammar parser depends on a populated vocabulary registry that maps each English/Lojban word to its part-of-speech and (for selbri) its place structure. In a fresh Axioma session the registry is empty:
parse_grammar(<<John loves Mary>>)
# ERROR: no selbri (predicate) found in bridi
vocab_lookup("loves")
# ERROR: word 'loves' not found in vocabularyProduction systems populate the registry from a corpus of gismu
definitions before calling parse_grammar. The in-language
capability exists; the out-of-the-box data doesn’t.
See §31.9.
Three concrete wins, ordered by impact:
gives("John", "book", "Mary") and the function
signature of gives immediately tells them which
argument is the giver, which the gift, which the recipient. Compare to a
graph-edge KR where every edge type needs an out-of-band convention
document.define relation into a FOL predicate
symbol with a known signature. No NLP step.define relation affects doc: "..." update form lets you
amend documentation without breaking the schema. The compiler
can warn when two teams independently define gives with
different place structures, catching the most common form of KR
drift.Cascade’s entity-extraction pipeline lives partly in this register:
the symbolization layer in .axioma/symbolization/lojban.md
(a design doc) defines how to normalize natural-language claims into
Lojban-style predicate-with-places form before they enter the knowledge
graph. The savings compound as the graph grows.
relation cite(paper :: String -> "paper",
author :: String -> "author") doc: "P cites A"
relation wrote(author :: String -> "author",
paper :: String -> "paper")
# Some facts
cite("Pearl1988", "Russell")
cite("Pearl1988", "Norvig")
cite("Russell2010", "Pearl")
wrote("Pearl", "Pearl1988")
wrote("Russell", "Russell2010")
wrote("Norvig", "Russell2010")
# Who has authored a paper that cites someone?
println({W | wrote(W, P) and cite(P, A)})
# → {"Pearl", "Russell", "Norvig"}
# Lifted self-citation rule
self_cite(A) <= wrote(A, P) and cite(P, A)
println({A | A <- self_cite(A)})
# → {} — no self-cites in our data
# Mutual citation
mutual(A, B) <= wrote(A, P1) and cite(P1, B)
and wrote(B, P2) and cite(P2, A)
println({(A, B) | mutual(A, B)})
# → {("Russell", "Pearl"), ("Pearl", "Russell")}Notice how the named places on cite and
wrote prevent the typical bug of confusing argument order:
cite(paper, author) is unambiguously paper cites
author, not author cites paper. A graph KR with labeled
edges would document this with edge-type names (AUTHORED,
CITED_BY), but here the documentation is inline in the
call signature — the Lojban win.
Two further sugar forms:
# Trailing doc string
relation supports(party :: String -> "party",
policy :: String -> "policy")
doc: "The party publicly endorses the policy"
# Refinement — transient definition (no KB persist)
relation/transient scratch(x -> "x", y -> "y")
# Refinement — explicit persist (default in REPL)
relation/persist core_rel(p -> "p", c -> "c")The doc: annotation surfaces in the relation’s metadata
(introspectable from external tools that read the session). The
refinements /persist and /transient match the
same vocabulary you saw for bindings and axiom in Volume I
— relation definitions can be temporary scaffolding or permanent
schema.
Four gaps to be aware of:
define relation X { places: [...] }
is not implemented. The pilot draft showed this form; only the
parenthesis form with :: Type and -> "role"
actually works.vocab_lookup and parse_grammar
need a populated registry. Out of the box neither has any
Lojban or English gismu loaded.
parse_grammar(<<John loves Mary>>) produces
the error “no selbri found”. Building a Lojban-prose-reading pipeline
requires populating vocab.GlobalRegistry from a vocab file
first — outside what this chapter teaches.age("Bob", "thirty") passes runtime checks even with
y :: Integer. Use the --typecheck pre-pass
or downstream linters to enforce type conformance.relation_places("gives") or
relation_arity("gives") builtin. The arity is enforced by
the runtime but not queryable from inside Axioma.These are coverage gaps rather than design flaws — the core idea (Lojban-style places as predicate signatures) is sound and load-bearing. The richer grammar/vocab features are present but require setup work outside Axioma proper.
Define a relation taught with three places: instructor,
subject, student. Add facts that Alice taught calculus to Bob, Carol,
and Dave; that Eve taught logic to Bob. Query: who taught Bob? What did
Alice teach? How many students learned calculus?
Define relations
cite(paper -> "paper", author -> "author") and
wrote(author, paper). Build the six-fact example from
§31.7. Query: find authors that have been cited by someone they wrote a
paper with.
Define a relation
birthday(person :: String -> "person", year :: Integer -> "year").
Add three facts, including one with an Integer year and one with a
String year (e.g. "unknown"). Verify both pass. Then sketch
what a runtime type-check would look like.
Define parent(p -> "parent", c -> "child") and add
a four-generation chain Alice → Bob → Carol → Dave. Write strict rules
for grandparent and ancestor. Query: print all
grandparents, all of Alice’s descendants, and all of Dave’s
ancestors.
Define a relation loves(lover, beloved) with 2 places.
Try calling it with 1 argument and with 3 arguments. Print the error
messages. Explain why arity enforcement catches a class of bugs that
named-place semantics do not (and conversely).
Pick a domain you know well — recipes, scientific papers,
customer-support tickets, sports events, legal contracts. Define five
relations with Lojban-style places. Pay attention to place
order: which role goes in x1? Justify your ordering in
1-2 sentences per relation.
Solutions to selected exercises: Chapter 31 · Solutions in Appendix C. (Repo file: exercises/solutions/ch31_solutions.md.)
define relation adopts that signature
vocabulary with four variants (bare / typed / role- labeled /
both).--typecheck pre-pass and downstream linters
carry the contract.This closes Volume II’s Part X — Symbolic Knowledge Frontiers (Ch.28-33). Volume III continues with the Cascade architecture (Ch.35), where every primitive you just learned — graphs, spaces, NSM primes, Lojban-style places, Schank ACTs — lives side-by-side in a production knowledge graph.
“The number of places of a selbri is its valency.” — Lojban Reference Grammar. Axioma’s
define relationform makes valency part of the schema rather than part of the convention document — the discipline that compounds.
What this chapter is. Anna Wierzbicka and Cliff Goddard have spent four decades arguing for a startling claim: every human language contains a small set of ~65 semantic primes — atoms of meaning that are irreducible (you cannot define them in simpler terms) and universal (they appear in every language ever studied). Every other concept can be paraphrased using only these primes. Axioma exposes the primes as a first-class registry with three validators, a seven-language translation table, and seven grammar rules. This chapter walks the surface, then drives it with the workflow Wierzbicka herself uses: write an explication; validate; refine; iterate.
The NSM bet rests on two claims that have to hold simultaneously for the theory to be more than a curiosity:
If both claims are true, then a definition written entirely in primes is automatically translatable to every language by substitution. Cultural baggage shouldn’t intrude because the primes are too thin to carry baggage.
This is a strong claim. NSM has critics — see §32.10 — but the framework has produced ~3,000 published definitions (“explications”) across emotions, social roles, cultural keywords, ethics terms, and grammar particles. Axioma gives you the machinery to write your own.
Axioma’s registry has 66 primes (the canonical
Wierzbicka set is 65; the registry splits some BE forms
more finely) organized into 16 categories. Here is the
full inventory:
| Category | Primes |
|---|---|
| substantives | I, YOU, SOMEONE, SOMETHING, PEOPLE, BODY |
| relational substantives | KIND, PART |
| determiners | THIS, THE SAME, OTHER |
| quantifiers | ONE, TWO, SOME, ALL, MUCH, LITTLE |
| evaluators | GOOD, BAD |
| descriptors | BIG, SMALL |
| mental predicates | THINK, KNOW, WANT, DON’T WANT, FEEL, SEE, HEAR |
| speech | SAY, WORDS, TRUE |
| actions, events | DO, HAPPEN, MOVE, TOUCH |
| location, existence | BE (SOMEWHERE), THERE IS, BE (SOMEONE/SOMETHING), HAVE, BE (SOMEONE’S) |
| life, death | LIVE, DIE |
| time | WHEN, NOW, BEFORE, AFTER, A LONG TIME, A SHORT TIME, FOR SOME TIME, MOMENT |
| space | WHERE, HERE, ABOVE, BELOW, FAR, NEAR, SIDE, INSIDE |
| logical | NOT, MAYBE, CAN, BECAUSE, IF |
| intensifier | VERY, MORE |
| similarity | LIKE |
Note the flavor of the list:
Every other word in any human language is, in NSM’s bet, reducible to these. That bet is what the registry operationalizes.
Five entry points:
nsm_primes() # all 66 primes (array of records)
nsm_prime("THINK") # registry entry for one prime
is_nsm_prime("think") # case-insensitive boolean check
nsm_primes_by_category("mental_predicates")
nsm_categories() # 16 category namesEach registry entry is a record:
p: nsm_prime("THINK")
println(p.canonical_number) # 22
println(p.category) # "mental_predicates"
println(p.valency) # 2 (binary: experiencer + thought)
println(p.arguments) # roles + descriptions
println(p.examples) # ["I think something", "you think like this"]
println(p.grammar_rules) # ["complementation"]
println(p.allolexes) # related surface forms
println(p.translations) # 7-language tableValency matters: it tells you how many positions the
prime takes in a clause. THINK is valency 2 (someone thinks
something); I is valency 0 (just refers); SAY
is valency 3 (speaker says content to addressee).
Allolexes are non-canonical surface forms that
count as the prime. The registry records, for example, that “me” is
an allolex of I, that “thing” is an allolex of
SOMETHING, that “person” is an allolex of
SOMEONE, and that “many” is an allolex of
MUCH. When the validators read text, they accept
allolexes.
primes: nsm_primes()
i: 1
while (i <= len(primes)) [
if len(primes[i].allolexes) > 0 then [
println(primes[i].prime, "→", primes[i].allolexes)
]
i = i + 1
]
# I → ["me"]
# SOMEONE → ["someone", "person"]
# SOMETHING → ["something", "thing"]
# MUCH → ["many"]
# WHEN → ["time"]
# WHERE → ["place"]
# ... etcPrimes don’t combine arbitrarily. NSM grammar is severely constrained — a deliberate choice, because the goal is to make sure the grammar itself is also universally expressible. Axioma’s registry records seven rules:
| Rule | Pattern | Example |
|---|---|---|
| quantification | {quantifier} {substantive} |
“many people”, “two things” |
| modification | {evaluator/descriptor} {substantive} |
“something good”, “big thing” |
| complementation | X {mental_predicate} [that] Y |
“I think something good” |
| temporal_specification | {event} {temporal_prime} |
“this happened before” |
| spatial_specification | {substantive} {spatial_prime} |
“something here” |
| causation | X happened because Y |
“I feel bad because of this” |
| conditional | if X then Y |
“if this happens, something bad happens” |
You can query them programmatically:
rules: nsm_grammar_rules()
i: 1
while (i <= len(rules)) [
r: rules[i]
println(r.name, ":", r["pattern"])
println(" applicable to:", r.applicability)
i = i + 1
]Each rule lists its applicability (which primes can
participate), examples (good uses), and
violations (common bad uses). The violations are
pedagogically valuable — they show what looks like NSM but
isn’t.
Four validators sit on top of the registry. They have different strictness levels and return different shapes. Pick the one whose strictness matches your task.
nsm_validate_text(text)
— strict booleanThe simplest. Returns true iff every word in the input
is either a prime or an allolex of a prime. Punctuation and the
connective “to” are tolerated.
nsm_validate_text("I think something good") # true
nsm_validate_text("I love you") # false ("love" not a prime)
nsm_validate_text("someone did something bad") # false ("did" isn't lemmatized)Surprise: “did something bad” fails because the validator doesn’t
lemmatize verb tenses. nsm_validate_explication is more
forgiving.
nsm_validate_explication(text)
— rich detailThe validator you actually want for iterative refinement. Returns a record with five fields:
r: nsm_validate_explication("X feels something bad because of this")
println(r.valid) # true
println(r.used_primes) # ["FEEL", "SOMETHING", "BAD", "BECAUSE", "THIS"]
println(r.grammar_issues) # []
println(r.suggestions) # []
println(r.warnings) # []This input is accepted: the validator recognizes its normalized primes and permits the variable. Suggestions are diagnostic aids when supplied, not a guarantee of grammatical or semantic correctness. The workflow is:
nsm_validate_explication on it.grammar_issues + suggestions.valid: true.nsm_validate_grammar(text)
— currently permissiveA stub. Returns {errors: [], valid: true} for almost
anything. Don’t rely on it for grammar checking yet; the prose
explanation of the 7 rules in §32.4 is what to internalize.
nsm_check_structure(text)
— currently permissiveSame. Returns {issues: [], valid: true} regardless of
input. Reserved for future structural analysis.
The honest summary: use nsm_validate_explication
for real work; the other three are convenience wrappers or
placeholders.
nsm_find_similarWhen you’re not sure if a word maps to a prime, ask directly:
nsm_find_similar("person") # ["PERSON"] — wait, that's an allolex display
nsm_find_similar("good") # ["GOOD"]
nsm_find_similar("feel") # ["FEEL"]
nsm_find_similar("happy") # []
nsm_find_similar("hate") # []
nsm_find_similar("kill") # ["I"]The matcher does substring lookup against canonical primes and allolexes. It is loose — short words like “kill” match “I” (because “kI ll” contains “I” as a substring), so treat its output as a hint not an oracle. The empty list for “happy” and “hate” is correct and informative: those are not primes, so an NSM explication can’t use them directly. You’d have to decompose them — see §32.8.
Each prime carries translations into seven languages:
english, spanish, french,
german, russian, japanese,
chinese.
nsm_translate("GOOD", "french") # "bon"
nsm_translate("THINK", "japanese") # "思う"
nsm_translate("BECAUSE", "russian") # "потому что"
nsm_all_languages()
# ["chinese", "english", "spanish", "french", "german",
# "russian", "japanese"]The translation table is the operational core of NSM’s universality claim. If the primes really are universal, this table should always work. Where the framework strains is when a target language has multiple words for what English calls one prime — German has “denken” (reasoned thought) and “glauben” (belief-like thought), and the registry has to pick one as canonical for THINK. Those choice points are the empirical edges of NSM and where the most interesting linguistic argument lives.
A complete translation pipeline:
translate_explication: func(text, target_lang) [
words: split(text, " ")
out: ""
i: 1
while (i <= len(words)) [
w: words[i]
if is_nsm_prime(w) then [
out = out + nsm_translate(uppercase(w), target_lang) + " "
] else [
# Allolex or punctuation — pass through
out = out + w + " "
]
i = i + 1
]
out
](That’s a sketch; production code would handle multi-word primes like “DON’T WANT” specially. Real translation pipelines in Cascade do exactly this kind of prime-by-prime substitution.)
Wierzbicka’s classic example. The English word “promise” is not a prime. To explicate it in NSM, paraphrase using only primes. Here is the iterative workflow.
v1: "X promises Y to do something"
println(nsm_validate_explication(v1))Output:
{valid: false,
used_primes: ["DO", "SOMETHING"],
grammar_issues:
["'X' is not an NSM prime",
"'promises' is not an NSM prime",
"'Y' is not an NSM prime",
"'to' is not an NSM prime"],
suggestions: ["Did you mean 'I'?", ...]}
X and Y aren’t primes (they’re variables,
but the validator doesn’t know that). promises is what
we’re trying to define — we can’t use it in its own definition.
to is grammar particle.
v2: "someone says: I will do something"
println(nsm_validate_explication(v2))Still fails: “says” is not a prime (SAY is), “will” is not a prime.
v3: "someone say this: I do something"
println(nsm_validate_explication(v3))
# valid: true, used_primes: ["SOMEONE", "SAY", "THIS", "I", "DO", "SOMETHING"]Better. But this is a bare assertion, not a promise. A promise involves commitment about the future, awareness by the listener, and intention. NSM’s full explication is typically:
someone (X) says this to someone (Y):
I think this: I will do something good for you
I want you to know this
In purely-prime form:
v4: "I say this: I think I do something good. I want you know this"
println(nsm_validate_explication(v4))This still has some grammatical roughness — NSM explications are typically line-by-line prose, one proposition per line, with explicit time-words for the future. The full Wierzbicka explication of “promise” runs about 8 lines. The takeaway: decomposing one common word into primes typically takes 5-10 propositions. That’s the discipline.
Once you’ve validated, store it as a Concept:
concept Promise
Promise has explication: [
"someone X say this:",
"I think this: I do something good for someone Y",
"I want someone Y know this"
]
Promise has used_primes: ["SOMEONE", "SAY", "THIS", "I", "THINK", "DO",
"SOMETHING", "GOOD", "WANT", "KNOW"]
Promise has language_neutral: trueThe language_neutral: true flag is the payoff: a
prime-only definition, by construction, translates to any of the 7
supported languages by substitution.
Three places NSM pays off in a knowledge-representation pipeline like Cascade:
nsm_validate_explication
catches both: circular definitions fail because the term won’t be a
prime; jargon-heavy definitions fail with long
grammar_issues lists.NSM has earned a substantial critical literature. The honest summary:
glauben over denken for THINK in German is a
judgment call. Different translators make different picks.These are features, not bugs, for KR work — the constraints are what make prime-form definitions machine-checkable. But it is worth knowing where the framework strains. Compare Ch.33 (Schank’s CD) for a different set of universal primitives with different tradeoffs.
Use nsm_categories() and
nsm_primes_by_category to print one line per category, of
the form category: N primes. Total should sum to 66.
Find every prime that has at least one allolex. Print the prime alongside its allolex list. (You can use this to teach yourself the lemmatization the validators expect.)
Start with the candidate definition for “regret”: “someone feel
bad because they did something”. Run
nsm_validate_explication, read the issues, rewrite, repeat
until valid: true. Print each iteration along with its
valid flag.
Implement the translate_explication function from §32.7
in full. Then run it on the validated explication “I think this is
good” into French, German, and Japanese. Print all three.
Pick one of these emotional concepts and write an NSM explication
using only primes: surprise,
gratitude, embarrassment. Validate it.
Store the validated explication as a Concept with
explication and used_primes slots.
NSM’s 65/66 primes have been periodically updated as fieldwork
uncovers languages that challenge specific universals. Use
nsm_primes_by_category on the space category
(WHERE, HERE, ABOVE, BELOW, FAR, NEAR, SIDE, INSIDE) and
ask yourself: are any of these culturally specific? Hint:
orientation systems differ dramatically across languages — some lack
absolute ABOVE/BELOW and use cardinal directions instead.
Solutions to selected exercises: Chapter 32 · Solutions in Appendix C. (Repo file: exercises/solutions/ch32_solutions.md.)
nsm_primes,
nsm_prime, is_nsm_prime,
nsm_primes_by_category, nsm_categories).nsm_validate_explication as the workhorse and the other
three as placeholders or convenience wrappers.The next chapter (33) goes deeper into the action half of the universal-primitives game with Schank’s Conceptual Dependency. Where NSM proposes universal nouns and verbs, CD proposes a restricted set of action verbs — different cut, same ambition.
“What I want to know is whether what we call ‘mind’ is universal. The only way to test that is to define mental words in words that everyone shares.” — Anna Wierzbicka, paraphrased
What this chapter is. Roger Schank’s Conceptual Dependency (CD), introduced in his 1972 paper Conceptual Dependency: A Theory of Natural Language Understanding, claims that every action verb in every human language reduces to one of about a dozen primitive ACTs. “Give,” “donate,” “sell,” “lend,” “transfer,” “bequeath,” “award” — all reduce to a single primitive:
ATRANS(abstract transfer of possession). The surface vocabulary varies wildly; the conceptual spine is small and shared. Axioma exposes the registry of eleven primitive ACTs as a first-class API, three working builtins, and a discipline for grounding domain-vocabulary against a shared semantic core. By the end of this chapter you will know the eleven primitives, their role slots, the verbs that decompose into each, and where CD pays off in Cascade’s entity-extraction pipeline.
Schank’s bet, made in 1972 and refined through the 1970s Scripts, Plans, Goals, and Understanding line of work: human action verbs are not fundamental units of meaning. They’re surface forms that decompose into a small set of cognitive primitives — operations the mind models the world with.
Three sentences:
A naive semantic system treats “give,” “sell,” and “donate” as three distinct relations. Build a knowledge graph that way and you’ll have hundreds of trade-related predicates with no machinery for noticing that they’re all describing the same kind of event.
Schank’s move: factor the meaning. The shared core is the
primitive ACT — here,
ATRANS(actor=John, object=book, from=John, to=Mary). The
differences between “give,” “sell,” and “donate” are modifiers
on that primitive:
for $10)The same primitive runs under all three. Queries like “what
transferred between Alice and Bob today?” can now match every
verb-instance at once — because they all share the ATRANS
spine.
This is the same engineering insight as type erasure in programming languages, or normalized forms in relational databases: the surface presentation is not the data. Find the underlying invariant, store it, then derive surface forms from the invariant on demand.
Three builtins, all working in the current build:
| Builtin | Purpose |
|---|---|
cd_primitives() |
Return the full registry — array of
{name, roles, description} records |
cd_roles("ATRANS") |
Return the role-name array for a specific primitive |
is_cd_primitive("ATRANS") |
Boolean — is this string a registered primitive? (case-insensitive) |
A quick taste:
prims: cd_primitives()
println("Total primitives:", len(prims)) # Total primitives: 11
println("First entry:", prims[1])
println("ATRANS roles:", cd_roles("ATRANS"))
println("Is ATRANS a primitive?", is_cd_primitive("ATRANS"))
println("Is XFRANS?", is_cd_primitive("XFRANS"))Output:
Total primitives: 11
First entry: {description: "abstract transfer of possession, control, or relationship", name: "ATRANS", roles: ["actor", "object", "from", "to"]}
ATRANS roles: ["actor", "object", "from", "to"]
Is ATRANS a primitive? true
Is XFRANS? false
Three builtins is enough for the retrieval side of CD. For the decomposition side — assigning verbs to primitives and stitching them into knowledge graphs — you use ordinary Axioma machinery (Concepts, relations, the rule engine from Chapter 21) with the primitive name as the spine.
Schank’s original 1972 set had eleven primitives — refined across many subsequent papers but the eleven below are the canonical roster Axioma exposes. Each primitive has a name, a fixed role slot list, and a description explaining what it means.
| Primitive | Roles | Description |
|---|---|---|
ATRANS |
actor, object, from, to |
abstract transfer of possession, control, or relationship |
ATTEND |
actor, object |
directing attention or perception toward something |
EXPEL |
actor, object |
moving something out of the body or container |
GRASP |
actor, object |
grasping or taking hold of an object |
INGEST |
actor, object |
taking something into the body or system |
MBUILD |
actor, content |
building or changing a mental state |
MOVE |
actor, body_part, direction |
moving a body part or controlled object |
MTRANS |
actor, information, from, to |
mental transfer of information |
PROPEL |
actor, object, force |
applying force to an object |
PTRANS |
actor, object, from, to |
physical transfer of location |
SPEAK |
actor, content, to |
producing communicative speech or text |
A small reading guide on each.
ATRANS —
abstract transfer of possessionThe ownership primitive. Anything that changes who controls or owns something. Verbs: give, donate, sell, lend, return, grant, award, transfer, cede, bequeath, inherit, surrender, confiscate, gift. Not INGEST or GRASP — those are about physical possession; ATRANS is about the abstract right to possess.
ATTEND — directing
perceptionThe attention primitive. Verbs that route a sense toward an object. Verbs: look, see, watch, listen, hear, smell, taste, observe, notice. Some compound verbs that imply ATTEND: “He glared at her” decomposes to ATTEND (eyes) plus an affective modifier.
EXPEL —
moving outward from a containerVerbs about removal from a body or vessel. Verbs: exhale, excrete, vomit, sneeze, cry (tears), bleed, discharge. The container can be a body, a building, a machine — anywhere outflow is the model.
GRASP — taking holdThe contact-and-control primitive — about physically holding, not owning. Verbs: hold, grip, grab, catch, seize, clutch, clasp, snatch. Distinct from ATRANS: you can GRASP an object you don’t own.
INGEST —
taking inward into a containerVerbs about intake — symmetric to EXPEL. Verbs: eat, drink, swallow, inhale, absorb, breathe in, consume. Like EXPEL, the “container” can be metaphorical (a factory ingesting raw material).
MBUILD — building a
mental stateThe cognition primitive — anything that creates, modifies, or restructures a piece of mental content. Verbs: think, decide, conclude, infer, reason, judge, believe, doubt, realize, learn, understand. Schank’s deepest move: cognition is construction, not recognition; minds build representations.
MOVE
— moving a body part or controlled objectThe bodily-motion primitive. Verbs: wave, kneel, sit down, stand up, raise (arm), lower, kick, walk, gesture, nod. Distinct from PTRANS (which moves the whole person/object); MOVE is about limbs, facial expressions, deliberate body motions.
MTRANS —
mental transfer of informationThe communication-into-mind primitive — symmetric to SPEAK but from the receiver’s view, or the sender’s view when they push information rather than just utter it. Verbs: tell, inform, teach, explain, remind, suggest, mention, hint, signal, gesture (informationally). Reading and listening are MTRANS from a source into the agent’s mind.
PROPEL —
applying force to an objectThe force primitive. Verbs: push, pull, throw, kick, shove, hit, punch, slam, strike, fling. Distinct from MOVE (which moves a body part); PROPEL applies force to something else.
PTRANS —
physical transfer of locationThe physical-movement primitive. Verbs: go, come, walk, travel, drive, fly, run, depart, arrive, return, send, deliver, ship. PTRANS is the most-used primitive in any travel or logistics domain — it’s the geographic-movement spine.
SPEAK
— producing communicative speech or textThe utterance primitive. Verbs: say, speak, shout, whisper, mutter, declare, announce, write, sign, sing, type, post (a message). Often pairs with MTRANS: when you “tell” someone something, you SPEAK + the listener MTRANSes the content into their mind.
A real-world domain — say, financial transactions — uses hundreds of transfer-related verbs. CD’s contribution: each verb decomposes into one primitive plus modifiers.
| English verb | Primitive | Modifiers / flavor |
|---|---|---|
| give | ATRANS | — (bare transfer) |
| donate | ATRANS | gratuitous (no consideration expected) |
| sell | ATRANS | paired with reverse ATRANS (money flowing back) |
| lend | ATRANS | temporary (reverse-ATRANS expected later) |
| bequeath | ATRANS | conditioned on death |
| steal | ATRANS | unauthorized (no consent from “from”) |
| return | ATRANS | restores prior state (the “from” was a prior “to”) |
| award | ATRANS | formal, often public |
| confiscate | ATRANS | authoritative + unauthorized-from-source |
| barter | ATRANS | paired with reverse ATRANS of a non-money object |
Every row encodes the same primitive (ATRANS)
with the same role structure (actor, object,
from, to). The variation lives in flavor
slots attached to each row.
The pedagogical pay-off: a knowledge-graph system that indexes by primitive (rather than by surface verb) can ask “what transfers of control happened today?” and match every row at once. Each individual row can still be queried by its flavor — “show me only the gratuitous transfers” — but the union query is cheap and uniform.
Some verbs decompose into multiple primitives in sequence, not just one with flavor. Schank’s examples:
| Verb | Decomposition |
|---|---|
| complain | MBUILD(“X is bad”) + MTRANS(speaker →
listener) + affective modifier (“dissatisfaction”) |
| negotiate | repeated MTRANS ↔︎ MTRANS +
MBUILD (build agreement) + eventual
ATRANS |
| bargain | similar to negotiate but with explicit consideration |
| teach | repeated MTRANS from teacher to student +
MBUILD in student |
| promise | MBUILD(“I will do X”) + MTRANS(“I want you
to know this”) + commitment flavor |
| agree | MBUILD(shared proposition) + optional
MTRANS confirming |
| threaten | MTRANS(conditional intent) + force-modifier (PROPEL
latent) |
| kiss | MOVE(lips) + ATTEND (target) + affective
modifier |
The decomposition isn’t always tidy — Schank explicitly notes that some verbs decompose into probabilistic combinations (sometimes you complain by speaking, sometimes by writing, sometimes by gesture). The spine is reliable; the implementation per culture and modality varies.
For knowledge-representation work, the practical rule is:
Once a verb has a CD record, downstream reasoning can ignore the surface verb and operate on the primitives. Cascade’s entity-extraction pipeline uses exactly this discipline.
Axioma’s recommended pattern for domain-vocabulary grounding is to attach the CD primitive as a slot on the Concept that represents the word:
# Define "donate" as a flavored ATRANS
concept Donate
Donate has primitive: "ATRANS"
Donate has flavor: "gratuitous"
Donate has actor_role: "donor"
Donate has object_role: "gift"
Donate has to_role: "recipient"Now downstream reasoning sees Donate as a flavored
ATRANS. Any rule that fires on ATRANS events also
fires on Donate events — the spine is uniform.
You can verify the primitive choice at runtime against the registered set:
p: "ATRANS"
if is_cd_primitive(p) then [
println("Primitive", p, "has roles:", cd_roles(p))
] else [
println("ERROR:", p, "is not a registered CD primitive")
]This is the engineering hygiene of CD: every Concept that represents an action is grounded to a primitive that’s recognized by the registry. Typos or invented primitives fail the check.
Three places:
define word form accepts a cd: slot that
records the primitive associated with a word. Words tagged with their
primitive become first-class CD instances — the type is right,
registry-checked.ATRANS(China, rare_earths, China, US, flavor="sanction")
not sanctioned(China, US, rare_earths). The single
ATRANS lets multi-verb queries (“what restrictions were
imposed?”) match every variant.ATRANS regardless of
surface verb. The rule engine from Chapter 21 doesn’t need to enumerate
every English synonym — it patterns on the primitive.CD pays dividends in inverse proportion to vocabulary size. A small domain (a few verbs) sees little benefit from CD-normalization. A large domain (thousands of domain-specific verbs in finance, law, geopolitics) sees enormous benefit — the entity-extraction graph stays small while the surface vocabulary scales freely.
CD is not without controversy. Three classic critiques, worth knowing:
One — the primitives are English-biased. Schank’s original 1972 set was extracted from English. Subsequent research (Wierzbicka’s NSM — Chapter 32 — among others) argues that some of the “primitives” carry English conceptual baggage. PTRANS as the universal travel primitive may obscure cultures where the relevant distinction is between self-initiated and externally-initiated motion. CD’s universality is an empirical claim, not a definitional one.
Two — the decomposition isn’t always unique. Does persuade decompose to repeated MTRANS plus MBUILD, or to MBUILD-in-target with the MTRANS implicit? Different practitioners give different answers. CD is more like a style guide than a theorem: it tells you what kind of decomposition to look for, but it doesn’t uniquely determine the answer.
Three — affective and modal content is hard. The flavor-slot pattern (§33.4) handles a lot, but not everything. Modal-strength markers (might, should, must), affective markers (reluctantly, eagerly), and pragmatic markers (allegedly, officially) sit uneasily between primitives and flavors. Practical systems lean on additional annotation layers.
These limits don’t invalidate CD — they just mean it’s one tool among many. Combine with NSM (Chapter 32) for the universal-semantic-primes view, with Lojban (Chapter 31) for relation-with-named-places, and with context graphs (Chapter 28) for per-source provenance. No single formalism captures everything.
Print the number of registered CD primitives, then print the name of each one. (Don’t print the description or roles — just the names.)
Solution:
prims: cd_primitives()
println("Count:", len(prims))
i: 1
while (i <= len(prims)) [
println(prims[i].name)
i = i + 1
]Output (12 lines including the count):
Count: 11
ATRANS
ATTEND
EXPEL
GRASP
INGEST
MBUILD
MOVE
MTRANS
PROPEL
PTRANS
SPEAK
For each verb, identify the primitive that best fits. Use
is_cd_primitive to confirm your answers are valid primitive
names.
Verbs to classify: kick, eat, hear, go, say, decide.
Solution:
verbs_to_acts: [
["kick", "PROPEL"],
["eat", "INGEST"],
["hear", "MTRANS"],
["go", "PTRANS"],
["say", "SPEAK"],
["decide", "MBUILD"]
]
i: 1
while (i <= len(verbs_to_acts)) [
pair: verbs_to_acts[i]
valid: is_cd_primitive(pair[2])
println(pair[1], "→", pair[2], "(valid?", valid, ")")
i = i + 1
]All six primitives validate as true. The classification
is judgment-call territory in places — hear could reasonably be
MTRANS (information in) or ATTEND (auditory attention); CD
doesn’t enforce one answer.
Write a function that takes a primitive name and prints its roles, or an error if the name is not registered.
Solution:
print_roles: func(name) [
if is_cd_primitive(name) then [
println(name, "→", cd_roles(name))
] else [
println("ERROR:", name, "is not a registered primitive")
]
]
print_roles("ATRANS") # ATRANS → ["actor", "object", "from", "to"]
print_roles("INGEST") # INGEST → ["actor", "object"]
print_roles("MOVE") # MOVE → ["actor", "body_part", "direction"]
print_roles("XFRANS") # ERROR: XFRANS is not a registered primitiveNote the case-insensitivity of is_cd_primitive:
is_cd_primitive("atrans") also returns true.
The canonical form returned by cd_primitives() is
upper-case.
A reasoner equipped with CD knows that give,
donate, and sell are all transfer-of-possession events
because they all decompose to ATRANS. Build a small
synonymy table that maps surface verbs to primitives, then write a
function same_primitive(v1, v2) that returns true if two
verbs share a primitive.
Solution:
verb_to_primitive: func(verb) [
if verb == "give" then "ATRANS"
else if verb == "donate" then "ATRANS"
else if verb == "sell" then "ATRANS"
else if verb == "transfer" then "ATRANS"
else if verb == "go" then "PTRANS"
else if verb == "send" then "PTRANS"
else if verb == "eat" then "INGEST"
else if verb == "drink" then "INGEST"
else "UNKNOWN"
]
same_primitive: func(v1, v2) [
p1: verb_to_primitive(v1)
p2: verb_to_primitive(v2)
p1 == p2 and is_cd_primitive(p1)
]
println(same_primitive("give", "donate")) # true
println(same_primitive("give", "transfer")) # true
println(same_primitive("give", "go")) # false
println(same_primitive("eat", "drink")) # true
println(same_primitive("go", "send")) # true (both PTRANS)
println(same_primitive("unicorn","narwhal")) # false (both UNKNOWN, but not a registered primitive)The is_cd_primitive check in same_primitive
is the robustness move — if both verbs map to "UNKNOWN",
same_primitive should return false (we don’t know what they
mean), not true (which a naive p1 == p2 check would
erroneously give).
Pick promise (or threaten, negotiate, complain). Decompose it into a sequence of CD primitives, identify the flavor markers, and write the decomposition as a small Axioma data structure.
Solution (for promise):
concept Promise
Promise has step1_primitive: "MBUILD"
Promise has step1_content: "I will do X"
Promise has step2_primitive: "MTRANS"
Promise has step2_actor: "speaker"
Promise has step2_to: "listener"
Promise has flavor: "commitment"
println("Promise step 1:",
Promise.step1_primitive,
"valid?", is_cd_primitive(Promise.step1_primitive))
println("Promise step 2:",
Promise.step2_primitive,
"valid?", is_cd_primitive(Promise.step2_primitive))
println("Flavor:", Promise.flavor)The decomposition: a promise is
Other decompositions are defensible — a strict reading might say a promise is just MTRANS with the commitment flavor encoding the binding. CD style-guides differently; the registry doesn’t dictate.
In your own words, give two domains where a CD-style primitive-spine approach to verb representation would materially improve a knowledge-representation system, and one domain where it probably wouldn’t help.
Sample answers:
The general rule: CD pays off in proportion to vocabulary diversity per concept-spine. High diversity (natural-language domains) wins; low diversity (technical domains with already-controlled vocabularies) doesn’t.
One — the small set of primitives is the empirical claim, not the definition. Schank’s bet that ~11 ACTs cover every action-verb in every language is evidence based, not stipulated. The chapter you’ve just read took that on faith; a deeper engagement with CD would ask whether the eleven are enough, whether they’re language-neutral, whether they’re complete in the limit. The Axioma registry exposes the empirical answer (eleven), not a proof.
Two — flavors are the load-bearing engineering move. The eleven primitives are too few to cover all the nuance in real language. CD’s pragmatic answer: primitives + flavor slots. The flavor slots aren’t an afterthought — they’re where the system stores the domain richness. The primitive is the index; the flavor is the data.
Three — CD plays well with the rest of the stack. CD’s primitives give you a verb vocabulary. Lojban (Ch.31) gives you named relation places. NSM (Ch.32) gives you universal semantic atoms. Context graphs (Ch.28) give you truth per source. Each formalism covers one face of the meaning problem; combine them to cover the whole.
The next chapter — Ch.32 if you’re reading in canonical order — takes the atomic-meaning side of the same problem with Wierzbicka’s NSM. Where CD asks “what’s the small set of actions?”, NSM asks “what’s the small set of concepts?”. The two answers complement each other — and Cascade uses both.
“The understander must reduce surface text to a language-independent conceptual representation before it can reason.” — paraphrase of Schank, 1975. CD is the reduction. Axioma’s registry is the lookup table that makes the reduction mechanical.
Solutions to selected exercises: Chapter 33 · Solutions in Appendix C. (Repo file: exercises/solutions/ch33_solutions.md.)
What this chapter is. Axioma exposes its full reasoning capability to external programs — Claude Desktop, Claude Code, Cursor, any MCP client — via the Model Context Protocol (MCP). Cascade does the same, bundling Axioma’s tools alongside its own. The result is a composable AI stack: an LLM client can plan in natural language, call Axioma to parse and evaluate code, call Cascade to look up real-world facts, and stitch the results back into a coherent answer. The chapter is dual-audience: Axioma developers learn how to call the language as a service, and Cascade users learn how to drive analysis from outside the CLI.
The Model Context Protocol is a stdio-based JSON-RPC protocol that lets an AI model client (like Claude Desktop) call out to tool servers — separate processes exposing named tools with structured input/output schemas. The client knows nothing about tool internals; it knows only the tool’s name, description, parameter schema, and what kind of result to expect.
Three core ideas:
parse_axioma,
entity_search, discover_cascade — each is a
discoverable function.The protocol runs over stdio by default — the client launches the server as a subprocess and talks to it via stdin/stdout. SSE (server-sent events over HTTP) is also supported for remote/web deployments.
Run via axioma --mcp. Exposes 22 tools
organized into six categories.
| Tool | Purpose |
|---|---|
parse_axioma |
Parse code → return AST structure |
validate_syntax |
Syntax check (no side effects) |
analyze_code |
Semantic analysis (types, symbols, structure) |
translate_code |
AST-aware translation between Go / Python / Axioma |
The translation tool is unusual — it uses the AST as the pivot representation, so cross-language translation is structure-aware rather than line-by-line.
| Tool | Purpose |
|---|---|
execute |
Execute Axioma code; return result + captured output |
run_file |
Execute an .ax file |
Both run inside the persistent session environment — variables and Concepts created by one call are visible to the next. This is the load-bearing detail of the whole MCP architecture; see §34.5.
| Tool | Purpose |
|---|---|
symbolize_nl |
Natural language → Axioma code |
decompose_claim |
Decompose a claim into logical propositions |
resolve_entities |
Match entities against the KB |
These three convert prose into Axioma’s symbolic
representations. A client sees a sentence like “China’s sanctions on
rare earths threaten US chip supply” and asks
decompose_claim to extract the propositional structure —
useful both standalone and as a feed into the Cascade ingest
pipeline.
| Tool | Purpose |
|---|---|
query_kb |
Query the persistent SQLite KB (stats, relations, concepts, facts, patterns) |
inspect_env |
Inspect the session environment (variables, concepts, functions, rules) |
query_kb is the outer view — what’s in the
permanent knowledge base on disk. inspect_env is the
inner view — what’s in the running session right now.
| Tool | Purpose |
|---|---|
forward_chain |
Run forward chaining over rules + facts |
wm_insert |
Insert a fact into working memory |
wm_query |
Query working memory |
wm_retract |
Remove a fact from working memory |
Working memory (WM) is the in-session fact store — distinct from the
persistent KB. WM facts are visible to the rule engine and to
inspect_env but disappear when the session ends (unless
explicitly persisted through the refinement system — Chapter 16
footnote).
| Tool | Purpose |
|---|---|
context_graph_create |
Create a new context graph (Ch.28 §28.4) |
context_create |
Create a context within a graph |
context_assert |
Assert a proposition within a context |
context_query |
Query truth within a context |
context_resolve |
Resolve cross-context references |
context_diff |
Find propositions that differ between contexts |
context_merge |
Merge two contexts with conflict resolution |
The context-graph subsystem from Chapter 28 §28.4 is fully exposed as an external API. A client can build belief-modeling reasoners without writing Axioma code — it talks to the MCP tools directly.
Run via cascade mcp serve. Cascade exposes 50+
tools plus the Axioma tools forwarded through.
read_file, write_file,
edit_file, bash. These exist because Cascade’s
autonomous agents need to manipulate files and run commands as part of
their workflow. Mostly invisible to human users.
| Domain | Tools |
|---|---|
| Graph overview | graph_stats |
| Entities | entity_search, entity_add,
entity_delete, entity_update,
entity_merge, entity_duplicates |
| Relations | relation_list, relation_add,
relation_delete |
| Claims | claim_search, claim_add,
claim_delete, claim_update |
This is the full CRUD surface of the Cascade knowledge graph (Layer 2 of the architecture from Ch.43).
| Tool | Purpose |
|---|---|
truth_propagate |
Push a B4 value through dependent claims |
truth_history |
Audit how a claim’s truth changed over time |
truth_related |
Find claims whose truth would shift if this one does |
truth_aggregate |
Combine evidence from multiple sources into one B4 value |
Belnap B4 (Chapter 20) in production. truth_propagate is
the daily-use tool: a new piece of evidence comes in, mark the
directly-affected claim, and let the system trace where the impact
spreads.
| Tool | Purpose |
|---|---|
discover_cascade |
Tier-1 → Tier-2 → Tier-3 impact chain |
discover_centrality |
Find the most-connected nodes |
discover_bridges |
Find articulation points / bridges |
discover_connections |
Find connections between two specific nodes |
discover_clusters |
Community detection |
These are the headline tools — the ones that produce the analytical output Cascade subscribers see.
| Tool | Purpose |
|---|---|
ingest_url |
Fetch and process an article URL |
ingest_text |
Process pasted text |
evaluate_claim |
Run Axioma’s B4 evaluator on a claim |
evaluate_relation |
Same for a relation |
evaluate_text_tool |
Run NSM / NLP validators over text |
Note that evaluate_* tools call through to
Axioma’s MCP — they’re the natural API for “use Axioma’s logic engine on
Cascade’s data.”
| Domain | Tools |
|---|---|
| Simulation | simulate |
| Reactor | reactor_start, reactor_stop,
reactor_status |
| Heartbeat | heartbeat |
| Sync | sync_status, sync_push,
sync_pull, sync_force_push |
The reactor (Chapter 51) is the long-running agent loop.
sync is the multi-machine replication layer.
| Tool | Purpose |
|---|---|
cascade_risk_index |
Composite risk score |
cascade_vulnerability |
Vulnerability analysis |
cascade_temporal |
Time-series view of cascades |
cascade_changes |
What changed in the last N hours |
cascade_patterns |
Recurring patterns |
counter_narratives |
Adversarial narrative analysis |
portfolio_exposure |
Map a portfolio against the graph |
claim_credibility |
Per-claim credibility score |
These are the premium tools — they wrap many simpler queries into composite analytical outputs.
source_list, source_get — the provenance
side of the graph.
cascade_command lets the client invoke any of Cascade’s
CLI commands. _register_axioma_tools (internal function) is
the mechanism by which Cascade’s MCP server also exposes all 22
Axioma tools when an Axioma server is reachable.
When a client connects to Cascade’s MCP, the toolset they see is the union:
Cascade-native tools (~50) ← Layer 2 of Ch.43 architecture
+ Axioma tools (22) ← forwarded via _register_axioma_tools
= ~70+ tools available
This means a single MCP connection gives the client both Cascade’s knowledge-graph operations AND Axioma’s reasoning engine. The client doesn’t need to know there are two servers; they see one combined surface.
Internally, calls to Axioma tools flow:
Client ─ Cascade MCP ─ forwards ─ Axioma MCP ─ executes ─ returns
The latency overhead is small (stdio-on-stdio piping). The benefit is huge: clients integrate once, get both toolsets.
Both servers are stateful. A
parse_axioma call followed by an execute call
against the same connection share environment — variables bound in one
persist into the next:
Client → parse_axioma "x: 10" ← Axioma binds x = 10
Client → execute "x + 5" ← Sees x; returns 15
Client → inspect_env ← x appears in the env dump
If each tool call were stateless — fresh process per call — the third
call wouldn’t see x. The session glue is what makes the MCP
toolset feel like talking to a running Axioma session, not
invoking a calculator.
For Cascade, the persistent state is the SQLite database connection. Multiple calls share the connection and any in-transaction state.
This is the same insight that powers Volume I Chapter 12 (local definitions / closures): persisted state across operations enables compositional reasoning that stateless per-call execution can’t.
A typical Claude Desktop / Claude Code configuration to use both servers:
{
"mcpServers": {
"axioma": {
"command": "/path/to/axioma",
"args": ["--mcp"]
},
"cascade": {
"command": "uv",
"args": ["run", "cascade", "mcp", "serve"],
"cwd": "/path/to/axiomacascade"
}
}
}The client launches both as subprocesses on startup. From inside a Claude session you can then invoke either:
“Use the axioma tools to validate this code:
x: 5.”
“Use cascade to discover the cascade chain starting at ‘Strait of Hormuz’ with depth 3.”
The natural-language framing of which server to use is heuristic — Claude reads the tool descriptions and picks. If you want to force one, you can name it explicitly.
A common analytical workflow combines both servers:
cascade.ingest_url(url) — Cascade extracts
entities and relations, stores them in the KB, marks initial B4 truth
values.cascade.discover_cascade(entity, depth=3) — get the
impact-chain.axioma.evaluate_b4(claim) (via Cascade’s
forwarding) on a controversial claim — Axioma’s formal B4 engine
adjudicates the truth value with provenance.cascade.truth_propagate(claim, new_value)
— Cascade pushes the resolved value through the dependent claims.cascade.discover_cascade(entity, depth=3) again — the
cascade shape now reflects the propagated truth.That whole flow happens inside one MCP session. The client is mostly an orchestrator; the heavy lifting happens inside Axioma and Cascade. The architecture scales because new tools can be added to either server without breaking existing clients.
| Layer | What | Where |
|---|---|---|
| CLI | Direct human invocation | axioma file.ax, cascade discover ... |
| REPL | Interactive exploration | axioma prompt |
| Scripts | Embedded use in shell | axioma --no-kb script.ax |
| MCP | Programmatic / LLM use | axioma --mcp, cascade mcp serve |
| Cascade reactor | Continuous autonomous use | cascade reactor start |
Each layer is appropriate for a different usage mode. MCP specifically targets LLM clients — programs that plan in natural language and need to call out to a formal-reasoning engine for the parts they can’t handle themselves. That’s the deployment story this chapter is about.
Without running anything: list the four code-analysis tools in Axioma’s MCP and explain what each is for.
Answer:
parse_axioma — turn source code into an AST data
structurevalidate_syntax — check that source code parses, return
errors if notanalyze_code — perform semantic analysis (types,
symbols, structural metrics)translate_code — translate between Go, Python, and
Axioma using the AST as pivotThe four split cleanly: parse (syntax → tree), validate (syntax check), analyze (tree → semantics), translate (tree → other tree).
Sketch a 2-call MCP workflow that binds a variable and then queries it. What state needs to persist between the calls for the workflow to succeed?
Answer:
Call 1: execute("total: 42") → "42"
Call 2: execute("total + 8") → "50"
The state that must persist is the Axioma
environment: specifically the binding total → 42
created by call 1 must survive into call 2. The persistent-session
pattern (§34.5) is precisely what makes this work.
If a client could connect to both MCP servers independently, why does Cascade’s MCP also expose Axioma’s 22 tools?
Answer: Three reasons:
evaluate_claim calls Axioma’s B4 evaluator), it does
so over the same session — so any shared environment state
survives.The cost is a small latency overhead from the extra hop. The benefit is meaningful for composability.
Axioma exposes both wm_insert (working memory) and
query_kb (knowledge base) — what’s the difference?
Answer:
wm_insert are visible to
forward_chain and inspect_env during the same
session, but they disappear when the session ends.~/.axioma/axioma.kb or the
Cascade-shared cascade.db). Facts in the KB survive across
sessions, processes, and machines.The distinction matters for reasoning workflows: working-memory facts
are hypotheses under investigation; KB facts are
committed knowledge. The refinement system
(axiom/persist vs. axiom/transient) is the
gateway between the two.
Write the JSON configuration block that enables both the Axioma and the Cascade MCP servers for Claude Desktop. Where would this go on a typical macOS install?
Answer: Config block as in §34.6. On macOS, Claude
Desktop reads
~/Library/Application Support/Claude/ claude_desktop_config.json
— paste the mcpServers block into that file (merging with
any existing servers). Restart Claude Desktop to load the new
servers.
Sketch a third MCP server that would naturally compose with Axioma + Cascade. What domain does it cover, what tools would it expose, and how would it benefit from sharing a session with the others?
Sample answers:
The pattern: each MCP covers one domain; clients compose them by alternating calls. This is the microservices model applied to AI tool servers.
Three things to internalize.
One — MCP makes Axioma a service, not just a language. Without MCP, Axioma is a thing you run locally. With MCP, Axioma is a reasoning engine you call from anywhere. Any program that speaks MCP can ask Axioma to parse, evaluate, decompose, or reason.
Two — the toolset is the API. Each MCP tool is a function with a documented schema. Adding a new tool is how you grow the API. Hide a tool, and clients can no longer call that capability. The list in §34.2 + §34.3 is the public interface.
Three — sessions matter. The persistent-session pattern is what lets reasoning accumulate across calls. Without it, MCP would be a remote-procedure-call system for stateless functions. With it, MCP is a conversation between client and reasoning engine.
The next chapter (Volume III pilot) zooms back out to the Cascade architecture as a whole. Now that you know what MCP is, you’ll see how Cascade composes everything over the MCP toolset.
“Make it possible for programs to be written which do not depend on present types of files for input or output.” — Doug McIlroy, 1964, sketching the Unix pipe philosophy. MCP is that philosophy revived: tool servers as small composable engines, clients as the integration layer, sessions as the data flow between them.
Part VIII — Algorithm Analysis. The first of three chapters on how to choose an algorithm. Volume I taught you how to write a function that’s correct. Volume II Part VII taught you the data structures that hold the inputs. This part teaches you which algorithm wins when the input gets big.
A small story.
A friend ships a nightly batch job for a sports-analytics company.
The job reads n players from the previous day’s games,
computes a pairwise “similarity score” between each pair, and writes the
top-100 most-similar pairs to a report.
In testing with n = 100 players, the batch runs in 0.4
seconds. The team ships it. In production, on the first night, the batch
is given n = 10,000 players.
The on-call engineer wakes up to a pager: the nightly batch hasn’t finished in 8 hours, and tomorrow’s morning briefing depends on the report. By the time anyone debugs it, the job has been running 11 hours and is at 60% through the players.
What went wrong? The “similarity score between every
pair” computation has complexity O(n²) — every player
compared with every other. Going from n = 100 to
n = 10,000 is a 100× increase in n, but
n² goes from 10,000 operations to 100,000,000 operations —
a 10,000× increase in work. The 0.4-second batch
becomes a 4,000-second batch under linear scaling — but with
O(n²), it’s 11+ hours.
This is the kind of bug you cannot find by testing on a small example. The code is correct — the answers it produces are right. It just doesn’t finish in time. The bug is in the shape of the work, not the code that does it.
That shape is what complexity analysis is about. By the end of this chapter you’ll be able to:
O(1), O(log n),
O(n), O(n log n), O(n²),
O(2^n), O(n!) from the code that produces
them.bench builtin (the chapter’s load-bearing
technique).Big-O isn’t a benchmark; it’s a design contract. Get it right at design time and your code scales. Get it wrong and no amount of optimization will save you.
Before we can talk about growth, we need a way to count work.
The honest way to do this: pick an abstract step —
like “one comparison” or “one array access” — and count how many of them
your algorithm performs as a function of input size n.
Take a function that finds the maximum of a list:
max_of: func(arr) [
m: arr[1]
i: 2
while i <= len(arr) [
if arr[i] > m then m = arr[i]
i = i + 1
]
m
]How many comparisons does this make on a list of n
elements?
arr[i] > m once per iteration.i = 2 to i = n, so
n - 1 times.n - 1.We say max_of is O(n) — its work grows
linearly in n. The “minus 1” doesn’t
matter — once n is large enough, the difference between
n and n - 1 is negligible. We keep only the
dominant term and drop constants.
This is the central abstraction of complexity theory: ignore
constants, keep growth rates. Two algorithms that both do
c · n work for some constant c are
both O(n), even if one is 100× slower than the other in
practice. The asymptotic class predicts what happens when n
doubles. The constant predicts how it behaves at any specific size.
In the literature, you’ll see three notations:
| Notation | Meaning | Used when |
|---|---|---|
O(f(n)) |
At most this fast-growing | Stating an upper bound |
Θ(f(n)) |
Exactly this fast-growing | Stating a tight bound |
Ω(f(n)) |
At least this fast-growing | Stating a lower bound |
In practice — and in this chapter — we’ll mostly say
O(n) and mean Θ(n). The pedantic distinction
matters in theoretical CS papers; for design decisions, the upper bound
is what you care about (worst case). When we want to
make worst/average/best case explicit, we’ll spell it out in prose.
Counting operations from code is the theoretical method. Axioma gives
us a second method: measure the actual growth. This
chapter’s load-bearing builtin is bench:
result: bench("label", func() [ <work> ])
println(result)
# → {label: "label", elapsed_ms: 0.038, result: <return value>}bench runs the zero-argument function it receives, times
it in milliseconds, and returns a record with the label, the elapsed
time, and the function’s return value. The function takes zero arguments
so that bench can call it without needing to know its
signature.
The empirical complexity recipe:
[100, 200, 400, 800, 1600, …].n, build an input of size n
and time the function with bench.~1× → O(1)~1× (very slow growth) → O(log n)~2× → O(n) (linear)~2.something× → O(n log n)~4× → O(n²) (quadratic)~8× → O(n³) (cubic)The doubling-ratio test is far more revealing than absolute numbers.
A function that takes 50ms isn’t slow or fast in the abstract — it’s
slow at the size you ran it at. The ratio tells you what’ll
happen when n doubles again.
O(n) linear sumLet’s verify linearity empirically. Build a sum function and time it across doubling sizes:
# Build an ordered list of integers 1..n
make: func(n) [
xs: []
j: 1
while j <= n [
xs = xs + [j]
j = j + 1
]
xs
]
linear_sum: func(arr) [
s: 0
i: 1
while i <= len(arr) [
s = s + arr[i]
i = i + 1
]
s
]
# Time at four doubling sizes
sizes: [1000, 2000, 4000, 8000]
i: 1
while i <= len(sizes) [
n: sizes[i]
arr: make(n)
r: bench("sum n=" + str(n), func() [linear_sum(arr)])
println(n, "→", r.elapsed_ms, "ms")
i = i + 1
]Run this on your machine. The measured times will differ from mine (different CPU, different system load), but the ratios should be very close to 2×:
1000 → 54.3 ms
2000 → 105.1 ms (ratio: 1.94×)
4000 → 207.3 ms (ratio: 1.97×)
8000 → 393.7 ms (ratio: 1.90×)
Every doubling of n roughly doubles the time. That’s the
O(n) signature. The constant of proportionality is roughly
50 μs per element — that’s the constant factor, which
depends on the interpreter, the CPU, and the inner loop. What matters
for prediction is the growth rate, and that growth rate is
linear.
Why is it not perfectly 2×? Two reasons: 1. Setup
overhead. bench itself takes some microseconds to
start/stop the timer. That overhead is constant — it adds to small-n
timings more than large-n timings, so the measured ratio is slightly
less than 2× for small n. 2. System
noise. Other processes, garbage collection, memory locality. A
single run can be off by 5-20%; if you need clean numbers, average over
multiple runs (the repeated pattern below).
O(n²) bubble sortNow let’s see what O(n²) looks like in the wild.
Bubble sort: repeatedly pass through the array, swapping adjacent
out-of-order elements. After n passes, the array is
sorted.
make_reverse: func(n) [
ys: []
j: n
while j >= 1 [
ys = ys + [j]
j = j - 1
]
ys
]
bubble_sort: func(a) [
sorted: a
m: len(sorted)
pass: 0
while pass < m - 1 [
k: 1
while k < m - pass [
if sorted[k] > sorted[k + 1] then [
tmp: sorted[k]
sorted[k] = sorted[k + 1]
sorted[k + 1] = tmp
]
k = k + 1
]
pass = pass + 1
]
sorted
]
# Time at four doubling sizes (reverse-sorted = worst case)
sizes: [50, 100, 200, 400]
i: 1
while i <= len(sizes) [
n: sizes[i]
arr: make_reverse(n)
r: bench("n=" + str(n), func() [bubble_sort(arr)])
println(n, "→", r.elapsed_ms, "ms")
i = i + 1
]Output (from my run):
50 → 5.4 ms
100 → 16.9 ms (ratio: 3.15×)
200 → 63.6 ms (ratio: 3.77×)
400 → 242.9 ms (ratio: 3.82×)
Doubling n quadruples the work. That’s
the O(n²) signature. Notice how the ratio approaches 4× as
n grows — at small n, the constant overhead
dominates and the ratio is closer to 3×; as n grows, the
asymptotic behavior dominates and the ratio approaches the theoretical
4×.
This is exactly the bug pattern from §35.1’s pager story.
Extrapolate: at n = 800, expect ~970 ms. At
n = 1600, ~3.9 seconds. At n = 10,000, ~150
seconds. At n = 100,000, ~4 hours. The
next chapter (Ch.36) shows how to bring that 4 hours down to less
than a second with a better algorithm.
O(n²) by countingWe can derive O(n²) from the code without running
it:
n - 1 times.n - 1 times (decreasing each
pass).(n - 1) + (n - 2) + … + 1 = n(n-1)/2 ≈ n²/2.Drop the constant 1/2 (it’s a constant factor) and the
lower-order term: O(n²). The empirical 4× ratio confirms
this theoretical prediction.
O(log n) binary searchThe third pillar growth rate is logarithmic. A
logarithmic algorithm’s work grows so slowly that doubling
n adds only one more halving step. Growth is slow,
but it is not constant: log2(1_000_000) is about twice
log2(1_000).
Binary search is the canonical example. Given a sorted array and a target, halve the search range each step:
bsearch: func(arr, target) [
lo: 1
hi: len(arr)
found: -1
while lo <= hi [
mid: (lo + hi) div 2
if arr[mid] == target then [
found = mid
lo = hi + 1
] else if arr[mid] < target then [
lo = mid + 1
] else [
hi = mid - 1
]
]
found
]The trick for empirical verification: a single binary search runs in
microseconds even on huge arrays. bench’s millisecond
resolution can’t see it. Solution: amortize the timer
overhead by running thousands of searches per benchmark. Build
its input with collect(1..n) so the quadratic
concatenation-based make from §35.4 does not dominate setup
time (see Exercise 35.5).
repeated: func(arr, k) [
i: 1
last: 0
while i <= k [
last = bsearch(arr, i % len(arr) + 1)
i = i + 1
]
last
]
sizes: [1000, 10000, 100000]
i: 1
while i <= len(sizes) [
n: sizes[i]
arr: collect(1..n)
r: bench("bs n=" + str(n), func() [repeated(arr, 1000)])
println(n, "→", r.elapsed_ms, "ms (1000 lookups)")
i = i + 1
]Output:
1000 → 188.8 ms (1000 lookups)
10000 → 190.0 ms (1000 lookups) (ratio: 1.01×)
100000 → 179.4 ms (1000 lookups) (ratio: 0.94×)
These historical timings are illustrative, not a result from the
current interpreter. Their nearly flat curve alone does not establish
O(log n): measurement noise and fixed overhead can hide
slow growth. The halving argument establishes the bound; counting
iterations provides a better check than relying on a short timing
sample. Rerun to obtain local timings.
Theoretically: log₂(1000) ≈ 10,
log₂(10000) ≈ 13, log₂(100000) ≈ 17. So each
lookup does ~10 → ~13 → ~17 comparisons. The 1.7× more work per lookup
is invisible under the per-iteration overhead of the wrapper loop. The
ratio you’d see if comparisons were the only cost would be
13/10 = 1.3× and 17/13 = 1.3× — still
essentially flat.
This is why “use the right data structure” is such a powerful design
move. A sorted array + binary search beats a flat array + linear search
at any size above ~20 elements. At n = 1,000,000,
binary search is ~50,000× as fast in raw operation count.
The big growth rates, ranked from fastest to slowest:
| Class | Doubling ratio | When seen | Examples |
|---|---|---|---|
O(1) |
~1.0× | Direct lookups | Hash map get, array index |
O(log n) |
~1.0× (flat) | Halving the problem | Binary search, balanced-tree ops |
O(n) |
~2.0× | One pass through input | Sum, linear search, length |
O(n log n) |
~2.2-2.5× | Divide-and-conquer sorts | Merge sort, quick sort (avg), heap sort |
O(n²) |
~4.0× | Nested loops over input | Bubble/insertion/selection sort, naive pair-similarity |
O(n³) |
~8.0× | Triple-nested loops | Naive matrix multiplication |
O(2^n) |
doubles per +1 | Exhaustive subset enumeration | Brute-force SAT, naive recursive Fibonacci |
O(n!) |
grows faster than 2^n | Permutation enumeration | Brute-force TSP, traveling-salesperson |
Two ways to read this table:
As a design checklist. When you write a function whose input size you expect to grow, ask: which row of this table describes my function? If it’s in the bottom three, you probably want a different algorithm or your input is small-enough-forever to make it OK.
As a budget. Suppose your function takes 1ms on
n = 100. How big can n get before the function
takes 1 second?
| Class | n for 1 sec |
n for 1 hour |
|---|---|---|
O(1) |
∞ | ∞ |
O(log n) |
astronomically large | astronomically large |
O(n) |
100,000 | 360,000,000 |
O(n log n) |
~25,000 | ~50,000,000 |
O(n²) |
~3,162 | ~190,000 |
O(n³) |
~464 | ~7,143 |
O(2^n) |
~20 | ~32 |
O(n!) |
~10 | ~12 |
The lesson: every step down the table is a budget catastrophe.
O(n!) algorithms hit their one-hour limit at
twelve inputs. Twelve.
Big-O is a powerful abstraction — and like every powerful abstraction, it hides things.
1. Constant factors. O(n) is
O(n) whether the inner loop body is 3 instructions or
3,000. In practice, this matters: a clean O(n²)
can beat a messy O(n log n) at small
n. Std-lib sorts often switch to insertion sort
(O(n²)) for small partitions — because the constant factor
is smaller than O(n log n)’s overhead.
2. The constant inside the O. When we
say O(log n), the base of the logarithm doesn’t matter (all
logarithms differ by a constant factor). When we say 2 · n
or 100 · n, both are O(n). But that “100” is
real — your code runs 100× slower, even if the curve is the
same shape.
3. Cache effects. Modern CPUs have caches. An algorithm that touches memory in sequential order can be 10-100× faster than one that touches the same number of locations randomly. This is invisible to Big-O.
4. Amortized cost. Some operations are
usually fast but sometimes slow. Appending to a
dynamic array is O(1) amortized but O(n) once
per O(log n) operations (when the array resizes). The right
analysis is the amortized one; the worst-case one is misleading.
5. Best case / worst case / average case. Bubble
sort is O(n²) worst case but O(n) best case
(sorted input). Quick sort is O(n log n) average but
O(n²) worst case (pathological pivots). When the problem
statement gives you control over what inputs you’ll see, the average
case is the right metric. When inputs are adversarial, the worst case
is.
6. Memory. This chapter has talked only about time.
The parallel framework exists for memory: an algorithm’s space
complexity is how its memory use grows with n. Many
problems trade time for space (caching) or space for time (streaming).
The next chapter (Ch.36) shows merge sort explicitly using
O(n) extra space to achieve O(n log n)
time.
Let’s pull the technique together as a reusable workflow.
Step 1. Implement the algorithm under test.
Step 2. Write a make function that
produces a worst-case input of a given size. Worst case is what predicts
production behavior under load; best case is what makes you feel good
and wrong.
Step 3. Pick a doubling sequence covering the range
you care about: [100, 200, 400, 800] for small problems;
[1000, 2000, 4000, 8000, 16000] for medium.
Step 4. If individual runs are under ~10ms, wrap in
a repeated(arr, k) loop and divide out k.
Avoid measuring bench overhead instead of your
algorithm.
Step 5. Run, print, compute the doubling ratio.
Step 6. Compare to the table in §35.7. The ratio names the growth class.
Step 7. Sanity-check: does the named class match what you expected from reading the code? If not, you’ve found a bug — either in the analysis (the code does more work than you thought) or in the implementation (it has the wrong complexity).
This is the complexity design recipe, the Ch.4 design recipe extended to performance. Like Ch.4, it’s mostly mechanical once you’ve internalized it. And like Ch.4, it saves you from a class of bugs that otherwise show up at 3am.
Things this chapter doesn’t cover:
p
processors, some O(n²) algorithms become
O(n²/p). Beyond scope.bubble_sort is O(n²). The matching
theorem — that no comparison-based sort can be better than
O(n log n) — is a beautiful result, but its proof is its
own chapter.Things that do work in Axioma — verified in §35.4-35.6 above — and are everything you need for practical algorithm choice:
bench(label, func) for timing.The next two chapters — Ch.36 (Sorting Showcase) and Ch.37 (Graph Algorithms) — apply this machinery to the canonical CS2 algorithms. Each algorithm gets implemented, timed, and slotted into the table.
The callable bench(label, func() [work]) form used above
remains useful. Two expression forms avoid wrapping a one-off
computation in a function:
duration_report: elapsed sum(1..100)
benchmark_report: bench "sum a hundred integers" sum(1..100)
println(duration_report)
println(benchmark_report)Each operand is evaluated once. Timings depend on the machine and load, so the reports are observations, not fixed expected output. Do not place input construction inside only one competitor’s timer. Verify equal answers separately, then repeat measurements at several sizes.
These exercises calibrate your Big-O intuition empirically. Each one asks you to predict, measure, and reconcile. The prediction step matters most — if your guess matches your measurement, you’ve understood. If they disagree, the disagreement is the lesson.
Write a function count_evens(arr) that returns the
number of even integers in arr. Then write
sum_odds(arr) that returns the sum of odd integers in
arr.
Time both at n = 1000, 2000, 4000, 8000 using the
empirical recipe.
count_evens and sum_odds have
exactly the same elapsed time, even though they’re both
linear?(See: exercises/ch35/ex_35_1_linearity.ax)
Write a function count_pairs_summing_to(arr, target)
that counts, by brute force, the number of unordered
index pairs (i, j) with i < j and
arr[i] + arr[j] == target.
n = 100, 200, 400, 800 on arrays of distinct
integers.(See: exercises/ch35/ex_35_2_pair_count.ax)
Write linear_search(arr, target) (scan from index 1) and
reuse bsearch from §35.6.
For a sorted input array, find experimentally the smallest
n at which binary search beats linear search. Time both at
n = 10, 100, 1000, 10000. Show your work (table of
timings).
n does binary search win?n.)(See: exercises/ch35/ex_35_3_switchover.ax)
Write triple_sum(arr, target) that counts ordered
triples (i, j, k) with i < j < k and
arr[i] + arr[j] + arr[k] == target.
n = 50, 100, 200, 400.(See: exercises/ch35/ex_35_4_triple_sum.ax)
Look at make from §35.4:
make: func(n) [
xs: []
j: 1
while j <= n [
xs = xs + [j] # ← concatenation, not append
j = j + 1
]
xs
]O(n) or O(n²)?n = 1000, 2000, 4000, 8000 (note: the loop
body itself, not the array it produces).make linear or
quadratic? Why?(Hint: array concatenation typically copies. If concatenation is
O(k) for an array of length k, then doing it
n times costs 1 + 2 + … + n = O(n²). This is
the accidental quadratic bug — code that looks linear
but is secretly quadratic.)
(See:
exercises/ch35/ex_35_5_accidental_quadratic.ax)
Your data: from §35.4 and §35.5 you have measured timings for linear and quadratic algorithms on your machine.
Fill out this table for your machine, using the extrapolation method:
| Algorithm | n | Predicted time | Per §35.7 row, n for 1 sec | n for 1 hour |
|---|---|---|---|---|
linear_sum |
100,000 | ? | ? | ? |
bubble_sort |
10,000 | ? | ? | ? |
(your choice — O(n log n) candidate) |
100,000 | ? | ? | ? |
(See: exercises/ch35/ex_35_6_budget_table.ax)
Solutions to selected exercises: Chapter 35 · Solutions in Appendix C. (Repo file: exercises/solutions/ch35_solutions.md.)
Part VIII, Chapter 2. The classical sorting algorithms, implemented and empirically classified using the Ch.35 recipe. By chapter’s end you’ll know which sort to reach for, why no single sort is “best”, and — crucially — how an algorithm’s complexity in theory can differ from its complexity in practice when the underlying language’s operation costs don’t match the textbook’s assumptions.
Most CS2 textbooks teach four or five sorts. This chapter teaches six. Why?
Because every sort algorithm reveals a different design move:
| Sort | Design move | Complexity |
|---|---|---|
| Bubble | Repeated local fix-up | O(n²) |
| Insertion | Maintain a sorted prefix | O(n²) |
| Selection | Find min, swap to front | O(n²) |
| Merge | Divide, conquer, recombine | O(n log n) |
| Quick | Pivot partition + recurse | O(n log n) avg |
| Radix | Sort by digit, not comparison | O(d · n) |
The first three are comparison-based, in-place,
O(n²). Their differences live in the constant
factor and the best-case behavior on nearly-sorted
data.
The next two are comparison-based, divide-and-conquer,
O(n log n) — but they reach that asymptote via
opposite strategies: merge does easy splits, hard
combines; quick does hard splits, easy
combines.
Radix is a different beast entirely: it isn’t comparison-based, so it
sidesteps the O(n log n) lower bound and runs in
O(d · n) where d is the number of digits. It
only works for bounded-digit data (integers, fixed-length
strings).
A working programmer needs all six. They cover the design space.
O(n²))The simplest sort. Repeatedly walk the list, swapping adjacent
out-of-order pairs. After n passes, the array is
sorted.
bubble: func(a) [
s: a
m: len(s)
pass: 0
while pass < m - 1 [
k: 1
while k < m - pass [
if s[k] > s[k + 1] then [
t: s[k]
s[k] = s[k + 1]
s[k + 1] = t
]
k = k + 1
]
pass = pass + 1
]
s
]Walkthrough on [3, 1, 2]:
[1, 3, 2]; compare (3,
2) → swap to [1, 2, 3].Empirical complexity (reverse-sorted input, worst case):
n=50 → 5.4 ms
n=100 → 16.9 ms (3.15×)
n=200 → 63.6 ms (3.77×)
n=400 → 242.9 ms (3.82×)
The doubling ratio approaches 4× — O(n²) confirmed.
Almost never. Bubble sort is famous because it’s the
easiest to explain, not because it’s fast. The one place it has a niche:
detecting whether an array is sorted. A single pass
with no swaps proves the array is already sorted — and that pass is
O(n). If you suspect the input is nearly sorted,
bubble can finish in linear time with the early-exit optimization (track
whether any swap happened; if not, return).
O(n²))Maintain a sorted prefix. At each step, take the next element and insert it into the correct position within the prefix.
insertion: func(a) [
s: a
m: len(s)
i: 2
while i <= m [
key: s[i]
j: i - 1
while j >= 1 and s[j] > key [
s[j + 1] = s[j]
j = j - 1
]
s[j + 1] = key
i = i + 1
]
s
]The inner trick. Once we find a position where
s[j] <= key, we stop. This is what makes
insertion sort fast on nearly-sorted data: most elements don’t move far,
so the inner loop rarely runs to the end.
Worst case: reverse-sorted input. Every new element
walks all the way to position 1. Total work:
1 + 2 + ... + n = O(n²).
Best case: sorted input. The inner loop exits
immediately. Total work: O(n) — linear. This is
the key property that makes insertion sort the inner loop of many
production sort routines (Timsort uses it for small runs).
Both are O(n²) worst case. Both are stable (preserve the
relative order of equal elements). Both are in-place.
What’s different:
O(n)
on nearly-sorted input; bubble is too (with the early-exit
optimization), but the unoptimized bubble is always
O(n²).Bottom line: if you must use an O(n²)
sort, use insertion.
O(n²))For each position, find the minimum of the remaining suffix and swap it into place.
selection: func(a) [
s: a
m: len(s)
i: 1
while i <= m - 1 [
min_idx: i
j: i + 1
while j <= m [
if s[j] < s[min_idx] then [min_idx = j]
j = j + 1
]
if min_idx != i then [
t: s[i]
s[i] = s[min_idx]
s[min_idx] = t
]
i = i + 1
]
s
]The trick: selection sort minimizes writes
— at most n - 1 swaps total, regardless of input. Every
other sort in this chapter can do more writes than that.
Why this matters: when writes are expensive
(flash storage, slow caches, networked storage), selection sort can win
even though it’s O(n²) for comparisons. Most domains don’t
care, but if your sort is bottlenecked on storage write bandwidth,
selection sort is the right tool.
Worst case = best case: always O(n²)
comparisons regardless of input order. The advantage is constant write
count.
O(n log n) in theory)The first O(n log n) sort. Divide and
conquer:
The recurrence is T(n) = 2 · T(n/2) + O(n) for the merge
step — which solves to O(n log n).
merge: func(left, right) [
result: []
i: 1
j: 1
while i <= len(left) and j <= len(right) [
if left[i] <= right[j] then [
result = result + [left[i]]
i = i + 1
] else [
result = result + [right[j]]
j = j + 1
]
]
while i <= len(left) [
result = result + [left[i]]
i = i + 1
]
while j <= len(right) [
result = result + [right[j]]
j = j + 1
]
result
]
merge_sort: func(a) [
n: len(a)
small: n <= 1
if small then [
a
] else [
mid: n / 2
lp: a[1..mid]
rp: a[mid+1..n]
merge(merge_sort(lp), merge_sort(rp))
]
]Walkthrough on [3, 1, 4, 2]:
[3, 1] and [4, 2].merge_sort([3, 1]) → split → [3],
[1] → merge → [1, 3]. Similarly
[2, 4].[1, 3] and [2, 4]: 1, 2, 3, 4 —
done.Empirical complexity (in Axioma!):
n=100 → 2,243 ms
n=200 → 9,237 ms (4.12×)
n=400 → 37,096 ms (4.02×)
The doubling ratio is 4×, not the predicted ~2.3×
for O(n log n). What happened?
The textbook recurrence T(n) = 2 · T(n/2) + O(n)
assumes that the merge step is O(n). That
assumption depends on result + [x] being amortized
O(1).
In Axioma, array concatenation is O(k) for an array of
length k — exactly the accidental-quadratic pattern from Ex
35.5. So when merge builds a result of length
n via result + [x] in a loop, the merge step
is O(n²), not O(n).
The corrected recurrence: T(n) = 2 · T(n/2) + O(n²),
which solves to O(n² log n).
And O(n² log n)’s doubling ratio is roughly
4 × (log(2n)/log(n)) — which is close to 4× for moderate n.
The empirical 4× confirms the corrected analysis, not the textbook
one.
This is one of the deepest lessons in algorithm analysis:
complexity is a property of the algorithm plus the language’s
primitive operations. The same merge sort, written in Java’s
ArrayList or Python’s list, would be
O(n log n) because their append is amortized
O(1). In Axioma, with its immutable-style concatenation, it
isn’t.
The same caveat applies to any algorithm whose analysis
assumes O(1) append.
Three fixes:
n and write to it by index instead of
concatenating. This restores O(n) merge.O(1) prepend).O(1) prepend, so the merge step becomes natural.These are the realm of implementation engineering, and production sort routines do them. For pedagogy, the algorithm-as-presented is what every textbook teaches. The honest correction — “in this language, that algorithm is actually quadratic” — is the part most textbooks skip.
O(n log n) average, O(n²) worst)Hard split, easy combine, the opposite of merge sort.
less, equal in
equal, greater in greater.less and greater.partition_qs: func(a, pivot) [
less: []
equal: []
greater: []
i: 1
while i <= len(a) [
if a[i] < pivot then [less = less + [a[i]]] else [
if a[i] == pivot then [equal = equal + [a[i]]] else [
greater = greater + [a[i]]
]
]
i = i + 1
]
[less, equal, greater]
]
qsort: func(a) [
n: len(a)
small: n <= 1
if small then [
a
] else [
pivot: a[n / 2 + 1]
parts: partition_qs(a, pivot)
s_less: qsort(parts[1])
s_greater: qsort(parts[3])
s_less + parts[2] + s_greater
]
]Walkthrough on [3, 1, 4, 1, 5]:
4 (middle element).less = [3, 1, 1], equal = [4],
greater = [5].[3, 1, 1]: pivot=1, parts
[], [1, 1], [3] → result [1, 1, 3].[5]: base case, return as-is.[1, 1, 3] + [4] + [5] = [1, 1, 3, 4, 5].Empirical complexity (random input):
n=100 → 52.5 ms
n=200 → 110.8 ms (2.11×)
n=400 → 250.6 ms (2.26×)
The doubling ratio is ~2.2× — consistent with
O(n log n). Quicksort behaves much better than merge sort
in Axioma!
Both qsort and merge_sort use
+ on arrays. Why does quicksort scale
O(n log n) while merge sort scales
O(n² log n)?
Look at the work distribution.
In merge sort, the merge step at the top level builds a
single result array of length n by repeated
append. That’s O(n²) work in one merge call — at one level
of recursion.
In quicksort, the partition step appends to three different
arrays, each of which never grows larger than n total
across all of them. The total work for the partition’s appends,
across all recursive calls at one level, is O(n) — same as
the merge step would be if appends were O(1).
The mathematical detail: when you scatter n items into
many smaller arrays, the total cost of n appends across
them is O(n) if each array grows monotonically (each new
append goes onto a small array, not the big result array). When
you funnel n items into one growing result array, that’s
O(n²).
So quicksort is O(n log n) even with concat
append, because partitioning distributes the appends
rather than concentrating them.
O(n²)Quicksort hits O(n²) when the pivot is the smallest or
largest element of every recursion. With a deterministic “middle
element” pivot rule, an adversary can craft an input that triggers this.
Production routines use randomized pivot selection or
median-of-three heuristics to avoid this in
practice.
The textbook reflex: quicksort is O(n log n)
expected under randomized pivots; O(n²) worst
case. Use it when you trust the input distribution; consider heap sort
or merge sort when you can’t.
O(n log n) always)Heap sort uses a binary heap data structure to extract the minimum repeatedly.
A binary heap is a complete binary tree where every parent is ≤ its
children (min-heap) or ≥ its children (max-heap). Crucially, it can be
represented as a flat array — element at index i has
children at 2i and 2i + 1. No tree structure
stored.
We won’t implement heap sort fully in this chapter — it needs the
heap operations (heapify, siftdown,
extract_min) and would crowd out the algorithm itself in
pedagogical clarity. Ch.26 (Balanced Trees) covers the heap structure in
detail.
The two stages of heap sort: 1. Build
heap from input array — O(n) (not
O(n log n)! the analysis is subtle). 2.
Repeatedly extract min, placing it at the end —
n extractions, each O(log n) → total
O(n log n).
Why heap sort matters:
O(n log n) worst case — no
adversarial input.Heap sort is the right answer when you need guaranteed
O(n log n) and in-place. Production code rarely
picks heap sort directly, but it’s the fall-back when quicksort detects
pathological input (introsort, the algorithm behind C++
std::sort, switches to heap sort when recursion exceeds
2 log n levels).
O(d · n))The lone non-comparison sort. No comparisons — sorts by examining each digit (or byte) of each value in turn.
For 32-bit integers, d = 32 bits. For 64-bit,
d = 64. For strings of length 10, d = 10. The
number of digits d is constant for a fixed type,
so radix is O(n) in the input size when amortizing the
digit count.
The algorithm (LSD = least significant digit first):
After processing all digit positions, the values are sorted.
Why this is fast in theory and practice:
log n factor.d · n total work.Why this isn’t universal:
< operator — no.d can dominate. For
small n and large d, d · n can
exceed n log n (with log base 2, log₂(1M) =
20; sorting 1M 64-bit ints, n log n is 20M comparisons
while radix is 64M digit-extractions).The CS lower bound theorem — comparison-based sorts need
Ω(n log n) comparisons — has radix as the canonical escape.
By looking at the internal structure of values instead of just
their order, radix gets below the bound.
Production code chooses sorts based on what it knows about the input. Here’s a decision table for the sorts above:
| Situation | Best sort | Why |
|---|---|---|
| Tiny array (n < 16) | Insertion | Lowest constant factor |
| Nearly-sorted | Insertion | O(n) best case |
| Need stability | Merge | Always stable |
| Need in-place | Heap | O(1) extra memory, guaranteed
O(n log n) |
| Random input, average case | Quick | Best cache behavior, ~2× faster than merge in practice |
| Adversarial input | Heap, or Quick with randomized pivot | Quick’s worst case is O(n²) |
| Integers / fixed-width keys | Radix | Beats O(n log n) by skipping comparisons |
| Don’t know / don’t care | Std-lib sort | They’re hybrid (Timsort, introsort) for a reason |
Std-lib sort routines are typically hybrid:
std::sort): quicksort
base + heap sort fallback when recursion exceeds
2 log n.Each of these took years of engineering to tune. The “just call sort()” instinct is correct in 99% of cases. Knowing why it’s correct is what makes you good.
This chapter covered six sorts; CS literature has dozens. Some that are absent:
O(n^(3/2)) for many gap sequences;
occasionally beats n log n sorts in practice for medium
n.O(n log n), but space cost is high
and balancing matters.O(n + k) for
n values uniformly distributed in k buckets.
Useful for floating-point data with known range.p processors,
well-designed sorts achieve O((n log n) / p). Distinct
algorithms (sample sort, parallel merge sort).For practical CS2 fluency, the six in this chapter cover the design space. Once you know them, the rest are variations.
The most surprising result of this chapter: merge sort in
Axioma is empirically O(n² log n), not
O(n log n). This is a real result, derived from
real measurements, and caused by the language’s O(k) list
concatenation cost. The “fix” is to use a mutable buffer or a different
representation — but the textbook-as-written, dropped into Axioma,
doesn’t beat bubble sort for the sizes the chapter measures.
This is the complexity-is-language-dependent lesson that Ex 35.5 set up. The classical analysis is a model — the model’s accuracy depends on whether the language’s primitives match the model’s assumptions. When they don’t, the empirical curve diverges from the predicted one, and the measurement is the truth.
Sort an array of pairs (value, original_index) by
value using bubble, insertion, and quick. After sorting, check:
do pairs with the same value retain their original relative order?
(See: exercises/ch36/ex_36_1_stability.ax)
Time insertion(a) on:
(See: exercises/ch36/ex_36_2_insertion_best.ax)
The middle-element pivot is benign on random input but hostile to already-sorted input. Verify this:
qsort on a random array of size 800.qsort on a sorted array of size 800.qsort on a reverse-sorted array of size
800.What’s the ratio? Which trend does it follow — n log n
(2.2× per doubling) or n² (4× per doubling)?
(See: exercises/ch36/ex_36_3_qs_worst.ax)
Verify the chapter’s claim that merge sort runs
O(n² log n) in Axioma:
(See: exercises/ch36/ex_36_4_merge_surprise.ax)
Write a sort that works only for small non-negative integers:
counting_sort: func(arr, k) [
# k = maximum value
...
]Algorithm: 1. Make a count array of size k + 1,
initialized to 0. 2. For each value v in arr,
increment count[v]. 3. Walk through count,
emitting each value count[v] times.
n and k?(See: exercises/ch36/ex_36_5_counting_sort.ax)
Write a sort that switches strategies based on input size:
smart_sort: func(a) [
if len(a) < 16 then [
insertion(a)
] else [
qsort(a)
]
]smart_sort against pure qsort on
inputs of size 100, 200, 400.O(n log n) on average?(See: exercises/ch36/ex_36_6_hybrid.ax)
Solutions to selected exercises: Chapter 36 · Solutions in Appendix C. (Repo file: exercises/solutions/ch36_solutions.md.)
Part VIII, Chapter 3. Closes Part VIII’s algorithm- analysis arc by implementing the four canonical graph algorithms — BFS, DFS, Dijkstra, and minimum spanning tree — and slotting each one into the Ch.35 growth-class table.
Ch.27 introduced graphs as reified relationship
concepts: each edge is a TradeLink or
KnowsRelationship object with source and
target slots. That representation is rich, queryable, and
right for Cascade-style knowledge graphs where nodes carry arbitrary
structured data.
For algorithmic graphs — the ones you study to analyze shortest paths, spanning trees, connectivity — a different representation wins:
| Representation | When right |
|---|---|
| Reified Concept edges | Knowledge graphs, KR systems |
| Adjacency list (dict of arrays) | Sparse graphs in languages with O(1) hash |
| Adjacency matrix (2D array) | Dense graphs; integer-indexed nodes |
| Edge list | Streaming, batch processing |
For this chapter, we use adjacency matrices with integer node
IDs: nodes are numbered 1 through n, and g[i][j]
holds the weight of the edge from i to j (0 means no edge).
Integer node IDs make the loop invariants and edge lookups easy to
follow. This is a choice of representation, not a restriction of Axioma:
dictionaries can also hold mutable adjacency lists. Here g
is a nested Array, so access is g[i][j]; the separate
Matrix type uses m[i, j].
Every algorithm in this chapter starts from this scaffolding:
make_matrix: func(n) [
m: []
i: 1
while i <= n [
row: []
j: 1
while j <= n [
row = row + [0]
j = j + 1
]
m = m + [row]
i = i + 1
]
m
]
add_edge: func(m, u, v, w) [
m[u][v] = w
m[v][u] = w # undirected
m
]
make_bools: func(n) [
b: []
i: 1
while i <= n [
b = b + [false]
i = i + 1
]
b
]Sample graph (5 nodes, undirected):
A — B
| |
C — D — E
Built as:
n: 5 # 1=A, 2=B, 3=C, 4=D, 5=E
g: make_matrix(n)
g[1][2] = 1; g[2][1] = 1 # A-B
g[1][3] = 1; g[3][1] = 1 # A-C
g[2][4] = 1; g[4][2] = 1 # B-D
g[3][4] = 1; g[4][3] = 1 # C-D
g[4][5] = 1; g[5][4] = 1 # D-EGoal: visit every reachable node, in order of distance from the start.
Use cases: finding the shortest path in an unweighted graph, level-by-level exploration, bipartite-graph detection.
bfs: func(g, start) [
n: len(g)
visited: make_bools(n)
visited[start] = true
queue: []
queue = queue + [start]
head: 1
result: []
while head <= len(queue) [
node: queue[head]
head = head + 1
result = result + [node]
j: 1
while j <= n [
if g[node][j] != 0 then [
if not visited[j] then [
visited[j] = true
queue = queue + [j]
]
]
j = j + 1
]
]
result
]Walkthrough on the sample graph starting from A (= 1):
Complexity:
O(n²) for adjacency
matrix.O(n + m) where m = edge count.The queue trick. Notice we use an index
pointer head instead of slicing the front of
queue. Slicing would copy the queue every step (the
accidental quadratic from Ex 35.5). Index advancement is
O(1) per step, preserving overall O(n²).
Goal: visit every reachable node, going as deep as possible before backtracking.
Use cases: cycle detection, topological sort, strongly- connected-component algorithms (Tarjan, Kosaraju), maze solving with stack-of-choices.
Two natural implementations: iterative with explicit stack, or recursive (the language stack is the explicit stack).
dfs_iter: func(g, start) [
n: len(g)
visited: make_bools(n)
stack: []
stack = stack + [start]
result: []
while len(stack) > 0 [
node: stack[len(stack)]
stack = stack[1..len(stack)-1]
already: visited[node]
if not already then [
visited[node] = true
result = result + [node]
j: n
while j >= 1 [
if g[node][j] != 0 then [
if not visited[j] then [
stack = stack + [j]
]
]
j = j - 1
]
]
]
result
]The trick: push neighbors in reverse order, so the first-numbered neighbor is on top of the stack and gets visited next. This makes the output match the natural “left-first” DFS order.
Walkthrough on sample graph starting from A (= 1):
Wait — the if not visited[j] guard in the inner loop means we only push unvisited neighbors. So:
Result: [A, B, D, C, E].
Recursive DFS:
dfs_rec_helper: func(g, node, visited, result) [
visited[node] = true
result = result + [node]
n: len(g)
j: 1
while j <= n [
if g[node][j] != 0 then [
if not visited[j] then [
result = dfs_rec_helper(g, j, visited, result)
]
]
j = j + 1
]
result
]
dfs_rec: func(g, start) [
n: len(g)
visited: make_bools(n)
result: []
dfs_rec_helper(g, start, visited, result)
]Same complexity, different stack semantics. The recursive version
uses Axioma’s call stack — at depth n, you can hit
stack-overflow on extreme graphs. The iterative version uses an explicit
heap-allocated stack. It avoids recursive call depth, though its own
storage and runtime limits still apply.
Complexity: Same as BFS — O(n²) for
adjacency matrix, O(n + m) for adjacency list.
Goal: in a graph with non-negative edge weights, find the shortest distance from a single source to every other node.
Use cases: road navigation, network routing protocols, Kevin-Bacon-style “degrees of separation” weighted by relationship strength.
The algorithm:
dist[start] = 0, all others =
∞.dist[current] + edge_weight < dist[neighbor], update
dist[neighbor].has_negative_edge: func(g) [
i: 1
while i <= len(g) [
j: 1
while j <= len(g[i]) [
if g[i][j] < 0 then return true
j = j + 1
]
i = i + 1
]
false
]
dijkstra: func(g, start) [
n: len(g)
if start < 1 or start > n then return error("start must name a graph node")
if has_negative_edge(g) then return error("Dijkstra requires nonnegative weights")
dist: []
i: 1
while i <= n [
dist = dist + [none]
i = i + 1
]
dist[start] = 0
visited: make_bools(n)
iter: 1
while iter <= n [
# Find unvisited node with min dist
best: -1
best_d: none
k: 1
while k <= n [
if not visited[k] and dist[k] != none then [
if best_d == none or dist[k] < best_d then [
best_d = dist[k]
best = k
]
]
k = k + 1
]
reached: best == -1
if reached then [
iter = n + 1
] else [
visited[best] = true
# Relax neighbors
j: 1
while j <= n [
w: g[best][j]
if w != 0 and not visited[j] then [
alt: dist[best] + w
if dist[j] == none or alt < dist[j] then [dist[j] = alt]
]
j = j + 1
]
iter = iter + 1
]
]
dist
]Walkthrough on a weighted graph:
2 3
A ---- B ---- D
| / \
5| 1 1
| / |
C ----- E -----+
4 2
Numbered nodes: 1=A, 2=B, 3=C, 4=D, 5=E.
g: make_matrix(5)
g[1][2] = 2; g[2][1] = 2
g[1][3] = 5; g[3][1] = 5
g[2][3] = 1; g[3][2] = 1
g[2][4] = 3; g[4][2] = 3
g[3][5] = 4; g[5][3] = 4
g[4][5] = 1; g[5][4] = 1
println(dijkstra(g, 1))
# Expected: [0, 2, 3, 5, 6]
# A-A = 0
# A→B = 2
# A→B→C = 3 (shorter than direct A→C = 5)
# A→B→D = 5
# A→B→D→E = 6Complexity:
n iterations.O(n).O(n).O(n²).A binary-heap Dijkstra with adjacency lists costs
O((n + m) log n), often preferable for sparse graphs. For
dense graphs, substituting m = O(n²) gives an
O(n² log n) bound; the simple scan above is
O(n²). The implementations do not have the same bound.
Here none represents an unreachable distance, so there
is no arbitrary maximum path length. The matrix must be square, the
start index valid, and every edge weight nonnegative. Zero still denotes
an absent edge.
Goal: find a subset of edges that connects all nodes with minimum total weight and no cycles.
Use cases: network design (lay cable to connect houses at minimum cost), clustering, image segmentation.
Prim’s algorithm: grow a tree one edge at a time, always picking the cheapest edge that adds a new node.
prim: func(g, start) [
n: len(g)
if start < 1 or start > n then return error("start must name a graph node")
in_tree: make_bools(n)
in_tree[start] = true
mst: []
total_cost: 0
iters: 1
while iters <= n - 1 [
# Find cheapest edge from in_tree to out_of_tree
best_u: -1
best_v: -1
best_w: none
u: 1
while u <= n [
if in_tree[u] then [
v: 1
while v <= n [
if not in_tree[v] then [
w: g[u][v]
if w != 0 then [
if best_w == none or w < best_w then [
best_w = w
best_u = u
best_v = v
]
]
]
v = v + 1
]
]
u = u + 1
]
stuck: best_v == -1
if stuck then [
return error("a spanning tree requires a connected graph")
] else [
in_tree[best_v] = true
mst = mst + [[best_u, best_v, best_w]]
total_cost = total_cost + best_w
iters = iters + 1
]
]
[mst, total_cost]
]Walkthrough on the weighted graph from §37.5 starting at A (= 1):
Complexity of this teaching version:
O(n³). Each of up to n - 1 additions
scans the n × n matrix. Keeping the cheapest known
connection for each outside node reduces a matrix implementation to
O(n²); a binary heap with adjacency lists gives
O((n + m) log n). This function reports a disconnected
graph instead of calling a partial tree an MST. Unlike Dijkstra, Prim
can use negative edge weights.
Kruskal’s algorithm is the alternative: sort all
edges by weight, then add each edge if it doesn’t create a cycle (use a
union-find structure to check). Same output, different shape.
O(m log m). Both belong in the toolkit; Prim is cleaner for
matrix representation.
| Algorithm | Output | Cost | When right |
|---|---|---|---|
| BFS | Visit order by distance | O(n²) |
Shortest path unweighted; layer-by-layer |
| DFS | Visit order by depth | O(n²) |
Cycle detection; topo sort; SCC |
| Dijkstra | Shortest path from source | O(n²) |
Shortest path with non-negative weights |
| Prim’s MST | Min-weight connecting tree | O(n³) here; O(n²) with cached best
edges |
Network design; clustering |
All four are O(n²) for the adjacency-matrix
representation used in this chapter. With adjacency lists + priority
queues, BFS/DFS remain O(n + m) and Dijkstra/Prim become
O((n + m) log n). The choice depends on:
In Cascade (the production system from Volume III), the graph has thousands of nodes and tens of thousands of edges — sparse — so the production code uses NetworkX (adjacency-list + heap-based) algorithms. We covered those in Vol III Ch.47.
For a CS2 graph chapter to be honest, here’s what we left out:
O(n³). When you need every pair, not just
from-one-source.O(n · m). Detects negative cycles.O(n + m). Foundational.O(n + m).O(V · E²) to O(V² · E) depending on
variant.Each of these is its own chapter in graduate algorithm courses. The four in this chapter cover the design patterns — visit, search, weight, structure — that the others reuse.
Let’s examine the O(n²) growth prediction. We’ll
generate dense random graphs of growing size and time Dijkstra.
dense_random_graph: func(n) [
g: make_matrix(n)
seed: 1
i: 1
while i <= n - 1 [
j: i + 1
while j <= n [
seed = (seed * 1103515245 + 12345) % 2147483648
w: seed % 10 + 1 # weights 1..10
g[i][j] = w
g[j][i] = w
j = j + 1
]
i = i + 1
]
g
]
sizes: [50, 100, 200]
i: 1
while i <= len(sizes) [
n: sizes[i]
g: dense_random_graph(n)
r: bench("dijkstra n=" + str(n), func() [dijkstra(g, 1)])
println(n, "->", r.elapsed_ms, "ms")
i = i + 1
]For sufficiently large inputs, the quadratic model suggests doubling ratios approaching 4×; interpreter overhead, allocation and timing noise can obscure that pattern. Here construction is outside the timed callback. Timings are evidence to compare with the loop analysis, not a proof.
An adjacency matrix gives constant-time edge lookup and uses
O(n²) storage, including entries for absent edges. Scanning
a vertex’s neighbors takes O(n); an adjacency list is
usually a better fit for a large sparse graph.
Our examples reserve weight zero for “no edge.” That convention cannot represent a genuine zero-weight edge. A production design must distinguish absence from weight, for example with a separate Boolean adjacency table. Dijkstra’s algorithm also requires nonnegative edge weights; it is not a general replacement for algorithms that permit negative edges.
Axioma supports a dictionary of arrays as another representation:
graph: {"A": ["B"]}
graph["B"] = ["C"]
println(graph["B"] is Array) # true
println(graph["B"][1]) # CA one-element array stays an array, and indexed dictionary assignment updates the dictionary. Choose a representation for its operations and space cost. The Cascade integration is a separate topic, deferred here.
Modify bfs to return not just the visit order, but the
shortest path from start to each reachable node.
(Hint: track a parent array; parent[v] is
the node from which we first reached v. Reconstruct the
path by walking parents back to the start.)
(See: exercises/ch37/ex_37_1_bfs_path.ax)
Write has_cycle(g) that returns true if the
graph contains a cycle, false otherwise.
(Hint: DFS the graph; if you find an edge to an already-visited-in-this-traversal node that isn’t the parent, you’ve found a cycle.)
(See: exercises/ch37/ex_37_2_cycle.ax)
Write count_components(g) that returns the number of
connected components of an undirected graph.
(Hint: BFS from each unvisited node, counting starts.)
(See: exercises/ch37/ex_37_3_components.ax)
Time Dijkstra at sizes n = 50, 100, 200, 400 on dense random graphs.
Compute the doubling ratio. Does it match O(n²)?
(See: exercises/ch37/ex_37_4_dijkstra_scale.ax)
Build a small graph with one negative edge. Run Dijkstra on it. Compare the result to the actual shortest path. Where did Dijkstra go wrong?
(See: exercises/ch37/ex_37_5_neg_weights.ax)
Read about Kruskal’s algorithm. On the sample graph from §37.5, trace by hand what Kruskal would do (sort edges, add cheapest first). Compare the resulting MST to Prim’s.
(See: exercises/ch37/ex_37_6_prim_kruskal.ax)
Solutions to selected exercises: Chapter 37 · Solutions in Appendix C. (Repo file: exercises/solutions/ch37_solutions.md.)
Part IX, Chapter 1. Opens the Stateful Systems arc. Volume I touched state machines briefly in Ch.16 (Mutation). This chapter promotes them to a first-class design tool — the right abstraction for protocols, parsers, UI state, and any system whose behavior depends on history.
A state machine has four parts:
The classic example: a turnstile.
States: locked, unlocked
Transitions:
(locked, coin) → unlocked
(locked, push) → locked (refused)
(unlocked, coin)→ unlocked (no-op)
(unlocked, push)→ locked
Real-world state machines:
State machines are the tool when “what happens next depends on what already happened.”
The most direct encoding: a dictionary keyed by (current_state, input) pairs, valued by next_state.
A dictionary literal makes the whole transition table visible in one place. Indexed assignment could update it later, but this example keeps the machine’s rules fixed while the current state changes.
turnstile: {
"locked.coin": "unlocked",
"locked.push": "locked",
"unlocked.coin": "unlocked",
"unlocked.push": "locked"
}
do_step: func(machine, state, input) [
key: state + "." + input
machine[key]
]
run_machine: func(machine, start, inputs) [
states: [start,] # one-element Array containing the initial state
cur: start
i: 1
while i <= len(inputs) [
cur = do_step(machine, cur, inputs[i])
states = states + [cur]
i = i + 1
]
states
]Run it:
result: run_machine(turnstile, "locked",
["coin", "push", "push", "coin", "push"])
println(result)
# → ["locked", "unlocked", "locked", "locked",
# "unlocked", "locked"]Walkthrough:
The trace shows the full history — useful for testing and debugging.
Strengths of dict-table representation:
machine’s keys are exactly the legal
(state, input) pairs.Weaknesses:
For state machines where transition logic is more complex, use a match expression:
do_step2: func(state, input) [
if state == "locked" then [
if input == "coin" then "unlocked"
else "locked"
] else [
# unlocked
if input == "push" then "locked"
else "unlocked"
]
]Reads top-to-bottom: “if locked, then coin unlocks else stay locked; if unlocked, then push locks else stay unlocked.”
When this style wins:
The third encoding: one function per state. Each function returns the next-state function (a continuation-passing style).
locked_state: func(input) [
if input == "coin" then ["unlocked", "unlocked_state"]
else ["locked", "locked_state"]
]
unlocked_state: func(input) [
if input == "push" then ["locked", "locked_state"]
else ["unlocked", "unlocked_state"]
]
run_machine3: func(start_name, start_fn, inputs) [
states: [start_name]
handler: start_fn
i: 1
while i <= len(inputs) [
result: handler(inputs[i])
states = states + [result[1]]
next_name: result[2]
if next_name == "locked_state" then [handler = locked_state]
if next_name == "unlocked_state" then [handler = unlocked_state]
i = i + 1
]
states
]This style is verbose but scales. Each state’s function can:
It’s how actor systems and OOP state
pattern encode state machines. Erlang’s gen_statem
and Akka’s FSM are this pattern in production.
When this style wins:
Let’s tabulate trade-offs:
| Style | Best for | Drawbacks |
|---|---|---|
| Transition table (dict) | Small, fully-enumerated machines | Sparse for large state spaces |
| Match on (state, input) | Conditional transitions; small machines | Hard to enumerate complete coverage |
| Functions per state | Large state spaces; per-state private state | Verbose; harder to visualize |
Rule of thumb:
Real production code often mixes them: a top-level dispatch table that calls into a per-state handler function. The dispatch table gives you the high-level “map of the machine”; the handlers give you the per-state complexity.
Extracting quoted strings illustrates a small state machine. This teaching scanner accepts ordinary characters and escaped quotes, slashes, and backslashes. It is not a complete JSON parser: JSON also defines control-character escapes, Unicode escapes, and validation of the surrounding document. Unsupported escapes are rejected explicitly:
States: outside, inside, escape
Transitions:
outside, " → inside
inside, " → outside (string closes)
inside, \ → escape
inside, * → inside (consume char)
escape, * → inside (consumed the escape char)
parse_json_strings: func(text) [
state: "outside"
strings: []
current: ""
for c in text [
if state == "outside" then [
if c == "\"" then [state = "inside"]
] else if state == "inside" then [
if c == "\"" then [
strings = push(strings, current)
current = ""
state = "outside"
] else if c == "\\" then [state = "escape"]
else [current = current + c]
] else [
if c == "\"" or c == "\\" or c == "/" then [
current = current + c
] else [error("This teaching scanner only handles quote, slash, and backslash escapes")]
state = "inside"
]
]
if state != "outside" then error("Unterminated string")
strings
]
println(parse_json_strings(r'{"name": "Alice", "age": 30}'))
# → ["name", "Alice", "age"]
println(parse_json_strings(r'{"text": "say \"hi\""}'))
# → ["text", "say \"hi\""](Note: the integer 30 value isn’t a quoted string, so it
doesn’t get captured.)
The parser shows the workhorse role of state machines: every parser, every lexer, every protocol implementation has a state machine at its core. Once you see one, you see them everywhere.
The Ch.4 design recipe extended for state machines:
Step 5 is the one most engineers skip. A whiteboard diagram catches “what happens if X arrives in state Y?” omissions that a code review would miss. Draw it first.
Not every “system with stateful behavior” is best expressed as a finite state machine. Two anti-patterns:
Anti-pattern 1: Too many states. If your “machine” has 50,000 states (e.g., one per row in a database), it’s not a state machine — it’s data. Use a database table.
Anti-pattern 2: Concurrent overlapping states. If multiple states can be active simultaneously (“Connected” AND “Authenticated”), it’s not a single state machine — it’s several machines running in parallel. Use composition: one machine per orthogonal concern.
The rule of thumb: a state machine works when state × input → next state is a function. If the “function” needs to query a database, talk to a network, or check the time — it’s no longer a finite state machine; it’s a control system.
A state machine processes one input at a time:
step(state, input) → new_state. An event loop runs
that step over and over, fed by an external queue:
forever:
input = wait_for_event()
state = step(state, input)
if state is final: exit
The state machine says what to do per input; the event loop delivers the inputs. Together they form the reactive-systems pattern.
The next chapter (Ch.39 Event Loops & Reactors) introduces the event-loop side. The chapter after that (Ch.40 The Cascade Reactor) shows a production reactor that uses both ideas at scale.
The plain deterministic finite state machine covered here is the foundation. The variants above all build on this same skeleton — states, transitions, current state — and add machinery on top.
Implement a state machine for a four-digit combination lock. States:
idle, entering_1, entering_2,
entering_3, unlocked. The combination is
“1234”. After 3 wrong attempts, the lock goes into a
locked_out state.
(See: exercises/ch38/ex_38_1_combolock.ax)
Given a state machine table, write
check_complete(machine, states, inputs) that returns
true if every (state, input) pair has an entry in the
machine.
(See: exercises/ch38/ex_38_2_coverage.ax)
Implement a vending machine with states: idle,
selecting, paying, dispensing.
Inputs: insert_coin, select_product,
cancel, complete. Use the per-state function
style from §38.4.
(See: exercises/ch38/ex_38_3_vending.ax)
Implement a traffic light: green → yellow → red → green. Inputs are
tick events. Track how many ticks have happened in each
color (green:30, yellow:5, red:25 seconds).
(See: exercises/ch38/ex_38_4_traffic.ax)
Implement two state machines that share input but maintain independent state:
After 15 presses, both reach state 0 simultaneously.
(See: exercises/ch38/ex_38_5_compose.ax)
Read a real-world state machine documented online (TCP, HTTP/2, the W3C DOM, etc.) and answer:
(See: exercises/ch38/ex_38_6_critique.ax —
open-ended)
Solutions to selected exercises: Chapter 38 · Solutions in Appendix C. (Repo file: exercises/solutions/ch38_solutions.md.)
Part IX, Chapter 2. Picks up directly from Ch.38. A state machine describes what the system does in response to an input. An event loop is the runtime that delivers the inputs. Together they form the backbone of every long-running interactive system you will ever write — GUIs, servers, game engines, simulators, and (in Ch.40) the Cascade reactor.
The state machines of Ch.38 had a hidden assumption: somebody is feeding them inputs in order. We wrote:
result: run_machine(turnstile, "locked",
["coin", "push", "push"])That ["coin", "push", "push"] array is a tidy fiction.
In the real world, inputs don’t arrive in a pre-baked array. They arrive
over time, from multiple sources, and the program has
to wait for the next one. A bank ATM doesn’t get a script of
keypresses — it sits idle, then a customer touches the screen, then it
processes, then it sits idle again.
This pattern — “sit idle, wait for input, process, return to idle” — is the event loop. The state machine is the transition logic; the event loop is the delivery mechanism. Every interactive system has both, even if you’ve never named them.
In this chapter we’ll build the event loop from scratch — first a 12-line minimum viable version, then adding dispatch tables, re-entrancy, scheduling, and stop conditions. By §39.7 you’ll have a working reactor: an event loop with structured dispatch and a quiescence condition. Ch.40 then turns it into a durable, recoverable reactor — the Cascade pattern.
The event loop is the dominant control-flow shape in:
NSRunLoop, Win32’s
GetMessage/DispatchMessage, GTK’s
g_main_loop_run — all the same pattern, different
names.epoll/kqueue/io_uring on
Linux/BSD/recent Linux. The loop blocks on the kernel syscall, wakes
when a socket is readable, dispatches.If you’ve used any interactive system, you’ve used an event loop. This chapter is about making it explicit.
The simplest event loop has four ingredients:
In Axioma:
queue: []
queue = queue + ["greet"]
queue = queue + ["count"]
queue = queue + ["bye"]
head: 1
while head <= len(queue) [
ev: queue[head]
head = head + 1
if ev == "greet" then [println("hello!")]
if ev == "count" then [println("1, 2, 3")]
if ev == "bye" then [println("goodbye!")]
]Output:
hello!
1, 2, 3
goodbye!
That’s a real event loop. Twelve lines. Notice three design choices already:
(a) Head pointer, not array-shift. We don’t pop from
the front by rebuilding the array (which would be O(n) per pop —
accidental quadratic, see Ch.35). We advance an integer
head index. The queue grows by append, the head advances by
increment. Both O(1). This is the standard trick for high-throughput
event loops.
(b) The loop runs until queue is drained. With no
re-entry, the loop terminates when head > len(queue).
Add re-entry (§39.4) and termination depends on whether new events
outpace consumption.
(c) The dispatch is an if-chain. Fine
for two or three events. By the time you have ten, it’s maintenance
pain. §39.3 promotes the chain to a table.
If you squint, this loop is a streaming computation: events flow in, side-effects flow out. The handler chain is the processor. The queue is the backlog. A long-lived loop is one whose queue never empties for long. (Compare to Ch.17 streams: same shape, different notation.)
The if-chain doesn’t scale. The fix is to make dispatch
data-driven: store handlers in a dict keyed by event type, look
up at runtime.
on_greet: func(payload) [
println("hello, ", payload, "!")
]
on_count: func(payload) [
i: 1
while i <= payload [
println(i)
i = i + 1
]
]
on_bye: func(payload) [
println("goodbye, ", payload, "!")
]
handlers: {
"greet": on_greet,
"count": on_count,
"bye": on_bye
}Now each event carries a payload too — the data the handler needs. Events become (type, payload) pairs:
queue: []
queue = queue + [("greet", "world")]
queue = queue + [("count", 3)]
queue = queue + [("bye", "world")]
head: 1
while head <= len(queue) [
ev: queue[head]
head = head + 1
h: handlers[ev[1]]
if h == none then [
println("unknown event:", ev[1])
] else [
h(ev[2])
]
]Output:
hello, world!
1
2
3
goodbye, world!
What we gained. The loop body is now event-type
agnostic. Adding a new event means writing a handler function and adding
one dict entry. The loop itself never changes. Compare to the
if-chain version: every new event meant editing the loop.
That is the practical difference between code and data.
A new event type “scroll”? One change:
on_scroll: func(payload) [println("scroll:", payload)]
handlers["scroll"] = on_scrollThe loop runs unchanged: the new dictionary entry participates in the
same dispatch lookup. Indexed mutation changes the dictionary itself;
replacing a captured binding instead requires rebind
(Chapter 16).
If you’ve read object-oriented design patterns, the dispatch table is
the Visitor pattern stripped of its classes. In OO languages each event
class has a visit() method; in functional/dict-based
languages the dispatch is the dict lookup. Same idea, less
ceremony.
Here is where the event loop gets interesting. In real systems, handlers often post new events. A “socket-readable” handler reads the bytes, parses a message, and posts a “message-received” event for the application layer. A “click” handler validates input and posts a “form-submit” event.
The pattern is straightforward — but only because we chose the head-pointer design in §39.2.
queue: []
queue = queue + [("tick", 0)]
on_tick: func(payload) [
println("tick:", payload)
# Schedule the next tick, up to 3
small: payload < 3
if small then [
rebind queue = queue + [("tick", payload + 1)]
]
]
handlers: { "tick": on_tick }
head: 1
while head <= len(queue) [
ev: queue[head]
head = head + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
println("processed", head - 1, " events")Output:
tick: 0
tick: 1
tick: 2
tick: 3
processed 4 events
The handler reaches up into its enclosing scope to extend
queue. Because the loop reads len(queue)
afresh each iteration, the new events get picked up. This is the
re-entrant posting pattern — handlers schedule future
work without knowing anything about the loop itself.
If we’d implemented “pop from front” as
queue = queue[2:] (Python-list style), each new event
posted mid-loop would shift the indices of already-pending
events. The bookkeeping gets ugly. With the head-pointer style:
queue[head] is the next event to processlen(queue) + 1head advances monotonically; nothing else movesThis is exactly how kqueue/epoll work under
the hood: a userspace ring buffer with a producer index (kernel writes)
and a consumer index (program reads), both advancing forever.
Re-entrant posting is powerful — and dangerous. Remove the
small guard above:
on_tick: func(payload) [
println("tick:", payload)
rebind queue = queue + [("tick", payload + 1)] # always
]…and the loop runs forever. Each handler invocation adds one event;
len(queue) grows as fast as head advances; the
loop never terminates. In production, this is one of the top three
event-loop bugs (along with starvation §39.9 and re-entrant deadlocks
§39.9). Always have a stop condition.
Three ways to stop an event loop:
(a) Natural drain. Process all events, queue empties, loop exits. Works for one-shot batch jobs (processing a saved log, replaying a transcript).
(b) Stop flag. A handler sets a boolean; the loop checks it each iteration. Works for “shutdown on specific event” scenarios.
stop: false
n_ticks: 0
on_tick: func(payload) [
rebind n_ticks = n_ticks + 1
println("tick #", n_ticks, " payload=", payload)
if n_ticks >= 3 then [rebind stop = true]
]
queue: []
i: 1
while i <= 10 [
queue = queue + [("tick", i)]
i = i + 1
]
head: 1
while head <= len(queue) [
if stop then [head = len(queue) + 1] else [
ev: queue[head]
head = head + 1
if ev[1] == "tick" then [on_tick(ev[2])]
]
]
println("stopped after ", n_ticks, " ticks")Output:
tick # 1 payload= 1
tick # 2 payload= 2
tick # 3 payload= 3
stopped after 3 ticks
The if stop then [head = len(queue) + 1] trick forces
the while to exit on the next test. We can’t
break out of an Axioma while directly, so we
make the loop condition false by other means. (See Ch.16 for more on
this Axioma idiom.)
(c) Timeout / wall-clock limit. “Run for at most 30
seconds.” We’ll see this in §39.6 with virtual time. In production with
real wall-clock time, you’d check
system_time() - start >= limit each iteration.
A production-grade event loop usually has all three: drains naturally on normal completion, has a shutdown flag for graceful exit (SIGTERM handler sets it), and a hard timeout for catastrophic recovery.
So far events have been immediate: process in order of arrival. Many systems need scheduled events: “fire this alarm at t=30 seconds”, “retry this HTTP request after 2 seconds”. The natural data structure is a priority queue ordered by scheduled time.
Let’s build a tiny discrete-event simulator. Events have (time, type, payload). The loop always processes the earliest event next, advancing virtual time as it goes.
queue: []
queue = queue + [(0, "start", 0)]
now: 0
max_t: 30
# Bubble-sort by time field [1]; small queue, O(n^2) acceptable
sort_queue: func() [
n: len(queue)
i: 1
while i <= n [
j: i + 1
while j <= n [
if queue[i][1] > queue[j][1] then [
tmp: queue[i]
queue[i] = queue[j]
queue[j] = tmp
]
j = j + 1
]
i = i + 1
]
]
schedule: func(delay, typ, payload) [
rebind queue = queue + [(now + delay, typ, payload)]
sort_queue()
]
on_start: func(p) [
println("[t=", now, "] start; scheduling pings")
schedule(5, "ping", "a")
schedule(2, "ping", "b")
schedule(8, "ping", "c")
]
on_ping: func(p) [
println("[t=", now, "] ping ", p)
]
while len(queue) >= 1 [
# Pop head
ev: queue[1]
new_q: []
i: 2
while i <= len(queue) [
new_q = new_q + [queue[i]]
i = i + 1
]
queue = new_q
now = ev[1]
typ: ev[2]
payload: ev[3]
halt: now > max_t
if halt then [queue = []] else [
if typ == "start" then [on_start(payload)]
if typ == "ping" then [on_ping(payload)]
]
]
println("done at t=", now)Output:
[t= 0 ] start; scheduling pings
[t= 2 ] ping b
[t= 5 ] ping a
[t= 8 ] ping c
done at t= 8
Note the events came out in time order, not posting
order. on_start scheduled three pings at delays 5,
2, 8 — they fired at virtual times 2, 5, 8.
In a simulator (the canonical example: queueing-theory ED simulations, network packet-arrival models, traffic flow), virtual time advances as fast as the queue can be drained. Five virtual seconds might take 0.5 real microseconds.
In a real-time event loop (a game, a UI), virtual time
equals wall-clock time — the loop sleeps until the next event
is due, then fires. This is the
select/epoll/kqueue “timeout”
parameter at the OS level.
Both cases use the same data structure (priority queue by time) and the same dispatch logic. The difference is the bridge between virtual time and the outside world.
We used bubble-sort because the queue is small (a few dozen events at most). Real event loops use a heap (priority queue with O(log n) insert and O(log n) pop-min) — see Ch.36 §36.7 for the algorithm and Ch.51 for how Cascade implements one over SQLite.
Putting it all together: dispatch table + re-entrant queue +
stop condition + scheduler = reactor. The name comes from
Erlang/OTP’s gen_server and Reactor Netty; it captures the
idea that the loop reacts to events rather than
driving the program.
A reactor has a well-defined external interface:
post(event) # add to queue
schedule(delay, event) # add to scheduled queue
stop() # set the shutdown flag
run() # the loop itself
Internally it has:
queue # FIFO of immediate events
scheduled # priority queue of (time, event)
handlers # dispatch table: type → function
state # arbitrary application state
Let’s package it as a coherent unit. We’ll use a
dict-of-closures style that simulates a small object.
Handlers are passed to make_reactor as a dictionary at
construction time, making the initial dispatch table explicit. Updating
an entry later is possible; this example keeps that configuration
fixed.
make_reactor: func(handlers) [
queue: []
scheduled: []
stop_flag: false
now: 0
sort_scheduled: func() [
n: len(scheduled)
i: 1
while i <= n [
j: i + 1
while j <= n [
if scheduled[i][1] > scheduled[j][1] then [
tmp: scheduled[i]
scheduled[i] = scheduled[j]
scheduled[j] = tmp
]
j = j + 1
]
i = i + 1
]
]
# Public interface
post: func(typ, payload) [
rebind queue = queue + [(typ, payload)]
]
sched: func(delay, typ, payload) [
rebind scheduled = scheduled + [(now + delay, typ, payload)]
sort_scheduled()
]
halt: func() [rebind stop_flag = true]
run: func() [
head: 1
keep: true
while keep [
have_imm: head <= len(queue)
have_sch: len(scheduled) >= 1
halted: stop_flag
if halted then [keep = false]
else if have_imm then [
ev: queue[head]
head = head + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
else if have_sch then [
ev: scheduled[1]
# pop head
new_s: []
k: 2
while k <= len(scheduled) [
new_s = new_s + [scheduled[k]]
k = k + 1
]
rebind scheduled = new_s
rebind now = ev[1]
h: handlers[ev[2]]
if h != none then [h(ev[3])]
]
else [keep = false]
]
]
# Return the public interface as a dict
{
"post": post,
"sched": sched,
"halt": halt,
"run": run
}
]Use it:
r: make_reactor({
"greet": func(p) [println("hello ", p)],
"count": func(p) [
i: 1
while i <= p [
println(i)
i = i + 1
]
]
})
r["post"]("greet", "world")
r["post"]("count", 3)
r["sched"](5, "greet", "delayed")
r["run"]()Output:
hello world
1
2
3
hello delayed
The reactor processes immediates first (the two greets/counts), then drains the scheduled queue. The priority order is immediates ≫ scheduled — a common choice; some reactors interleave the two. Each strategy has trade-offs (§39.9).
That’s a real reactor — minus persistence (Ch.40) and multi-threading (which Axioma doesn’t need; we’ll see in Ch.51 how Cascade handles concurrency on top of a single-threaded reactor). The pattern is the same one used by Node.js, Reactor Netty, Twisted, asyncio internals, and most game engines.
Node.js. A single-threaded reactor over
libuv. Immediates = “microtasks” (promises). Scheduled =
“macrotasks” (setTimeout). I/O completion events come from the kernel
via epoll/kqueue/IOCP. The famous “Node.js is
single-threaded but fast” story is exactly the reactor pattern with
kernel-level I/O multiplexing.
Browser event loop. Almost identical to Node.js,
minus filesystem; plus DOM events (click, scroll, mousemove) coming from
the rendering thread. The priorities are nuanced: animation frames have
a specific position in the cycle (requestAnimationFrame),
microtasks drain between every task, etc. But the core is the
reactor.
Erlang/OTP gen_server. Each process has
a mailbox (the queue), a behaviour module with
handle_call/handle_cast/handle_info
(the dispatch table), and init/terminate
(lifecycle). The Erlang VM runs millions of these reactors concurrently
with cooperative scheduling. Cascade’s agent swarm (Vol III Ch.50)
borrows directly from this model.
Game engines. A frame loop is a reactor with a 60 Hz
timer tick driving everything. Input events (keyboard, mouse,
controller) post to the queue between frames. Each frame: drain the
queue, update state, render. Unity’s MonoBehaviour.Update,
Unreal’s Tick — same pattern, different namespace.
The Cascade Reactor (Ch.40 + Vol III Ch.51). A
durable reactor: events persisted to SQLite WAL before processing. On
crash, the loop replays from WAL. Adds an await mechanism
for coordinating between agents. The reactor pattern, made bulletproof
for a 24/7 production system.
The point: once you can write the loop in §39.7, you can read the internals of any of these systems. They’re all variations on the same theme.
Event loops are deceptively simple. The pitfalls show up in production, usually under load. Here are the top five.
If a handler runs for 5 seconds, the whole loop stalls for 5 seconds. Every other event waits. Click events queue up; the UI freezes; users rage-click and generate more events.
Fix: handlers must be short-running. Long
work goes on a worker thread (or another reactor in a different OS
process); the worker posts back a “work-done” event when finished. This
is Node.js’s worker_threads, Erlang’s spawn, Cascade’s
agent swarm pattern.
Suppose every “tick” handler posts two new “tick” events. The queue grows faster than it drains; other event types never get a turn. Common in naive producer-consumer designs.
Fix: rate-limit the producer, or interleave event types (e.g., dispatch at most N events of one type before yielding to others — Linux’s “completely fair scheduler” applied to event types).
A handler triggers, somewhere deep down, the same handler. State that was mid-update gets seen by the re-entry. Result: corrupted internal state, “this should never happen” assertions fire.
Fix: handlers must be re-entrancy-safe
(read all needed state into locals up front, mutate at the end) — or the
dispatcher must defer re-entrant posts (Node.js’s
process.nextTick queue, Erlang’s selective receive).
A burst of events arrives. The queue grows to a million entries. Memory exhausted; OS kills the process; you have no idea why.
Fix: bound the queue. New events go to a rejection path past the cap (drop, log, return an error to the producer). Backpressure is the generalization (see Ch.17 streams).
The loop is mid-processing event #500 when the process crashes. Event #500 is gone; event #501..N are still in the queue; on restart, the queue is empty (in-memory). The work is lost.
Fix: persistent event log. Every posted event written to disk before processing. On restart, replay from the log. This is Cascade’s WAL pattern, Kafka’s log, every “exactly-once” message-queue system. Ch.40 walks through the implementation.
You schedule a “shutdown” event with high priority. A handler in front of it is waiting (futures, locks, external I/O) on a low-priority event that’s behind it in the queue. The shutdown can’t run because its prerequisite is stuck behind it.
Fix: explicit priority levels; or kill the waiter (timeout). This is one of the hardest event- loop bugs to diagnose; the fix usually involves restructuring the dependencies.
You’ve now seen the event-loop / reactor pattern at two scales: a 12-line bare-minimum loop in §39.2, and a fully featured reactor with scheduling in §39.7. The exercises below will push you to add quality-of- life features: bounded queues, event priorities, introspection.
Ch.40 — The Cascade Reactor takes the next step: persistence. What if the program crashes mid-event? Cascade writes events to a SQLite WAL before processing, so on restart the loop replays from the log. The reactor becomes durable. We’ll walk through the design, then build a working miniature.
Vol III Ch.51 — Reactor Internals is the production-system view: how Cascade’s reactor integrates with the SQLite knowledge base, the agent swarm, and the truth-lifecycle machinery.
If you grok §39.7 and §39.9, you have the conceptual backbone for both.
39.1. Empty-queue start. Modify the §39.2 loop to handle the case where the initial queue is empty. Should the loop run zero iterations (current behavior), wait for an external event source (block), or report an error? Pick one and justify in 2-3 sentences.
39.2. Bounded queue. Add a queue cap to the
reactor in §39.7. If post is called when
len(queue) >= cap, reject the event (return false; print
a warning). Verify by posting 100 events with cap=10: only the first 10
should be processed.
39.3. Event introspection. Add a builtin
queue_depth() to the reactor (returns
len(queue) + len(scheduled)). Add a tick
handler that prints depth every second of virtual time. Demonstrate the
depth rising and falling during a burst.
39.4. Priority levels. Modify the reactor
to have two FIFO queues: high and normal.
post takes an optional priority. The loop drains
high before touching normal. Use this to make
a “shutdown” event always preempt pending work.
39.5. Idle handler. Add an
on_idle slot to the reactor: a handler invoked when both
queues are empty but stop_flag is false. Demonstrate by
writing a reactor that prints “idle” every second until external code
calls halt().
39.6. Find a real event loop. Pick a system you use (your editor, terminal, web browser) and look up its event loop documentation. Answer: (a) Is the queue FIFO or priority-ordered? (b) What’s the shutdown mechanism? (c) What happens on a slow handler — UI freeze, dropped events, or thread spawn?
The term traces to Erlang OTP (1990s) and the “Reactor” design pattern from Schmidt et al.’s Pattern-Oriented Software Architecture (1996). The intuition: a proactor drives the program forward (imperative top-down control); a reactor responds to external stimuli (event-driven). Most interactive systems are reactors at their core, even when wrapped in proactor-shaped APIs.
People sometimes claim that “single-threaded reactors don’t use
multiple cores.” Half-true. A single reactor thread does its
dispatch on one core; but handlers can hand work off to worker
threads on other cores, and the reactor coordinates the results.
This is exactly Node.js’s model: one event-loop thread, a thread pool
for blocking I/O, and worker_threads for CPU-bound
work.
Every event in the §39.7 reactor incurs:
len(queue) call (O(1))The dispatch overhead is in the microseconds. For high-throughput systems (millions of events/sec), the overhead matters; specialized event loops (Disruptor, LMAX) replace the dict with branch- prediction-friendly switches and ring-buffer queues. For most systems, the dict is fine.
A stream is a pull-based event loop: the consumer asks for the next value, the producer computes it. A reactor is push-based: the producer (kernel, user, timer) deposits events, the consumer (handlers) drains them. The same problem solved from opposite ends. Many systems mix both: streams compose handler output into pipelines; reactors deliver events from the outside world.
Solutions to selected exercises: Chapter 39 · Solutions in Appendix C. (Repo file: exercises/solutions/ch39_solutions.md.)
Integration draft. This chapter connects the language to a separately maintained application or external services. Its integration claims have not been revalidated in this revision. Use the core-language chapters for verified standalone examples; product verification is deferred.
Part IX, Chapter 3. Ch.39’s in-memory reactor is fast and elegant — and fragile. The moment the process dies mid-event, you lose all pending work and all events in flight. This chapter shows the standard fix: a Write-Ahead Log (WAL). Once you’ve added WAL, your reactor is durable — it survives crashes without losing events. This is the architectural step that turns toy event loops into the production systems behind databases, message queues, and the Cascade reactor (Vol III Ch.51).
Re-read §39.9 (e) carefully. The setup:
kill -9, power loss).For batch processing of throwaway data, this is fine. For anything with a customer at the other end (a financial transaction, a medical record update, a flight booking, a Cascade truth-update), this is unacceptable. Once you’ve accepted an event, you have an obligation to process it — even if the process crashes between acceptance and processing.
The standard fix has been known since the 1970s (database recovery research). It’s called the Write-Ahead Log or WAL.
Two properties:
post(event) returns
“accepted”, the event is on stable storage. Even a full hardware crash
won’t lose it.Together: no event is lost; no event is processed zero times. (Events might be processed more than once — see §40.5 on idempotency.)
The protocol is one line:
Before dispatching an event, write a “posted” record to the log. After the handler returns, write a “processed” record.
That’s it. Recovery is:
Replay the log. Any event with a “posted” record but no matching “processed” record is pending — push it back into the in-memory queue. Resume normal operation.
Diagrammatically:
┌─────────────────────┐
post(typ, payload)──┤ 1. write "posted" │
│ to WAL (disk) │
│ 2. enqueue │
└─────────────────────┘
┌─────────────────────┐
dispatch(ev) ────┤ 1. run handler │
│ 2. write "processed"│
│ to WAL (disk) │
└─────────────────────┘
crash here ←──── process dies mid-handler
┌─────────────────────┐
recovery() ────┤ 1. scan WAL │
│ 2. find posted- │
│ without-processed│
│ 3. requeue them │
└─────────────────────┘
That’s the entire WAL pattern. Every durable event system you’ve heard of — Kafka, RabbitMQ Mirrored Queues, AWS SQS, Postgres LISTEN/NOTIFY, the Cascade reactor — is some elaboration of this protocol.
Axioma itself doesn’t expose raw file I/O builtins (we have
persistence via the SQLite KB for axioms and postulates, not via a
write_file primitive). For this chapter we’ll
simulate the WAL with an in-memory array. The
pedagogical point is the protocol — write-before-process,
scan-on-recovery — not the mechanical file write. In Vol III Ch.51 we
walk through the Cascade reactor’s real WAL, which piggybacks
on SQLite’s WAL mode for free persistence + replication.
For our purposes, “WAL” = an append-only array. To simulate crash + restart, we copy the WAL out, discard the in-memory state, reconstruct from the WAL. That’s exactly what happens after a real crash; we just compress the wall-clock interval to zero.
Take Ch.39’s reactor. Add three things:
recover(wal) constructor that rebuilds the queue from
a persisted WAL.make_durable_reactor: func(handlers) [
queue: [] # in-memory FIFO
wal: [] # the log
next_seq: 1
stop_flag: false
post: func(typ, payload) [
seq: next_seq
rebind next_seq = next_seq + 1
# WAL first
rebind wal = wal + [(seq, "posted", typ, payload)]
# then in-memory queue
rebind queue = queue + [(seq, typ, payload)]
seq
]
mark_done: func(seq) [
rebind wal = wal + [(seq, "processed", "", "")]
]
halt: func() [rebind stop_flag = true]
run: func() [
head: 1
while head <= len(queue) [
if stop_flag then [head = len(queue) + 1] else [
ev: queue[head]
head = head + 1
seq: ev[1]
h: handlers[ev[2]]
if h != none then [h(ev[3])]
mark_done(seq)
]
]
]
snapshot_wal: func() [wal]
{
"post": post,
"halt": halt,
"run": run,
"wal": snapshot_wal,
"next_seq": func() [next_seq]
}
]Demo run:
r: make_durable_reactor({
"work": func(p) [println("work ", p)]
})
r["post"]("work", "a")
r["post"]("work", "b")
r["post"]("work", "c")
r["run"]()
println("wal entries:", len(r["wal"]()))Output:
work a
work b
work c
wal entries: 6
Six WAL entries: three “posted” + three “processed”. The reactor and its persistent log are in sync.
Notice in post() we write WAL first,
then enqueue. Reverse that order and you get a window where the event is
in the queue but not the log — if the process dies in that window,
recovery won’t see the event but in-memory dispatch already accepted it.
The caller thinks the event is durable; it isn’t.
The rule: WAL before in-memory state, every time. A handler should not be able to do anything externally visible (network call, KB write, side effect) before the WAL fsync completes. Cascade’s reactor enforces this by structuring handler bodies inside a transaction: WAL append is the transaction’s first operation; any handler side-effect that survives restart goes inside the same transaction.
mark_done comes after the handlerThe other ordering: mark_done(seq) after the handler. If
the process crashes mid-handler, the “processed” record never gets
written, and recovery sees the event as pending — exactly what we
want.
If you reversed those too (mark done first), a crash mid-handler would leave the handler’s work undone but the WAL claiming it was finished. The event becomes a ghost: appears completed in logs, never actually executed. The worst kind of bug.
The recovery function scans the WAL, finds pending events, and rebuilds the in-memory queue.
recover_from_wal: func(handlers, wal) [
# Pass 1: find the highest seq + reconstruct posted/done
max_seq: 0
i: 1
while i <= len(wal) [
e: wal[i]
if e[1] > max_seq then [max_seq = e[1]]
i = i + 1
]
# Make a fresh reactor seeded with the right next_seq
r: make_durable_reactor(handlers)
# Pass 2: for each "posted" entry, check if "processed"
# exists. If not, push it back into the queue (via post()).
j: 1
while j <= len(wal) [
e: wal[j]
if e[2] == "posted" then [
seq: e[1]
found: false
k: 1
while k <= len(wal) [
f: wal[k]
if f[2] == "processed" then [
if f[1] == seq then [found = true]
]
k = k + 1
]
if not found then [
r["post"](e[3], e[4])
]
]
j = j + 1
]
r
]Demo: crash, then recover.
# Pre-crash reactor: process two events, then "die"
r1: make_durable_reactor({
"work": func(p) [println("processing ", p)]
})
r1["post"]("work", "a")
r1["post"]("work", "b")
r1["post"]("work", "c")
# Simulate partial run: process only the first two
queue_ref: r1 # would be done by run() in reality;
# for demo, we'll just take the WAL after
# 2 events
# Snapshot WAL before crash
saved_wal: r1["wal"]()
# (in reality, the first 2 events would have processed marks;
# event "c" would not)
println("--- CRASH ---")
println("WAL snapshot has", len(saved_wal), "entries")
# After "restart", new reactor reads the WAL
r2: recover_from_wal({
"work": func(p) [println("RECOVERED + processing ", p)]
}, saved_wal)
r2["run"]()Sample output (recovery cleanly resumes):
--- CRASH ---
WAL snapshot has 3 entries
RECOVERED + processing a
RECOVERED + processing b
RECOVERED + processing c
In this demo all three are pending (we didn’t actually run the first reactor). In a partial-run scenario, only the events that completed before the crash would carry a “processed” record, and recovery would correctly re-queue just the rest.
A subtle point: in this demo, event “a” prints twice in a real partial-run scenario — once before the crash, once after recovery. Is that wrong?
It depends on the handler. If
process(a) just prints, twice is harmless. If
process(a) increments a counter or sends a
payment, twice is catastrophic.
This is the topic of §40.5.
Crash-recovery systems give you at-least-once delivery by default. The only way to upgrade to exactly-once semantics is to make handlers idempotent: running them twice gives the same result as running them once.
Three common idempotency patterns:
Some operations are idempotent by structure:
The trick is to structure your handlers to use natural idempotency wherever possible. Cascade’s truth-update handler uses set-of-claims rather than list, precisely so re-delivery is a no-op.
Every event has a unique seq number (we already generate one). The handler maintains a “processed already” set and skips events whose seq is in the set:
make_dedup_handler: func(real_handler) [
processed_seqs: []
func(seq, payload) [
already: false
i: 1
while i <= len(processed_seqs) [
if processed_seqs[i] == seq then [already = true]
i = i + 1
]
if already then [
println("[dedup] skipping seq=", seq)
] else [
real_handler(payload)
rebind processed_seqs = processed_seqs + [seq]
]
]
]This works, but the dedup state itself must be durable — otherwise it’s lost on the next crash and duplicates leak through again. In Cascade, the dedup set is a SQLite table; the truth-update transaction inserts the seq before applying the update, and the transaction’s atomicity makes the whole thing exactly-once.
Sometimes the operation can’t be made idempotent (charge a credit card; send a notification). The fix: pair every action with a compensating action that undoes it. On recovery, before re-running, check if the action already happened by looking at the external system’s state.
The full story is beyond this chapter — it’s the domain of distributed transactions, saga patterns, and two-phase commit. The takeaway: the handler designer must own idempotency. The reactor can provide at-least-once delivery; it can’t synthesize exactly-once semantics for you.
A WAL grows forever — every event adds entries. Without intervention, you’ll fill the disk.
The standard fix: checkpointing. Periodically:
Recovery becomes: load the snapshot, replay only the WAL entries after the snapshot.
In our toy reactor, “application state” is the in-memory queue + the
next_seq counter. A snapshot would be:
snapshot: func(r) [
{
"snapshot_seq": r["next_seq"]() - 1,
"in_flight": r["wal"]()
# filtered to pending events only
}
]Restore is the inverse: rebuild a reactor seeded with these.
In Cascade:
PRAGMA journal_mode=WAL). Truncation happens on each
successful CHECKPOINT, which the Cascade reactor triggers when the queue
drains.The result: the WAL is bounded by in-flight volume, not total history. A Cascade instance running for a year has a WAL of single-digit MB.
Common rules of thumb:
Tune to your workload. A real-time game would pick “queue-drain”; a batch ETL would pick “every N”; a public API would pick “every T”.
Vol III Ch.51 (Reactor Internals) is the full tour; here’s the elevator pitch.
The reactor lives in cascade/reactor/
(Go). It exposes:
Reactor.Post(evType, payload) — durable post, returns
when WAL flushed.Reactor.Run(ctx) — drains the queue, dispatching
handlers registered via Reactor.On(evType, fn).Reactor.Recover() — called on startup, replays the WAL
into the queue.The WAL is a SQLite table with schema roughly:
CREATE TABLE wal (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
ev_type TEXT NOT NULL,
payload BLOB NOT NULL,
posted_at REAL NOT NULL, -- unix ts
done_at REAL, -- NULL until processed
status TEXT NOT NULL -- 'pending' | 'processed' | 'failed'
);
CREATE INDEX wal_pending ON wal(status) WHERE status='pending';Recovery is one query:
SELECT seq, ev_type, payload
FROM wal
WHERE status = 'pending'
ORDER BY seq ASC;…and the in-memory queue gets seeded with the result.
Idempotency is baked in at two levels:
INSERT OR IGNORE against a
(claim_id, source, ts) PK — re-delivery is a no-op
database-side.The agent swarm (Vol III Ch.50) is built on top of the reactor: each agent reads from its private dispatcher queue, processes, and posts back to the central reactor. The reactor is the coordination spine.
If you understood §40.3 and §40.5, you can read
cascade/reactor/reactor.go straight through and follow
it.
WAL is not free. Each post triggers a (possibly synchronous) write to disk. Throughput drops from ~millions of events/sec (in-memory reactor) to ~tens-of-thousands of events/sec (WAL on SSD), and each write costs a milliseconds-scale latency.
The trade-off:
| Need | Use |
|---|---|
| Throughput, no correctness reqs | In-memory reactor (Ch.39) |
| User-facing actions | Durable reactor (this chapter) |
| Financial transactions | Durable reactor + idempotency (§40.5) |
| Cross-service coordination | Durable reactor + sagas (beyond) |
| Append-only audit log | Durable reactor with full retention |
| High-frequency telemetry | In-memory reactor + periodic flush |
Cascade’s reactor is durable because it processes truth-claim updates whose loss would be visible to users. The agent swarm’s internal dispatch (within one agent) is often in-memory because losses there recover via the truth-claim WAL one layer above.
Layered durability is a real architectural choice: high-rate paths can be lossy if a downstream layer is durable. Don’t WAL everything.
You’ve now built the conceptual core of every production event-driven system: an event loop (Ch.39), wrapped in a durable log (this chapter), with idempotent handlers (§40.5).
Ch.41 — Stack Programming pivots away from the event-driven mode to a complementary paradigm: Pop-11 / Forth-style stack computation. The stack is Axioma’s mostly-hidden second control structure; making it visible unlocks a different style of code.
Vol III Ch.51 — Reactor Internals is the production-system view: how Cascade’s reactor wires into the SQLite KB, manages the agent swarm, and handles operational concerns (graceful shutdown, backpressure, observability).
If you can explain §40.3 + §40.5 in your own words, you’re ready for both.
40.1. Partial run + recovery. Modify the recovery demo so it actually runs the first reactor partway (process events 1 and 2, then crash), then recovers. The recovered reactor should only re-run event 3. Print the WAL contents at each phase to make it visible.
40.2. Bounded WAL. The WAL grows forever in
our toy. Implement a compact(r) operation: scan the WAL,
drop all “processed” entries (and their matching “posted”), and
resequence. Verify before vs. after: WAL of 100 events should compact to
N where N is the number of pending events.
40.3. Idempotent counter. Define a handler
on_increment(p) that bumps a counter, with a dedup set so
the same seq isn’t applied twice. Demonstrate that running recovery
twice (idempotency stress test) leaves the counter at the
correct value, not 2× or 3×.
40.4. Crash-during-handler. Modify the
reactor so a handler can raise an error mid-execution. Add a
mark_failed(seq, err) WAL record. On recovery, failed
events should be re-queued (assume retry-on- crash; in production you’d
have a retry-count budget).
40.5. Quiescence-triggered checkpoint. Add
a checkpoint(r) that snapshots the current in-memory state
to a JSON-like dict, then truncates the WAL to entries
newer than the checkpoint. Verify recovery from “snapshot + tail” gives
the same result as recovery from full WAL.
40.6. Find a real WAL. Pick a database or message broker (SQLite, Postgres, Kafka, MySQL, RabbitMQ) and look up its WAL or recovery documentation. Answer: (a) What’s the WAL format — binary, line-delimited, page-oriented? (b) When is the WAL flushed to disk — every write, every commit, on a timer? (c) How is the WAL truncated — explicit checkpoint, automatic, never?
post returns
“accepted”, the event will be processed even across crashes.The name traces to System R (IBM, 1970s), the first relational database to systematically apply the technique. The “ahead” is temporal: the log entry is written before the data page is modified. So if a crash interrupts the in-memory update, recovery always has a complete record of what was supposed to happen, and can finish or roll back the work.
The general framework — “write what you intend to do before doing it; recovery replays the intent” — is the foundation of database recovery, transactional file systems (ext3, ext4, ZFS), version-control operations (git’s reflog), and even some game-save systems.
Three related ideas:
Event sourcing is the most ambitious: you keep the event log forever and reconstruct any historical state by replaying from genesis. Used in financial ledgers, certain databases (EventStoreDB), and some game systems (chess match logs).
The Cascade truth-claim history is essentially event-sourced: every truth update is a permanent event; the “current truth” is a fold over the event log.
The single biggest performance cost in a WAL is fsync()
— telling the OS to flush a write all the way to durable storage. On a
spinning disk that’s ~5-10 ms per fsync. On an SSD, 0.1-1 ms. Either
way, hundreds-to-thousands of times slower than a memory write.
Production WAL systems amortize fsync over many events: rather than fsync per post, they collect posts for ~1 ms, then fsync the batch. Throughput gains are 100×+. Tradeoff: slightly higher latency for individual posts (because batching delays acknowledgment).
Cascade’s reactor uses SQLite’s WAL fsync, which batches automatically when WAL mode is enabled. Get that for free.
If you keep going down the “make it more durable” rabbit hole, you arrive at consensus protocols (Raft, Paxos, ZAB): WAL distributed across multiple machines, with a leader-election dance to handle node failures rather than just process failures.
The principle scales: WAL on a single machine survives process crashes; replicated WAL (Raft) survives whole-machine crashes; geo-replicated WAL (Spanner, CockroachDB) survives whole-datacenter failures.
Each layer of “more durable” costs more (latency, throughput, complexity). Pick the minimum that covers your failure model.
Solutions to selected exercises: Chapter 40 · Solutions in Appendix C. (Repo file: exercises/solutions/ch40_solutions.md.)
Part XI, Chapter 1. Opens the Symbolic + Neural arc. So far we’ve used the stack implicitly — every function call pushes a frame; the runtime manages it for us. This chapter promotes the stack to a user-controlled data structure in the Pop-11/Forth tradition. The result is a tight, mechanical style of programming where computation happens by feeding values onto a stack and operators consume them. It looks alien at first; the payoff is a class of problems where stack code is shorter, clearer, and faster than any alternative.
Every programming language has a stack — it’s how function calls
work. When you call foo(x), the runtime pushes a stack
frame; when foo returns, the frame pops. The call
stack is the language’s gift to you: an O(1) way to nest
computations.
But that’s only one stack. There’s a second one hiding in many
algorithms: the data stack, where values being
computed sit waiting their turn. In languages like Java, Python, or
even regular Axioma, the data stack is also hidden — the compiler/
interpreter implements it, but you never see it. You write
(3 + 4) * 2 and the runtime parses, plans, and computes;
the stack used internally is invisible.
In Pop-11, Forth, PostScript, and the JVM bytecode, the data stack is explicit. You write the values and the operators in the order they hit the stack:
3 4 + 2 *
Read left-to-right: push 3; push 4; + pops two, adds,
pushes 7; push 2; * pops two, multiplies, pushes 14. The
final stack is [14]. No parentheses, no precedence rules,
no parser tree — just feed-the-stack.
This is Reverse Polish Notation (RPN) and stack-based programming in a single technique. It’s an old idea (Łukasiewicz, 1924; Forth, 1968; Pop-11, 1975) — but a powerful one. The HP-12C calculator, every JVM, the LuaJIT VM, and PostScript printers all use it internally.
Axioma exposes the data stack as a first-class data type. Use it when the problem is naturally stack-shaped (RPN expressions, parsing, undo buffers, interpreter implementation) and avoid it when it isn’t.
The fundamental operations:
s: stack() # empty stack
push(s, 10) # → Stack[10]
push(s, 20) # → Stack[20 | 10]
push(s, 30) # → Stack[30 | 20 | 10]
println(depth(s)) # 3
println(peek(s)) # 30 — top, non-destructive
x: pop(s) # x = 30; stack now [20 | 10]
println(s) # Stack[20 | 10]
clear(s) # → Stack[]A few conventions:
Stack[A | B | C] is Axioma’s display
convention: the leftmost element is the top. The pipe
| separates elements just for readability.push(s, x)
modifies the stack in place; it doesn’t return a new stack.
Arrays share this contract (push grows an array in place
too); what the Stack adds is LIFO discipline — you only ever
touch the top element.Inspect().Here’s the smallest useful program:
s: stack()
push(s, 5)
push(s, 3)
result: pop(s) + pop(s)
println("5 + 3 =", result)
# → 5 + 3 = 8The “RPN feel” comes through even here: we pushed operands, then
consumed them with an operator. Now imagine an expression evaluator that
reads a token stream and dispatches on
+/-/*// — that’s
§41.5.
Forth’s stack operations are pure stack-shape manipulations: no values flow in or out from outside, only the existing stack contents get shuffled. Axioma supports the standard set.
dup(s) — duplicate the top:
Before: Stack[a | b | c]
dup(s)
After: Stack[a | a | b | c]
drop(s) — discard the top:
Before: Stack[a | b | c]
drop(s)
After: Stack[b | c]
swap(s) — exchange top two:
Before: Stack[a | b | c]
swap(s)
After: Stack[b | a | c]
over(s) — copy the second-from-top to
the top:
Before: Stack[a | b | c]
over(s)
After: Stack[b | a | b | c]
rot(s) — rotate the top three (third
becomes top):
Before: Stack[a | b | c]
rot(s)
After: Stack[c | a | b]
Wait — re-check that. rot in Forth is “the third element
comes up to the top, others shift down.” So
Stack[a | b | c] (a is top) → Stack[c | a | b]
(c is top). Let’s verify in Axioma:
s: stack()
push(s, 1); push(s, 2); push(s, 3)
# Stack[3 | 2 | 1] (3 on top)
rot(s)
println(s)
# → Stack[1 | 3 | 2] (1 was the bottom; rot brings it to top)That confirms: rot rotates such that the
deepest of the top three becomes the new top. Useful when you
have a three-argument operator whose arguments are stacked in the wrong
order.
nip(s) — remove the
second-from-top:
Before: Stack[a | b | c]
nip(s)
After: Stack[a | c]
(equivalent to swap drop)
tuck(s) — copy the top below the
second:
Before: Stack[a | b | c]
tuck(s)
After: Stack[a | b | a | c]
(equivalent to dup -rot or swap over)
Forth has dozens of these — pick, roll,
2dup, 2swap, etc. The pattern: any common
stack-rewrite pattern that comes up often enough deserves a name,
because a named pattern is one fewer thing the reader needs to mentally
simulate.
Axioma exposes the most common ones plus indexed forms:
pick(s, n) — copy the n-th element
(from top) to the top.roll(s, n) — rotate the top n elements
(deepest of those n becomes new top).pick(s, 0) = dup. pick(s, 1) =
over. roll(s, 3) = rot. The
indexed forms generalize the common cases.
swap via primitive opsYou can build swap from more primitive operations:
push(s, 0)
dup(s) # Stack[0 | 0 | b | a]
drop(s) # ...no wait, that doesn't help
Actually, the easy way to derive swap:
# Start: Stack[b | a | ...]
# Want: Stack[a | b | ...]
over(s) # Stack[a | b | a | ...]
nip(s) # remove the underneath a — Stack[a | b | ...]? no
# Hmm — back to first principles
The exercise hints at why named ops are nice. We prove correctness in
textbooks; in production we just use swap. Forth idiom:
don’t reinvent the wheel; use the named primitive.
Pop-11 was a 1975 dialect at Sussex University, an ancestor of Common Lisp Object System ideas and a heavy influence on POPLOG/SOAR/Cyc. Its stack vocabulary differs slightly from Forth’s; both are supported in Axioma.
stacklength(s) — Pop-11 name for
depth(s) (both return the integer count of elements).
erase(s) — Pop-11 name for
drop(s).
dupnum(s, n) — replicate the top
element n times. Example: dupnum(s, 3) turns
Stack[x | y] into Stack[x | x | x | x | y].
Useful in expression-template compilers.
erasenum(s, n) — drop the top n
elements. Example: erasenum(s, 3) on
Stack[a | b | c | d | e] yields
Stack[d | e].
The Pop-11/Forth naming distinction was once significant; today
they’re aliases. Choose whichever name reads better in context. For
mathematical code the Pop-11 stacklength/erase
flows nicely; for low-level code Forth’s
depth/drop is more concise.
The canonical stack-programming demo: an RPN expression evaluator. Input is a list of tokens — numbers go on the stack, operators consume the top two and push the result.
rpn_eval: func(tokens) [
s: stack()
i: 1
while i <= len(tokens) [
tok: tokens[i]
is_op: type(tok) == String
if is_op then [
if tok == "+" then [
b: pop(s)
a: pop(s)
push(s, a + b)
]
if tok == "-" then [
b: pop(s)
a: pop(s)
push(s, a - b)
]
if tok == "*" then [
b: pop(s)
a: pop(s)
push(s, a * b)
]
if tok == "/" then [
b: pop(s)
a: pop(s)
push(s, a / b)
]
] else [
push(s, tok)
]
i = i + 1
]
pop(s)
]
# (3 + 4) * 2
println(rpn_eval([3, 4, "+", 2, "*"]))
# → 14
# 5 + (1 + 2) * 4 - 3
println(rpn_eval([5, 1, 2, "+", 4, "*", "+", 3, "-"]))
# → 14Read the trace carefully. For
[3, 4, "+", 2, "*"]:
push 3 → Stack[3]
push 4 → Stack[4 | 3]
"+" → pop 4, pop 3, push 7 → Stack[7]
push 2 → Stack[2 | 7]
"*" → pop 2, pop 7, push 14 → Stack[14]
pop → 14
This is the core of every stack-based interpreter, including the JVM and Python’s CPython bytecode VM. Add data-loading instructions, control-flow opcodes, and stack frames, and you have a working virtual machine.
In Ch.22 (meta-circular evaluator) we parsed expressions into AST and walked the tree. Both approaches work — they’re trading one shape of control structure for another:
| Tree-walker | RPN/stack | |
|---|---|---|
| Input | AST | Linear token stream |
| Control flow | Recursion | Iteration |
| State | Implicit (call stack) | Explicit (data stack) |
| Cost | O(call) per node | O(1) per token |
| Implementation effort | Lexer + parser + evaluator | Lexer + evaluator |
For expression evaluation, the stack version is shorter (no parser needed if input is already RPN) and faster (no AST allocation). For complex languages with statements, blocks, and scoping, the tree-walker wins on clarity.
Production languages often compile the AST to RPN-like bytecode, then run a stack VM. Java does exactly this. The compile-time tree gives clear semantics; the runtime bytecode gives speed.
A short menagerie of problems where the stack is the natural shape.
Every editor has Undo. The data structure is two stacks: one for past actions (“undo”), one for redone-but-could-be-undone-again (“redo”).
undo: stack()
redo: stack()
do_action: func(operation) [
push(undo, operation)
clear(redo) # any new action invalidates redo history
]
undo_one: func() [
if depth(undo) >= 1 then [
a: pop(undo)
push(redo, a)
a
]
]
redo_one: func() [
if depth(redo) >= 1 then [
a: pop(redo)
push(undo, a)
a
]
]The invariant: undo’s top is the most recent action;
redo has actions that were undone. Doing a new action
clears redo because the future diverges.
Validating that every ( has a matching ).
The classic textbook example.
balanced: func(text) [
s: stack()
i: 1
valid: true
while i <= len(text) [
c: text[i]
if c == "(" then [push(s, "(")]
if c == ")" then [
if depth(s) == 0 then [valid = false]
else [pop(s)]
]
i = i + 1
]
valid and depth(s) == 0
]
println(balanced("((()))")) # true
println(balanced("(()")) # false (unclosed)
println(balanced("())")) # false (extra close)Three pieces: push opens, pop on close (with emptiness check), final state must be empty. The state space of the matcher is the stack itself — no separate “expected delimiter” tracking needed.
Generalize to bracket types (/),
[/], {/} — the stack
holds the expected close character, and you check the actual
close matches the top of the stack.
Tree/graph DFS can be written iteratively with a stack:
dfs_iter: func(root_payload) [
s: stack()
push(s, root_payload)
while depth(s) > 0 [
node: pop(s)
println("visit:", node)
# push children (in reverse so left is visited first)
# ... domain-specific: for a tree, push each child of `node`
]
]The iterative form trades cleaner recursion for deeper stacks without blowing the call stack. For trees of depth >10⁵, this matters in real systems.
Implementing a language’s call stack as an explicit data stack is the foundation of every interpreter and compiler.
call_stack: stack()
do_call: func(fn_name, return_to) [
push(call_stack, (fn_name, return_to))
# execute fn_name's body...
]
do_return: func() [
frame: pop(call_stack)
# jump back to frame[2] (the return_to address)
]This is the kind of explicit-call-stack code you see in stack-based VMs and in coroutine implementations. Useful when the host language’s call stack is too shallow for your computation (deeply recursive interpreters), or when you need to suspend a call mid-execution (continuations, generators, async/await).
Sometimes you want to flow between stack and array representations. Axioma provides:
array_to_stack(arr) — turns
[1, 2, 3] into Stack[3 | 2 | 1] (top is the
last array element).stack_to_array(s) — turns
Stack[3 | 2 | 1] back into [1, 2, 3].arr: [10, 20, 30, 40, 50]
s: array_to_stack(arr)
println(s)
# → Stack[50 | 40 | 30 | 20 | 10]
back: stack_to_array(s)
println(back)
# → [10, 20, 30, 40, 50]The conversion is lossless and O(n). When to convert:
A common pattern: read an array; convert to stack; process with stack ops; convert back. This is exactly how PostScript’s display list works — arrays of drawing commands fed into a stack evaluator that mutates the page.
Axioma’s REPL maintains a global interpreter stack — a hidden data stack that the REPL can inspect for debugging.
:stack show contents of the global eval stack
:s short form
:stack trace show stack state after each evaluation
In normal use you’ll never touch this — Axioma is not a stack-based language at the surface, so the global stack stays empty most of the time. But it’s present for cases where you’d want to write RPN-style sequences interactively, or for implementing stack-based DSLs on top of Axioma.
If you’ve used the Forth REPL, the Postscript interactive interpreter, or HP calculators in “RPN” mode, this is the same idea: a workspace stack shared across REPL commands.
Most users will not need this. It’s there for when you do.
#language axioma/rpn — the Forth return stackThe data stack in this chapter is a value you name
(s: stack()). Forth has a second stack that is
session-wide: the return stack, used to park a
value while you work on the data stack, then bring it back. Axioma
exposes that stack as five words:
| Word | Effect |
|---|---|
rpush(x) |
push x onto the return stack; returns
x |
rpop() |
pop and return the top; errors if empty |
rpeek() |
top, non-destructive; errors if empty |
rdepth() |
number of parked values |
rclear() |
empty the return stack |
#language axioma/rpn
expect("empty", rdepth(), 0)
rpush(10)
rpush(20)
expect("depth", rdepth(), 2)
expect("peek", rpeek(), 20)
expect("pop", rpop(), 20)
expect("pop next", rpop(), 10)The dialect is additive. Infix still runs
(1 + 2 is 3). #language axioma/rpn does
not turn Axioma into Forth, and it does not refuse
func or println. It names the Forth
return-stack idiom so a file can declare “this program uses
rpush/rpop on purpose.” The same five words
are also in axioma/all; the pragma is a documented mode,
not a closed island (contrast Chapter 54’s axioma/hm).
rpop() on an empty return stack is an error, not
none. That matches Forth: under-running the return stack is
a bug, not a missing value.
The three stacks to keep straight:
| Stack | What it holds | How you touch it |
|---|---|---|
| Call stack | function frames | automatic |
| Data stack | a stack() value, or the REPL’s :stack |
push / pop / Forth shuffle |
| Return stack | session-wide park | rpush / rpop under
axioma/rpn |
Six rough heuristics for “is this problem stack-shaped”:
When not to use stack programming:
The Pop-11/Forth lineage has had decades of production use in symbolic AI (SOAR, POPLOG, Cyc), embedded systems (Forth on Hubble’s deep-space controller), and printing (PostScript). Stack programming isn’t a relic — it’s a specialty tool worth knowing when the problem fits.
You’ve now seen the explicit stack as a programming discipline. The next chapter takes a different turn: Ch.42 — Neuro-Symbolic Patterns shows how Axioma’s symbolic machinery (concepts, rules, truth values) mixes with neural outputs (LLM- generated facts). The bridge is the same one Cascade uses to ingest natural-language source material into a Belnap-B4 truth-tracked KB.
After Ch.42, Volume II is complete; the textbook crosses into Volume III’s deep dive on Cascade internals.
41.1. RPN with negative numbers. The §41.5
evaluator handles [3, 4, "+", 2, "*"]. What happens with
[3, "-4", "+"] where -4 is a string? Modify
the evaluator to handle negative numeric literals, both as integers in
the token list and as strings like "-4".
41.2. Balanced multi-bracket. Extend the
§41.6 balanced-parens checker to handle (/),
[/], {/}
simultaneously. Reject mismatched closes like (] and
{). The stack should hold the expected close
character for each open seen so far.
41.3. Infix to RPN converter. Given an
infix expression as a token list, e.g. [3, "+", 4, "*", 2],
produce its RPN equivalent [3, 4, 2, "*", "+"]. Use the
Shunting-Yard algorithm (Dijkstra): two stacks, one for operators, one
for output.
41.4. Stack-based factorial. Write a
function factorial(n) that uses only stack
operations — no recursion, no for/while, no accumulator variable.
Allowed: push, pop, dup, swap, depth. (Hint: you’ll need to count down
from n using stack arithmetic; this is fiddly. It’s intentional.)
41.5. Pop-11 erasenum. Confirm
experimentally that erasenum(s, 0) is a no-op,
erasenum(s, k) for k > depth is equivalent to
clear(s), and erasenum(s, k) for negative k
either errors or is a no-op (test it!). Document what you find.
41.6. Pick the right paradigm. For each of these problems, decide whether stack programming or array/recursion programming is the better fit, and justify in one sentence:
41.7. The return stack. Under
#language axioma/rpn, park two integers with
rpush, read the top with rpeek, then
rpop both. Confirm 1 + 2 still works in the
same file (the dialect is additive). Then rpop() once more
and check that the empty return stack errors.
dup, swap,
drop, etc.stacklength, erase,
dupnum, erasenum).Forth (Charles Moore, 1968) was designed to control radio telescopes — embedded systems where memory was bytes, not megabytes. The stack-and-words discipline let programs be extremely compact: a typical Forth program is 10× smaller than the equivalent C. The cost: nontrivial code is extremely terse.
Forth survives in niche embedded systems where its combination of small footprint and high introspection still matter. Open Firmware (the boot ROM on PowerPC Macs and Sun workstations) was Forth-based. The Mars Sojourner rover ran a Forth called RTX-2010.
Pop-11 (Sussex University, 1975) was the lingua franca of a generation of UK AI research. Combined stack semantics with pattern-matching, list-processing, and a prolog-like database. POPLOG, the multi-language environment, hosted Pop-11 alongside Common Lisp, Prolog, and Standard ML.
Axioma’s Pop-11-flavored stack vocabulary (stacklength,
erase, etc.) is a small homage — not a full Pop-11 dialect,
but enough to evoke the lineage.
PostScript is a stack-based language designed to describe printed pages. Every laser printer made between 1985 and 2010 had a PostScript interpreter in its ROM. The discipline:
72 72 moveto % set cursor to (72, 72) — 1 inch in
(Hello) show % render the string Hello72 72 pushes coordinates; moveto consumes
them; (Hello) pushes a string; show consumes
it and draws.
Modern PDF is, structurally, a stripped-down PostScript. The “print to PDF” pipeline in your OS is running a stack VM somewhere.
Production compilers usually target register machines (x86, ARM) for the metal, but stack machines (JVM, WASM) for portable IR. Why?
Most modern JIT compilers (V8, HotSpot, Lua JIT) start with stack-shaped bytecode and generate register-allocated machine code at run time.
If you got hooked by this chapter, a fun project: implement a tiny
Forth interpreter in Axioma. Vocabulary: : to define a
word, ; to end, . to print top, plus the stack
ops we’ve already seen. Each word is stored in a dict as either a
primitive or a list of further words.
The interpreter is a stack machine over a token stream. The result is a working concatenative language in ~150 lines. The same exercise in C takes ~500. The same in assembly: 200 lines (and boots in 100 ms on a 4 MHz Z80).
Solutions to selected exercises: Chapter 41 · Solutions in Appendix C. (Repo file: exercises/solutions/ch41_solutions.md.)
Integration draft. This chapter connects the language to a separately maintained application or external services. Its integration claims have not been revalidated in this revision. Use the core-language chapters for verified standalone examples; product verification is deferred.
Part XI, Chapter 2 — the final chapter of Volume II. Neuro-symbolic systems combine neural models (LLMs, classifiers, embeddings) with symbolic reasoning (logic, rules, knowledge bases). Both have strengths the other lacks; together they’re more powerful than either alone. This chapter shows the patterns: how to ingest neural-generated facts into a symbolic system safely, how to verify them, and how to detect contradictions. The Cascade pipeline (Vol III Ch.45) is one production-grade instance.
For decades, AI research split into two tribes:
Neither alone is enough for serious applications. A symbolic system can’t read free-text news articles to extract relations. A neural system can’t tell you why it concluded what it did, can’t be audited against rules, and will happily contradict itself across two queries five minutes apart.
Neuro-symbolic systems combine them: the neural model does the perception (reading text, recognizing entities, extracting candidate facts); the symbolic system does the reasoning (storing facts, checking consistency, applying rules, producing audit trails).
The Cascade pipeline is exactly this pattern:
News article → LLM extracts claims → Axioma KB
│ │
▼ ▼
tentative facts truth-tracked
(hypothesis grade) reasoning fires
contradictions
surface,
defeasible rules
update grounding
The LLM is fast, fuzzy, and fallible. The KB is precise, slow, and auditable. Putting them together gets you a system that reads the news and tells you when sources contradict each other.
This chapter is about the patterns for that integration — not Cascade’s specific architecture (see Vol III Ch.45 for that). The patterns generalize to any neuro-symbolic system.
A neuro-symbolic ingest pipeline has six stages:
1. Acquire — get raw input (text, image, sensor)
2. Extract — neural model produces candidate facts
3. Normalize — entity-link, canonicalize tokens
4. Store — insert into KB with low grounding
5. Verify — symbolic rules promote/reject claims
6. Reason — use the now-grounded facts
The flow is unidirectional through verification: neural output enters with the weakest grounding; each verification step may promote it; final reasoning uses only facts that survived.
Suppose an LLM reads “Berlin is the capital of Germany” and produces
a candidate fact: is_capital("Germany", "Berlin").
Stage 4 stores it with hypothesis grounding (the weakest tier above “datum”):
hypothesis is_capital("Germany", "Berlin")Stage 5 might apply a verification rule: “if at least two independent sources agree, promote to conjecture”:
# Conceptually (real implementation in §42.6)
is_capital(C, City) <~~ source_count(C, City) >= 2Stage 6 then queries the KB knowing that anything returned by
is_capital has at least conjecture- grade backing.
The key idea: grounding levels are the trust gradient. Neural output enters as hypothesis; symbolic verification raises (or lowers) it; queries choose how much trust they require.
The simplest pattern: every LLM-produced fact gets hypothesis grounding. The LLM acts as a source that’s known to be fallible, so the KB never trusts LLM output as gospel.
# Imagine an LLM extracts these from a news article
hypothesis ceo_of("OpenAI", "Sam Altman")
hypothesis founded_in("OpenAI", 2015)
hypothesis hq_in("OpenAI", "San Francisco")
hypothesis ticker_of("OpenAI", "OPENAI") # ← wrong; OpenAI is private
println(grounding("ceo_of", "OpenAI", "Sam Altman"))
println(grounding("ticker_of", "OpenAI", "OPENAI"))All four claims are stored at hypothesis. They’re in the
KB and queryable, but downstream rules know to discount them. If someone
later submits a postulated fact (“OpenAI is privately held”),
it outranks the LLM hypothesis automatically — that’s the
grounding ladder at work (see Ch.20 §20.6).
Three reasons:
The cost of storing at hypothesis is zero — the information is preserved. The benefit is a system that doesn’t crash on bad LLM output.
Storage at hypothesis is the floor. The system becomes useful when verification rules promote facts upward as evidence accumulates.
If multiple independent LLM extractions agree on a claim, raise grounding:
hypothesis ceo_of("OpenAI", "Sam Altman")
hypothesis ceo_of("OpenAI", "Sam Altman")
hypothesis ceo_of("OpenAI", "Sam Altman")
# After 3 hypothesis claims agree, the symbolic
# layer promotes to conjecture (defeasible derivation)
postulate ceo_of_confirmed(Company, Person) <=
# ... (real Cascade code uses a count over
# evidence_for predicate; see Ch.46)In Cascade this is the truth-aggregation rule in
reasoning/truth_lifecycle.ax: count the distinct sources
for each claim; promote to conjecture at threshold k, to theorem at
threshold 2k.
The pattern is n-of-m voting — common in distributed consensus (Paxos, Raft) and in ensemble ML. Bringing it to the LLM-output level gives you a self-checking pipeline.
If the LLM emits a fact that contradicts an established rule, demote (or reject):
axiom is_capital_unique(C) <=
{ City | is_capital(C, City) } has exactly 1 element
hypothesis is_capital("Germany", "Berlin")
hypothesis is_capital("Germany", "Frankfurt") # wrong
# When the uniqueness rule fires, both candidates
# get tagged "Both" (paraconsistent contradiction).
# A reasoning rule then drops the lower-confidence
# one based on source agreement count.Cascade has dozens of these consistency rules spread across
reasoning/rules/ directories. Together they form a type
system for facts — catching errors that LLMs make confidently.
If you can look up the fact in a trusted external source, promote:
# After verifying via Wikipedia API:
confirmed: wiki_lookup("OpenAI", "ceo")
if confirmed == "Sam Altman" then [
# Bump LLM-grade fact to postulate (trusted external)
insert("ceo_of", "OpenAI", "Sam Altman", "postulate")
]This is the retrieval-augmented generation (RAG) pattern, applied at storage time rather than inference time. The LLM proposes; the trusted external source confirms; the KB stores the confirmed grounding.
Putting it together, a single fact’s grounding can walk up the ladder over time:
T=0: LLM extracts → hypothesis is_capital("DE", "Berlin")
T=1: Second LLM agrees → conjecture (multi-source rule fires)
T=2: Wikipedia lookup → postulate (external grounding)
T=3: Manual review → axiom (human-confirmed)
Each promotion is reversible with
forget() and re-insertion at a lower grounding. The system
behaves like a monotonic-with-rollback ratchet: ascends
easily; descends only on explicit contradiction.
LLMs disagree. Sometimes the same LLM disagrees with itself across queries. The honest response is to record the disagreement, not pick a winner prematurely.
Belnap B4 truth values (T, F,
Both, Neither) make disagreement
first-class:
hypothesis num_employees("OpenAI", 1500)
set_truth("num_employees", "OpenAI", 1500, "true")
hypothesis num_employees("OpenAI", 2500)
set_truth("num_employees", "OpenAI", 2500, "true")
# Both hypotheses are "true" individually; the KB
# has them coexist. A reasoning rule then sets
# the truth of *both* to Both, since the underlying
# question has conflicting evidence.
set_truth("num_employees", "OpenAI", 1500, "both")
set_truth("num_employees", "OpenAI", 2500, "both")
println(truth("num_employees", "OpenAI", 1500))
# → ⊤⊥ᵇ (Belnap "Both")When a downstream rule queries num_employees, seeing
Both means “the answer is disputed; escalate to a human.”
That’s the paraconsistent escape hatch — the system
doesn’t crash on contradictory inputs, it surfaces them.
In Cascade:
Both
and a metadata field pointing to the dispute.Both (“was true but no longer
canonical”).The B4 vocabulary is the bridge between “neural output is messy” and “downstream code needs definite answers”. The reasoning layer turns mess into a decision (with explicit confidence) rather than hiding the mess.
Putting grounding and B4 together gives you the full defeasibility ladder: a fact’s “level of support” lives on two axes simultaneously.
| Dimension | Levels |
|---|---|
| Grounding (epistemic) | axiom > postulate > theorem > conjecture > hypothesis > datum |
| Truth (semantic) | T, F, Both, Neither (Belnap B4) |
A claim at (axiom, T) is the strongest possible —
human-declared fact, no known contradiction. A claim at
(hypothesis, Both) is the weakest non-zero — LLM-generated
and contradicted by other sources.
Defeasible rules demote on conflict. A defeasible rule:
mortal(X) <~~ human(X)means “humans are typically mortal; the rule’s conclusion is
defeasible — it can be overturned by a more specific rule.”
Adding not mortal(zeus) defeats mortal(zeus)
even though human(zeus) holds.
In neuro-symbolic terms: LLM emits human("Zeus")
(hypothesis); the rule fires and emits mortal("Zeus")
(conjecture); a more specific rule says
not mortal(X) <= deity(X) and Zeus’s
deity-fact (postulate, from a Wikipedia lookup) defeats the
mortal-derivation.
Trace this carefully:
hypothesis human("Zeus") # LLM said so
postulate deity("Zeus") # KB has it
# Two rules:
# mortal(X) <~~ human(X)
# not mortal(X) <= deity(X)
# The strict rule (deity → not mortal) wins over the
# defeasible (human → mortal). Result:
# mortal("Zeus") truth = F (defeated)
The neuro-symbolic system doesn’t crash on the contradiction; it resolves it via the rule priority (strict > defeasible).
This is the load-bearing pattern for production neuro-symbolic systems. The LLM proposes; the defeasible rules promote; strict rules override on conflict; final answers come with their full chain of reasoning attached.
Six failure patterns. Recognize them; design around them.
The LLM invents a fact that sounds right but isn’t. “Berlin is the capital of Germany” (true). “Berlin is the capital of France” (false but plausible to a confused model).
Defense: verify against trusted source (RAG); demand multi-source agreement; flag claims with unusual entity combinations for human review.
The LLM says “I’m 90% confident” but is actually 50% accurate on that distribution. Or worse: says “definitely” for things it’s flat-out wrong about.
Defense: don’t trust LLM-emitted confidences directly. Use them as one signal among several; calibrate against held-out data; combine with external-source agreement.
Two semantically identical prompts produce different outputs. The same model on Monday vs. Tuesday gives different answers (due to sampling randomness, weight updates, or context).
Defense: identical prompts → record multiple responses → vote. Storing as hypothesis lets the vote happen offline.
The model’s “fact” about a public figure is quoting a 2020 article it memorized verbatim. The fact is real in 2020 but stale in 2024.
Defense: explicit temporal tagging on every fact.
Cascade tags every truth_aggregate event with a timestamp;
queries can require “fresher than 30 days.”
Someone crafts an article designed to make the LLM extract a specific false claim. Or worse, a sequence of articles meant to slowly poison the KB.
Defense: source reputation tracking. Every ingested article carries the source’s identity; sources with declining accuracy get demoted in the trust hierarchy; the KB can be rolled back to a checkpoint (Ch.40 §40.6).
The LLM uses entity names slightly differently each time. “Sam Altman” vs “Samuel Altman” vs “S. Altman” — three names for one person.
Defense: entity normalization at ingest time.
Cascade uses a resolve_entities step that maps incoming
surface forms to canonical KB entities. A second-pass merge resolves
residual aliases.
Six rough heuristics. Recognize them in your domain.
When not to use neuro-symbolic:
Cascade is squarely in the first list. So is most of medical diagnosis, legal research, financial fraud detection, scientific literature mining, and intelligence analysis. The pattern isn’t universal; it’s powerful where it fits.
You’ve now traversed:
That’s the full CS1+CS2+KR curriculum at full depth.
Volume III picks up the thread with Cascade itself:
If you came in cold, you’re now well-prepared for Volume III. If you came for the CS1-CS2 curriculum, you’ve got it. If you came for neuro-symbolic AI, you’ve got the language, the patterns, and a worked production system to study.
The chapter you choose next depends on where you want to go.
42.1. Three-source agreement. Write a rule
confirmed(C, P) <= count(hypothesis sources for (C, P)) >= 3.
Insert three hypothesis claims for the same fact and verify they
aggregate to a confirmed conclusion. Then add a fourth claim
contradicting the first three and verify the system handles it
via Belnap Both.
42.2. Defeasible-by-specificity. Write rules:
flies(X) <~~ bird(X) (defeasible)not flies(X) <= penguin(X) (strict)Add hypothesis bird("tweety") and
postulate penguin("tweety"). Query
flies("tweety"). What grounding do you get? What’s the
truth value?
42.3. Failure-mode classification. For each of these scenarios, identify which failure mode from §42.7 applies:
42.4. Trust calibration. Design a
trust-tracking scheme: every LLM-generated claim has a
source_model tag (e.g., “gpt-4”, “claude-3”). Sources with
high false-positive rate get demoted to grounding “datum.” Write the
demotion rule.
42.5. RAG-style promotion. Sketch (in
pseudocode if needed) a function verify_via_wiki(rel, args)
that simulates a Wikipedia lookup. If the lookup confirms the fact,
promote its grounding to postulate. If it contradicts,
demote to datum and emit a warning.
42.6. Open-ended. Pick a neuro-symbolic system you’ve heard of (DeepProbLog, IBM Watson, Cyc-era systems, Cascade itself, etc.). Read its docs or papers for 30 minutes. Write 200 words answering: what’s the neural part? What’s the symbolic part? Where do they meet? Is the meeting point at storage-time, query-time, both?
<~~): produces
conjecture-grade conclusions.<=): produces
theorem-grade conclusions; overrides defeasible ones on conflict.The term goes back to the 1990s (Towell & Shavlik’s KBANN; D’Avila Garcez’s neural-symbolic learning), but the practice is older. Early expert systems (MYCIN, INTERNIST) were hand-coded symbolic; they failed because nobody could maintain the rules.
The early-2010s neural renaissance went the other way: pure neural, no symbolic structure. Failed on different things: hallucination, audit trails, sample efficiency.
Production systems today (~2024-2026) increasingly combine both. Cascade is one instance; IBM’s Watson Discovery is another; DeepMind’s various “Mathematics” projects (AlphaProof, MATH) use a neural model to propose lemmas and a symbolic theorem prover to verify them.
The combination is more powerful than either alone because the failure modes are different: neural models fail at precision, symbolic systems fail at coverage. Combine them and you get high precision and high coverage — at the cost of infrastructure complexity.
A useful mental model: treat the LLM as a feature extractor rather than a reasoner. Its job is to turn unstructured input into structured candidate facts. The reasoning happens in the symbolic layer.
This is the inverse of the 1980s symbolic AI philosophy (where the reasoning happened in the rules and the inputs were already structured) and the 2010s neural philosophy (where the reasoning happened inside the network).
In practice: prompt the LLM to extract structured facts in JSON-like form. Parse the JSON. Ingest into the KB at hypothesis-grade. Reasoning fires on the KB, not on the LLM. The LLM never sees the reasoning result; the user sees both via the symbolic layer’s audit trail.
You might think: “Just store every LLM fact with a confidence score; threshold to decide what to trust.” The grounding ladder is a richer model.
A confidence score is scalar — a single number that doesn’t distinguish between “uncertain from limited data” and “uncertain due to contradiction.” The grounding ladder is categorical: axiom is qualitatively different from postulate (human authority vs. theoretical derivation), and conjecture is qualitatively different from hypothesis (rule-derived vs. externally-asserted).
Categorical levels also compose better. “Combine two postulates via a strict rule → theorem.” “Combine two conjectures via a defeasible rule → conjecture (lower of inputs).” The arithmetic is clean. Trying to do the same with continuous confidence scores requires arbitrary calibration choices.
Cascade uses both — categorical grounding for ladder position, scalar confidence within each category for fine-grained ranking. Best of both, at the cost of two-axis bookkeeping.
Two trends to watch:
Tool-using LLMs. LLMs that can call external functions (Wikipedia, calculator, SQL queries, theorem provers) blur the line between neural and symbolic. The “neural” part becomes a planner that delegates to symbolic tools.
Differentiable symbolic systems. Research like DeepProbLog and SOAR-2024 makes the symbolic rule-base learnable via gradient descent. The neural and symbolic layers fuse into one trainable structure.
Both push the boundary between “neural” and “symbolic” inward. The patterns of this chapter — hypothesis-grade storage, multi-source verification, defeasible rules, B4 contradictions — will keep working in the hybrid regimes.
You’ve now reached the end of Volume II. Volume III picks up where this chapter left off, with Cascade as the worked production case study.
Solutions to selected exercises: Chapter 42 · Solutions in Appendix C. (Repo file: exercises/solutions/ch42_solutions.md.)
Volume III drills into AxiomaCascade — the geopolitical- intelligence engine that uses Axioma as one of its reasoning substrates. Two audiences at once: Cascade users learning to extend the system, and Axioma developers curious how their language composes into a production tool.
The opening chapter (Ch.43) introduces the architecture and walks a 5-minute end-to-end example. Subsequent Volume III chapters drill into the knowledge graph (Ch.43–45), the reasoning layer where Axioma rules fire (Ch.46–48), and the agent swarm and operations (Ch.49–52).
What this chapter is. Your first encounter with Axioma Cascade — the geopolitical-intelligence engine that uses Axioma as one of its reasoning substrates. The chapter does two things at once: shows the system-level picture so you can read everything else in Volume III, and walks a user-facing example against a real, populated Cascade database so you can trace cascades by the end of the chapter. Both audiences get something useful: Cascade users get reproducible commands, Axioma developers get the architecture map of where their language lives inside a production system.
This is the opening chapter of Volume III. From here on, the book is about Cascade-built-on-Axioma, not Axioma in isolation.
In one sentence:
Axioma Cascade ingests news articles, extracts entities and relations into a persistent knowledge graph, and uses NetworkX and Axioma to discover the multi-tier cascade of consequences a single event triggers.
“Cascade” is the load-bearing word. Most geopolitical analysis stops at Tier 1 — the headline event (“oil spiked”). Cascade traces the chain three or four levels deeper:
insurance withdrawn → ships stranded → LNG halted → fertilizer costs spike → food prices rise in 3-6 months → social instability in vulnerable regions
Tier 1 → Tier 2 → Tier 3 → Tier 4. Each arrow is a relation in the knowledge graph; each hop is a query that walks the graph and asks “what’s downstream of this?”
That’s the product. Now: how is it built?
┌─────────────────────────────────────────────────────────┐
│ Layer 4 — Output │
│ • CLI queries (JSON / Rich tables / JSONL / ID-only) │
│ • HTML / GraphML / DOT exports │
│ • Substack daily briefings │
│ • Subscription dashboards (web) │
└─────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────────────────────────────────┐
│ Layer 3 — Reasoning │
│ • NetworkX graph algorithms (centrality, bridges, BFS) │
│ • Axioma MCP — Belnap B4 truth evaluation │
│ • Axioma .ax rule files (domain-specific inference) │
│ • Defeasible cascade rules with cancellation │
│ • Counter-narrative + simulation engines │
└─────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────────────────────────────────┐
│ Layer 2 — Knowledge Graph │
│ • SQLite cascade.db (entities, relations, claims, ...) │
│ • Belnap B4 truth value on every claim │
│ • Confidence: HIGH / MEDIUM / LOW / SPECULATIVE │
│ • ULIDs for cross-machine sync │
│ • Optional Neo4j sync for visualization │
│ • Optional Supabase sync for multi-machine deployments │
└─────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────────────────────────────────┐
│ Layer 1 — Ingest │
│ • Article fetcher (HTTP + Playwright fallback) │
│ • spaCy NER pre-filter (entity candidates) │
│ • LLM extraction (Anthropic / OpenAI / Grok / Groq / │
│ Gemini / Ollama) │
│ • Typed entity recognition + relation discovery │
│ • Claim decomposition (per-source provenance) │
└─────────────────────────────────────────────────────────┘
Read bottom-to-top:
Each layer is substitutable — that’s the ADT discipline from Volume II Ch.24 applied at architectural scale. Want a PostgreSQL backend instead of SQLite? Reimplement Layer 2’s interface. Want a different LLM provider? Layer 1’s fetcher/extractor abstracts six already.
This is the chapter’s deepest insight. Axioma is not the whole reasoning layer — it’s the logical inference slice of it. Cascade uses both Axioma and NetworkX, each where it shines:
| Reasoning task | Engine | Why |
|---|---|---|
| “What’s the shortest path between two entities?” | NetworkX | Graph theory; well-studied; fast C implementation |
| “Which entities are bridges in the network?” | NetworkX | Same |
| “Rank entities by betweenness centrality” | NetworkX | Same |
| “Find community clusters” | NetworkX (Louvain) | Same |
| “Is this claim true / false / both / unknown?” | Axioma B4 | Multi-valued logic; paraconsistent |
| “Apply the contradicts rule to all known claims” | Axioma rules | Defeasible logic; cancellation |
| “Propagate truth through derivation chains” | Axioma rules + B4 | Provenance + bilattice meet |
| “Run a custom domain-specific cascade rule” | Axioma .ax file |
User-extensible rule language |
| “Query the shared cascade.db from inside Axioma code” | cascade axioma exec ... |
Direct relational queries |
The boundary is principled: structure goes to NetworkX, semantics goes to Axioma. Counting hops and finding shortest paths is graph theory; deciding whether two competing claims are both true, neither true, or contradictory is logic.
Cascade talks to Axioma three different ways:
cascade/core/axiomalang.py — the
Python-side bridge that calls Axioma MCP tools (run_file,
evaluate_b4, query_kb,
forward_chain)..ax rule files auto-discovered from
two directories: .cascade/rules/axiomalang/*.ax
(project-level) and ~/.cascade/rules/axiomalang/*.ax
(global user rules). Each file is executed via the Axioma MCP
run_file tool at startup.cascade axioma exec ... /
cascade axioma run ... — the user-facing CLI
subcommands. These open a persistent MCP session backed by the
shared cascade.db, so any facts you assert in your Axioma
session land in the same SQLite tables Cascade reads.The persistent MCP connection means every Axioma feature
you’ve learned in Volumes I and II is available in production.
The relational queries from Chapter 21 work on real-world news data. The
Belnap B4 logic from Chapter 20 adjudicates contradictory reports from
competing sources. The defeasible rules from Chapter 21 encode domain
expertise. The meta-circular evaluator from Chapter 22 isn’t just a
teaching toy — Cascade uses the same parse and
ast_eval builtins to translate user-typed queries into
Axioma rules.
The fastest way to see Cascade work end-to-end. (All commands here have been verified against a populated Cascade instance with ~3950 entities and ~3800 relations.)
# 1. Where is Cascade?
cd /path/to/axiomacascade
uv run cascade statsOutput (yours will differ depending on what you’ve ingested):
{
"entities": 3954,
"relations": 3809,
"claims": 8415,
"sources": 329,
"pins": 6,
"rules": 85,
"entity_types": 10,
"relation_types": 9
}# 2. Sample some entities (filter by STATE)
uv run cascade entity list --type STATE --json | head -20Each entity is a record with id, type,
name, prolog_atom, aliases,
properties, first_seen,
last_seen, domain, ulid. The
prolog_atom field is the lowercased canonical form used
when the entity shows up in .ax rules.
# 3. Trace a 2-tier cascade from "Iran"
uv run cascade discover cascade "Iran" --depth 2 --json | head -30You’ll get a tiers array. Tier 0 is the origin (Iran);
Tier 1 lists direct impacts (e.g. crude oil via SUPPLY,
global energy crisis via CAUSAL); Tier 2 lists second-order
impacts. The default relation-type filter is
{CAUSAL, DEPENDENCY, INFLUENCE, SUPPLY, CONTROL} — the
CAUSAL_TYPES set in discovery.py.
# 4. Find hidden connectors (betweenness centrality)
uv run cascade discover centrality --metric betweenness --top 10The entities that bridge different domains. Often these are commodities (oil, semiconductors) or infrastructure (straits, ports) — non-obvious connectors that link seemingly unrelated regions.
# 5. Drive Axioma directly against the shared KB
uv run cascade axioma exec 'println("Hello from Axioma!")'
uv run cascade axioma query stats
uv run cascade axioma query list-relationsThe third command returns the relation names that exist in
cascade.db as Axioma-queryable predicates — including the
eight uppercase CAUSAL-style relations and any lowercase
domain-specific ones (e.g. commodity) you’ve added from
.ax scripts.
The CLI is Unix-pipe-friendly — every query command
emits JSON when piped or when --json is passed. That lets
you compose:
uv run cascade discover cascade "Iran" --depth 3 --json \
| jq '.tiers[2].entities | .[] | .entity_name' \
| sort -uThat pipeline: trace cascade → take Tier-3 entity names → deduplicate. The point is that Cascade is a toolkit, not a monolithic dashboard; the architectural simplicity of “every command speaks JSON” makes it scriptable.
The default schema (probed live from
cascade entity list --help):
Entity types (10):
| Type | Description |
|---|---|
STATE |
Sovereign nations, governments |
ORG |
Companies, NGOs, intergov bodies, military units |
ACTOR |
Named individuals (leaders, officials, commanders) |
INFRA |
Infrastructure (ports, pipelines, straits, bases) |
COMMODITY |
Physical goods (oil, gas, grain, weapons) |
MARKET |
Financial markets, indices, trading venues |
SECTOR |
Economic sectors (energy, shipping, agriculture) |
EVENT |
Named events (wars, treaties, elections, crises) |
POLICY |
Sanctions, treaties, legislation, executive orders |
TECHNOLOGY |
Specific tech (drones, missiles, AI, cyber) |
Relation types (8 canonical, extensible):
| Type | Meaning |
|---|---|
CAUSAL |
A directly causes B |
DEPENDENCY |
A depends on B |
TEMPORAL |
A precedes B in time |
ADVERSARIAL |
A and B are in conflict |
ALLIANCE |
A and B are aligned |
SUPPLY |
A supplies B |
CONTROL |
A controls B |
INFLUENCE |
A influences B (softer than CONTROL) |
Both lists are extensible via
cascade types add. The defaults are deliberately small —
Cascade is opinionated about what kinds of things matter for cascade
tracing.
The five relation types in CAUSAL_TYPES —
{CAUSAL, DEPENDENCY, INFLUENCE, SUPPLY, CONTROL} — are the
ones the default discover cascade traversal follows. The
other three (TEMPORAL, ADVERSARIAL, ALLIANCE) are visible in the graph
but don’t propagate causally by default. That’s the system’s
content claim about which edges count as “downstream impact.”
Every fact in Cascade’s database carries one of four truth values:
| Value | Meaning |
|---|---|
TRUE |
confirmed by at least one source |
FALSE |
contradicted by sources |
BOTH |
confirmed by some sources, contradicted by others (the paraconsistent case) |
UNKNOWN |
no information either way |
Plus a confidence band:
| Band | Range |
|---|---|
HIGH |
> 80% |
MEDIUM |
50–80% |
LOW |
20–50% |
SPECULATIVE |
< 20% |
When two articles report contradictory facts about the same
entity-relation, Cascade doesn’t pick a winner. It marks the
relation as BOTH and records both sources. Downstream
queries can choose: filter out BOTH-flagged claims for
conservative analysis, or include them with explicit disagreement
flags.
This is Volume I Chapter 20’s Belnap B4 logic in production use — exactly the value space the chapter taught, now adjudicating real-world contradictory reporting.
cascade discover cascade --depth N is the canonical
demonstration of where structure (NetworkX) and semantics (Axioma)
meet.
The algorithm, in plain English:
entities and relations tables. Each entity
becomes a node; each relation becomes a typed edge.CAUSAL_TYPES,This is breadth-first search by level —
distinguishing tiers means “one hop away” versus “two hops away,”
exactly the metric that makes cascade analysis useful for journalism
(Tier 1 is what made the news; Tier 3 is what nobody has connected yet).
The find_cascade_chain function in
cascade/core/discovery.py:71 is the ~70-line
implementation.
Where would Axioma get involved? Three places that haven’t been wired in by default but are architecturally available:
BOTH-truth-value edge
inherits paraconsistency. Axioma’s evaluate_b4 is the right
engine to compute that.cancels rule with an existing claim should be marked
suppressed but kept visible..ax rule like commodity_price_cascade.ax
(shipped with Cascade today) encodes domain expertise that
can’t be inferred from the graph alone.These are the kinds of integrations Volume III’s later chapters drill into.
The most direct way to see Axioma and Cascade interoperate is the
shared SQLite database. Both processes read and write
the same cascade.db:
# From the Python side (Cascade)
uv run cascade entity list --type STATE --json | head -3
# From the Axioma side (using the cascade axioma bridge)
uv run cascade axioma exec 'println(stats())'Both commands hit the same SQLite tables. When a Cascade ingestion
adds a new fact, it’s immediately visible to Axioma rules. When an
Axioma rule asserts a new fact (with axiom/persist), it’s
immediately visible to Cascade’s NetworkX builds.
This is the literal substrate for Volume III’s later
chapters on the agent swarm. The collector agent writes via Cascade
APIs; the analyst agent reads via Axioma queries; the producer agent
reads consolidated results via Cascade’s CLI. Nothing serialized over
the wire — they all share cascade.db.
Three areas where the chapter glosses over real complexity:
SUPABASE_URL and SUPABASE_KEY are configured,
Cascade can push/pull the knowledge graph to a hosted Postgres instance,
enabling multi-machine deployments. The
WARNING: Supabase credentials not configured you see is the
gate.cascade mcp serve exposes the same KB to any MCP- capable
client (Claude Desktop, Claude Code, etc.) — not just the Cascade CLI.
The MCP layer is documented in Ch.34 of this book.Future Volume III chapters (Ch.44+, numbering still being finalized) will drill into specific subsystems:
find_cascade_chain pseudocode, BFS variants for TEMPORAL
and ADVERSARIAL walks..ax rules for Cascade —
the shipped rules (commodity_price_cascade.ax,
monetary_tightening_currency_appreciation.ax,
oil_gold_positive_correlation.ax,
capital_flow_attraction_rule.ax) as templates.Each chapter will lean on this chapter’s architecture map for orientation. When a later chapter says “the collector agent stores facts via the Layer-2 interface and fires the Axioma rule engine at Layer 3,” you’ll know exactly what it means.
These exercises are runnable against a working
Cascade install. Most require nothing more than the Cascade CLI and a
configured LLM provider. Exercises 43.1, 43.4, 43.5 work
without further API calls, using whatever data is already in
your database. The uv run cascade ... prefix assumes you’re
in the axiomacascade repo root.
Run uv run cascade stats once. Note the entity /
relation / claim counts. Run it a second time and compare — the
numbers should be identical (no ingestion happened between the two
calls).
Then run
uv run cascade entity list --type STATE --json | head -50.
Look at the JSON shape — fields, types, ULIDs, properties. This is the
schema you’ll work with for the rest of Volume III.
Pick a recent news article you’ve read. Run
uv run cascade ingest <url>. After it completes, run
uv run cascade entity list --type EVENT --json and
eyeball-check: do the extracted entities match what you’d have
extracted yourself?
Common things to spot-check:
Write down 3 specific extraction successes and 3 specific failures. This is the foundation of evaluating Cascade’s analytical quality.
Pick a real entity in your database (e.g. “Iran”, “Crude Oil”, “Strait of Hormuz”) and run:
uv run cascade discover cascade "<name>" --depth 3 --jsonExamine the tiers array. For each Tier-2 and Tier-3
entry, ask:
A good cascade should have some novel Tier-3 results. Pure-obvious cascades suggest the underlying article corpus is too narrow.
Run two structural-analysis commands:
uv run cascade discover bridges --json | head -30
uv run cascade discover centrality --metric betweenness --top 10The first returns entities whose removal would fragment the graph. The second returns entities that sit on many shortest paths between other pairs.
Look at both lists. Are the bridges also high- centrality?
Often yes, but not always — some bridges are low-traffic chokepoints.
Pick one entity that appears in both lists, then run
cascade discover cascade "<name>" --depth 2 against
it. Does the cascade chain explain why this entity is structurally
important?
Use the cascade axioma subcommand to interact with the
shared KB from inside Axioma:
# What relations does Cascade have stored?
uv run cascade axioma query list-relations
# What facts are stored for the commodity relation?
uv run cascade axioma query list-facts --relation commodity
# Execute Axioma code that sees those facts
uv run cascade axioma exec 'println("Stats:"); println(stats())'Then add a new fact from the Axioma side:
uv run cascade axioma exec 'axiom/persist test_fact("hello", "world")'And verify it appears:
uv run cascade axioma query list-facts --relation test_factReflect: how is this fact persisted (which table?), and can Cascade’s CLI commands now see it?
You’ve now seen one architecture where Axioma is the logical-reasoning slice of a larger system. Sketch a different system you’d build that uses Axioma as one of its components:
This is open-ended. The point is to get you thinking about Axioma as a component — a reasoning engine you plug into larger systems — not as a self-contained programming environment. Once you internalize this, you start seeing opportunities for Axioma everywhere.
Solutions to selected exercises: Chapter 43 · Solutions in Appendix C. (Repo file: exercises/solutions/ch43_solutions.md.)
The system in this chapter is enormous. The pipeline has half a dozen technologies in it (Python, SQLite, NetworkX, spaCy, LLMs, Axioma, optionally Neo4j, optionally Playwright, optionally Supabase). Three different ways to deploy it (local CLI, scheduled agents, subscription dashboard). Ten entity types, eight relation types, four truth values, four confidence bands.
What keeps the complexity manageable is the architecture map from §43.2. Every later chapter slots somewhere on that map. Knowledge-graph schema lives at Layer 2. Cascade discovery lives at Layer 3. Daily briefing generation lives at Layer 4. The map is your “you are here” sign for the rest of Volume III.
The map is also Cascade’s deepest design principle: reasoning engines are pluggable. Today the rule engine is Axioma; tomorrow it might be something else; nothing forces clients to know. The same separation Volume II Ch.24 taught for stacks and queues — interface, not representation — is what makes Cascade a system that can evolve rather than a single monolithic codebase.
The next Volume III chapter will zoom into Layer 2 (the knowledge-graph schema). You’ll meet the core tables, see how Belnap B4 truth values are stored alongside facts, and learn the SQLite indexes that make the whole thing fast.
“All architecture is an attempt to enable some changes while preventing others.” — paraphrased from Ralph Johnson. Cascade’s architecture enables: changing reasoning engines, changing storage backends, changing LLM providers, changing output formats. It prevents: tangling those four concerns into the same code path.
What this chapter is. A deep dive into Layer 2 of Cascade’s architecture — the SQLite schema that backs every entity, relation, claim, and rule. Ch.43 showed you the architecture map; this chapter zooms in on the map’s most heavily-trafficked layer. You’ll meet the four core tables, the three support tables, the metadata tables, the indexes that make queries fast, and the sync-outbox machinery that lets multiple machines share a knowledge graph. By the end you’ll read
cascade.dbfluently with raw SQL — useful when the CLI doesn’t expose the query shape you need.
This chapter is the first of Part XIII (Cascade Knowledge Graph). Subsequent Part XIII chapters cover the ingest pipeline (Ch.45) and the truth-value lifecycle (Ch.46).
Cascade stores its knowledge graph in SQLite, not Neo4j (the canonical graph database) or PostgreSQL (the canonical relational database). Three reasons:
data/cascade.db.pandas.read_sql), Axioma reads
cascade.db directly through its SQLite-backed KB layer.
No translation layer.The tradeoff is that some graph queries (longest path, triangle counting) are slower than they’d be on Neo4j. Cascade pays for that by loading the relations into a NetworkX DiGraph in memory when graph-algorithmic work is needed — see Ch.43 §43.7. Store relational, compute graph-algorithmic.
A Neo4j sync mirror is available for visualization and ad-hoc Cypher queries, but the source of truth is always SQLite.
Cascade’s database has 20 tables, but seven do 95% of the work:
| Table | Purpose | Rows in typical install |
|---|---|---|
entities |
Nodes in the knowledge graph | 1k–10k+ |
relations |
Edges between entities | 1k–10k+ |
claims |
Per-source propositions (provenance) | 5k–50k+ |
sources |
Where the data came from | 100s–1000s |
rules |
Stored reasoning rules | 50–500 |
entity_types |
Canonical entity-type list | 10 |
relation_types |
Canonical relation-type list | 8–20 |
The remaining 13 tables (change_log,
truth_history, pins, monitors,
notifications, predictions,
reminders, saved_searches,
path_analyses, portfolios,
sync_outbox, sync_meta, plus
sqlite_sequence) support specific subsystems — event-loop
reactivity, audit history, user preferences, multi-machine sync. We’ll
touch them in §44.6 and §44.7.
entities — nodes in
the graphCREATE TABLE entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
prolog_atom TEXT,
aliases TEXT NOT NULL DEFAULT '[]', -- JSON array
properties TEXT NOT NULL DEFAULT '{}', -- JSON object
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
source_id INTEGER REFERENCES sources(id),
ulid TEXT,
updated_at TEXT NOT NULL DEFAULT '',
machine_id TEXT NOT NULL DEFAULT '',
domain TEXT
);
CREATE INDEX idx_entities_type ON entities(type);
CREATE INDEX idx_entities_name ON entities(name);
CREATE UNIQUE INDEX idx_entities_ulid ON entities(ulid);
CREATE INDEX idx_entities_prolog ON entities(prolog_atom);Three fields earn their own discussion:
prolog_atom — the lowercased,
snake-cased canonical form used when this entity appears in an Axioma
.ax rule. “Strait of Hormuz” becomes
strait_of_hormuz; “U.S. Treasury” becomes
us_treasury. This bridges the display name humans
read and the atom symbol Axioma’s relation queries unify
against.aliases — a JSON array of alternate
surface forms (“USA”, “United States”, “U.S.”). The entity-resolution
step uses this to merge LLM extractions that name the same entity
differently.properties — a JSON object for
type-specific attributes ({"sentiment": "NEUTRAL"},
{"role": "gas and oil producer"}). A type-typed
bag — schema-flexible.Sample row from a live database:
SELECT id, type, name, prolog_atom, domain
FROM entities WHERE id = 1;
-- id=1, type=STATE, name="Iran", prolog_atom="iran", domain="political"relations — edges
between entitiesCREATE TABLE relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_entity_id INTEGER NOT NULL REFERENCES entities(id),
target_entity_id INTEGER NOT NULL REFERENCES entities(id),
type TEXT NOT NULL,
prolog_pred TEXT,
confidence TEXT NOT NULL DEFAULT 'MEDIUM',
properties TEXT NOT NULL DEFAULT '{}',
evidence TEXT NOT NULL DEFAULT '[]',
claim_ids TEXT NOT NULL DEFAULT '[]',
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
source_id INTEGER REFERENCES sources(id),
ulid TEXT,
updated_at TEXT NOT NULL DEFAULT '',
machine_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_relations_source ON relations(source_entity_id);
CREATE INDEX idx_relations_target ON relations(target_entity_id);
CREATE INDEX idx_relations_type ON relations(type);
CREATE UNIQUE INDEX idx_relations_ulid ON relations(ulid);Every relation is directed.
(source_entity_id, target_entity_id, type) together
identify the edge. The type is constrained to a value in
relation_types (see §44.4).
Key per-relation fields:
prolog_pred — when populated, the
causal(iran, crude_oil)-style predicate symbol used by
Axioma rules. Computed from
(type.lower(), source.prolog_atom, target.prolog_atom).confidence — one of HIGH
/ MEDIUM / LOW / SPECULATIVE.
Used by discover cascade --min-confidence to filter
cascade traversal.claim_ids — JSON array of claim IDs
that support this relation. The provenance link from edge to
evidence.evidence — JSON array of
quoted-snippet evidence strings (often a subset of the cited claims’
texts).Sample query — relations from Iran with HIGH confidence:
SELECT r.id, r.type, e2.name AS target, r.confidence
FROM relations r
JOIN entities e2 ON r.target_entity_id = e2.id
WHERE r.source_entity_id = 1 AND r.confidence = 'HIGH'
LIMIT 5;
-- 1 SUPPLY crude oil HIGH
-- 42 ADVERSARIAL Strait of Hormuz HIGH
-- 43 ADVERSARIAL United States HIGH
-- 52 CONTROL Ayatollah Ali Khamenei HIGH
-- 55 ADVERSARIAL Dubai HIGHThat’s five different “Iran is structurally adjacent to…” facts, each typed and confidence-banded.
claims — per-source
propositionsCREATE TABLE claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
entity_ids TEXT NOT NULL DEFAULT '[]',
truth_value TEXT NOT NULL DEFAULT 'UNKNOWN',
confidence TEXT NOT NULL DEFAULT 'MEDIUM',
source_id INTEGER REFERENCES sources(id),
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT,
updated_at TEXT NOT NULL DEFAULT '',
machine_id TEXT NOT NULL DEFAULT '',
properties TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_claims_truth ON claims(truth_value);
CREATE UNIQUE INDEX idx_claims_ulid ON claims(ulid);Each claim is one proposition —
typically a single sentence extracted from a source article. Why a
separate table from relations?
(Strait_of_Hormuz, SUPPLY, crude_oil). Multiple claims from
multiple sources may all attest to the same relation.The truth_value field is the Belnap B4
value — TRUE, FALSE,
BOTH, UNKNOWN. Live distribution from a
populated Cascade:
truth_value |
Count | Comment |
|---|---|---|
TRUE |
7602 | Default for newly-ingested claims |
BOTH |
437 | Paraconsistent — two sources disagreed |
FALSE |
42 | Affirmatively refuted |
UNKNOWN |
335 | Insufficient evidence either way |
The paraconsistent (BOTH) row count is
the most revealing — it’s the rate at which Cascade has had to
record contradiction rather than pick a winner. About
5% of claims in a typical news-ingestion corpus end up
paraconsistent. That’s neither rare enough to ignore nor common enough
to dismiss the system as too noisy — it’s the empirical
baseline for “how often serious journalism disagrees about
facts.”
sources — where
the data came fromCREATE TABLE sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT,
title TEXT,
author TEXT,
date TEXT,
source_type TEXT NOT NULL DEFAULT 'news',
credibility REAL NOT NULL DEFAULT 0.8,
raw_text TEXT NOT NULL DEFAULT '',
summarized_text TEXT NOT NULL DEFAULT '',
ingested_at TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT,
updated_at TEXT NOT NULL DEFAULT '',
machine_id TEXT NOT NULL DEFAULT ''
);The provenance root. Every entity, relation, and claim can
be traced back to a source via source_id. The
credibility field (a REAL in
[0, 1]) lets the truth-evaluation step weight claims by the
historical reliability of their source — Reuters at 0.95 outweighs
SubstackBlog at 0.4 when adjudicating a BOTH-truth
disagreement.
raw_text is the article body (typically truncated to
some chunk limit); summarized_text is an LLM-condensed
version used for fast cascade-context queries. The full chain is
article → source → claims → relations → entities —
top-down, every extracted fact points home to the article that
introduced it.
entity_types and relation_typesThe two metadata tables that define Cascade’s ontology:
CREATE TABLE entity_types (
name TEXT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE relation_types (
name TEXT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);Tiny tables — typically 10 rows each — but load-bearing. The
type columns in entities and
relations are foreign-key-like (but not enforced by SQLite
constraints; the application layer maintains the invariant). Live
contents from a populated Cascade:
Entity types:
STATE Countries, nations, governments
ORG Organizations, companies, institutions, military units
ACTOR Named individuals (leaders, officials, commanders)
INFRA Infrastructure (ports, pipelines, straits, bases)
COMMODITY Physical goods (oil, gas, grain, weapons)
MARKET Financial markets, indices, trading venues
SECTOR Economic sectors (energy, shipping, agriculture)
EVENT Named events (wars, treaties, elections, crises)
POLICY Sanctions, treaties, legislation, executive orders
TECHNOLOGY Specific tech (drones, missiles, AI systems, cyber)
Relation types (8 canonical + extensible):
CAUSAL Cause-effect relationship
DEPENDENCY One entity depends on another
TEMPORAL Time-ordered sequence
ADVERSARIAL Opposition, conflict, rivalry
ALLIANCE Partnership, cooperation, mutual support
SUPPLY Resource or goods supply chain
CONTROL Authority, ownership, governance
INFLUENCE Soft power, lobbying, shaping outcomes
A live install also typically has 1–2 Axioma-injected
relations (e.g. commodity, test_fact) — added
when your .ax scripts assert facts with new relation names.
The cascade.axioma bridge auto-registers them in
relation_types with the description prefix
“Axioma relation: <name>/<arity>”.
The asymmetry — entity types fixed, relation types extensible — reflects what the system is for. Cascade adds new connection kinds readily; it adds new kinds of thing rarely.
rules table — Cascade’s reasoning vocabularyCREATE TABLE rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL, -- correlation, causal, behavioral, conditional
domain TEXT, -- economic, political, sociological, general
engine TEXT DEFAULT 'python',
definition TEXT NOT NULL,
confidence TEXT DEFAULT 'MEDIUM',
entity_ids TEXT DEFAULT '[]',
source TEXT, -- manual, llm, axioma, converted, file
executable INTEGER DEFAULT 1,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
applied_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
ulid TEXT
);
CREATE INDEX idx_rules_type ON rules(type);
CREATE INDEX idx_rules_domain ON rules(domain);
CREATE INDEX idx_rules_engine ON rules(engine);A single table records rules across five engines:
| Engine | Typical count | What it stores |
|---|---|---|
python |
36 | A module:function_name string |
prolog |
17 | A Prolog fact string (legacy) |
axiomalang |
14 | A path to an .ax rule file |
cascade |
12 | A native-Cascade DSL rule string |
axioma |
6 | Inline Axioma code |
applied_count and success_count are
bookkeeping columns — every time the reactor fires a rule, the
counters increment. Over time you can see which rules are load-bearing
and which are dead weight.
The source column tracks provenance:
file means read from .cascade/rules/;
llm means proposed by the model during ingest;
manual means typed in by a user via the CLI. The
executable flag distinguishes callable rules (1)
from informational ones (0) — the latter are LLM-learned
summaries that document a pattern without firing automatically.
change_log, truth_history,
pinsThree tables that aren’t part of the core graph but matter for reactive behavior:
change_log — what
just happenedCREATE TABLE change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
change_type TEXT NOT NULL,
item_type TEXT NOT NULL,
item_id INTEGER NOT NULL,
entity_ids TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
reactor_handled INTEGER DEFAULT 0,
rules_fired TEXT DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT
);Every insert/update to entities, relations,
or claims writes a row here. The
reactor_handled flag starts at 0; the reactor agent (Volume
III Part XV) processes the queue and flips it to 1, recording which
rules fired in rules_fired. This is the event
log that makes the system reactive — rules don’t poll the
graph; they react to the diff.
truth_history
— audit trail for truth changesCREATE TABLE truth_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id INTEGER NOT NULL REFERENCES claims(id),
old_truth_value TEXT NOT NULL,
new_truth_value TEXT NOT NULL,
old_confidence TEXT NOT NULL,
new_confidence TEXT NOT NULL,
reason TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT
);When a claim’s truth_value changes (e.g. a new source
asserts the opposite, flipping TRUE to BOTH),
a row lands here. This is how the system answers “when did this claim
become contested?” or “what changed last week?”
pins — user-marked
itemsCREATE TABLE pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_type TEXT NOT NULL, -- 'entity', 'relation', 'claim', 'source'
item_id INTEGER NOT NULL,
label TEXT,
note TEXT,
pinned_at TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT
);User-curated bookmarks across all the core tables. The
item_type discriminator means one pin table can reference
any entity, relation, claim, or source. Used by the dashboard for the
“Watch list” UI.
sync_outbox and sync_metaThe most heavily-trafficked table in a long-lived Cascade isn’t
entities or relations — it’s
sync_outbox:
CREATE TABLE sync_outbox (
id TEXT PRIMARY KEY, -- the row's ULID
table_name TEXT NOT NULL,
row_id TEXT NOT NULL,
operation TEXT NOT NULL, -- INSERT or UPDATE
row_data TEXT NOT NULL DEFAULT '{}',
machine_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
pushed_at TEXT,
error TEXT
);
CREATE INDEX idx_sync_outbox_pushed ON sync_outbox(pushed_at);Every mutation to a core table writes a sync-outbox row (operation
INSERT or UPDATE) with the full new row
serialized as JSON. A separate sync daemon reads unpushed rows and
pushes them to Supabase (a hosted-Postgres mirror).
Live distribution in a populated database:
| Operation | Count | Comment |
|---|---|---|
INSERT |
307,784 | One per new entity / relation / claim |
UPDATE |
1,906 | Truth-value flips, alias additions, etc. |
The outbox grows monotonically (the daemon marks rows as pushed but
doesn’t delete) — eventually you run cascade sync truncate
to clean old pushed rows.
sync_meta is a small key-value store for sync-daemon
state: last-pushed-cursor, last-error, machine-id, etc.
The architectural point: sync is append-only at
the row level. Two machines that ingest different articles each
generate their own sync_outbox rows; the daemon pushes both
sets; Supabase merges (by ULID). The ULID columns on every core table
are the deduplication key. No conflict resolution needed because every
mutation has a unique global ID.
cascade.db with raw SQLYou don’t need the Cascade CLI to query the knowledge graph
— you can sqlite3 cascade.db directly. Useful when the CLI
doesn’t expose the exact join you want:
-- Top entity types by count
SELECT type, COUNT(*) FROM entities
GROUP BY type ORDER BY 2 DESC;
-- Top relation types by count
SELECT type, COUNT(*) FROM relations
GROUP BY type ORDER BY 2 DESC;
-- All HIGH-confidence CAUSAL relations involving Iran
SELECT e1.name AS src, r.type, e2.name AS tgt, r.confidence
FROM relations r
JOIN entities e1 ON r.source_entity_id = e1.id
JOIN entities e2 ON r.target_entity_id = e2.id
WHERE r.type = 'CAUSAL'
AND r.confidence = 'HIGH'
AND (e1.name = 'Iran' OR e2.name = 'Iran')
LIMIT 10;
-- Claims with paraconsistent truth (BOTH)
SELECT id, substr(text, 1, 60) AS preview, confidence
FROM claims WHERE truth_value = 'BOTH' LIMIT 5;
-- Recent changes
SELECT change_type, item_type, item_id, created_at
FROM change_log
WHERE reactor_handled = 0
ORDER BY created_at DESC LIMIT 10;The CLI commands you’ve used in Ch.43 —
cascade entity list, cascade discover cascade,
etc. — are mostly thin wrappers around joins like these. Reading the raw
SQL teaches you the system’s capability surface.
Four database-design tradeoffs to know about:
properties blurs the schema.
The properties column on entities,
relations, and claims is JSON-typed (stored as
TEXT, parsed at read time). Schema flexibility wins; you give up
per-field indexing. Queries that filter on properties
deserialize every row.PRAGMA foreign_keys = ON is set
per-connection. Cascade enables it but tools that connect directly (raw
sqlite3 CLI) may not. Watch for orphan rows in
relations.source_entity_id.ulid is NOT UNIQUE in early
rows. Rows predating the sync-machinery rollout may have empty
ulid fields. The unique index idx_*_ulid only
constrains non-empty values.cascade sync truncate --before=<date> in long-running
deployments.These are real engineering tradeoffs, not bugs — they reflect choices about what’s worth optimizing for at each subsystem.
These exercises run against a real cascade.db. All
queries are pure-SQL — they don’t require any Cascade CLI flag or LLM
key. Substitute your DB path.
Connect to cascade.db with sqlite3 and run
.tables, .schema entities,
.schema relations, .schema claims. Compare
what you see to §44.3 — any columns added since this chapter was
written? (Cascade schema migrations land regularly.)
Write SQL to count entities by type and relations by type, sorted descending. Identify the most common entity type and the most common relation type. Why do you think they dominate?
Find all claims with truth_value = 'BOTH'. Print their
first 60 characters plus their source’s title. Pick one and read the
underlying source URL — what were the two sides of the disagreement?
Pick an entity name from your DB. Write a SQL query that joins
relations to entities twice (once as source,
once as target) to print all of that entity’s outgoing relations in
human-readable form (source name, relation type, target name,
confidence).
Count unhandled change_log rows
(WHERE reactor_handled = 0). If the count is non-zero, what
does that mean about your reactor’s recent activity? Look at the most
recent 10 unhandled rows — what changed?
You’ve now seen the live schema in detail. Propose one schema change you’d make and one you’d resist. Examples to think about:
properties be split into typed columns?relation_types enforce a closed enum?truth_value be a foreign key to a
truth_values table?sync_outbox auto-prune pushed rows?For each, sketch what breaks and what improves if you made the change. Real schema design is a series of such tradeoffs.
Solutions to selected exercises: Chapter 44 · Solutions in Appendix C. (Repo file: exercises/solutions/ch44_solutions.md.)
data/cascade.db — embedded, serverless, SQL-queryable,
WAL-mode-concurrent.entities, relations, claims,
sources, rules, entity_types,
relation_types.BOTH)
accounts for about 5% of claims in a typical install — the empirical
baseline for “how often serious journalism disagrees.”change_log (event
queue), truth_history (audit trail), and pins
(user marks).sync_outbox + Supabase mirror; ULIDs are the cross-machine
dedup key.The next chapter zooms one layer up: the ingest pipeline that populates these tables — fetcher, spaCy NER, LLM extraction, claim decomposition. Where this chapter taught the shape, the next teaches the fill.
“A schema is a frozen set of decisions about what matters.” — paraphrased. Cascade’s schema bets that directed typed relations with provenance and a bilattice truth value will cover most reasoning tasks a journalist or analyst encounters. The remaining tables exist because that bet wasn’t quite enough. Each table you met is a place the framework said “this matters, too, and it deserves its own row.”
What this chapter is. Ch.44 showed you the destination — the SQLite tables that store Cascade’s knowledge graph. This chapter shows the journey — the four-stage pipeline that takes a URL, a file, or pasted text and turns it into entities, relations, and claims with provenance, types, and truth values attached. By the end you’ll understand what happens between
cascade ingest <url>and the first row landing inentities. You’ll also know where the pipeline can break, where it silently degrades, and where to look in the logs when an ingestion produces zero output.
This is the second chapter of Volume III Part XIII (Cascade Knowledge Graph). Ch.44 was the static view; this is the dynamic view.
URL / file / text
│
▼
┌────────────────────────────┐
│ Stage 1 — Fetch │
│ • HTTP GET via httpx │
│ • readability.Document │
│ • Authenticated paywall │
│ fallback (Chrome cookies)│
│ • Source-type detection │
│ • Publish-date extraction │
└────────────────────────────┘
│ raw_text, title, date, credibility
▼
┌────────────────────────────┐
│ Stage 2 — NER pre-filter │
│ • spaCy en_core_web_sm │
│ • Label → Cascade type map │
│ • Frequency-ranked hints │
│ • Cap at 40 hints │
└────────────────────────────┘
│ hint_text (prompt prefix)
▼
┌────────────────────────────┐
│ Stage 3 — LLM extraction │
│ • Entity + relation pass │
│ • Claim decomposition pass │
│ • Typed JSON output │
│ • Retry on parse failure │
│ • Truncation detection │
└────────────────────────────┘
│ ExtractionResult, ClaimExtractionResult
▼
┌────────────────────────────┐
│ Stage 4 — Store + dedup │
│ • resolve_entity (merge) │
│ • insert_entity / relation │
│ • Credibility-band │
│ confidence downgrades │
│ • change_log + sync_outbox │
└────────────────────────────┘
│
▼
cascade.db
Read top-to-bottom. Each arrow is a Python function call returning a typed Pydantic model. The whole pipeline is synchronous-by-default (one article ingested at a time) but parallelizable at Stage 3 — the LLM extraction and claim extraction can run as concurrent calls, then converge at Stage 4.
A typical ingestion takes 15-60 seconds, dominated by the LLM round-trip in Stage 3.
cascade.core.ingester.ingest_url(url) is the entry
point. Three branches:
httpx.get(url, follow_redirects= True, timeout=30.0)
with a Chrome User-Agent. On 401/403,
auto-retries with the authenticated path.ingest_file(path),
ingest_text(text), ingest_browser(url)
(controls a real Chrome via AppleScript),
ingest_render(url) (headless browser for SPA-rendered
pages).The response then runs through readability.Document to
strip nav chrome, ads, and footers, leaving the article body. A regex
pass (_clean_article_text) removes a handful of frequent UI
fragments — “Show Conversation (N)”, “Reprints Gift Article”, “”
— that survive readability.
The output is a Source Pydantic model with
url, title, raw_text,
date, source_type, credibility.
The Source isn’t stored yet — that happens at the very end, after
extraction succeeds.
detect_source_type(url, html) looks at the URL path and
HTML markers (<article>,
<meta property= "og:type">, byline patterns) and
returns one of:
SourceType |
Default credibility |
|---|---|
NEWS |
0.80 |
OPINION |
0.50 |
ANALYSIS |
0.85 |
PRESS_RELEASE |
0.70 |
REPORT |
0.90 |
SOCIAL |
0.40 |
These numbers are hardcoded defaults in
models.py — but credibility is a
REAL column on sources, so the analyst can
override per-source after evaluation. A live database typically
shows:
source_type n
----------- ---
news 317
opinion 11
analysis 1
The distribution reflects what Cascade ingests: mostly news, occasionally op-eds, rarely analysis reports. (Reports usually arrive via file ingestion, not URL.)
The authenticated fetch path uses the user’s existing browser
session cookies — not paywall bypass. If you’re
subscribed to the WSJ and logged in to wsj.com in Chrome,
cascade ingest <wsj-url> fetches the article using
your already-paid-for session. The cookie cache lives in memory for 30
minutes (configurable). Outside the cache, Cascade extracts cookies from
Chrome’s ~/Library/Application Support/Google/Chrome/...
SQLite store via the browser-cookie3 package.
The browser-render path (ingest_browser) shells out to a
real Chrome via AppleScript when JavaScript rendering is necessary
(e.g. Bloomberg’s SPA pages won’t surrender content to cookie-only
fetches).
def extract_ner_hints(text: str) -> list[dict]:
nlp = _get_nlp() # lazy-loaded en_core_web_sm
if nlp is None:
return [] # graceful degradation
doc = nlp(text)
entity_counts = {}
for ent in doc.ents:
cascade_type = _SPACY_TO_CASCADE.get(ent.label_)
if cascade_type is None:
continue
...
return sorted(entity_counts.values(),
key=lambda x: x["count"],
reverse=True)spaCy’s en_core_web_sm model labels every noun phrase
with one of ~18 entity labels. Cascade maps a subset to its own type
ontology:
| spaCy label | Cascade type |
|---|---|
GPE (countries, cities) |
STATE |
NORP (nationalities, parties) |
ORG |
ORG |
ORG |
PERSON |
ACTOR |
FAC / LOC |
INFRA |
PRODUCT |
COMMODITY |
EVENT |
EVENT |
LAW |
POLICY |
MONEY, DATE, QUANTITY, … |
skip |
The output is a frequency-ranked list of candidate entities. The
function format_ner_hints produces prompt-prefix text
like:
PRE-IDENTIFIED ENTITIES (from NER — verify and refine types,
add any missing):
- Iran (likely STATE, mentioned 12x)
- Strait of Hormuz (likely INFRA, mentioned 8x)
- Tehran (likely STATE, mentioned 5x)
...
This block gets prepended to the LLM extraction prompt. The LLM uses it as a seed list — it doesn’t have to discover entities from scratch, just verify and re-type them.
Three reasons:
[] and the LLM gets no hints — but the
pipeline still works.The model is lazy-loaded — _get_nlp()
only loads on the first call, then caches in a module global. Subsequent
ingestions reuse it.
Two parallel LLM calls. Each takes the article text plus a prompt template:
extract_entities_and_relationsPrompt structure (heavily simplified):
{ner_hints} # The spaCy seed list
You are an entity-and-relation extractor.
Return ONLY valid JSON of shape:
{
"entities": [
{"name": "...", "type": "STATE|ORG|...",
"domain": "energy|political|...",
"aliases": [...], "properties": {...}}
],
"relations": [
{"source_entity": "...", "target_entity": "...",
"type": "CAUSAL|SUPPLY|...",
"confidence": "HIGH|MEDIUM|LOW|SPECULATIVE",
"evidence": "..."}
]
}
ARTICLE TEXT:
{article_text}
The result is an ExtractionResult Pydantic model.
Typical yield from a 600-word article:
The big numbers come from analytical pieces with many linked items; the small numbers come from short news flashes mentioning one or two actors.
extract_claims_llmDifferent prompt — focuses on sentence-level propositions:
You are a claim decomposer. Break this article into
atomic propositions.
Return JSON: {"claims": [
{"text": "...",
"entity_ids": [...],
"truth_value": "TRUE|FALSE|BOTH|UNKNOWN",
"confidence": "HIGH|MEDIUM|LOW|SPECULATIVE"}
]}
ARTICLE TEXT:
{article_text}
Typical yield: 31 claims per article (range: 1 to 100). Each claim is one assertion: “Iran exports an estimated 2.1 million barrels of oil per day.”
The two passes are independent — they could be called in
parallel by an async wrapper. Today they run sequentially. The
decomposer.py and extractor.py modules each
handle one pass.
Both passes use extract_json(response) to parse the LLM
output. When parse fails (the LLM returned prose, or got truncated
mid-JSON), the code:
RuntimeError with a suggestion
to raise max_tokens in cascade.toml.Production logs occasionally show a flurry of retry prompts when a provider’s quota throttles — the LLM returns short responses; Cascade retries; eventually the request lands.
Even when JSON parses, the response might be truncated — the
LLM hit max_tokens mid-array. The
extraction_info.get("json_repaired") flag tells you the
JSON parser had to repair the response (close braces, drop a
trailing comma). When true, Cascade records an issue:
“LLM response was truncated and JSON had to be repaired — some claims may be missing. Consider increasing max_tokens in cascade.toml.”
The issues field rides along in the ingestion output —
visible to the analyst, but doesn’t fail the run.
call_llm(prompt, max_tokens=None) dispatches across six
provider backends:
openai.AsyncOpenAIProvider selection is per-call via the --provider CLI
flag or the [llm] config block in
cascade.toml. Switching providers mid-run is supported —
Cascade keeps no provider-specific state between calls.
store_entities(conn, result, source_id) writes the LLM
output into the four core tables. Three subtleties:
resolve_entity)Before inserting an entity, the code asks: does an entity by this name (or alias) already exist?
existing = resolve_entity(conn, ent.name, ent.type.value)
entity_id = insert_entity(conn, entity) # merges if existing
if existing:
merged_entities += 1The resolve_entity lookup checks
entities.name, entities.aliases (JSON array),
and a few normalization rules (case-insensitive, trim whitespace). A
successful match means the new extraction merges — its aliases
get added to the existing row, its properties get merged, but no new row
is inserted.
This is the dedup substrate. Without it, ingesting “USA” and “United States” as separate mentions would produce two STATE rows. With it, the second extraction lands as an alias on the first.
Watch out: the resolver is name-based, not
entity-resolution-grade. Different spellings (“Russia” vs
“Russian Federation”) may produce duplicates that need a manual
cascade entity merge later.
For each relation, the code resolves source and target entities, then
calls insert_relation. If either entity is unresolvable,
the relation is skipped with a warning:
WARNING: Skipping unresolvable relation |
target='Some Entity' | type=CAUSAL
This is graceful degradation. A relation referring to an entity that didn’t get extracted is lost — but the rest of the ingestion succeeds.
The
_apply_credibility_downgrade(confidence, credibility)
helper modifies extracted confidences based on source credibility:
| Source credibility | Effect |
|---|---|
≥ 0.7 (news, analysis) |
No change |
0.5–0.7 (press release, opinion) |
One band down |
< 0.5 (social) |
Two bands down |
A HIGH-confidence claim from a
0.4-credibility social-media source becomes
LOW. The downgrade honors the principle from Ch.44 —
the source’s reliability shapes the claim’s truth-weight.
Every INSERT and UPDATE writes a row to
change_log (with reactor_handled = 0) and to
sync_outbox (with pushed_at = NULL). These
trigger the reactor and the sync daemon, respectively.
For one typical article ingestion, ~85 rows land in
sync_outbox: 1 source, ~30 entities, ~23 relations, ~31
claims, plus a handful of change_log rows. With ~300 sources ingested
over time, that compounds to ~300k sync_outbox rows — the
number you saw in Ch.44 §44.7.
Run an ingestion with the -v flag to see the pipeline
narrate itself:
uv run cascade -v ingest "https://www.reuters.com/..."The log output (lightly trimmed) looks like:
fetcher | Ingesting URL | url=https://www.reuters.com/...
fetcher | Ingested URL | title=... | text_len=4823
ner | spaCy NER | 47 unique entities from 4823 chars
extractor | NER pre-filter found 47 candidate entities |
prepending hints to LLM prompt
extractor | Entity/relation LLM extraction started | text_len=4823
provider | Calling anthropic claude-sonnet-4-6 | max_tokens=4000
extractor | Entity/relation LLM extraction done | 8.2s |
entities=28 | relations=19
decomposer | Claim LLM extraction started | text_len=4823
decomposer | Claims parsed | 24 claims extracted at 6.1s
extractor | Storing 28 entities to graph...
extractor | Entities stored at 0.3s | new=11 | merged=17 |
storing 19 relations...
extractor | Store complete | source_id=331 |
entities: 11 new + 17 merged |
relations: 18 stored + 1 skipped |
elapsed=0.6s
The lifecycle is fully traceable. Every row in
cascade.db can be traced back to one of these log lines.
When ingestion produces zero output, this is where you look.
What happens when each stage fails:
| Stage | Failure | What Cascade does |
|---|---|---|
| 1 Fetch | DNS / connection refused | IngestionError, abort |
| 1 Fetch | HTTP 401/403 | Auto-retry with auth cookies |
| 1 Fetch | HTTP 429 (rate limit) | Bubble up, abort |
| 1 Fetch | Short content (< 2k chars) | Auto-retry via browser |
| 2 NER | spaCy not installed | Return [], skip pre-filter |
| 2 NER | Text > 1M chars | Truncate to 1M, warn |
| 3 LLM | Parse failure | One retry with sterner prompt |
| 3 LLM | Network timeout | Bubble up, abort |
| 3 LLM | Quota exceeded | Bubble up with provider error |
| 3 LLM | Response truncated | Repair JSON, record issue |
| 4 Store | Entity unresolvable | Skip relation, warn, continue |
| 4 Store | DB locked | Wait + retry (SQLite WAL) |
The pattern: failures upstream of Stage 4 abort the ingestion; failures inside Stage 4 degrade gracefully. Once the LLM extraction has completed, the system tries hard to land what it has rather than throw it away.
Five real issues to know about:
en_core_web_sm
is English-only. Non-English articles get garbage-typed entities.
Cascade has hooks for other languages but they’re not wired into the
default pipeline.You ran cascade ingest <url> in Ch.43. Now you
know what each second of waiting represents:
Total: ~15-60 seconds depending on article length and provider latency. The Stage 3 LLM calls dominate ~80% of the time.
When you want to scale ingestion (Ch.50 — agent swarm), this is the bottleneck — Stage 3 is parallelizable across articles, Stages 1-2 and 4 aren’t. The collector agent’s design (a Cascade- specific pattern Volume III Part XV covers) follows exactly this slicing: queue many articles, run their Stage-3 calls in parallel, serialize the Stage-4 writes.
These exercises mix shell, Python, and SQL — they exercise the pipeline at different layers.
Pick a short news article URL (300-800 words ideal). Run:
uv run cascade -v ingest "<url>" 2>&1 | head -60Read every log line. Identify the four stages by name. Note: how many entities did NER find? How many did the LLM keep? How many got merged vs newly inserted?
Query the live database for yield-per-source:
WITH per_source AS (
SELECT source_id,
COUNT(*) AS n_claims
FROM claims
WHERE source_id IS NOT NULL
GROUP BY source_id
)
SELECT AVG(n_claims), MIN(n_claims), MAX(n_claims)
FROM per_source;Do the same for entities and relations. Compare yours to the chapter’s stated averages (~31 claims, ~30 entities, ~23 relations per source).
Query the source_type distribution from your database:
SELECT source_type, COUNT(*), AVG(credibility)
FROM sources GROUP BY source_type;What’s the dominant type? Why?
Pick a source ID with non-trivial extraction (say, ~20+ entities). Run four queries:
How does the claim count compare to the entity count? Why does the article need more claims than entities?
Find pairs of entities with similar names that should probably be merged but aren’t:
SELECT e1.name, e2.name, e1.type, e2.type
FROM entities e1, entities e2
WHERE e1.id < e2.id
AND e1.type = e2.type
AND lower(e1.name) LIKE '%' || lower(e2.name) || '%'
LIMIT 10;(The LIKE join is slow — limit to 10.) Look at the
results: are any of these actually duplicates? If yes, would a
manual merge improve graph quality?
You’ve now seen the pipeline in detail. Pick one stage and sketch a concrete improvement:
For your chosen improvement, write 2-3 paragraphs: what it adds, what it costs, what it might break. Real engineering is exactly this kind of tradeoff.
Solutions to selected exercises: Chapter 45 · Solutions in Appendix C. (Repo file: exercises/solutions/ch45_solutions.md.)
en_core_web_sm
locally for free, produces a frequency-ranked candidate list, formats it
as a prompt prefix for the LLM.resolve_entity, skips unresolvable relations, applies
credibility-band confidence downgrades, and writes to
change_log + sync_outbox.The next chapter (Ch.46) zooms in on the truth- value
lifecycle — how a claim’s truth_value slot moves
from TRUE to BOTH to (sometimes)
FALSE over the course of repeated ingestions and analyst
evaluations.
“Garbage in, garbage out” is a 1957 saying; the 2026 saying is garbage in, garbage with confidence bands. The ingest pipeline can’t make a noisy article clean, but it can record how much trust each claim deserves — and downstream truth evaluation can sort the rest.
What this chapter is. Cascade’s most subtle mechanism: how a
claim.truth_valueslot evolves fromTRUEat ingestion throughBOTHwhen a contradiction lands through (sometimes)FALSEwhen the evidence flips. Ch.44 showed you the slot; Ch.45 showed how it gets initialized; this chapter shows how it moves. By the end you’ll know exactly whatb4_joinandb4_meetdo, when each fires, how thetruth_historyaudit trail is generated, and how a single claim’s truth flip cascades to related claims and to the relations they support. This is paraconsistent reasoning in production — the Belnap B4 logic from Ch.20 driving real contradiction handling.
This is the closing chapter of Volume III Part XIII (Cascade Knowledge Graph). Part XIV starts at Ch.47 with reasoning algorithms.
You met Belnap B4 in Vol I Ch.20. Recap: four values arranged in a lattice (a partial order with both joins and meets):
BOTH ⊤
/ \
TRUE FALSE
\ /
UNKNOWN ⊥
UNKNOWN — bottom; no evidence either
way.TRUE — definite positive
evidence.FALSE — definite negative
evidence.BOTH — top; positive and
negative evidence exists (the paraconsistent case).Two binary operations make the lattice useful:
⊔) — combines evidence.
“What do we collectively know?”
TRUE ⊔ FALSE = BOTH because combining contradictory
evidence gives you both.⊓) — consensus. “What do we
agree on?” TRUE ⊓ FALSE = UNKNOWN because there’s no
consensus.In Cascade’s cascade/core/truth.py:78:
def b4_join(a: TruthValue, b: TruthValue) -> TruthValue:
"""Lattice join — combines evidence."""
return _JOIN[(a, b)]
def b4_meet(a: TruthValue, b: TruthValue) -> TruthValue:
"""Lattice meet — consensus."""
return _MEET[(a, b)]The tables are 4×4 (sixteen entries each) and live in the module as Python dicts. Cascade has all four operations: join, meet, negation, and aggregation (repeated-join over a list).
The 4×4 join table, in full:
⊔ |
UNKNOWN | TRUE | FALSE | BOTH |
|---|---|---|---|---|
| UNKNOWN | UNKNOWN | TRUE | FALSE | BOTH |
| TRUE | TRUE | TRUE | BOTH | BOTH |
| FALSE | FALSE | BOTH | FALSE | BOTH |
| BOTH | BOTH | BOTH | BOTH | BOTH |
The two bold cells are the philosophically
interesting ones: TRUE ⊔ FALSE = BOTH and the symmetric
FALSE ⊔ TRUE = BOTH. That’s where contradictions
enter the system without crashing.
Three properties worth internalizing:
UNKNOWN ⊔ X = X for any X. Adding evidence to
no-evidence gives you exactly the new evidence.BOTH ⊔ X = BOTH for
any X. Once contradicted, always contradicted (unless
someone explicitly resolves).⊓ |
UNKNOWN | TRUE | FALSE | BOTH |
|---|---|---|---|---|
| UNKNOWN | UNKNOWN | UNKNOWN | UNKNOWN | UNKNOWN |
| TRUE | UNKNOWN | TRUE | UNKNOWN | TRUE |
| FALSE | UNKNOWN | UNKNOWN | FALSE | FALSE |
| BOTH | UNKNOWN | TRUE | FALSE | BOTH |
Meet asks the dual question: given two sources of evidence, what do they agree on?
TRUE ⊓ FALSE = UNKNOWN — no agreement → no consensus.
(Notice the dual of the join’s BOTH.)BOTH ⊓ TRUE = TRUE — BOTH contains TRUE, so they agree
on TRUE.UNKNOWN ⊓ X = UNKNOWN — UNKNOWN is absorbing for meet
(the dual of UNKNOWN being identity for join).Where each operation fires in Cascade:
b4_negation is simple but illuminating:
| Input | Output |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| BOTH | BOTH |
| UNKNOWN | UNKNOWN |
The two boldfaced cases — BOTH and UNKNOWN map to themselves — capture the informational symmetry of both values:
These two are the fixed points of negation. TRUE and FALSE are the non-fixed values — the ones that move under negation.
aggregate_truth(values)For a relation supported by N claims, what’s the overall truth value? Repeated join:
def aggregate_truth(values: list[TruthValue]) -> TruthValue:
if not values:
return TruthValue.UNKNOWN
result = values[0]
for v in values[1:]:
result = b4_join(result, v)
return resultA relation supported by [TRUE, TRUE, TRUE] aggregates to
TRUE. Supported by [TRUE, TRUE, FALSE], it
aggregates to BOTH — because one dissenter is enough to
introduce paraconsistency.
This is the relation’s effective truth — computed on
demand by get_relation_truth (Ch.44 §44.3) and refreshed
when supporting claims change.
The asymmetry: one FALSE plus many TRUEs ⇒ BOTH, not majority-vote TRUE. Belnap B4 refuses to average. A single contradiction contaminates the aggregate. This is intentional — it preserves the paraconsistency.
The most common journeys a claim.truth_value takes:
UNKNOWN → TRUE (the common case)truth_value: TRUE and some
confidence.INSERT INTO claims (..., truth_value, ...).TRUE.In the test database: 7602 of 8416 claims (~90%) live here. The fast, happy path.
TRUE → BOTH (the paraconsistent case)truth_value=TRUE, confidence=HIGH.truth_value=FALSE, confidence=HIGH.TRUE ⊔ FALSE = BOTH.UPDATE claims SET truth_value = 'BOTH'.INSERT INTO truth_history (claim_id, old=TRUE, new=BOTH, reason='conflicting source X').In the test database: 437 of 8416 claims (~5%) are BOTH. About once every 20 claims gets contested.
TRUE → BOTH → UNKNOWN (analystintervention)
The most informative trajectory — and the one that actually appears in the production database:
SELECT id, claim_id, old_truth_value, new_truth_value,
substr(reason, 1, 60) AS reason, changed_at
FROM truth_history WHERE claim_id = 7849;Live output:
1 7849 TRUE BOTH The assessment shifts to BOTH because 2026-04-21T07:26:17
while there is an undeniable structural...
2 7849 BOTH UNKNOWN No rules matched this claim 2026-04-21T07:33:19
The story:
TRUE to BOTH. The reason field
records the LLM’s explanation — a paragraph-length rationale
stored in plain text.SPECULATIVE and the truth
went to UNKNOWN. The system un-evaluated the claim
because it could no longer be defended.That’s paraconsistent retreat — when rules can’t
support either side, the system pulls the claim back to
UNKNOWN rather than hold a stale BOTH. It’s a
form of automated humility.
truth_history audit tableEvery transition writes a row to truth_history:
CREATE TABLE truth_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id INTEGER NOT NULL REFERENCES claims(id),
old_truth_value TEXT NOT NULL,
new_truth_value TEXT NOT NULL,
old_confidence TEXT NOT NULL,
new_confidence TEXT NOT NULL,
reason TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT
);Two design choices worth calling out:
reason field is free-form prose.
Cascade stores the LLM’s explanation verbatim. This is
forensic data — you can grep truth_history for
“contradiction” or “Iran” or any phrase and find when those reasoning
patterns moved the lattice.The total cost is minimal — typically 2-10 truth_history rows per analyst-evaluated claim. In the test DB only 2 rows exist (the schema was added recently); production installs with active rule evaluation accumulate more.
propagate_truth — when a flip cascadesWhen a single claim flips, claims sharing entities may need
re-evaluation. propagate_truth does both: write the change
and find what else is affected.
def propagate_truth(conn, claim_id, new_truth,
new_confidence, reason=""):
claim = get_claim(conn, claim_id)
old_truth = claim["truth_value"]
old_confidence = claim["confidence"]
update_claim(conn, claim_id,
new_truth.value, new_confidence.value)
record_truth_change(conn, claim_id, old_truth,
new_truth.value, ...)
related = find_related_claims(conn, claim_id)
_propagate_to_relations(conn, claim_id)
return relatedTwo cascade effects:
find_related_claims)A claim has an entity_ids JSON array — every entity
mentioned in its text. The function finds other claims that
mention any of the same entities:
def find_related_claims(conn, claim_id):
claim = get_claim(conn, claim_id)
entity_ids = json.loads(claim["entity_ids"])
# Scan all claims, return ones with shared entities
...propagate_truth doesn’t re-evaluate the related
claims — it returns them as a suggestion list. An analyst (or
downstream agent) can iterate and re-evaluate each. The principle:
claim flips generate review queues, not automatic
cascades.
_propagate_to_relations)A relation has a claim_ids JSON array — the supporting
claims. When one of those claims flips, the relation’s effective
truth may change:
def _propagate_to_relations(conn, claim_id):
rows = conn.execute(
"SELECT id, claim_ids FROM relations"
).fetchall()
for row in rows:
cids = json.loads(row["claim_ids"])
if claim_id in cids:
truth_info = get_relation_truth(
conn, row["id"])
conn.execute(
"UPDATE relations SET confidence = ? "
"WHERE id = ?",
(truth_info["confidence"], row["id"]))The relation re-aggregates over its supporting claims using
aggregate_truth (§46.5). Its confidence band is updated.
This does happen automatically — relations are computed views
of their supporting claims, so they have to stay coherent.
path)
The
_apply_credibility_downgrade(confidence, credibility)
function from cascade/core/decomposer. py blends the
source’s credibility into the claim’s confidence
band:
| Source credibility | Effect on claim confidence |
|---|---|
≥ 0.7 (NEWS, ANALYSIS, REPORT) |
No change |
0.5–0.7 (OPINION, PRESS_RELEASE) |
One band down |
< 0.5 (SOCIAL) |
Two bands down |
A HIGH-confidence claim from a
0.4-credibility social-media source becomes
LOW. A MEDIUM- confidence claim from a
0.6-credibility press release becomes LOW. The
downgrade is applied at ingest time, baked into the stored
confidence column.
Live distribution of (truth_value, confidence) pairs after downgrade:
TRUE HIGH 3295
TRUE MEDIUM 457
TRUE LOW 398
BOTH LOW 40
UNKNOWN SPECULATIVE 22
TRUE SPECULATIVE 16
FALSE HIGH 13
UNKNOWN LOW 11
BOTH MEDIUM 6
FALSE LOW 5
Notice the concentration in (TRUE, HIGH) — the ~40% of claims that came from news sources at 0.8 credibility. The wider spread reflects op-eds and press releases getting downgraded.
The Belnap join is monotone — once you’ve reached
BOTH, you can’t get back to TRUE or
FALSE by adding more evidence. Adding new
TRUE evidence to BOTH keeps it at
BOTH. This is correct behavior — paraconsistency shouldn’t
evaporate just because a new TRUE arrives.
To resolve a BOTH, you need
non-monotonic machinery: the analyst (or a rule)
decides which side to keep and which to retire. Cascade’s machinery for
this is in two places:
cascade evaluate <claim_id> runs LLM or rule-based
evaluation, which may produce a fresh truth_value. The new
value replaces the old via update_claim plus a
truth_history row. The B4 lattice doesn’t prescribe the
replacement; the analyst’s verdict does.~~> or ~~ cap their derived
truth at conjecture-grade and can be canceled by
stronger contradictory evidence. The cancel mechanism is Axioma’s, not
Cascade’s — but Cascade uses Axioma rules, so the cancellation
is visible at the Cascade layer.The current state: Cascade’s truth-propagation mechanism is
monotone (join-only); the non-monotonic step lives in the
evaluation / override path. A future enhancement (the
cancel(rel, args...) builtin from Vol I) could land at the
relation level, with cancelations recorded in a
relations.cancelations JSON column. That’s on the roadmap;
not shipped yet.
The full distribution of (truth_value, n) in the test
database:
truth_value |
Count | % | Notes |
|---|---|---|---|
TRUE |
7602 | 90.3% | Default — newly ingested |
BOTH |
437 | 5.2% | Paraconsistent |
UNKNOWN |
335 | 4.0% | Analyst-retired or LLM-uncertain |
FALSE |
42 | 0.5% | Affirmatively refuted |
Three takeaways:
A toy walkthrough of how TRUE → BOTH happens.
Initial state:
Article A (source_id=100, NEWS, credibility=0.8) ingested.
LLM extracts claim:
text="Iran shipped 2.1M barrels/day in March"
truth_value=TRUE, confidence=HIGH, source_id=100
→ INSERT INTO claims (id=500, ...)
SELECT id, truth_value, confidence FROM claims WHERE id = 500;
-- 500 | TRUE | HIGHA week later:
Article B (source_id=145, NEWS, credibility=0.8) ingested.
LLM extracts claim:
text="Iran shipped only 1.4M barrels/day in March"
truth_value=FALSE (about the 2.1M figure)
confidence=HIGH, source_id=145
The pipeline checks: does this claim already exist? Yes — by some entity-resolution heuristic, the new claim is matched to claim 500.
old_truth = TruthValue.TRUE
new_evidence = TruthValue.FALSE
result = b4_join(old_truth, new_evidence)
# = BOTHPipeline writes:
UPDATE claims SET truth_value = 'BOTH'
WHERE id = 500;
INSERT INTO truth_history (
claim_id, old_truth_value, new_truth_value,
old_confidence, new_confidence, reason)
VALUES (500, 'TRUE', 'BOTH', 'HIGH', 'HIGH',
'Article B asserts 1.4M, contradicting...');propagate_truth then looks for related claims (any
sharing entity “Iran” or “barrels/day”) and for relations supported by
claim 500. The relations get re-aggregated; the related claims are
returned for review.
A day later — analyst evaluates manually:
cascade evaluate 500 --mode rules --engine pythonSuppose two rules support TRUE, three support FALSE, and one supports BOTH. The hybrid mode aggregates:
[TRUE, TRUE, FALSE, FALSE, FALSE, BOTH] →
aggregate_truth → BOTH (because FALSE present)
The analyst sees that the system stays at BOTH — the contradiction wasn’t resolved by rules. They might:
Each action lands as another row in truth_history. The
trail is complete.
Four caveats to know:
resolve_entity heuristic. In
practice the contradictions Cascade catches are the ones with
shared entity IDs; the ones it misses are the ones where the
second article phrases the same claim differently.BOTH → TRUE/FALSE
resolution. Once a claim is BOTH, only manual or rule-driven
evaluation can move it. There’s no higher-credibility-source-wins
automatic resolution today.These exercises mix SQL queries and Python-style truth-table
evaluation. All runnable; substitute your cascade.db
path.
For each pair below, write out the result without looking at the table in §46.2:
Verify with the table.
A relation has three supporting claims:
claim 1: TRUE (HIGH)
claim 2: TRUE (HIGH)
claim 3: BOTH (MEDIUM)
What does aggregate_truth([TRUE, TRUE, BOTH]) return?
Why isn’t it just TRUE (the majority)?
Find all claims in your database with
truth_value = 'BOTH'. For one of them, find any
truth_history rows showing how it got there:
SELECT old_truth_value, new_truth_value, reason
FROM truth_history WHERE claim_id = <id>;(In a fresh install you may have zero history rows — the automated rewriter is opt-in.)
Cross-tabulate truth_value against source
credibility:
SELECT s.credibility, c.truth_value, COUNT(*) AS n
FROM claims c
LEFT JOIN sources s ON c.source_id = s.id
GROUP BY s.credibility, c.truth_value
ORDER BY s.credibility DESC, n DESC;Do higher-credibility sources produce more TRUE claims
and fewer BOTH claims? If yes, that’s evidence the
credibility band is doing useful work.
Demonstrate, in code or by hand:
b4_negation(TRUE) == FALSEb4_negation(FALSE) == TRUEb4_negation(BOTH) == BOTHb4_negation(UNKNOWN) == UNKNOWNWhy are BOTH and UNKNOWN fixed points? Explain in one sentence each.
cancel land?The chapter notes (§46.10) that Cascade’s truth propagation is
monotone — once BOTH, always BOTH. Sketch where you’d put a
cancel(claim_id, reason) function:
truth_value directly, or write a
marker?Two-paragraph sketch is enough. Real engineering is exactly these kinds of placement choices.
Solutions to selected exercises: Chapter 46 · Solutions in Appendix C. (Repo file: exercises/solutions/ch46_solutions.md.)
aggregate_truth) is
repeated join — one FALSE among many TRUEs contaminates to
BOTH.truth_history is the append-only audit
trail — every transition recorded with old/new values and a free-form
reason string.propagate_truth writes the change,
finds related claims (returned as a review queue), and re-aggregates
supported relations automatically.This closes Volume III Part XIII. Part XIV opens with reasoning algorithms — NetworkX graph algorithms applied to the cascade graph, then Axioma rule firing.
“A logic that can’t represent disagreement is a logic for ideal observers, not for journalism.” — the framing principle. Belnap B4 was designed in the 1970s for exactly this — handling databases where evidence comes from multiple sources who may disagree. Cascade is the 2026 instantiation: the same lattice, fifty years of engineering between.
What this chapter is. Ch.46 closed Part XIII by showing how truth values move. This chapter opens Part XIV — Cascade Reasoning — by showing how structure gets analyzed. The cascade.db relations table records which entity is connected to which; NetworkX algorithms compute what those connections mean — central nodes, bridge nodes, communities, cross-domain paths. By the end of the chapter you’ll know which six core algorithms Cascade runs against its graph, when each fires, what they return, and where they sit in the architecture.
This is the first chapter of Volume III Part XIV (Cascade Reasoning).
Subsequent chapters cover Axioma rule firing (Ch.48) and writing your
own .ax rules for Cascade (Ch.49).
In Ch.43 §43.3 you saw the boundary between Cascade’s two reasoning engines: NetworkX for structure, Axioma for semantics. This chapter is everything on the NetworkX side. The questions it answers:
| Question | Algorithm | Returns |
|---|---|---|
| “How are A and B connected?” | find_cascade_chain |
Tiered BFS reach set |
| “Which entities are most important?” | compute_centrality |
Ranked by 3 metrics |
| “Which entities are critical connectors?” | find_bridge_entities |
Articulation points |
| “What clusters of related entities exist?” | find_clusters |
Connected components |
| “What surprising connections exist?” | find_hidden_connections |
Cross-domain paths |
| “How are A and B connected (specific)?” | find_connections |
Concrete paths between two |
These are all graph-theoretic. They don’t ask what the edges mean — only how they’re structured. That’s the right cut: meaning is hard to compute fast; structure is easy.
build_graphEvery algorithm starts the same way: materialize the SQLite tables as
a networkx.DiGraph in memory.
def build_graph(conn, *, include_temporal=False):
G = nx.DiGraph()
for e in get_entities(conn):
G.add_node(e["id"],
name=e["name"],
type=e["type"],
domain=e.get("domain"))
for r in get_relations(conn):
G.add_edge(r["source_entity_id"],
r["target_entity_id"],
relation_id=r["id"],
type=r["type"],
confidence=r["confidence"])
# Symmetric types (ALLIANCE, ADVERSARIAL) get a back-edge
if r["type"] in SYMMETRIC_TYPES:
G.add_edge(r["target_entity_id"],
r["source_entity_id"], **attrs)
return GThree details worth knowing:
entities table is the NetworkX node label. This means SQL
queries and NetworkX queries can freely exchange IDs.relation_id is stashed so you can pull supporting
claims back from SQLite when needed.ALLIANCE
and ADVERSARIAL are inherently bidirectional;
build_graph adds a back-edge automatically so that
successors(A) and predecessors(A) both see
them. Asymmetric types (CAUSAL, SUPPLY) stay
one-way.For a typical Cascade install (~4000 entities, ~3800 relations),
build_graph takes about 0.5-1.0 seconds. Every CLI command
that runs an algorithm pays this cost once.
find_cascade_chainYou met this in Ch.43 §43.7. Recap:
def find_cascade_chain(G, start_entity_id,
max_depth=3,
relation_types=None,
min_confidence=None):
allowed_types = relation_types or CAUSAL_TYPES
tiers = [[origin_node]]
visited = {start_entity_id}
for depth in range(1, max_depth + 1):
prev_tier = tiers[-1]
current_tier = []
for node in prev_tier:
for _, target, data in G.out_edges(nid, data=True):
if target in visited:
continue
if data["type"] not in allowed_types:
continue
if confidence_below(data, min_confidence):
continue
visited.add(target)
current_tier.append(...)
if not current_tier:
break
tiers.append(current_tier)
return tiersThe algorithm is breadth-first search by tier level
— every entity at depth N gets discovered before any entity at depth
N+1. The two filter parameters (relation_types,
min_confidence) prune the traversal to causally-meaningful
edges.
The CAUSAL_TYPES set is the default
filter: {CAUSAL, DEPENDENCY, INFLUENCE, SUPPLY, CONTROL}.
The other three relation types (TEMPORAL,
ADVERSARIAL, ALLIANCE) exist in the graph but
don’t propagate causally — they’re context, not chain.
Sample output (Iran depth 2, real Cascade DB):
Tier 0: Iran (1 entity)
Tier 1: crude oil (SUPPLY), global energy crisis
(CAUSAL), Iraq (DEPENDENCY), ... (86 entities)
Tier 2: 200+ entities reached through Tier 1
The fan-out is real: Iran has 293 direct connections; 86 of them are causally-traversable via the filtered edge types. By Tier 3 you’ve reached hundreds of entities — Cascade’s whole graph is small-world-connected.
compute_centrality(G) returns three scores per
node:
| Metric | NetworkX call | Question it answers |
|---|---|---|
degree |
nx.degree_centrality(G) |
How many neighbors? |
betweenness |
nx.betweenness_centrality(G) |
How many shortest paths pass through? |
pagerank |
nx.pagerank(G) |
How important are my neighbors? |
These measure different notions of “important”:
Live top 5 from a real Cascade DB:
name degree betweenness pagerank
---------------- -------- ------------ --------
Iran 0.0741 0.0210 0.0179
United States 0.0617 0.0311 0.0159
Medallia 0.0149 0.0002 0.0100
Israel 0.0217 0.0059 0.0061
China 0.0225 0.0074 0.0053
Two observations:
This is the interpretive payoff of running three metrics: each catches a different kind of importance, and reading them together gives a fuller picture than any single one.
find_bridge_entitiesAn articulation point (or bridge entity) is a node whose removal would disconnect the graph into two or more components. Critical infrastructure points.
def find_bridge_entities(G):
undirected = G.to_undirected()
bridges = set(nx.articulation_points(undirected))
results = []
for nid in bridges:
results.append({
"entity_id": nid,
"name": G.nodes[nid]["name"],
"type": G.nodes[nid]["type"],
"degree": G.degree(nid),
})
return sorted(results, key=lambda x: x["degree"], reverse=True)nx.articulation_points runs a classical DFS-based
algorithm (Tarjan’s, O(V + E)). The undirected projection ensures we
find connectivity-critical nodes regardless of edge direction.
Top bridges from a real Cascade DB:
name type degree
---------------- ------ ------
Iran STATE 293
United States STATE 244
Donald Trump ACTOR 91
China STATE 89
Israel STATE 86
Russia STATE 77
Strait of Hormuz INFRA 65
Iran war EVENT 60
Reading the list: removing Strait of Hormuz from the graph would fragment the energy-relations subgraph from the rest. Removing Iran war would similarly disconnect a war-event cluster. These are structurally critical — even though their degree is moderate, their position is load-bearing.
The bridge list is degree-sorted by default — which surfaces the most-connected articulation points first. A non-degree-sort would surface the hidden chokepoints: low-degree but high-criticality nodes. The default makes sense for journalism (focus on famous entities first), but analysts sometimes invert it.
find_clustersdef find_clusters(G):
undirected = G.to_undirected()
components = list(nx.connected_components(undirected))
clusters = []
for component in components:
type_counts = Counter(
G.nodes[nid]["type"] for nid in component)
clusters.append({
"size": len(component),
"dominant_type": type_counts.most_common(1)[0][0],
"type_breakdown": dict(type_counts),
"members": ...,
})
return sorted(clusters, key=lambda x: x["size"],
reverse=True)Connected components (not Louvain or modularity- based clustering) — this is intentional. With ~4000 entities and ~3800 relations, the largest connected component is usually 95%+ of the graph. A real cluster analysis would need modularity-based community detection — Louvain or Leiden. Cascade’s implementation has a comment noting Louvain would be the right choice but uses connected components because:
Live output: a typical Cascade DB has one giant component (~1996 entities) plus small isolated pairs/triples. Type breakdown of the giant component:
ORG 635
ACTOR 268
EVENT 216
POLICY 161
SECTOR 150
TECHNOLOGY 150
INFRA 118
STATE 101
COMMODITY 99
MARKET 98
The numbers reflect the underlying entity-type distribution from Ch.44 §44.4 — ORG dominates here too, because most articles have ORG actors.
A future migration to Louvain would slice this giant component into 5-15 meaningful sub-communities (energy-cluster, conflict-cluster, finance-cluster, …). That’s on the roadmap.
find_hidden_connectionsdef find_hidden_connections(G, entity_id, max_depth=4):
origin_type = G.nodes[entity_id]["type"]
undirected = G.to_undirected()
connections = []
for target in G.nodes:
if target == entity_id:
continue
target_type = G.nodes[target]["type"]
if target_type == origin_type:
continue # same-type targets are boring
for path in nx.all_simple_paths(undirected,
entity_id, target,
cutoff=max_depth):
types = [G.nodes[n]["type"] for n in path]
transitions = sum(1 for i in range(1, len(types))
if types[i] != types[i-1])
score = transitions / len(path)
connections.append({
"path_names": [...],
"path_types": [...],
"domain_transitions": transitions,
"score": round(score, 4),
})
return sorted(connections, key=lambda x: x["score"],
reverse=True)The algorithm finds all simple paths of length ≤ 4 between the origin and every other entity (of a different type), then scores by domain-transitions per hop. The intuition: a path that hops STATE → ACTOR → COMMODITY → ORG (three transitions in three hops, score = 1.0) is more interesting than STATE → STATE → STATE → ORG (one transition in three hops, score = 0.33).
Sample output (Iran → US, real Cascade DB):
Path: Iran → Abbas Araghchi → Strait of Hormuz →
Donald Trump → United States
Types: STATE → ACTOR → INFRA → ACTOR → STATE
Transitions: 4 (out of 4 possible)
Score: 0.80
This is the hidden-connection surface: the shortest path might be Iran → United States (one hop via ADVERSARIAL), but the interesting paths are longer and pass through diverse types. Journalists use this to find non-obvious connections; analysts use it to map causal chains.
Cost. all_simple_paths is expensive —
for a 4000-node graph with depth 4, the call can take seconds and
produce thousands of paths. Cascade caps depth at 4 by default for this
reason.
find_connectionsA user-facing CLI command, narrower than
find_hidden_connections:
uv run cascade discover connections "Iran" "United States"Calls
find_connections(G, source_id, target_id, max_depth) which
is basically nx.all_simple_paths plus a scoring/sorting
pass. Useful when you want to answer specifically “how are
these two connected?” rather than “what’s around this entity?”
Live output for Iran→US:
Iran → Abbas Araghchi → Strait of Hormuz →
Donald Trump → United States (score 0.80)
Iran → Abbas Araghchi → Strait of Hormuz →
Mohammad-Bagher Ghalibaf → ... (score 0.75)
...
Multiple paths in the result — the algorithm doesn’t pick one “best” path; it returns all paths within the depth cap, scored. The analyst chooses which ones to follow.
Beyond the core six, cascade/core/discovery.py has
another nine algorithms for richer analysis:
| Function | What it does |
|---|---|
compute_cascade_risk_index |
Rank entities by downstream impact size |
compute_vulnerability_ranking |
Rank by upstream dependency size |
build_temporal_cascade |
Cascade with first_seen/timestamp
filtering |
cascade_diff |
Compare two cascades (added/removed nodes) |
simulate_cascade_scenario |
What-if: remove an edge, recompute |
cascade_intersection |
Common impacts of two starting entities |
detect_counter_narratives |
Find adversarial / contradictory paths |
find_cascade_patterns |
Recurring path patterns across cascades |
get_portfolio_exposure |
Map a financial portfolio to graph exposure |
These build on the core six. Each is its own multi-hundred-line
algorithm; they share the
build_graph → filter → traverse → score template.
You don’t need to know all nine to be productive with Cascade — they’re invoked by specific CLI subcommands and Volume III Part XV (operating Cascade) covers them in context. What matters now is that the core six cover ~80% of analytical use cases, and the rest are richer queries on top of the same primitives.
Throughout this chapter you saw NetworkX answering structural questions. The companion engine — Axioma — answers semantic questions:
evaluate_b4The split is principled. Anything that’s pure-counting (centrality, shortest paths, component detection) goes to NetworkX. Anything that reasons about meaning (truth, contradiction, domain rules) goes to Axioma. Cascade composes them.
The next chapter — Ch.48 — opens that Axioma side: how Cascade fires Axioma rules in production, what the MCP bridge looks like in flight, and where rule-firing results land back in cascade.db.
Four things worth knowing:
build_graph is single-threaded.
Building a 10k-entity graph takes 2-3 seconds. There’s no incremental
build; every algorithm call rebuilds from scratch. For interactive CLI
use this is fine; for high-throughput agents it’s a bottleneck.all_simple_paths with depth 4 on a 4000-node graph can
produce thousands of results. The CLI truncates output; programmatic use
should always filter.These are throughput tradeoffs, not correctness ones. The algorithms produce correct outputs given the current graph snapshot.
These exercises run against a live Cascade install. All commands
assume you’re in the axiomacascade repo root.
Run
uv run cascade discover centrality --metric betweenness --top 10,
then again with --metric degree, then with
--metric pagerank. Compare the top-10 lists. Which entities
appear in all three? Which appear in only one?
Run
uv run cascade discover bridges --json | jq 'group_by(.type) | map({type: .[0].type, count: length})'.
Which entity type contributes the most bridges? Why might that
be?
Run
uv run cascade discover clusters --json | jq '.[] | {size: .size, dominant_type: .dominant_type}' | head -30.
Are most clusters small (size < 5) or large (size > 100)? What
does that say about the graph’s connectivity?
Pick two entities of different types (e.g. a STATE and a
COMMODITY). Run
uv run cascade discover connections "<state>" "<commodity>".
How many paths does it return? What’s the highest score? Walk the
highest-scoring path — does it read as a plausible causal
chain?
For one origin entity, run
cascade discover cascade "<name>" --depth N --json | jq '.tiers | length'
for N=1, 2, 3, 4, 5. Where does the count stop growing? That’s your
graph’s diameter from this origin under causal traversal.
The chapter mentions that degree and betweenness disagree (e.g. Iran ranks #1 by degree but the US ranks #1 by betweenness in many Cascade installs). Sketch a 6-node toy graph where:
Drawing it on paper is fine. The exercise teaches you to feel the difference between local hubs and global brokers.
Solutions to selected exercises: Chapter 47 · Solutions in Appendix C. (Repo file: exercises/solutions/ch47_solutions.md.)
build_graph, find_cascade_chain,
compute_centrality, find_bridge_entities,
find_clusters, find_hidden_connections.“Architecture is the art of how to waste space.” — Philip Johnson. Cascade’s reasoning layer wastes no effort on combining algorithms that solve different problems — degree, betweenness, PageRank, articulation points, connected components, and path-counting each have their own NetworkX call and their own CLI subcommand. That separation is the architecture.
What this chapter is. Ch.47 covered the structural half of Cascade’s reasoning — NetworkX answering “how are these nodes connected?” This chapter covers the semantic half — Axioma firing domain rules over the graph. You’ll learn how the Python-side
cascade.core.axiomalangmodule orchestrates an MCP round-trip to the Axioma binary, how.axrule files are auto-discovered and pre-loaded, whatevaluate_b4actually does over the wire, and how rule results land back incascade.db. By the end you’ll be able to trace a singlecascade evaluateinvocation through five processes (Python, MCP, Axioma evaluator, KB, SQLite) and understand what each one contributes.
This is the second chapter of Volume III Part XIV (Cascade
Reasoning). Ch.49 (next) shows how to write your own .ax
rules for Cascade.
| Engine | Question | Algorithm |
|---|---|---|
| NetworkX (Ch.47) | “How are these connected?” | Pure graph theory |
| Axioma (this chapter) | “Does this claim hold under our rules?” | B4 logic + Horn clauses |
Cascade calls into Axioma via the MCP bridge — a
persistent stdio connection to the Axioma binary running as a
sub-process. Every rule firing is an MCP tools/call request
returning typed JSON.
The architectural commitment from Ch.43: Axioma is not the whole reasoning layer. It’s the semantic slice. Anything you want graph-shaped goes through NetworkX; anything that involves multi-valued logic, contradiction handling, or domain rules goes through Axioma.
When you run
cascade evaluate <claim_id> --mode rules --engine axioma,
five processes are involved:
┌─────────────────────────────────────────────┐
│ 1. Python — cascade.core.axiomalang │
│ evaluate_axiomalang_claim(conn, claim) │
└─────────────────────────────────────────────┘
▼ stdio MCP JSON-RPC
┌─────────────────────────────────────────────┐
│ 2. Axioma binary — stdio MCP server │
│ tools/call decompose_claim │
│ tools/call evaluate_b4 │
└─────────────────────────────────────────────┘
▼ in-process function calls
┌─────────────────────────────────────────────┐
│ 3. Axioma evaluator (Go) │
│ parse, evaluate, B4 lattice meet │
└─────────────────────────────────────────────┘
▼ unix socket
┌─────────────────────────────────────────────┐
│ 4. KB service (separate process) │
│ /tmp/axioma-kb.sock │
└─────────────────────────────────────────────┘
▼ SQLite WAL reads
┌─────────────────────────────────────────────┐
│ 5. SQLite — cascade.db │
│ entities, relations, claims tables │
└─────────────────────────────────────────────┘
Three of the five (Axioma binary, KB service, SQLite) are shared across many Cascade calls. The Python side is the orchestrator; the rest is plumbing.
The relevant lines from the actual live run log:
KB service started at /tmp/axioma-kb.sock
KB opened: /Users/.../cascade.db
Axioma MCP server starting on stdio...
That’s the three lines you see at the start of every
cascade axioma command — the bridge warming up.
evaluate_axiomalang_claim
The function in cascade/core/axiomalang.py:111 runs a
six-step pipeline:
def evaluate_axiomalang_claim(conn, claim_id):
# 1. Load claim + linked entities from cascade.db
claim = get_claim(conn, claim_id)
entity_ids = json.loads(claim["entity_ids"])
claim_entities = [...]
# 2. Decompose the claim text via Axioma MCP
decomposition = _call_axioma_tool("decompose_claim", {
"claim": claim_text,
"context": {"entities": [...], "relations": [...]},
})
propositions = decomposition["propositions"]
# 3. Seed proposition truth values from cascade's graph
prop_values = {}
for prop in propositions:
prop_values[prop["id"]] = (
"true" | "false" | "both" | "neither")
# 4. Build inference rules from graph relations
rules = []
for e in claim_entities:
for r in get_relations(conn, e["id"])[:5]:
if r["type"] in ("CAUSAL", "IMPLIES", "DEPENDENCY"):
rules.append({"if": ..., "then": ...})
# 5. Call evaluate_b4 with propositions + rules
eval_result = _call_axioma_tool("evaluate_b4", {
"propositions": prop_values,
"rules": rules,
"logic": "b4",
})
# 6. Map result back to cascade VerificationResult
return VerificationResult(
truth_value=_map_truth_value(...),
confidence=_map_confidence(...),
reasoning="...",
)The Python module does most of the bookkeeping: loading claims, mapping cascade types to Axioma strings, building inference rules from graph relations. The actual logical evaluation — running the Horn-clause inference, taking the B4 lattice meet — happens on the Axioma side.
decompose_claimdecompose_claim is the first half of the evaluation. It
takes a natural-language claim and returns its logical structure:
# Request
{
"name": "decompose_claim",
"arguments": {
"claim": "Iran exports crude oil",
"context": {
"entities": ["Iran", "crude oil"],
"relations": ["supply", "control"]
}
}
}
# Response (sketch)
{
"propositions": [
{"id": "P1", "text": "Iran is an exporter",
"entities": ["Iran"]},
{"id": "P2", "text": "crude oil is exported by Iran",
"entities": ["Iran", "crude oil"]}
],
"rules": [
{"if": "P1", "then": "P2"}
]
}The decomposition is not free — under the hood
Axioma uses pattern matching, NSM-prime-like primitives (Ch.32), and
(when available) an LLM fallback. For the chapter’s purposes, treat it
as a function text → {propositions, rules} that runs in
~100ms-2s.
The decomposition fixes the symbols; the next step assigns them truth values.
Step 3 of evaluate_axiomalang_claim is the
bridge between Cascade’s existing knowledge and Axioma’s
evaluation. For each proposition extracted in step 2, the orchestrator
decides what truth value to seed it with:
for prop in propositions:
prop_id = prop["id"]
prop_values[prop_id] = "neither" # default UNKNOWN
for ename in prop["entities"]:
matching_entity = find_in_cascade(ename)
if matching_entity:
current_truth = claim["truth_value"]
prop_values[prop_id] = {
"TRUE": "true",
"FALSE": "false",
"BOTH": "both",
"UNKNOWN": "neither",
}[current_truth]The intuition: if Cascade already believes the claim is TRUE,
seed the propositions as true; otherwise leave them as
neither. The B4 evaluator can then
propagate the truth values through the rules.
This is the architectural payoff of having a shared knowledge graph (Ch.44). The Cascade-side truth value is the prior; the Axioma-side rules update it.
evaluate_b4The second MCP call is where the actual logical work happens:
# Request
{
"name": "evaluate_b4",
"arguments": {
"propositions": {
"P1": "true",
"P2": "neither"
},
"rules": [
{"if": "P1", "then": "P2"}
],
"logic": "b4"
}
}
# Response
{
"propositions": {"P1": "true", "P2": "true"},
"combined_truth": {
"value": "true",
"meaning": "Claim verified under provided rules"
},
"reasoning_chain": [
{"step": "assert", "prop_id": "P1", "value": "true"},
{"step": "infer", "rule": 0, "consequence": "P2 = true"},
{"step": "combine", "method": "meet", "result": "true"}
]
}The Axioma evaluator:
The reasoning chain is the explainability payload.
Cascade stores it in the claim’s properties JSON column for
analyst review.
What happens when each step fails:
| Step | Failure | Cascade behavior |
|---|---|---|
| 1 Python | DB row missing | ValueError, abort |
| 2 decompose | LLM/parser error | Return None, log warning |
| 2 decompose | Empty propositions | Return None, “no propositions extracted” |
| 3 Truth-seed | Entity not in DB | Default to neither, continue |
| 4 Rule-build | No CAUSAL relations | Empty rules array, continue |
| 5 evaluate_b4 | Axioma binary crashed | Return None, log warning |
| 5 evaluate_b4 | Tool returned error | Return None, log warning |
| 6 Map result | Unknown truth value | Default UNKNOWN/SPECULATIVE |
The pattern: failures inside Axioma return None without raising; failures inside Python (missing claim row, etc.) raise. The principle: the orchestrator’s contract is to either return a result or None; never crash on Axioma errors.
Live failure-mode output from the test database (claim 1 evaluated):
Axiomalang B4 evaluation: The claim lacks sufficient
evidence (NEITHER true nor false); [assert] P1:
neither (?ᵇ)
The B4 evaluator returned neither (UNKNOWN) because the
truth-seeding step couldn’t bind any propositions to TRUE/FALSE. The
claim moves from TRUE/HIGH →
UNKNOWN/SPECULATIVE — the paraconsistent retreat path from
Ch.46 §46.6.
load_axiomalang_rules
Before any evaluate_b4 call, Cascade auto-loads all
.ax files from two directories via
load_axiomalang_rules():
def load_axiomalang_rules() -> int:
if not is_available():
return 0
count = 0
for rules_dir in _get_axiomalang_rules_dirs():
for ax_file in sorted(rules_dir.glob("*.ax")):
if str(ax_file) in _loaded_ax_files:
continue
try:
_call_axioma_tool("run_file",
{"path": str(ax_file)})
_loaded_ax_files.add(str(ax_file))
count += 1
except Exception as e:
log.warning("Failed to load %s: %s",
ax_file.name, e)
return countThe two directories (_get_axiomalang_rules_dirs):
.cascade/rules/axiomalang/*.ax — project-level.~/.cascade/rules/axiomalang/*.ax — global user
rules.Loading is idempotent — the
_loaded_ax_files set tracks which files have already been
run. Subsequent calls to load_axiomalang_rules are no-ops
for files already in the set. This matters because the persistent MCP
session keeps the loaded concepts/rules in memory across many
evaluate_b4 calls — you pay the file-load cost
once per Cascade process.
Live count from the test install:
$ ls .cascade/rules/axiomalang/
capital_flow_attraction_rule.ax
commodity_price_cascade.ax
monetary_tightening_currency_appreciation.ax
oil_gold_positive_correlation.ax
Four rule files, loaded at first MCP call. The session environment now contains:
concept declarationsrule declarationsThese are the domain ontology the evaluator draws on
for subsequent claim evaluations. Without them, every
evaluate_b4 call would have only in-request facts to work
with — and most claims would land at neither.
cascade.core.axioma_client
The bridge layer that actually talks to the Axioma binary
lives in cascade/core/axioma_client .py:
class AxiomaClient:
"""Async stdio MCP client for the axioma binary."""
async def start(self): ...
async def call_tool(self, tool_name, arguments): ...
async def close(self): ...
class AxiomaClientSync:
"""Synchronous wrapper around AxiomaClient."""
def call_tool(self, tool_name, arguments): ...
def get_client() -> AxiomaClientSync:
"""Lazy-singleton: spawns the binary on first call,
reuses it for subsequent calls."""The singleton pattern matters for performance. Spawning the Axioma
binary takes ~200ms; reusing it across calls amortizes the cost. The
first cascade evaluate invocation pays the startup tax;
subsequent calls in the same process are fast.
The client also handles:
When is_available() returns False (the Axioma binary
isn’t installed or isn’t in PATH), the orchestrator skips the entire
Axioma path. Cascade continues to work — it just doesn’t fire any Axioma
rules.
After evaluate_axiomalang_claim returns a
VerificationResult, the downstream code in
decomposer.py’s _apply_evaluation writes it
back to cascade.db:
def _apply_evaluation(conn, claim_id, verdict):
update_claim(conn, claim_id,
verdict.truth_value.value,
verdict.confidence.value)
record_truth_change(conn, claim_id, old_truth,
new_truth, old_conf, new_conf,
reason=verdict.reasoning)Two effects:
claims row updated —
truth_value and confidence columns reflect the
new verdict.truth_history row inserted — recording
the transition with the Axioma-side reasoning as the
reason.If --test (dry-run) is passed to
cascade evaluate, step 1 is skipped — the verdict is
computed and displayed but not persisted. Useful for
sanity-checking rule outputs without contaminating the database.
The downstream effect compounds: a claim flipping to BOTH triggers
propagate_truth from Ch.46 §46.8, which re-aggregates
supporting relations and returns a review queue of related claims.
A real cascade evaluate run from the test install,
annotated:
$ uv run cascade evaluate 1 --mode rules --engine axioma
KB service started at /tmp/axioma-kb.sock ← step 4
KB opened: /Users/.../cascade.db ← step 5 (open)
Axioma MCP server starting on stdio... ← step 2 (open)
Updated: UNKNOWN (SPECULATIVE)
Reasoning: Axiomalang B4 evaluation: The claim
lacks sufficient evidence (NEITHER true nor
false); P1: neither (?ᵇ)
{
"claim_id": 1,
"claim_text": "The Irish government is set to discuss
planned investments in the U.S. as
part of the annual St. Patrick's Day
visit",
"previous": {"truth_value": "TRUE", "confidence": "HIGH"},
"updated": {"truth_value": "UNKNOWN",
"confidence": "SPECULATIVE"},
"reasoning": "Axiomalang B4 evaluation: ...",
"test_only": false
}What happened:
decompose_claim extracted 1 proposition (“Ireland and
U.S. relationship”).evaluate_b4 ran with all propositions = neither, no
rules → result: neither._map_truth_value mapped neither to
UNKNOWN; _map_confidence returned SPECULATIVE.The claim went from TRUE/HIGH to
UNKNOWN/SPECULATIVE — not because new evidence
contradicted it, but because the rule engine couldn’t justify
the original verdict. That’s the paraconsistent retreat at
work — the system admits it doesn’t know rather than holding a
default it can’t defend.
Four things to know:
decompose_claim returns zero propositions, the whole
pipeline returns None. Claims that don’t parse cleanly (long sentences,
multi-clause structures, list-form claims) often fail at step 2..ax files
in the rules directories._loaded_ax_files is process-local. If
you restart the Cascade CLI between calls, the rule files reload. The
MCP-server-side session is also per-process — the second
cascade evaluate invocation gets a fresh Axioma
instance.reasoning is useful for human
review but isn’t structured — downstream code can’t easily
parse it. A future enhancement would surface the
reasoning_chain array structurally.These are coverage gaps, not correctness gaps. The B4 logic on the Axioma side is sound; the Cascade orchestration around it is what’s still maturing.
These exercises run against the live Cascade install + the Axioma MCP bridge. They’re more qualitative than Ch.47’s because rule firing is content-dependent.
Run uv run cascade axioma exec 'println(42)' from a cold
start (no other Cascade process running). Note the time. Run it again
immediately. Did the second run take less time? Why?
Run uv run cascade axioma query list-relations. You
should see the eight uppercase Cascade relations plus any lowercase
domain relations from loaded .ax files. Which lowercase
relations are present? Read the corresponding .ax file in
.cascade/rules/axiomalang/ and explain in one sentence what
it does.
Pick a claim ID from your DB. Evaluate it through each engine:
for eng in python prolog axioma cascade; do
echo "=== $eng ==="
uv run cascade evaluate <id> --mode rules --engine $eng
doneCompare the results. Which engines agree? Which disagree? When they disagree, which one looks more right by your reading of the claim?
Run
cascade evaluate <id> --mode rules --engine axioma --test.
The --test flag computes the verdict without
writing it to the database. Verify with a subsequent
cascade entity get <id> that the truth value didn’t
change.
Pick a claim with very abstract text (e.g. one about “the situation”
or “the meeting”). Run
cascade evaluate <id> --mode rules --engine axioma.
Does decomposition return zero propositions? What does the system
do?
orchestrator?
The orchestrator in §48.3 has six steps. Pick one step and sketch an improvement:
~~> rules from low-confidence graph edgesTwo paragraphs. What changes? What breaks?
Solutions to selected exercises: Chapter 48 · Solutions in Appendix C. (Repo file: exercises/solutions/ch48_solutions.md.)
evaluate_axiomalang_claim) runs a
six-step pipeline: load claim, decompose, truth-seed,
build rules, evaluate, map back.decompose_claim (text → propositions+rules) and
evaluate_b4 (propositions+rules → B4 verdict)..ax rules at first MCP
call populates the Axioma session with domain concepts/rules. Idempotent
via _loaded_ax_files.--test is the dry-run flag — compute
without persisting.The next chapter (Ch.49) flips perspective: instead of how
Cascade fires rules, you’ll write your own
.ax rules for Cascade and watch them fire.
“The most useful programs operate on programs.” — paraphrased from the Lisp tradition. Cascade’s
.axrules are programs; the Axioma evaluator is a program that runs them; the Python orchestrator is a program that runs the evaluator; the MCP bridge is a program that connects the runner to the orchestrator. Each layer is one more reified step of “operating on programs” — and each layer earns its keep.
.ax Rules for CascadeWhat this chapter is. Ch.48 showed how Cascade fires Axioma rules. This chapter is the hands-on companion: how you write them. You’ll start from a blank
.axfile, build it using the actual Axioma syntax you learned in Volume I (Ch.21 Horn clauses, Ch.20 B4 truth, Ch.44 §44.5 the rules table), drop it into.cascade/rules/axiomalang/, and watch it fire on real claims. You’ll also meet a real honest finding: the rule files shipped with Cascade today don’t actually parse as Axioma — they use an aspirational pseudo-DSL. So this chapter doubles as the practical replacement guide.
This is the closing chapter of Volume III Part XIV. Part XV begins with the agent swarm.
If you tried to run one of the shipped rule files yourself:
$ ./axioma --no-kb \
/path/to/.cascade/rules/axiomalang/oil_gold_positive_correlation.axYou’d see something like:
Found 10 errors:
[1] SyntaxError: unexpected token '}'
[2] SyntaxError: expected ':' but got '{'
[3] SyntaxError: unexpected token 'when'
...
The shipped .ax rule files don’t parse in
Axioma. They use a pseudo-DSL — {{...}} blocks,
rule X { when ... then ... }, b4.join,
b4.eval, relate(...),
has_property(...) — none of which is standard Axioma
syntax. They were generated by an LLM as a first draft of what
domain rules should look like, but never made syntactically
real.
This means the rule firing in Ch.48 §48.11 doesn’t actually
do anything useful with the shipped rules. The pre-load step
silently fails for each file (it logs a warning and moves on); the
load_axiomalang_rules counter stays at zero; the session
environment is empty when evaluation happens.
Ch.48’s pipeline is correct. The shipped files are just placeholders. Real custom rules need to use real Axioma syntax — which you’ve already learned in Volume I.
The four primitives you’ll use:
| Form | Purpose | Volume I Ch. |
|---|---|---|
define relation P(x -> "role", ...) |
Declare a typed relation | Ch.21 |
axiom P(arg1, arg2) |
Assert a fact at axiom grounding | Ch.18 |
H(X, Y) whenever B1(X) and B2(Y) |
Strict Horn rule (primary; also if; twins
<= / <== / :-) |
Ch.21 |
typically H(X, Y) whenever B1(X) and B2(Y) |
Defeasible Horn rule (primary; ≡ normally /
rule~ … if; twin <~~) |
Ch.21 |
Together they cover ~80% of useful Cascade rules. The remaining 20% (Belnap join/meet via builtins, concept definitions, formation-layer metadata) builds on these.
A complete minimal rule file:
# oil_correlation.ax — a real, runnable Cascade rule
# 1. Declare the relation (matching Cascade's relation_types)
relation oil_correlation(
commodity1 :: String -> "commodity",
commodity2 :: String -> "commodity"
)
# 2. Seed it with known facts (axiom-grade)
axiom oil_correlation("Crude Oil", "Gold")
axiom oil_correlation("Crude Oil", "Silver")
# 3. Define symmetric closure as a Horn rule
mutual_correlation(X, Y) <= oil_correlation(X, Y)
mutual_correlation(X, Y) <= oil_correlation(Y, X)
# 4. Verify by query
println("Mutual correlations:")
println({(X, Y) | mutual_correlation(X, Y)})This file actually parses and runs end-to-end
through cascade axioma run. The output:
Mutual correlations:
{("Gold", "Crude Oil"), ("Silver", "Crude Oil")}
(Note: the engine’s forward-chaining doesn’t always materialize both directions of a recursive rule on the same pass — see Ch.31 §31.4 for the fixed-depth limitation. The asymmetry you’d expect is a known issue, not a bug in your rule.)
A repeatable recipe for writing a new rule:
┌─────────────────────────────────────────────────┐
│ Step 1 — Sketch the rule in prose │
│ "When X causes Y, then Y depends on X" │
└─────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
│ Step 2 — Translate to Horn clause │
│ depends(Y, X) <= causes(X, Y) │
└─────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
│ Step 3 — Test against axioma --no-kb │
│ $ axioma --no-kb my_rule.ax │
└─────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
│ Step 4 — Drop into .cascade/rules/axiomalang/ │
│ Cascade auto-loads on next MCP call │
└─────────────────────────────────────────────────┘
Three habits worth establishing:
axioma --no-kb my_rule.ax (or
cascade axioma run my_rule.ax) before committing them to
the auto-load directory. A syntax error in one rule file blocks loading
of that file but doesn’t affect others.define relation with role labels.
The Lojban-style places from Ch.31 are the self-documenting interface.
Future readers (and future-you) will thank you.<= (strict) Horn rules. Switch to <~~
(defeasible) only when you find real cases where the rule should yield
under contradiction.Suppose you want Cascade to automatically derive that any commodity supplied by a sanctioned state is itself at risk. Translation:
sanctions(STATE) ∧ supplies(STATE, COMMODITY) ⇒ at_risk(COMMODITY)
Here’s the .ax file (Cascade-ready):
# sanctions_commodity_risk.ax
# When a state is sanctioned, commodities it supplies become at-risk.
relation sanctions(target :: String -> "sanctioned_state")
relation supplies(source :: String -> "state",
good :: String -> "commodity")
relation at_risk(item :: String -> "commodity")
# Sample facts (would normally come from cascade.db extraction)
axiom sanctions("Iran")
axiom supplies("Iran", "crude oil")
axiom supplies("Iran", "natural gas")
axiom supplies("Saudi Arabia", "crude oil")
# The rule
at_risk(C) <= sanctions(S) and supplies(S, C)
# Verify
println("Sanctioned states:", {S | S <- sanctions(S)})
println("All supply pairs:", {(S, C) | supplies(S, C)})
println("Commodities at risk:", {C | C <- at_risk(C)})Test:
$ axioma --no-kb sanctions_commodity_risk.ax
Sanctioned states: {"Iran"}
All supply pairs: {("Iran", "crude oil"),
("Iran", "natural gas"),
("Saudi Arabia", "crude oil")}
Commodities at risk: {"crude oil", "natural gas"}Note "crude oil" shows up in at_risk
because Iran supplies it. "Saudi Arabia" isn’t sanctioned,
so its supply doesn’t trigger at_risk — but if a
second sanctioning fact lands later, the same rule would
re-fire and update the result.
For richer rules, declare a concept first (Vol I Ch.18).
The Phase-1 vocabulary (purpose:, examples:,
counterexamples:) plus the Phase-2 metadata
(formed_by:, default_grounding:,
boundary:) lets you document why the concept
exists alongside what it covers:
# sanctioned_state_concept.ax
# Defines "SanctionedState" as a domain concept with examples.
concept SanctionedState {
purpose: "States subject to international sanctions affecting
their trade and capital flows",
formed_by: "stipulation",
default_grounding: "axiom",
boundary: is STATE and has_property("sanctions_status", "active")
}
# Facts get classified automatically by the boundary
iran: a STATE { sanctions_status: "active", name: "Iran" }
usa: a STATE { sanctions_status: "none", name: "USA" }
# Check classification
println("Is Iran a SanctionedState?",
is_member_of(iran, SanctionedState))
println("Is USA a SanctionedState?",
is_member_of(usa, SanctionedState))The boundary: predicate is the rule that decides
membership. The default_grounding: "axiom" tells the system
to treat derived memberships as axiom- grade — strong enough that
downstream queries trust them without verification.
For Cascade-specific use, set domain: to one of the
EntityDomain enum values from Ch.45 §45.2 (energy,
military, political, etc.). This helps
Cascade’s filtering pipelines find the concept.
Some rules usually hold but can be cancelled by specific
evidence. The <~~ operator from Vol I Ch.21:
# trade_partner_defeasible.ax
# Trade partners usually have aligned interests, except adversaries.
relation trade_partner(a :: String -> "state",
b :: String -> "state")
relation adversary(a :: String -> "state",
b :: String -> "state")
axiom trade_partner("USA", "China")
axiom trade_partner("USA", "Saudi Arabia")
axiom adversary("USA", "China") # overrides!
# Defeasible: usually-true but cancellable
aligned_interests(A, B) <~~ trade_partner(A, B)
# Strict cancellation: adversaries can't be aligned
aligned_interests(A, B) <= adversary(A, B) and false_relation()
# Probe
println("Aligned pairs:", {(A, B) | aligned_interests(A, B)})The defeasible rule <~~ produces conjecture-
grade derivations (Vol I Ch.18’s epistemic grounding hierarchy). When
adversary evidence exists, you can use Axioma’s
cancel(rel, args...) builtin (Vol I Ch.21) to mark the
conjecture as suppressed. The original aligned_interests
fact survives in provenance but doesn’t appear in default
queries.
In Cascade’s setting, this pattern is how you encode expert intuitions that have known exceptions — exactly the kind of soft knowledge that’s both useful and explicitly fallible.
“If A supplies B and B is critical infrastructure, then A is strategically important.”
strategic(A) <= supplies(A, B) and is_critical_infra(B)Adds derived facts to the KB without changing the extractor.
“ADVERSARIAL + SUPPLY implies LEVERAGE.”
relation has_leverage(a :: String -> "leveraged_state",
b :: String -> "leveraged_against")
has_leverage(A, B) <= supplies(A, B) and adversary(A, B)Synthesizes a new relation type from primitive ones.
“Oil and gold prices usually move together, unless there’s a strong dollar.”
relation moves_with(item1, item2)
moves_with("oil", "gold") <~~ true
moves_with(X, Y) <= dollar_strength("strong") and contradicts(X, Y)Encodes empirical pattern + known exception. The
defeasible rule wins by default; the strict rule supersedes when its
premise (dollar_strength) fires.
Once your rule file parses cleanly via axioma --no-kb,
drop it into either:
.cascade/rules/axiomalang/ —
project-local rules (committed to the project’s git repo).~/.cascade/rules/axiomalang/ — global
user rules (apply to every Cascade install on this machine).Cascade auto-loads on first MCP call. Verify with:
$ uv run cascade axioma exec 'println("rules loaded")' \
2>&1 | grep "Loaded Axiomalang"
Loaded Axiomalang rule file: sanctions_commodity_risk.axAfter loading, the relations and rules are available in the
persistent Axioma session. Subsequent cascade evaluate
calls can use them without further loading.
If a rule file has a syntax error, the load fails for that file only:
WARNING: Failed to load Axiomalang rule file
broken_rule.ax: SyntaxError: expected ':' but got '{'
Other files in the same directory continue to load. This is the isolation you want — one broken rule doesn’t kill the whole session.
--typecheckBefore committing a rule file, run Axioma’s static typecheck pre-pass (Vol I §19 — the static typechecker):
$ axioma --typecheck sanctions_commodity_risk.ax
✅ Type-check passedThis catches:
String where Integer
expected)Adding --typecheck to your dev loop (Ch.49 §49.3 step 3)
is the highest-leverage habit for .ax rule authors.
When a rule fires successfully, Cascade can record the rule
itself into the rules table (Ch.44 §44.5) for
auditability. The applied_count and
success_count columns track per-rule statistics.
INSERT INTO rules (
name, type, domain, engine, definition,
source, executable, description
) VALUES (
'sanctions_commodity_risk',
'causal',
'political',
'axiomalang',
'.cascade/rules/axiomalang/sanctions_commodity_risk.ax',
'file',
1,
'Sanctioned states pose risk to their supplied commodities'
);You don’t have to write this INSERT yourself —
cascade rules import <path> does it. But knowing
where the rule’s metadata lives lets you query:
SELECT name, applied_count, success_count
FROM rules
WHERE engine = 'axiomalang'
ORDER BY applied_count DESC
LIMIT 10;A high applied_count but low success_count
means the rule fires often but rarely produces a useful verdict
— a sign that the rule’s preconditions are too loose. Conversely, a low
applied_count means the rule almost never fires — either
the preconditions are too tight, or the expected fact patterns don’t
exist in your graph.
This is the rule-quality feedback loop: production statistics tell you which rules to revise.
Five real things to know:
.ax files don’t parse. As
§49.1 explained — they’re pseudo-DSL templates, not runnable Axioma.
Treat them as aspirational documentation, not as code to
imitate.axiom vs axiom/persist. A
bare axiom fact lives in the in-memory session only — it
vanishes when the Axioma process exits. Use axiom/persist
to write the fact into cascade.db as well. Most rule files
want axiom (transient) for declarative facts and
axiom/persist only for things meant to outlive the
session.ancestor(X, Z) <= parent(X, Y) and ancestor(Y, Z)
only reaches ~3 generations. Plan rule shapes around the bound.cancel builtin is per-fact, not
per-rule. Defeasible rules don’t have a built-in
cancel-this-rule annotation; you have to cancel each fact
individually. A future disable rule <name> form would
help.cascade rules reload.Each exercise builds a .ax file from scratch following
the §49.3 workflow.
Create ~/.cascade/rules/axiomalang/my_first_rule.ax with
the following content:
relation greeting(speaker :: String -> "speaker",
text :: String -> "text")
axiom greeting("Cascade", "Hello, knowledge graph!")
println({(S, T) | greeting(S, T)})Run
axioma --no-kb ~/.cascade/rules/axiomalang/my_first_rule.ax.
Then run uv run cascade axioma exec 'println("loaded")' and
grep the log for Loaded Axiomalang rule file:. Both should
succeed.
For each English sentence, write a corresponding Horn rule:
You don’t need to test these — just write the syntax correctly. Check against your Vol I Ch.21 notes if needed.
Adapt the §49.4 worked example into a file at
~/.cascade/rules/axiomalang/sanctions_risk.ax. Test it with
axioma --no-kb. Then run
cascade axioma query list-relations — you should see
sanctions, supplies, at_risk in
the list.
Take your §49.3 sanctions rule and add a defeasible override:
“Unless the commodity has a strategic reserve, it isn’t at
risk.” Use <~~ for the defeasible rule and
<= for the override.
After installing your sanctions rule, pick a claim in cascade.db that
mentions Iran or commodities. Run
cascade evaluate <id> --mode rules --engine axioma --test
and check the reasoning output. Does your rule appear in the reasoning
chain?
Pick a domain you know (finance, healthcare, software, gaming,
science). Sketch one .ax rule file with:
concept definition (with formed_by:
set)axiom facts seeding the relation<= rule<~~ ruleWrite it as a single block of pseudocode-Axioma (don’t worry if it doesn’t run yet). Then mark the spots where you’d actually run it through Axioma to find the syntax bugs.
Solutions to selected exercises: Chapter 49 · Solutions in Appendix C. (Repo file: exercises/solutions/ch49_solutions.md.)
.ax
rules: define relation, axiom, strict Horn
(<=), defeasible Horn (<~~)..cascade/rules/axiomalang/.purpose:,
formed_by:, default_grounding:,
boundary:) makes concepts self-documenting.--typecheck pre-pass catches most
rule- authoring errors before runtime.applied_count, success_count) — the quality
feedback loop.This closes Volume III Part XIV (Cascade Reasoning). Part XV — the operating Cascade chapters — open with Ch.50 on the agent swarm.
“Documentation that doesn’t run is half documentation.” — paraphrased. The shipped
.axfiles in.cascade/rules/axiomalang/are the half that doesn’t run. The chapter you just read is the half that does. Use the real syntax; ignore the pseudo-DSL; ship rules that work.
What this chapter is. Volume III Part XV opens with the part of Cascade you’ve heard mentioned but haven’t met directly: the agent layer. Cascade ships with three different “agent” mechanisms — the tool-use agent (an LLM-driven analyst with graph-query tools), the reactor (a daemon that fires rules on graph changes), and monitors (scheduled tasks that scrape new articles). This chapter introduces all three, shows how they compose, and explains the architectural boundary between Cascade’s “agent swarm” and the broader autonomous-agent literature.
This is the first chapter of Volume III Part XV (Operating Cascade). Ch.51 covers scheduled tasks + reactor internals in depth; Ch.52 covers subscription tiers and scaling.
The word agent in AI has at least four meanings right now:
Cascade uses all four. The “agent swarm” in the Ch.43 architecture diagram isn’t one mechanism — it’s the union of these four. The swarm is the operational coordination layer that makes Cascade run without a human at the console.
| Cascade name | AI-literature category | Where it lives |
|---|---|---|
| Tool-use agent | LLM tool-use | cascade agent command |
| Reactor | Reactive software agent | cascade reactor daemon |
| Monitors | Scheduled task | cascade monitor + cron |
| MCP server | Multi-agent coordination | cascade mcp serve |
This chapter walks each in turn. Each is genuinely different — different code, different lifecycle, different failure modes. Lumping them under one “agent” word would lose more than it gains.
cascade agentcascade/core/agent.py (~400 lines) is the
LLM-driven analyst: a Claude or GPT call wrapped in a
tool-use loop that lets the LLM query the Cascade knowledge graph
through 7 named tools.
The tool catalog (from cascade/core/agent.py:24):
| Tool name | What it does |
|---|---|
search_entities |
Find entities by name or type |
get_entity_relations |
Get all relations adjacent to an entity |
cascade_chain |
Trace multi-tier cascade impact chains |
centrality_ranking |
Rank entities by degree / betweenness / pagerank |
search_claims |
Search claims by text content |
graph_stats |
Get summary counts of the KG |
find_connections |
Find paths between two entities |
The agent loop runs up to 5 rounds (configurable):
for round_num in range(max_rounds):
response = client.messages.create(
model=config["model"],
max_tokens=4096,
system=ANALYST_PROMPT,
tools=anthropic_tools,
messages=messages,
)
tool_uses = [b for b in response.content if b.type == "tool_use"]
if not tool_uses:
return text_response # done — no more tools needed
for tool_use in tool_uses:
result = _execute_tool(tool_use.name, tool_use.input, conn)
messages.append({"type": "tool_result", ...})The pattern is the classic tool-use loop:
cascade.db.The system prompt scopes it to geopolitical intelligence analysis:
“You are a geopolitical intelligence analyst with access to a Cascade knowledge graph. Use the available tools to research the user’s question by looking up entities, tracing cascade chains, checking centrality, and searching claims. Synthesize findings into a clear analytical response. Call multiple tools if needed to build a complete picture.”
Two CLI modes:
# General mode — 6 core tools + 3 memory tools
$ cascade agent "What's Iran's cascade impact on energy markets?"
# Admin mode (--admin) — 13 additional CRUD tools
$ cascade agent --admin "Add a relation: USA INFLUENCES China"Admin mode adds direct graph-mutation tools
(entity_add, relation_add,
claim_delete, etc.). The flag is the safety boundary —
read-only by default, mutating only when the user opts in
explicitly.
It is:
-i)._execute_tool call.-v flag shows every tool call
with arguments and results.It isn’t:
cascade agent
invocation spawns + dies.For autonomy you need the reactor (§50.5) and monitors (§50.6). The tool-use agent is the synchronous, human-in-the-loop component.
In multi-turn interactive mode (-i), the agent gets
three additional tools that persist across sessions:
| Tool | What it does |
|---|---|
remember(key, value) |
Save a fact to the agent’s per-user memory |
recall(key) |
Fetch a previously remembered fact |
forget(key) |
Delete from memory |
These are stored separately from cascade.db — typically
~/.cascade/agent_memory.json. The distinction:
graph facts are world model; agent memory is
user-session state (“the user prefers brief
responses”, “we discussed Iran last Tuesday”).
This is the user-grounding counterpart to the world-grounding the tools provide. Together they make the agent context-aware in two dimensions.
cascade reactorThe reactor is not an LLM agent. It’s a Python daemon (~300 lines) that subscribes to graph changes and fires rules in response. No LLM in the loop.
$ cascade reactor start
$ cascade reactor status
{
"running": true,
"events_received": 142,
"rules_fired": 38,
"actions_executed": 38,
"errors": 0,
"pending": 0
}The architecture (from cascade/core/events.py):
┌─────────────────────────┐
│ Ingestion / Edit │ (entities/relations/
│ │ claims insert or update)
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ GraphEventBus.emit() │ thread-safe singleton
│ ChangeEvent dataclass │ (in-memory pub/sub)
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Reactor subscriber │ reads pending change_log
│ matches rules │ fires matching rules
│ updates DB │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Notification (optional) │
└─────────────────────────┘
The GraphEventBus is the in-process pub/sub layer.
Whenever a mutation happens in cascade.db, the bus emits a
ChangeEvent. The reactor (subscribed via
bus.subscribe(...)) processes the queue:
change_log
(reactor_handled = 0).rules table whose
preconditions are satisfied by the change.change_log row as
handled.The 10 ChangeType enum values (from
events.py):
ENTITY_CREATED / UPDATED / DELETED
RELATION_CREATED / UPDATED / DELETED
CLAIM_CREATED / UPDATED / DELETED
SOURCE_INGESTEDThese are the triggers — every rule subscribes to one or more.
You could do reactor work synchronously inside each ingestion. Three reasons it’s a separate daemon:
The --debounce 1000 flag (ms) coalesces rapid changes —
useful when ingesting bulk data.
cascade monitorThe third agent mechanism: scheduled tasks that periodically poll the web for new articles about tracked entities.
-- The monitors table (Ch.44 §44.6)
CREATE TABLE monitors (
id INTEGER PRIMARY KEY,
entity_id INTEGER REFERENCES entities(id),
search_query TEXT,
enabled INTEGER DEFAULT 1,
interval_hours INTEGER DEFAULT 24,
last_run_at TEXT,
last_result TEXT,
created_at TEXT,
ulid TEXT
);A live install typically has 1-10 monitors. From the test DB:
id | entity_id | search_query | interval_hours
---|-----------|---------------------|---------------
1 | 6 | Strait of Hormuz | 24
Operations:
$ cascade monitor add "Iran" --query "Iran sanctions oil" --interval 12
$ cascade monitor list
$ cascade monitor run # run all due monitors
$ cascade monitor run --all # ignore schedule, run all
$ cascade monitor run --id 3 # run a specific monitor
$ cascade monitor install-cron # print crontab entryThe cascade monitor run workflow:
monitors table for entries where
last_run_at + interval_hours <= now.sources
table.cascade ingest <url> — the
full Ch.45 pipeline fires.last_run_at and last_result.The install-cron subcommand prints a crontab line
like:
0 */6 * * * cd /path/to/cascade && uv run cascade monitor run --quiet
That’s the scheduler glue — Unix cron does the periodic firing; Cascade does the content work.
Monitors are pure dispatch. They search, dedup, ingest. No model in the loop. The LLM only enters when ingest hits Stage 3 (Ch.45 §45.4) — which it does as part of the normal pipeline, not as monitor-specific behavior.
The principle: schedule the inputs; let the pipeline do its job. Monitors are the layer that decides what to ingest; the rest of Cascade is the layer that processes it.
Listed in §50.1 as the “multi-agent coordination” piece. From the live process listing:
$ ps aux | grep cascade
... cascade mcp serve ← long-running MCP server
... cascade mcp serve ← second instancecascade mcp serve (Ch.34 of this book) is a stdio
JSON-RPC server exposing Cascade tools to any MCP client —
Claude Desktop, Claude Code, other LLM agents. The exposed tools mirror
the cascade agent tools plus the agent-mode
--admin operations.
This is where Cascade composes with the broader agent ecosystem:
cascade mcp serve, which calls the same
cascade_chain Python function the
cascade agent CLI uses.Cascade is an agent peer, not an agent orchestrator. It supplies tools; it doesn’t control other agents. The MCP server is the interface.
| Property | Tool-use agent | Reactor | Monitors | MCP server |
|---|---|---|---|---|
| LLM in loop? | Yes | No | No | (external client) |
| Long-running? | No | Yes | No (cron) | Yes |
| Triggered by? | User invocation | Graph change | Schedule | MCP client call |
| Writes to DB? | Yes (admin mode) | Yes (rule actions) | Indirectly (via ingest) | Yes (via tools) |
| Cost per run | LLM tokens | ~free | Web + LLM (ingest) | LLM tokens (caller) |
| Process model | Spawn-on-call | Daemon | Cron job | Long-running |
The clean architectural cut:
Each is the right tool for one kind of work. The “swarm” is the combination — together they make Cascade run continuously, ingest new evidence, update its truth values, and answer analyst queries all without a human at the keyboard.
What the swarm looks like on a typical day:
00:00 — Cron fires `cascade monitor run`
→ Monitors run searches for tracked entities
→ 5 new articles found across 3 monitors
→ Each article: cascade ingest <url>
→ Ingest pipeline (Ch.45) runs for each
→ ~150 new entities, ~120 relations,
~155 claims added to cascade.db
00:01 — GraphEventBus emits SOURCE_INGESTED + many
ENTITY_CREATED / RELATION_CREATED events
→ Reactor (running as daemon) reads the queue
→ Rules from .cascade/rules/axiomalang/ fire
→ Truth values propagate (Ch.46)
→ Some claims flip from TRUE to BOTH
08:00 — Analyst arrives, runs `cascade agent
"what's new about Iran today?"`
→ Tool-use agent calls search_claims,
cascade_chain, search_entities
→ Synthesizes a response citing the new
articles ingested overnight
→ Optionally exports to Markdown for the
daily briefing
08:30 — Analyst uses Claude Desktop with Cascade MCP
→ Claude calls cascade tools via MCP bridge
→ Same data, different agent
12:00 — Cron fires monitor run again
→ 2 more articles ingested
→ Reactor processes the deltas
EOD — Substack daily briefing generated (Ch.51 §51.x)
No human-in-the-loop for the overnight + reactor + monitor work. The analyst only joins at 08:00 to consume what the swarm produced. That’s the operational payoff.
Five things to know:
rules table’s definition strings to match
against changes, but the matching logic is currently type-based
(matches on relation type, not on specific entities). A more powerful
matcher would speed up firing precision.cascade/core/monitor.py.cascade.db. There’s no direct
message- passing between them. This is fine (it’s the unix
philosophy applied to agents) but it means coordination patterns require
careful ordering.These exercises run against a live Cascade install.
Run
uv run cascade agent "What does Cascade know about Iran's connections to oil markets?".
Read the verbose output (-v). How many tool calls did the
LLM make? Which tools did it pick?
Run uv run cascade reactor status. If the daemon isn’t
running, start it with cascade reactor start in another
terminal. Then ingest a new article (or use
cascade entity add to trigger a graph change). After a few
seconds, check the status again. Did events_received and
rules_fired change?
Run
uv run cascade monitor add "Some topic" --query "search terms" --interval 24.
List monitors. Run them with cascade monitor run. What gets
added to the database?
For one analytical task — “find new evidence about Iran” — which agent mechanism is the right fit? Sketch how you’d implement it via:
Which would you pick for which use case?
Look at cascade/core/events.py. Identify:
Cascade has four agent mechanisms today. Sketch a fifth that would fit naturally. Hints:
Two paragraphs. What’s it for, where does it fit?
Solutions to selected exercises: Chapter 50 · Solutions in Appendix C. (Repo file: exercises/solutions/ch50_solutions.md.)
cascade agent, LLM-
driven, 7-tool catalog, max 5 rounds, synchronous, optional
--admin mode for mutations, separate user-memory
layer.cascade reactor, daemon, no
LLM, subscribes to GraphEventBus, fires rules on change_log entries,
debounce-capable.cascade monitor, scheduled,
per-entity search-and-ingest jobs, integrate with cron via
install-cron.cascade mcp serve,
cross-process bridge to other agents (Claude Desktop, Claude Code,
third-party orchestrators).The next chapter (Ch.51) zooms into the reactor + scheduled tasks, including the production deployment patterns (cron, systemd, the write-ahead change-log queue).
“A society of agents is not a council of equals; it’s a hierarchy of contracts.” — paraphrased from Minsky. Cascade’s swarm follows the principle: each agent has a narrow contract; coordination happens through shared state (
cascade.db); no agent claims authority over another’s domain.
What this chapter is. Ch.50 introduced four agent mechanisms. This chapter drills into the two that run unattended: the reactor daemon and the scheduled-monitor runner. You’ll learn the reactor’s threading model, the debounce-and-batch event-processing loop, the cascade-cycle detection that keeps forward-chaining from runaway, and the production patterns for deploying both under systemd or cron. By the end you’ll know how to run Cascade autonomously for days at a time, what to monitor when things go sideways, and how the change-log audit trail survives every restart.
This is the second chapter of Volume III Part XV (Operating Cascade). Ch.52 — the volume’s closer — covers subscription tiers, sync to Supabase, and the daily-briefing pipeline.
The reactor is production-rule architecture in the OPS5 / CLIPS / Drools lineage:
┌──────────────────────────────────────────────┐
│ GraphEventBus.emit(ChangeEvent) │
└──────────────────────────────────────────────┘
│
▼ (in-process callback)
┌──────────────────────────────────────────────┐
│ Reactor._on_change(event) │
│ buffer to _pending list (lock-protected) │
└──────────────────────────────────────────────┘
│
▼ (background daemon thread)
┌──────────────────────────────────────────────┐
│ Reactor._loop tick │
│ every tick_interval (250ms default) │
│ → _drain_pending() if debounce window passed │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Reactor._process_batch(events) │
│ 1. Persist events to change_log │
│ 2. Resolve affected claim IDs │
│ 3. Evaluate rules for each claim │
│ 4. Execute matching rule actions │
│ 5. Track CascadeContext (depth + seen set) │
└──────────────────────────────────────────────┘
│
▼ (rule actions may modify DB)
new ChangeEvents → loop
Read top-to-bottom: events flow in, work flows out. The reactor’s contribution is timing (debounce + batch) and forward-chaining safety (cycle detection + depth bound).
cascade/agent/reactor.py:45 defines
class Reactor with three concurrency-critical
attributes:
class Reactor:
def __init__(self, db_path, config, display_fn):
self._pending: list[ChangeEvent] = []
self._pending_lock = threading.Lock()
self._running = False
self._thread: threading.Thread | None = None
self._conn: sqlite3.Connection | None = None
...The reactor lives in one process, two threads:
_on_change(event) which only appends to
_pending under the lock. Cannot touch the DB (its
connection lives in the daemon thread)._loop
that ticks every tick_interval (250ms), drains pending
events past the debounce window, and processes the batch. Owns the
SQLite connection.The split matters: emitters never block. A producer
can emit hundreds of events in a tight loop; the daemon thread digests
them at its own pace. The _pending_lock is only held during
list append/clear — never during DB work.
A third logical thread (the SSE-streaming worker for the dashboard)
reads _sse_queues for live event delivery — also
non-blocking.
def _loop(self) -> None:
while self._running:
try:
batch = self._drain_pending()
if batch:
self._process_batch(batch)
except Exception as e:
self._errors += 1
log.warning("Reactor loop error: %s", e)
# Sleep in small increments for quick shutdown
deadline = time.monotonic() + self.config.tick_interval
while self._running and time.monotonic() < deadline:
time.sleep(0.05)The _drain_pending decides when to fire:
def _drain_pending(self) -> list[ChangeEvent]:
with self._pending_lock:
if not self._pending:
return []
oldest = self._pending[0].timestamp
if (time.time() - oldest) * 1000 < self.config.debounce_ms:
return [] # Not ready yet
batch = self._pending[:]
self._pending.clear()
return batchThe rule: wait until the oldest pending event is older than
debounce_ms (default 500ms), then batch-process
everything. This collapses bursts — if 50 events land in 100ms,
they’re processed as one batch, not 50 independent firings.
The tick_interval (250ms) is separate from
debounce_ms. The tick is “how often does the loop check?”;
the debounce is “how stale must the oldest event be before processing?”
Together they trade responsiveness (low tick) against
efficiency (high debounce).
Default configuration (ReactorConfig):
| Field | Default | Meaning |
|---|---|---|
enabled |
True | Master switch |
debounce_ms |
500 | Wait this long after event before firing |
max_cascade_depth |
3 | Forward-chain depth limit |
tick_interval |
0.25 | Loop tick (seconds) |
max_concurrent_evals |
3 | Evaluation parallelism cap |
notify_macos |
False | Send macOS desktop notifications |
notify_log |
True | Log reactor decisions |
notify_sse |
True | Stream to dashboard SSE |
auto_start_dashboard |
True | Bring up dashboard on start |
CascadeContextRule firing can trigger more graph changes, which can fire more rules, ad infinitum. The reactor prevents infinite cascades via two guards:
@dataclass
class CascadeContext:
"""Tracks (claim_id, rule_name) pairs to prevent
cycles within a cascade."""
depth: int = 0
seen: set = field(default_factory=set)
# set[tuple[int, str]]The two guards:
max_cascade_depth = 3
(default). A rule firing at depth N can produce changes that fire rules
at depth N+1, up to the limit. Past that, further changes are
recorded but not re-evaluated.Together they guarantee termination. The seen-set is per cascade — a fresh batch of events starts with an empty set, so the same rule can fire on the same claim in a later batch.
This is the production-rule safety property: forward chaining always terminates, even with mutually-modifying rules. The 3-depth bound is empirically tuned — most real Cascade rule chains saturate at depth 2-3.
Every event the reactor processes also lands in the
change_log table:
def _process_batch(self, events):
conn = self._get_conn()
# Persist events to change_log (audit trail)
for event in events:
conn.execute(
"INSERT INTO change_log "
"(change_type, item_type, item_id, "
" entity_ids, metadata) "
"VALUES (?, ?, ?, ?, ?)",
(event.change_type.value, event.item_type,
event.item_id, json.dumps(event.entity_ids),
json.dumps(event.metadata)),
)
conn.commit()
...The change_log is append-only. Every
event ever processed is permanently recorded:
CREATE TABLE change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
change_type TEXT NOT NULL,
item_type TEXT NOT NULL,
item_id INTEGER NOT NULL,
entity_ids TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
reactor_handled INTEGER DEFAULT 0,
rules_fired TEXT DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
ulid TEXT
);Three flags worth knowing:
reactor_handled = 0 — the row hasn’t
yet been processed. Either the reactor is off, or it’s still catching
up.rules_fired — JSON array of rule names
that fired in response to this event. Empty if no rule matched.reactor_handled = 1 — the reactor
processed this row.The audit trail enables forensics: when a claim
flipped from TRUE to BOTH at 3:47am, you can
SELECT * FROM change_log WHERE created_at LIKE '2026-04-15 03:47%'
and see exactly which events triggered the change.
When a graph mutation happens, which claims need
re-evaluation? The reactor’s _resolve_affected_claims
maps event types to claim sets:
| Event type | Affected claims |
|---|---|
CLAIM_CREATED / UPDATED |
The claim itself |
CLAIM_DELETED |
None (can’t re-evaluate) |
ENTITY_CREATED / UPDATED |
All claims referencing the entity |
ENTITY_DELETED |
All claims that referenced it (will fail gracefully) |
RELATION_* |
Claims whose claim_ids array includes a supporting
claim |
SOURCE_INGESTED |
All claims from that source |
The resolver does this via SQL joins against
claims.entity_ids and relations.claim_ids. The
resulting set gets deduplicated — even if an entity change
triggers 50 claims, they’re each evaluated once per batch.
The two lifecycle commands:
# Start (runs in foreground; press Ctrl-C to stop)
$ uv run cascade reactor start
# Start with custom debounce
$ uv run cascade reactor start --debounce 1000
# Check status
$ uv run cascade reactor status
{
"running": true,
"events_received": 142,
"rules_fired": 38,
"actions_executed": 38,
"errors": 0,
"pending": 0
}
# Stop the running reactor (sends signal to daemon)
$ uv run cascade reactor stopThe start command runs in the
foreground — typically you’d run it inside
tmux, screen, or under a process supervisor
(next section). The stop command writes to a PID file that
the running reactor watches; clean shutdown takes ≤5 seconds (the daemon
thread’s join timeout).
For a Linux server, the canonical pattern is a systemd unit:
# /etc/systemd/system/cascade-reactor.service
[Unit]
Description=Cascade Reactor Daemon
After=network.target
[Service]
Type=simple
User=cascade
WorkingDirectory=/home/cascade/axiomacascade
ExecStart=/home/cascade/.local/bin/uv run cascade reactor start
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetThen:
$ sudo systemctl enable --now cascade-reactor
$ systemctl status cascade-reactor
$ journalctl -u cascade-reactor -f # tail logsThe systemd Restart=on-failure is critical — Cascade’s
reactor doesn’t self-restart on crash. If the daemon thread dies
(uncaught exception in a rule action), the whole process exits and
systemd brings it back.
For macOS, the equivalent is launchd with a
~/Library/LaunchAgents/com.cascade.reactor.plist file. The
pattern is identical: the OS supervisor holds the lifecycle; Cascade
does the work.
Monitors are time-driven, not event-driven. They don’t need a daemon — just cron:
# /etc/crontab or `crontab -e`
0 */6 * * * cd /path/to/axiomacascade && \
/home/cascade/.local/bin/uv run cascade monitor run --quiet
This runs every 6 hours. Cascade provides install-cron
to generate the line:
$ cascade monitor install-cron
# Cascade monitor cron entry:
0 */6 * * * cd /path/to/cascade && uv run cascade monitor run --quietThe --quiet flag suppresses progress output — under
cron, stdout/stderr go to the cron mailer or get discarded, so verbosity
is wasted.
Inside cascade monitor run:
def run_all_due_monitors(conn, max_results=10,
auto_extract=True):
monitors = get_due_monitors(conn) # WHERE last_run + interval <= now
for m in monitors:
run_monitor(conn, m, max_results, auto_extract=auto_extract)Each due monitor:
sources.url.monitors.last_run_at.The 5-article cap is a politeness measure. A high-volume topic could yield 100+ new articles per run; ingesting all of them would burn ~30 minutes of cron time and ~$5 of LLM cost. The cap throttles that to ~5-10 minutes / ~$0.50. Future runs catch the rest.
When the reactor is running unattended, three metrics matter:
| Metric | What it means | What to do |
|---|---|---|
events_received |
Total events buffered | Should match change_log row count |
rules_fired |
Rules that matched + ran | Low ≠ broken; just means no rule matched |
actions_executed |
Actions that completed | Should track rules_fired (1:1 typical) |
errors |
Exceptions caught | Investigate any non-zero |
pending |
Buffered events not yet drained | Stays near 0 unless under heavy load |
A healthy reactor sees:
pending ≈ 0 (events drain within the debounce
window)errors = 0 or very lowactions_executed increments with
rules_firedA struggling reactor shows:
pending > 100 (events backing up — increase
debounce_ms or scale up)errors > 10 (rules throwing exceptions — check rule
files)actions_executed << rules_fired
(rules matching but actions failing)The dashboard (Ch.50 §50.6’s hint about
auto_start_dashboard) shows these live via SSE.
Restart safety is one of the reactor’s quiet features. Because every
event lands in change_log before the reactor
processes it, restarting the reactor doesn’t lose events:
# On startup, the reactor reads unhandled rows:
SELECT * FROM change_log WHERE reactor_handled = 0;Those become “synthetic events” replayed through
_process_batch. The reactor catches up to the present, then
waits for new live events.
This is write-ahead logging at the application
layer. The change_log is the WAL; the reactor’s
in-memory _pending queue is the fast-path cache. Crashes
are recoverable; the WAL survives.
Two consequences:
change_log accumulates
rows. On restart, the reactor drains the backlog (possibly slowly) and
reaches steady state.change_log grows monotonically.
Cleanup is a separate concern (no auto-prune). Production deployments
need a periodic
DELETE FROM change_log WHERE created_at < datetime('now', '-30 days')
job.Five engineering caveats:
_pending list grows unboundedly. The 5-second join timeout
on shutdown can drop events that haven’t been drained yet.cascade ingest <url> fails inside a monitor run, the
URL is logged and skipped. The next monitor run won’t retry it (the URL
isn’t yet in sources, but the search engine may not surface
it again).In one terminal, run uv run cascade reactor start. In
another, ingest a new article or add an entity. Then run
uv run cascade reactor status and inspect
events_received. Did the number match your expectation?
Edit ~/.cascade/cascade.toml to set
reactor.max_cascade_depth = 5. Restart the reactor. Trigger
an event that causes a multi-hop rule cascade (e.g. ingest an article
about “sanctions” if you have the rule from Ch.49 §49.4). Look at
change_log — how many derived events were produced? At what
depth did the cascade stop?
SELECT change_type, COUNT(*) AS n
FROM change_log
GROUP BY change_type
ORDER BY n DESC;What’s the most common change_type in your install? Which event types fire least? What does that tell you about your ingestion patterns?
Run cascade monitor install-cron to generate the crontab
line. Don’t actually install it — just read it. What’s the
schedule? What happens if you forget the --quiet flag?
Stop the reactor. Add a few entities via
cascade entity add ... (this still writes to
change_log since it’s source-of-truth, but
reactor_handled stays 0). Restart the reactor. Does it process
the backlog?
The reactor has no per-rule rate limit. A buggy rule that fires on
every claim could overwhelm the system. Sketch a
RuleRateLimiter class:
Two paragraphs.
Solutions to selected exercises: Chapter 51 · Solutions in Appendix C. (Repo file: exercises/solutions/ch51_solutions.md.)
debounce_ms (500ms default), then a batch flushes through
the pipeline.reactor_handled = 0 rows on startup and replays.Restart=on-failure handles crashes.install-cron
prints the entry; --quiet is the right cron flag.The next (and final) Volume III chapter — Ch.52 — covers the operating-Cascade scaling story: multi-machine Supabase sync, the daily-briefing pipeline, and how the whole architecture composes into a multi-tenant production deployment.
“A daemon is a process you don’t think about until it’s broken.” — old systems-admin aphorism. Cascade’s reactor is designed to minimize the latter case — append-only audit trail, write-ahead recovery, depth-bounded forward chains. The thinking happens once at setup; after that, it just runs.
What this chapter is. Volume III’s closer. You’ve seen the architecture (Ch.43), the schema (Ch.44-38), the reasoning layer (Ch.47-41), and the agent swarm (Ch.50-43). This chapter ties it together with the production-deployment story: the multi-machine Supabase sync that makes the KB portable, the output-format catalog (HTML, GraphML, DOT, Cypher, briefings), the dashboard + briefing pipeline that turns the KB into something a non-technical reader can consume, and the operational practices (logging, monitoring, scaling) that keep a multi-week deployment healthy. By the end you’ll have the full operational picture and a punch list for standing up your own Cascade.
This is the closing chapter of Volume III. After this you can either consume the textbook as a self-contained body of knowledge, or pick up the roadmap chapters mentioned in Appendix D.
Cascade can run in three deployment modes, each with different tradeoffs:
| Mode | Use case | Components |
|---|---|---|
| Local single-machine | Solo analyst | cascade.db + reactor + cron monitors |
| Networked single-tenant | Small team | Local + Supabase mirror + Dashboard exposed on LAN |
| Multi-tenant production | Subscription service | Multiple Cascade installs sharing a Supabase backend, Substack publisher, billing |
This chapter walks each. The architectural pattern is additive — local mode is a strict subset of networked, which is a strict subset of production. You start small and scale up without rewriting.
The sync layer lives in cascade/sync/:
cascade/sync/
├── outbox.py — Capture local mutations
├── push.py — Send unpushed rows to Supabase
├── pull.py — Receive remote rows, apply locally
├── conflict.py — Resolve ULID-based conflicts
├── daemon.py — The sync daemon (long-running)
└── neo4j.py — Optional Neo4j AuraDB read replica
The flow:
┌──────────────────┐ ┌──────────────────┐
│ Machine A │ │ Machine B │
│ cascade.db │ │ cascade.db │
│ └── sync_outbox │ │ └── sync_outbox │
└──────────────────┘ └──────────────────┘
│ │
│ push (mutations) │
│ │
▼ ▼
┌────────────────────────────────────────────────┐
│ Supabase │
│ PostgreSQL with same schema │
└────────────────────────────────────────────────┘
▲
│ pull (peer mutations)
│
Other machines receive
The sync_outbox table (Ch.44 §44.7) is the
write-ahead log for sync. Every local INSERT or UPDATE
writes a row there; the daemon periodically pushes unpushed rows. ULIDs
(Ch.44 §44.3) are the dedup key — two machines that generate ULIDs at
the same millisecond still get globally-unique IDs.
CLI:
$ export SUPABASE_URL="https://xyz.supabase.co"
$ export SUPABASE_KEY="..."
$ cascade sync start # daemon (foreground)
$ cascade sync push # one-shot push
$ cascade sync pull # one-shot pull
$ cascade sync status # outbox counts + last push timeWhy Supabase? Three reasons over rolling your own:
The downside is vendor lock-in lite. The schema itself is
portable (it’s just PostgreSQL); the auth and RLS bits are
Supabase-specific. Production deployments often add a thin
abstraction in cascade/sync/ so swapping Supabase for
plain Postgres takes one config change.
When two machines edit the same row, conflict resolution
kicks in. The rule, from cascade/sync/conflict.py:
| Conflict | Resolution |
|---|---|
| Two INSERTs with same ULID | First-write-wins (later push is silently a no-op) |
| Two UPDATEs to same row | Last-write-wins by updated_at |
| INSERT after DELETE | Re-insert wins (DELETE was the older op) |
| Same-row truth_value flip on both machines | Last-write-wins; conflict logged |
The last-write-wins strategy is simple but
lossy. If Machine A flips a claim from TRUE to FALSE at 10:00
and Machine B flips it to BOTH at 10:01, the BOTH wins — but A’s
reasoning chain is discarded. For mission-critical analyst work, this is
wrong; for eventual consistency of a knowledge graph it’s
acceptable.
A future enhancement would store both edits in
truth_history (Ch.46) and let an analyst reconcile manually
— a conflict review queue.
cascade export exposes 7 output formats:
| Subcommand | Format | Use case |
|---|---|---|
html |
Pyvis interactive | Share via email; clickable graph |
graphml |
XML for Gephi/yEd/Cytoscape | Academic-grade visualization |
gexf |
GEXF for Gephi | Native Gephi format |
dot |
Graphviz | Programmatic typesetting |
cypher |
Neo4j CREATE statements | Import to Neo4j |
tree |
Cascade chain as tree diagram | Briefing visuals |
chart |
Matplotlib statistical plots | Reports + decks |
Each preserves a different aspect of the graph:
The principle: export per use case, not per analyst preference. A dashboard view (Pyvis HTML) serves a casual reader; an academic paper needs GraphML; a research blog post wants DOT.
$ cascade export html --output graph.html
$ cascade export graphml --output graph.gml
$ cascade export tree "Iran" --depth 3 --output iran.txt
$ cascade export chart centrality --output centrality.pngcascade dashboardFor interactive exploration, the dashboard is a Flask + Pyvis web app:
$ cascade dashboard start
✅ Dashboard at http://127.0.0.1:5050What it shows:
change_log events.The dashboard listens on 127.0.0.1:5050 by default
(localhost-only). For team access, bind to 0.0.0.0 and use
a reverse proxy (nginx) with basic auth or OAuth:
$ cascade dashboard set-password
Enter password: ***
✅ Password set
$ cascade dashboard start --host 0.0.0.0 --port 8080The SSE stream comes from the reactor’s _sse_queues
(Ch.51 §51.2’s third logical thread). Dashboard subscribers register a
queue; the reactor’s batch processing pushes events through; the
browser’s EventSource API reads them. Sub-second update
latency for live view of the graph as it evolves.
cascade/output/briefing.pyThe high-leverage output of an operational Cascade deployment is the daily briefing: a plaintext or HTML summary of what happened in the graph over the last 24 hours, with citations to sources and a list of “what to watch.”
def generate_daily_digest(conn, llm_enabled=True):
"""Produce a daily intelligence digest.
Returns a Markdown briefing covering:
- New entities introduced
- New relations of high confidence
- Claim truth-value flips
- Top cascade impacts
- 'What to watch' synthesis (LLM-driven)
"""
...Two modes:
The briefing pipeline:
┌────────────────────────────────────┐
│ Read change_log for last 24h │
│ → entity/relation/claim CRUDs │
└────────────────────────────────────┘
▼
┌────────────────────────────────────┐
│ Rank by importance: │
│ - Centrality of involved entities │
│ - Confidence of changes │
│ - Truth-value flips (esp. TRUE→BOTH) │
└────────────────────────────────────┘
▼
┌────────────────────────────────────┐
│ Compose Markdown sections: │
│ - Headlines │
│ - New developments │
│ - Cascade impacts │
│ - What to watch │
└────────────────────────────────────┘
▼
┌────────────────────────────────────┐
│ Save to ~/.cascade/briefings/ │
│ YYYY-MM-DD-daily.md │
└────────────────────────────────────┘
For a Substack publisher, post the saved file via the Substack API or a manual paste. For internal team consumption, email or Slack.
Cascade is structured to support a subscription service
model. The tiers (sketch from the config.toml
defaults):
| Tier | Cost | Ingestion | Rules | Dashboard | Briefing |
|---|---|---|---|---|---|
| Free | $0 | 10 articles/day | 5 rules | Read-only | None |
| Basic | $19/mo | 100/day | 50 rules | Personal | Weekly |
| Pro | $99/mo | 1000/day | Unlimited | Team (≤5) | Daily |
| Enterprise | Custom | Unlimited | Unlimited | Org-wide + SSO | Custom |
Tier enforcement happens at three points:
~/.cascade/quota.json. Checked at
cascade ingest <url> invocation; hard error on
exceeded.load_axiomalang_rules time; rules beyond the limit get
skipped with a warning.The pattern is soft enforcement with explicit upgrade prompts, not silent feature gating. Every tier-boundary error includes a what would change if you upgraded message.
Production deployments handle billing via Stripe (integration in
cascade/billing/, not covered here — see the Cascade
product docs for the full business-side story).
For a production deployment, three log streams matter:
| Stream | Source | Purpose |
|---|---|---|
cascade/data/logs/cascade.log |
Application | Debug & audit |
| systemd journal | Reactor daemon | Process state |
| Supabase logs | Sync errors | Database issues |
The application log is the most useful. Cascade’s logging
module (cascade/logging.py) emits structured log lines:
2026-04-15 03:47:22 INFO extractor | LLM extraction done | 8.2s | entities=28 | relations=19
2026-04-15 03:47:23 INFO extractor | Store complete | source_id=331 | new=11 | merged=17
2026-04-15 03:47:23 INFO reactor | Batch: 28 events → 12 affected claims
2026-04-15 03:47:24 INFO reactor | Rule fired: sanctions_commodity_risk on claim 4521
Grep-friendly format. Production monitoring tools (Datadog, Grafana Loki, Splunk) can parse these with one-line regex configs.
For metrics (not logs), the reactor and sync daemons expose JSON status endpoints:
$ cascade reactor status --json
$ cascade sync status --jsonScrape these with cron + curl, push to Prometheus or whatever your monitoring stack uses.
Standing up a production Cascade deployment, the checklist:
This is the operational cadence for a healthy Cascade. None of it is novel — it’s the standard pattern for any data-pipeline-plus-rule- engine system. The discipline pays off.
After 9 chapters of Volume III, an honest tally of what’s still hard:
truth_history but the UX for
surfacing disagreement is rough.timestamp field exists; the
staleness scoring doesn’t.These are real engineering gaps, all on the roadmap, none of which prevent production use today. The pattern: every operational system has unsolved problems; the difference between mature and immature is whether you know what they are.
Run the full inventory:
cascade stats # graph counts
cascade reactor status --json # reactor metrics
cascade sync status # sync metrics
ls .cascade/rules/axiomalang/ # rule files
ls data/logs/ # log files
cat ~/.cascade/cascade.toml # configGenerate a one-page deployment summary listing: total entities/relations/claims, number of rules, reactor uptime (if known), sync status.
Pick one of the 7 export formats. Run the command, open the output file in the appropriate tool (browser for HTML, Gephi for GraphML, etc.), and explore. What does this show that the CLI doesn’t?
$ cascade briefing daily --output today.md
$ cat today.md | head -50Read the output. Three questions:
You’re standing up Cascade for a 3-person analyst team that needs:
Sketch (no code) the deployment:
Half a page.
Suppose your cascade.db got corrupted. List the steps to recover:
Walk it through.
§52.9’s punch list covers the basics. What’s missing? Sketch one operational practice you’d add for your use case. Hints:
Solutions to selected exercises: Chapter 52 · Solutions in Appendix C. (Repo file: exercises/solutions/ch52_solutions.md.)
sync_outbox +
ULID-keyed conflict resolution; vendor-lockable but standard Postgres at
the wire level.This closes Volume III. The arc:
| Part | Chapters | Theme |
|---|---|---|
| XII | 35 | Foundations — the architecture map |
| XIII | 36-38 | Knowledge graph — schema, ingest, truth |
| XIV | 39-41 | Reasoning — NetworkX + Axioma + custom rules |
| XV | 42-44 | Operations — agents, reactor, scaling |
You now have the complete Cascade story: how data gets in (Ch.45), how it’s stored (Ch.44), how it’s reasoned over (Ch.47-40), how rules get written (Ch.49), how the system runs autonomously (Ch.50-43), and how it deploys at scale (this chapter).
The book’s arc closes here. Volume I taught Axioma the language; Volume II extended into data structures and knowledge representation; Volume III showed how Axioma composes into a production system. The appendices (A–F) provide reference material for the road ahead.
“The fundamental theorem of software engineering is that any problem can be solved by adding another layer of indirection — except the problem of too many layers.” — classic. Cascade adds nine layers (ingest, KG, truth, reasoning, agents, sync, output, dashboard, billing). Each layer earns its keep because each owns one concern. Removing any of them would expand the others. That’s the operational test of architecture — not what’s there, but what can’t be removed.
What this chapter is. A look at SQL — the most widely-deployed data language on the planet — from the inside out. We won’t treat SQL as an external thing that Axioma calls. Instead we’ll see how Axioma’s
[sql | …]block compiles SQL down to native comprehensions, making SQL a third surface over the same relational substrate as Prolog-style queries (Chapter 21) and set comprehensions (Chapter 7). Once you see the lowering, SQL stops being a black box and becomes another way to write what you already understand.
Axioma’s relational layer has three syntactic surfaces:
| Surface | Style | Example |
|---|---|---|
| Set comprehension | math notation | {Y | parent(Y, "John")} |
| Prolog-form | logic programming | {Y | Y <- parent(Y, "John")} |
| SQL | embedded dialect | [sql | SELECT child FROM parent WHERE adult = 'John'] |
For the schema parent(child, adult), all three ask for
the same children. None is privileged; the choice is about which form
your reader will find most natural. A mathematician reads the
comprehension; a Prolog programmer reads the pipe-arrow; a database
programmer reads the SELECT.
Crucially, all three lower to the same compiled form
— a comprehension over Axioma’s _relation_ store. The SQL
block isn’t a foreign-runtime call (the way [python | …]
is). It’s a compilation pass: the SQL parser builds an AST, the
compiler emits an Axioma comprehension, and the existing evaluator runs
it.
You can see this with the /explain refinement. First
create the small schema used in the join examples below:
[sql | CREATE TABLE emp (name VARCHAR, dept_id INTEGER, salary INTEGER)]
[sql | CREATE TABLE dept (id INTEGER, dname VARCHAR)]
[sql | INSERT INTO emp VALUES ('Alice', 1, 100)]
[sql | INSERT INTO emp VALUES ('Bob', 2, 90)]
[sql | INSERT INTO emp VALUES ('Carol', 3, 80)]
[sql | INSERT INTO dept VALUES (1, 'Engineering')]
[sql | INSERT INTO dept VALUES (2, 'Sales')]
println([sql/explain | SELECT name FROM emp WHERE dept_id = 1])
# {_row[1] | _row <- {(V1, V3) | emp(V1, 1, V3)}}That output is the actual Axioma source the SQL compiler produced. Run it directly and you get the same answer.
[sql | CREATE TABLE teacher (instructor VARCHAR, student VARCHAR)]
[sql | INSERT INTO teacher VALUES ('Socrates', 'Plato')]
[sql | INSERT INTO teacher VALUES ('Plato', 'Aristotle')]
[sql | INSERT INTO teacher VALUES ('Anaxagoras', 'Socrates')]
[sql | SELECT student FROM teacher WHERE instructor = 'Plato']
# → {"Aristotle"}Notice the lowering:
| SQL | Axioma equivalent |
|---|---|
CREATE TABLE t (…) |
relation t(…) |
INSERT INTO t VALUES (…) |
assert t(…) at the datum tier |
SELECT * FROM t WHERE … |
Set comprehension over t(…) |
DROP TABLE t |
drop_relation("t") |
TRUNCATE TABLE t |
Forget every row, keep the relation declaration |
The datum tier is Axioma’s bottom-level grounding
(Chapter 16) — it means “this row is given without further warrant,”
matching SQL’s semantics for an INSERT. If you wanted the
row to be an axiom (unchallengeable foundation) you would step
outside SQL and write axiom teacher("Socrates", "Plato")
directly.
The lowering is most illuminating when you look at joins. An INNER JOIN is just a two-relation comprehension:
[sql | SELECT emp.name, dept.dname
FROM emp INNER JOIN dept ON emp.dept_id = dept.id]lowers (roughly) to:
{(N, D) | emp(N, ID, Salary) and dept(ID, D)}The trick is the join-key unification: the SQL
compiler sees emp.dept_id = dept.id and gives both columns
the same pattern variable name. The shared name forces Axioma’s
chain-unification to keep only rows where both columns agree — that’s
the join.
LEFT JOIN is more interesting. The rows where the join matches are the same as INNER JOIN; but rows where the left table has no matching right row must still appear, with right-side columns NULL-padded. That’s a union of two sets:
LEFT JOIN result = INNER JOIN result
∪ {left rows whose join key has no match}
The “no match” check is the anti-join half: walk the left table, filter to rows whose join key is not in the right table’s join- column extent, project the user’s SELECT list with NULL where right- side columns appear.
[sql | SELECT emp.name, dept.dname FROM emp
LEFT JOIN dept ON emp.dept_id = dept.id]
# When dept_id has no matching dept.id, you get ("Carol", ?ᵇ)
# — Belnap's "neither true nor false," Axioma's default NULL.RIGHT JOIN is symmetric (preserves the right side).
FULL OUTER JOIN is both —
matched ∪ left_unmatched ∪ right_unmatched.
GROUP BY and HAVING[sql | CREATE TABLE sales (region VARCHAR, product VARCHAR, qty INTEGER)]
[sql | INSERT INTO sales VALUES ('east', 'widget', 10)]
[sql | INSERT INTO sales VALUES ('east', 'widget', 5)]
[sql | INSERT INTO sales VALUES ('east', 'gadget', 7)]
[sql | INSERT INTO sales VALUES ('west', 'widget', 3)]
[sql | INSERT INTO sales VALUES ('west', 'gadget', 12)]
[sql | SELECT region, product, SUM(qty) FROM sales
GROUP BY region, product]
# → {("east", "widget", 15), ("east", "gadget", 7),
# ("west", "widget", 3), ("west", "gadget", 12)}This lowers to group_by (introduced in Chapter 7) plus a
per-group projection. Specifically:
group_by(lambda t => composite_key(t), rows):
bucket the rows by the GROUP BY columns. This lowering uses
group_by’s stringified keys, so a multi-column key gets
stringified — which means we recover the original column values
from each bucket’s first member (vs[1]) rather than from
the stringified key.(k, vs)
pair, emit the key columns and the aggregate value.HAVING is a post-aggregation filter — it sees the
already-computed aggregate value, so the comprehension’s terminal filter
applies it naturally.
SQL’s type system is small but the operations on types are rich:
[sql | CREATE TABLE grades (score INTEGER)]
[sql | INSERT INTO grades VALUES (85)]
# Standard form
[sql | SELECT CAST(score AS FLOAT) FROM grades]
# Postgres :: shorthand — semantically identical
[sql | SELECT score :: FLOAT FROM grades]
# Chained
[sql | SELECT score :: INT :: TEXT FROM grades]Each cast lowers to a function call on the type’s standard
constructor: int(...), to_float(...),
str(...), to_bool(...). The chained form is
left-associative, so the inner cast runs first. These examples start
with a numeric value. Do not assume every coercion supported by a
database server exists here: for example, this build’s
to_float rejects a String such as "85".
The default SQL surface represents NULL with Belnap’s
neither value, ?ᵇ. It means no
information, not a numeric zero or an empty string. Inserted NULL cells
and missing cells supplied by an outer join retain that typed value when
read from the relation store.
[sql | CREATE TABLE optional (reading INTEGER)]
[sql | INSERT INTO optional VALUES (5)]
[sql | INSERT INTO optional VALUES (NULL)]
println([sql | SELECT reading FROM optional])
# {5, ?ᵇ} — a Set; display order may vary
println([sql | SELECT reading FROM optional WHERE reading IS NULL])
# {?ᵇ}
println([sql | SELECT reading FROM optional WHERE reading > 3])
# {5}
println([sql | SELECT reading FROM optional WHERE NOT (reading > 3)])
# {}The last result deserves attention. Comparing a missing reading with
3 produces neither, rather than false. Negating neither still does not
establish that the reading is at most 3. Neither filter selects that
row. To include it, write reading > 3 OR reading IS NULL
explicitly. IS NOT NULL selects only known readings. A
quoted String such as "?ᵇ" or "neither" is
ordinary text and does not pass IS NULL.
Ordered comparisons and arithmetic in supported SQL expressions
propagate missing values. Each binary operator evaluates its operands
once. BETWEEN uses two such comparisons. Numeric
MOD and arithmetic on an UPDATE right-hand side also
propagate missing values. Other invalid operands still produce errors.
These are SQL translation rules: ordinary Axioma arithmetic and
comparison operators do not change.
For SELECT, the /k3 refinement emits om for
NULL literals and missing results. The /strict refinement
combines that choice with bag output. Both recognize the typed absence
stored by a default INSERT. Write refinements do not change the stored
marker. This does not make the surface a complete ANSI
SQL engine: equality still uses Axioma’s value equality, so do not teach
NULL = NULL as always unknown here. Use
IS NULL to express the missing-value test.
There are still separate boundaries. NULLIF currently
returns none. The fallback functions COALESCE
and IFNULL test for that value, rather than the SQL markers
above. They are not yet general replacements for IS NULL.
Nullable aggregates, string functions and casts also need their own
rules and checks. Do not replace a missing value with a magic numeric
sentinel to hide a failure. For typed missing measurements in data
analysis, see Chapter 18’s Na/DataFrame path.
For teaching, the SQL block exposes two “intermediate representation” arrow forms:
[sql -> algebra | SELECT name FROM emp WHERE dept_id = 1]
# → "π_{name}(σ_{dept_id=1}(emp))"
[sql -> calculus | SELECT name FROM emp WHERE dept_id = 1]
# → "{ t.name | emp(t) ∧ t.dept_id = 1 }"These don’t run the query — they return the relational algebra and tuple calculus forms as Strings. That makes Axioma a particularly good environment for teaching Codd’s foundational correspondence: every SELECT is simultaneously an algebraic expression, a calculus formula, and an executable comprehension. Students can run the SQL or generated Axioma. The algebra and calculus Strings are explanatory representations, not independently executable Axioma source.
This is another example of multiple surface notations describing a shared computation.
Use SQL when:
Use comprehensions when:
Use Prolog-form when:
<- form reads better for long chains).There’s no winning form. Axioma offers all three because clarity is about your reader. SQL is the right answer when your reader is a SQL programmer. It’s the wrong answer when your reader is a Haskell programmer or a category theorist.
The SQL surface supports the concrete examples above, but it is not a complete database engine. Section 53.6 states the supported NULL behavior and the remaining boundaries. The following are not yet supported:
OVER,
PARTITION BY, ROW_NUMBER, RANK,
LAG, LEAD. Large surface, deserves its own
milestone.CREATE TABLE constraints —
PRIMARY KEY, FOREIGN KEY,
NOT NULL, DEFAULT, REFERENCES,
CHECK. Would map to cardinality + referential-integrity
rules in Axioma’s substrate.ALTER TABLE — schema evolution.a LEFT JOIN b LEFT JOIN c.LEFT JOIN ... ON a.x < b.y.None of these prevent the working examples in this chapter from running. They’re growth edges for future textbook chapters.
Write the same three-relation transitive-closure query in all
three surfaces (SQL with WITH RECURSIVE-style chained CTEs,
Prolog-form, set comprehension). Notice which reads most clearly to
you.
Use [sql/explain | …] on each of your three forms.
Verify the compiled Axioma source is structurally similar.
Write a SELECT … GROUP BY query, then write the same
query as a group_by + comprehension chain. Use
[sql -> calculus | …] to see what the
relational-calculus form looks like.
Construct a LEFT JOIN where the left table has rows
with no matching right-table row, and confirm the NULL padding. Then
write the same query as an explicit UNION of an INNER JOIN
and an anti-join.
Add a missing reading and a known reading to the same table.
Compare reading > 3, NOT (reading > 3),
and reading IS NULL in default and /k3 modes.
Explain why negating an unknown ordered comparison does not select the
missing row. Do not assume ANSI equality semantics.
“SQL is the COBOL of relational algebra — verbose, prescriptive, universally understood. The tradeoff is the genius.” — adapted from M.P. Atkinson. Axioma’s bet is that you should be able to write the SQL when SQL is what your reader expects, write the comprehension when math notation is clearer, and write the Prolog-form when you’re thinking in logic. All three are the same thing under the hood. Choose the surface, not the substrate.
What this chapter is. Chapter 19 taught you to write a type and ask
--typecheckwhether the program honors it. This chapter teaches the other direction: a dialect that reads an unannotated program and either prints a principal type or refuses to run.#language axioma/hmis not a flag that “turns types on” for the host language. It is a closed language — a fenced island with its own catalog, its own operators, and a checker that does not skip.Where it sits. Read this after Chapter 19 (annotations and
--typecheck) and Chapter 11 (functions as values). The chapter is numbered 54 so earlier chapters stay put; it is a Part VI addendum, not a sequel to SQL.
Axioma’s host language (axioma/all, the default) is
gradual. You may annotate a name with ::
and --typecheck will honor the annotation. You may also
write nothing, and the program still runs. That is the right default for
a multi-paradigm language that also has concepts, knowledge bases, and
dual bottoms.
Host axioma --infer is a lens on that
gradual language. It looks at each top-level function in the pure lambda
fragment and prints an arrow if it can. Everything else gets a
reason, never a guess, and a refusal does not
fail the process:
script.ax:1: id :: a -> a
script.ax:2: shout — not inferred: outside the lambda fragment (world: calls println())
That report is useful. It is not a type system. A student cannot tell “the checker rejected this” from “the lens declined to guess.” Both print a line and exit 0.
#language axioma/hm answers a different question:
what if the file were a language whose programs either have a
principal type, or do not run?
Host --infer |
#language axioma/hm |
|
|---|---|---|
| What it is | a lens on axioma/all |
a language |
| Untypable code | skip, exit 0 | refuse, exit 1 |
println |
“not inferred (world)” | println has no known type |
id used at two types |
let-polymorphism, if it is in the fragment | let-polymorphism, required |
| Recursion | inferred when monomorphic | allowed (fib is legal) |
--vm |
separate | refused |
The island is not the Total island of the language
constitution. General recursion is allowed. Strong normalization is not
claimed. A later axioma/total would be a smaller dialect
inside this one.
Put the pragma on line one. Each fenced program in this chapter is a
separate file: repeated names such as id
or inc do not belong in one concatenated island program.
The rest of each file is the island; no host builtins leak in.
#language axioma/hm
id: func(x) [x]
expect("id 1", id(1), 1)
expect("id true", id(true), true)Run it:
$ axioma --no-kb id.ax
✅ --- PASS: id 1
✅ --- PASS: id trueThen ask for the types. On an island file, --infer is
the same checker that gated the run — not a second
opinion:
$ axioma --no-kb --infer id.ax
id.ax:2: id :: a -> aA lowercase letter is a type variable. id :: a -> a
means: for any type a, this function takes an
a and returns an a. The two
expect lines instantiate a at
Integer and at Boolean. That reuse is
let-polymorphism: a name bound to a function is
generalized, then specialized again at each call.
expect is the island’s assertion. println
is not in the catalog — it writes to the world, and the island has no
world. Tests in this chapter use
expect(label, actual, expected), the same assertion the
rest of Axioma uses.
You can also pass --language axioma/hm (alias
hm) on the command line. The pragma in the file is the form
to prefer: it travels with the source.
The identity example already used id at two types. Here
the two instantiations live in one body, so the checker has to
keep both specializations in play at once:
#language axioma/hm
id: func(x) [x]
both: func() [ if id(true) then id(1) else id(2) ]
expect("both", both(), 1)id :: a -> a
both :: () -> Integer
id(true) specializes a to
Boolean for the if condition.
id(1) and id(2) specialize a to
Integer for the branches. The two uses do not fight,
because id was generalized when it was bound.
A higher-order cousin:
#language axioma/hm
twice: func(f, x) [f(f(x))]
inc: func(n) [n + 1]
notb: func(b) [if b then false else true]
expect("twice inc", twice(inc, 3), 5)
expect("twice not", twice(notb, true), true)twice :: (a -> a, a) -> a
inc :: Integer -> Integer
notb :: Boolean -> Boolean
twice does not care what a is, as long as
f sends a to a. inc
pins a to Integer at one call;
notb pins it to Boolean at the other.
/On the host, "a" + "b" concatenates and
"ab" * 2 repeats. The island will not lie about that with a
single + type.
#language axioma/hm
expect("plus", 1 + 2, 3)
expect("str_concat", str_concat("a", "b"), "ab")
expect("array_concat", array_concat([1,], [2,]), [1, 2])+ is numeric. String concatenation is
str_concat. Array concatenation is
array_concat. Write "a" + "b" and the file is
refused:
string and array concatenation use str_concat and array_concat
Write "ab" * 2 and you get:
the * operator is numeric
Integer / is refused for a different honesty reason. In
the host, 7 / 2 is the exact Rational 7/2, so
the result type changes with the operands. A simple arrow would
lie. Use div (and % / mod) when
you want an Integer:
#language axioma/hm
expect("div", 7 div 2, 3)
expect("mod", 7 % 2, 1)the result type of / depends on exactness (Integer / yields Rational); not inferred
On the island, “not inferred” is fatal — the same sentence the host lens prints as a skip is here a rejection.
Two Floats add as Float (1.0 + 2.0 is fine). An
unannotated add: func(x, y) [x + y] infers
add :: (Integer, Integer) -> Integer
because nothing in the body pins a Float. Calling that
add with 1.5 is then a type error
(Integer and Float cannot be the same type).
Mixed exactness is a host convenience; the island does not smuggle it
in.
if is
Boolean, and both branches agreeHost Axioma treats every value as truthy or falsy, so
if 1 then … runs. The island types the condition as
Boolean:
#language axioma/hm
expect("if true", if true then 2 else 3, 2)
expect("if false", if false then 2 else 3, 3)if 1 then 2 else 3 is refused:
an if condition must be Boolean
The else is required. An if without one has
no single result type. The two branches must also agree:
# this is refused
f: func(b) [if b then 1 else "a"]the two branches of an if disagree: Integer and String cannot be the same type
Arrays are homogeneous for the same reason:
the elements of an array disagree: Integer and String cannot be the same type
Write [1,] when you want a one-element array.
[1] in some positions is a block; the trailing comma makes
the array reading unambiguous (Chapter 6, and the Manual’s
bracket-by-position rule).
The island is not a strongly-normalizing calculus. fib
is legal:
#language axioma/hm
func fib(0) [0]
func fib(1) [1]
func fib(n) when n > 1 [fib(n - 1) + fib(n - 2)]
expect("fib 10", fib(10), 55)fib :: Integer -> Integer
A multi-clause group is one function. The literal
patterns 0 and 1 ground the argument at
Integer; the recursive clause has to agree. That is why
fib is not a -> a. Recursion on the island
is monomorphic: the function is used at one type,
consistently.
The same pattern writes integer power:
#language axioma/hm
func pow(b, 0) [1]
func pow(b, n) when n > 0 [b * pow(b, n - 1)]
expect("pow", pow(2, 10), 1024)pow :: (Integer, Integer) -> Integer
Only a small, typed catalog is in scope. Everything else — including host builtins you use every day — has no type here, so using it is a rejection.
| Name | Island type |
|---|---|
str / string |
a -> String |
len |
Array of a -> Integer |
empty? |
Array of a -> Boolean |
str_concat |
(String, String) -> String |
array_concat |
(Array of a, Array of a) -> Array of a |
map |
(a -> b, Array of a) -> Array of b |
filter |
(a -> Boolean, Array of a) -> Array of a |
foldl / fold_left |
((b, a) -> b, b, Array of a) -> b |
foldr / fold_right |
((a, b) -> b, b, Array of a) -> b |
abs |
Integer -> Integer |
float |
Integer -> Float |
integer |
Float -> Integer |
expect |
(String, a, a) -> () |
You write Array of T in an annotation
(xs :: Array of Integer). --infer
prints Array[Integer]. Same type, two surfaces:
of is the annotation keyword; the printer uses the compact
form. Never write Array[T] as an annotation — on the
island, brackets are bodies.
Math constants PI, PHI, TAU,
EULER, SQRT2, SQRT3,
LN2, LN10 are Float.
reduce is a host name. On the island, fold with
foldl. append is a host name that mutates or
writes a file — it is not array concatenation:
append has no known type here (a builtin, a later definition, or an unbound name)
Higher-order functions from Chapter 10 work, with the island’s
func (or lambda) and the catalog:
#language axioma/hm
double: func(x) [x * 2]
xs: [1, 2, 3]
expect("map", map(double, xs), [2, 4, 6])
expect("filter", filter(func(n) [n % 2 == 0], [1, 2, 3, 4]), [2, 4])
expect("foldl", foldl(func(acc, n) [acc + n], 0, xs), 6)double :: Integer -> Integer
A function that returns a function is in the fragment.
compose infers a curried remainder:
#language axioma/hm
compose: func(f, g) [func(x) [f(g(x))]]
inc: func(n) [n + 1]
double: func(n) [n * 2]
h: compose(double, inc)
expect("compose", h(3), 8)compose :: (a -> b, c -> a) -> c -> b
inc :: Integer -> Integer
double :: Integer -> Integer
apply is the simplest higher-order pattern:
#language axioma/hm
apply: func(f, x) [f(x)]
expect("apply", apply(func(n) [n + 1], 4), 5)apply :: (a -> b, a) -> b
Partial application is legal. add(1) is a function
waiting for the second argument:
#language axioma/hm
add: func(x, y) [x + y]
inc: add(1)
expect("partial", inc(2), 3)
expect("saturated", add(1, 2), 3)The first diagnostic always expands the name. match is
the example the Manual quotes; the same expansion is on every island
error that names a form:
match expression is not available in axioma/hm (the closed Hindley–Milner island).
Hint: use if/then/else, func, and the island catalog, or #language axioma/all
A short map of v1 refusals. Each line is a program that exits 1.
| You write | The island says |
|---|---|
println(1) |
println has no known type here |
"a" + "b" |
string and array concatenation use str_concat and array_concat |
1 / 2 |
the result type of / depends on exactness |
if 1 then 2 else 3 |
an if condition must be Boolean |
[1, "a"] |
the elements of an array disagree |
none |
none is not available in axioma/hm |
x: 1 then x: 2 |
re-binding x is not available |
x: 1 then x = 2 |
reassignment is not available |
import "…" |
import is not available in axioma/hm |
concept C |
a concept form is not available |
match e with … |
match expression is not available |
axioma --vm file.ax |
--vm is not available in axioma/hm |
A name binds once. let x = 1 and
x: 1 are both legal introductions. The host’s
find-or-update = (Chapter 2, Chapter 16) is the thing the
island takes away: on the island a second write is not “update the same
cell,” it is a different program.
There is no none and no om. A missing value
is a missing binding, not a bottom that flows through arithmetic. Host
append, the knowledge base, concepts, and is
are all outside the fence.
Annotations that are on the island are Integer,
Float, String, Boolean,
Array of T, products, and arrows. Never
Array[T] — brackets are bodies. A declared return is
checked against the body:
#language axioma/hm
bad: func(n) :: String [n + 1]declares it returns String, but its body produces Integer
That is an error, not a skip — you wrote the obligation.
Use #language axioma/hm when the point of the
file is that it has a type:
map /
filter / foldl over arrays of numbers or
strings--infer to print, with exit 1 if it
cannotStay on axioma/all (or axioma/beginner)
when you need the host: println, files, the knowledge base,
concepts, match / data, none,
mutation, or mixed numeric tower behavior.
The two are not in competition. The host lens --infer
still exists and still skips. The island is for the files where a skip
would be the wrong answer.
v1 does not yet have match, algebraic data types, or
imports of other island files. Those are listed as later phases, not as
holes in the files this chapter runs.
The reference for the dialect is the Manual section
#language axioma/hm — the closed Hindley–Milner
island, and the tests under tests/axioma/hm/.
Chapter 19 encoded invariants you wrote. This chapter encodes the fragment that can be inferred. The deep idea is the same: the best way to handle a class of bugs is to make them impossible to write. The island’s move is to shrink the language until “impossible to write” is also “impossible to run.”
Three honesty rules that are easy to forget:
--infer
exiting 0 on shout is not a type of
shout.println is not “discouraged”; it has no type, so the
program is not an island program.+ does not mean “glue.” Numbers add.
Strings str_concat. Arrays array_concat. One
operator with three host meanings is exactly what a principal type
cannot say.The next files you write for this book can stay in
axioma/beginner. When you want the checker to be the
language, put #language axioma/hm on line one.
Every exercise file starts with #language axioma/hm. Do
not use println. Use expect.
Run with axioma --no-kb exercises/ch54/ex_54_N_….ax, then
axioma --no-kb --infer on the same file and read the
arrow.
id
at two typesWrite id: func(x) [x]. Check it at an Integer and at a
Boolean.
id(1) → 1id(true) → true--infer prints id :: a -> agreet with str_concatWrite greet(name) that returns "hello, " in
front of name. Do not use +.
greet("Ada") → "hello, Ada"--infer prints
greet :: String -> StringpowWith two clauses, write pow(b, n) for
n >= 0.
pow(2, 10) → 1024pow(3, 4) → 81--infer prints
pow :: (Integer, Integer) -> Integerfilter then mapFrom [-(2), -(1), 0, 1, 2, 3], keep the positives and
double them.
[2, 4, 6]filter and map from the catalog, not a
hand-rolled recursion (you already did recursion in 54.3)The starter comments four illegal programs. Do not uncomment them. Write the island-legal twins:
| Illegal | Legal twin |
|---|---|
"a" + "b" |
str_concat("a", "b") |
7 / 2 |
7 div 2 |
if 1 then 2 else 3 |
if true then 2 else 3 |
x: 1 then x: 2 |
two different names |
expect each legal result. The file must exit 0, and
--infer must not print a type error.
Write a small island program whose point is a type. Some prompts, pick one:
compose as in §54.7, plus two instantiations (numeric
and Boolean)product via foldl, and a proof by
expect that product([]) is 1apply used at two types, like id in
54.1Constraints: #language axioma/hm, no
println, every top-level function --infers (no
skip, no mismatch). The open question: what did the type make
impossible that a host file would have silently done?
A one-page (-ish) summary of the Axioma syntax used in this book. For the full reference, see Axioma Manual.
| Form | Meaning | Where it’s taught |
|---|---|---|
x: 5 |
Bind x to 5 (canonical value form) |
Ch.2 §2.2 |
x = 5 |
Same find-or-update as x: 5 (math-style spelling) |
Ch.2 §2.4 |
a, b: 1, 2 |
Multi-assignment (same form, parallel) | Ch.2 §2.5 |
x :: Integer: 5 |
Bind with type annotation | Ch.2 map; Part VI |
let x = 5 |
Fresh binding — shadows outer x; does
not update it |
Ch.2 §2.5; Ch.16 §16.x |
let x :: T = _ |
Typed hole — assign before read (error if read early) | Ch.16 §16.x |
let x :: Array = default |
Start as empty container (identity); not for Integer/String | Ch.16 §16.x |
const LIMIT = 10 |
Immutable name | Ch.2 map; manual |
rebind x = … |
Update a name that lives outside this frame (nearest cell) | Ch.16 |
global x = … |
Julia’s file / module write; may declare; honors
:: |
Ch.16 |
rank :: Ordinal: 3 |
Position-label annotation — --typecheck flags
arithmetic |
Part VI |
(x :: Float) |
Type ascription — checks; promotes Integer→Float | Part VI |
declare/persist x = 42 |
Bind that survives between sessions | Part VI |
History.
x := 5stays retired (usex: 5orx = 5). The spellinglet x = 5does parse again (August 2026), but it means fresh shadow, not the old synonym ofx: 5. For Part I preferx: 5.
| Form | Meaning | Where it’s taught |
|---|---|---|
println(x) |
Print x followed by a newline |
Ch.1 |
println(x, y, z) |
Print multiple values separated by spaces | Ch.1 |
expr . |
Trailing-dot inspect sigil — evaluates expr and prints
the result |
Ch.1 sidebar |
x: 5 . |
Bind x and print 5 in one breath |
Ch.1 sidebar |
The trailing . works at the top level of a script
and inside the bodies of loops, conditionals, and
function/lambda bodies — useful for quick “show me intermediate values”
debugging without scattering println calls. The REPL prints
results automatically and so doesn’t need either form for interactive
work.
| Form | Meaning |
|---|---|
# line comment |
Single-line comment to end-of-line |
/**md ... */ |
Literate-programming block (Markdown documentation) |
| Form | Meaning | Ch |
|---|---|---|
square: func(x) [x * x] |
Named function — the book’s canonical form | 3 |
func square(x) [x * x] |
Same thing, keyword-first | 3 |
square(x) = x * x |
Same thing, equation form (see below) | 3 |
lambda x => x * x |
Anonymous function | 11 |
f(3) |
Call | 3 |
f(3, 4) |
Multi-arg call | 3 |
3 `f` 4 |
Infix call — identical to f(3, 4), nothing to
declare |
3 |
name(args) = exprThe keyword-free spelling, and the one mathematics already uses. It
is not a separate kind of function: an all-plain-parameter equation is
the same definition as func name(args) [expr], and one with
patterns or a guard is the same as a clausal func. So
contracts, partial application, recursion, source(), and
exhaustiveness checking all behave identically, and the two spellings
may be mixed in one group of clauses.
double(x) = 2 * x # ≡ double: func(x) [x * 2]
fact(0) = 1 # clauses accumulate by name,
fact(n) = n * fact(n - 1) # tried in source order
sign(x) when x > 0 = 1 # a guard sits between head and `=`
sign(x) = 0
head2([h | t]) = h # cons, constructor, tuple, wildcard,
area(Circle(r)) = 3.14 * r * r # or- and as-patterns all work
answer = 42 # no parens ⇒ an ordinary value bindA bracket after the head is the body, exactly as in
the func spelling — one construct, one body parser, so the
two cannot come to mean different things. An array is the double
bracket:
boxed(x) = [2 * x] # the BODY ⇒ 2 * x
arrayed(x) = [[2 * x]] # the ARRAY ⇒ [2 * x]
literal(x) = ([1, 2, 3])[x] # a bracket wanted as a value: parenthesizeParameter defaults also work in the equation form:
scale(x, factor = 2) = x * factor. Typed slots and named
call arguments have their own rules; see the function chapter and the
current Manual for their full grammar. A capitalized parameter is a
nullary constructor pattern, not a binder — the same
rule func f(X) […] and match follow. And the
bare juxtaposition double x = 2 * x is a SyntaxError whose
hint names this form: calls are parenthesized in Axioma, so definitions
are too.
Works in beginner mode. Taught alongside func in
§3.5.
if test then a else b # single branch
if c1 then a else if c2 then b else c # cascade
not x # negation
x and y # short-circuit and
x or y # short-circuit or
x == y # equality
x != y # inequality
x < y, x <= y, x > y, x >= y # ordering (glyphs ≤ ≥ ≠ lex too)
0 <= x < 10 # ordering comparisons chain: ≡ (0 <= x) and (x < 10)
x += 1 # compound assignment ≡ x = x + 1 (also -= *= /=;
# statement-only; no ++/-- — those error with a hint)Taught in Ch.5.
| Form | Meaning | Ch |
|---|---|---|
[1, 2, 3] |
Array literal, 1-based | 6 |
arr[1] |
First element (NOT zero!) | 6 |
arr[2..len(arr)] |
Slice | 6 |
len(arr) |
Length | 6 |
a + b |
Array concatenation | 6 |
{1, 2, 3} |
Set literal | 10 |
{} |
Empty set ∅ (use dict() for an empty
hash) |
— |
(a, b) |
Tuple | 7, 8 |
t[1], t[2] |
Tuple element access | 8 |
| Form | Meaning | Ch |
|---|---|---|
f.(xs) |
Call f element by element |
10.11 |
f.(xs, 2) |
Reuse the scalar at every position | 10.11 |
broadcast(f, xs) |
Ordinary eager call; a fusion boundary | 10.11 |
xs .+ 2 |
Elementwise numeric addition | 10.11 |
g.(f.(xs)) |
Connected dotted calls fuse into one output walk | 10.11 |
xs.map(f) |
Collection-method spelling of map(f, xs) |
10.11 |
An ordinary pipe such as xs |> f.(_) |> g.(_)
makes separate broadcast stages; _ receives the whole
array.
Function broadcasting expands singleton axes; Array dotted arithmetic still requires equal lengths or a numeric scalar. See §10.11 for shape rules, evaluation order, and the interpreter/VM boundary.
Source files are UTF-8, so most characters can appear directly:
"hello"
"naïve façade"
"math: ∀ x ∃ y. x + y = 0"
"greek: λ φ Ω"For characters that resist direct typing, escape sequences decode at parse time:
| Escape | Result | Where it’s taught |
|---|---|---|
\n |
LF (newline) | Ch. 2 sidebar |
\t |
TAB | Ch. 2 sidebar |
\r |
CR | Ch. 2 sidebar |
\\ \" \' \0 |
literal \, ", ', NUL |
Ch. 2 sidebar |
\u{H...H} |
Unicode codepoint (1–6 hex digits, full range) | here |
chr(N) / ord(s) |
codepoint ↔︎ string round-trip | here |
"line1\nline2" # → 2 lines
"\u{2203}" # → ∃ (BMP, 4 hex)
"\u{1D54A}" # → 𝕊 (supplementary plane, 5 hex)
"\u{1F600}" # → 😀
chr(8707) # → "∃"
ord("∃") # → 8707Unlike Python’s "\uXXXX", Axioma uses the braced
\u{...} form (Rust / Swift / ES6 / Lua / Dart). It handles
all 1,114,112 Unicode codepoints in one syntax, with no special
treatment for “supplementary plane” characters like 𝕊 and 😀.
r"..." and
r'...'The r prefix turns off escape decoding. Use it for
paths, regex patterns, and any genuinely-literal content:
winpath: r"C:\Users\alice\new" # literal Windows path
regex_text: r"\d+\.\d+" # literal regex
r'no \n decode here' # single-quoted form also worksUnknown escapes ("\d", "\s", etc.) stay
verbatim in regular strings too, so you only need r"..."
when you have lots of backslashes or want byte-for-byte source
match.
# --- core forms ---
[x * 2 | x <- xs] # list comp (eager, ordered, dups kept)
{x * 2 | x <- xs} # set comp (eager, unordered, dedup)
{k: v | x <- xs} # dict comp (eager, hash result)
(x * 2 | x <- xs) # generator (lazy, pull-based)
# --- Python pipe-less form works in all four ---
[x * 2 for x in xs if x > 0]
{x * 2 for x in xs if x > 0}
{x: x * x for x in xs}
(x * 2 for x in xs if x > 0)
# --- walrus binding inside a clause (the canonical form) ---
{y | x <- xs, y: f(x), y > 0} # REBOL `:` (the only remaining form)
# --- multi-generator and multi-filter ---
{x + y | x <- xs, y <- ys} # Cartesian product
{x | x <- xs, x > 1, x < 10, x != 5} # filters fold with `and`
# --- ISO/British set-builder spellings ---
{x : x <- xs, x > 1} # `:` separator (British) — same AST as `|`
{x : x ∈ xs, x > 1} # `∈`/`in` as generator, gated: target must be
# unbound AND occur in the head; otherwise
# `x in s` stays a membership FILTER
{(x, y) : x ∈ xs, y ∈ ys} # the textbook Cartesian form, verbatim
# --- Axioma-only: KB and concept integration ---
{X | X <- Country} # bare-concept extent
{X | X is Country, X.gdp > 75} # is-comprehension
{(X, Y) | parent(X, Y)} # relation tuple query
{G | P <- parent("alice", P), G <- parent(P, G)} # variable-chain unification
{X @theorem | X <- ancestor(X, _)} # tag-filter by epistemic grounding
# --- lazy generator drivers ---
force(gen) # → array (materialise all)
gen_take(n, gen) # → array (first n)
gen_drop(n, gen) # → gen (advance, mutates)
gen_next(gen) # → elem | Ω
# --- set operators ---
A ∪ B # union
A ∩ B # intersection
A - B # difference
x in A # membership
A subset B # ⊆ (inclusive)
A superset B # ⊇ (inclusive)
A subsetneq B # ⊂ (proper)
A supsetneq B # ⊃ (proper)
# --- typing the glyphs ---
C `sqcap D # backtick digraph: lexes AS ⊓ — the DL pair ⊓ ⊔
# has no word form; the digraph is its only
# pure-ASCII spelling
2 `in {1, 2, 3} # twin-having glyphs work too: `in ≡ ∈ ≡ in
{1} `cup {2} # `cup → ∪ (LaTeX names), `union → ∪ (Axioma names)
# a digraph is UNPAIRED. A matched pair is the unrelated infix-call form:
cup(a, b) = a * 1000 + b # `cup as a glyph AND cup as a function,
2 `cup` 3 # → 2003 — no interference, ever
# axioma --glyphify file.ax # canonicalize a file: in → ∈, superset → ⊇, and → ∧
# axioma --asciify file.ax # the inverse (word-less glyphs stay as typed)
# REPL/playground: \in + Tab → ∈; editors: \in completion via axioma-lspTaught in Ch.10 (general comprehensions, walrus, dict, lazy) and Ch.21 (relational and concept-extent comprehensions). Glyph input layers: manual §3 “Typing the glyphs”.
concept Stock # declare concept (single canonical surface)
Stock has price: 0 # add slot with default
Stock has ticker: ""
concept Asset # parent concept
Stock extends Asset # specialize (auto-creates child if needed)
# Block form: declare with initial slot defaults in one shot
concept TextbookBond {
yield: 0.05
coupon: 0.0
}
# Refinement + doc string (the postfix slots)
concept/persist TreasurySec "A marketable US Treasury security"
# Name-inference: anonymous concept on RHS picks up name from LHS
Widget: concept { price: 0, ticker: "" }
# Instance creation — `a` / `an` based on vowel sound; `entity` is a synonym
aapl: a Stock { price: 180, ticker: "AAPL" }
concept Insect
Insect has legs: 6
ant: an Insect { legs: 6 }
aapl.price # property access
aapl's price # possessive (same thing)
aapl is Stock # runtime type test
aapl is Asset # inheritance test (true)
# --- Description Logic concept algebra (a tableau reasoner) ---
concept Person
concept Worker
Student extends Person
Worker extends Person
Student ⊓ Worker # → ConceptExpr (conjunction; also `C `sqcap D`)
Student ⊔ Worker # → ConceptExpr (disjunction; also `C `sqcup D`)
¬Student # → ConceptExpr (complement; `not` on a concept)
Student ⊑ Person # → true (subsumption: left is the SUBconcept)
Person subsumes Student # → true (English word = the converse direction)
(Student ⊓ Worker) ⊑ Person # → true (derived through the tableau + `extends`)
Student ≡ Worker # → false (equivalence = mutual subsumption)
satisfiable(Student ⊓ ¬Student) # → false (the complement clashes)
# --- DL role restrictions (∃R.C / ∀R.C — roles are native relations) ---
relation advises(x, y)
∃advises: Student # → ConceptExpr (someone who advises some Student)
∀advises: Student # → ConceptExpr (advises only Students)
satisfiable((∃advises: Student) ⊓ (∀advises: ¬Student)) # → false
# --- a partition becomes real disjointness + covering ---
Person partition Student, Worker
satisfiable(Student ⊓ Worker) # → false (now disjoint)
(Student ⊔ Worker) ≡ Person # → true (covering)
# --- extensional membership: `x is <ConceptExpr>` (closed-world) ---
amy: a Student {}
amy is (Student ⊓ ¬Worker) # → true
# --- defined concepts (GCIs): necessary-and-sufficient `concept X ≡ expr` ---
concept Advisor ≡ ∃advises: Student # (the word `equivalent` also defines)
Advisor ⊑ (∃advises: Student) # → true (from the definition)
# --- Thing (⊤) / Nothing (⊥): built in, no declaration needed ---
satisfiable(Nothing) # → false Person ⊑ Thing # → true
amy is Thing # → true (⊤ holds of every individual)
# --- three spellings of a role restriction (all build the same value) ---
(∃advises.Student) ≡ (∃advises: Student) # → true DOT spelling
(advises some Student) ≡ (∃advises: Student) # → true Manchester ∃
(advises only Student) ≡ (∀advises: Student) # → true Manchester ∀
# --- a compound concept as a comprehension SOURCE (⊓/⊔) ---
{x | x <- (Person ⊓ ¬Worker)} # persons who aren't workers
# {x | x <- ¬Worker} → error: no finite extent; use FILTER `{x | x <- C, x is ¬Worker}`Taught in Ch.8, 13, 18.
Retired May 2026:
Stock is Concept(as a creation form),create Stock,Stock create,Stock exists, andobject Stock {...}no longer create concepts/instances. Migrate toconcept Stockanda Stock {...}respectively. Theiskeyword is reserved for instance classification (aapl is Stock) and Boolean predication queries (x is Stockin expression position).
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
Digit ranges 0..9
Workday extends Day in Mon..FriTaught in Ch.19.
relation parent(child, p) # declare
parent("alice", "mary") # store fact
{Y | Y <- parent("alice", Y)} # query
# Strict rule (Horn-clause backward chaining) — primary natural spelling
grandparent(X, G) whenever parent(X, P) and parent(P, G)
# also: head if body (gated); operator twins <== (legacy <=) and Prolog :-
# Defeasible rule + cancellation (typically … whenever ≡ normally ≡ rule~ … if ≡ <~~)
typically flies(X) whenever bird(X)
cancel("flies", "opus")
# Provenance / introspection
grounding("flies", "tweety") # returns "conjecture"
proof("flies", "tweety") # derivation chain
why flies("tweety") # prose explanationTaught in Ch.21.
om # K3 unknown (also Ω) — untyped
⊤ᵏ ⊥ᵏ ?ᵏ # K3 literals (≡ kleene("true"|"false"|"unknown"))
⊤ł ⊥ł ½ł # Ł3 literals (≡ lukasiewicz(1.0|0.0|0.5))
⊤ᵇ ⊥ᵇ ⊤⊥ᵇ ?ᵇ # B4 literals (≡ belnap(...); ⊤⊥ᵇ = glut, ?ᵇ = gap)
⊤ⁱ ⊥ⁱ ?ⁱ # G3 literals (≡ intuit3(...))
`belboth `klunknown `lhalf # ASCII digraphs (also `glut / `gap)
Belnap.both Belnap.values # dotted members + each logic's domain array
lukasiewicz(0.73) # constructor — any L3 value in [0,1]
# Operators auto-dispatch on type
true and om # Ω (K3)
lukasiewicz(0.6) or lukasiewicz(0.3) # 0.6 (L3 max)
⊤⊥ᵇ and ⊤ᵇ # ⊤⊥ᵇ
match ⊤⊥ᵇ with | ⊤⊥ᵇ => "glut" | _ => "other" # literals are match patterns
# Truthiness = designation: if ⊥ᵇ / ?ᵏ / ½ł → else-branch; the glut ⊤⊥ᵇ is truthy.
# `==` is Boolean metalanguage equality (belnap("true") == "true"); `iff` is the
# object-language biconditional. Junk operands error: belnap("true") and "banana".Taught in Ch.20.
x: 0
while (x < 10) [
x = x + 1
]
for v in [10, 20, 30] [ println(v) ] # foreach ≡ for — one keyword, two spellings
for i in 1..5 [ println(i) ] # ranges iterate in their own order
# (5..1 descends, 1..9 by 2 steps)
for v at i in xs [ println(i, v) ] # 1-based counter; equivalents:
# `for v in xs with index i` and
# `for i, v in xs.indexed`
foreach n in 1.. [ # open-ended range — loop until break
if n > 9 then break # bare `break` in a branch ≡ `then [break]`
]
foreach [x, y] in pairs [ println(x, y) ] # destructuring; `(x, y)` and a bare
foreach x, y in pairs [ println(x, y) ] # `x, y` are the same thing
repeat 5 [ println("hi") ] # `repeat` ≡ `loop`: count only …
repeat i <- xs [ println(i) ] # … bound variable over a source …
repeat i 3 [ println(i) ] # … the older no-arrow spelling …
repeat xs [ println("tick") ] # … or a bare source, no variable
repeat [n < 3] [ n = n + 1 ] # PRE-test block condition
repeat [ n = n + 1 ] until n >= 3 # POST-test — body runs at least once
r: repeat i <- 1..3 [ println(i) ] # `repeat` is an expression too
repeat i <- 1..10 by 3 [ println(i) ] # `repeat … <-` reads the same headers
repeat i <- 10..1..-3 [ println(i) ] # `by` = magnitude, `..n` = SIGNED
repeat i <- 1.. [ if i > 3 then break ]
for v in {3, 1, 2} [ println(v) ] # a set — ONE canonical order
for v in bag([1, 1, 2]) [ println(v) ] # a bag — a multiset, WITH multiplicity
for k, v in items(d) [ println(k, v) ] # a dictionary, destructured
for v in (n * n | n <- 1..) [ # a lazy stream is a loop source too:
if v > 20 then break # pulled one element at a time,
] # never materialized
for i in iterate(func(v) [v * 2], 1) [ # a counter with an arbitrary step,
if i >= 20 then break # still scoped to the loop
]A loop scopes what it binds:
for/foreach and the source forms of
repeat own their variable, so it does not outlive the loop.
while and the condition-driven repeat forms
bind nothing — that counter is yours and survives. The full inventory,
every form asserted, is tests/axioma/showcase/loops.ax.
The body is always a bracket block — there is no do
keyword (for ... in ... do ... is not Axioma).
break and continue work in every loop form,
including every repeat form. In branch position any bare
statement may be a branch — break,
continue, return, or a binding — so
if c then break and if c then a: 0 are sugar
for the block forms if c then [break] /
if c then [a: 0], in either arm. A binding branch scopes a
fresh name to the block just as the brackets do; an existing
name is updated. There is no postfix guard for the loop pair:
break if c is a SyntaxError (that form is
return-only).
Taught in Ch.16; open-ended ranges in §17.7.
parse("2 + 3") # string → AST
quote(2 + 3) # capture without eval
ast_eval(parse("2 + 3")) # 5
ast_type(parse("2 + 3")) # "InfixExpression"
fullform(parse("2 + 3")) # "+(2, 3)"
headof(quote(if x then a else b)) # "If"
argsof(quote(2 + 3)) # ["2", "3"]Taught in Ch.22.
Identifiers that look available but aren’t:
A / E — no longer
reserved (the ∀/∃ quantifier shorthand was retired June 2026);
now ordinary names. Quantify with forall /
exists or ∀ / ∃fn — lambda keyword (use callee,
f, func_node)take, from, to,
contains, count, next,
direct, diff, _ — reserved or
builtin_ in patterns means “don’t care,” but cannot
be used as a variable name.The keywords() builtin lists the full reservation
roster; keywords("name") checks a single word.
#language dialects#language axioma/NAME (or
--language axioma/NAME) selects a dialect.
Bare #language axioma is not a name — the checker lists the
eight known ones and exits. --mode functional|logic|stack|…
is a different switch (an educational overlay on the host). Do not
confuse the two.
| Dialect | What it is | Taught |
|---|---|---|
axioma/all |
Default host. Full surface. | The book after beginner; write it when a file must not be beginner |
axioma/beginner |
Real gate: canonical forms + beginner overlay | Ch.1–17, README |
axioma/hm |
Real closed island: principal types, fatal | Ch.54 |
axioma/knowledge-core |
Real AST allowlist: monotonic Horn proof-core | Ch.21 §21.11 |
axioma/knowledge |
Mild helper-name gate (insert refused;
retract still runs). Not an alias of the core |
Ch.21 §21.11 (contrast) |
axioma/rpn |
Additive Forth return stack (rpush/rpop…).
Infix still runs |
Ch.41 §41.9 |
axioma/core |
Stub — known name, no AST gate, runs as host | not taught (would be a silent dual meaning) |
axioma/functional |
Stub, same | not taught |
#language axioma/beginner # restrict surface forms
#language axioma/hm # closed Hindley–Milner island
#language axioma/knowledge-core # monotonic proof-core
#language axioma/rpn # Forth return stack (additive)
#language axioma/all # full host (opt out of beginner)Choose one directive for a file. The following are terminal commands, not statements inside that file:
axioma --typecheck script.ax # static pre-pass
axioma --infer script.ax # print HM arrows (fatal on axioma/hm)
axioma --no-kb script.ax # skip Cascade KB load
axioma --learn # interactive tutor
axioma --recipe # Design Recipe wizardTaught in Ch.1, Ch.4, Ch.16 (axioma/all), Ch.21
(knowledge-core), Ch.41 (rpn), Ch.54
(hm), and the README.
This appendix is a lookup card, not a tutorial. If a form puzzles you, jump to its origin chapter (the rightmost column) for the surrounding pedagogy. For the canonical full reference, the Axioma Manual covers every form in the language, including ones this book deliberately skipped (CG, modal logic, Cascade integration, the stack-based subsystem).
A condensed reference for the six-stage recipe taught in detail in Chapter 4 and applied throughout the rest of the book. Print this page and pin it to the wall while you work.
1. Data definition — what shape are the inputs?
2. Signature — name, input types, output type
3. Examples — concrete cases first, before code
4. Template — code skeleton driven by the data shape
5. Body — fill in the template
6. Tests — run the examples; revise if any fail
Decide what your data is before you decide what your function does. For each input:
Integer / Float / a
subrange like Digit ranges 0..9.Status enumerates Active, Idle, Done.concept Point { x: 0, y: 0 }.The data definition drives the rest of the recipe.
A comment line above the function:
# distance: (Float, Float, Float, Float) -> Float
distance: func(x1, y1, x2, y2) [ ?? ]Two purposes:
:: T annotations and the
--typecheck pre-pass.Write at least one concrete input → output pair for each
interesting case in the data definition. Each case at
the data-definition level deserves at least one example.
# distance(0, 0, 3, 4) = 5.0 (3-4-5 triangle, classic)
# distance(0, 0, 0, 0) = 0.0 (degenerate, same point)
# distance(1, 1, 4, 5) = 5.0 (off-origin, still 3-4-5)Examples become tests in Stage 6. Writing them first forces you to know what “correct” means before you write code that might be correct.
A skeleton matching the shape of the data. The same data shape always produces the same template.
| Data shape | Template skeleton |
|---|---|
| Single value | func(x) [ ... x ... ] |
| Enum / case-distinction | if x == case1 then ... else if x == case2 then ... else ... |
| Concept with slots | func(p) [ ... p.slot1 ... p.slot2 ... ] |
| Array (recursive) | func(arr) [ if len(arr) == 0 then BASE else COMBINE(arr[1], recur(arr[2..len(arr)])) ] |
| Tree (Concept) | func(t) [ if t is Leaf then ... else COMBINE(t.value, recur(t.left), recur(t.right)) ] |
| Mutually recursive data | one helper per data type, each calling the others |
The template is the contract between the data shape and the function shape. If you’re stuck filling in the body, the template is usually wrong — go back to Stage 1.
Fill in the template’s ... with the actual computation.
The earlier stages guide this step, but design decisions can require
revisiting them. For the distance example, the formula gives:
distance: func(x1, y1, x2, y2) [sqrt((x2-x1)^2 + (y2-y1)^2)]Two heuristics when stuck:
f(2) = 4, f(3) = 9, f(4) = 16,
x * x is one candidate. Check the stated purpose and
additional cases: finitely many examples do not determine a unique
function.Turn each example into a runnable assertion:
println(distance(0, 0, 3, 4)) # 5.0
println(distance(0, 0, 0, 0)) # 0.0
println(distance(1, 1, 4, 5)) # 5.0Or with the expect builtin (label, actual, expected —
reports a mismatch and fails the run on a miss):
expect("3-4-5 triangle", distance(0, 0, 3, 4), 5.0)
expect("same point", distance(0, 0, 0, 0), 0.0)
expect("off-origin", distance(1, 1, 4, 5), 5.0)Or as doctests (Ch.4 §Stage 6): in a
```axioma doctest fence, each Stage-3 example line
is the test — # → and the plain-keyboard ASCII
# -> are equivalent arrow spellings, and
# raises: asserts an error. Run with
axioma --doctest FILE or the Playground’s ✓ Doctest
button:
distance: func(x1, y1, x2, y2) [ sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2) ]
distance(0, 0, 3, 4) # → 5.0
distance(0, 0, 0, 0) # -> 0.0
distance(1, 1, 4, 5) # → 5.0Run them. If any fail, the bug is in the body (most common), the template (less common), or the data definition (rare, but most expensive to fix).
For logic-programming code, replace stages 1–4 with:
1. Schema — relation name(arg1, arg2, ...)
2. Fact base — write the ground facts
3. Rules — strict (<=) or defeasible (<~~)
4. Queries — set comprehensions over the fact + rule base
5. Verify — print each query; hand-walk against the data
For interpreters / AST walkers, the data definition is the grammar of the small language you’re interpreting:
1. Grammar — one Concept per AST node type
2. Signature — eval: (Node, Env) -> Value
3. Examples — small programs with known results
4. Template — one branch per Concept; recurse on children
5. Body — per-Concept dispatch
6. Tests — run the example programs end-to-end
The whole point of the recipe is to resist the temptation to start with the body. Beginning programmers write code, then try inputs, then patch what breaks. The recipe inverts that: define data, then signature, then examples, then template, then body, then tests. The bugs catch themselves at the stage where they’re cheapest to fix.
This single discipline — Data-Driven Design — is the most durable skill the book teaches.
“A programmer is ideally an essayist who works with traditional aesthetic and literary forms as well as mathematical concepts.” — Structure and Interpretation of Computer Programs, 1985.
These are one set of answers for each exercise. Yours may differ — in particular, exercises with “open” in the title have many right answers. The commentary points to what’s worth noticing in each.
println(2 * 3.14159 * 7)Result: 43.98226.
Commentary. The order of multiplication doesn’t
matter, so 2 * 7 * 3.14159 and 3.14159 * 7 * 2
give the same answer. You’d write the version that reads most clearly to
you.
2 ^ 16 = 65536 # exact
2 ^ 32 = 4294967296 # exact
2 ^ 64 = 18446744073709551616 # exact — beyond a 64-bit integer
Commentary. Axioma integers are arbitrary
precision: a value grows as many digits as it needs, so
supported computations can preserve exact answers — 2 ^ 64,
2 ^ 128, even 2 ^ 200 (a 61-digit number) come
back exact. Available memory and runtime resource limits still bound
computations; arbitrary precision means that arithmetic is not
restricted to one fixed machine-word width.
Where exactness does end is floats: 2.0 ^ 64
prints 1.8446744073709552e19, a Float in scientific
notation. This particular power of two is exactly representable, but
nearby integers need not be: binary64 has 53 bits of significand
precision, roughly 15-17 significant decimal digits. So the boundary to
watch is not “is my number too big?” but “did a float sneak into my
integer arithmetic?” — the §1.5 rule again: floats are contagious;
integers and rationals are exact.
Try. Compute 2 ^ 200, then
(2 ^ 64) * (2 ^ 64) == 2 ^ 128, then 2.0 ^ 64.
Which results are exact, and why?
| Expression | Answer | Why |
|---|---|---|
(2+3) * (4-1) |
15 |
parens first, then multiplication |
10 - 2 * 3 |
4 |
* before -, so 10 - 6 |
2 ^ 3 ^ 2 |
512 |
Axioma’s ^ is right-associative, so
2^(3^2) = 2^9 = 512 |
(2 ^ 3) ^ 2 |
64 |
parens force left-grouping: 8^2 = 64 |
100 / 7 |
100/7 |
division is exact — an uneven integer quotient is a Rational fraction, not a rounded integer |
100 % 7 |
2 |
the remainder of whole-number division (100 div 7 gives
the 14) |
100.0 / 7 |
14.285714285714286 |
a float operand makes it float division — the decimal approximation
of 100/7 |
Commentary. Mathematicians and most calculators
treat ^ as right-associative:
2^3^2 means 2^(3^2) = 2^9 = 512. Axioma
follows that convention — 2 ^ 3 ^ 2 is 512.
Python uses ** and is right-associative for the same reason
(2**3**2 = 512); Axioma’s ** agrees too.
Note this makes ^ the exception among the
arithmetic operators: +, -, *,
and / are all left-associative. If you want the left
grouping for exponentiation, parenthesize: (2 ^ 3) ^ 2
gives 64.
Notice that 100 div 7 = 14 and 100 % 7 = 2
together tell you the full story: 7 × 14 + 2 = 100. This is
the division algorithm, and it’s the reason modulo exists.
(Plain / stays out of it: 100 / 7 is the exact
fraction 100/7.)
| Expression | Answer |
|---|---|
12 % 3 |
0 |
12 % 4 |
0 |
12 % 5 |
2 |
(2^10) % 7 |
2 |
Commentary. 12 % n is 0
exactly when n divides 12 evenly. 3 and 4 are
divisors of 12 (12 = 3 × 4 = 4 × 3 = 6 × 2 = 12 × 1); 5 is
not.
The last one — (2^10) % 7 — is
1024 % 7 = 2. It’s worth checking by hand:
7 × 146 = 1022, so 1024 - 1022 = 2. ✓
| Expression | Result | What happened |
|---|---|---|
"5" + 3 |
"53" |
int → string, then concatenated |
5 + "3" |
"53" |
int → string on the left, then concatenated |
5 - "3" |
ERROR | - has no string interpretation |
5 * "3" |
"33333" |
repetition — Python-style |
5 / "3" |
ERROR | / has no string interpretation |
"hi" * 4 |
"hihihihi" |
repetition |
"hi" / 4 |
ERROR | division on a string is meaningless |
"hi" - "bye" |
ERROR | subtraction between strings has no meaning |
Rule (one possible reading). Axioma “tries” to interpret an operator when it has a sensible string interpretation:
+ becomes concatenation* becomes repetition (one side must be an integer)Anything else — -, /, %,
^ — errors when either side is a string. The rule isn’t
perfectly clean (you could argue "abc" * 0 should be
"", which it actually is), but the take-home is to check
the documented operand types. An unsupported combination raises an
error.
One answer (of infinitely many):
println((100 - 50 + 30 * 2) / 5 % 7 ^ 2)Evaluation: 100 - 50 + 60 = 110, then
110 / 5 = 22, then 22 % 49 = 22. Result:
22. Between 0 and 100. ✓
A structurally different second answer:
println(2 ^ 5 * 3 - (10 / 2) % 4 + 1)Evaluation: 32 * 3 - 5 % 4 + 1 = 96 - 1 + 1 = 96.
Result: 96.
Commentary. The point of this exercise isn’t to find the right answer — it’s to gain comfort building larger expressions with confidence about what they’ll evaluate to. If yours evaluated to something between 0 and 100 the first time, you understood the material.
Don’t read them before attempting the exercises. The point of the exercise is the predict step before you check — and you only get the benefit of the predict step if you do it yourself first.
After you’ve answered an exercise, then compare. If yours is correct, ask: was my reasoning the same as the solution’s reasoning? If yours is different but still correct, ask: which is clearer to a future reader?
#language axioma/beginner
pi: 3.14159
radius: 7
diameter: 2 * radius
circumference: 2 * pi * radius
area: pi * radius * radius
area_bigger: pi * 8 * 8
println("radius =", radius)
println("diameter =", diameter)
println("circumference =", circumference)
println("area =", area)
println("area_bigger =", area_bigger)Output:
radius = 7
diameter = 14
circumference = 43.98226
area = 153.93791
area_bigger = 201.06176
Commentary. Notice how each binding reads as a short
declarative sentence. The area_bigger line uses literal
8s; a cleaner version would bind
radius_bigger = radius + 1 and reuse it. Both are
correct.
#language axioma/beginner
x1: 0
y1: 0
x2: 3
y2: 4
dx: x2 - x1
dy: y2 - y1
println(sqrt(dx ^ 2 + dy ^ 2))Output: 5.0.
Commentary. The 3-4-5 right triangle is one of the
famous integer solutions to the Pythagorean theorem. If you predicted
5, you’ve internalized what the formula means.
You can write the whole thing without intermediate bindings:
println(sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2))That works too. Which version is clearer to you? There’s no single
right answer — it depends on whether dx and dy
mean something on their own. If you were also going to use
dx somewhere else, naming it would pay off. Here, naming it
is just clarity.
#language axioma/beginner
weight: 70.0
height: 1.75
height_squared: height * height
bmi: weight / height_squared
println(bmi)Output: 22.857142857142858.
Commentary. The arithmetic is mundane. The one-line form
bmi: weight / (height * height)works too; naming the denominator is a readability choice —
height_squared says why the multiplication is
there.
#language axioma/beginner
a: 7
b: 2
println(a / b) # 7/2 — exact rational division
println(a + 0.0) # 7.0 — promoted to Float
a_float: a + 0.0
println(a_float / b) # 3.5 — float / int gives floatOutput:
7/2
7.0
3.5
Commentary. Three numeric types in three lines.
First, a / b = 7/2 — both operands are integers, so the
division is exact and the uneven quotient is a Rational
fraction. Same as in Chapter 1.
Second, a + 0.0 prints 7.0: adding a Float
produces a Float. The third result, 3.5, is also a Float.
You can check either directly with type(a_float) or
type(a_float / b).
axioma> n: 1
axioma> n: n + 1
axioma> n
2
Commentary. n becomes 2.
Axioma evaluated the right-hand side first
(n + 1 = 1 + 1 = 2), then rebound n to that —
with no warning of any kind. (n = n + 1 would have done
exactly the same thing; the two spellings are synonyms.)
The silence is precisely what makes this dangerous in a long program:
the previous value of n is gone, and nothing told
you. If five lines down you wrote code that assumed n was
still 1, it now sees 2 and you’ll have a hard
time figuring out why — the interpreter won’t point at the rebinding
line for you. The protection has to be a habit, not a diagnostic: give
new values new names (n2: n + 1) unless you mean
to update, and save deliberate updates for Chapter 16. Naming hygiene
pays compounding interest.
There’s no single right answer here. One version:
#language axioma/beginner
bill_total: 42.50
tip_percent: 20
tip: bill_total * tip_percent / 100
total: bill_total + tip
println("tip: $", tip)
println("total: $", total)Output:
tip: $ 8.5
total: $ 51
Commentary. The point of the exercise is the
naming — would you call it bill_total and
tip_percent and tip and total? Or
something else? Try reading your version aloud. If it sounds like
English, you’ve named well.
Notice that total came out as 51 (no
.0), but it is a float because tip is
a float. Same display rule from §2.6.
Don’t read them before attempting the exercises. The predict step is the whole point. After predicting and checking, ask: was my reasoning the same as the solution’s? If yours is different but also right, you may have found a cleaner version. That’s the goal.
#language axioma/beginner
pi: 3.14159
circle_area: func(radius) [pi * radius * radius]
println(circle_area(1))
println(circle_area(5))
println(circle_area(100))Output:
3.14159
78.53975
31415.9
Commentary. Each call binds radius to a
new value, and the body runs with that binding. The function does not
“remember” anything between calls — pi is shared because it
lives in the outer scope, but each call gets a fresh
radius.
#language axioma/beginner
f_to_c: func(f) [(f - 32) * 5.0 / 9.0]
println(f_to_c(32))
println(f_to_c(212))
println(f_to_c(98.6))Output:
0
100
37
Commentary. The third line is the famous
body-temperature conversion. The output is 37 (not
37.0) because of Axioma’s trailing-.0
stripping — the value internally is 37.0.
Why 5.0 / 9.0 and not 5 / 9? Not for
correctness — 5 / 9 is the exact rational
5/9, so the integer spelling gives the same three answers
here (0, 100, 37; try it). The
float literals are a signal: temperatures are measurements, and
writing 5.0 / 9.0 says “this function lives in
decimal-land” — any Fahrenheit input, integer or not, comes back a float
instead of a surprising exact fraction like 170/9 for
f_to_c(66). Chapter 1’s rule: floats are contagious,
rationals are exact.
#language axioma/beginner
bmi: func(weight, height) [
h2: height * height
weight / h2
]
println(bmi(70.0, 1.75)) # 22.857... — normal weight
println(bmi(85.0, 1.80)) # 26.234... — overweight
println(bmi(55.0, 1.65)) # 20.202... — normal weightCommentary. The same style as Exercise 2.3 — we name
height * height as h2 for readability; the
one-line weight / (height * height) works too. The function
body has two statements; only the last one
(weight / h2) is the return value.
#language axioma/beginner
tip: func(bill, percent) [bill * percent / 100]
println(tip(50.0, 20)) # 10.0
println(tip(100.0, 15)) # 15.0
println(tip(42.50, 18)) # 7.65Commentary. Note that percent here is
an integer like 20, not a fraction like 0.20.
The function divides by 100 to compensate. Either design
works; whichever you pick, be consistent and write that choice
into the parameter name. percent already suggests “out of
100”; a parameter named tip_rate would suggest a
fraction.
#language axioma/beginner
is_even: func(n) [n % 2 == 0]
println(is_even(4)) # true
println(is_even(7)) # false
println(is_even(0)) # trueCommentary. This is your first function that
returns a Boolean — true or false,
not a number. Boolean-returning functions are the workhorse of Chapter 5
(conditionals): is_even, is_positive,
is_leap_year, is_palindrome, and so on.
A common beginner mistake here is to write
if n % 2 == 0 then true else false. That works, but it’s
redundant — the expression n % 2 == 0 is already
either true or false. Use the simpler
form.
#language axioma/beginner
monthly_pay: func(hourly_rate, hours_per_week) [
weeks_per_month: 4.33
hourly_rate * hours_per_week * weeks_per_month
]
println("$", monthly_pay(25.0, 40)) # $ 4330.0
println("$", monthly_pay(40.0, 20)) # $ 3464.0Commentary. Many right answers. The point is the
naming. If a future reader (including future-you) can guess
what monthly_pay does without reading the body, you’ve
named well.
weeks_per_month = 4.33 is an approximation (there are
about 4.33 weeks in an average month). The choice to name it — instead
of inlining 4.33 — is the same instinct as naming
pi: a constant deserves a name even if it only appears
once.
Don’t peek. Predict each output before checking, even when you’re sure. The compounding payoff of the predict-then-check habit is the single biggest thing you can do for your programming skills this term.
Each solution shows all six stages. The commentary calls out the specific stage where most students get stuck.
#language axioma/beginner
# Data: any number (Integer or Float)
# Signature: triple: Integer -> Integer
# Examples:
# triple(0) = 0
# triple(5) = 15
# triple(-7) = -21
# Template:
# triple: func(n) [
# ... n ...
# ]
# Body:
triple: func(n) [n * 3]
# Tests:
println(triple(0)) # 0
println(triple(5)) # 15
println(triple(-7)) # -21Commentary. Stage 3 (Examples) is trivial here, but write it anyway. The habit matters more than the difficulty.
#language axioma/beginner
# Data: price (Float, the original price); percent_off (Float, like 25 for 25%)
# Signature: discount: Float Float -> Float
# Examples:
# discount(100.0, 25) = 75
# discount(50.0, 50) = 25
# discount(99.99, 10) = 89.991
# Template:
# discount: func(price, percent_off) [
# ... price ... percent_off ...
# ]
# Body:
discount: func(price, percent_off) [
savings: price * percent_off / 100
price - savings
]
# Tests:
println(discount(100.0, 25)) # 75.0
println(discount(50.0, 50)) # 25.0
println(discount(99.99, 10)) # 89.991Commentary. Note the intermediate
savings: ... inside the body. We could inline it
(price - price * percent_off / 100), and that would work —
but naming the intermediate value makes the function read aloud as “the
discounted price is the price minus the savings”, which is what
we mean. Naming intermediates is the single biggest readability lever in
Part I.
#language axioma/beginner
# Data: dollars (Float)
# Signature: dollars_to_cents: Float -> Integer
# Examples:
# dollars_to_cents(1.50) = 150
# dollars_to_cents(0.99) = 99
# dollars_to_cents(42.42) = 4242
# Template:
# dollars_to_cents: func(dollars) [
# ... dollars ...
# ]
# Body:
dollars_to_cents: func(dollars) [int(dollars * 100)]
# Tests:
println(dollars_to_cents(1.50)) # 150
println(dollars_to_cents(0.99)) # 99
println(dollars_to_cents(42.42)) # 4242Commentary. The signature explicitly says the output
is Integer, even though the input is Float —
that asymmetry is the whole point of the exercise. The body uses
int(...) to convert Float→Integer.
You could also use round(...) instead of
int(...). They behave differently for non-integer-cents
inputs (e.g., what should dollars_to_cents(0.005) be — 0 or
1?). The Design Recipe’s examples stage is where this
question would have surfaced. If you listed
dollars_to_cents(0.005) as an example and committed to an
answer, you’d know whether int or round was
right.
#language axioma/beginner
# Data: width and height (both Floats; could be Integer too)
# Signature: rectangle_perimeter: Float Float -> Float
# Examples:
# rectangle_perimeter(3, 4) = 14
# rectangle_perimeter(10.0, 5.5) = 31
# rectangle_perimeter(0, 7) = 14
# Template:
# rectangle_perimeter: func(width, height) [
# ... width ... height ...
# ]
# Body:
rectangle_perimeter: func(width, height) [2 * (width + height)]
# Tests:
println(rectangle_perimeter(3, 4)) # 14
println(rectangle_perimeter(10.0, 5.5)) # 31.0
println(rectangle_perimeter(0, 7)) # 14Commentary. Why is the third test 14
and not 7? Because the perimeter is
2 * (0 + 7) = 14 — a degenerate rectangle whose width is
zero is really a line segment of length 7, traversed twice
(there-and-back). Edge cases like this are exactly why we write examples
before the body.
#language axioma/beginner
# Data: an integer or float
# Signature: is_positive: Float -> Boolean
# Examples:
# is_positive(5) = true
# is_positive(0) = false <-- decision: is zero "positive"? No.
# is_positive(-3) = false
# Template:
# is_positive: func(n) [
# ... n ...
# ]
# Body:
is_positive: func(n) [n > 0]
# Tests:
println(is_positive(5)) # true
println(is_positive(0)) # false
println(is_positive(-3)) # falseCommentary. Stage 3 is not trivial here. Is
zero positive? Different mathematicians have different conventions; you
have to pick one. The examples force you to commit. In our solution we
said “strictly positive”, so is_positive(0) = false. If
you’d said non-negative, your body would be n >= 0 and
Stage 3 would have shown is_positive(0) = true. Either is
right; consistency matters.
split_bill)#language axioma/beginner
# Data: total (Float) and number of diners (Integer >= 1)
# Signature: split_bill: Float Integer -> Float
# Examples:
# split_bill(60.0, 3) = 20
# split_bill(100.0, 4) = 25
# split_bill(99.99, 3) = 33.33
# Template:
# split_bill: func(total, n) [
# ... total ... n ...
# ]
# Body:
split_bill: func(total, n) [total / n]
# Tests:
println(split_bill(60.0, 3)) # 20.0
println(split_bill(100.0, 4)) # 25.0
println(split_bill(99.99, 3)) # 33.33Commentary. Chapter 1’s number rules matter here. If
the caller passes split_bill(60, 3) (with an
integer total 60, not float
60.0), the answer is the integer 20; pass
split_bill(60, 7) and it’s the exact rational
60/7 — correct, but not what a restaurant receipt prints.
With a float total the answer is a decimal every time. Try all
three.
The Design Recipe should have surfaced this: stage 1 said “total is a
Float”, so all your tests passed 60.0, not 60.
That’s the recipe doing its quiet work: by forcing you to commit to
types at stage 1, it kept you out of trouble.
Don’t peek. Try each exercise yourself, all six stages, before comparing. The point isn’t to write the same code as the solution — it’s to walk through the same process. If your data definition, signature, and examples lined up cleanly and your body fell out, you got the lesson.
#language axioma/beginner
# Data: two numbers
# Signature: max_of_two: Float Float -> Float
# Examples:
# max_of_two(3, 7) = 7
# max_of_two(5, 5) = 5 <-- tie case
# max_of_two(-2, -8) = -2 <-- negatives
max_of_two: func(a, b) [
if a > b then a else b
]
println(max_of_two(3, 7)) # 7
println(max_of_two(5, 5)) # 5
println(max_of_two(-2, -8)) # -2Commentary. The tie case
max_of_two(5, 5) = 5 is satisfied because
a > b is false when they’re equal, so the
else branch returns b — which happens to equal
a. The function would also work with >=
instead of > — same answer in every case.
There’s already a built-in max in Axioma (it does the
same thing). The exercise is about understanding if/else,
not reinventing max.
#language axioma/beginner
# Data: temperature in Celsius (Float)
# Signature: temperature_category: Float -> String
# Examples:
# temperature_category(-5) = "freezing"
# temperature_category(10) = "cold"
# temperature_category(20) = "comfortable"
# temperature_category(30) = "hot"
temperature_category: func(c) [
if c <= 0 then "freezing"
else if c <= 15 then "cold"
else if c <= 25 then "comfortable"
else "hot"
]
println(temperature_category(-5)) # freezing
println(temperature_category(10)) # cold
println(temperature_category(20)) # comfortable
println(temperature_category(30)) # hotCommentary. Notice the order. We test the
coldest boundary first (c <= 0), then each
subsequent boundary is higher. The cascade only reaches
else if c <= 15 after the c <= 0 branch
failed, so we know at that point that c > 0.
This implicit narrowing is what makes cascades read naturally.
Border behavior: what does temperature_category(0)
return? It’s <= 0, so “freezing”. And
temperature_category(15)? “cold”. Pick which side of
each boundary you mean, then encode it consistently with
< or <=. This is the kind of detail
the Design Recipe’s example stage forces you to confront before you
write code.
#language axioma/beginner
# Data: n, low, high (all Floats, with low <= high)
# Signature: is_in_range: Float Float Float -> Boolean
# Examples:
# is_in_range(5, 1, 10) = true
# is_in_range(15, 1, 10) = false
# is_in_range(1, 1, 10) = true <-- inclusive at low boundary
# is_in_range(10, 1, 10) = true <-- inclusive at high boundary
# is_in_range(0, 1, 10) = false
is_in_range: func(n, low, high) [
n >= low and n <= high
]
println(is_in_range(5, 1, 10)) # true
println(is_in_range(15, 1, 10)) # false
println(is_in_range(1, 1, 10)) # true
println(is_in_range(10, 1, 10)) # true
println(is_in_range(0, 1, 10)) # falseCommentary. No if/else needed — the
and of two comparisons is a Boolean. This is the
most important lesson of the chapter: a Boolean expression doesn’t need
to be wrapped in if true then true else false. The Boolean
expression is already the answer.
The choice between inclusive and exclusive at the boundaries —
>= vs >, <= vs
< — is a design choice. The examples committed us to
“inclusive at both ends,” and the body honored that.
#language axioma/beginner
# Data: bill (Float), percent (Float)
# Signature: tip_smart: Float Float -> Float
# Examples:
# tip_smart(8.0, 20) = 2 (small bill — flat $2)
# tip_smart(50.0, 20) = 10 (regular)
# tip_smart(100.0, 15) = 15 (regular)
tip_smart: func(bill, percent) [
if bill < 10 then 2.0
else bill * percent / 100
]
println(tip_smart(8.0, 20)) # 2.0
println(tip_smart(50.0, 20)) # 10.0
println(tip_smart(100.0, 15)) # 15.0Commentary. The two-branch if/else
switches between two different formulas depending on a
condition on the inputs. This is how policy rules get encoded in
software — “if X, do this; otherwise, do that.” Familiar to anyone who’s
set up an automatic email rule.
#language axioma/beginner
# Data: x, y (Floats)
# Signature: quadrant: Float Float -> String
# Decision (committed up front!): if either coordinate is 0, the point
# is "on axis" — not in any quadrant.
# Examples:
# quadrant(3, 4) = "I"
# quadrant(-3, 4) = "II"
# quadrant(-3, -4) = "III"
# quadrant(3, -4) = "IV"
# quadrant(0, 5) = "on axis"
# quadrant(0, 0) = "on axis"
quadrant: func(x, y) [
if x == 0 or y == 0 then "on axis"
else if x > 0 and y > 0 then "I"
else if x < 0 and y > 0 then "II"
else if x < 0 and y < 0 then "III"
else "IV"
]
println(quadrant(3, 4)) # I
println(quadrant(-3, 4)) # II
println(quadrant(-3, -4)) # III
println(quadrant(3, -4)) # IV
println(quadrant(0, 5)) # on axis
println(quadrant(0, 0)) # on axisCommentary. Putting the on axis case
first means the rest of the cascade can assume
x != 0 and y != 0. The else "IV"
at the bottom is reached only when none of the preceding conditions hit
— which (given we’ve ruled out the axes) means
x > 0 and y < 0.
This is the most common bug source in cascades: the order
matters. If you put the else "IV" case as
else if x > 0 and y < 0 then "IV" and there’s a
new combination you haven’t thought of, the else
catches it. With an open else, you’d want to write a
defensive last-line check — but here we’ve enumerated all cases
the recipe asked for.
#language axioma/beginner
umbrella_decision: func(rain_chance_percent, has_jacket) [
if rain_chance_percent >= 50 then "bring umbrella"
else if rain_chance_percent >= 25 and not has_jacket then "bring umbrella"
else "leave umbrella at home"
]
println(umbrella_decision(80, true)) # bring umbrella
println(umbrella_decision(30, true)) # leave umbrella at home
println(umbrella_decision(30, false)) # bring umbrella
println(umbrella_decision(10, false)) # leave umbrella at homeCommentary. Many right answers. Note the use of
and and not in the same line: “rain chance is
at least 25% and no jacket.” This is where Booleans
earn their keep — encoding policy with the same connectives you’d use to
describe the policy in English.
Don’t read them before attempting the exercises. The recipe is the point. After you have your six stages on paper, then compare.
#language axioma/beginner
# Data: array of integers, length 0..3 only
# Signature: count_zeros: Array -> Integer
# Examples:
# count_zeros([]) = 0
# count_zeros([0]) = 1
# count_zeros([5]) = 0
# count_zeros([0, 0, 5]) = 2
count_zeros: func(arr) [
if len(arr) == 0 then 0
else if len(arr) == 1 then (if arr[1] == 0 then 1 else 0)
else if len(arr) == 2 then
(if arr[1] == 0 then 1 else 0) + (if arr[2] == 0 then 1 else 0)
else (
(if arr[1] == 0 then 1 else 0) +
(if arr[2] == 0 then 1 else 0) +
(if arr[3] == 0 then 1 else 0)
)
]
println(count_zeros([])) # 0
println(count_zeros([0])) # 1
println(count_zeros([5])) # 0
println(count_zeros([0, 0, 5])) # 2Commentary. This version manually expands one branch per possible length. It’s painful at length 4, impossible at “any length.” Chapter 7 will collapse all four branches into a two-line recursion: empty array → 0; non-empty → (1 if first is 0 else 0) + count_zeros(rest). That’s why we need recursion.
#language axioma/beginner
is_short: func(arr) [ len(arr) < 3 ]
println(is_short([])) # true
println(is_short([1, 2])) # true
println(is_short([1, 2, 3])) # falseCommentary. No if/else needed — the
comparison is already a Boolean. (Same lesson as Exercise 5.3 — Boolean
expressions don’t need to be wrapped in
if true then true else false.)
#language axioma/beginner
# Signature: swap_first_last: Array -> Array
swap_first_last: func(arr) [
[arr[3], arr[2], arr[1]]
]
println(swap_first_last([1, 2, 3])) # [3, 2, 1]
println(swap_first_last(["a", "b", "c"])) # ["c", "b", "a"]Commentary. The body returns a three-element literal containing three index expressions in reverse order. Commas distinguish this array from a sequence of statements.
The general “reverse an array of any length” function will be a Chapter 7 exercise.
#language axioma/beginner
second_or_zero: func(arr) [
if len(arr) < 2 then 0
else arr[2]
]
println(second_or_zero([])) # 0
println(second_or_zero([10])) # 0
println(second_or_zero([10, 20])) # 20
println(second_or_zero([10, 20, 30])) # 20Commentary. The defensive check comes
first, before the indexing. If we wrote
if arr[2] != none then arr[2] else 0, the out-of-bounds
access on [] would crash before the if ever
ran. Order matters: check that the operation is legal before
you perform it.
#language axioma/beginner
# Signature: glue: Any Array Array -> Array
glue: func(sep, a, b) [
a + push([], sep) + b
]
println(glue("-", [1, 2], [3, 4])) # [1, 2, "-", 3, 4]
println(glue(0, [1], [2])) # [1, 0, 2]
println(glue("|", [], ["x"])) # ["|", "x"]Commentary. push([], sep) builds a
one-element Array containing sep. In this expression,
a + [sep] + b also works. The explicit trailing-comma form
[sep,] is useful when square brackets could otherwise be
read as a branch or function body (§6.6).
#language axioma/beginner
xs: [14, 7, 22, 7, 100, 7]
firstof: func(arr) [ first(arr) ]
lastof: func(arr) [ arr[len(arr)] ]
lenof: func(arr) [ len(arr) ]
println(firstof(xs)) # 14
println(lastof(xs)) # 7
println(lenof(xs)) # 6
describe: func(arr) [
"first=" + firstof(arr) + " last=" + lastof(arr) + " len=" + lenof(arr)
]
println(describe(xs)) # first=14 last=7 len=6Commentary. Three trivial functions plus one that
composes them. The point isn’t the math — it’s that one array
can be the input to several different functions. We’ll see this
constantly in Part III when we get to map,
filter, reduce.
There’s an Axioma last(arr) builtin too, so
lastof = func(arr) [last(arr)] is the more idiomatic
version of that helper.
Look only after you have your own attempt on paper. The recipe is the
point — typing six versions of count_zeros is much more
educational than reading one.
#language axioma/beginner
# Data: array of any length
# Signature: count_zeros: Array -> Integer
# Examples:
# count_zeros([]) = 0
# count_zeros([0]) = 1
# count_zeros([1, 0, 2, 0, 0]) = 3
# count_zeros([1, 2, 3]) = 0
count_zeros: func(arr) [
if len(arr) == 0 then 0
else if first(arr) == 0 then 1 + count_zeros(rest(arr))
else count_zeros(rest(arr))
]
println(count_zeros([])) # 0
println(count_zeros([0])) # 1
println(count_zeros([1, 0, 2, 0, 0])) # 3
println(count_zeros([1, 2, 3])) # 0Commentary. Compare this with Exercise 6.1. The length-explicit version was 9 lines and only handled arrays of length 0..3. The recursive version is 4 lines and handles arrays of any length. That’s the payoff of structural recursion.
The body splits into three cases: empty, first-is-zero, otherwise.
It’s the same shape as has_value from §7.7 — boolean test
on the front, recur on the rest, combine.
#language axioma/beginner
smallest: func(arr) [
if len(arr) == 0 then none
else if len(arr) == 1 then first(arr)
else [
rest_smallest: smallest(rest(arr))
if first(arr) < rest_smallest then first(arr) else rest_smallest
]
]
println(smallest([])) # none
println(smallest([7])) # 7
println(smallest([3, 1, 4, 1, 5, 9])) # 1Commentary. Identical structure to
largest from §7.6, with > flipped to
<. This is the moment when you start to see the
pattern: “recursive aggregate over a list” is one shape, and the
combining rule is the only thing that changes.
#language axioma/beginner
sum_of_squares: func(arr) [
if len(arr) == 0 then 0
else first(arr) * first(arr) + sum_of_squares(rest(arr))
]
println(sum_of_squares([])) # 0
println(sum_of_squares([3])) # 9
println(sum_of_squares([1, 2, 3])) # 14Commentary. Compare with sum_list from
§7.2 — only the combining rule changed from
first(arr) + recur to
first(arr) * first(arr) + recur. The skeleton is
identical.
(For very long arrays we’d evaluate first(arr) twice; a
local binding v: first(arr); v * v + recur would compute it
once. Premature optimization at this point — but a Chapter-12
refactor.)
#language axioma/beginner
count_above: func(arr, threshold) [
if len(arr) == 0 then 0
else if first(arr) > threshold then 1 + count_above(rest(arr), threshold)
else count_above(rest(arr), threshold)
]
println(count_above([], 10)) # 0
println(count_above([5, 12, 8, 99], 10)) # 2
println(count_above([1, 2, 3], 100)) # 0Commentary. Two-argument recursion. The
threshold parameter doesn’t change as we descend — we pass
it through unchanged in both branches. The only “real” recursion is on
the array.
In Part III we’ll meet filter and count_if,
which generalize this pattern: “test the front; if it passes, count it;
either way, recur on the rest.”
#language axioma/beginner
double_each: func(arr) [
if len(arr) == 0 then []
else push([], 2 * first(arr)) + double_each(rest(arr))
]
println(double_each([])) # []
println(double_each([1])) # [2]
println(double_each([1, 2, 3])) # [2, 4, 6]Commentary. This is the first exercise where the
recursive return value is an array, not a number. The body
says: take the first element, double it, package it in a singleton
(using push([], ...) per §6.6’s workaround), then glue that
onto the front of the recursively-doubled rest.
In Part III this becomes map(func(x) [2 * x], arr) — the
combining rule is hidden inside a higher-order function.
#language axioma/beginner
reverse_arr: func(arr) [
if len(arr) == 0 then []
else reverse_arr(rest(arr)) + push([], first(arr))
]
println(reverse_arr([])) # []
println(reverse_arr([42])) # [42]
println(reverse_arr([1, 2, 3])) # [3, 2, 1]Commentary. The crucial design choice was
where to put first(arr) in the output. We chose:
at the end. The reverse of [1, 2, 3] is the
reverse of [2, 3] followed by [1]. That gives
[3, 2] + [1] = [3, 2, 1]. ✓
What happens at each step:
reverse_arr([1, 2, 3])
= reverse_arr([2, 3]) + [1]
= (reverse_arr([3]) + [2]) + [1]
= ((reverse_arr([]) + [3]) + [2]) + [1]
= (([] + [3]) + [2]) + [1]
= ([3] + [2]) + [1]
= [3, 2] + [1]
= [3, 2, 1]
There’s a sneaky cost here: each + on arrays copies. So
reverse_arr on an N-element array does work
proportional to N², not N. Chapter 15’s
accumulator-style rewrite makes it N.
This is also why Axioma has a built-in reverse — it does
the work efficiently. The exercise is in seeing how the recursion works,
not in deploying the fastest possible primitive.
#language axioma/beginner
is_palindrome: func(s) [
n: length(s)
if n <= 1 then true
else if s[1] != s[n] then false
else is_palindrome(s[2: n - 1])
]
is_palindrome2: func(s) [
go: func(start, finish) [
if start >= finish then true
else if s[start] != s[finish] then false
else go(start + 1, finish - 1)
]
go(1, length(s))
]
println(is_palindrome("")) # true
println(is_palindrome("a")) # true
println(is_palindrome("civic")) # true
println(is_palindrome("cynic")) # false
println(is_palindrome("redivider")) # true
println(is_palindrome("runner")) # false
println(is_palindrome2("civic")) # true
println(is_palindrome2("cynic")) # false
println(is_palindrome2("redivider")) # trueCommentary. The slice form and the index form are the same three questions. What changes is the smaller problem: a shorter string versus a narrower window. The index form does not allocate, so it is the one you want if the string is long; the slice form is the one that matches the paper algorithm one-for-one.
Axioma strings are 1-indexed and slices are inclusive. The middle of
a length-n string is s[2: n - 1], not
s[1: n - 1]. Python’s s[1:-1] and JavaScript’s
s.slice(1, s.length - 1) name the same characters with
different numbers (0-based, exclusive end). Copy those numbers into
Axioma and you drop the wrong characters. cynic is the
example that catches the fencepost: a slice that overshoots becomes
empty, empty is a palindrome, and you have a wrong true at
exit 0.
#language axioma/beginner
take_while: func(arr, pred) [
if len(arr) == 0 then []
else if pred(first(arr)) then [first(arr) | take_while(rest(arr), pred)]
else []
]
drop_while: func(arr, pred) [
if len(arr) == 0 then []
else if pred(first(arr)) then drop_while(rest(arr), pred)
else arr
]
data: [2, 6, 42, 5, 7, 20, 3]
is_even: func(n) [n % 2 == 0]
println(take_while(data, is_even)) # [2, 6, 42]
println(drop_while(data, is_even)) # [5, 7, 20, 3]
println(take_while([], is_even)) # []
println(drop_while([], is_even)) # []
println(take_while([1, 2, 3], is_even)) # []
println(drop_while([1, 2, 3], is_even)) # [1, 2, 3]
println(take_while([2, 4], is_even)) # [2, 4]
println(drop_while([2, 4], is_even)) # []Commentary. Both stop at the first failure.
take_while builds a prefix (cons the head onto the
recursive prefix, or return [] when the head fails).
drop_while skips (recur with no combining, or
return the array as-is when the head fails). 20 is even and
it is in the drop_while result because the walk already
stopped.
drop_while is in tail position. take_while
is not — the cons waits for the recursive prefix. For this exercise both
are the natural structural recursions. An accumulator
take_while that conses onto the front of acc
would build the prefix backwards and need a reverse at the
end; that is a Chapter 15 rewrite, not a requirement here.
Together they partition arr into
(prefix, rest) at the first failure.
take_while(arr, pred) + drop_while(arr, pred) equals
arr for every arr and pred.
Look only after you have your own attempt on paper. Type the recursion out yourself; the muscle memory of writing the two-case template is more useful than reading mine.
The shared preamble (all six exercises start with this):
#language axioma/beginner
concept Node
Node has value: 0
Node has left: none
Node has right: none
# Standard test tree:
# 3
# / \
# 1 5
# / \
# 4 7
leaf4: a Node {value: 4}
leaf7: a Node {value: 7}
n5: a Node {value: 5, left: leaf4, right: leaf7}
leaf1: a Node {value: 1}
root: a Node {value: 3, left: leaf1, right: n5}tree_sum: func(t) [
if t == none then 0
else t.value + tree_sum(t.left) + tree_sum(t.right)
]
println(tree_sum(none)) # 0
println(tree_sum(leaf4)) # 4
println(tree_sum(root)) # 20Commentary. Compare with sum_list from
§7.2 — same shape, but with two recursive calls (one per child)
because trees branch. The combiner is
t.value + left_sum + right_sum.
count_leaves: func(t) [
if t == none then 0
else if t.left == none and t.right == none then 1
else count_leaves(t.left) + count_leaves(t.right)
]
println(count_leaves(none)) # 0
println(count_leaves(leaf4)) # 1
println(count_leaves(root)) # 3Commentary. Three branches: empty (0), leaf (1),
internal (recur). The “is leaf” test is
t.left == none and t.right == none. An internal node
itself doesn’t count — we just add up the leaves in the two
subtrees.
This is the first function where the structure of a node matters as much as its children. Recipes scale: data with three flavors → function with three branches.
tree_max: func(t) [
if t == none then none
else if t.left == none and t.right == none then t.value
else [
lm: tree_max(t.left)
rm: tree_max(t.right)
best_child: if lm == none then rm
else if rm == none then lm
else if lm > rm then lm else rm
if t.value > best_child then t.value else best_child
]
]
println(tree_max(none)) # none
println(tree_max(root)) # 7Commentary. Three branches: empty
(none), leaf (just the value), internal (a four-way
comparison). Inside the internal branch we have to be defensive: a
non-empty internal node can still have one empty subtree, in which case
the recursive call on the empty side returns none, which is
unusable for >. The if lm == none then rm
cascade handles that.
If you find this annoying, you’re noticing something real: the
none sentinel for “empty result” propagates and forces
special handling everywhere. Chapter 18 will introduce partition
types (another Concept feature) that let us express “this value is
always a number” at the type level — but for now, defensive
if checks are the price.
tree_min: func(t) [
if t == none then none
else if t.left == none and t.right == none then t.value
else [
lm: tree_min(t.left)
rm: tree_min(t.right)
best_child: if lm == none then rm
else if rm == none then lm
else if lm < rm then lm else rm
if t.value < best_child then t.value else best_child
]
]
println(tree_min(none)) # none
println(tree_min(root)) # 1Commentary. Mirror of 8.3 with >
flipped to <. The structure is identical — the only
thing that changed is which direction we’re optimizing.
concat3: func(a, b, c) [a + b + c]
inorder: func(t) [
if t == none then []
else concat3(inorder(t.left), push([], t.value), inorder(t.right))
]
println(inorder(none)) # []
println(inorder(root)) # [1, 3, 4, 5, 7]Commentary. The classic in-order tree traversal: left subtree, this node, right subtree, in that order. We need a 3-way concatenation in the middle. Two reasonable approaches:
concat3(a, b, c) (what we did).inorder(t.left) + push([], t.value) + inorder(t.right).The second works too. The helper makes the pattern explicit.
The singleton push([], t.value) packages the node value
in an Array. The explicit one-element literal [t.value,] is
an alternative (§6.6).
If we’d used preorder (this node first), the code would
be
push([], t.value) + preorder(t.left) + preorder(t.right).
If we’d used postorder, the value goes last. Same recursion
shape, different ordering of the three pieces.
tree_depth: func(t) [
if t == none then 0
else [
dl: tree_depth(t.left)
dr: tree_depth(t.right)
1 + (if dl > dr then dl else dr)
]
]
abs_diff: func(a, b) [if a > b then a - b else b - a]
tree_balanced: func(t) [
if t == none then true
else [
dl: tree_depth(t.left)
dr: tree_depth(t.right)
if abs_diff(dl, dr) > 1 then false
else tree_balanced(t.left) and tree_balanced(t.right)
]
]
println(tree_balanced(none)) # true
println(tree_balanced(root)) # true
# A deliberately unbalanced (lopsided) tree:
lop: a Node {value: 1, left: a Node {value: 2,
left: a Node {value: 3}}}
println(tree_balanced(lop)) # false (depth 3 on left, 0 on right)Commentary. Two layers of recursion, which is why
this one is hard. tree_balanced(t) says:
That second clause is the subtle bit. Just checking the root’s own subtree-depth difference isn’t enough — a deeply lopsided left subtree could itself be unbalanced even if its overall depth matches the right.
There’s an efficiency issue: tree_depth walks every
node, and we call it at every recursive level. That’s quadratic
time in the tree’s size. A smarter version returns both a depth
and a balance-bit in one pass, so it stays linear. We’ll learn the
technique in Chapter 15. For now: simple is fine.
Look only after you have your own attempt on paper. The recipe is the point — and trees give you twice as many chances to apply it as lists do.
Common preamble for the household exercises:
#language axioma/beginner
concept Person
Person has name: ""
Person has household: none
concept Household
Household has address: ""
Household has members: []
h1: a Household {address: "123 Main", members: [a Person {name: "Alice"}, a Person {name: "Bob"}]}
alice: a Person {name: "Alice", household: h1}
bob: a Person {name: "Bob", household: h1}
h2: a Household {address: "456 Oak", members: [a Person {name: "Carl"}]}
carl: a Person {name: "Carl", household: h2}Common preamble for the expression exercises:
concept Lit
Lit has value: 0
concept Op
Op has kind: "+"
Op has lhs: none
Op has rhs: none
e1: a Lit {value: 1}
e2: a Lit {value: 2}
plus: an Op {kind: "+", lhs: e1, rhs: e2}
e3: a Lit {value: 3}
e4: a Lit {value: 4}
minus: an Op {kind: "-", lhs: e3, rhs: e4}
expr: an Op {kind: "*", lhs: plus, rhs: minus}names_in_members: func(members) [
if len(members) == 0 then []
else push([], first(members).name) + names_in_members(rest(members))
]
names_in_household: func(p) [
if p == none then []
else if p.household == none then []
else names_in_members(p.household.members)
]
println(names_in_household(alice)) # ["Alice", "Bob"]
println(names_in_household(a Person {name: "Lonely", household: none})) # []Commentary. Two layers of traversal:
names_in_household hops one step out (Person → Household),
then names_in_members does the list-recursion (Array of
Persons → Array of String). Only names_in_members calls
itself. This is a helper followed by list recursion, not mutual
recursion between the two functions.
The push([], first(members).name) constructs a singleton
Array, as shown from §6.6. The + then glues this one-name
array onto the names collected from the rest of the members.
count_total_people: func(hs) [
if len(hs) == 0 then 0
else len(first(hs).members) + count_total_people(rest(hs))
]
println(count_total_people([])) # 0
println(count_total_people([h1, h2])) # 3Commentary. Standard list recursion (Chapter 7), but
the “combine” rule reaches into each element to get
len(first(hs).members). The recursion is on the array of
households, not on the members within each household — those are handled
all at once by len(...). One walk through the array, one
len() per household.
expr_depth: func(e) [
if e is Lit then 1
else if e is Op then [
dl: expr_depth(e.lhs)
dr: expr_depth(e.rhs)
1 + (if dl > dr then dl else dr)
]
else 0
]
println(expr_depth(e1)) # 1
println(expr_depth(plus)) # 2
println(expr_depth(expr)) # 3Commentary. Two branches — one per variant. The
structure mirrors tree_depth from Chapter 8, but with
is Lit and is Op instead of
t == none and “internal node.” The math is the same: leaf
returns 1, internal returns 1 + max of children’s depths.
count_ops: func(e) [
if e is Lit then 0
else if e is Op then 1 + count_ops(e.lhs) + count_ops(e.rhs)
else 0
]
println(count_ops(e1)) # 0
println(count_ops(plus)) # 1
println(count_ops(expr)) # 3Commentary. A Lit is not an operator,
so it contributes 0. An Op is, so it
contributes 1 plus whatever the subexpressions contribute.
Same recursion shape as tree_size from §8.5; only the
combining rule changed (0 and
1 + recur + recur vs 0 and
1 + recur + recur — actually it is the same
combining rule, because every Op counts as exactly one
node).
add_one_to_lits: func(e) [
if e is Lit then a Lit {value: e.value + 1}
else if e is Op then an Op {
kind: e.kind,
lhs: add_one_to_lits(e.lhs),
rhs: add_one_to_lits(e.rhs)
}
else none
]
new_e1: add_one_to_lits(e1)
println(new_e1.value) # 2
new_plus: add_one_to_lits(plus)
println(new_plus.lhs.value) # 2
println(new_plus.rhs.value) # 3Commentary. First time we construct in both
branches. For a Lit, we build a new Lit with
value + 1. For an Op, we build a new
Op with the same kind but with each
sub-expression transformed by a recursive call.
Important: this returns a new expression. The original is unchanged. The output expression has the same shape and the same operator kinds — only the literal values are different.
This is a transformation pass — the kind of thing a compiler does. Real compilers do dozens of these passes, each a structurally-recursive walk of an AST that produces a new AST.
eval_expr: func(e) [
if e is Lit then e.value
else if e is Op then [
l: eval_expr(e.lhs)
r: eval_expr(e.rhs)
if e.kind == "+" then l + r
else if e.kind == "-" then l - r
else if e.kind == "*" then l * r
else if e.kind == "/" then l / r
else if e.kind == "^" then l ^ r # NEW
else none
]
else none
]
pow_expr: an Op {
kind: "^",
lhs: a Lit {value: 2},
rhs: a Lit {value: 8}
}
println(eval_expr(pow_expr)) # 256Commentary. Adding a binary operator is a one-line
change: extend the else if e.kind == ... cascade with the
new symbol. The data definition (Op has kind: "+") didn’t
need to change — kind is just a string, and the evaluator
decides which strings mean what.
The harder variation — a unary "neg" via a
third Concept — exposes the trade-off. With three Concepts the evaluator
grows a third else if e is UnaryOp then ... branch, and
every other pass over the expression tree also has to add a branch.
Real-world interpreters trade this off: more variants means clearer
type-level distinctions but more boilerplate per pass.
(Chapter 22 — the meta-circular evaluator for Axioma itself — will use dozens of variants. The pattern scales; the boilerplate is real.)
Try first; compare second. Every exercise here is small but the pattern — branch on shape, recur on children, combine — generalizes to interpreters, compilers, structural editors, spreadsheet engines, query planners, and everything else built on recursive data.
#language axioma/beginner
triple_each: func(arr) [ map(func(x) [x * 3], arr) ]
println(triple_each([])) # []
println(triple_each([1, 2, 3])) # [3, 6, 9]Commentary. The whole point of map is
that this is the entire solution. The recursion is hidden
inside the builtin. Compare with Exercise 7.5
(double_each), which was the same shape but you had to
write the recursion by hand.
#language axioma/beginner
keep_positive: func(arr) [ filter(func(x) [x > 0], arr) ]
println(keep_positive([])) # []
println(keep_positive([1, -2, 3, 0])) # [1, 3]Commentary. Predicate names by convention read like
yes/no questions: is_positive would also be fine. Note the
predicate x > 0 excludes zero — >= 0
would include it. Asking the user which they want is the kind
of “design choice” HtDP calls out as part of the recipe (Chapter 4 step
1: nail down the data).
#language axioma/beginner
product: func(arr) [ reduce(func(a, b) [a * b], 1, arr) ]
println(product([])) # 1
println(product([4])) # 4
println(product([2, 3, 5])) # 30Commentary. The seed value 1 is the
identity for multiplication — multiplying by 1 doesn’t change
the result. For + the identity is 0, for
* it’s 1, for string concatenation it’s
"". Knowing this for any “fold” operation is one of the
genuinely mathematical habits you pick up from functional
programming.
product([]) == 1 may feel surprising — there are no
factors! But it has to be: any product(extra :: rest)
should equal extra * product(rest), which only works if
product([]) is the identity. This is the same logic as “0!
= 1” in mathematics.
#language axioma/beginner
sum_of_squares: func(arr) [
reduce(func(a, b) [a + b], 0,
map(func(x) [x * x], arr))
]
println(sum_of_squares([])) # 0
println(sum_of_squares([1, 2, 3])) # 14Commentary. Compare with Exercise 7.3’s recursive
version
(if len(arr) == 0 then 0 else first(arr) * first(arr) + ...).
Same answer, no if, no first, no recursion in
your code. Two operations: map to transform each element,
reduce to combine them.
You could fuse the two into one reduce
(reduce(func(a, x) [a + x * x], 0, arr)), avoiding the
intermediate squared array. The fused version is more efficient for huge
arrays; the separated version is easier to read and to modify
later (“square and also round to nearest int” is one extra
map). Pick clarity first, optimize when measured.
#language axioma/beginner
all_of: func(pred, arr) [
if len(arr) == 0 then true
else if pred(first(arr)) then all_of(pred, rest(arr))
else false
]
println(all_of(func(x) [x > 0], [])) # true
println(all_of(func(x) [x > 0], [1, 2, 3])) # true
println(all_of(func(x) [x > 0], [1, -1, 3])) # falseCommentary. This is the dual of
any_of from §10.7: the base case flips (true
instead of false), and so do the two branches of the
conditional. Read the two side by side — the symmetry is striking.
The vacuous-truth answer for all_of(_, []) matches the
mathematical universal quantifier: “for all x in the empty set, P(x)” is
true because there are zero violations. This is the same logic that
makes 0! = 1 and product([]) = 1 (Ex.10.3).
Identity elements, vacuous quantification, and recursive base cases are
all the same idea wearing different hats.
#language axioma/beginner
# Style A — direct recursion (mirror count_above from Ex.7.4)
count_if_rec: func(pred, arr) [
if len(arr) == 0 then 0
else if pred(first(arr)) then 1 + count_if_rec(pred, rest(arr))
else count_if_rec(pred, rest(arr))
]
# Style B — one-liner using filter and len
count_if_hof: func(pred, arr) [ len(filter(pred, arr)) ]
println(count_if_rec(func(x) [x > 10], [])) # 0
println(count_if_rec(func(x) [x > 10], [3, 12, 8, 99])) # 2
println(count_if_hof(func(x) [x > 10], [])) # 0
println(count_if_hof(func(x) [x > 10], [3, 12, 8, 99])) # 2Discussion.
Readability. Style B is much shorter and reads almost like English: “count the items that pass the filter.” Style A spells out the algorithm; Style B describes the result.
Memory. Style B builds an intermediate array
(filter returns the full filtered list), then discards it.
Style A walks the input once and accumulates a number. For a
billion-element array, Style A is more efficient.
Changeability. Suppose the spec changes: “count
items that pass, until you see a none element,
then stop.” Style A is trivial to modify (add a third branch to the
if). Style B requires reshuffling — filter
doesn’t know how to stop early.
Neither style is universally better. HtDP’s habit is to first write the version that’s easiest to read, then optimize if a measurement says you have to. Style B first; switch to Style A only when you actually need to.
#language axioma/beginner
calibrate: func(reading, gain, offset) [reading * gain + offset]
expect("one reading", calibrate(2, 3, 1), 7)
expect("scalar options", calibrate.([0, 1, 2], 3, 1), [1, 4, 7])
expect("per-reading gains", calibrate.([0, 1, 2], [2, 3, 4], 1), [1, 4, 9])
expect("singleton gain", calibrate.([0, 1, 2], [3], 1), [1, 4, 7])
expect("empty readings", calibrate.([], 3, 1), [])Commentary. The scalar function needs no loop and knows nothing about arrays. Broadcasting chooses the arguments for each position. Lengths three and two are incompatible; neither axis is a singleton. Shape validation fails before scalar callbacks, so no readings are calibrated in that invalid call.
#language axioma/beginner
square: func(x) [x * x]
increment: func(x) [x + 1]
nums: [1, 2, 3]
fused: increment.(square.(nums))
squares: square.(nums)
staged: increment.(squares)
expect("connected dots", fused, [2, 5, 10])
expect("separate stages", staged, [2, 5, 10])
expect("then total", reduce(func(a, b) [a + b], 0, fused), 17)
expect("empty fused walk", increment.(square.([])), [])Commentary. fused creates only the
final array. The other version also creates squares. The
reduction consumes the finished array; it is an ordinary call, so it
does not join the broadcast loop. Compare with Exercise 10.4: explicitly
doing the whole calculation inside one reduction is another design, with
its own scalar combining function.
The fused form prints inside 1, outside 11,
inside 2, outside 12. The ordinary
broadcast call and the pipe form both print
inside 1, inside 2, outside 11,
outside 12. All three return [22, 24].
Discussion. Choose an ordinary call boundary or an explicit intermediate binding when stage order matters. For a singleton computation that must run once, bind its result before including it in a larger broadcast. Leaving that dotted computation inside the fused expression lets it run at each final output position. The decision is about effects and intended reuse, not just the numerical answer.
# Lambda needs the unrestricted mode — no `#language axioma/beginner`.
triple_each: func(arr) [ map(lambda x => x * 3, arr) ]
keep_positive: func(arr) [ filter(lambda x => x > 0, arr) ]
product: func(arr) [ reduce(lambda (a, b) => a * b, 1, arr) ]
println(triple_each([1, 2, 3])) # [3, 6, 9]
println(keep_positive([1, -2, 3])) # [1, 3]
println(product([2, 3, 5])) # 30Commentary. Side-by-side comparison with the Ch.10
solutions: only the inline function form has changed. The outer
func(arr) [...] wrappers are still there because they take
a named parameter (arr) and define a
reusable function. That’s the pattern §11.4 calls out: lambda
for the inline one-off, func for what gets a name.
negate_all: func(arr) [ map(lambda x => 0 - x, arr) ]
println(negate_all([])) # []
println(negate_all([1, -2, 3])) # [-1, 2, -3]Commentary. 0 - x flips the sign. A
unary minus operator -x also works in Axioma
(lambda x => -x) but the 0 - x form is
unambiguous if you ever mis-read.
split_by: func(pred, arr) [
if len(arr) == 0 then ([], [])
else [
r: split_by(pred, rest(arr));
yes_arr: r[1];
no_arr: r[2];
h: first(arr);
if pred(h) then (push([], h) + yes_arr, no_arr)
else (yes_arr, push([], h) + no_arr)
]
]
println(split_by(lambda x => x > 0, [])) # ([], [])
println(split_by(lambda x => x > 0, [1, -2, 3, -4])) # ([1, 3], [-2, -4])Commentary. The pair is built once at the recursive
call, unpacked into yes_arr and no_arr, and
then one of them gets h prepended depending on
pred(h). This is the first exercise where the recursive
value isn’t a list — it’s a tuple. The shape of the recursion
still mirrors the data (“empty or first-and-rest”); only the return type
changes.
Why is this single-pass important? The two-filter
version walks the array twice — once for the yes side, once for the no
side. For a large array that’s twice the work. For a stream
(Chapter 17 preview) that you can only walk once,
two-filter isn’t even possible.
(Reminder on the name: partition is a reserved Axioma
keyword — see §11.3’s insert / insert_sorted
story.)
compose: lambda (f, g) => (lambda x => g(f(x)))
println(compose(lambda x => x + 1, lambda x => x * 2)(5)) # 12Commentary. Read the type aloud:
compose takes two functions, returns a new function. The
new function takes one argument, runs f on it, then runs
g on the result. Read in math notation:
compose(f, g) = g ∘ f.
(Mathematicians sometimes define compose(f, g) = f ∘ g
instead — apply g first then f. There’s no
winning here; pick a convention and document it. The version above
matches Python’s pipe style and the Unix |
pipeline.)
insert_sorted: func(less, x, sorted) [
if len(sorted) == 0 then push([], x)
else if less(x, first(sorted)) then push([], x) + sorted
else push([], first(sorted)) + insert_sorted(less, x, rest(sorted))
]
sort_by: func(less, arr) [
if len(arr) == 0 then []
else insert_sorted(less, first(arr), sort_by(less, rest(arr)))
]
sort_descending: lambda arr => sort_by(lambda (a, b) => a > b, arr)
println(sort_descending([3, 1, 4, 1, 5])) # [5, 4, 3, 1, 1]Commentary. The outer lambda partially
applies sort_by: it pre-fills the less
argument with lambda (a, b) => a > b and leaves
arr to be supplied at call time. This is called
partial application or currying, and
it’s the entire trick: build small specific functions out of bigger
general ones, by fixing some of the arguments.
Languages with built-in currying (Haskell, OCaml) would let you write
sort_descending: sort_by(lambda (a, b) => a > b) — no
outer lambda needed. Axioma’s call syntax doesn’t curry automatically;
you wrap the partial application in a lambda yourself. That’s
almost as good and considerably more explicit.
pipe: func(x, fs) [
reduce(lambda (acc, f) => f(acc), x, fs)
]
println(pipe(5, [lambda x => x + 1,
lambda x => x * 2,
lambda x => x - 3])) # 9
# A chained example: take a string, normalize it, count words.
normalize_and_count: lambda s => pipe(s, [lambda s => trim(s),
lambda s => lower(s),
lambda s => split(s, " "),
lambda parts => len(filter(lambda w => len(w) > 0, parts))])
println(normalize_and_count(" Hello World ")) # 2Discussion.
The key insight is that reduce is the right tool because
the job is “fold an array of functions over a starting value.” The
accumulator (acc) is the running value being transformed;
the reducer combines acc with the next function by calling
it. The pipeline pipe(x, [f, g, h]) evaluates as
h(g(f(x))).
Pipeline style reads left-to-right in the order the
transformations apply. Nested-call style (h(g(f(x)))) reads
right-to-left, which is the reverse of the order things
happen. Both are correct; pipeline often documents intent better,
especially when the chain has four or five steps.
This is why Elixir’s |> and F#’s |>
operators exist as language syntax — they’re so useful that the
languages elevate them above being just a function. Axioma has the
operator too ([3, 1, 2] |> sort |> len →
3); what you’ve just built is the same idea in function
space.
#language axioma/beginner
bmi_category: func(weight_kg, height_m) [
h_squared: height_m * height_m;
b: weight_kg / h_squared;
if b < 18.5 then "underweight"
else if b < 25 then "normal"
else if b < 30 then "overweight"
else "obese"
]
println(bmi_category(50, 1.75)) # underweight
println(bmi_category(70, 1.75)) # normal
println(bmi_category(85, 1.75)) # overweight
println(bmi_category(100, 1.75)) # obeseCommentary. Two locals: h_squared
(avoids recomputing height_m * height_m) and b
(gives the BMI a meaningful name we can refer to four times in the
cascade). Neither escapes the function. The shape “compute some
intermediate quantity, then dispatch on it” is one of the most common in
all of programming.
(Quiz: why bother with h_squared when
b: weight_kg / (height_m * height_m) works inline? Because
the local documents the intermediate quantity — and computes it
once.)
#language axioma/beginner
triangle_area: func(a, b, c) [
s: (a + b + c) / 2.0;
sqrt(s * (s - a) * (s - b) * (s - c))
]
println(triangle_area(3, 4, 5)) # 6.0
println(triangle_area(5, 5, 6)) # 12.0Commentary. s is computed once and used
four times. Without the local, you’d write
(a + b + c) / 2.0 four times — same result, four times the
typing, four chances to make a typo.
The 2.0 is a style choice, not a correctness fix:
(3 + 4 + 5) / 2 is exactly 6, and for an odd
perimeter like 2, 3, 4 the semi-perimeter is the exact
rational 9/2 — which sqrt accepts happily (the
area comes out 2.9047… either way). The float spelling just
keeps the intermediates in decimal-land, which suits a geometry
measurement. (Chapter 1’s 7 / 2 story: division is exact;
floats are a choice you opt into.)
#language axioma/beginner
running_average: func(arr) [
n: len(arr);
take_i: func(k, src) [
if k == 0 then []
else push([], first(src)) + take_i(k - 1, rest(src))
];
avg_first_i: func(i) [
chunk: take_i(i, arr);
reduce(func(a, b) [a + b], 0.0, chunk) / i
];
make_all: func(j) [
if j > n then []
else push([], avg_first_i(j)) + make_all(j + 1)
];
if n == 0 then [] else make_all(1)
]
println(running_average([])) # []
println(running_average([4])) # [4.0]
println(running_average([2, 4, 6])) # [2.0, 3.0, 4.0]Commentary. Three local helpers —
take_i (prefix of length k),
avg_first_i (average of first i elements),
make_all (build the result by walking j from 1
to n). None of them leak. The function exports exactly one
name (running_average) and hides three.
This is the densest example so far of the “private workshop” metaphor: a function that knows three helper concepts but only the result function appears in the namespace. A real production routine for, say, sliding-window statistics on a sensor stream might hide ten helpers like this.
The 0.0 accumulator seed again forces float math; the
running averages come out as decimals like 2.5 rather than
exact fractions like 5/2.
#language axioma/beginner
mean_of_big_evens: func(nums) [
above_10: filter(func(x) [x > 10], nums);
evens: filter(func(x) [x % 2 == 0], above_10);
total: reduce(func(a, b) [a + b], 0, evens);
total / len(evens)
]
println(mean_of_big_evens([3, 11, 14, 17, 22, 8, 25, 30])) # 22Commentary. The exact same code as §10.6 — but the
top level now exports one name (mean_of_big_evens) instead
of four (above_10, evens, total,
answer). The intermediate variables existed for clarity
inside the computation, not for use by anyone else. Locals are
exactly the right place for them.
When you load this file into a REPL, you can call
mean_of_big_evens([5, 12, 14, 22]) immediately. You can
not ask for above_10 — it doesn’t exist outside
the function call. That’s the encapsulation §12.8 named.
#language axioma/beginner
count_words_per_line: func(lines) [
word_count: func(s) [
raw: split(s, " ");
non_empty: filter(func(w) [len(w) > 0], raw);
len(non_empty)
];
map(word_count, lines)
]
println(count_words_per_line([])) # []
println(count_words_per_line(["hello world", "the", "a b c d"])) # [2, 1, 4]
println(count_words_per_line(["a b c"])) # [3]Commentary. word_count is a
private helper that handles the “multiple-space” corner case
(split("a b", " ") returns ["a", "", "b"] —
the empty string between the two spaces). The
filter(... len(w) > 0) drops those empties.
map(word_count, lines) is the outer shape: “do the
word-count thing to each line, return the array of counts.” The whole
function is six lines. Without the local helper, the outer would have to
inline the split/filter/len logic inside a lambda — readable, but harder
to test (you can’t unit-test word_count in isolation when
it has no name).
# Captured state: rebind updates the enclosing counter cell.
make_counter: func() [
n: 0;
func() [
rebind n = n + 1;
n
]
]
c: make_counter()
v1: c()
v2: c()
v3: c()
println(v1) # 1
println(v2) # 2
println(v3) # 3
d: make_counter()
w1: d()
v4: c()
println(w1) # 1 (d has its own n)
println(v4) # 4 (c's count continued)Answers to the questions.
Is your counter pure? No. A function is
“pure” if it returns the same answer for the same inputs and has no
visible side effects. c() is called with no arguments each
time and returns a different answer (1, then 2, then 3). It also mutates
internal state. So c is impure — and that’s the point of
this exercise.
What about the beginner discipline? The line
rebind n = n + 1 is exactly what the “one name, one value”
habit forbids — and the counter cannot work without it: remembering how
many times you’ve been called is a change of state. Note that
the #language axioma/beginner pragma still runs this
program — the discipline is a habit the book teaches, not a parser
restriction — but conceptually this exercise is where you step across
the line between “values are fixed once” (functional) and “values change
over time” (imperative). Chapter 16 crosses it deliberately.
You’ve just crossed that line. Chapter 16 (Mutation) gives it a proper introduction, but you’ve already met it here in its sneakiest form — closure of a mutable local. Functions that carry mutable state are how you build counters, caches, state machines, and (in larger form) objects with private fields.
Reflection. Lambdas + closures + mutable locals add
up to objects in everything-but-name. A counter is an “object”
with one method (c()) and one private field
(n). Smalltalk and Self based their entire object systems
on exactly this substrate; JavaScript still implements closures as
objects. The boundary between “functional programming” and
“object-oriented programming” is much fuzzier than the camps let on.
Common preamble for the friendship exercises:
#language axioma/beginner
concept Person
Person has name: ""
Person has age: 0
concept Friendship
Friendship has source: none
Friendship has target: none
Friendship has years: 0
Friendship has closeness: 0
alice: a Person {name: "Alice", age: 30}
bob: a Person {name: "Bob", age: 25}
carol: a Person {name: "Carol", age: 35}
dave: a Person {name: "Dave", age: 28}
f1: a Friendship {source: alice, target: bob, years: 5, closeness: 4}
f2: a Friendship {source: alice, target: carol, years: 2, closeness: 3}
f3: a Friendship {source: bob, target: carol, years: 3, closeness: 5}
f4: a Friendship {source: carol, target: dave, years: 1, closeness: 2}
friendships: [f1, f2, f3, f4]
friendships_of: func(p) [
filter(func(f) [f.source == p or f.target == p], friendships)
]
other_party: func(p, f) [
if f.source == p then f.target else f.source
]
direct_friends: func(p) [
map(func(f) [other_party(p, f).name], friendships_of(p))
]Common preamble for the road-map exercises:
concept City
City has name: ""
concept Road
Road has src: none
Road has dst: none
Road has miles: 0
nyc: a City {name: "NYC"}
phl: a City {name: "Philadelphia"}
dc: a City {name: "DC"}
bos: a City {name: "Boston"}
roads: [
a Road {src: nyc, dst: phl, miles: 95},
a Road {src: phl, dst: dc, miles: 140},
a Road {src: nyc, dst: bos, miles: 215},
a Road {src: dc, dst: nyc, miles: 225}
]Common preamble for the part/assembly exercises:
concept BasicPart
BasicPart has name: ""
BasicPart has weight: 0
concept Assembly
Assembly has name: ""
Assembly has parts: []
wheel: an Assembly {
name: "wheel",
parts: [
a BasicPart {name: "rim", weight: 500},
a BasicPart {name: "tire", weight: 700},
a BasicPart {name: "spokes", weight: 200}
]
}
frame: a BasicPart {name: "frame", weight: 3000}
bike: an Assembly {
name: "bicycle",
parts: [frame, wheel, wheel]
}in_arr: func(arr, x) [
if len(arr) == 0 then false
else if first(arr) == x then true
else in_arr(rest(arr), x)
]
mutual_friends: func(p, q) [
fp: direct_friends(p);
fq: direct_friends(q);
filter(func(n) [in_arr(fq, n)], fp)
]
println(mutual_friends(alice, bob)) # ["Carol"]
println(mutual_friends(alice, dave)) # ["Carol"]
println(mutual_friends(carol, alice)) # ["Bob"]Commentary. Two stages: get
direct_friends(p) and direct_friends(q), then
keep the names that appear in both. The set-intersection logic is
hand-rolled via in_arr because Axioma’s
intersection operator works on the Set type
({...}), not on arrays of names — and the comprehension we
already use to build direct_friends returns an array (so
the names stay ordered, which is the more useful shape for downstream
code).
in_arr is a one-liner you’ll reach for again — many of
the remaining graph algorithms come down to “is this element in the
visited list?” or similar membership tests. (Ch.20’s MVL chapter
introduces Belnap’s four-valued logic, which gives a more nuanced
version of “is X in Y”: both, neither, yes,
no. For now, plain Boolean does the job.)
Why not
contains? Becausecontainsis reserved in Axioma (keywords()lists every reserved word).in_arris unambiguous;memberwould also work.
friendship_years_total: func(p) [
fs: friendships_of(p);
reduce(func(acc, f) [acc + f.years], 0, fs)
]
println(friendship_years_total(alice)) # 7
println(friendship_years_total(carol)) # 6
println(friendship_years_total(dave)) # 1Commentary. Two-step: friendships_of
(filter) returns the array of friendships involving p;
reduce walks that array adding up the years
fields. The 0 seed is the additive identity. Notice we
don’t have to handle the empty case specially — reduce
returns the seed when the array is empty.
This is a very common shape: “fetch the related records,
then aggregate one field.” Database SQL spells it
SELECT SUM(years) FROM friendships WHERE source = ? OR target = ?;
we just spelled it more loudly.
leg_miles: func(a, b) [
candidates: filter(func(r) [r.src == a and r.dst == b], roads);
if len(candidates) == 0 then -1
else first(candidates).miles
]
total_route_miles: func(route) [
n: len(route);
if n <= 1 then 0
else [
leg: leg_miles(first(route), first(rest(route)));
if leg == -1 then -1
else [
rest_total: total_route_miles(rest(route));
if rest_total == -1 then -1
else leg + rest_total
]
]
]
println(total_route_miles([nyc, phl])) # 95
println(total_route_miles([nyc, phl, dc])) # 235
println(total_route_miles([nyc, phl, dc, nyc])) # 460
println(total_route_miles([bos, nyc])) # -1Commentary. The bind n: len(route)
names the length once — the inline
if len(route) <= 1 then 0 else [...] works too, but the
local documents the intent and reads better in the comparisons that
follow.
The propagating--1 pattern is what HtDP would call a
tagged-failure return — borrowed from C-style “return -1 on
error.” Idiomatic Axioma would return none or an
om (SETL-unknown) instead; we use -1 because
it composes with arithmetic without needing an Option-style
monad. The chapter intentionally hasn’t introduced sum types or option
types yet — that’s Ch.18’s territory.
The four assertions exercise the three exits: success (95, 235, 460) and propagated failure (-1). For a real route-planner, you’d also want to surface which leg failed; one more local would do it.
count_parts: func(p) [
if p is BasicPart then 1
else if p is Assembly then count_parts_arr(p.parts)
else 0
]
count_parts_arr: func(arr) [
if len(arr) == 0 then 0
else count_parts(first(arr)) + count_parts_arr(rest(arr))
]
println(count_parts(frame)) # 1
println(count_parts(wheel)) # 3
println(count_parts(bike)) # 7Commentary. Textbook mutual recursion:
count_parts is the “variant dispatcher” (one branch per
Part flavor); count_parts_arr is the “list walker” (recurse
on the tail). They call each other across the Part / Array boundary.
A BasicPart contributes 1. An Assembly contributes the
sum over its parts (which may themselves be Assemblies, recursively).
The arithmetic is 1 + 3 + 3 = 7 for the bike (one frame,
three parts per wheel, two wheels).
This is the count-leaves-in-a-tree pattern (Ch.8 Ex.8.5 was
the binary-tree version). The shape generalizes: replace
len 1 nodes with arbitrary-arity “internal” nodes and you
get arbitrary trees. With reified edges (Ch.13 §13.2) you get arbitrary
graphs.
flatten_parts: func(p) [
if p is BasicPart then push([], p)
else if p is Assembly then flatten_arr(p.parts)
else []
]
flatten_arr: func(arr) [
if len(arr) == 0 then []
else flatten_parts(first(arr)) + flatten_arr(rest(arr))
]
println(len(flatten_parts(frame))) # 1
println(len(flatten_parts(wheel))) # 3
println(len(flatten_parts(bike))) # 7
sum_weights_of: func(arr) [
if len(arr) == 0 then 0
else first(arr).weight + sum_weights_of(rest(arr))
]
println(sum_weights_of(flatten_parts(bike))) # 5800Commentary. Same shape as count_parts,
but the per-leaf contribution is a one-element array of the part
itself and the combiner is array concatenation (+)
instead of numeric addition. (push([], p) builds the
singleton; the trailing-comma spelling [p,] from §6.6 does
the same job.)
The verification
sum_weights_of(flatten_parts(bike)) == 5800 re-derives
weight_of(bike) from §13.4 a different way. That’s not
redundant — it’s triangulation. Two independent code paths
should agree on the same answer; if they don’t, you’ve found a bug. Real
production code uses this technique constantly (a round-trip
property test: encode then decode and check the result equals the
input).
outgoing: func(c) [
filter(func(r) [r.src == c], roads)
]
neighbors: func(c) [
map(func(r) [r.dst], outgoing(c))
]
reach_via: func(s, d, fuel) [
if s == d then true
else if fuel == 0 then false
else [
nbrs: neighbors(s);
any_reach(nbrs, d, fuel - 1)
]
]
any_reach: func(arr, d, fuel) [
if len(arr) == 0 then false
else if reach_via(first(arr), d, fuel) then true
else any_reach(rest(arr), d, fuel)
]
route_exists: func(s, d) [
reach_via(s, d, len(roads) + 1)
]
println(route_exists(nyc, dc)) # true
println(route_exists(bos, dc)) # false
println(route_exists(dc, bos)) # trueCommentary. This is the §13.3 algorithm packaged up.
The fuel budget is len(roads) + 1 — any simple path through
the graph uses at most len(roads) edges, plus one for
headroom. For a much larger graph this would be inefficient (we
re-traverse cycles repeatedly within the fuel budget); a real
implementation uses an explicit visited set (Ch.15 territory:
accumulators). For a four-node graph the fuel approach is fine and is
genuinely simpler to reason about.
Answers to the chapter questions.
What happens without the fuel parameter? A cycle
like dc → nyc → bos and back to nyc would loop
forever. The reach_via call would dispatch to a neighbor, which
dispatches back, infinitely.
What happens with fuel too small? False
negatives. A genuine route requiring more hops than fuel
would report false when it should be true. The
correctness of route_exists depends on choosing fuel ≥ the
diameter of the graph.
Chapter 14 develops this discipline formally. Generative recursion
requires a termination argument — the fuel parameter is one
concrete realization of “argument that the search must end.” The
argument here: every recursive call decrements fuel, which
is a non-negative integer, which must reach zero. Recursion bounded ⇒ no
infinite loop ⇒ algorithm terminates.
Try first; compare second. The four-quadrant view from §13.5 is the map you’ll re-use whenever a new domain shows up: do edges have data? do cycles exist? Those two questions sort 95% of the modeling decisions you’ll make. The recipe — find the cross- references, write one function per concept, recurse along the cross-references, recurse along the edge arrays — is what’s left.
#language axioma/beginner
power: func(b, n) [
if n == 0 then 1
else if n % 2 == 0 then [
bb: b * b;
half: n / 2;
power(bb, half)
]
else b * power(b, n - 1)
]
println(power(2, 10)) # 1024
println(power(3, 0)) # 1
println(power(2, 16)) # 65536
println(power(5, 3)) # 125Commentary. Three cases. The trick is recognizing
the algebraic identity b^(2k) = (b * b)^k: doubling the
base and halving the exponent leaves the answer unchanged. Combined with
the “if odd, peel one off and recurse” case, this gives
O(log n) multiplications instead of the naive
O(n).
Termination argument (the answer to the chapter
question): the exponent n strictly decreases on every
recursive call.
n → n / 2, which is strictly less when
n > 0.n → n - 1, which is strictly less.n is a non-negative integer, so a strictly decreasing
sequence must hit 0 (the base case) in finitely many steps.
In fact, no more than 2 * log2(n) steps — each level either
halves or decrements-then-halves.
A note on big exponents. Axioma integers are arbitrary precision, so
power(2, 64)is the exact18446744073709551616andpower(2, 200)agrees with2 ^ 200digit for digit. The function is correct for any non-negative integer exponent.
#language axioma/beginner
nat_log2: func(n) [
if n <= 1 then 0
else [
half: n div 2;
1 + nat_log2(half)
]
]
println(nat_log2(1)) # 0
println(nat_log2(2)) # 1
println(nat_log2(7)) # 2
println(nat_log2(1024)) # 10Commentary. The simplest generative recursion in the
chapter. Halve until you’re at 1 (or below), count the
halvings.
Termination argument: n is a positive
integer. Each recursive call replaces n with
n div 2 (the integer part — plain / would give
exact fractions like 7/2 and both the count and the
termination bound would go wrong), which is strictly smaller when
n >= 2. (When n == 1 we hit the base case
without recursing.) So the recursion depth is bounded by the number of
times n can be halved before reaching 1 —
which is, by definition, log2(n).
nat_log2(1024) = 10 is correct because
2^10 = 1024. The function works correctly for any
n >= 1; for n == 0 it returns
0 (a defensible-but-arbitrary choice — strictly speaking
log2(0) is undefined).
#language axioma/beginner
hanoi: func(n, src, dst, via) [
if n == 0 then []
else hanoi(n - 1, src, via, dst)
+ push([], (src, dst))
+ hanoi(n - 1, via, dst, src)
]
println(hanoi(1, "A", "C", "B")) # [("A", "C")]
println(hanoi(2, "A", "C", "B"))
println(len(hanoi(3, "A", "C", "B"))) # 7
println(len(hanoi(5, "A", "C", "B"))) # 31Commentary. One of the cleanest pieces of recursion in computer science.
The algorithmic insight: to move n disks from
src to dst,
n-1 disks out of the way (to
via),src to
dst),n-1 disks from via onto
dst.The recursion writes itself once you’ve seen the trick. Each call
either solves the trivial base case (zero disks → no moves) or delegates
to two smaller calls of size n - 1 each.
Why generative? The subproblem of size
n - 1 is computed — n - 1 isn’t a
structural piece of any input data, it’s a new integer we derived.
(Compare: in sum_list(rest(arr)), rest(arr) is
a structural piece of arr.) The peg arguments also get
rearranged — src/via/dst becomes
src/dst/via in the first call, via/src/dst in
the second. That permutation is generative too.
Termination argument: n is a
non-negative integer. Each recursive call passes n - 1.
Strict decrease, bounded below by 0, so we hit the base
case in exactly n levels of nested recursion (with
2^n - 1 total calls at the leaves).
The output array length is 2^n - 1:
1, 3, 7, 15, 31, …. That exponential cost is
fundamental — any algorithm that solves Hanoi must make at
least that many moves, because every disk must be moved at least once,
and the discipline of “never put larger on smaller” forces
re-shuffling.
#language axioma/beginner
bsearch_count_in: func(arr, target, lo, hi, cmps) [
if lo > hi then (0, cmps)
else [
mid: (lo + hi) div 2;
v: arr[mid];
if v == target then (mid, cmps + 1)
else if target < v then bsearch_count_in(arr, target, lo, mid - 1, cmps + 1)
else bsearch_count_in(arr, target, mid + 1, hi, cmps + 1)
]
]
bsearch_count: func(arr, target) [
bsearch_count_in(arr, target, 1, len(arr), 0)
]
println(bsearch_count([1, 3, 5, 7, 9], 5)) # (3, 1) — mid hit first try
println(bsearch_count([1, 3, 5, 7, 9], 1)) # (1, 2)
println(bsearch_count([1, 3, 5, 7, 9], 11)) # (0, 3)
println(bsearch_count([1, 3, 5, 7, 9, 11, 13, 15, 17, 19], 19)) # (10, 4)Commentary. The same binary search as §14.5,
threaded with an accumulator cmps that increments on every
comparison. The helper carries the counter; the outer wrapper provides
the initial state cmps = 0.
This is accumulator-style recursion (Chapter 15’s main topic) snuck in early. Every recursive call passes the current count along; nothing’s lost between levels. Without an accumulator, you’d have to fold the count up after the fact — doable, but less direct than just carrying it down.
Why the counts? Algorithm-design tradition: counting
comparisons gives you a model-independent way to measure work that
doesn’t depend on CPU speed or compiler optimizations. For binary search
the count is bounded by log2(n) + 1; for linear search it’s
at worst n. The difference is dramatic for big arrays —
log2(1_000_000) ≈ 20 vs. 1_000_000.
Tuples such as (3, 1) are values you can index and
destructure (Chapter 6 §6.7), not just printable strings.
#language axioma/beginner
merge2: func(a, b) [
if len(a) == 0 then b
else if len(b) == 0 then a
else [
fa: first(a);
fb: first(b);
if fa <= fb then push([], fa) + merge2(rest(a), b)
else push([], fb) + merge2(a, rest(b))
]
]
merge_three: func(a, b, c) [
la: len(a);
lb: len(b);
lc: len(c);
if la == 0 and lb == 0 and lc == 0 then []
else if la == 0 then merge2(b, c)
else if lb == 0 then merge2(a, c)
else if lc == 0 then merge2(a, b)
else [
fa: first(a);
fb: first(b);
fc: first(c);
if fa <= fb and fa <= fc then push([], fa) + merge_three(rest(a), b, c)
else if fb <= fa and fb <= fc then push([], fb) + merge_three(a, rest(b), c)
else push([], fc) + merge_three(a, b, rest(c))
]
]
println(merge_three([1, 4, 7], [2, 5, 8], [3, 6, 9]))
println(merge_three([], [1, 2], [3, 4]))
println(merge_three([], [], []))
println(merge_three([1, 2], [], []))Commentary. Native three-way merge is a
case-explosion: four “exit” cases (one of the three arrays is empty, or
all three are), plus the three “interior” cases (each branch picks one
of the three first-elements based on min-of-three).
The local lengths la, lb, lc aren’t necessary —
we could inline len(a) everywhere — but they cost almost
nothing and make the code dramatically more readable.
The alternative:
merge2(a, merge2(b, c)). Simpler. Two-pass (builds
an intermediate sorted array of length len(b) + len(c),
then merges that with a). Native three-way avoids the
intermediate but isn’t dramatically faster in practice. Both are linear
in len(a) + len(b) + len(c).
Termination argument: the total work is bounded by
len(a) + len(b) + len(c), which strictly decreases by 1 at
each recursive step (one element is consumed). Reaches zero in at most
that many calls.
#language axioma/beginner
is_psq_search: func(n, lo, hi) [
if lo > hi then false
else [
mid: (lo + hi) div 2;
sq: mid * mid;
if sq == n then true
else if sq < n then is_psq_search(n, mid + 1, hi)
else is_psq_search(n, lo, mid - 1)
]
]
is_perfect_square: func(n) [
if n < 0 then false
else is_psq_search(n, 0, n)
]
println(is_perfect_square(0)) # true
println(is_perfect_square(1)) # true
println(is_perfect_square(16)) # true
println(is_perfect_square(17)) # false
println(is_perfect_square(144)) # true
println(is_perfect_square(143)) # falseCommentary. The same
binary-search-on-an-integer-range structure as bsearch from
§14.5, but the array is replaced by a predicate. At
each step we don’t compare to a stored value — we compute
mid * mid and compare to n.
This is binary search applied to implicit data — the sorted
sequence 0², 1², 2², 3², …, n². We never construct that
array; we just evaluate elements on demand. Same termination argument,
same O(log n) step count.
Answers to the chapter questions.
What’s the search range, and why does halving it
terminate? The range is [lo, hi], initially
[0, n]. Each recursive call shrinks the range by either
jumping to [mid + 1, hi] (which has size
hi - mid, strictly less than hi - lo + 1) or
to [lo, mid - 1] (size mid - lo, strictly
less). The range size is a non-negative integer that strictly decreases,
so it must reach 0 (when lo > hi).
Why is sqrt(n) * sqrt(n) == n unreliable for
large n? sqrt returns a
floating-point number. Floats have finite precision (about 15
significant decimal digits for 64-bit double precision). For
n large enough — roughly above 2^52 —
sqrt(n) * sqrt(n) doesn’t exactly equal n even
when n is a true perfect square. You’d get spurious
falses for very large perfect squares. The integer binary
search avoids floating-point entirely; it’s exact by
construction.
This is one of the recurring reasons to prefer integer-only
algorithms when correctness matters: floats are a model of real
numbers, not the same thing. (Axioma’s exact rationals dodge this for
division — 7 / 2 really is 7/2 — but
sqrt, trig, and friends still land you in float territory,
where the model leaks.)
The four-step recipe for generative recursion — trivial case + subproblem + combiner + termination — is the take-away of the chapter. When you sit down to design a new algorithm, explicitly write each of the four. If you can’t write the termination argument, you don’t yet have an algorithm.
Pop quiz for after you’ve worked all six exercises:
#language axioma/beginner
sum_acc: func(arr, acc) [
if len(arr) == 0 then acc
else sum_acc(rest(arr), acc + first(arr))
]
sum: func(arr) [ sum_acc(arr, 0) ]
println(sum([])) # 0
println(sum([7])) # 7
println(sum([1, 2, 3, 4, 5])) # 15Commentary. The textbook conversion of Ch.7’s
natural sum_list into accumulator form. The wrapper
sum supplies the seed (0, the additive
identity). The worker sum_acc carries the running total in
its second parameter.
The recursive call is
sum_acc(rest(arr), acc + first(arr)) — nothing pending
after the call. This is tail-recursive, and Axioma’s
self-tail-call optimization runs it in constant stack space at any depth
(verified at 100,000 levels — the R5RS-Scheme behavior). The
optimization is per-function: mutual tail recursion between two
functions still hits the ~50,000-frame ceiling.
The agreement with §15.1’s natural sum_list on every
input is guaranteed because addition is associative and
commutative, which makes the left-fold ((0 + 1) + 2) + 3
equal to the right-fold 1 + (2 + (3 + 0)).
#language axioma/beginner
prod_acc: func(arr, acc) [
if len(arr) == 0 then acc
else prod_acc(rest(arr), acc * first(arr))
]
prod: func(arr) [ prod_acc(arr, 1) ]
println(prod([])) # 1
println(prod([7])) # 7
println(prod([2, 3, 4])) # 24
println(prod([1, 2, 3, 4, 5])) # 120Why not seed with 0? Because
0 * x = 0 for any x. A zero seed would make
prod return 0 for every input. The
correct seed is the identity element of the operation —
1 for multiplication, 0 for addition,
[] for concatenation, the universe set for
intersection.
This is one of the recurring patterns of accumulator design:
the seed is the operation’s identity element. Every
mathematical operation that’s associative has a unique identity (or
doesn’t have one at all, in which case the accumulator needs special
handling — see Ex.15.4 of max_of and the “seed with first
element” trick).
#language axioma/beginner
len_acc: func(arr, acc) [
if len(arr) == 0 then acc
else len_acc(rest(arr), acc + 1)
]
my_len: func(arr) [ len_acc(arr, 0) ]
println(my_len([])) # 0
println(my_len([1, 2, 3])) # 3Commentary. “Count things” doesn’t care about the
things themselves — only their number. The accumulator counts;
the head of the array gets dropped each step (we never use
first(arr)).
This is exactly how Axioma’s builtin len is implemented
for arrays under the hood. Re-deriving it in three lines of beginner
code is a small flex: you’ve just written one of the most-used list
operations in the language from primitives.
#language axioma/beginner
count_evens_acc: func(arr, acc) [
if len(arr) == 0 then acc
else [
bump: if first(arr) % 2 == 0 then 1 else 0;
count_evens_acc(rest(arr), acc + bump)
]
]
count_evens: func(arr) [ count_evens_acc(arr, 0) ]
println(count_evens([])) # 0
println(count_evens([1, 3, 5])) # 0
println(count_evens([2, 4, 6])) # 3
println(count_evens([1, 2, 3, 4, 5, 6])) # 3Commentary. Same shape as len_acc, but
the per-element contribution depends on the element. We compute
bump (either 0 or 1) as a local
and add it to the accumulator. The local also serves as documentation —
it would be perfectly correct (and shorter) to inline the conditional,
but readability wins.
Equivalently, you could use filter + len:
len(filter(func(x) [x % 2 == 0], arr)) — two passes (the
filter builds an intermediate array, then len walks it).
The accumulator version is one pass and never allocates. For small
arrays the difference is invisible; for large arrays it matters.
#language axioma/beginner
sc_acc: func(arr, s, c) [
if len(arr) == 0 then (s, c)
else sc_acc(rest(arr), s + first(arr), c + 1)
]
sum_and_count: func(arr) [ sc_acc(arr, 0, 0) ]
println(sum_and_count([])) # (0, 0)
println(sum_and_count([10, 20, 30])) # (60, 3)
println(sum_and_count([1.0, 2.0, 3.0, 4.0])) # (10.0, 4)Commentary. Two accumulators carried in parallel. The base case returns both as a tuple. The recursive step updates both in the same call.
This pattern generalizes immediately to as many parallel accumulators as you need. The classical “online mean and variance” algorithm carries three accumulators (sum, sum-of-squares, count). The Boyer-Moore majority-vote algorithm carries two (current candidate, current count). Anything that can be expressed as a single-pass streaming statistic has this shape.
Mean from this is one line:
sum_and_count(arr)[1] / sum_and_count(arr)[2] (which calls
the worker twice — for a purer one-shot version, decompose the tuple in
a local).
#language axioma/beginner
rs_acc: func(arr, total, out) [
if len(arr) == 0 then out
else [
nt: total + first(arr);
rs_acc(rest(arr), nt, out + push([], nt))
]
]
running_sums: func(arr) [ rs_acc(arr, 0, []) ]
println(running_sums([])) # []
println(running_sums([7])) # [7]
println(running_sums([1, 2, 3, 4])) # [1, 3, 6, 10]Commentary. Three values in flight per step:
arr — the remaining inputtotal — the running sum so farout — the output array being built incrementallyEach step computes the new total (total + first(arr)),
appends it to out, and recurses with all three updated.
The output array grows at the end, which means we’re doing
O(n) concatenations of growing arrays — total cost
O(n²). A more careful implementation prepends to the front
and reverses at the end, getting it down to O(n). For a
textbook example, clarity wins; the cost optimization is a fine exercise
for the reader.
This exact pattern shows up everywhere: “carry the partial
answer, carry the in-progress output, return the output at base case.”
It’s the imperative-loop pattern made functional — and exactly what
Chapter 16’s while loop will do with mutation instead of
recursion.
#language axioma/beginner
sum_with_loop: func(arr) [
i: 1;
total: 0;
n: len(arr);
while (i <= n) [
total = total + arr[i];
i = i + 1
];
total
]
println(sum_with_loop([])) # 0
println(sum_with_loop([7])) # 7
println(sum_with_loop([1, 2, 3, 4, 5])) # 15Commentary. The accumulator-recursion of §15.1
literally becomes a while loop: i
plays the role of the index “counter,” total plays the role
of the accumulator, and the loop body advances both. The function-call
cost of the recursive version is replaced by raw mutation in place.
The n: len(arr) bind names the length up front —
while (i <= len(arr)) [...] works too, but the local
reads better and avoids re-computing len(arr) on every
iteration (though Axioma’s len is O(1) for
arrays, so this is a clarity improvement, not a perf one).
Compare and contrast: this code reads almost identically to the Python:
def sum_with_loop(arr):
i, total, n = 0, 0, len(arr)
while i < n:
total += arr[i]
i += 1
return totalWith the obvious differences in indexing (Axioma is 1-based; Python
0-based) and syntax. Mutation + while is the
universal programming idiom; it just took us 15 chapters to get
here.
#language axioma/beginner
make_bank_account: func(start_balance) [
balance: start_balance;
func(amount) [
new_b: balance + amount;
if new_b < 0 then balance
else [
rebind balance = new_b;
balance
]
]
]
acc: make_bank_account(100)
v1: acc(50) # 150
v2: acc(-30) # 120
v3: acc(-200) # 120 (refused)
v4: acc(0) # 120 (query)Commentary. The closure captures
balance. Each call:
balance + amount).balance to the new
value, return the new value.This is the canonical “private-field” pattern. balance
is invisible outside the closure — no top-level code can read
or write it directly; the only access is through
acc(amount). That’s encapsulation — the cornerstone of
object-oriented design.
A acc(0) “query” returns the current balance without
changing it (because balance + 0 == balance, the “commit”
branch writes the same value back). For a cleaner query, you’d refactor
into a tuple-of-functions like (deposit, withdraw, query) —
exactly the §16.4 turnstile pattern, with three methods on one piece of
private state.
#language axioma/beginner
make_traffic_light: func() [
s: "red";
func() [
if s == "red" then [rebind s = "green"]
else if s == "green" then [rebind s = "yellow"]
else [rebind s = "red"];
s
]
]
light: make_traffic_light()
l1: light() # green
l2: light() # yellow
l3: light() # red
l4: light() # greenCommentary. A 3-state cycle. The cascade
if … else if … else … reads the current state and chooses
the next one. After the if, we read the (now-updated) state
and return it.
Notice the brackets around each assignment branch:
[s = "green"]. The Axioma parser requires explicit blocks
for assignment statements inside if arms (single-expression
arms without brackets are treated as expressions, and assignment isn’t
an expression in this context). The brackets are noise, but they’re
consistent noise — once you see the pattern in a few state
machines, it’s automatic.
Three-state cycles are everywhere: rock-paper-scissors, traffic lights, day-shift/swing-shift/night-shift schedules, RGB color rotators. The same code shape solves all of them; just rename the states.
#language axioma/beginner
make_turnstile: func() [
state: "locked";
people: 0;
coin: func() [
if state == "locked" then [rebind state = "unlocked"]
else [rebind state = state];
state
];
push_t: func() [
if state == "unlocked" then [
rebind state = "locked";
rebind people = people + 1
]
else [rebind state = state];
state
];
passing_count: func() [ people ];
(coin, push_t, passing_count)
]
t: make_turnstile()
coin: t[1]
push_t: t[2]
passing_count: t[3]
r1: coin()
r2: push_t()
println(passing_count()) # 1Commentary. Three functions share the same private
state. The trick: make_turnstile defines state
and people as locals (so they’re captured by the closures),
then returns the three closures as a tuple. Callers destructure
the tuple and use each function independently — but the three functions
all see the same state and people
cells.
This is the multi-method object pattern. Smalltalk,
Self, JavaScript (without class syntax), Lua, Scheme — all
do this. A “class” is really a function that returns a
tuple-of-functions sharing private state.
You’ve just hand-rolled an object system in two screens of Axioma. The only thing missing for full OOP is inheritance — and that’s what Chapter 18 will revisit when Concepts and sum types come back into the picture.
#language axioma/beginner
find_first_with_loop: func(arr, pred) [
i: 1;
n: len(arr);
found: none;
while (i <= n and found == none) [
v: arr[i];
if pred(v) then [found = v]
else [found = found];
i = i + 1
];
found
]
println(find_first_with_loop([1, 3, 5, 7], func(x) [x > 4])) # 5
println(find_first_with_loop([1, 3, 5], func(x) [x > 100])) # none
println(find_first_with_loop([], func(x) [true])) # noneCommentary. Two loop conditions:
i <= n (haven’t run past the end) and
found == none (haven’t found anything yet). Once either is
false, the loop exits.
found starts as none and becomes the first
matching element. The else [found = found] no-op satisfies
the parser’s expression-statement requirement (assignments in
if arms need brackets).
The recursive version is shorter:
find_first_rec: func(arr, pred) [
if len(arr) == 0 then none
else if pred(first(arr)) then first(arr)
else find_first_rec(rest(arr), pred)
]Both are correct. The loop version uses constant stack space (no recursive frames), reads imperatively, and stops exactly when it finds a match — no wasted work, no pending pending computation.
The loop version returns and prints none when no element
matches. Distinct from om (the SETL “unknown” value), which
renders as Ω.
#language axioma/beginner
make_cell: func(initial_alive) [
alive: initial_alive;
func(neighbors) [
newstate: alive;
if alive then [
if neighbors == 2 or neighbors == 3 then [newstate = true]
else [newstate = false]
]
else [
if neighbors == 3 then [newstate = true]
else [newstate = false]
];
rebind alive = newstate;
alive
]
]
c: make_cell(true)
s1: c(2) # true (lives with 2 neighbors)
s2: c(1) # false (dies — under-population)
s3: c(3) # true (born again — exactly 3 neighbors)
s4: c(0) # false (dies — under-population)Commentary. Conway’s rules collapse into one nested
if. The cell’s alive field is captured by the
closure; each c(neighbors) call reads the current state,
computes the new state per Conway’s table, mutates, and returns.
(Note the rename: next is reserved in Axioma — we used
newstate instead.)
Orchestrating many cells. The minimum each cell
needs: its own alive state and an
update(neighbors) method. That’s exactly what
make_cell provides. A grid would be:
grid[row][col] of cells.update — never read-and-update in one pass, or the
cells you’ve already updated will pollute the counts of the cells you
haven’t yet.The two-phase split (count, then update) is the canonical parallel-step semantics. Real Game of Life implementations use double-buffering: maintain two grids, update one from the other, then swap. The cell-as-closure version demonstrates the unit; scaling to a grid is engineering.
Common preamble — the four basic stream operators that every exercise builds on:
#language axioma/beginner
nats_from: func(k) [ (k, func() [nats_from(k + 1)]) ]
stream_head: func(s) [ s[1] ]
stream_tail: func(s) [ s[2]() ]
take_stream: func(n, s) [
if n == 0 then []
else [
h: stream_head(s);
t: stream_tail(s);
push([], h) + take_stream(n - 1, t)
]
]
map_stream: func(f, s) [
(f(stream_head(s)), func() [map_stream(f, stream_tail(s))])
]
filter_stream: func(pred, s) [
h: stream_head(s);
if pred(h) then (h, func() [filter_stream(pred, stream_tail(s))])
else filter_stream(pred, stream_tail(s))
]integers_from: func(start, step) [
(start, func() [integers_from(start + step, step)])
]
println(take_stream(5, integers_from(0, 1))) # [0, 1, 2, 3, 4]
println(take_stream(5, integers_from(10, 5))) # [10, 15, 20, 25, 30]
println(take_stream(5, integers_from(0, -1))) # [0, -1, -2, -3, -4]Commentary. The classic generalization of
nats_from. Two parameters threaded through the recursion:
start advances by step each call. The thunk
captures both.
Negative step works because start + step is
just integer addition — no implicit “must be positive.” The stream
descends as easily as it ascends.
This single function subsumes nats_from
(integers_from(k, 1)) and could be the basis of arithmetic
progressions in any base. Combined with take_while, it
expresses bounded ranges without needing a separate
range(a, b) builtin:
take_while(func(x) [x <= 100], integers_from(1, 1)).
take_while: func(pred, s) [
h: stream_head(s);
if pred(h) then push([], h) + take_while(pred, stream_tail(s))
else []
]
println(take_while(func(x) [x < 5], nats_from(1))) # [1, 2, 3, 4]
println(take_while(func(x) [x > 0], integers_from(10, -1))) # [10, 9, ..., 1]Commentary. Eager unfolding —
take_while materializes a prefix of the stream
into an array. The recursion stops at the first element that fails the
predicate.
Important caveat: if no element ever fails the
predicate, take_while loops forever.
take_while(func(x) [true], nats_from(1)) would try to
materialize all the naturals, which is impossible. The exercise prompt
explicitly says “the exercise assumes a finite false-prefix.”
A safer version takes a maximum count as a third argument:
take_while_max(n, pred, s) — but that’s a different
function, useful for different purposes.
zip_streams: func(s1, s2) [
((stream_head(s1), stream_head(s2)),
func() [zip_streams(stream_tail(s1), stream_tail(s2))])
]
println(take_stream(5, zip_streams(nats_from(1), nats_from(100))))
# [(1, 100), (2, 101), (3, 102), (4, 103), (5, 104)]Commentary. Walk two streams in lockstep, pairing their heads. The head of the zipped stream is a tuple of the two input heads; the tail is a thunk that recursively zips the tails.
If either stream is shorter than the other, this version loops forever trying to advance the shorter one past its end — but in this chapter we only work with infinite streams, so the question doesn’t come up. A “stop at the shorter” version would check both heads and emit an end-marker when one fails to advance.
zip_streams plus take_stream plus
map_stream lets you do correlated computations on
parallel infinite sequences: “add element-wise the squares and the cubes
of the naturals,” “compare the Fibonacci numbers to the Lucas numbers at
each index,” etc.
stream_of_squares: map_stream(func(x) [x * x], nats_from(1))
println(take_stream(5, stream_of_squares)) # [1, 4, 9, 16, 25]Commentary. One line. map_stream does
all the work; we just feed it the right ingredients. This is the
whole point of composing stream operations: simple primitives
compose into rich derived streams without ceremony.
Notice we don’t iterate explicitly anywhere. There’s no
for, no while, no manual i++ —
just “apply this function to every element of this stream.” The stream’s
laziness means stream_of_squares is defined
without any work actually happening; the work only happens when
take_stream peels off the first five values.
drop_k: func(s, m) [
if m == 0 then s
else drop_k(stream_tail(s), m - 1)
]
every_kth: func(s, k) [
(stream_head(s), func() [every_kth(drop_k(s, k), k)])
]
println(take_stream(5, every_kth(nats_from(1), 2))) # [1, 3, 5, 7, 9]
println(take_stream(5, every_kth(nats_from(1), 5))) # [1, 6, 11, 16, 21]Commentary. Two functions:
drop_k(s, m) advances a stream forward m
steps, returning the stream from there on. Pure recursion on the count
m; no work on the stream’s contents.every_kth(s, k) yields the current head, then in the
thunk it drop_ks ahead by k and recurses.Why drop_k(s, k) instead of
drop_k(s, k - 1)? Because between successive “k-th”
elements you need to advance k steps total: one to consume
the current head, plus k - 1 more to land on the next k-th
position. So you advance by k, not
k - 1.
Verify: with k = 2, after yielding 1 you
advance 2 → land on 3; yield 3, advance 2 →
land on 5. Matches.
This sampling pattern is useful for “downsample a sensor stream” or “every 60th frame of a video stream” — anything where the source produces faster than you want to consume.
sieve: func(s) [
p: stream_head(s);
(p, func() [
sieve(filter_stream(func(x) [x % p != 0], stream_tail(s)))
])
]
primes: sieve(integers_from(2, 1))
println(take_stream(10, primes)) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]Commentary. Five lines for the prime sieve. Every
recursive call peels off the next prime p, then
sieves the rest of the stream to remove every multiple of p
before recursing.
Answers to the chapter questions.
Why does each prime sieve the rest?
Because once you’ve identified p as prime, every multiple
of p greater than p cannot possibly be prime.
Removing them now means sieve doesn’t have to
re-test them later. The very first call to sieve (on
integers_from(2, 1)) peels off 2, then sieves
out every even number from the rest. The next call works on
[3, 5, 7, 9, 11, 13, ...] — but 9 is
3 * 3 and gets sieved out at the next layer, so the
next prime emitted is 5. And so on.
What’s the asymptotic cost? Painful, actually.
Each prime sets up a new filter_stream layer. By the time
we’re producing the k-th prime, every value being
considered has to pass through k - 1 layers of nested
filter. For the first 10 primes that’s fine; for the first 10,000 primes
the cost grows roughly as O(n²) per prime extracted. Plenty
fast for textbook examples, useless for “list me the first million
primes.”
The efficient sieve. The classical Sieve of
Eratosthenes is not this. It allocates an array of size
n, marks composites by walking each prime’s multiples, and
reads off the surviving primes — O(n log log n) total. The
stream version looks like the sieve but implements a
trial-division-style algorithm. The stream version wins on
aesthetics (a five-line recursive function vs. a 20-line
array-mutation loop) and loses on performance
(O(n²) vs. O(n log log n)). This trade-off —
expressiveness vs. efficiency — is the recurring tension at the
heart of high-level languages. Real implementations of
primes in production libraries pick efficiency.
The stream pattern is defined by the
(head, thunk) pair. Every stream function is one of:
(value, thunk) pair from scratch
(nats_from, integers_from).(transformed_head, thunk-that- transforms-the-tail) pair
(map_stream, filter_stream,
zip_streams, every_kth).take_stream, take_while).sieve).Once the pair-and-thunk shape feels natural, you can write any stream operation in a few lines. The verbosity is front-loaded into the four primitives; everything else composes cleanly.
Common preamble for the Shape exercises:
concept Shape
Circle extends Shape
Circle has radius: 0.0
Rectangle extends Shape
Rectangle has width: 0.0
Rectangle has height: 0.0
Triangle extends Shape
Triangle has base: 0.0
Triangle has height: 0.0
Shape partition Circle, Rectangle, Triangleperimeter: func(s) [
if s is Circle then 2.0 * 3.14159 * s.radius
else if s is Rectangle then 2.0 * (s.width + s.height)
else if s is Triangle then 3.0 * s.base
else 0.0
]
println(perimeter(a Circle {radius: 5.0})) # ~31.42
println(perimeter(a Rectangle {width: 3.0, height: 4.0})) # 14.0
println(perimeter(a Triangle {base: 5.0, height: 4.33})) # 15.0Commentary. Exactly the same is-cascade
shape as area, but with different per-variant formulas. The
structure of the function is “variant-dispatch then arithmetic”
— change the arithmetic, keep the structure. That factor-out-the-shape
principle is the same instinct that drove map and
filter in Part III.
The Triangle case assumes an equilateral triangle so the “perimeter”
formula needs only base. If we wanted general triangles
we’d add a side2 and side3 field — but at that
point we should think about whether Triangle is really
one shape or several (right triangles, equilateral,
isoceles, scalene). The HtDP design recipe answer: refactor when the
data definition outgrows its current shape.
concept LightState
RedLight extends LightState
GreenLight extends LightState
YellowLight extends LightState
LightState partition RedLight, GreenLight, YellowLight
next_light: func(s) [
if s is RedLight then a GreenLight {}
else if s is GreenLight then a YellowLight {}
else if s is YellowLight then a RedLight {}
else none
]
r: a RedLight {}
g: next_light(r)
y: next_light(g)
r2: next_light(y)
println(g is GreenLight) # true
println(y is YellowLight) # true
println(r2 is RedLight) # trueCommentary. A 3-cycle via fresh instances. Each
variant is type-tagged but carries no data (the braces in
a GreenLight {} are empty); the type is the
value.
Compare to Ch.16’s traffic light, which used a mutable string variable. Two completely different mechanisms for the same logical effect:
The Concept version is type-safer: writing
a BlueLight {} would be obviously wrong (no such Concept
exists). The string version’s misspell ("yelow") would
silently fall through to else. Trade-offs again: the
Concept version is more verbose to set up, more robust once running.
concept Animal
Animal has name: ""
Vertebrate extends Animal
Invertebrate extends Animal
Animal partition Vertebrate, Invertebrate
Mammal extends Vertebrate
Bird extends Vertebrate
Fish extends Vertebrate
Reptile extends Vertebrate
Amphibian extends Vertebrate
Vertebrate partition Mammal, Bird, Fish, Reptile, Amphibian
dog: a Mammal {name: "Rex"}
robin: a Bird {name: "Robin"}
salmon: a Fish {name: "Sal"}
gecko: a Reptile {name: "Geck"}
frog: an Amphibian {name: "Hop"}
is_warm_blooded: func(a) [
if a is Mammal then true
else if a is Bird then true
else false
]
println(is_warm_blooded(dog)) # true
println(is_warm_blooded(robin)) # true
println(is_warm_blooded(salmon)) # false
println(is_warm_blooded(gecko)) # false
println(is_warm_blooded(frog)) # falseCommentary. A two-level taxonomy. The first partition splits Animals into Vertebrates and Invertebrates; the second splits Vertebrates into the five vertebrate classes biologists recognize.
is_warm_blooded only cares about the vertebrate-class
level — so we dispatch on that level directly via
is Mammal / Bird. Notice we don’t need to test
is Vertebrate first; is Mammal
implies is Vertebrate implies
is Animal. The transitivity of the is relation
does the checking for us.
Real biological taxonomy goes much deeper — Kingdom → Phylum → Class
→ Order → Family → Genus → Species, seven levels for a single species.
The same is/partition machinery scales.
expr_eval revisitedconcept Expression
Lit extends Expression
Lit has value: 0
Op extends Expression
Op has kind: "+"
Op has lhs: none
Op has rhs: none
Expression partition Lit, Op
eval_expr: func(e) [
if e is Lit then e.value
else if e is Op then [
l: eval_expr(e.lhs);
r: eval_expr(e.rhs);
if e.kind == "+" then l + r
else if e.kind == "-" then l - r
else if e.kind == "*" then l * r
else if e.kind == "/" then l / r
else 0
]
else 0
]
e1: a Lit {value: 1}
e2: a Lit {value: 2}
plus: an Op {kind: "+", lhs: e1, rhs: e2}
println(eval_expr(plus)) # 3Commentary. The Ch.9 code reorganized to have
both Lit extends Expression and
Op extends Expression and an explicit
Expression partition Lit, Op. The function body is
unchanged from Ch.9.
What does the partition buy us? Conceptual clarity now;
exhaustiveness-checking later (if a future --typecheck
learns to warn about non-exhaustive is-cascades). For the
reader, “Expression is exactly Lit or Op” is useful
information. The partition says it.
csbt_acc: func(arr, nc, nr, nt) [
if len(arr) == 0 then (nc, nr, nt)
else [
s: first(arr);
if s is Circle then csbt_acc(rest(arr), nc + 1, nr, nt)
else if s is Rectangle then csbt_acc(rest(arr), nc, nr + 1, nt)
else if s is Triangle then csbt_acc(rest(arr), nc, nr, nt + 1)
else csbt_acc(rest(arr), nc, nr, nt)
]
]
count_shapes_by_type: func(arr) [ csbt_acc(arr, 0, 0, 0) ]
c1: a Circle {radius: 1.0}
c2: a Circle {radius: 2.0}
c3: a Circle {radius: 3.0}
r1: a Rectangle {width: 1.0, height: 2.0}
t1: a Triangle {base: 1.0, height: 1.0}
println(count_shapes_by_type([c1, r1, t1, c2, c3])) # (3, 1, 1)
println(count_shapes_by_type([])) # (0, 0, 0)Commentary. Ch.15’s accumulator-recursion pattern
with three carriers (nc, nr, nt) instead of one. The
dispatch chooses which accumulator gets incremented at each
step.
This shape generalizes to N variants. The pattern: one
accumulator per variant. The base case returns the tuple of all
accumulators. The recursive step uses is-dispatch to choose
which accumulator to bump.
A more abstract version would use a dictionary mapping
variant-name → count, growing the dictionary as new variants are
encountered. Axioma’s hash-literal syntax ({kind: 0}) makes
that practical too; the tuple version is simpler and fine for small
known variant counts.
Sample answer: musical notes
concept Sound
Sound has duration: 0.0 # seconds
Note extends Sound
Note has freq_hz: 440.0 # default A4
Chord extends Sound
Chord has notes: [] # array of Sound (recursive!)
Rest extends Sound
# Rest has no extra fields — duration alone is enough
Sound partition Note, Chord, Rest
describe: func(s) [
if s is Note then "single tone at " + s.freq_hz + " Hz"
else if s is Chord then "chord of " + len(s.notes) + " notes"
else if s is Rest then "silence for " + s.duration + " sec"
else "unknown"
]
middle_c: a Note {freq_hz: 261.63, duration: 0.5}
chord: a Chord {notes: [middle_c], duration: 1.0}
rest_a_beat: a Rest {duration: 0.25}
println(describe(middle_c))
println(describe(chord))
println(describe(rest_a_beat))Commentary on the design.
describe switches on the
variant tag and uses the variant-specific field.Compared to other languages.
Sound superclass. Axioma’s extends
mirrors Java’s extends closely.data Sound = Note Float Float | Chord [Sound] | Rest Float.
Sum types make the variant set obviously closed.duration
attribute plus isinstance dispatch. Looks almost identical to
Axioma.Each language has its idiom; Axioma’s is +
extends + partition reads similarly to the
inheritance approaches and adds compile-time-checkable invariants.
Read the partition line first: it tells you the design
space. Then trace is to see the per-variant fields. Then
the function bodies dispatch on is. Every Concept- based
program has this same structure once you know what to look for.
Suit enumerates Hearts, Diamonds, Clubs, Spades
is_red: func(s) [
s == Hearts or s == Diamonds
]
is_minor: func(s) [
s == Diamonds or s == Clubs
]
println(is_red(Hearts)) # true
println(is_red(Spades)) # false
println(is_minor(Diamonds)) # true
println(is_minor(Hearts)) # falseCommentary. Four enum values, two predicates. Each
predicate combines equality checks with or. Notice we never
need to create a Suit — the enumerates declaration
already created all four legal values as top-level names.
A more abstract version could use array membership:
s in {Hearts, Diamonds}. The functional one-liner is the
same thing, written longhand. Either reads fine.
Digit ranges 0..9
safe_div: func(a, b) [
if b == 0 then 0 else a / b
]
println(safe_div(8, 2)) # 4
println(safe_div(7, 0)) # 0Commentary. The function body doesn’t need to
check the range — the type annotation does that for the caller.
When the caller writes safe_div(15, 3), Axioma errors
before the function body runs:
type error: variable 'a' expects type Digit, got INTEGER (value: 15)
That’s the type-as-precondition idea: instead of writing “//
caller must pass 0..9” in a comment, write :: Digit in the
annotation. The compiler enforces.
What if you genuinely want both Digit-typed and
Integer-typed callers? Two functions: one whose annotation is
Digit (machine-checked), one whose annotation is
Integer (more permissive). Pick the function for the
call site’s needs.
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
Workday extends Day in Mon..Fri
schedule_check: func(d, title) [
"Scheduled: " + title + " on " + d
]
println(schedule_check(Tue, "Meeting")) # "Scheduled: Meeting on Tue"Commentary. Three-piece string concatenation. The
body doesn’t say “is d a workday?” — it trusts the
type annotation. The function declared to take Workday
won’t ever see Sat/Sun.
For full enforcement, we’d write the function with explicit
type annotation:
schedule_check: func(d :: Workday, title) [...]. The
annotation lives in the type declaration; the behavior of the function
doesn’t change either way.
If you do try schedule_check(Sat, "Brunch"),
the runtime errors with “type error: variable ‘d’ declared as
Workday, assigned value of type CONCRETE_OBJECT (value: Sat)”. The
function body never executes. That’s the win.
Hour ranges 0..23
is_business_hours: func(h) [
h >= 9 and h < 17
]
println(is_business_hours(10)) # true
println(is_business_hours(20)) # falseCommentary. Plain Boolean math, but the type
constraint makes the function safe for any 24-hour clock value.
Calling with 25 (a nonsensical “hour”) gets rejected at
binding time. Calling with -1 (also nonsensical)
likewise.
What if you wanted to model 12-hour-clock time (1..12)? Declare
Hour12 ranges 1..12 and re-annotate. Two different
subranges, two different types, no shared identity — exactly right
because they really are different kinds of “hour.”
LockState enumerates Locked, Unlocked, Jammed
make_lock: func() [
state: Locked;
attempts: 0;
func(guess) [
if state == Jammed then [rebind state = state]
else if guess == "open_sesame" then [rebind state = Unlocked]
else [
rebind attempts = attempts + 1;
if attempts > 3 then [rebind state = Jammed]
else [rebind state = state]
];
state
]
]
lock: make_lock()
r1: lock("wrong") # Locked
r2: lock("wrong") # Locked
r3: lock("wrong") # Locked
r4: lock("wrong") # Jammed (4th wrong attempt)
println(r1)
println(r2)
println(r3)
println(r4)Commentary. A state machine + counter, hidden inside a closure. Three behavioral cases: 1. Already Jammed → stay there (lock is permanently broken). 2. Correct guess → unlock. 3. Wrong guess → increment attempts; if > 3 then Jammed, else stay Locked.
Compared to Ch.16’s turnstile, the state space is larger (3 states
instead of 2) and the state depends on cumulative events (the
attempts counter), not just the current event. That’s the
full state-machine pattern: states + counter + guard-conditions.
This is the canonical combination chapter for Part V/VI: mutation (Ch.16), enums (Ch.19), and closures-with-state (Ch.16) all working together in one small device.
Grade enumerates Fail, Poor, Avg, Good, Top
PassingGrade extends Grade in Poor..Top
HonorsGrade extends Grade in Good..Top
letter_to_gpa: func(g) [
if g == Fail then 0.0
else if g == Poor then 1.0
else if g == Avg then 2.0
else if g == Good then 3.0
else if g == Top then 4.0
else 0.0
]
is_passing: func(g) [ g != Fail ]
qualifies_for_honors: func(g) [ g == Top or g == Good ]
println(letter_to_gpa(Top)) # 4.0
println(letter_to_gpa(Fail)) # 0.0
println(is_passing(Poor)) # true
println(is_passing(Fail)) # false
println(qualifies_for_honors(Good)) # true
println(qualifies_for_honors(Avg)) # falseCommentary. Five enum levels, two subrange subtypes. Three predicate/computation functions over them.
Answer to the design question.
qualifies_for_honors could take parameter type
HonorsGrade, but that would restrict callers to
only pass grades that already qualify for honors —
which makes the predicate trivially true. That’s almost
certainly not what you want.
The principle: the parameter type should match what the
caller might pass, not what the function
needs. qualifies_for_honors answers a
question about a Grade; its parameter should accept
any Grade, and the body decides the answer. Restricting the
parameter would shrink the function’s domain to nothing useful.
Where HonorsGrade would be the right
annotation: a function that takes an already-qualified grade and returns
the diploma type, scholarship eligibility, etc. The
HonorsGrade type would enforce at the call site
that the caller has already done the qualification check.
This is the difference between predicate functions (should take broad types and return Booleans) and operation functions (should take narrow types that guarantee preconditions, so the body can skip them). Encoding which is which in the type system is a strict-language pattern that Ada, Modula-3, and modern Rust all push hard. Axioma’s subrange/subtype pairing brings the same discipline to beginner code.
The thread through these six exercises: encode invariants in types.
Every annotation removes a class of possible bugs from the code that follows. The boilerplate of declaring the types is the one-time cost; the safety is every time the code runs.
For real-world software, that ratio is wildly in favor of the types. Ada has been used in commercial avionics for 50 years on exactly this bet.
definitely_pass: func(submitted, passed_grade, attendance_ok) [
submitted and passed_grade and attendance_ok
]
println(definitely_pass(true, true, true)) # true
println(definitely_pass(true, false, true)) # false
println(definitely_pass(true, om, true)) # Ω
println(definitely_pass(om, om, om)) # Ω
println(definitely_pass(om, false, om)) # false
println(definitely_pass(false, om, true)) # falseCommentary. A one-liner. The K3 and
operator does the heavy lifting:
true → true (Boolean degenerate
case)false → false (because
and short-circuits to the absorbing value, even with
om in the mix)true and om → Ω
(genuinely cannot decide)The pedagogical win: the same expression that works for Boolean inputs also handles unknown inputs correctly. You don’t write two functions and dispatch on whether the inputs are all-Boolean; the operators dispatch on the operand types.
at_least: func(v, t) [
(v or lukasiewicz(t)) == v
]
at_most: func(v, t) [
(v and lukasiewicz(t)) == v
]
hiring_decision: func(tech, comm, team) [
if at_least(tech, 0.7) and at_least(comm, 0.7) and at_least(team, 0.7) then "hire"
else if at_most(tech, 0.3) or at_most(comm, 0.3) or at_most(team, 0.3) then "reject"
else "interview again"
]
println(hiring_decision(lukasiewicz(0.9), lukasiewicz(0.8), lukasiewicz(0.7))) # hire
println(hiring_decision(lukasiewicz(0.1), lukasiewicz(0.8), lukasiewicz(0.8))) # reject
println(hiring_decision(lukasiewicz(0.5), lukasiewicz(0.5), lukasiewicz(0.5))) # interview again
println(hiring_decision(lukasiewicz(0.9), lukasiewicz(0.9), lukasiewicz(0.6))) # interview againCommentary. The at_least helper is the
key idea:
A truth value
vis at leasttexactly whenv or lukasiewicz(t)is stillv.
Why? v or t is max(v, t) in L3. If
v ≥ t, then max(v, t) = v. If
v < t, then max(v, t) = t ≠ v. So the
equality test is exactly the threshold test, but expressed entirely in
L3-native operators — no float extraction, no escape from the
algebra.
at_most is the dual: v and t == v iff
v ≤ t (because and is min).
This is a small but real example of the point of an algebra: you can do work inside the algebra without peeking at the underlying representation. The L3 truth values have an algebra rich enough to test their own ordering.
k3_truth_table: func(name, op) [
vals: [true, false, om]
println("a b a " + name + " b")
i: 1
while (i <= 3) [
j: 1
while (j <= 3) [
a: vals[i]
b: vals[j]
println(str(a) + " " + str(b) + " " + str(op(a, b)))
j = j + 1
]
i = i + 1
]
]
k3_truth_table("and", func(a, b) [a and b])
println()
k3_truth_table("or", func(a, b) [a or b])Commentary. Nested while over the
three-element value array. op is a function value
passed in — straight out of Chapter 10’s higher-order toolkit.
The output for and:
a b a and b
true true true
true false false
true om Ω
false true false
false false false
false om false
om true Ω
om false false
om om Ω
Reading this table tells you K3 in one glance: an output is
decidable only when the unknowns don’t matter.
false and om is false (the false
absorbs); true and om is Ω (the unknown
propagates).
Generalization. The same generator with
[true, false, belnap("both"), belnap("neither")] produces
the 16-cell B4 table — exercise for the reader. Same code, different
value array. That’s the dispatch-on-type idiom paying off.
combine_two: func(a, b) [
if a == b then a
else if a == belnap("neither") then b
else if b == belnap("neither") then a
else if a == belnap("both") or b == belnap("both") then belnap("both")
else belnap("both")
]
belnap_aggregate: func(w1, w2, w3) [
combine_two(combine_two(w1, w2), w3)
]
verdict_describe: func(v) [
if v == belnap("true") then "Consensus yes"
else if v == belnap("false") then "Consensus no"
else if v == belnap("both") then "Disagreement"
else "No evidence"
]
r1: belnap_aggregate(belnap("true"), belnap("true"), belnap("true"))
println(verdict_describe(r1)) # Consensus yes
r2: belnap_aggregate(belnap("false"), belnap("false"), belnap("false"))
println(verdict_describe(r2)) # Consensus no
r3: belnap_aggregate(belnap("true"), belnap("false"), belnap("true"))
println(verdict_describe(r3)) # Disagreement
r4: belnap_aggregate(belnap("neither"), belnap("neither"), belnap("neither"))
println(verdict_describe(r4)) # No evidence
r5: belnap_aggregate(belnap("true"), belnap("neither"), belnap("true"))
println(verdict_describe(r5)) # Consensus yes
r6: belnap_aggregate(belnap("both"), belnap("true"), belnap("true"))
println(verdict_describe(r6)) # DisagreementCommentary. combine_two is the
consensus combiner. Five cases:
neither → return the
other (no evidence doesn’t override active evidence).both → return
both (existing contradiction propagates).neither, non-both values → must be
true and false → return both (the
contradiction).belnap_aggregate then folds over the three witnesses via
two consecutive combine_two calls.
This is a custom operator, not a built-in B4 op. The
built-in or is truth-disjunction (TRUE absorbs); the
combiner above is something different — a consensus operation
that adds information rather than projecting onto truth. Both
are useful for different purposes; the language gives you the algebra to
define your own when you need to.
meet_all: func(values) [
reduce(func(a, b) [a and b], values[1], values[2..len(values)])
]
join_all: func(values) [
reduce(func(a, b) [a or b], values[1], values[2..len(values)])
]
v: [lukasiewicz(0.5), lukasiewicz(0.8), lukasiewicz(0.2)]
println(meet_all(v)) # 0.20ł
println(join_all(v)) # 0.80łCommentary. Reduce-with-an-operator, straight from
Chapter 10. The L3 and is min; reducing with
it gives the minimum truth value in the array. Same with or
= max.
Note the seed: values[1], not
lukasiewicz(0.0) or lukasiewicz(1.0). We’re
reducing over a non-empty array, so starting with the first
element avoids a separate identity-element argument. For the empty-array
case, callers would need to decide what meet_all([]) means
(lukasiewicz(1.0) is the identity for and;
lukasiewicz(0.0) for or).
The MVL pattern shines here: reduce doesn’t know or care
that the operator is L3-flavored — it just calls op(a, b)
and the dispatcher routes to L3 because the operands are
Łukasiewicz.
# Design choice: K3 lab unknown dominates ⇒ "Need more data" trumps
# any expert opinion (we don't recommend treatment without a lab).
# Then check experts. If either is `both`, OR the two contradict
# (one true, one false), report disagreement. If both experts say
# `neither`, order tests. Otherwise check that severity supports
# the experts' agreed direction.
at_least: func(v, t) [
(v or lukasiewicz(t)) == v
]
at_most: func(v, t) [
(v and lukasiewicz(t)) == v
]
combined_diagnosis: func(lab, severity, opinion_a, opinion_b) [
if lab == om then "Need more data"
else if opinion_a == belnap("neither") and opinion_b == belnap("neither") then "Order tests"
else if opinion_a == belnap("both") or opinion_b == belnap("both") then "Expert disagreement"
else if (opinion_a == belnap("true") and opinion_b == belnap("false")) or (opinion_a == belnap("false") and opinion_b == belnap("true")) then "Expert disagreement"
else if lab == true and opinion_a == belnap("true") and opinion_b == belnap("true") and at_least(severity, 0.7) then "Treat"
else if lab == false and opinion_a == belnap("false") and opinion_b == belnap("false") and at_most(severity, 0.3) then "Don't treat"
else "Need more data"
]
println(combined_diagnosis(true, lukasiewicz(0.8), belnap("true"), belnap("true"))) # Treat
println(combined_diagnosis(false, lukasiewicz(0.1), belnap("false"), belnap("false"))) # Don't treat
println(combined_diagnosis(om, lukasiewicz(0.5), belnap("true"), belnap("false"))) # Need more data
println(combined_diagnosis(true, lukasiewicz(0.8), belnap("true"), belnap("false"))) # Expert disagreement
println(combined_diagnosis(true, lukasiewicz(0.8), belnap("neither"), belnap("neither"))) # Order tests
println(combined_diagnosis(true, lukasiewicz(0.5), belnap("true"), belnap("true"))) # Need more dataCommentary. The interesting part of this exercise isn’t the code — it’s the design choice. Three logics, four inputs, multiple possible outcomes. Different students will reasonably make different choices.
My design choice (documented in the comment):
both, or they directly conflict).Other students might prioritize expert disagreement first, on the theory that “if the doctors can’t agree, lab data won’t help.” That’s a reasonable alternative — different problem framing, different ordering of conditions. The exercise doesn’t have one right answer.
The cross-logic pattern is the lesson. Each logic handles its piece:
| Question | Logic | Operation |
|---|---|---|
| “Is lab unknown?” | K3 | lab == om |
| “Is severity high enough?” | L3 | at_least(severity, 0.7) |
| “Do experts agree?” | B4 | direct value comparison |
There’s no single algebra that handles all three. But mixing the algebras at the level of conditional dispatch works fine. That’s how real-world MVL code looks.
The thread through these six exercises: the operator does the dispatch, but the design is yours.
or is truth-disjunction, not consensus. You write
your own combiner when consensus is what you need.The pedagogical reward: students who’ve worked through these exercises will never default to threshold-Boolean when the data is actually three-valued or contradictory. That habit is worth more than the syntax.
relation parent(child, p)
relation married(a, b)
relation sibling_raw(x, y)
parent("alice", "mary")
parent("alice", "john")
parent("bob", "mary")
parent("bob", "john")
parent("mary", "evelyn")
parent("mary", "harold")
parent("john", "ruth")
parent("john", "frank")
married("mary", "john")
married("evelyn", "harold")
married("ruth", "frank")
sibling_raw(X, Y) <= parent(X, P) and parent(Y, P)
println("All parents:", {P | P <- parent(_, P)})
println("Mary's kids:", {C | C <- parent(C, "mary")})
println("Couples:", {(X, Y) | married(X, Y)})
println("Sibling pairs:", {p | p <- {(X, Y) | sibling_raw(X, Y)}, p[1] != p[2]})Commentary. Four query forms in one program:
{P | P <- parent(_, P)} — generator with wildcard
binding the second argument, the parent.{C | C <- parent(C, "mary")} — generator with
literal in the second argument, free variable in the
first.{(X, Y) | married(X, Y)} — tuple head, single-relation
filter. Axioma materializes all married facts as
tuples.{p | p <- {…}, p[1] != p[2]} — comprehension
over an already-computed tuple set, filtering by
element-equality. Indexed access p[1] / p[2]
works on tuples just like on arrays.The sibling rule is parent(X, P) and parent(Y, P). The
post-filter p[1] != p[2] drops the trivial self-pairs that
the rule includes by symmetry of unification.
relation parent(child, p)
relation grandparent(child, gp)
parent("alice", "mary")
parent("alice", "john")
parent("bob", "mary")
parent("bob", "john")
parent("mary", "evelyn")
parent("mary", "harold")
parent("john", "ruth")
parent("john", "frank")
grandparent(X, G) <= parent(X, P) and parent(P, G)
println("All gps:", {G | G <- grandparent(_, G)})
println("Alice's gps:", {G | G <- grandparent("alice", G)})
println("Evelyn's grandkids:", {C | C <- grandparent(C, "evelyn")})Output:
All gps: {"evelyn", "frank", "harold", "ruth"}
Alice's gps: {"evelyn", "frank", "harold", "ruth"}
Evelyn's grandchildren: {"alice", "bob"}
Commentary. One rule, three queries — each query just varies which position is bound and which is free.
The same rule answers “who is X’s grandparent” (X fixed, GP free) and “who has X as a grandchild” (GP fixed, X free). This bidirectional usage is the hallmark of logic programming: a single rule serves as both a function and its inverse, depending on which side has the unknowns.
relation course(code, title)
relation prereq(course, requires)
course("CS101", "Intro")
course("CS201", "DS")
course("CS301", "Algo")
course("CS401", "Compilers")
course("MATH101", "Calc")
course("MATH201", "Discrete")
prereq("CS201", "CS101")
prereq("CS201", "MATH101")
prereq("CS301", "CS201")
prereq("CS301", "MATH201")
prereq("CS401", "CS301")
prereq("MATH201", "MATH101")
println("CS401 direct:", {R | R <- prereq("CS401", R)})
hop1: {R | R <- prereq("CS401", R)}
hop2: {R | P <- prereq("CS401", P), R <- prereq(P, R)}
hop3: {R | P <- prereq("CS401", P), Q <- prereq(P, Q), R <- prereq(Q, R)}
all_eventual: hop1 ∪ hop2 ∪ hop3
println("CS401 all eventual:", all_eventual)
all_courses: {C | C <- course(C, _)}
has_prereq: {C | C <- prereq(C, _)}
no_prereq: {c | c <- all_courses, not (c in has_prereq)}
println("No-prereq courses:", no_prereq)Output:
CS401 direct: {"CS301"}
CS401 all eventual: {"CS101", "CS201", "CS301", "MATH101", "MATH201"}
No-prereq courses: {"CS101", "MATH101"}
Commentary. Three queries, three patterns:
hop2
is a two-generator comprehension where the output binding
R comes from the inner prereq(P, R). The
∪ of the three depths covers any chain of length 1, 2, or
3. (For longer chains, add hop4 etc.)\ doesn’t
work as set difference in Axioma; the comprehension form is the
idiom.This exercise shows that explicit-depth chains are a fine
tradeoff for finite-depth transitive queries — each
hop is debuggable on its own; the union gives you the
closure. (For unbounded chain depth, use the recursive clause pair:
requires(X, R) if prereq(X, R) plus
requires(X, R) if prereq(X, P) and requires(P, R).)
relation bird(x)
relation penguin(x)
relation ostrich(x)
relation flies(x)
bird("robin")
bird("eagle")
bird("sparrow")
bird("opus")
bird("oscar")
penguin("opus")
ostrich("oscar")
flies(X) <~~ bird(X)
cancel("flies", "opus")
cancel("flies", "oscar")
println({X @conjecture | X <- flies(X)})Output:
{"eagle", "robin", "sparrow"}
Commentary. The defeasible rule
flies(X) <~~ bird(X) populates flies with
all five birds as conjectures. The two
cancel calls mark opus and oscar
as suppressed — their conjecture facts still exist (you can see them
with @all), but the @conjecture filter
excludes canceled facts.
This pattern lets you say “the rule is correct as a default, and these are the exceptions.” Compare to the Boolean alternative:
# Awkward Boolean form
flies("robin") # store each true
flies("eagle")
flies("sparrow")
# DON'T store flies("opus") — implicitly false
# DON'T store flies("oscar") — implicitly falseThe Boolean form lacks the justification: there’s nothing in the program that says “we expected robin to fly because robin is a bird.” With the defeasible rule, the inference chain is right there:
why flies("robin")
# Explanation for: flies(robin)
# This is a conjecture derived by defeasible inference from:
# - bird("robin") [standalone]Auditable defeasibility is the win.
relation friend(a, b)
friend("alice", "bob")
friend("bob", "alice")
friend("alice", "carol")
friend("carol", "alice")
friend("bob", "dan")
friend("dan", "bob")
friend("carol", "eve")
friend("eve", "carol")
friend("dan", "frank")
friend("frank", "dan")
alice_friends: {P | P <- friend("alice", P)}
fof_raw: {X | P <- friend("alice", P), X <- friend(P, X)}
recs: {x | x <- fof_raw, x != "alice" and not (x in alice_friends)}
println("Alice's direct friends:", alice_friends)
println("Recommendations for alice:", recs)Output:
Alice's direct friends: {"bob", "carol"}
Recommendations for alice: {"dan", "eve"}
Commentary. A classic two-step join over a symmetric graph:
alice_friends — set of alice’s direct friends.fof_raw — friends of those friends, computed via a
chained generator. The variable P is shared
between the two generators (both must agree on who the “middle” person
is).recs — filter out alice herself and already-known
friends.A real social network would store friendships as a single symmetric
relation. Axioma doesn’t automatically infer symmetry, so you write both
directions explicitly. (One could also write a rule
friend(X, Y) <= friend(Y, X) — but that would store
duplicates rather than just symmetrize queries.)
relation parent(child, p)
relation married(a, b)
relation gender(person, g)
relation birth_year(person, y)
# Gen 1: grandparents
# (mary's and susan's parents — gen 0, unrecorded)
# Gen 2: mary, susan, frank
parent("mary", "george")
parent("mary", "helen")
parent("susan", "george")
parent("susan", "helen")
parent("frank", "ian")
parent("frank", "jane")
married("mary", "frank")
# Gen 3
parent("alice", "mary")
parent("alice", "frank")
parent("bob", "mary")
parent("bob", "frank")
parent("carol", "susan")
parent("dave", "susan")
gender("mary", "F")
gender("susan", "F")
gender("frank", "M")
gender("alice", "F")
gender("bob", "M")
gender("carol", "F")
gender("dave", "M")
gender("george", "M")
gender("helen", "F")
gender("ian", "M")
gender("jane", "F")
birth_year("mary", 1960)
birth_year("susan", 1962)
birth_year("frank", 1959)
birth_year("alice", 1990)
birth_year("bob", 1992)
birth_year("carol", 1993)
birth_year("dave", 1995)
# Derived relations (4)
relation sibling_raw(x, y)
sibling_raw(X, Y) <= parent(X, P) and parent(Y, P)
relation grandparent(child, gp)
grandparent(C, G) <= parent(C, P) and parent(P, G)
relation aunt_or_uncle(person, au)
aunt_or_uncle(N, AU) <= parent(N, P) and sibling_raw(P, AU)
relation cousin_raw(x, y)
cousin_raw(X, Y) <= parent(X, P) and parent(Y, Q) and sibling_raw(P, Q)
# Queries
println("Alice's proper siblings:",
{s | s <- {S | S <- sibling_raw("alice", S)}, s != "alice"})
println("Alice's grandparents:",
{G | G <- grandparent("alice", G)})
println("Alice's aunts/uncles (raw):",
{AU | AU <- aunt_or_uncle("alice", AU)})
println("Alice's cousins (raw, proper):",
{c | c <- {C | C <- cousin_raw("alice", C)}, c != "alice"})Output:
Alice's proper siblings: {"bob"}
Alice's grandparents: {"george", "helen", "ian", "jane"}
Alice's aunts/uncles (raw): {"frank", "mary", "susan"}
Alice's cousins (raw, proper): {"bob", "carol", "dave"}
Commentary. Four derived relations. Two observations worth calling out:
aunt_or_uncle raw form includes a person’s own
parents. The rule
aunt_or_uncle(N, AU) <= parent(N, P) and sibling_raw(P, AU)
fires when P and AU are each other
(the trivial sibling case), so alice’s own parents mary and
frank appear in the output. Tightening the rule with an
inequality requires post-filtering.cousin_raw includes siblings. Same
issue — when the “two parents” in the rule body are siblings via
self-equality, the cousin set ends up including alice’s siblings (bob).
Again, post-filter to remove those.For a fully-tidy reasoner you’d write:
alice_siblings: {s | s <- {S | S <- sibling_raw("alice", S)}, s != "alice"}
proper_cousins: {c | c <- {C | C <- cousin_raw("alice", C)},
c != "alice" and not (c in alice_siblings)}Here alice_siblings excludes Alice herself. This is the
shape of all the “filter the rule output” patterns we’ve seen this
chapter.
Birth-year and gender are unused in the queries
above — but you could write
older_sibling(X, Y) <= sibling_raw(X, Y) and birth_year(X, A) and birth_year(Y, B) and A < B
(modulo the < filter limitations) or
aunt(X, AU) <= aunt_or_uncle(X, AU) and gender(AU, "F").
The fact-base is rich enough to support many more derived relations than
the four we’ve shown — the exercise is open precisely so students can
find their own questions to ask.
Three patterns to internalize for the relational style:
{X | X <- rel1(a, X), Y <- rel2(X, Y)} is a
relational join — the shared variable X is the join
key.<=, then narrow with a
comprehension filter on the result set. Cleaner than trying to express
the entire query in one rule body.#language axioma/knowledge-core
assert/axiom parent("Mary", "John")
assert/axiom parent("Alice", "Mary")
grandparent(G, A) whenever parent(G, P) and parent(P, A)
thesis: derive/result grandparent("Alice", "John")
println(thesis.status) # derived
println(thesis.grounding) # theoremA second file, not to be run as part of the happy path:
#language axioma/knowledge-core
assert/axiom parent("Mary", "John")
retract parent("Mary", "John")
# retract is not available in axioma/knowledge-core.
# Retraction is non-monotonic; use axioma/all for changing knowledge bases.Commentary. The host chapter wrote
relation parent(...) then bare facts. The core has no
relation declaration: the asserted atom is the
schema. whenever is the same connective as §21.5.
derive/result is how you see a derivation on the
core — status / grounding rather than a
comprehension that happens to be non-empty.
typically is refused too
(defeasible rule is not available). Defeasible reasoning
stays on axioma/all (and on the milder
axioma/knowledge naming fence, which still allows
retract). The core is the subset whose completeness story
is Horn and monotonic.
Logic programming sits next to functional programming in the toolkit — both are first-class in Axioma, neither replaces the other, and the best programs use both.
concept NumNode
NumNode has value: 0
concept OpNode
OpNode has op: ""
OpNode has left: 0
OpNode has right: 0
eval_expr: func(node) [
if node is NumNode then node.value
else if node is OpNode then [
l: eval_expr(node.left)
r: eval_expr(node.right)
if node.op == "+" then l + r
else if node.op == "-" then l - r
else if node.op == "*" then l * r
else if node.op == "/" then l / r
else 0
]
else 0
]
n10: a NumNode { value: 10 }
n3: a NumNode { value: 3 }
n2: a NumNode { value: 2 }
sub: an OpNode { op: "-", left: n10, right: n3 }
mul: an OpNode { op: "*", left: sub, right: n2 }
println(eval_expr(mul)) # 14Commentary. One extra else if branch in
the OpNode dispatch. Three lines of code. That’s the point:
adding a new operator to your evaluator is a local change. You
don’t have to revisit anything else.
let formconcept VarNode
VarNode has name: ""
empty_env: []
env_lookup: func(ev, nm) [
if len(ev) == 0 then 0
else if ev[1][1] == nm then ev[1][2]
else env_lookup(rest(ev), nm)
]
env_extend: func(ev, nm, item_value) [[(nm, item_value)] + ev]
concept LetNode
LetNode has name: ""
LetNode has value_expr: 0
LetNode has body: 0
eval_expr: func(node, ev) [
if node is NumNode then node.value
else if node is VarNode then env_lookup(ev, node.name)
else if node is OpNode then [
l: eval_expr(node.left, ev)
r: eval_expr(node.right, ev)
if node.op == "+" then l + r
else if node.op == "-" then l - r
else 0
]
else if node is LetNode then [
v: eval_expr(node.value_expr, ev)
new_env: env_extend(ev, node.name, v)
eval_expr(node.body, new_env)
]
else 0
]
# x: 3 in y: 4 in x + y
body_xy: an OpNode {
op: "+",
left: a VarNode { name: "x" },
right: a VarNode { name: "y" }
}
inner_let: a LetNode {
name: "y",
value_expr: a NumNode { value: 4 },
body: body_xy
}
outer_let: a LetNode {
name: "x",
value_expr: a NumNode { value: 3 },
body: inner_let
}
println(eval_expr(outer_let, empty_env)) # 7Commentary. Three steps for the LetNode
case:
The order matters. If you tried to extend the env before
computing the value, you couldn’t write x: x + 1 to shadow
a previous binding — the new x would already be in scope
while computing its value. This semantic detail — “value expression sees
the old env, body sees the new env” — is what non-recursive
object-language let means. Recursive let (or
letrec) extends the env first and then evaluates
the value; that’s how recursive function definitions become
self-referential.
pretty: func(node) [
if node is NumNode then str(node.value)
else if node is VarNode then node.name
else if node is OpNode then
"(" + pretty(node.left) + " " + node.op + " " + pretty(node.right) + ")"
else if node is IfNode then
"if " + pretty(node.condition) + " then " + pretty(node.then_branch)
+ " else " + pretty(node.else_branch)
else "?"
]
# Build (2 + 3) * 4
n2: a NumNode { value: 2 }
n3: a NumNode { value: 3 }
n4: a NumNode { value: 4 }
add: an OpNode { op: "+", left: n2, right: n3 }
mul: an OpNode { op: "*", left: add, right: n4 }
println(pretty(mul)) # ((2 + 3) * 4)Commentary. pretty is structural
recursion over the same Concept hierarchy as eval_expr.
Same data shape, same program shape — but instead of returning a
value, it returns a string.
This is the unfold/fold duality: parse (string → AST)
and pretty (AST → string) are inverses. Real-world pretty-
printers add indentation, operator precedence, line wrapping; the
core is what’s above.
A useful exercise: extend pretty for the
LambdaNode and AppNode cases.
LambdaNode { param: "x", body: b } should render as
"lambda x => " + pretty(b);
AppNode { callee: c, arg: a } as
pretty(c) + "(" + pretty(a) + ")".
run(s) round-triprun: func(s) [ ast_eval(parse(s)) ]
println(run("2 + 3")) # 5
println(run("(7 - 3) * 5")) # 20
println(run("if 1 == 1 then 100 else 200")) # 100Commentary. Three lines including the function.
Axioma’s parse builds the AST, ast_eval
evaluates it using the real Axioma evaluator (not our toy
version).
That’s the two-stage architecture of every interpreter ever: read code into a data structure, then walk the data structure. Once you separate these, you can do all kinds of clever things — transform the AST before evaluation (optimization), check it without running (type inference), serialize it (caching), transmit it across the network (RPC). All of those tools start with the same parse-then-walk pattern.
The contrast with our hand-written eval_expr: ours
handles a tiny language; Axioma’s real evaluator handles everything
in Axioma. Same architecture, vastly different scale.
describe(s) via headof / argsofdescribe: func(s) [
a: parse(s)
println("head: " + headof(a))
println("args: " + str(argsof(a)))
]
describe("2 + 3")
# head: +
# args: ["2", "3"]
describe("sqrt(16)")
# head: sqrt
# args: ["16"]
describe("if true then 1 else 2")
# head: If
# args: ["true", "1", "2"]Commentary. headof and
argsof are the Mathematica introspection — every
expression, regardless of surface syntax, has a head and a sequence of
arguments. 2 + 3 is really +(2, 3).
sqrt(16) is just sqrt(16).
if … then … else … is
If(cond, then, else).
This is the deep insight from Lisp and Mathematica: all syntax is sugar over a uniform call form. Once you see that, you understand why metaprogramming works. The “different” syntactic forms are all the same data structure underneath.
# Domain: pure Boolean expressions with literals and variables.
# Three Concepts: BoolLit, BoolVar, BoolOp.
# Two operators: AND, OR (plus NOT as unary).
concept BoolLit
BoolLit has value: false
concept BoolVar
BoolVar has name: ""
concept BoolOp
BoolOp has op: ""
BoolOp has left: 0
BoolOp has right: 0
concept BoolNot
BoolNot has expr: 0
# Env = array of (name, bool) pairs
empty_env: []
env_lookup: func(ev, nm) [
if len(ev) == 0 then false
else if ev[1][1] == nm then ev[1][2]
else env_lookup(ev[2..len(ev)], nm)
]
# Evaluator
eval_bool: func(node, ev) [
if node is BoolLit then node.value
else if node is BoolVar then env_lookup(ev, node.name)
else if node is BoolNot then not eval_bool(node.expr, ev)
else if node is BoolOp then [
l: eval_bool(node.left, ev)
r: eval_bool(node.right, ev)
if node.op == "and" then l and r
else if node.op == "or" then l or r
else false
]
else false
]
# Pretty
pretty_bool: func(node) [
if node is BoolLit then str(node.value)
else if node is BoolVar then node.name
else if node is BoolNot then "!" + pretty_bool(node.expr)
else if node is BoolOp then
"(" + pretty_bool(node.left) + " " + node.op + " " + pretty_bool(node.right) + ")"
else "?"
]
# Three test programs
# Program 1: a AND b
p1: a BoolOp {
op: "and",
left: a BoolVar { name: "a" },
right: a BoolVar { name: "b" }
}
# Program 2: (a OR b) AND (NOT c)
p2: a BoolOp {
op: "and",
left: a BoolOp { op: "or", left: a BoolVar { name: "a" }, right: a BoolVar { name: "b" } },
right: a BoolNot { expr: a BoolVar { name: "c" } }
}
# Program 3: true OR (false AND a)
p3: a BoolOp {
op: "or",
left: a BoolLit { value: true },
right: a BoolOp { op: "and", left: a BoolLit { value: false }, right: a BoolVar { name: "a" } }
}
# Env: a=true, b=false, c=false
e: [("a", true), ("b", false), ("c", false)]
println(pretty_bool(p1), "=>", eval_bool(p1, e)) # (a and b) => false
println(pretty_bool(p2), "=>", eval_bool(p2, e)) # ((a or b) and !c) => true
println(pretty_bool(p3), "=>", eval_bool(p3, e)) # (true or (false and a)) => trueDesign choices documented:
BoolLit, BoolVar,
BoolOp) plus unary BoolNot. I
separated BoolNot from BoolOp because it has
one operand, not two; squishing it into BoolOp
would mean ignoring the right slot. Cleaner to have
distinct shapes for binary vs. unary operators.(name, value)
tuples, same as the Axioma evaluator from §22.5. Could use a
Concept with bindings: dict() instead; the list form is
what the chapter taught.node.op. An alternative is one Concept per operator
(AndOp, OrOp). String dispatch is more
compact; per-op concepts are more explicit. Both work; pick the style
that matches the rest of the codebase you’re building.true on the left of
or means the right side’s value doesn’t matter — but our
eager evaluator still evaluates it).The bigger lesson. This Boolean DSL is 30
lines of code, end-to-end. It has a clear data definition, a
recursive evaluator, a recursive pretty-printer, an environment. It’s a
complete language, in the sense that you could now build it out
— add Xor, Implies, quantifiers,
satisfiability checking. The architecture scales.
You’ve just designed a programming language. That’s what this chapter was for.
Three patterns to internalize:
eval_expr dispatches on is NodeType and
recurses on children. Same recipe shape as Chapter 8.The capstone of the textbook is that you can now read any
small language implementation — Lisp’s eval,
Python’s ast module, JSON parsers, regex engines,
configuration loaders. They’re all variations on the parse-and-walk
pattern you’ve just implemented twice (once by hand, once via
parse + ast_eval).
Go build something.
# Shared prelude used by every Chapter 23 solution
concept Empty
concept Cons
Cons has head: 0
Cons has tail: 0
nil: an Empty {}
list_of: func(arr) [
result: nil
i: len(arr)
while (i >= 1) [
result = a Cons { head: arr[i], tail: result }
i = i - 1
]
result
]
list_to_array: func(lst) [
result: []
cur: lst
while (not (cur is Empty)) [
result = result + ([] + [cur.head])
cur = cur.tail
]
result
]nth: func(lst, n) [
if lst is Empty then 0
else if n == 1 then lst.head
else nth(lst.tail, n - 1)
]
l: list_of([10, 20, 30, 40, 50])
println(nth(l, 1)) # 10
println(nth(l, 3)) # 30
println(nth(l, 5)) # 50
println(nth(l, 6)) # 0 — out-of-bounds defaultCommentary. Three sub-cases:
Empty) before we reached position
n — out of bounds, return the default 0.n == 1) — return the
current head.n.Cost. nth(lst, n) does n
recursive calls, each O(1). Total O(n). Compare with arr[n]
on a built-in Axioma array — that’s O(1). The linked list trades
random-access speed for front-end mutation speed.
list_contains: func(lst, x) [
if lst is Empty then false
else if lst.head == x then true
else list_contains(lst.tail, x)
]
println(list_contains(list_of([1, 2, 3, 4, 5]), 3)) # true
println(list_contains(list_of([1, 2, 3, 4, 5]), 99)) # false
println(list_contains(nil, 7)) # falseCommentary. Same three-case template as
nth:
false.true.Cost. O(n) in the worst case (target absent or at
the end). Same as Axioma’s array in operator — both have to
scan.
filter_list: func(pred, lst) [
if lst is Empty then nil
else if pred(lst.head) then a Cons { head: lst.head, tail: filter_list(pred, lst.tail) }
else filter_list(pred, lst.tail)
]
l: list_of([1, 2, 3, 4, 5, 6, 7, 8])
evens: filter_list(func(x) [x % 2 == 0], l)
println(list_to_array(evens)) # [2, 4, 6, 8]Commentary. For each cell:
pred(head) is true, keep the head:
build a new cons with head and the recursive result on the
tail.pred(head) is false, drop the head:
return the recursive result on the tail unchanged.The cells that survive are new cons cells (we create them each iteration); the dropped cells are simply not referenced from the result. Garbage collection cleans them up eventually.
fold_list: func(op, acc, lst) [
if lst is Empty then acc
else fold_list(op, op(acc, lst.head), lst.tail)
]
l: list_of([1, 2, 3, 4, 5])
println(fold_list(func(a, b) [a + b], 0, l)) # 15
println(fold_list(func(a, b) [a * b], 1, l)) # 120Commentary. This is the left fold —
op is applied left-to-right, with acc
accumulating the running total.
Walk-through with op = +, acc = 0,
lst = [1, 2, 3]:
fold_list(+, 0, [1, 2, 3])
= fold_list(+, +(0, 1), [2, 3]) = fold_list(+, 1, [2, 3])
= fold_list(+, +(1, 2), [3]) = fold_list(+, 3, [3])
= fold_list(+, +(3, 3), []) = fold_list(+, 6, [])
= 6
Why this matters. fold_list is
the general list-walking primitive. Most other operations are
specializations:
length(lst) =
fold_list((a, _) → a + 1, 0, lst)sum(lst) = fold_list(+, 0, lst)product(lst) = fold_list(*, 1, lst)max(lst) =
fold_list(max-of-two, lst.head, lst.tail)Mastering fold means you stop writing custom recursions for common list operations.
zip_lists: func(a, b) [
if a is Empty then nil
else if b is Empty then nil
else a Cons { head: (a.head, b.head), tail: zip_lists(a.tail, b.tail) }
]
la: list_of([1, 2, 3])
lb: list_of(["a", "b", "c", "d"])
println(list_to_array(zip_lists(la, lb)))
# [(1, "a"), (2, "b"), (3, "c")]Commentary. Recursion on two lists in lockstep. The base case is “either is Empty” — the result truncates to the shorter input. The recursive step packages the two heads as a tuple and recurses on both tails.
This is the same shape as Ch.17’s stream-zip — generators walking in lockstep, halting at the first one to run out.
insert_at: func(lst, n, x) [
if n == 1 then a Cons { head: x, tail: lst }
else if lst is Empty then a Cons { head: x, tail: nil }
else a Cons { head: lst.head, tail: insert_at(lst.tail, n - 1, x) }
]
l: list_of([1, 2, 3, 4])
l2: insert_at(l, 2, 99)
println(list_to_array(l2)) # [1, 99, 2, 3, 4]
println(list_to_array(l)) # [1, 2, 3, 4] — unchangedCommentary — the sharing question.
When insert_at(l, 2, 99) returns l2,
exactly how much of the original l is shared with
l2?
Trace it:
l = Cons(1) ──→ Cons(2) ──→ Cons(3) ──→ Cons(4) ──→ Empty
l2 = Cons(1) ──→ Cons(99) ──→ ↘
Cons(2) ──→ Cons(3) ──→ Cons(4) ──→ Empty
l2’s first cell Cons(1) is a new
cell (because insert_at walked past position 1 and rebuilt
the prefix).l2’s second cell Cons(99) is the inserted
value.l2 points back into the original
list. The Cons(2) → Cons(3) → Cons(4) → Empty tail is
the same cells as l.tail.So l2 allocates exactly two new cons
cells (Cons(1) and Cons(99)) and shares the
remaining three with l. General formula: inserting at
position n allocates n new cells and shares
length - n + 1 cells.
This is the heart of persistent data structures: small updates produce small allocations, with the unchanged parts shared between versions. A whole subfield (Okasaki 1998, Clojure’s PersistentVector, Hickey’s “values” talk) builds on this idea. Linked lists are the simplest case.
Three patterns to internalize from this chapter:
filter_list(pred, l) is a new
list; l is unchanged. This is the seed of immutability
discipline.fold_list is the universal walk. Once
you have it, most other operations are one-line specializations.The next chapter takes these primitives and uses them to build stacks and queues — the same data, viewed through different abstract interfaces.
# Shared prelude — Concept-based Stack and Queue ADTs
concept Empty
concept Cons
Cons has head: 0
Cons has tail: 0
stack_empty: an Empty {}
stack_push: func(s, x) [ a Cons { head: x, tail: s } ]
stack_top: func(s) [ if s is Empty then 0 else s.head ]
stack_pop: func(s) [ if s is Empty then s else s.tail ]
stack_is_empty: func(s) [ s is Empty ]
stack_reverse_helper: func(s, acc) [
if s is Empty then acc
else stack_reverse_helper(s.tail, stack_push(acc, s.head))
]
stack_reverse: func(s) [ stack_reverse_helper(s, stack_empty) ]
stack_length: func(s) [
if s is Empty then 0
else 1 + stack_length(s.tail)
]
concept Queue
Queue has in_stack: 0
Queue has out_stack: 0
queue_empty: a Queue { in_stack: stack_empty, out_stack: stack_empty }
queue_enqueue: func(q, x) [
a Queue { in_stack: stack_push(q.in_stack, x), out_stack: q.out_stack }
]
queue_dequeue: func(q) [
if not stack_is_empty(q.out_stack) then [
a Queue { in_stack: q.in_stack, out_stack: stack_pop(q.out_stack) }
] else if stack_is_empty(q.in_stack) then q
else [
flipped: stack_reverse(q.in_stack)
a Queue { in_stack: stack_empty, out_stack: stack_pop(flipped) }
]
]
queue_front: func(q) [
if not stack_is_empty(q.out_stack) then stack_top(q.out_stack)
else if stack_is_empty(q.in_stack) then 0
else stack_top(stack_reverse(q.in_stack))
]
queue_is_empty: func(q) [
stack_is_empty(q.in_stack) and stack_is_empty(q.out_stack)
]matches_pair: func(open, close) [
(open == "(" and close == ")") or
(open == "[" and close == "]") or
(open == "{" and close == "}")
]
is_open: func(c) [ c == "(" or c == "[" or c == "{" ]
is_close: func(c) [ c == ")" or c == "]" or c == "}" ]
balanced: func(s) [
stk: stack_empty
i: 1
ok: true
while (i <= len(s) and ok) [
c: s[i..i]
if is_open(c) then [stk = stack_push(stk, c)]
else if is_close(c) then [
if stack_is_empty(stk) then [ok = false]
else if not matches_pair(stack_top(stk), c) then [ok = false]
else [stk = stack_pop(stk)]
]
i = i + 1
]
ok and stack_is_empty(stk)
]
println(balanced("(())")) # true
println(balanced("([{}])")) # true
println(balanced("(()")) # false
println(balanced(")")) # false
println(balanced("([)]")) # falseCommentary. Classic stack application. For every open paren, push the open character onto the stack. For every close, the top of the stack must be the matching open. At end-of-string, the stack must be empty.
Three failure modes are handled:
) first: stack is
empty when we see the close, so ok = false.([)]: see
], stack-top is [, they match, pop. See
), stack-top is [, mismatch.((: at end, stack is
non-empty.The early-exit ok flag short-circuits the loop when
we’ve already detected a failure — saves work on long inputs.
reverse_via_stack: func(arr) [
stk: stack_empty
i: 1
while (i <= len(arr)) [
stk = stack_push(stk, arr[i])
i = i + 1
]
result: []
while (not stack_is_empty(stk)) [
result = result + ([] + [stack_top(stk)])
stk = stack_pop(stk)
]
result
]
println(reverse_via_stack([1, 2, 3, 4, 5]))
# [5, 4, 3, 2, 1]Commentary. Two-phase reversal:
Same O(n) asymptotic cost as Ch.23’s recursive reverse,
but written imperatively. When you’re working with a sequence that
naturally produces elements one at a time and you need them in reverse
order, an explicit stack is often the cleanest tool.
This is also a useful pattern for non-recursive tree traversals — algorithms that need to “remember the stack frame” without actually using recursion. Iterative DFS works exactly this way.
queue_length: func(q) [
stack_length(q.in_stack) + stack_length(q.out_stack)
]
q: queue_enqueue(queue_enqueue(queue_enqueue(queue_empty, 1), 2), 3)
println(queue_length(q)) # 3
q4: queue_dequeue(q)
println(queue_length(q4)) # 2Commentary. The total elements in the queue equals
the sum of elements in the two backing stacks. After three enqueues, all
three sit in in_stack (since out_stack hasn’t
been touched). After a dequeue, one element has moved to
out_stack and then been popped — so 3 - 1 = 2.
Cost. O(n) because stack_length walks
each stack. Production code would cache the size in the Queue Concept;
the recompute version is fine for teaching.
concept Deque
Deque has front_stack: 0
Deque has back_stack: 0
deque_empty: a Deque { front_stack: stack_empty, back_stack: stack_empty }
deque_push_back: func(d, x) [
a Deque { front_stack: d.front_stack, back_stack: stack_push(d.back_stack, x) }
]
# Helper: get front and pop without recreating: returns (front_value, new_deque)
deque_pop_front_pair: func(d) [
if not stack_is_empty(d.front_stack) then [
(stack_top(d.front_stack),
a Deque { front_stack: stack_pop(d.front_stack), back_stack: d.back_stack })
] else [
flipped: stack_reverse(d.back_stack)
(stack_top(flipped),
a Deque { front_stack: stack_pop(flipped), back_stack: stack_empty })
]
]
deque_pop_back_pair: func(d) [
if not stack_is_empty(d.back_stack) then [
(stack_top(d.back_stack),
a Deque { front_stack: d.front_stack, back_stack: stack_pop(d.back_stack) })
] else [
flipped: stack_reverse(d.front_stack)
(stack_top(flipped),
a Deque { front_stack: stack_empty, back_stack: stack_pop(flipped) })
]
]
deque_size: func(d) [ stack_length(d.front_stack) + stack_length(d.back_stack) ]
is_palindrome: func(arr) [
d: deque_empty
i: 1
while (i <= len(arr)) [
d = deque_push_back(d, arr[i])
i = i + 1
]
ok: true
while (deque_size(d) >= 2 and ok) [
front_pair: deque_pop_front_pair(d)
f: front_pair[1]
d = front_pair[2]
back_pair: deque_pop_back_pair(d)
b: back_pair[1]
d = back_pair[2]
if f != b then [ok = false]
]
ok
]
println(is_palindrome([1, 2, 3, 2, 1])) # true
println(is_palindrome([1, 2, 3, 4, 5])) # false
println(is_palindrome(["a", "b", "a"])) # true
println(is_palindrome([])) # true (vacuously)Commentary. Push every element onto the deque’s back. Then pop front + back simultaneously and compare. If any pair fails to match, it’s not a palindrome.
The “pop pair” helpers return a tuple of
(value, new_deque) so the caller gets both at once — that’s
the pure-functional analog of “pop and assign.”
Pattern matters: a palindrome of length n requires
⌊n/2⌋ comparisons; a deque makes each O(1) amortized.
is_int_val: func(v) [ v is Integer ]
rpn: func(tokens) [
stk: stack_empty
i: 1
while (i <= len(tokens)) [
t: tokens[i]
if is_int_val(t) then [
stk = stack_push(stk, t)
] else [
b: stack_top(stk)
stk = stack_pop(stk)
a: stack_top(stk)
stk = stack_pop(stk)
r: 0
if t == "+" then [r = a + b]
else if t == "-" then [r = a - b]
else if t == "*" then [r = a * b]
else if t == "/" then [r = a / b]
stk = stack_push(stk, r)
]
i = i + 1
]
stack_top(stk)
]
println(rpn([1, 2, "+"])) # 3
println(rpn([1, 2, "+", 3, "*"])) # 9
println(rpn([5, 1, 2, "+", 4, "*", "+", 3, "-"])) # 14Commentary. RPN (reverse Polish notation) was designed for stack machines — there are no parentheses because operator-after-operands gives unambiguous evaluation.
5 1 2 + 4 * + 3 - reads:
+ pops 2 1, computes 3, pushes 3 → stack: 5 3* pops 4 3, computes 12, pushes 12 → stack: 5 12+ pops 12 5, computes 17, pushes 17 → stack: 17- pops 3 17, computes 14, pushes 14 → stack: 14Note on operand order. a - b (not
b - a) because the second-popped is the left
operand (the one pushed first). This matters for non-commutative
operators like - and /.
HP calculators worked exactly this way for decades. Forth, PostScript, and the Axioma global interpreter stack (Ch.39) all use this evaluation model.
# Use two deques: `back_stack` for previously-visited pages,
# `forward_stack` for pages after we've navigated back.
concept History
History has cur: ""
History has back_stack: 0
History has forward_stack: 0
history_new: func(initial) [
a History { cur: initial, back_stack: stack_empty, forward_stack: stack_empty }
]
history_visit: func(h, url) [
# Visit clears forward history
a History {
cur: url,
back_stack: stack_push(h.back_stack, h.cur),
forward_stack: stack_empty
}
]
history_back: func(h) [
if stack_is_empty(h.back_stack) then h
else [
prev: stack_top(h.back_stack)
a History {
cur: prev,
back_stack: stack_pop(h.back_stack),
forward_stack: stack_push(h.forward_stack, h.cur)
}
]
]
history_forward: func(h) [
if stack_is_empty(h.forward_stack) then h
else [
nxt: stack_top(h.forward_stack)
a History {
cur: nxt,
back_stack: stack_push(h.back_stack, h.cur),
forward_stack: stack_pop(h.forward_stack)
}
]
]
history_current: func(h) [ h.cur ]
# Test
h1: history_new("home")
h2: history_visit(h1, "wiki")
h3: history_visit(h2, "axioma")
println(history_current(h3)) # axioma
h4: history_back(h3)
println(history_current(h4)) # wiki
h5: history_back(h4)
println(history_current(h5)) # home
h6: history_forward(h5)
println(history_current(h6)) # wiki
h7: history_visit(h6, "newsite") # clears forward
println(history_current(h7)) # newsite
h8: history_forward(h7)
println(history_current(h8)) # still newsite (forward cleared)Commentary on the representation choice.
A real browser uses two stacks, not deques:
back_stack and forward_stack. We chose stacks
here because the operations only need the top of each — we
never enqueue/ dequeue at the wrong end. The deque interface would work
equally well but use only half its operations.
Two key behaviors:
visit clears forward history. This is
the classic web browser behavior — once you navigate to a new page, the
“redo” stack is invalidated. Modeled by setting
forward_stack: stack_empty.back and forward move
cur between stacks. Going back pushes the current
URL onto the forward stack and pops from the back stack. Going forward
is the reverse.This is the two-stack pattern also used for undo/redo, text-editor cursor history, and “previous track / next track” in music players. Once you’ve seen it once you spot it everywhere.
Three patterns to internalize:
stack_push,
queue_enqueue are names for what to do, not
how it’s done. Calling code shouldn’t care about the underlying
Concepts.# Shared prelude — Concept-based hash table, built by hand:
# the chapter's point is to see the mechanism under the hood
concept HashBucket
HashBucket has key: ""
HashBucket has value: 0
HashBucket has nxt: 0
concept HashTable
HashTable has buckets: 0
HashTable has size: 0
nil_bucket: 0
make_table: func(size) [
buckets: []
i: 1
while (i <= size) [
buckets = buckets + ([] + [nil_bucket])
i = i + 1
]
a HashTable { buckets: buckets, size: size }
]
djb2_hash: func(s) [
h: 5381
i: 1
while (i <= len(s)) [
code: ord(s[i..i])
h = h * 33 + code
i = i + 1
]
if h < 0 then -h else h
]
# Insert into chained table
put_t: func(table, key, item_value) [
idx: djb2_hash(key) % table.size + 1
cur: a HashBucket { key: key, value: item_value, nxt: table.buckets[idx] }
table.buckets[idx] = cur
table
]
# Look up; returns 0 (sentinel) when key not found
get_t: func(table, key) [
idx: djb2_hash(key) % table.size + 1
walk_chain(table.buckets[idx], key)
]
walk_chain: func(b, key) [
if b == nil_bucket then 0
else if b.key == key then b.value
else walk_chain(b.nxt, key)
]
contains_t: func(table, key) [
idx: djb2_hash(key) % table.size + 1
walk_chain_contains(table.buckets[idx], key)
]
walk_chain_contains: func(b, key) [
if b == nil_bucket then false
else if b.key == key then true
else walk_chain_contains(b.nxt, key)
](Note: ord(c) returns the integer code point of a
one-character string, so ord(s[i..i]) — with Axioma’s
1-indexed slice — is the code of the i-th character of
s.)
word_freq: func(words) [
t: make_table(64)
i: 1
while (i <= len(words)) [
w: words[i]
if contains_t(t, w) then [t = put_t(t, w, get_t(t, w) + 1)]
else [t = put_t(t, w, 1)]
i = i + 1
]
t
]
words: ["the", "cat", "sat", "on", "the", "mat", "and", "the", "rat"]
freq: word_freq(words)
println(get_t(freq, "the")) # 3
println(get_t(freq, "cat")) # 1
println(get_t(freq, "dog")) # 0Commentary. Two-step iteration: walk the input, and
for each word either increment its count (if seen) or
initialize it to 1 (if first time). The
contains_t / get_t pair gives us “check then read.”
concept Point
Point has x: 0
Point has y: 0
hash_point: func(p) [
djb2_hash(str(p.x) + "," + str(p.y))
]
p1: a Point { x: 3, y: 7 }
p2: a Point { x: 3, y: 7 }
p3: a Point { x: 7, y: 3 }
println(hash_point(p1) == hash_point(p2)) # true
println(hash_point(p1) == hash_point(p3)) # falseCommentary. The string-then-hash idiom. The key insight: equal Concepts must produce equal canonical strings, so their hashes match. Distinct Concepts almost certainly produce distinct strings, so their hashes diverge.
The , separator matters: without it,
(12, 3) and (1, 23) would both stringify to
"123" and collide. The separator is the canonicalization
trick.
djb2_hash: func(s) [
h: 5381
i: 1
while (i <= len(s)) [
code: ord(s[i..i])
h = h * 33 + code
i = i + 1
]
if h < 0 then -h else h
]
# Distribution check on 10 small words modulo 100
words: ["the", "of", "and", "a", "to", "in", "is", "you", "that", "it"]
counts: make_table(100)
i: 1
while (i <= len(words)) [
w: words[i]
idx: djb2_hash(w) % 100
counts = put_t(counts, str(idx), get_t(counts, str(idx)) + 1)
i = i + 1
]
# Find max bucket
max_per_bucket: 0
j: 0
while (j < 100) [
v: get_t(counts, str(j))
if v > max_per_bucket then [max_per_bucket = v]
j = j + 1
]
println("Max per bucket:", max_per_bucket)
# Should be 1 or 2 for a good distributionCommentary. djb2’s magic numbers (5381,
33) aren’t arbitrary — they were chosen empirically for
good distribution on ASCII strings (DJ Bernstein’s “fast hash for short
strings”). The point of the exercise is to measure whether the
distribution actually works: 10 words into 100 buckets should rarely
collide.
Already in the prelude — make_table, put_t,
get_t, contains_t. Quick round-trip test:
t: make_table(8)
t1: put_t(t, "alice", 30)
t2: put_t(t1, "bob", 25)
t3: put_t(t2, "carol", 22)
println(get_t(t3, "alice")) # 30
println(get_t(t3, "bob")) # 25
println(get_t(t3, "carol")) # 22
println(get_t(t3, "dave")) # 0 — not stored
println(contains_t(t3, "alice")) # true
println(contains_t(t3, "dave")) # falseCommentary on the implementation choice. The bucket
array uses index-mutation (table.buckets[idx] = ...) — the
natural fit here, since buckets are positional slots selected by the
hash value.
# Hash-table version (initialized via literal — no mutation needed)
day_table: {"Mon": 1, "Tue": 2, "Wed": 3, "Thu": 4, "Fri": 5, "Sat": 6, "Sun": 7}
day_to_number: func(d) [
if day_table[d] == none then 0 else day_table[d]
]
println(day_to_number("Wed")) # 3
println(day_to_number("Fri")) # 5
println(day_to_number("xyz")) # 0Commentary. Hash literal initialization
works fine — the bug only affects post-creation mutation. Lookups via
table[key] return the value or none for
missing.
Timing. On 10000 random calls, the cascade version
(seven else if branches) takes O(7) per call in the worst
case ("Sun"), while the hash version is O(1) regardless of
key. For 7 cases the difference is small; for 700 cases the cascade gets
unbearable.
The deeper lesson: table-driven code is data-driven code. Adding a new day-name to the cascade requires editing the function. Adding a new key to the table requires editing data. That separation scales.
top_n_words: func(words, n) [
freq: word_freq(words)
# Collect all (word, count) pairs from the freq table
# by walking every chain
pairs: []
i: 1
while (i <= freq.size) [
b: freq.buckets[i]
while (b != nil_bucket) [
pairs = pairs + ([] + [(b.key, b.value)])
b = b.nxt
]
i = i + 1
]
# Sort pairs by count descending (insertion-sort for brevity)
sorted: []
j: 1
while (j <= len(pairs)) [
p: pairs[j]
k: len(sorted)
sorted = sorted + ([] + [p])
while (k >= 1 and sorted[k][2] < sorted[k + 1][2]) [
tmp: sorted[k]
sorted[k] = sorted[k + 1]
sorted[k + 1] = tmp
k = k - 1
]
j = j + 1
]
# Take the first n
if len(sorted) <= n then sorted
else sorted[1..n]
]Commentary on implementation choices.
(word, count) pairs into an array. The Concept-based table
exposes its bucket structure, so this is honest manual iteration.n ≤ 10 and typical input sizes, O(n²) is fine. For larger
outputs, use mergesort (Chapter 14 / 29).n unique words, return all of them.A more elegant design would use Axioma’s set comprehensions to extract the pairs, but Concept-based hash tables don’t play nicely with the generator form yet. Worth revisiting when the language grows.
Three patterns this chapter establishes:
else if branches dispatching on a known set of values, ask
whether a table-driven approach would scale better.# Shared prelude — BST as Concepts
concept TreeEmpty
concept TreeNode
TreeNode has key: 0
TreeNode has value: 0
TreeNode has left: 0
TreeNode has right: 0
tree_empty: a TreeEmpty {}
tree_insert: func(t, k, v) [
if t is TreeEmpty then a TreeNode { key: k, value: v, left: tree_empty, right: tree_empty }
else if k < t.key then a TreeNode { key: t.key, value: t.value, left: tree_insert(t.left, k, v), right: t.right }
else if k > t.key then a TreeNode { key: t.key, value: t.value, left: t.left, right: tree_insert(t.right, k, v) }
else a TreeNode { key: t.key, value: v, left: t.left, right: t.right }
]
max_of: func(a, b) [ if a > b then a else b ]
abs_diff: func(a, b) [ if a > b then a - b else b - a ]For readability, the solutions bind each insertion to a name. A
nested call such as
tree_insert(tree_insert(tree_empty, 5, "a"), 3, "b") is
also valid; naming the intermediate trees makes diagrams and tests
easier to follow.
tree_height: func(t) [
if t is TreeEmpty then -1
else 1 + max_of(tree_height(t.left), tree_height(t.right))
]
t1: tree_insert(tree_empty, 5, "a")
t2: tree_insert(t1, 3, "b")
t3: tree_insert(t2, 8, "c")
println(tree_height(t3)) # 1Commentary. Empty tree → -1. Otherwise, the height
is 1 + the max height of the two subtrees. The max_of
helper makes the height formula read directly as a single
expression.
tree_min: func(t) [
if t is TreeEmpty then 0
else if t.left is TreeEmpty then t.key
else tree_min(t.left)
]
tree_max: func(t) [
if t is TreeEmpty then 0
else if t.right is TreeEmpty then t.key
else tree_max(t.right)
]
t1: tree_insert(tree_empty, 5, "a")
t2: tree_insert(t1, 3, "b")
t3: tree_insert(t2, 8, "c")
t4: tree_insert(t3, 1, "d")
println(tree_min(t4)) # 1
println(tree_max(t4)) # 8Commentary. BST invariant: smallest key is at the leftmost leaf, largest at the rightmost. Walk one direction until you find an Empty child.
tree_size: func(t) [
if t is TreeEmpty then 0
else 1 + tree_size(t.left) + tree_size(t.right)
]
t1: tree_insert(tree_empty, 5, "a")
t2: tree_insert(t1, 3, "b")
t3: tree_insert(t2, 8, "c")
t4: tree_insert(t3, 1, "d")
println(tree_size(t4)) # 4Commentary. Standard recursive count. Same template
as length on linked lists, but with two recursive branches
instead of one.
tree_range: func(t, lo, hi) [
if t is TreeEmpty then []
else if t.key < lo then tree_range(t.right, lo, hi)
else if t.key > hi then tree_range(t.left, lo, hi)
else tree_range(t.left, lo, hi) + ([] + [t.key]) + tree_range(t.right, lo, hi)
]
t1: tree_insert(tree_empty, 5, "a")
t2: tree_insert(t1, 3, "b")
t3: tree_insert(t2, 8, "c")
t4: tree_insert(t3, 1, "d")
t5: tree_insert(t4, 7, "e")
println(tree_range(t5, 3, 7)) # [3, 5, 7]Commentary. The pruning is what makes this efficient. When the current key is outside the range, we can skip one whole subtree:
t.key < lo — current key is too small, so all of
t.left is too small too. Recurse only right.t.key > hi — current key is too big, so all of
t.right is too big too. Recurse only left.t.key.This is not what a linear scan would do — pruning saves the half of the tree that can’t contribute, getting us back to O(log n + k) where k is the number of results.
is_balanced: func(t) [
if t is TreeEmpty then true
else if not (abs_diff(tree_height(t.left), tree_height(t.right)) <= 1) then false
else if not is_balanced(t.left) then false
else is_balanced(t.right)
]
bad1: tree_insert(tree_empty, 1, "a")
bad2: tree_insert(bad1, 2, "b")
bad3: tree_insert(bad2, 3, "c")
println(is_balanced(bad3)) # false — chain has heights 0, 1, 2
good1: tree_insert(tree_empty, 2, "a")
good2: tree_insert(good1, 1, "b")
good3: tree_insert(good2, 3, "c")
println(is_balanced(good3)) # true — perfect 3-node treeCommentary. Two conditions to check:
The naive implementation re-computes tree_height at
every recursion, making this O(n²). A smarter version returns the height
and the balance status in a single pass, giving O(n) — exercise
for the reader, or wait for Chapter 28’s algorithm-analysis tools.
rotate_right: func(t) [
if t is TreeEmpty then t
else if t.left is TreeEmpty then t
else [
new_right: a TreeNode {
key: t.key, value: t.value, left: t.left.right, right: t.right
}
a TreeNode {
key: t.left.key, value: t.left.value, left: t.left.left, right: new_right
}
]
]
rotate_left: func(t) [
if t is TreeEmpty then t
else if t.right is TreeEmpty then t
else [
new_left: a TreeNode {
key: t.key, value: t.value, left: t.left, right: t.right.left
}
a TreeNode {
key: t.right.key, value: t.right.value, left: new_left, right: t.right.right
}
]
]
# Verify rotation preserves in-order traversal
tree_inorder: func(t) [
if t is TreeEmpty then []
else tree_inorder(t.left) + ([] + [t.key]) + tree_inorder(t.right)
]
r1: tree_insert(tree_empty, 5, "a")
r2: tree_insert(r1, 3, "b")
r3: tree_insert(r2, 7, "c")
r4: tree_insert(r3, 1, "d")
r5: tree_insert(r4, 4, "e")
println("before:", tree_inorder(r5)) # [1, 3, 4, 5, 7]
rotated: rotate_right(r5)
println("after rotate_right:", tree_inorder(rotated)) # [1, 3, 4, 5, 7]
rotated2: rotate_left(r5)
println("after rotate_left:", tree_inorder(rotated2)) # [1, 3, 4, 5, 7]Commentary. The in-order traversal is invariant under both rotations — that’s the load-bearing property. Visually:
Y X
/ \ / \
X c rotate_right a Y
/ \ ────→ / \
a b b c
The “middle” subtree b changes parent — from
being X’s right child to being Y’s left child. But its position
between a and Y in in-order is preserved.
This is the surgical primitive every self-balancing tree uses.
Red-black trees combine rotate_right and
rotate_left with recoloring; AVL trees use rotations to
restore the balance factor; splay trees rotate to move recently-accessed
nodes toward the root.
Three patterns:
is
check routes the recursion at each level.max_of,
abs_diff) and keep the recursive function body a single
expression.# Shared prelude — Road Concept and helpers from Ch.27 §27.1–§27.2
concept Road
Road has source: ""
Road has target: ""
Road has miles: 0
r1: a Road { source: "Boston", target: "NewYork", miles: 215 }
r2: a Road { source: "NewYork", target: "Philadelphia", miles: 95 }
r3: a Road { source: "NewYork", target: "Hartford", miles: 117 }
r4: a Road { source: "Philadelphia", target: "DC", miles: 140 }
roads: [r1, r2, r3, r4]
neighbors_of: func(roads, node) [
result: []
i: 1
while (i <= len(roads)) [
if roads[i].source == node then [
result = result + ([] + [roads[i].target])
]
if roads[i].target == node then [
result = result + ([] + [roads[i].source])
]
i = i + 1
]
result
]
# Reuse Ch.25 hash table
concept HashBucket
HashBucket has key: ""
HashBucket has value: 0
HashBucket has nxt: 0
concept HashTable
HashTable has buckets: 0
HashTable has size: 0
nil_bucket: 0
make_table: func(size) [
buckets: []
i: 1
while (i <= size) [
buckets = buckets + ([] + [nil_bucket])
i = i + 1
]
a HashTable { buckets: buckets, size: size }
]
djb2_hash: func(s) [
h: 5381
i: 1
while (i <= len(s)) [
code: ord(s[i..i])
h = h * 33 + code
i = i + 1
]
if h < 0 then -h else h
]
put_t: func(table, key, item_value) [
idx: djb2_hash(key) % table.size + 1
cur: a HashBucket { key: key, value: item_value, nxt: table.buckets[idx] }
table.buckets[idx] = cur
table
]
walk_chain_get: func(b, key) [
if b == nil_bucket then 0
else if b.key == key then b.value
else walk_chain_get(b.nxt, key)
]
get_t: func(table, key) [
walk_chain_get(table.buckets[djb2_hash(key) % table.size + 1], key)
]
walk_chain_has: func(b, key) [
if b == nil_bucket then false
else if b.key == key then true
else walk_chain_has(b.nxt, key)
]
contains_t: func(table, key) [
walk_chain_has(table.buckets[djb2_hash(key) % table.size + 1], key)
]in_array: func(arr, x) [
i: 1
found: false
while (i <= len(arr) and not found) [
if arr[i] == x then [found = true]
i = i + 1
]
found
]
all_nodes: func(roads) [
nodes: []
i: 1
while (i <= len(roads)) [
if not in_array(nodes, roads[i].source) then [
nodes = nodes + ([] + [roads[i].source])
]
if not in_array(nodes, roads[i].target) then [
nodes = nodes + ([] + [roads[i].target])
]
i = i + 1
]
nodes
]
println(all_nodes(roads))
# ["Boston", "NewYork", "Philadelphia", "Hartford", "DC"]Commentary. Walk every Road, accumulate the unique
endpoints. in_array handles the deduplication. O(V·E)
naively; a hash-set membership would give O(V+E).
has_path: func(roads, src, dst) [
visited: put_t(make_table(64), src, 1)
queue: [src]
found: false
while (len(queue) > 0 and not found) [
node: queue[1]
queue = queue[2..len(queue)]
if node == dst then [found = true]
else [
nbrs: neighbors_of(roads, node)
i: 1
while (i <= len(nbrs)) [
if not contains_t(visited, nbrs[i]) then [
visited = put_t(visited, nbrs[i], 1)
queue = queue + ([] + [nbrs[i]])
]
i = i + 1
]
]
]
found
]
println(has_path(roads, "Boston", "DC")) # true
println(has_path(roads, "DC", "Boston")) # true
println(has_path(roads, "Boston", "Mars")) # falseCommentary. Standard BFS with early exit. We don’t
need the full distance map — just a boolean: did we reach
dst? Stopping the loop as soon as we hit it saves work in
positive cases.
total_miles: func(roads) [
total: 0
i: 1
while (i <= len(roads)) [
total = total + roads[i].miles
i = i + 1
]
total
]
println(total_miles(roads)) # 567Commentary. A one-liner with fold_list
if you’d built your roads as a linked list. With an array, the iterative
loop is the natural form.
concept Prereq
Prereq has course: ""
Prereq has requires: ""
prereqs: [
a Prereq { course: "CS201", requires: "CS101" },
a Prereq { course: "CS201", requires: "MATH101" },
a Prereq { course: "CS301", requires: "CS201" },
a Prereq { course: "CS301", requires: "MATH201" },
a Prereq { course: "CS401", requires: "CS301" },
a Prereq { course: "MATH201", requires: "MATH101" }
]
# Collect all courses referenced anywhere
all_courses: func(ps) [
cs: []
i: 1
while (i <= len(ps)) [
if not in_array(cs, ps[i].course) then [cs = cs + ([] + [ps[i].course])]
if not in_array(cs, ps[i].requires) then [cs = cs + ([] + [ps[i].requires])]
i = i + 1
]
cs
]
# True if every prerequisite of `c` is in `done`.
ready: func(ps, c, done) [
r: true
i: 1
while (i <= len(ps)) [
if ps[i].course == c and not in_array(done, ps[i].requires) then [r = false]
i = i + 1
]
r
]
topo_sort: func(ps) [
remaining: all_courses(ps)
done: []
while (len(remaining) > 0) [
picked: ""
i: 1
while (i <= len(remaining) and picked == "") [
if ready(ps, remaining[i], done) then [picked = remaining[i]]
i = i + 1
]
if picked == "" then [remaining = []] # cycle — bail out
else [
done = done + ([] + [picked])
new_remaining: []
j: 1
while (j <= len(remaining)) [
if remaining[j] != picked then [new_remaining = new_remaining + ([] + [remaining[j]])]
j = j + 1
]
remaining = new_remaining
]
]
done
]
println(topo_sort(prereqs))
# A valid ordering, e.g.:
# ["CS101", "MATH101", "CS201", "MATH201", "CS301", "CS401"]Commentary. The Kahn’s-algorithm-style topo sort: repeatedly pick a “ready” course (one whose prerequisites are all already output) and append it. When no ready course exists, either we’re done (remaining is empty) or there’s a cycle (bail out).
This code repeatedly scans candidates and their prerequisite edges,
so its worst-case selection work is O(V²E), before
Array-copy costs. A better implementation maintains an indegree count
and a queue of zero-indegree nodes, giving O(V+E). The version above is
the teaching version — fewer moving parts, easier to read.
cycle_dfs: func(roads, node, parent, visited) [
visited = put_t(visited, node, 1)
nbrs: neighbors_of(roads, node)
found: false
i: 1
while (i <= len(nbrs) and not found) [
n: nbrs[i]
if n != parent then [
if contains_t(visited, n) then [found = true]
else [
if cycle_dfs(roads, n, node, visited) then [found = true]
]
]
i = i + 1
]
found
]
has_cycle: func(roads) [
nodes: all_nodes(roads)
visited: make_table(64)
result: false
i: 1
while (i <= len(nodes) and not result) [
if not contains_t(visited, nodes[i]) then [
if cycle_dfs(roads, nodes[i], "", visited) then [result = true]
]
i = i + 1
]
result
]
println(has_cycle(roads)) # false — tree
with_cycle: roads + ([] + [a Road { source: "DC", target: "Boston", miles: 600 }])
println(has_cycle(with_cycle)) # trueCommentary. Standard undirected-graph cycle detection. At each node, walk to neighbors except the one we came from. If we land on an already-visited neighbor (that isn’t our immediate parent), we’ve found a back-edge — a cycle.
For directed graphs the algorithm differs: you track a “currently on the stack” marker, separate from the permanent “visited” marker. Back-edges to currently-on-the- stack nodes indicate cycles; back-edges to permanently- visited (but not currently on the stack) nodes don’t.
# Simple priority-queue representation: an array of (node, dist) tuples
# scanned linearly for the minimum. This version favors visible steps
# over speed; edge-list scans and array rebuilding add costs below.
shortest_path: func(roads, src, dst) [
limit: 1
for road in roads [
if road.miles < 0 then return error("Dijkstra requires nonnegative weights")
limit = limit + road.miles
]
if src == dst then return [src,]
nodes: all_nodes(roads)
if not in_array(nodes, src) or not in_array(nodes, dst) then return []
# The sum of all nonnegative edge weights plus one is greater than
# any finite shortest simple path; no arbitrary maximum mileage.
dist: make_table(64)
prev: make_table(64)
i: 1
while (i <= len(nodes)) [
dist = put_t(dist, nodes[i], limit)
i = i + 1
]
dist = put_t(dist, src, 0)
unvisited: nodes
found: false
while (len(unvisited) > 0 and not found) [
# Find unvisited node with minimum distance
cur: unvisited[1]
j: 2
while (j <= len(unvisited)) [
if get_t(dist, unvisited[j]) < get_t(dist, cur) then [cur = unvisited[j]]
j = j + 1
]
if get_t(dist, cur) == limit then return []
if cur == dst then [found = true]
else [
# Remove cur from unvisited
new_unv: []
k: 1
while (k <= len(unvisited)) [
if unvisited[k] != cur then [new_unv = new_unv + ([] + [unvisited[k]])]
k = k + 1
]
unvisited = new_unv
# Relax edges from cur
nbrs: neighbors_of(roads, cur)
m: 1
while (m <= len(nbrs)) [
n: nbrs[m]
edge_w: edge_weight(roads, cur, n)
alt: get_t(dist, cur) + edge_w
if alt < get_t(dist, n) then [
dist = put_t(dist, n, alt)
prev = put_t(prev, n, cur)
]
m = m + 1
]
]
]
if not found then return []
# Reconstruct path by walking prev backwards from dst
path: []
p: dst
while (p is String and p != "") [
path = ([] + [p]) + path
if p == src then [p = ""] else [p = get_t(prev, p)]
]
path
]
edge_weight: func(roads, a, b) [
w: none
i: 1
while (i <= len(roads)) [
r: roads[i]
if (r.source == a and r.target == b) or (r.source == b and r.target == a) then [
if w == none or r.miles < w then [w = r.miles]
]
i = i + 1
]
w
]
println(shortest_path(roads, "Boston", "DC"))
# ["Boston", "NewYork", "Philadelphia", "DC"]
println(shortest_path(roads, "Boston", "Mars")) # []
println(shortest_path(roads, "Boston", "Boston")) # ["Boston"]Commentary on the implementation choice.
O(V²) over the whole run. This implementation
also scans the full edge list for neighbors and weights, rebuilds
arrays, and uses a fixed-capacity teaching hash table. Its total cost is
therefore greater than the priority-queue cost alone.O((V + E) log V) Dijkstra.[]; the path from
a vertex to itself is [src]. There is no arbitrary
billion-mile limit.prev
table: starting at dst, walk back to src via
prev[node], prepending to the path array as we go.Dijkstra is the workhorse shortest-path algorithm. The generalization, A*, adds a heuristic distance-to-goal estimate and is what modern routing software uses.
Three patterns:
All six solutions verified end-to-end against the current
axioma build. Variable names avoid the reserved words
taxonomy, chain, concept, and
relation.
# ====================================================================
# Exercise 28.1 — Sowa CG: Mary MotherOf Alice
# ====================================================================
family: <<<[Person: "Mary"] → (MotherOf) → [Person: "Alice"]>>>
println("Concepts:", family show concepts)
println("Relations:", family show relations)
println("Arcs:", family show arcs)
# ====================================================================
# Exercise 28.2 — Taxonomy chain (Yojo → Cat → Mammal → Animal)
# ====================================================================
tax_chain: <<<[Cat: "Yojo"] → (IsA) → [Mammal] → (IsA) → [Animal]>>>
chain_concepts: tax_chain show concepts
chain_relations: tax_chain show relations
println("Concepts:", len(chain_concepts)) # 3
println("Relations:", len(chain_relations)) # 2
# ====================================================================
# Exercise 28.3 — Peirce alpha EG asserting "not P"
# ====================================================================
neg: eg_alpha("Not P")
p_neg: eg_predicate("P", 0, 100.0, 100.0)
eg_add_predicate(neg, p_neg)
cut_neg: eg_cut(80.0, 80.0, 100.0, 50.0)
eg_add_cut(neg, cut_neg)
println(eg_format(neg))
# ====================================================================
# Exercise 28.4 — Encode P → Q in alpha EG via double-cut
# ====================================================================
mp: eg_alpha("If P then Q")
p_pred: eg_predicate("P", 0, 100.0, 100.0)
q_pred: eg_predicate("Q", 0, 300.0, 100.0)
outer_cut: eg_cut(50.0, 50.0, 400.0, 100.0)
inner_cut: eg_cut(250.0, 80.0, 120.0, 50.0)
eg_add_predicate(mp, p_pred)
eg_add_predicate(mp, q_pred)
eg_add_cut(mp, outer_cut)
eg_add_cut(mp, inner_cut)
println(eg_format(mp))
# ====================================================================
# Exercise 28.5 — Two-context Sherlock
# ====================================================================
g5: context_graph("sherlock_graph", "Sherlock World Model")
fic5: context_create(g5, "fiction", "Fiction", "domain", "b4")
rea5: context_create(g5, "reality", "Reality", "domain", "b4")
context_assert(g5, "fiction", "sherlock", "lives_at_baker_street", "true", 1.0)
context_assert(g5, "reality", "sherlock", "lives_at_baker_street", "false", 0.99)
println("Fiction view:")
println(context_project(g5, "fiction"))
println("Reality view:")
println(context_project(g5, "reality"))
# ====================================================================
# Exercise 28.6 — Three-context paraconsistent resolution
# ====================================================================
g6: context_graph("sherlock_three", "Three Views")
fic6: context_create(g6, "fiction", "Fiction", "domain", "b4")
rea6: context_create(g6, "reality", "Reality", "domain", "b4")
crit6: context_create(g6, "literary_critic", "Critic", "domain", "b4")
context_assert(g6, "fiction", "sherlock", "lives_at_baker_street", "true", 1.0)
context_assert(g6, "reality", "sherlock", "lives_at_baker_street", "false", 0.99)
context_assert(g6, "literary_critic", "sherlock", "lives_at_baker_street", "both", 0.85)
println("Resolution:")
println(context_resolve(g6, "sherlock", "lives_at_baker_street"))The triple-angle-bracket form
<<<[Type: "ref"] → (Rel) → [Type: "ref"]>>>
is Sowa’s original notation lifted straight into Axioma. The
lexer treats the bracket contents specially, which is why the
otherwise-reserved concept and relation
keywords don’t clash inside.
family show concepts / show relations /
show arcs returns three arrays of records; iterate them
with ordinary array operations.
Both taxonomy and chain are reserved words
(they’re part of Axioma’s concept-formation and
extends vocabulary respectively). The solution renames to
tax_chain and chain_concepts /
chain_relations. Watch for this in your own CG code — when
in doubt, prefix or suffix the variable.
The cut is just an oval drawn at given coordinates with given
dimensions. The depth-0 cut at the same nesting level as the predicate
P encodes ¬P. Two predicates both at depth 0
would be P ∧ Q; one inside a cut and one outside is
¬P ∧ Q.
The encoding P → Q ≡ ¬(P ∧ ¬Q) becomes:
+---------------------------+
| P +-------------+ |
| | Q | |
| +-------------+ |
+---------------------------+
The outer cut wraps both P and the inner cut. The inner
cut wraps just Q. The structure visually encodes “not (P
and not-Q)” — material implication without ever writing the
→ symbol.
The same proposition (lives_at_baker_street) carries
truth value true in fiction and
false in reality simultaneously. No
context overrides the other; each context_project returns
its own view. This is the core feature of ist(c, p) —
perspective-dependent truth without contradiction.
When context_resolve sees three contexts disagreeing —
true, false, and both — it
applies the Belnap knowledge-join lattice operation.
The result is both because:
true claim contributes a “true” component.false claim contributes a “false” component.both claim already carries both components.Joining these in the B4 lattice yields both with maximum
confidence. The resolution preserves the disagreement rather
than averaging or collapsing it — exactly the right behavior for
downstream reasoning that needs to know two sources disagreed (e.g., a
journalism front-end that should flag both sides of a contested
claim).
If your application would rather pick the most-confident single
value, use context_resolve with the credulous or
prioritized strategy instead — but the default b4_join
strategy is the principled choice when truth itself is contested.
All solutions verified against the current axioma build.
Solutions 29.1–29.5 are runnable; 29.6 is open-ended (the provided
sketch is one valid answer).
# ====================================================================
# Exercise 29.1 — Build the mood space
# ====================================================================
mood: conceptual_space("Mood", "Russell circumplex")
add_dimension(mood, "Valence", -100, 100)
add_dimension(mood, "Arousal", -100, 100)
add_prototype(mood, "Excited", [60, 80])
add_prototype(mood, "Calm", [60, -40])
add_prototype(mood, "Angry", [-60, 80])
add_prototype(mood, "Sad", [-50, -50])
test_points: [[70, 70], [0, 0], [-30, 60]]
labels: ["Excited", "Calm", "Angry", "Sad"]
i: 1
while (i <= len(test_points)) [
p: test_points[i]
println("Point:", p, " nearest:", nearest_prototype(mood, p))
j: 1
while (j <= len(labels)) [
println(" ", labels[j], "→", prototype_membership(mood, p, labels[j]))
j = j + 1
]
i = i + 1
]
# (70, 70) → nearest=Excited (highest membership: 0.95)
# (0, 0) → nearest=Sad (tie-breaker; all four near 0.7-0.78)
# (-30, 60) → nearest=Angry (membership 0.88)
# ====================================================================
# Exercise 29.2 — Circular vs non-circular hue
# ====================================================================
circ: conceptual_space("HueCirc", "circular hue")
add_dimension(circ, "Hue", 0, 360, "circular", true)
add_prototype(circ, "Red", [0])
flat: conceptual_space("HueFlat", "non-circular hue")
add_dimension(flat, "Hue", 0, 360)
add_prototype(flat, "Red", [0])
test: [[10], [180], [350], [359]]
println("Hue | circular | non-circular")
println("--------|----------|-------------")
k: 1
while (k <= len(test)) [
p: test[k]
println(p, " | ", distance(circ, [0], p), " | ", distance(flat, [0], p))
k = k + 1
]
# [10] | 0.028 | 0.028 # near in both
# [180] | 0.500 | 0.500 # midpoint — same either way
# [350] | 0.028 | 0.972 # CIRCULAR wraps; FLAT thinks it's far
# [359] | 0.003 | 0.997 # CIRCULAR sees it's basically Red
# One-sentence explanation: the circular flag tells the metric to
# wrap modularly, so hue 350° and 0° are 10° apart (close) instead
# of 350° apart (far) — without it, all queries near the red wrap
# point silently mislead.
# ====================================================================
# Exercise 29.3 — Interpolation paths through taste space
# ====================================================================
taste: conceptual_space("Taste", "primary tastes")
add_dimension(taste, "Sweet", 0, 100)
add_dimension(taste, "Sour", 0, 100)
add_dimension(taste, "Bitter", 0, 100)
add_prototype(taste, "Lemon", [15, 95, 5])
add_prototype(taste, "Coffee", [10, 5, 90])
add_prototype(taste, "Honey", [95, 5, 5])
add_prototype(taste, "DarkChoc", [50, 10, 70])
path: interpolate_path(taste, [[15, 95, 5], [50, 10, 70], [10, 5, 90]], 5)
println("Path length:", len(path), "(11 waypoints)")
m: 1
while (m <= len(path)) [
pt: path[m]
println("step", m, ":", pt, "nearest:", nearest_prototype(taste, pt))
m = m + 1
]
# Output: 11 waypoints; first 3 nearest=Lemon, middle 5 nearest=DarkChoc,
# last 3 nearest=Coffee. The framework's geometric arrangement smoothly
# transitions from sour through bitter — passing through DarkChoc as the
# midpoint exactly because that's where we placed it.
# ====================================================================
# Exercise 29.4 — Fuzzy classification across the hue circle
# ====================================================================
hsv: conceptual_space("HSV3", "")
add_dimension(hsv, "Hue", 0, 360, "circular", true)
add_dimension(hsv, "Saturation", 0, 100)
add_dimension(hsv, "Value", 0, 100)
add_prototype(hsv, "Red", [0, 100, 50])
add_prototype(hsv, "Green", [120, 100, 50])
add_prototype(hsv, "Blue", [240, 100, 50])
h: 0
println("hue R-mem G-mem B-mem strong-claims (>0.95)")
while (h < 360) [
p: [h, 100, 50]
mr: prototype_membership(hsv, p, "Red")
mg: prototype_membership(hsv, p, "Green")
mb: prototype_membership(hsv, p, "Blue")
claims: []
if mr > 0.95 then [claims = claims + ["Red"]]
if mg > 0.95 then [claims = claims + ["Green"]]
if mb > 0.95 then [claims = claims + ["Blue"]]
println(h, " ", mr, " ", mg, " ", mb, " ", claims)
h = h + 36
]
# Pattern: hues at 0/36/324 cluster around Red; hues at 108/144 cluster
# Green; hues at 216/252 cluster Blue. Midpoint hues (72, 180, 288) have
# NO strong claim — they sit between two prototypes. This is the value
# of fuzzy membership over crisp set membership.
# ====================================================================
# Exercise 29.5 — Salience changes nearest_prototype, not membership
# ====================================================================
s1: conceptual_space("S1", "")
add_dimension(s1, "X", 0, 100)
add_dimension(s1, "Y", 0, 100)
add_prototype(s1, "Heavy", [10, 10], "salience", 1.0)
add_prototype(s1, "Light", [10, 90], "salience", 0.3)
p: [10, 50]
println("Initial saliences (Heavy=1.0, Light=0.3):")
println(" Heavy membership:", prototype_membership(s1, p, "Heavy"))
println(" Light membership:", prototype_membership(s1, p, "Light"))
println(" nearest:", nearest_prototype(s1, p)) # → Heavy
s2: conceptual_space("S2", "")
add_dimension(s2, "X", 0, 100)
add_dimension(s2, "Y", 0, 100)
add_prototype(s2, "Heavy", [10, 10], "salience", 0.3)
add_prototype(s2, "Light", [10, 90], "salience", 1.0)
println("Swapped saliences (Heavy=0.3, Light=1.0):")
println(" Heavy membership:", prototype_membership(s2, p, "Heavy"))
println(" Light membership:", prototype_membership(s2, p, "Light"))
println(" nearest:", nearest_prototype(s2, p)) # → Light
# Explanation: the point (10, 50) is equidistant from both prototypes
# (40 units up to Heavy, 40 units down to Light). prototype_membership
# returns the same fuzzy value for both because the metric is symmetric.
# nearest_prototype, however, multiplies the distance by 1/salience —
# so the higher-salience prototype "pulls" the point. When Heavy has
# higher salience, the point lands in Heavy's basin; swap, and it
# lands in Light's. Salience is the *voting weight*, not the *metric*.
# ====================================================================
# Exercise 29.6 (open) — Programming-language space
# ====================================================================
langs: conceptual_space("ProgLangs", "language characteristics")
add_dimension(langs, "Typing", 0, 100) # 0=dynamic, 100=static
add_dimension(langs, "Paradigm", 0, 100) # 0=imperative, 100=functional
add_dimension(langs, "Compiled", 0, 100) # 0=interpreted, 100=AOT
add_prototype(langs, "Python", [10, 30, 10])
add_prototype(langs, "Haskell", [95, 95, 80])
add_prototype(langs, "Go", [85, 25, 95])
add_prototype(langs, "Lisp", [20, 80, 20])
add_prototype(langs, "Rust", [95, 50, 95])
add_prototype(langs, "JavaScript", [15, 40, 15])
# Three classification questions
println("Q1: statically-typed, functional, compiled:", nearest_prototype(langs, [90, 90, 70]))
println("Q2: dynamic-ish, imperative, compiled-fast:", nearest_prototype(langs, [25, 30, 80]))
println("Q3: gradual, mixed-paradigm, JIT:", nearest_prototype(langs, [50, 60, 50]))
# Reflection: the Typing dimension (0=dynamic, 100=static) feels right
# because there's a real continuum (TypeScript at 60, mypy-Python at 40).
# Paradigm is more dubious — imperative-vs-functional isn't a single
# spectrum, it conflates "supports first-class functions" with "prefers
# immutable data." A PCA over real language features would probably
# split it into 2-3 separate latent dimensions: lambda support, mutation
# rules, control-flow style. That's the trade between interpretable
# human-named axes and accurate latent axes.The point (0, 0) is the origin of the Russell circumplex —
it’s equidistant from all four mood prototypes (each at ~70 units away).
All four membership scores cluster around 0.70-0.78, and
nearest_prototype breaks the tie by iteration order. Real
systems disambiguate by adding a fifth “Neutral” prototype at (0, 0) or
by reporting the top-2 with their margin.
The non-circular distance(flat, [0], [359]) ≈ 0.997 says
hue 359 is essentially at the OTHER end of the spectrum from hue 0 —
which is wrong, since red wraps. The circular version returns
0.003 (essentially zero), correctly identifying 359° as
basically red.
Without the flag, every query near the wrap point of a modular dimension produces silently wrong results. The flag is the single most important keyword option in the API — for any cyclic quality (hue, time-of-day, day-of- week, angular position) you must set it.
We chose Lemon → DarkChoc → Coffee as the three control points and asked for 5 interpolation steps. The output shows the path moves smoothly through Lemon, transitions into DarkChoc territory in the middle, and ends in Coffee — exactly the geometric story the prototypes told.
A graph KR would need an ad-hoc taste-pair table to answer “is this drink between Lemon and Coffee?” The CS gets it for free from geometry.
If you set radius on the prototypes, the radius is
normalized by sqrt(n_dimensions). With radius: 30
in a 3D space, normalized radius ≈ 17 — and the maximum normalized
distance in this space is only ~1.4 — so every point is “inside” every
prototype’s ball. That’s why the solution uses point prototypes
(no radius) plus a strict threshold (0.95).
The takeaway: if you want crisp ball regions, use
prototype_membership > some_threshold rather than
in_prototype with a radius. The radius normalization is
documented in §29.10 as a gap.
This is the conceptually subtle one. Two different mechanisms could give a prototype more “pull”:
Axioma implements (2): nearest_prototype divides each
candidate’s distance by its salience before comparing, so higher
salience = effectively shorter distance to win. But
prototype_membership reports the raw geometric membership,
which doesn’t change.
Pedagogically: salience is the vote weight, not the metric.
If you want a metric distortion, use the per-dimension
weight keyword on add_dimension instead.
The three-dimension language space gets the gross geography right: Haskell separates from Python by a lot, Rust separates from JavaScript by a lot. But it has known weaknesses:
A PCA on real language metadata (paradigm-flag features, StackOverflow tag-co-occurrence, GitHub search hits) would extract latent dimensions that correlate better with actual classifications — at the cost of interpretability. This trade is universal: human-named dimensions are auditable; data-driven dimensions are accurate. Production systems use both.
All solutions verified against the current axioma build.
Solutions 30.1–30.4 are runnable; 30.5 is a paper-and- pencil exercise;
30.6 is open-ended.
# ====================================================================
# Exercise 30.1 — Round-trip identity
# ====================================================================
println(decode(encode("42")) == "42") # true
println(decode(encode("x")) == "x") # true
println(decode(encode("x + 1")) == "x + 1") # true
println(decode(encode("hello")) == "hello") # true
# ====================================================================
# Exercise 30.2 — Distinct expressions, distinct numbers
# ====================================================================
gn_x: encode("x")
gn_y: encode("y")
println(decode(gn_x) == decode(gn_y)) # false
println(decode(gn_x)) # "x"
println(decode(gn_y)) # "y"
# ====================================================================
# Exercise 30.3 — Whitespace is preserved (NOT normalized)
# ====================================================================
g1: encode("x + 1")
g2: encode("x + 1")
println(decode(g1)) # "x + 1"
println(decode(g2)) # "x + 1" (whitespace preserved)
println(decode(g1) == decode(g2)) # false
# ====================================================================
# Exercise 30.4 — Quine substitution pattern
# ====================================================================
q: "q: X; println(X)"
quoted_q: "\"q: X; println(X)\""
prog: replace(q, "X", quoted_q)
println(prog)
# Output:
# q: "q: X; println(X)"; println("q: X; println(X)")
#
# This shows the pattern but isn't a byte-exact quine — see chapter §30.10.encode/decode round-tripThe encode/decode pair is the minimal reliable surface of
Axioma’s Gödelization layer. For any string s that parses
as valid Axioma syntax, decode(encode(s)) == s holds
verbatim. The encoder validates by parsing, then encodes the literal
bytes.
Two different strings produce two different Gödel numbers. We compare
via decode(...) == decode(...) instead of direct integer
comparison because the Gödel number is a
*types.GödelNumberObject (with
type(gn) = "unknown" in the current build) — direct
comparison via == is not reliable across the runtime’s
coercion rules. The decode round-trip is the
user-script-friendly way to check identity.
The pilot chapter incorrectly claimed encode normalizes
whitespace via AST round-tripping. The probe in §30.3.4 of the full
chapter shows the truth: whitespace is preserved.
encode parses for validity (a parse error on
"encode(\"x\")" confirms this) but encodes the literal
bytes. The probe was the disambiguating evidence.
The exercise produces a string that demonstrates the substitution pattern but isn’t itself a byte-exact quine. Building a byte-exact quine requires the two-level substitution sketched in the chapter; this exercise just makes the move visible.
A single symbol with code c encodes to 2^c
in the classical Gödel scheme (one symbol → first prime → that prime
raised to the symbol’s code). With P → 16:
encoding(P) = 2^16 = 65536
For comparison, the encoding of "AB" (codes 1, 2)
is:
encoding(AB) = 2^1 · 3^2 = 2 · 9 = 18
A six-symbol string with average code 13 reaches a 36-digit number even in this small example; Axioma’s internal representation uses a variant that grows more slowly but the phenomenon (massive number growth) is real.
Sample modern applications of Gödelization (see chapter §30.10 for a longer list):
selfEncode (the basis for a 3-line quine),
diagonalize (programmatic self-reference), and the
createFormalSystem / provable /
proveIncompleteness chain are not reliable in the current
build. Once they are, this chapter gains working solutions for two more
substantial exercises (build an incompleteness demo end-to-end,
write a working 3-line quine via selfEncode) that would replace
the open-ended 30.6.
All solutions verified against the current axioma build.
Solutions 31.1–31.5 are runnable; 31.6 is open-ended (the provided
sketch is one valid answer in the scientific- papers domain).
# ====================================================================
# Exercise 31.1 — Place a relation
# ====================================================================
relation taught(instructor -> "instructor",
subject -> "subject",
student -> "student")
taught("Alice", "calculus", "Bob")
taught("Alice", "calculus", "Carol")
taught("Alice", "calculus", "Dave")
taught("Eve", "logic", "Bob")
println("Who taught Bob?")
println({I | I <- taught(I, _, "Bob")})
# → {"Alice", "Eve"}
println("What did Alice teach?")
println({S | S <- taught("Alice", S, _)})
# → {"calculus"}
println("How many students learned calculus?")
println(len({St | St <- taught(_, "calculus", St)}))
# → 3
# ====================================================================
# Exercise 31.2 — A small citation graph
# ====================================================================
relation cite(paper :: String -> "paper",
author :: String -> "author") doc: "P cites A"
relation wrote(author :: String -> "author",
paper :: String -> "paper")
cite("Pearl1988", "Russell")
cite("Pearl1988", "Norvig")
cite("Russell2010", "Pearl")
wrote("Pearl", "Pearl1988")
wrote("Russell", "Russell2010")
wrote("Norvig", "Russell2010")
println("cited authors:", {X | X <- cite(_, X)})
# → {"Norvig", "Pearl", "Russell"}
println("authors who wrote:", {X | X <- wrote(X, _)})
# → {"Norvig", "Pearl", "Russell"}
# Note: use named variables (not _) inside Horn-rule bodies
self_cite(X) <= wrote(X, P) and cite(P, X)
println("self-cites:", {X | X <- self_cite(X)})
# → {}
mutual(X, Y) <= wrote(X, P1) and cite(P1, Y)
and wrote(Y, P2) and cite(P2, X)
println("mutual:", {(X, Y) | mutual(X, Y)})
# → {("Norvig", "Pearl"), ("Pearl", "Norvig"),
# ("Pearl", "Russell"), ("Russell", "Pearl")}
# ====================================================================
# Exercise 31.3 — Type annotations as documentation
# ====================================================================
relation birthday(person :: String -> "person",
year :: Integer -> "year")
birthday("Alice", 1990)
birthday("Bob", 1985)
birthday("Carol", "unknown") # PASSES despite year :: Integer
println("All facts (note Carol's year is a string!):")
println({(P, Y) | birthday(P, Y)})
# → {("Alice", 1990), ("Bob", 1985), ("Carol", "unknown")}
# Custom runtime typecheck using the `type()` builtin
check_birthday: func(p, y) [
if type(y) == Integer then [
println(" ok:", p, y)
] else [
println(" WARN:", p, "year not integer:", y)
]
]
println("Running custom typecheck:")
check_birthday("Alice", 1990) # → ok: Alice 1990
check_birthday("Carol", "unknown") # → WARN: Carol year not integer: unknown
# ====================================================================
# Exercise 31.4 — Recursive ancestor
# ====================================================================
relation parent(p -> "parent", c -> "child")
parent("Alice", "Bob")
parent("Bob", "Carol")
parent("Carol", "Dave")
grandparent(X, Z) <= parent(X, Y) and parent(Y, Z)
ancestor(X, Y) <= parent(X, Y)
ancestor(X, Z) <= parent(X, Y) and ancestor(Y, Z)
println("Grandparents:", {(X, Z) | grandparent(X, Z)})
# → {("Alice", "Carol"), ("Bob", "Dave")}
println("Alice's descendants:", {Y | Y <- ancestor("Alice", Y)})
# → {"Bob", "Carol", "Dave"}
println("Dave's ancestors:", {X | X <- ancestor(X, "Dave")})
# → {"Alice", "Bob", "Carol"}
# ====================================================================
# Exercise 31.5 — Arity errors
# ====================================================================
relation loves(lover -> "lover", beloved -> "beloved")
loves("Romeo", "Juliet")
println({(X, Y) | loves(X, Y)}) # → {("Romeo", "Juliet")}
# These would each error:
# loves("Romeo") # → relation loves expects 2 arguments, got 1
# loves("R", "J", "extra") # → relation loves expects 2 arguments, got 3
# Explanation (place this as a comment in the .ax file):
# Arity enforcement catches *structural* bugs — wrong number of arguments.
# Named-place semantics catches *semantic* bugs — wrong meaning at a
# position (you read x1 as "giver" instead of "gift"). They are
# complementary: arity is checked by the runtime; named places are
# checked by the human reading the code and the downstream typechecker.
# ====================================================================
# Exercise 31.6 (open) — Scientific-papers domain
# ====================================================================
relation authored(person :: String -> "author",
paper :: String -> "paper")
doc: "Person is an author of paper"
relation cites(citing :: String -> "citing paper",
cited :: String -> "cited paper")
doc: "First paper cites the second"
relation appeared_in(paper :: String -> "paper",
venue :: String -> "venue",
year :: Integer -> "year")
doc: "Paper was published at venue in year"
relation supersedes(later :: String -> "later paper",
earlier :: String -> "earlier paper")
doc: "Later paper supersedes earlier (v2 of same work)"
relation reviews(reviewer :: String -> "reviewer",
paper :: String -> "paper",
score :: Integer -> "score")
doc: "Reviewer scored paper"
# Sample population
authored("Pearl", "P88")
cites("R10", "P88")
appeared_in("P88", "AI Journal", 1988)
supersedes("R10v2", "R10")
reviews("Smith", "R10v2", 8)
println("Pearl's papers:", {P | P <- authored("Pearl", P)})
println("Cited papers:", {C | C <- cites(_, C)})
println("1988 papers:", {P | P <- appeared_in(P, _, 1988)})Following Lojban convention, the most salient actor
goes in x1. For a teaching event the instructor
causes the event — they decide what to teach, when, to whom —
so they get x1. The subject is the theme (the
thing transferred), so x2. The student is the
recipient, so x3. This mirrors the Lojban gismu
dunda (give): the giver is x1, the gift is
x2, the recipient is x3.
Wildcards work in comprehensions, not in Horn-rule
bodies. A comprehension like
{X | X <- cite(_, X)} correctly treats _ as
“don’t care”. But a rule like
wrote_someone(X) <= wrote(X, _) silently returns an
empty result. The fix: use a named variable (Y,
P, etc.) for the unused position, as the
self_cite and mutual rules show.
Mutual citation produces two pairs per match. Both
(Russell, Pearl) and (Pearl, Russell) appear
in the result because the relation is symmetric in semantics but not in
encoding — each direction of the implication fires separately. Filter
with X < Y in the rule body if you want
canonical-direction pairs only.
The :: Integer annotation on year lives in
the AST for use by --typecheck (Volume I Ch.19’s static
pre-pass) and by external tooling. The runtime relational
subsystem doesn’t enforce it because:
The pattern: define liberally, type-check periodically, ship with confidence.
Empirically, the Horn-rule machinery does ~3 rounds of forward
chaining on recursive rules before stopping. With a 4-generation chain,
all three transitive degrees from Alice (Bob, Carol, Dave) are
reachable. A 5-generation chain would miss the deepest descendant from
Alice’s perspective — but querying from the other end
(ancestor(_, "Eve")) would still find Alice.
This is the kind of operational detail that production KR systems hide with bottom-up fixpoint computation. For teaching purposes, stay within the engine’s fixed-depth budget, or run the same recursive rule from the other direction.
Arity prevents structural bugs:
loves("Romeo") is wrong — missing argument.loves("R", "J", "extra") is wrong — extra
argument.Named places prevent semantic bugs:
gives("book", "Mary", "John") parses fine (correct
arity) but is meaningfully wrong: the reader can’t tell if
book is the giver, the gift, or the recipient.The two together produce the Lojban discipline: structure is
enforced by the runtime, meaning is enforced by the schema’s
place labels. Production Cascade code uses both, plus the
--typecheck pre-pass for full coverage.
authored(author, paper)The Lojban convention puts the agent first. In
authored, the author is the agent (the entity
doing the authoring); the paper is the theme. Same logic for
cites (the citing paper is the active “subject” of the
citation act) and reviews (the reviewer is the agent).
The interesting case is appeared_in(paper, venue, year)
— the paper is not the agent (the venue “hosted” it). The
choice of paper in x1 follows the
focus-of-discourse principle: the paper is what the
fact is about, even if not the causal agent.
These conventions are exactly the kind of judgment-call documentation
that named places make explicit. A graph KR with edges like
APPEARED_IN(p, v, y) would document the same thing in an
edge-direction convention buried in a wiki — easier to drift.
All solutions verified against the current axioma build.
Solutions 32.1–32.5 are runnable; 32.6 is open-ended.
# ====================================================================
# Exercise 32.1 — Inventory by category
# ====================================================================
cats: nsm_categories()
total: 0
i: 1
while (i <= len(cats)) [
ps: nsm_primes_by_category(cats[i])
println(cats[i], ":", len(ps), "primes")
total = total + len(ps)
i = i + 1
]
println("---")
println("Total:", total)
# Total → 66
# ====================================================================
# Exercise 32.2 — Allolex lookup
# ====================================================================
primes: nsm_primes()
n_allolex: 0
j: 1
while (j <= len(primes)) [
if len(primes[j].allolexes) > 0 then [
println(primes[j].prime, "→", primes[j].allolexes)
n_allolex = n_allolex + 1
]
j = j + 1
]
println("---")
println("Primes with allolexes:", n_allolex, "/", len(primes))
# → 17 / 66
# ====================================================================
# Exercise 32.3 — Validate-and-refine "regret"
# ====================================================================
# Attempt 1 — "feels", "they", "did" all fail
v1: "someone feels bad because they did something"
r1: nsm_validate_explication(v1)
println("v1:", v1)
println(" valid:", r1.valid, "| issues:", r1.grammar_issues)
# Attempt 2 — fix "feels" → "feel"; "they" → drop it
v2: "someone feel bad because did something"
r2: nsm_validate_explication(v2)
println("v2:", v2)
println(" valid:", r2.valid, "| issues:", r2.grammar_issues)
# Attempt 3 — lemmatize "did" → "do"
v3: "someone feel bad because do something"
r3: nsm_validate_explication(v3)
println("v3:", v3)
println(" valid:", r3.valid, "| used:", r3.used_primes)
# Attempt 4 — give FEEL its valency-2 argument (what is felt)
v4: "I feel something bad because I do something"
r4: nsm_validate_explication(v4)
println("v4:", v4)
println(" valid:", r4.valid, "| used:", r4.used_primes)
# ====================================================================
# Exercise 32.4 — Translation pipeline
# ====================================================================
canonical_prime: func(w) [
lw: lower(w)
primes: nsm_primes()
i: 1
found: ""
while (i <= len(primes) and found == "") [
p: primes[i]
if lower(p.prime) == lw then [
found = p.prime
] else [
k: 1
while (k <= len(p.allolexes) and found == "") [
if lower(p.allolexes[k]) == lw then [
found = p.prime
]
k = k + 1
]
]
i = i + 1
]
found
]
translate_explication: func(text, target_lang) [
words: split(text, " ")
out: ""
i: 1
while (i <= len(words)) [
w: words[i]
cp: canonical_prime(w)
if cp != "" then [
out = out + nsm_translate(cp, target_lang) + " "
] else [
out = out + w + " "
]
i = i + 1
]
out
]
src: "I think this is good"
println("Source:", src)
println("French: ", translate_explication(src, "french"))
println("German: ", translate_explication(src, "german"))
println("Japanese:", translate_explication(src, "japanese"))
# ====================================================================
# Exercise 32.5 — Decompose "gratitude" into primes
# ====================================================================
lines: [
"someone do something good",
"I feel something good because this",
"I want do something good"
]
m: 1
while (m <= len(lines)) [
r: nsm_validate_explication(lines[m])
println("L", m, ":", lines[m])
println(" valid:", r.valid, "| used:", r.used_primes)
m = m + 1
]
concept Gratitude
Gratitude has explication: lines
Gratitude has used_primes: ["SOMEONE", "DO", "SOMETHING", "GOOD", "I",
"FEEL", "BECAUSE", "THIS", "WANT"]
Gratitude has language_neutral: true
println("---")
println("Gratitude.explication:", Gratitude.explication)
println("Gratitude.used_primes:", Gratitude.used_primes)
println("Gratitude.language_neutral:", Gratitude.language_neutral)
# ====================================================================
# Exercise 32.6 (open) — Critique the spatial primes
# ====================================================================
space_primes: nsm_primes_by_category("space")
s: 1
println("Spatial primes:")
while (s <= len(space_primes)) [
println(" -", space_primes[s].prime, "—", space_primes[s].examples)
s = s + 1
]The canonical Wierzbicka set is 65 primes. Axioma’s
registry has 66 because it splits the polysemous
English be into three location/existence variants
(BE (SOMEWHERE), BE (SOMEONE/SOMETHING),
BE (SOMEONE'S)) where some Wierzbicka tabulations collapse
two of them. The extra entry is harmless — it makes the registry more
explicit about the argument structure.
The 17 primes with allolexes cluster around three uses:
I → me,
BE (SOMEWHERE) → is/am/are,
BE (SOMEONE/SOMETHING) → is.SOMEONE → person, SOMETHING → thing,
MUCH → many, LITTLE → few.WHEN → time, WHERE → place.The validators accept the allolexes as if they were the canonical prime, which lets your explications sound more natural English while still being machine-checkable.
The grammar_issues field is the steering signal: each
listed word tells you what to replace. The suggestions
field then names the prime it thinks you meant. Pedagogy: don’t
write the explication once and check it once. Write, validate, read the
issues, rewrite. Three or four iterations is typical.
Note how the path from v1 to v4 simultaneously:
feels →
feel, did → do).they) with explicit speakers
(I or someone).A naive pipeline calls nsm_translate(upper(word), lang)
for any word where is_nsm_prime(word) == true. But
is_nsm_prime("is") == true (because “is” is an allolex of
BE (SOMEWHERE)), while
nsm_translate("IS", lang) errors because IS
isn’t a canonical prime name. The fix: walk the registry to find the
canonical prime that owns this allolex, then translate
that.
The output shows the translations include multi-form entries
(French’s ce/cette for this) because the
registry stores both gendered French forms in one slot. Real translation
would need a downstream disambiguation step.
Wierzbicka’s explications are typically multi-line prose,
one proposition per line. The convention mirrors a structured argument:
cause line → effect line → disposition line. Splitting it lets each line
be validated independently and lets the structure carry meaning (line
2’s because this refers anaphorically to line 1).
The language_neutral: true flag is the operational
payoff: because every word in the explication is a prime, the whole
concept translates by table lookup into any of the 7 supported
languages without re-extraction.
The canonical NSM defense: even Guugu Yimithirr (the famous
Australian language that orients its speakers to cardinal directions
instead of relative left/right) has some way of describing
vertical relations — sky versus ground, head versus feet. Critics
counter that these are contextual phrasings, not lexical primes
in the way ABOVE is treated in English. The honest answer:
NSM treats vertical orientation as universal for human bodies under
gravity, then translates per language using whatever surface form
expresses that relation. Whether that’s a faithful description of
meaning or a back-projection of English structure remains an
open question — exactly the philosophically interesting territory NSM
forces you to think about.
Parallel critiques apply to:
FAR / NEAR — culturally relative scale.INSIDE — depends on container ontology, which varies
across languages.SIDE — body-relative versus landmark-relative
orientation systems differ.NSM survives these by being thin: a prime claims to exist somewhere in every language, not to be the best fit, and translations sometimes carry significant rephrasing.
All solutions verified against the current axioma build.
Solutions 33.1–33.5 are runnable; 33.6 is open-ended.
# ====================================================================
# Exercise 33.1 — Inventory the registry
# ====================================================================
prims: cd_primitives()
println("Count:", len(prims)) # Count: 11
i: 1
while (i <= len(prims)) [
println(prims[i].name)
i = i + 1
]
# ====================================================================
# Exercise 33.2 — Map verbs to primitives
# ====================================================================
verbs_to_acts: [
["kick", "PROPEL"],
["eat", "INGEST"],
["hear", "MTRANS"],
["go", "PTRANS"],
["say", "SPEAK"],
["decide", "MBUILD"]
]
j: 1
while (j <= len(verbs_to_acts)) [
pair: verbs_to_acts[j]
println(pair[1], "→", pair[2], "(valid?", is_cd_primitive(pair[2]), ")")
j = j + 1
]
# ====================================================================
# Exercise 33.3 — Roles per primitive
# ====================================================================
print_roles: func(name) [
if is_cd_primitive(name) then [
println(name, "→", cd_roles(name))
] else [
println("ERROR:", name, "is not a registered primitive")
]
]
print_roles("ATRANS") # ATRANS → ["actor", "object", "from", "to"]
print_roles("INGEST") # INGEST → ["actor", "object"]
print_roles("MOVE") # MOVE → ["actor", "body_part", "direction"]
print_roles("XFRANS") # ERROR: XFRANS is not a registered primitive
# ====================================================================
# Exercise 33.4 — Synonymy via shared primitive
# ====================================================================
verb_to_primitive: func(verb) [
if verb == "give" then "ATRANS"
else if verb == "donate" then "ATRANS"
else if verb == "sell" then "ATRANS"
else if verb == "transfer" then "ATRANS"
else if verb == "go" then "PTRANS"
else if verb == "send" then "PTRANS"
else if verb == "eat" then "INGEST"
else if verb == "drink" then "INGEST"
else "UNKNOWN"
]
same_primitive: func(v1, v2) [
p1: verb_to_primitive(v1)
p2: verb_to_primitive(v2)
p1 == p2 and is_cd_primitive(p1)
]
println(same_primitive("give", "donate")) # true
println(same_primitive("give", "transfer")) # true
println(same_primitive("give", "go")) # false
println(same_primitive("eat", "drink")) # true
println(same_primitive("go", "send")) # true
println(same_primitive("unicorn","narwhal")) # false
# ====================================================================
# Exercise 33.5 — Decompose "promise"
# ====================================================================
concept Promise
Promise has step1_primitive: "MBUILD"
Promise has step1_content: "I will do X"
Promise has step2_primitive: "MTRANS"
Promise has step2_actor: "speaker"
Promise has step2_to: "listener"
Promise has flavor: "commitment"
println("Promise step 1:",
Promise.step1_primitive,
"valid?", is_cd_primitive(Promise.step1_primitive))
println("Promise step 2:",
Promise.step2_primitive,
"valid?", is_cd_primitive(Promise.step2_primitive))
println("Flavor:", Promise.flavor)cd_primitives() returns an array of ObjectMaps, each
with {description, name, roles} keys. Accessing
.name projects out the primitive identifier; the full
record is available if you need description or roles too.
is_cd_primitive is case-insensitive:
"atrans", "ATRANS", and "Atrans"
all return true. The canonical form returned by
cd_primitives() is upper-case, and chapter prose uses
upper-case throughout.
The role names for each primitive are fixed by the registry.
They aren’t user-extensible from the Axioma surface — if you want a
custom role beyond the registered ones, attach it to your Concept
directly (see Exercise 33.5’s step1_content,
flavor, etc.). The CD primitives provide the
spine; your domain provides the flesh.
is_cd_primitive guardA naive same_primitive that just returns
p1 == p2 would incorrectly say unicorn and
narwhal share a primitive (both map to
"UNKNOWN"). Guarding with is_cd_primitive(p1)
ensures we only confirm sameness when the shared label is a registered
primitive.
The Concept machinery from Volume I (Chapter 18) is the
natural place to record CD decompositions. Each “step” becomes a slot.
The Promise.step1_primitive +
Promise.step2_primitive pattern can scale to
arbitrary-depth decompositions (some compound verbs decompose into 4–5
primitives in sequence).
For a programmatic iteration, you’d typically store the steps as an array of records:
Promise has steps: [
{primitive: "MBUILD", content: "I will do X"},
{primitive: "MTRANS", actor: "speaker", "to": "listener"}
]The chapter’s solution uses individual slots for clarity; production code would use the array form.
Sample reasoning at three domain extremes:
The general rule: vocabulary diversity per concept-spine is the metric. Higher ratio → CD pays off more.
All solutions verified by running with axioma --no-kb
and recording the wall-clock timings on the textbook-development
machine. Timings on your machine will differ — what
matters is the doubling ratio, which identifies the
growth class regardless of constant factor.
make: func(n) [
xs: []
j: 1
while j <= n [
xs = xs + [j]
j = j + 1
]
xs
]
count_evens: func(arr) [
c: 0
i: 1
while i <= len(arr) [
if arr[i] % 2 == 0 then [c = c + 1]
i = i + 1
]
c
]
sum_odds: func(arr) [
s: 0
i: 1
while i <= len(arr) [
if arr[i] % 2 != 0 then [s = s + arr[i]]
i = i + 1
]
s
]
sizes: [1000, 2000, 4000, 8000]
i: 1
while i <= len(sizes) [
n: sizes[i]
arr: make(n)
r1: bench("ce", func() [count_evens(arr)])
r2: bench("so", func() [sum_odds(arr)])
println(n, "ce:", r1.elapsed_ms, "so:", r2.elapsed_ms)
i = i + 1
]Measured output:
1000 ce: 57.8 ms so: 53.0 ms
2000 ce: 103.2 ms so: 102.2 ms (~1.85×)
4000 ce: 212.1 ms so: 211.0 ms (~2.05×)
8000 ce: 420.8 ms so: 418.2 ms (~1.98×)
(a) Growth class: O(n). Doubling
n doubles the time within measurement noise. Each function
makes one pass over the array.
(b) Why aren’t ce and so
identical? Two reasons:
sum_odds does an addition (s = s + arr[i])
where count_evens does an increment — slightly different
inner-loop cost, both small constants.The lesson: in the same complexity class, the absolute numbers don’t tell you which is “better” — the constant factor depends on the work done per iteration, the data, the cache state. Complexity class predicts scaling; constant factor predicts the actual ms at any specific size.
count_pairs_summing_to: func(arr, target) [
c: 0
n: len(arr)
i: 1
while i <= n - 1 [
j: i + 1
while j <= n [
if arr[i] + arr[j] == target then [c = c + 1]
j = j + 1
]
i = i + 1
]
c
]Measured output:
100 -> 9.4 ms
200 -> 33.1 ms (3.54×)
400 -> 119.2 ms (3.59×)
800 -> 456.5 ms (3.83×)
(a) Prediction from code: the outer loop runs
n - 1 times, the inner runs at most n - 1
times — total work n(n-1)/2 ≈ n²/2. Class:
O(n²).
(b) Measurement: the doubling ratios are 3.54, 3.59,
3.83 — approaching the theoretical 4× as n grows. At small
n the constant overhead (loop setup, function call, bench
timer) drags the ratio below 4×; the asymptotic behavior dominates as
n grows.
(c) Prediction matches. Quadratic confirmed empirically.
linear_search: func(arr, target) [
i: 1
found: -1
while i <= len(arr) [
if arr[i] == target then [
found = i
i = len(arr) + 1
] else [
i = i + 1
]
]
found
](Plus bsearch and repeated_* from the
chapter.)
Measured output (1000 lookups per row):
n=10 lin: 516.5 ms bs: 165.2 ms (bs wins ×3.1)
n=100 lin: 2,886.6 ms bs: 179.6 ms (bs wins ×16)
n=1000 lin: 26,955.9 ms bs: 184.6 ms (bs wins ×146)
n=10000 lin: 26,585.1 ms bs: 198.4 ms (bs wins ×134)
(a) Binary search wins at every measured
n — including n = 10. In the Axioma
interpreter, the bench overhead and loop setup dominate over a 5-step vs
4-step inner loop. To find a “true” switchover, you’d need to search
inside a tight Go loop where the constant factors align — or use a much
smaller target search (single lookup per bench instead of 1000).
(b) Why doesn’t binary search always win?
n < 16 and switch to binary above that — exactly this
kind of constant-factor tuning.Note on the n=1000 vs n=10000 anomaly: the timings
are similar because our repeated_lin always searches for
targets 1..len(arr), but i only ranges
1..1000, so both n=1000 and n=10000 search for targets
1..1000 — both of which land in the front of a sorted
ascending array. The cost is approximately average position of
target × 1000 searches = 500 × 1000 = 500,000 comparisons in both
cases. A fairer test would search for random targets across the full
range.
triple_sum: func(arr, target) [
c: 0
n: len(arr)
i: 1
while i <= n - 2 [
j: i + 1
while j <= n - 1 [
k: j + 1
while k <= n [
if arr[i] + arr[j] + arr[k] == target then [c = c + 1]
k = k + 1
]
j = j + 1
]
i = i + 1
]
c
]Measured output:
50 -> 38.4 ms
100 -> 274.7 ms (7.15×)
200 -> 2,224.9 ms (8.10×)
(a) Growth class: O(n³). Three nested
loops, total work n(n-1)(n-2)/6 ≈ n³/6.
(b) Empirical: doubling n multiplies
the time by roughly 8× — exactly the cubic signature.
(c) Match: prediction matches. At
n = 400 we’d expect ~17.5 seconds. Extrapolating,
n = 800 ≈ 140 seconds (~2.5 minutes), n = 1600
≈ 18 minutes. This is why brute-force triple-sum is not a
viable approach for sizable n — you need a faster algorithm
(the standard trick: sort, then for each i, do a
two-pointer scan in O(n), for total O(n²)
instead of O(n³)).
make: func(n) [
xs: []
j: 1
while j <= n [
xs = xs + [j]
j = j + 1
]
xs
]Measured output:
1000 -> 5.5 ms
2000 -> 10.0 ms (1.83×)
4000 -> 33.1 ms (3.32×)
8000 -> 111.2 ms (3.36×)
(a) Prediction from code: xs + [j]
copies the entire array xs to build a new one. At iteration
k, this copy costs k. Total:
1 + 2 + ... + n = n(n+1)/2 ≈ n²/2. Class:
O(n²).
(b) Measurement: doubling ratios 1.83, 3.32, 3.36 —
approaching 4×. The first ratio is below 4× because at
n = 1000 the constant overhead still dominates; by
n = 8000 we see the true quadratic behavior.
(c) The accidental-quadratic bug. This pattern —
xs = xs + [j] inside a loop — is one of the most common
performance bugs in beginner code. The code looks linear (“loop
once, do constant work each step”) but the “constant work” is secretly
O(n).
Fix patterns:
{j | j <- 1..n}, though note set semantics, not
list).This bug is the first thing to check when a function that should scale linearly is actually scaling quadratically. Always inspect the inner-loop allocation.
This exercise is open-ended; here’s one plausible answer based on the §35.4 and §35.5 measurements:
| Algorithm | n | Predicted time | n for 1 sec | n for 1 hour |
|---|---|---|---|---|
linear_sum |
100,000 | ~5,200 ms | ~19,000 | ~70,000,000 |
bubble_sort |
10,000 | ~150 sec | ~3,200 | ~190,000 |
count_pairs (n²) |
10,000 | ~71 sec | ~3,700 | ~225,000 |
Method: For each algorithm, take a measured timing
at a known n, then scale by the complexity class.
For linear_sum: 52 ms at n=1000 → ~5200 ms at
n=100,000.
For bubble_sort (using §35.5 chapter data, 243 ms at
n=400): time at n=10,000 is
(10000/400)² × 243 ms = 625 × 243 = 151,875 ms ≈ 152 seconds.
For count_pairs from Ex 35.2 (457 ms at n=800):
(10000/800)² × 457 = 156.25 × 457 = 71,406 ms ≈ 71 sec.
(a) Where did predictions match? The linear case is usually within ±10% — Axioma’s inner loop is well-behaved and predictable. The quadratic cases can drift more because the actual workload (number of swaps in bubble sort, number of pairs found in count_pairs) depends on input distribution.
(b) Most common mismatch source:
n, the constant-factor
overhead is not yet swamped — extrapolating up is reliable.n, memory
becomes the bottleneck: arrays larger than CPU cache experience a
step-change in cost. A perfectly O(n) function can suddenly
look super-linear at n = 10⁶ because each access starts
missing the L2 cache.This is the dropout #3 of §35.8 (cache effects)
materializing in practice. The asymptotic class is still
O(n); the constant factor changes.
The lesson of §35.1 is that the question “how fast is this function?” is ill-posed. The right question is “how does it scale?” — because the answer to that is composable across machines, languages, and inputs.
When you tell a colleague “this function is O(n)”, they know exactly what to expect when their input grows. When you tell them “this function took 53 ms on 1000 items,” they have to do mental arithmetic to predict 5300 ms on 100,000 items — and that arithmetic only works if your function is linear. Big-O packages the scaling rule into the type signature.
The brute-force O(n²) is sometimes the right
algorithm — for n < 100 or so, the simplicity is worth
the cost. But CS2 will teach two improvements:
O(n). For each
arr[i], look up target - arr[i] in a hash set
of previously-seen values. One pass, constant-time lookup → linear
total.O(n log n). Sort
the array (n log n), then walk a pointer from each end inward
(linear).The first is faster asymptotically but uses extra memory. The second
is in-place but slightly slower. Both beat the brute force for any
n above ~50.
Production sort routines (Python’s Timsort, C++’s introsort) all use a hybrid strategy: switch to insertion sort below ~16 elements, switch to quick sort above. The thresholds were tuned empirically over years of benchmarking. The “right” answer for switchover depends on:
A general rule of thumb: measure on your target hardware with your target inputs. Don’t trust folk-wisdom constants from a different system.
Cubic algorithms get a bad rap, but for some problems they’re
inherent. Matrix multiplication, the standard 3-SUM problem, all-pairs
shortest paths — these have lower bounds at or near O(n³)
(more precisely: O(n^ω) where
2 ≤ ω < 3).
When a problem is inherently cubic, you can’t escape it — you can only:
n (sometimes possible).Knowing which problems are inherently hard is the realm of computational complexity theory — a notch beyond Big-O.
The hidden quadratic of xs = xs + [j] has analogs in
every language:
| Language | Quadratic anti-pattern | Linear fix |
|---|---|---|
| Python | s = ""; s += x for x in arr |
"".join(arr) |
| Java | String s = ""; s += x |
StringBuilder |
| JavaScript | s = s + arr[i] in loop |
arr.join("") |
| Go | s = s + str in loop |
strings.Builder |
| Axioma | xs = xs + [j] in loop |
comprehension when possible |
The deep lesson: immutable update has a cost. When you need to grow a structure incrementally, the language is either:
bytes.Buffer.Knowing your tool’s actual implementation is the difference between accidental quadratic and accidental linear.
The budget table is the bridge between Big-O theory and engineering practice. A senior engineer reading a design doc doesn’t ask “is this algorithm correct?” — they ask “will this algorithm fit the budget?”
The skill of writing this table in your head, before implementation, is what makes someone good at choosing algorithms. Big-O analysis is the language of that prediction.
All timings measured on the textbook-development machine via
axioma --no-kb. Your numbers will differ; the
ratios are what classify the algorithm.
A sort is stable if it preserves the relative order of equal-keyed elements.
by_first: func(p, q) [p[1] <= q[1]]
# Bubble sort by .first
bubble_pairs: func(a) [
s: a
m: len(s)
pass: 0
while pass < m - 1 [
k: 1
while k < m - pass [
if s[k][1] > s[k + 1][1] then [
t: s[k]
s[k] = s[k + 1]
s[k + 1] = t
]
k = k + 1
]
pass = pass + 1
]
s
]
# Similarly insertion_pairs, qsort_pairs.(a) Stability empirically:
>, not >=). Equal-value pairs
preserved.s[j] > key, stops at
equality.equal bucket is
produced in scan order, and concatenation preserves order). The
2-way partition version is not stable.(b) Theoretical:
The rule of thumb: a sort is stable iff it only ever swaps adjacent elements. Adjacent swaps preserve relative order between equal-keyed neighbors transitively. Cross-array swaps don’t.
# Build sorted, reverse-sorted, and random inputs of size 1000
# Time insertion on each.Measured (n=1000):
sorted (best): 3.5 ms
random: 374.8 ms (105×)
reverse (worst): 741.4 ms (209×)
(a) Ratio worst:best ≈ 200×. Two orders of magnitude between insertion’s best and worst case.
(b) When does insertion win? When the input is
nearly sorted. In production code, insertion sort is
the inner loop of std-lib sort routines (Timsort uses it for sorted
“runs”). The reason: log-files, time-series data, “yesterday’s data plus
a few new entries” — these are almost sorted, and insertion is
O(n) on them.
For randomly-distributed input, insertion is comparable to the other
O(n²) sorts. For adversarial (reverse-sorted)
input, it’s their equal in cost. Knowing the input distribution is what
unlocks insertion’s superpower.
With the middle-element pivot rule, sorted input is hostile: the middle pivot is the median of the array, so the first recursion is balanced — but each subsequent recursion picks the median of a sorted subarray, which is again the middle. So actually middle-element pivots work well on sorted input!
The truly hostile input depends on the pivot rule:
Demonstration with first-element pivot variant (modify
qsort so pivot = a[1]):
n=800 sorted: 18,000 ms (hostile — pivot is the min, every recursion)
n=800 random: 250 ms
n=800 reverse: 17,500 ms (also hostile — pivot is the max)
The 70× difference between random and sorted is the
O(n²) worst case manifesting. The lesson: always
use randomized or median-of-three pivots in production
code.
Measured:
merge:
n=100 → 2,243 ms
n=200 → 9,237 ms (4.12×)
n=400 → 37,096 ms (4.02×)
bubble:
n=100 → 17 ms
n=200 → 62 ms (3.65×)
n=400 → 244 ms (3.94×)
(a) Merge sort doubling ratio: ~4× — the signature
of O(n²) (or O(n² log n), where the
log n factor adds a small bias toward larger ratios as
n grows).
(b) Bubble sort doubling ratio: ~4× — also
O(n²). Both are quadratic.
(c) At n=400, bubble is 152× faster than merge. Bubble’s constant factor is small (in-place swaps); merge’s is huge (list construction via concatenation, function call overhead per recursion).
(d) Why is merge slower if both are quadratic?
Merge sort in Axioma is O(n² log n) due to the
accidental quadratic in result + [x]. Bubble sort is
O(n²). So at n = 400:
400² = 160,000 units of work.400² × log₂(400) ≈ 160,000 × 8.6 = 1.38M units
of work.That’s ~8.6× more work for merge. The empirical 152× difference includes the larger per-unit constant (function call overhead, sub-array allocation per recursion). Both factors compound.
The fix: mutable-buffer merge sort.
n.O(n log n).counting_sort: func(arr, k) [
counts: []
i: 0
while i <= k [
counts = counts + [0]
i = i + 1
]
j: 1
while j <= len(arr) [
v: arr[j]
counts[v + 1] = counts[v + 1] + 1
j = j + 1
]
out: []
v: 0
while v <= k [
c: counts[v + 1]
while c > 0 [
out = out + [v]
c = c - 1
]
v = v + 1
]
out
]
println(counting_sort([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 10))
# → [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]Measured (n=10000):
k=100: 679.7 ms
k=10000: 845.7 ms
(a) Complexity: O(n + k). Plus the
O(k) setup and O(k) output-loop overhead. In
Axioma the out + [v] appends add a hidden
O(n²) term — but the tight inner loop keeps it small enough
that the asymptotic story holds.
(b) k=100 is faster than k=10000 by 166 ms — the
extra buckets to scan in the output loop. At n=10000, the n-pass
dominates; the k=100 vs k=10000 difference reflects the extra
k - 100 scanned zero-counts.
(c) Use counting sort when:
k is bounded and not too large relative to
n. The rule of thumb: k ≤ n for counting sort
to beat O(n log n).k >> n, the bucket setup dominates and
you’re better off with quicksort.Real-world use: sorting ages (range 0-120), sorting digits in radix sort (range 0-9), sorting bytes (range 0-255). Whenever the keyspace is small and known, counting sort crushes the comparison-based competition.
smart_sort: func(a) [
n: len(a)
small: n < 16
if small then [
insertion(a)
] else [
qsort(a)
]
](a) Empirical comparison: at sizes 100, 200, 400 on
random input, smart_sort and pure qsort
perform similarly because the top-level call always falls into the
qsort path. The hybrid’s advantage shows in the
recursive calls: when qsort recurses on a
partition of size < 16, it should switch to insertion.
The fully-correct hybrid recurses with smart_sort (not
qsort):
smart_qsort: func(a) [
n: len(a)
small: n < 16
if small then [
insertion(a)
] else [
pivot: a[n div 2 + 1]
parts: partition_qs(a, pivot)
s_less: smart_qsort(parts[1])
s_greater: smart_qsort(parts[3])
s_less + parts[2] + s_greater
]
]Typical improvement: 10-30% over pure quicksort on medium-size random inputs.
(b) Hybrid wins by: ~10-30% on typical inputs.
(c) Why does the switchover help even though both algorithms
are O(n log n) average?
Because constant factors are real. Insertion sort on a 10-element array does 50-ish operations. Quicksort on a 10-element array does:
For tiny arrays, the recursion overhead swamps the algorithmic benefit. Insertion’s tight inner loop wins. Production sort routines (introsort, Timsort, pdqsort) all use this kind of size-based switchover, typically at thresholds in the 12-32 range.
Stability is the property that multi-key sorting works correctly. To sort users first by department, then by name within department: sort by name first (any sort), then sort by department (must be stable). If the second sort isn’t stable, names get scrambled within each department.
Python’s sort() is guaranteed stable. C’s
qsort() is not. Knowing your tool’s guarantees prevents
subtle bugs when multi-key sorting.
Insertion is the simplest “adaptive” sort — one that runs faster on partially-sorted input. The adaptive family includes:
O(n + d) where
d is the number of inversions.O(n) on fully-sorted input.O(n) on
sorted input.For workloads where data arrives mostly-sorted (logs, time-series, append-only event streams), adaptive sorts can be 100× faster than non-adaptive ones.
Takeaway 1: Big-O analysis depends on the cost
model. The recurrence T(n) = 2T(n/2) + O(n) is
correct in a cost model where array concatenation is O(1).
Axioma’s cost model isn’t that. When you read complexity claims, ask: in
what cost model? RAM model? Bit operations? Memory accesses? The answer
matters.
Takeaway 2: Measure, don’t assume. A senior engineer who’d been taught “merge sort is O(n log n)” their whole career would write the chapter’s merge sort, hit production slowness, blame the language, and miss the lesson. The analysis was right; the cost model was wrong. The fix is to change the implementation (mutable buffer) so it matches the assumed cost model.
This is exactly the failure mode that empirical complexity verification (Ch.35’s recipe) protects you from. Always measure.
The comparison lower bound — Ω(n log n) for any
comparison-based sort — is one of the most famous results in CS. It’s
proven via the decision-tree argument: any comparison sort can be drawn
as a binary tree where each node is a comparison; a tree with
n! leaves (the number of permutations of an n-element
array) must have depth ≥ log₂(n!) = Θ(n log n).
The escape: don’t compare. Radix sort, counting sort, bucket sort all examine value structure — digits, range buckets — rather than ordering. They beat the lower bound by sidestepping its premise.
This is a general design lesson: lower bounds apply to classes of algorithms. If you can change classes, you can escape. Whenever you see an algorithm hitting a lower bound, ask if there’s value structure you can exploit to switch classes.
All exercises use the integer-indexed adjacency-matrix scaffolding from §37.2.
bfs_paths: func(g, start) [
n: len(g)
visited: make_bools(n)
visited[start] = true
parent: []
i: 1
while i <= n [
parent = parent + [-1] # -1 = no parent yet
i = i + 1
]
queue: []
queue = queue + [start]
head: 1
while head <= len(queue) [
node: queue[head]
head = head + 1
j: 1
while j <= n [
if g[node][j] != 0 then [
if not visited[j] then [
visited[j] = true
parent[j] = node
queue = queue + [j]
]
]
j = j + 1
]
]
parent
]
reconstruct: func(parent, target) [
path: [target]
cur: target
while parent[cur] != -1 [
cur = parent[cur]
path = [cur] + path
]
path
]On the sample graph from §37.2:
bfs_paths(g, 1) → parents [-1, 1, 1, 2, 4]
reconstruct(p, 5) → [1, 2, 4, 5] # A → B → D → E
Lesson: BFS gives shortest paths in an
unweighted graph “for free” — every node’s parent is
set when first discovered, which is at minimum BFS distance.
has_cycle_helper: func(g, node, parent, visited) [
visited[node] = true
n: len(g)
found: false
j: 1
while j <= n [
if g[node][j] != 0 then [
if not visited[j] then [
sub: has_cycle_helper(g, j, node, visited)
if sub then [found = true]
] else [
# j is visited; cycle if j != parent
if j != parent then [found = true]
]
]
j = j + 1
]
found
]
has_cycle: func(g) [
n: len(g)
visited: make_bools(n)
found: false
i: 1
while i <= n [
if not visited[i] then [
sub: has_cycle_helper(g, i, -1, visited)
if sub then [found = true]
]
i = i + 1
]
found
]The “parent” trick: an edge to the parent is just the edge we came in on, not a cycle. An edge to any other visited node is a back-edge → cycle.
Tree (no cycle): has_cycle returns
false. With an extra A-D edge added:
has_cycle returns true.
count_components: func(g) [
n: len(g)
visited: make_bools(n)
components: 0
i: 1
while i <= n [
if not visited[i] then [
components = components + 1
# BFS from i to mark its component
queue: []
queue = queue + [i]
visited[i] = true
head: 1
while head <= len(queue) [
node: queue[head]
head = head + 1
j: 1
while j <= n [
if g[node][j] != 0 then [
if not visited[j] then [
visited[j] = true
queue = queue + [j]
]
]
j = j + 1
]
]
]
i = i + 1
]
components
]On the sample graph: 1 component. On a graph with {A-B, C-D, E-F}: 3 components.
Illustrative timing ratios on dense graphs (not measurements from this revision):
n=50 → 85.3 ms
n=100 → 333.4 ms (3.91×)
n=200 → 1,289.6 ms (3.87×)
Ratios near 4× are consistent with quadratic growth, not a proof. The chapter’s timer surrounds only Dijkstra; graph construction happens before the callback. Run the experiment locally instead of treating these illustrative numbers as a performance promise.
Use a directed graph with edges A → B (2), A → C (5), and C → B (-10). There is no cycle. Dijkstra settles B at distance 2 before visiting C, but the path A → C → B costs -5. Settling a node too early invalidates the nonnegative-weight proof. The implementation in §37.5 rejects this input before attempting the algorithm.
negative_graph: make_matrix(3)
negative_graph[1][2] = 2
negative_graph[1][3] = 5
negative_graph[3][2] = -10
println(has_negative_edge(negative_graph)) # trueFor a graph with negative weights, Bellman–Ford relaxes all edges for
up to n - 1 rounds and uses a further pass to detect
reachable negative cycles. An undirected negative edge permits a
negative round trip, so shortest walks through its connected
component have no finite minimum.
Kruskal on the sample graph from §37.5 (weights: A-B=2, A-C=5, B-C=1, B-D=3, C-E=4, D-E=1):
Sort all edges by weight:
Add edges in order, skipping any that create a cycle:
Total weight: 1 + 1 + 2 + 3 = 7.
Prim from A (from §37.6):
(a) Same tree? Same weight, same edge set in this case. In general, Prim and Kruskal can produce different MSTs of equal weight when there are ties — both are valid minimum spanning trees.
(b) Why might they differ? When two non-tree edges have the same weight, Prim and Kruskal might pick different ones (Prim picks based on current frontier, Kruskal by sorted order). Both produce a MST; neither is “more correct”.
(c) Which is simpler? Kruskal is conceptually simpler — sort + add-if-no-cycle. But it requires a union-find data structure for the cycle check, which is its own subtopic. Prim’s algorithm doesn’t need union-find but does need to track the “frontier” (neighbors of in-tree nodes).
For adjacency-matrix dense graphs, Prim is shorter. For sparse graphs given as an edge list, Kruskal plus a fast union-find wins.
The parent-array technique is universal across graph algorithms:
Whenever you write a graph algorithm that builds a tree of decisions, the parent array captures the tree shape for later use.
Dijkstra’s proof requires nonnegative weights. The chapter’s
executable implementation checks this at its boundary and returns an
error when the condition fails. The preceding negative-edge example
tests the predicate separately; calling
dijkstra(negative_graph, 1) is an intentional failure.
Preconditions should state the accepted data, and practical checks should catch violations where possible. This does not remove the need to explain representation conventions such as zero meaning “no edge” in this chapter.
The MST of a graph isn’t necessarily unique — graphs with weight ties can have multiple MSTs of identical total weight. This matters when:
In production, you’d often add a secondary cost (cable length, latency) as a tiebreaker — making the MST unique by construction.
State-machine solutions build the state history as an Array. A
singleton such as [start,] is an explicit one-element
Array, not a String.
code_lock: {
"idle.1": "entering_1",
"entering_1.2": "entering_2",
"entering_2.3": "entering_3",
"entering_3.4": "unlocked",
"idle.x": "wrong_1",
"wrong_1.x": "wrong_2",
"wrong_2.x": "locked_out"
}
do_step: func(m, st, inp) [
k: st + "." + inp
next_state: m[k]
if next_state == none then [st] else [next_state]
]Any input not in the table → stay in current state. Three “x” (any wrong digit) inputs from idle → locked_out. Four correct sequential digits (“1”, “2”, “3”, “4”) from idle → unlocked.
Lesson: the “default = stay” rule handles the inputs not enumerated. Without it, you’d need to enumerate every state × every input.
check_complete: func(machine, states, inputs) [
missing: []
i: 1
while i <= len(states) [
j: 1
while j <= len(inputs) [
key: states[i] + "." + inputs[j]
if machine[key] == none then [
missing = missing + [key]
]
j = j + 1
]
i = i + 1
]
missing
]Returns the list of missing (state, input) keys. An empty list means complete coverage.
Lesson: for any state machine whose transition table must cover all (state, input) pairs (TCP, parsers, protocol state machines), automate this check. The number of bugs caught by a coverage check on a complex protocol state machine in production is staggering.
idle_state: func(input) [
if input == "insert_coin" then ["selecting", "selecting_state"]
else ["idle", "idle_state"]
]
selecting_state: func(input) [
if input == "select_product" then ["paying", "paying_state"]
else if input == "cancel" then ["idle", "idle_state"]
else ["selecting", "selecting_state"]
]
paying_state: func(input) [
if input == "complete" then ["dispensing", "dispensing_state"]
else if input == "cancel" then ["idle", "idle_state"]
else ["paying", "paying_state"]
]
dispensing_state: func(input) [
if input == "complete" then ["idle", "idle_state"]
else ["dispensing", "dispensing_state"]
]The per-state function style. Each handler returns [next_state_name, next_state_function_name]. The main loop looks up the new function by name (§38.4 pattern).
When per-state functions help: when handlers need to do real work (charge a credit card, dispense a product, play a sound). The dict-table doesn’t have a place for that work; the per-state function does.
light: {
"green.tick": "green", # will increment, but logically stays green
"yellow.tick": "yellow",
"red.tick": "red"
}
advance: func(state, count) [
if state == "green" then [
if count >= 30 then ["yellow", 0] else [state, count + 1]
] else if state == "yellow" then [
if count >= 5 then ["red", 0] else [state, count + 1]
] else [
if count >= 25 then ["green", 0] else [state, count + 1]
]
]State transitions are guarded by count rather than input alone. Each tick: increment count if still in the state; reset count + transition if threshold met.
Lesson: timer-driven state machines are everywhere (network keepalives, UI animations, the Cascade reactor’s debounce window). The pure FSM model needs augmenting with a clock or counter.
mod3: func(s, _) [(s + 1) % 3]
mod5: func(s, _) [(s + 1) % 5]
run_paired: func(presses) [
s3: 0
s5: 0
i: 1
while i <= presses [
s3 = mod3(s3, "press")
s5 = mod5(s5, "press")
if s3 == 0 then [
if s5 == 0 then [println("press", i, ": both 0!")]
]
i = i + 1
]
]
run_paired(15)
# → press 15: both 0!Lesson: two independent state machines fed the same input. They reset to 0 together at the LCM(3, 5) = 15. This is composition by parallel composition.
In production: a TCP connection’s state machine is composed with the application’s state machine; both react to packet-arrival events independently.
Sample answer using TCP (RFC 793):
(a) How many states? TCP has 11 states: CLOSED, LISTEN, SYN_SENT, SYN_RECEIVED, ESTABLISHED, FIN_WAIT_1, FIN_WAIT_2, CLOSE_WAIT, CLOSING, LAST_ACK, TIME_WAIT.
(b) Time- vs input-triggered:
(c) Impossible inputs: RFC 793 specifies: “in CLOSED state, drop the segment and reply with RST.” In every other state, unexpected segments either get a RST reply or are silently dropped. The “be liberal in what you accept” Postel principle in action.
Lesson: real production state machines are defensive — they assume any input can arrive in any state, and each combination is documented. Sloppy code crashes on unexpected input; production code degrades gracefully.
In production code, having every transition table entry explicit
makes the table huge. The “missing entries default to stay in current
state” convention shrinks the table to only meaningful
transitions. The trade-off: you can no longer use the empty entry to
detect “should not happen” — you need a separate disallowed
table.
The timer-driven traffic light is the simplest case of a hierarchical state machine: the “outer” state is green/yellow/red, the “inner” state is the tick count. Harel statecharts make hierarchy a first-class concept; plain FSMs need to fold the hierarchy into a single state space.
If your code has bugs that only manifest “in certain orders” — race conditions, sequence dependencies — the fix is often to make the implicit state explicit by writing the state machine. The act of enumerating states forces you to confront which inputs are possible in which states, and the bugs become visible in the transition table.
The state machine doc is the debugging tool.
Reactor and event-loop solutions use the construction-time dictionary
of handlers from §39.7. Mutable state captured by callbacks is updated
with rebind; indexed dictionary updates do not replace the
captured binding.
queue: []
# (no initial events)
head: 1
if len(queue) == 0 then [
println("nothing to do; exiting")
] else [
while head <= len(queue) [
ev: queue[head]
head = head + 1
println("dispatch:", ev)
]
]Recommendation: zero iterations, no error. The empty-queue case isn’t a bug — it’s a degenerate but valid input (think: starting an event loop before any events have been posted is the standard pattern; the initial state of a GUI is always “queue empty, waiting for first user input”).
A production loop would, instead of exiting, block on an
external source (kernel epoll, SDL_PollEvent, etc.). For a
synchronous in-process loop, “drain and exit” is the right behavior —
exiting on len(queue) == 0 means tools that build a queue
then run it once work cleanly with zero events as a no-op.
Lesson: no input is a valid input. Handle it explicitly rather than letting it manifest as a crash or an infinite block.
make_reactor: func(handlers, cap) [
queue: []
dropped: 0
post: func(typ, payload) [
full: len(queue) >= cap
if full then [
rebind dropped = dropped + 1
] else [
rebind queue = queue + [(typ, payload)]
]
]
run: func() [
head: 1
while head <= len(queue) [
ev: queue[head]
head = head + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
head - 1
]
{
"post": post,
"run": run,
"dropped": func() [dropped]
}
]
r: make_reactor({
"tick": func(p) [println("tick ", p)]
}, 10)
i: 1
while i <= 100 [
r["post"]("tick", i)
i = i + 1
]
n: r["run"]()
println("processed:", n)
println("dropped:", r["dropped"]())Output: 10 ticks printed, “processed: 10”, “dropped: 90”. The cap protects the loop from being overwhelmed during a burst — the first 10 events get processed, the remaining 90 are dropped (counted, not silently lost).
Lesson: bounded queues are the difference
between an event loop that survives load spikes and one that
OOM-crashes. The dropped counter is your
backpressure metric — if it’s non-zero, something is
wrong upstream (the producer is sending too fast, or your handlers are
too slow).
In production: dropped events should be logged with event type, so you can answer “which event type is overwhelming us?” Cascade emits this to telemetry on every drop.
make_reactor: func(handlers) [
queue: []
post: func(typ, payload) [
rebind queue = queue + [(typ, payload)]
]
depth: func() [len(queue)]
run: func() [
head: 1
while head <= len(queue) [
ev: queue[head]
head = head + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
]
{ "post": post, "depth": depth, "run": run }
]
r: make_reactor({})
h_tick: func(p) [
println("[t=", p, "] depth=", r["depth"]())
small: p < 3
if small then [
r["post"]("tick", p + 1)
r["post"]("tick", p + 1) # 2x to demonstrate growth
]
]
r = make_reactor({ "tick": h_tick })
r["post"]("tick", 0)
r["run"]()
println("done, depth=", r["depth"]())Sample output:
[t= 0 ] depth= 1
[t= 1 ] depth= 3
[t= 1 ] depth= 5
[t= 2 ] depth= 7
...
[t= 3 ] depth= 15
done, depth= 15
Note depth returns len(queue) including
already- processed events (the head-pointer doesn’t shrink the array). A
more accurate “depth remaining” would be
len(queue) - head + 1 — but head isn’t exposed
from the closure. The exercise is open-ended; either answer is
acceptable as long as the rise+fall pattern is demonstrated.
Lesson: introspection is debugging insurance. The
two minutes to add depth() saves hours when you’re trying
to diagnose a misbehaving reactor in production. Build the dashboard
before you need it.
make_priority_reactor: func(handlers) [
high: []
normal: []
stop_flag: false
post: func(typ, payload, prio) [
if prio == "high" then [
rebind high = high + [(typ, payload)]
] else [
rebind normal = normal + [(typ, payload)]
]
]
halt: func() [rebind stop_flag = true]
run: func() [
head_h: 1
head_n: 1
keep: true
while keep [
have_h: head_h <= len(high)
have_n: head_n <= len(normal)
halted: stop_flag
if halted then [keep = false]
else if have_h then [
ev: high[head_h]
head_h = head_h + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
else if have_n then [
ev: normal[head_n]
head_n = head_n + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
else [keep = false]
]
]
{ "post": post, "halt": halt, "run": run }
]
r: make_priority_reactor({
"work": func(p) [println("work ", p)],
"shutdown": func(p) [println("shutdown")]
})
r["post"]("work", 1, "normal")
r["post"]("work", 2, "normal")
r["post"]("shutdown", 0, "high") # this jumps ahead
r["post"]("work", 3, "normal")
r["run"]()Output:
shutdown
work 1
work 2
work 3
Note: shutdown ran first, even though it was posted third in real order. The high queue drains before normal in this design.
Lesson: strict priority (high always wins) is the right default for “shutdown” and “watchdog” events. Weighted priority (drain N high before 1 normal) is better when both queues need progress under sustained load. Get the simple version working first, then complicate.
In Cascade: the reactor uses three priorities — “shutdown” (instant), “user-query” (high), and “background-ingest” (normal). The shutdown class is strict, the other two are interleaved 3:1.
make_reactor_idle: func(handlers, on_idle) [
queue: []
stop_flag: false
idle_ticks: 0
max_idle: 5 # demo: halt after 5 idle ticks
post: func(typ, payload) [
rebind queue = queue + [(typ, payload)]
]
halt: func() [rebind stop_flag = true]
run: func() [
head: 1
keep: true
while keep [
have: head <= len(queue)
halted: stop_flag
if halted then [keep = false]
else if have then [
ev: queue[head]
head = head + 1
h: handlers[ev[1]]
if h != none then [h(ev[2])]
]
else [
rebind idle_ticks = idle_ticks + 1
on_idle()
if idle_ticks >= max_idle then [keep = false]
]
]
]
{ "post": post, "halt": halt, "run": run }
]
r: make_reactor_idle(
{ "tick": func(p) [println("tick ", p)] },
func() [println("[idle]")]
)
r["post"]("tick", 1)
r["post"]("tick", 2)
r["run"]()Output:
tick 1
tick 2
[idle]
[idle]
[idle]
[idle]
[idle]
After the two posted ticks process, the loop falls into the idle
branch each iteration. In a real reactor the idle handler would
block on a kernel syscall (e.g., epoll_wait with
no timeout), waking when external events arrive — no busy-loop. Here we
add an idle_ticks counter and halt at 5 to keep the demo
terminating.
Lesson: idle is a legitimate phase,
not a bug. GUI applications spend ~99% of their wall-clock time in
epoll_wait waiting for the next event. The idle handler is
where you do bookkeeping that shouldn’t compete with event processing:
GC sweeps, periodic logging, watchdog heartbeats.
Open-ended. A representative sample answer (the Linux kernel’s softirq subsystem):
(a) Queue ordering. Per-CPU softirq vectors, each a
FIFO list of pending tasklet_struct. Within a vector, FIFO.
Across vectors, priority order: HI_SOFTIRQ > TIMER_SOFTIRQ >
NET_TX > NET_RX > BLOCK > IRQ_POLL > TASKLET > SCHED >
HRTIMER > RCU. Effectively a priority-ordered
multi-queue.
(b) Shutdown. Shutdown isn’t an event in the softirq
model — the kernel doesn’t shut down through the event loop. The closest
equivalent is local_bh_ disable() which suspends softirq
processing on the calling CPU; __do_softirq() checks this
flag and short-circuits.
(c) Slow handler. Softirqs have a budget:
MAX_SOFTIRQ_TIME (2 jiffies = ~2ms) or
MAX_SOFTIRQ_RESTART (10 iterations). If a softirq handler
runs too long, the remainder is deferred to
ksoftirqd — a per-CPU kernel thread. This is the “slow
path” — it doesn’t drop events, but it does remove them from the
critical interrupt context where hard real-time guarantees matter.
Lesson: real event loops have learned from decades of production incidents. The Linux softirq budget answers “what if a handler runs too long” with defer to a worker thread — exactly the pattern §39.9 recommended.
The bounded queue dropped events when full. The alternative is to
block the producer (the post call doesn’t
return until queue has space). Blocking is what
bounded channel does in Go, mpsc::sync_channel
in Rust, and BlockingQueue in Java.
The choice between drop and block defines your back-pressure semantics. State it explicitly.
from §39.6?
Two reasons. (a) Granularity. Two FIFO queues give you two discrete priorities (high/normal). A priority-queue gives you arbitrary numeric priorities. For most systems, two or three discrete classes is plenty; arbitrary numeric priorities encourage priority inflation (everyone’s a 9-of-10).
(b) Cost. Two FIFO queues with head pointers are O(1) per operation. A priority queue is O(log n). For high-throughput systems, the constant matters.
The idle handler in this exercise is active (busy-looping
with a counter to halt). A blocked loop calls a syscall like
epoll_wait() or select() and the kernel
suspends the thread until data arrives. Blocking is
essential for production: without it, the event loop pegs a CPU
at 100% even when there’s nothing to do.
Axioma doesn’t have a built-in syscall for “sleep-until-event” because there isn’t one universal across event-source types (timer? file descriptor? signal? user-defined?). The Cascade reactor (Ch.40) uses SQLite’s WAL flush as its blocking primitive — ingenious because it doubles as durability.
Every event loop you’ll encounter has, somewhere:
If you can name those four things for a system, you understand its event loop.
Durable-reactor solutions. All extend the §40.3
make_durable_reactor skeleton.
make_durable_reactor: func(handlers) [
queue: []
wal: []
next_seq: 1
post: func(typ, payload) [
seq: next_seq
rebind next_seq = next_seq + 1
rebind wal = wal + [(seq, "posted", typ, payload)]
rebind queue = queue + [(seq, typ, payload)]
seq
]
mark_done: func(seq) [
rebind wal = wal + [(seq, "processed", "", "")]
]
run_n: func(limit) [
head: 1
processed: 0
while head <= len(queue) [
cont: processed < limit
if not cont then [head = len(queue) + 1]
else [
ev: queue[head]
head = head + 1
seq: ev[1]
h: handlers[ev[2]]
if h != none then [h(ev[3])]
mark_done(seq)
processed = processed + 1
]
]
processed
]
{
"post": post,
"run_n": run_n,
"wal": func() [wal]
}
]
recover_from_wal: func(handlers, wal) [
r: make_durable_reactor(handlers)
j: 1
while j <= len(wal) [
e: wal[j]
if e[2] == "posted" then [
seq: e[1]
found: false
k: 1
while k <= len(wal) [
f: wal[k]
if f[2] == "processed" then [
if f[1] == seq then [found = true]
]
k = k + 1
]
if not found then [
r["post"](e[3], e[4])
]
]
j = j + 1
]
r
]
# Phase 1: post 3
r1: make_durable_reactor({
"work": func(p) [println("[pre] processing ", p)]
})
r1["post"]("work", "a")
r1["post"]("work", "b")
r1["post"]("work", "c")
# Phase 2: process 2, "crash"
r1["run_n"](2)
saved_wal: r1["wal"]()
# Phase 3: recover, run rest
r2: recover_from_wal({
"work": func(p) [println("[post] RECOVERED + ", p)]
}, saved_wal)
r2["run_n"](100)Output:
[pre] processing a
[pre] processing b
[post] RECOVERED + c
Only event c re-runs after recovery — events
a and b had matching “processed” entries in
the WAL.
Lesson: run_n(limit) lets us simulate
the “crash” cleanly. In a real system, the crash happens inside
the handler — making the mark_done(seq) placement after the
handler critical (§40.3). If mark_done ran first, recovery
would think a half-finished event was complete.
compact: func(wal) [
# Find seqs with processed records
done: []
i: 1
while i <= len(wal) [
e: wal[i]
if e[2] == "processed" then [
done = done + [e[1]]
]
i = i + 1
]
# Drop all entries whose seq is in done
result: []
j: 1
while j <= len(wal) [
e: wal[j]
seq: e[1]
drop: false
k: 1
while k <= len(done) [
if done[k] == seq then [drop = true]
k = k + 1
]
if not drop then [result = result + [e]]
j = j + 1
]
result
]Verification:
# Mock WAL: 5 posted + 3 processed → expect 2 pending after compact
wal: []
wal = wal + [(1, "posted", "work", "a")]
... (5 posts, 3 processeds)
before: 8 entries
after: 2 entries
(4, "posted", "work", "d")
(5, "posted", "work", "e")
The compaction removes 3 posted/processed pairs (6 entries), leaving 2 pending posts. The result is identical-for-recovery: replaying the compacted WAL yields the same pending set.
Lesson: compaction is safe because
the “processed” record means “this work is done; the result is durably
recorded elsewhere.” Once that’s true, the WAL entries can go. But you
must run compaction only during a quiescent moment — if
a new “posted” arrives mid-compaction, the resequencing or filter logic
can corrupt. Cascade runs compaction inside a
BEGIN EXCLUSIVE transaction to be safe.
make_idempotent_counter: func() [
value: 0
processed_seqs: []
increment: func(seq, amount) [
already: false
i: 1
while i <= len(processed_seqs) [
if processed_seqs[i] == seq then [already = true]
i = i + 1
]
if already then [
println("[skip] seq=", seq, " (already applied)")
] else [
rebind value = value + amount
rebind processed_seqs = processed_seqs + [seq]
]
]
get: func() [value]
{ "increment": increment, "get": get }
]Stress test (3× replay):
1st run: value = 15 (5+3+7)
1st replay: value = 15 (all three skipped)
2nd replay: value = 15 (all three skipped)
3rd replay: value = 15 (all three skipped)
The counter is idempotent: replaying events is safe.
Lesson: the linear scan of
processed_seqs is O(n) per increment — fine for
tens-of-thousands but not millions. Production code uses a hash set
(O(1) amortized). The Cascade KB does it with a SQLite
INSERT OR IGNORE against a (seq) primary key —
leveraging the database’s existing uniqueness index for free.
The processed_seqs set itself must be
durable to survive crashes. In Cascade it lives in
SQLite; here we punt on that detail.
make_failing_reactor: func(handlers) [
queue: []
wal: []
next_seq: 1
post: func(typ, payload) [
seq: next_seq
rebind next_seq = next_seq + 1
rebind wal = wal + [(seq, "posted", typ, payload)]
rebind queue = queue + [(seq, typ, payload)]
]
mark_done: func(seq) [
rebind wal = wal + [(seq, "processed", "", "")]
]
mark_failed: func(seq, err) [
rebind wal = wal + [(seq, "failed", err, "")]
]
run: func() [
head: 1
while head <= len(queue) [
ev: queue[head]
head = head + 1
seq: ev[1]
h: handlers[ev[2]]
if h != none then [
result: h(ev[3])
# Handler returns ["ok"] or ["err", msg]
if result[1] == "ok" then [
mark_done(seq)
] else [
mark_failed(seq, result[2])
]
]
]
]
recover_failed: func() [
# Re-queue any seq with "failed" but no later
# "processed" (matches our 40.1 recovery pattern,
# extended to treat failed as "still pending")
result: []
j: 1
while j <= len(wal) [
e: wal[j]
if e[2] == "failed" then [
seq: e[1]
done: false
k: j + 1
while k <= len(wal) [
f: wal[k]
if f[1] == seq then [
if f[2] == "processed" then [done = true]
]
k = k + 1
]
if not done then [
# Find original posted to re-queue
m: 1
while m <= len(wal) [
p: wal[m]
if p[1] == seq then [
if p[2] == "posted" then [
result = result + [p]
]
]
m = m + 1
]
]
]
j = j + 1
]
result
]
{
"post": post,
"run": run,
"wal": func() [wal],
"failed": recover_failed
}
]Lesson: distinguishing transient
failures (retry) from permanent failures (dead-letter
queue, manual intervention) is the operational complication. A simple
“retry-on-failed” works until you encounter a deterministic failure that
retries forever. The fix: a retry counter. After N retries, mark the
event as dead_letter and emit an alert.
In Cascade, the truth-update path retries 3× with exponential
backoff; after that the failed event goes to a dead_letter
table that triggers a human-readable telemetry alert.
checkpoint: func(r) [
wal: r["wal"]()
# Build the snapshot: list of pending (seq, typ, payload)
pending: []
j: 1
while j <= len(wal) [
e: wal[j]
if e[2] == "posted" then [
seq: e[1]
done: false
k: 1
while k <= len(wal) [
f: wal[k]
if f[2] == "processed" then [
if f[1] == seq then [done = true]
]
k = k + 1
]
if not done then [
pending = pending + [(seq, e[3], e[4])]
]
]
j = j + 1
]
# Return snapshot tuple
("checkpoint", pending, r["next_seq"]())
]
# Recovery from snapshot is just rebuilding from
# pending list (no WAL replay needed).Verification protocol: 1. Reactor R1: post 100
events, process 80. 2. cp = checkpoint(R1) — snapshot has
20 pending. 3. Reactor R2 from full WAL: should also yield 20 pending.
4. Reactor R3 from snapshot only: same 20 pending.
Both R2 and R3 must produce identical pending lists.
Lesson: the snapshot is smaller than the WAL (O(pending) vs O(total history)). Recovery from snapshot is O(pending); recovery from WAL is O(history). Hence checkpointing’s value: bounding recovery time.
The “compact” of §40.2 is the continuous version; checkpointing is the discrete version that emits a recovery artifact. Pick based on recovery RTO requirements: full WAL = bigger but self-contained; snapshot = smaller but requires snapshot file to be safe.
Open-ended. Sample answer (SQLite WAL mode):
(a) Format. SQLite’s WAL is a page-oriented
binary file (<dbname>-wal). Each entry is a
4096- byte page (or whatever the database’s page size is) plus a 24-byte
header (frame number, page number, salt, checksum). Designed for
sequential append + binary diff.
(b) Flush. SQLite flushes on COMMIT by
default (durability guarantee). With
PRAGMA synchronous=NORMAL, flushing happens less often (at
WAL checkpoint, not per commit) — trades durability for throughput. With
synchronous=OFF, no explicit flush — fastest, can lose
committed transactions on crash.
(c) Truncation. Via checkpoint:
PRAGMA wal_checkpoint(PASSIVE|FULL|RESTART|TRUNCATE).
PASSIVE: opportunistic, no blocking. TRUNCATE: full, empties the WAL
file. Automatic checkpoint happens when WAL exceeds
wal_autocheckpoint pages (1000 default).
Lesson: SQLite’s WAL is the industry-standard embedded WAL implementation — battle-tested across billions of devices (every iPhone, every Android, every macOS, every Mozilla product). Cascade uses it directly rather than reimplementing. Reuse mature WAL infrastructure unless you have a very specific reason not to.
You could keep “processed” entries forever (an audit trail of work done). The tradeoff:
In Cascade: WAL drops both pairs because the KB itself is
the audit trail (every truth update is durably recorded in
truth_history). The WAL only needs to track in-flight work,
not history.
The processed_seqs list grows forever. In
production:
The choice depends on your retry-timeout model: how long is a duplicate event still possible to arrive?
Two failure modes:
The retry-N pattern catches both: retry N times for transient, give up after N for poison. Real systems add circuit breakers (stop retrying a downstream service that’s been failing for 10 minutes) and exponential backoff (wait longer between retries to avoid hammering a recovering service).
Checkpointing is not free. The snapshot operation must:
That’s tens to hundreds of milliseconds. Run it too often and throughput suffers; run it too rarely and recovery RTO grows. The Cascade default is “on queue-drain or 5 minutes whichever comes first” — balances both.
There are deep design choices in any WAL that we glossed over:
If you find yourself caring about any of these, you’re beyond a chapter on WAL and into a chapter on distributed storage. Vol III Ch.51 has the Cascade-specific story.
Stack-programming solutions. All use Axioma’s built-in
stack() + the push/pop/peek/depth + Forth/Pop-11
operations.
parse_num: func(tok) [
# Numeric tokens: pass through
is_num: type(tok) == Integer or type(tok) == Float
if is_num then [tok]
else [
# String token: try interpreting as negative number
first: tok[1]
rest_len: len(tok) - 1
if first == "-" and rest_len >= 1 then [
# Parse the rest as integer; only digits supported here
n: 0
i: 2
valid: true
while i <= len(tok) [
c: tok[i]
if c == "0" then [n = n * 10]
else if c == "1" then [n = n * 10 + 1]
else if c == "2" then [n = n * 10 + 2]
else if c == "3" then [n = n * 10 + 3]
else if c == "4" then [n = n * 10 + 4]
else if c == "5" then [n = n * 10 + 5]
else if c == "6" then [n = n * 10 + 6]
else if c == "7" then [n = n * 10 + 7]
else if c == "8" then [n = n * 10 + 8]
else if c == "9" then [n = n * 10 + 9]
else [valid = false]
i = i + 1
]
if valid then [-n] else [tok]
] else [tok]
]
]
rpn_eval: func(tokens) [
s: stack()
i: 1
while i <= len(tokens) [
tok: parse_num(tokens[i])
is_op: type(tok) == String
if is_op then [
b: pop(s)
a: pop(s)
if tok == "+" then [push(s, a + b)]
if tok == "-" then [push(s, a - b)]
if tok == "*" then [push(s, a * b)]
if tok == "/" then [push(s, a / b)]
] else [
push(s, tok)
]
i = i + 1
]
pop(s)
]
# Test
println(rpn_eval([3, "-4", "+"])) # -1
println(rpn_eval([3, -4, "+"])) # -1 (already-int negatives also work)
println(rpn_eval([10, "-3", "*"])) # -30Lesson: integer literals in the token list are
trivial; string "-4" requires a tiny parser. In production
you’d use a regex / strconv.Atoi-style builtin. The
exercise demonstrates that tokenization is its own
problem: what looks like one token (-4) could be
either a unary-minus + literal or a single negative literal, and
disambiguation requires context (post-operator vs post-operand).
matches: func(open_c, close_c) [
if open_c == "(" then [close_c == ")"]
else if open_c == "[" then [close_c == "]"]
else if open_c == "{" then [close_c == "}"]
else [false]
]
balanced: func(text) [
s: stack()
i: 1
valid: true
while i <= len(text) [
c: text[i]
if c == "(" or c == "[" or c == "{" then [push(s, c)]
if c == ")" or c == "]" or c == "}" then [
if depth(s) == 0 then [valid = false]
else [
top: pop(s)
if not matches(top, c) then [valid = false]
]
]
i = i + 1
]
valid and depth(s) == 0
]Test outputs:
balanced("({[]})") → true
balanced("({[)]}") → false (mismatched)
balanced("(()") → false (unclosed)
balanced("())") → false (extra close)
balanced("") → true (empty is balanced)
balanced("abc(d)e") → true (non-brackets ignored)
Lesson: the stack holds what we’re expecting
next in implicit form — the matching close for each open. Two
cleaner alternatives: (a) store the expected close on the stack
so the pop-and-compare is direct, (b) use the helper
matches(open, close) as above. Pick by which reads better
in your domain.
A common extension: report where the mismatch happened. Add
the index i to each pushed element so errors can point at
“unclosed ( at column 12”.
precedence: func(op) [
if op == "+" or op == "-" then [1]
else if op == "*" or op == "/" then [2]
else [0]
]
shunting_yard: func(tokens) [
output: []
ops: stack()
i: 1
while i <= len(tokens) [
tok: tokens[i]
is_op: type(tok) == String
if is_op then [
# Drain operators of higher-or-equal precedence
keep: true
while keep [
if depth(ops) == 0 then [keep = false]
else [
top: peek(ops)
if precedence(top) >= precedence(tok) then [
output = output + [pop(ops)]
] else [keep = false]
]
]
push(ops, tok)
] else [
output = output + [tok]
]
i = i + 1
]
while depth(ops) >= 1 [
output = output + [pop(ops)]
]
output
]Test outputs:
shunting_yard([3, "+", 4, "*", 2]) → [3, 4, 2, "*", "+"]
shunting_yard([3, "*", 4, "+", 2]) → [3, 4, "*", 2, "+"]
shunting_yard([1, "+", 2, "+", 3]) → [1, 2, "+", 3, "+"]
The first one — 3 + 4 * 2 — converts to
3 4 2 * +, meaning “push 3, push 4, push 2, multiply 4×2=8,
add 3+8=11.” Correct precedence: * binds tighter, so it
fires first in RPN.
Lesson: Shunting-Yard is Dijkstra’s elegant algorithm for infix → RPN conversion. It’s a 30- line two-stack algorithm that handles arbitrary operator precedence. Many production parsers start with Shunting-Yard and embellish from there: parentheses, function calls, right-associative operators, prefix/postfix unary.
The full algorithm also handles ( and ) in
the infix — open paren goes on the operator stack unconditionally; close
paren pops until matching open. Adding that to this solution is a
one-line extension.
factorial_stack: func(n) [
s: stack()
push(s, n)
push(s, 1) # accumulator
keep: true
while keep [
acc: pop(s)
cur: pop(s)
done: cur <= 1
if done then [
push(s, acc)
keep = false
] else [
push(s, cur - 1)
push(s, acc * cur)
]
]
pop(s)
]
println(factorial_stack(5)) # 120
println(factorial_stack(7)) # 5040
println(factorial_stack(0)) # 1
println(factorial_stack(1)) # 1Trace for factorial_stack(3):
push 3, push 1 → Stack[1 | 3]
pop 1, pop 3 cur=3 acc=1
push 2, push 3 → Stack[3 | 2]
pop 3, pop 2 cur=2 acc=3
push 1, push 6 → Stack[6 | 1]
pop 6, pop 1 cur=1 acc=6 (done)
push 6 → Stack[6]
pop → 6
Each loop iteration consumes (cur, acc), produces (cur-1, acc*cur).
Loop exits when cur <= 1, leaving just the final
acc on the stack.
Lesson: this accumulator-style recursion can be expressed as a loop that updates its arguments. That is tail-recursion elimination. The explicit stack here makes the state visible for practice; factorial does not need a stack of pending calls.
erasenum edge
casesEmpirical findings from Axioma:
# erasenum(s, 0): no-op
s1: stack()
push(s1, 1); push(s1, 2); push(s1, 3)
erasenum(s1, 0)
# s1 unchanged: Stack[3 | 2 | 1]
# erasenum(s, k > depth): ERROR
s2: stack()
push(s2, 1); push(s2, 2); push(s2, 3)
erasenum(s2, 10)
# → ERROR: cannot erase more items than stack contains
# erasenum(s, -1): ERROR
s3: stack()
push(s3, 1); push(s3, 2); push(s3, 3)
erasenum(s3, -1)
# → ERROR: count must be non-negativeSummary:
| Input | Behavior |
|---|---|
n = 0 |
no-op (silent) |
0 < n ≤ depth(s) |
drops top n |
n > depth(s) |
error: “cannot erase more items than stack contains” |
n < 0 |
error: “count must be non-negative” |
Lesson: Axioma’s erasenum follows the
fail-fast philosophy: invalid arguments throw errors
rather than silently clamping or doing nothing. The contrasting design
(clamp k to depth, silently no-op on negatives) is also valid — Python’s
list.pop() raises IndexError on empty,
JavaScript’s arr.splice() silently clamps. Axioma chose the
strict interpretation. Knowing this prevents bugs where you intended
“drop up to N” but got an error instead.
For “drop up to N” semantics, wrap:
safe_erase: func(s, n) [
to_drop: depth(s)
if n < to_drop then [to_drop = n]
if to_drop > 0 then [erasenum(s, to_drop)]
]| Problem | Paradigm | Why |
|---|---|---|
| (a) Sum of squares of a list | Array/recursion | Linear scan over indexed data — no LIFO need. |
| (b) Validate nested HTML tags | Stack | Each open tag pushes; each close pops + checks. Classic balanced-delimiter. |
| (c) Find median of sorted list | Array | O(1) indexed access; stack adds no value. |
| (d) Traverse directory tree | Either | Recursion is natural; an explicit stack handles depth-overflow on deep trees. |
| (e) Undo/redo in text editor | Stack | LIFO of past actions; a second stack for redo. Pattern A from §41.6. |
| (f) Reverse a string | Either | Stack: push every char, pop into output. Array: build output
backward. Both O(n). The stack version is one-liner if you have
stack_to_array. |
Lesson: the right paradigm depends on the problem’s access pattern. LIFO problems get clearer with explicit stacks; random-access problems get clearer with arrays. Often the same algorithm can be expressed either way; prefer the one that matches the problem’s natural shape.
The infix question “is -4 a unary-minus + 4, or a single
negative literal” doesn’t have a universal answer — it depends on
context. After an operator or (, it’s a literal; after a
number or ), it’s subtraction.
Real expression parsers track an “expecting operand vs operator” state (one bit) and use it to disambiguate. This is the famous “two-state lexer” trick. Axioma’s expression parser uses something similar.
The same pattern generalizes to:
{ pushes “expecting
property-or-close”; each [ pushes “expecting
value-or-close”; the stack of expectations drives the parser’s
state.Generally: any nesting structure where mismatches must be detected is a stack problem.
Shunting-Yard is a great teaching algorithm but hand-coded production parsers usually use Pratt parsing (a.k.a. top-down operator precedence) instead. Pratt parsers are recursive descent + a precedence table; they produce ASTs directly instead of RPN; they handle prefix, postfix, mix-fix, and right-associative operators gracefully.
Axioma’s own parser is a Pratt parser. Read
parser/parser.go to see one in action.
The factorial exercise stores the next iteration’s state explicitly. For a tree traversal, an explicit stack can instead hold several pieces of pending work. Pushing and popping those frames replaces the call stack.
Defunctionalization is a related, more specific idea: replacing a finite family of function values with data describing their cases and an interpreter for those cases. It can be used to represent continuations, but a loop or an iterator protocol is not automatically an example of it.
Axioma’s erasenum is defensive: invalid
input errors immediately. The opposite philosophy is
forgiving: silently clamp or no-op.
Defensive APIs catch bugs early at the cost of verbosity (callers
must validate). Forgiving APIs are quieter but mask bugs
(erasenum(s, count) where count is
accidentally -1 silently does nothing — confusing).
For this API, a visible error helps diagnose an incorrect count. Other APIs deliberately clamp values; the appropriate choice depends on the contract, and callers need that contract to be explicit.
In practice, ~80% of code is array/recursion — that’s the universal hammer. ~15% is stack-shaped: parsers, undo buffers, tree iterators. ~5% is weirder: state machines (Ch.38), reactors (Ch.39-40), neuro-symbolic mixes (Ch.42).
Knowing all three lets you pick the right tool. Knowing only arrays leaves you re-deriving stack algorithms from scratch (the textbook “manual recursion-to-iteration conversion” tax).
#language axioma/rpn
rpush(10)
rpush(20)
expect("peek", rpeek(), 20)
expect("pop", rpop(), 20)
expect("pop next", rpop(), 10)
expect("infix still works", 1 + 2, 3)
empty: try(rpop())
expect("empty errors", error?(empty), true)Commentary. Five words, one session-wide stack. The
dialect is additive: 1 + 2 is still infix addition, not two
pushes and an + word. That is the difference between
axioma/rpn and a Forth image. try(rpop()) plus
error? is how you observe the empty-stack error without
aborting the file — the same try the rest of Axioma uses
for errors-as-values.
The return stack is not a stack() value you can pass
around. It is parked in the interpreter session. rpush on
axioma/all does the same work; the pragma documents the
idiom.
Neuro-symbolic pattern solutions. All use Axioma’s B4 truth values,
grounding tags, and defeasible-rule syntax (<~~).
# Phase 1: three LLM extractions agree
hypothesis ceo_of("OpenAI", "Sam Altman")
hypothesis ceo_of("OpenAI", "Sam Altman")
hypothesis ceo_of("OpenAI", "Sam Altman")
# Phase 2: four sources (one contradicts)
hypothesis ceo_of("OpenAI", "Sam Altman")
hypothesis ceo_of("OpenAI", "Greg Brockman")
# The system stores both; a rule sets Both:
set_truth("ceo_of", "OpenAI", "Sam Altman", "both")
set_truth("ceo_of", "OpenAI", "Greg Brockman", "both")
println(truth("ceo_of", "OpenAI", "Sam Altman"))
# → ⊤⊥ᵇ
println(truth("ceo_of", "OpenAI", "Greg Brockman"))
# → ⊤⊥ᵇBoth records exist; both are tagged Belnap Both;
downstream queries see “the question is contested” rather than picking a
winner.
Lesson: the symbolic layer’s job in multi-source aggregation is not to pick a winner but to surface the disagreement. The “Both” tag is the API by which the LLM ingest layer tells the reasoning layer “I had multiple inputs and they didn’t agree.”
Real aggregation in Cascade weights sources by historical accuracy,
recency, and topic match — adding considerable machinery on top of this
base mechanism. The base mechanism (B4 Both as the
contradiction signal) stays the same.
flies(X) <~~ bird(X)
nonfly(X) <= penguin(X)
hypothesis bird("tweety")
postulate penguin("tweety")
r: {X | X <- flies(X)}
println("flies (defeasibly derived):", r)
# → {"tweety"}
println("flies(tweety) strength:", grounding("flies", "tweety"))
# → conjecture
n: {X | X <- nonfly(X)}
println("nonfly (strictly derived):", n)
# → {"tweety"}
println("nonfly(tweety) strength:", grounding("nonfly", "tweety"))
# → theoremTweety is in both sets — defeasibly classified as flying (via bird) and strictly classified as non-flying (via penguin). The groundings differ: conjecture (weaker, defeasible) vs theorem (stronger, strict).
A downstream query “is Tweety flying?” must choose its minimum trust.
At minimum=conjecture both predicates are visible (and the system flags
the conflict). At minimum=theorem only nonfly is
visible.
The semantic point: a specifically-derived strict result (penguin→nonfly) defeats a generally-derived defeasible result (bird→flies). The grounding ladder makes this mechanically explicit.
Lesson: real-world reasoning is shot through with this pattern. “Birds fly (usually) but penguins don’t” is the textbook example, but the deeper pattern is: rules have scope, and more specific scopes override more general ones. The ladder + the strict/defeasible distinction together encode the override priority.
| Scenario | Failure mode |
|---|---|
| (a) LLM says “Paris is in Italy” | Hallucination — plausible-sounding but flat-out wrong. |
| (b) LLM says “I’m 100% sure Pluto is a planet” | Confidence-calibration mismatch — high stated confidence with wrong-for-the-current-definition answer. |
| (c) Same prompt: yesterday Y, today N | Prompt drift — non-reproducibility across calls. |
| (d) 2024 election quotes 2020 names | Training-data leak (or staleness) — memorized old article surfaces as current fact. |
| (e) Poisoned RSS feed gets ingested | Adversarial input — attacker crafts source to inject false claims. |
| (f) “Sam” today, “Samuel” tomorrow | Ontology drift — entity references diverge across time. |
Lesson: giving the failure modes names makes them debuggable. If your system is unreliable, the first triage step is: “which failure mode is biting us?” Hallucination needs RAG; calibration needs ensemble voting; prompt drift needs deterministic seeds; staleness needs temporal tags; adversarial input needs source reputation; ontology drift needs entity normalization.
Each mode has a different fix. Conflating them is how teams ship neuro-symbolic systems that work in demos and fail in production.
# Track each model's accuracy
postulate model_accuracy("gpt-4", 0.85)
postulate model_accuracy("claude-3", 0.92)
postulate model_accuracy("rumor-bot", 0.30)
# Demotion rule: if a model's accuracy is below 0.5,
# claims from it are demoted to standalone (the
# floor of the grounding ladder).
# Conceptual pseudocode (Cascade does this via the
# truth-aggregation pipeline, not as a single ax rule):
demote_low_trust: func(source_model) [
acc_pairs: {A | A <- model_accuracy(source_model, A)}
# Take the only value (assume one accuracy per model)
# Lower-bound check
acc: 0
if len(acc_pairs) >= 1 then [acc = acc_pairs[1]]
if acc < 0.5 then [
# Find all hypothesis-grade facts tagged from this
# source, demote them. This is a metaprogramming
# operation (see Ch.22). In Cascade, sources are
# tracked via the `said_by` relation; the demotion
# rule rewrites the source-tagged facts.
println("would demote claims from ", source_model)
]
]
demote_low_trust("rumor-bot") # would fire
demote_low_trust("claude-3") # no-opLesson: this is the source-reputation tracking pattern. The simple version: a scalar accuracy per source. The richer version: topic-conditional accuracy — gpt-4 is great on science but weak on celebrity gossip, so use different accuracy values per topic.
Cascade tracks accuracy per (source, topic) and demotes on a per-claim basis. The result: poisoned sources get downgraded exactly on the topics where they were poisoned, not blanket-banned.
verify_via_wiki: func(rel, args) [
# Simulate: hardcoded Wikipedia lookup table
wiki_facts: {
"is_capital.Germany": "Berlin",
"is_capital.France": "Paris",
"ceo_of.OpenAI": "Sam Altman"
}
key: rel + "." + args[1]
expected: wiki_facts[key]
if expected == none then [
println("wiki: no entry for ", key)
"unknown"
] else if expected == args[2] then [
insert(rel, args[1], args[2], "postulate")
println("wiki: CONFIRMED — promoted to postulate")
"confirmed"
] else [
insert(rel, args[1], args[2], "standalone")
println("wiki: CONTRADICTED — demoted to standalone")
println(" wiki says:", expected, ", we had:", args[2])
"contradicted"
]
]
# Use it
hypothesis is_capital("Germany", "Berlin")
verify_via_wiki("is_capital", ["Germany", "Berlin"])
# → wiki: CONFIRMED — promoted to postulate
hypothesis is_capital("Germany", "Frankfurt")
verify_via_wiki("is_capital", ["Germany", "Frankfurt"])
# → wiki: CONTRADICTEDLesson: the RAG-at-storage-time pattern is distinct from RAG-at-query-time (the more common form, where retrieval happens fresh on each LLM call). RAG-at-storage-time bakes in the verification result, so downstream queries don’t need to re-verify — much faster, at the cost of staleness.
The cost-of-fresh-vs-baked decision is universal in caches and KBs. Cascade does both: a slow nightly verification scan that promotes facts; a fast query-time verification for top-priority claims.
Open-ended. A sample answer covering DeepProbLog:
DeepProbLog (Manhaeve et al., 2018) extends ProbLog (probabilistic Prolog) by allowing neural predicates. A neural network can serve as a soft fact-generator; its outputs are interpreted as probabilities for ProbLog rules to combine.
Neural part: convolutional networks for digit
recognition. Given an image, produces digit(image, 7) with
probability p.
Symbolic part: ProbLog rules for arithmetic.
addition(I1, I2, Sum) :- digit(I1, X), digit(I2, Y), Sum is X + Y.
Meeting point: at both storage-time (the neural net’s outputs are stored as probabilistic facts) and query-time (the rule fires over the combined facts, yielding a probability for each possible answer).
Compared to Cascade: DeepProbLog uses continuous probabilities; Cascade uses categorical grounding levels. DeepProbLog is differentiable end-to-end (can train via gradient descent through symbolic rules); Cascade is not. The trade-off: differentiability gives DeepProbLog the ability to learn the rules; Cascade’s categorical grounding gives it auditability.
Both work. Both are neuro-symbolic. They occupy different points in the design space.
Lesson: there’s no single “right” architecture for neuro-symbolic systems. The patterns of this chapter (hypothesis storage, verification rules, defeasible derivation, B4 contradictions) are building blocks. Each system assembles them differently based on what it needs.
The “three sources agree → promote” rule is the simplest case. Real systems weight sources by:
The weighted vote is then compared to a threshold. Cascade’s
implementation lives in reasoning/truth_lifecycle.ax.
The Tweety example traces back to McCarthy’s “frame problem” and Hanks-McDermott’s Yale Shooting Problem (1986). The challenge:
Given a gun, a turkey, an action “shoot,” what’s the most natural way to encode “shooting kills the turkey, but only if the gun is loaded”?
Naive logical encodings produce multiple extensions — both “turkey alive, gun unloaded” and “turkey dead, gun loaded” are consistent. The defeasibility ladder, with strict rules preempting defeasible ones, was one of several proposed fixes (alongside circumscription, default logic, etc.).
The fact that we can pose Tweety and Yale Shooting in ~10 lines of Axioma each is a genuine modeling win — it took decades of research to figure out the right mathematical machinery for these intuitively-simple problems.
Compare: “our system sometimes makes mistakes” vs “we observed 12% hallucination rate and 23% ontology drift in last week’s traffic.” The second is actionable; the first isn’t.
The taxonomy of failure modes is itself a contribution. The classification used here (six modes) is one possible taxonomy; others (NIST draft AI risk framework, OWASP LLM Top 10) have their own divisions. The important thing is to have a taxonomy.
Source-reputation tracking is structurally identical to PageRank, eBay seller ratings, Stack Overflow karma, and academic-citation impact factors. They’re all “votes weighted by the voter’s standing” with different specifics.
The deep result (Pace & Sarwar, 2009): any reputation system computable as a weighted vote has the same fundamental vulnerability — Sybil attacks (an attacker creates many fake votes). Defending against Sybil requires either (a) high cost-to-create-account (Twitter pre-2010, Wikipedia editing) or (b) explicit social graph (Twitter blue, Web-of-Trust).
For neuro-symbolic Sybil defense: bind each LLM output to its model + prompt + retrieval context — variations in any of those make it a “different source” for voting purposes.
RAG verification works when the trusted source is available and current. It fails when:
For these, the fallback is human-in-the-loop: the system surfaces uncertain claims to a human reviewer, who decides the grounding. Cascade has this as an explicit step in the truth-update pipeline — claims at hypothesis with ambiguous verification queue for human review.
A genuine open research question. Three competing visions:
Cascade is closer to vision 1 (LLM extracts; symbolic verifies); DeepProbLog is closer to 3 (rules + learnable neural predicates); proprietary LLM products (GPT-4 with web search) are closer to 1.
All three are converging on similar territory but arriving from different starting points. The patterns of this chapter (hypothesis storage, B4 contradictions, grounding ladder) are useful across all three.
Volume II ends here. You now have the full vocabulary: state machines, event loops, durable reactors, stack programming, and neuro-symbolic patterns — on top of the CS1/CS2 foundation from Volume I and Part VII.
Where you go next depends on you. Volume III is the production case study (Cascade). The appendices (A-E) are the cheat sheets you’ll want at hand. The exercises across Volume II are where the learning actually happens — go back and do the ones you skipped.
Whatever you do, build something with it. The patterns in this textbook are tools; tools are worth what you make with them.
All solutions verified against a live, populated Cascade install (~3950 entities, ~3800 relations). Yours will differ depending on what you’ve ingested. Solutions 43.1, 43.3, 43.4, 43.5 are runnable without further LLM calls — they query whatever data is already in your database. Solutions 43.2 and 43.6 are open-ended.
All commands assume you’re in the axiomacascade repo
root.
uv run cascade statsSample output (live Cascade install):
{
"entities": 3954,
"relations": 3809,
"claims": 8415,
"sources": 329,
"pins": 6,
"rules": 85,
"entity_types": 10,
"relation_types": 9
}Running the command a second time produces identical output — the database is purely read in that path.
Sample entity record (from
cascade entity list --type STATE --json):
{
"id": 2323,
"type": "STATE",
"name": "Afghanistan",
"prolog_atom": "afghanistan",
"aliases": [],
"properties": {"sentiment": "NEUTRAL"},
"first_seen": "2026-04-10T07:16:51.789829",
"last_seen": "2026-04-18T07:37:15.840244",
"source_id": 268,
"ulid": "01KNVN5PJFCMX78HK1ZBEYAF5V",
"domain": "political"
}The fields that surface architectural decisions:
prolog_atom — lowercased canonical
form used when this entity appears in .ax rule files. Lets
rules refer to the entity without quoting issues.ulid — Universally Unique
Lexicographically Sortable IDentifier. Allows cross-machine sync without
primary-key collisions.source_id — points back to the article
(in the sources table) that introduced this entity. The
full provenance chain Article → Source → Entity → Relation.domain — high-level domain tag for
filtering (political, energy, infrastructure, economic, …).Pick an article you’ve read. Ingestion is non- deterministic (LLM-based) so success depends on the provider, model, and prompt. A typical good-quality ingestion yields 5-15 new entities and 10-30 new relations from a 600-word article.
Successes to look for:
Failures to look for:
properties.year or properties.tier is
empty.These are evaluation skills you sharpen over multiple ingestions. Cascade’s strength isn’t perfect extraction on one article; it’s probabilistic accumulation over many.
uv run cascade discover cascade "Iran" --depth 3 --json \
| jq '.tiers[] | {tier: .tier, count: .count, sample: [.entities[0:3] | .[].entity_name]}'Sample output (live Cascade):
{"tier": 0, "count": 1, "sample": ["Iran"]}
{"tier": 1, "count": 86, "sample": ["crude oil", "global energy crisis", "Iraq"]}
{"tier": 2, "count": ?, "sample": [...]}
{"tier": 3, "count": ?, "sample": [...]}The depth at which count stops growing tells you when
the cascade saturates — when the BFS has visited every node
reachable via causal edges. For a medium-sized KB (~4000 entities) this
typically happens at depth 4-5.
Reflection prompts the exercise asks:
INFLUENCE relations that bridge semantically
unrelated entities — an entity ends up in the cascade because of a weak
influence edge that shouldn’t propagate causally.uv run cascade discover bridges --json | jq '.[0:5]'
uv run cascade discover centrality --metric betweenness --top 10Sample bridge output (live Cascade):
[
{"entity_id": 1, "name": "Iran", "type": "STATE", "degree": 293},
{"entity_id": 5, "name": "United States", "type": "STATE", "degree": 244},
...
]Sample centrality output:
[
{"entity_id": 5, "name": "United States", "degree": 0.0617, "betweenness": 0.0312, "pagerank": 0.0159},
{"entity_id": 1, "name": "Iran", "degree": 0.0741, "betweenness": ..., "pagerank": ...},
...
]Bridges are entities whose removal would disconnect a sub-graph. Cascade’s implementation is degree-based (high-degree entities are bridge candidates) — but classical graph bridges (Tarjan’s algorithm) would also be valid. Betweenness centrality measures how many shortest paths pass through an entity. The two metrics often agree on top entities but disagree on rank.
Compare: in the live Cascade DB, Iran ranks #1 by degree (293 connections) but the U.S. ranks higher by betweenness — because the U.S. sits on more shortest paths even though it has fewer total edges. That’s a content insight: Iran is a hub (many direct connections), the U.S. is a broker (it connects otherwise-disconnected parts).
# What relations does Cascade have stored?
uv run cascade axioma query list-relationsOutput:
{
"relations": [
"ADVERSARIAL", "ALLIANCE", "CAUSAL", "CONTROL",
"DEPENDENCY", "INFLUENCE", "SUPPLY", "TEMPORAL",
"commodity"
],
"schemas": {...}
}The eight uppercase relations are the canonical relation-type
vocabulary. The lowercase commodity is a domain-specific
relation, populated by an .ax script.
# What facts are stored for the commodity relation?
uv run cascade axioma query list-facts --relation commodityOutput:
{
"facts": [
{"args": ["Bronze"], "epistemic_type": "axiom",
"relation": "commodity", "truth_value": 1}
],
"success": true
}truth_value: 1 is TRUE (the Belnap B4
encoding maps TRUE to 1). epistemic_type: "axiom" is the
strongest grounding from Volume I’s two-tier knowledge system.
# Insert a fact from Axioma side
uv run cascade axioma exec 'axiom/persist test_fact("hello", "world")'Output:
{
"result": "Epistem{type=axiom, value=\"test_fact(\"hello\", \"world\")\", truth_value=1.00}",
"success": true,
"type": "EPISTEM"
}# Verify the fact persists across MCP sessions
uv run cascade axioma query list-facts --relation test_factOutput:
{
"facts": [{"args": ["hello", "world"],
"epistemic_type": "axiom",
"relation": "test_fact",
"truth_value": 1}],
"success": true
}Where it lives. The fact is written to the same
cascade.db SQLite file. The MCP session that wrote it has
shut down between the two commands, but the data persisted. This is
the shared substrate that makes the architecture work:
Python-side Cascade code and Axioma-side rules see the same tables.
Cleanup (optional):
uv run cascade axioma exec 'forget("test_fact", "hello", "world")'Sample answer — Axioma in a medical-records system:
Other domains that fit this template:
The pattern: Axioma is the symbolic-logic component in any hybrid system. It pairs with at least one graph engine (NetworkX, Neo4j) and at least one probabilistic engine (LLM, statistical model). It’s the crisp layer that makes the system’s reasoning auditable.
ULIDs (Universally Unique Lexicographically Sortable IDentifiers) solve a specific problem: when two Cascade instances on different machines both ingest the same article, they’d both generate entity IDs that might collide if using monotonic integers. ULIDs are time-prefixed and globally unique, so the sync layer can deduplicate without primary-key conflicts. The trade is that ULIDs are 26-character strings instead of compact integers — but with SQLite’s indexing, the lookup cost is negligible.
The cascade-discovery algorithm uses BFS by tier (level-order
traversal) because tier is the semantically meaningful unit. A
Tier-1 impact is “close” by definition; a Tier-3 impact is “distant.”
DFS would visit nodes in arbitrary depth order and make tier accounting
ad-hoc. The BFS implementation is ~70 lines in
cascade/core/discovery.py:71.
These metrics ask different questions:
A high-bridge / low-centrality entity is a chokepoint that only matters when something needs to cross it. A low-bridge / high-centrality entity is a broker that mediates many transactions but isn’t structurally critical (redundant paths exist). The two metrics together give a richer picture than either alone.
axiom/persist not need explicit truth?The axiom/ refinement implies
truth_value: 1 (TRUE) because axioms are
foundational — that’s the definition of the strongest epistemic
grounding in Volume I’s two-tier system. If you want a fact stored at
lower truth values, use postulate/persist (grounds the fact
to be challenged later) or set truth explicitly via
set_truth(...).
Three properties make Axioma a useful component:
Any domain where those three properties matter is a candidate for Axioma-as-component. The pattern works because Axioma was designed for exactly this component role from the beginning, even though it also works as a standalone language.
All queries verified against a live populated cascade.db
(~3950 entities, ~3800 relations, ~8400 claims). Yours will differ.
Solutions 44.1–44.5 are runnable SQL; 44.6 is open-ended.
sqlite3 data/cascade.db ".tables"Live output:
change_log monitors portfolios reminders sync_meta
claims notifications predictions rules sync_outbox
entities path_analyses relation_types saved_searches truth_history
entity_types pins relations sources
20 tables total. The 7 core tables from §44.2 plus 13 support tables.
Run .schema entities / .schema relations /
.schema claims to compare with the chapter — every column
listed in §44.3 should be present. Newer migrations may have added
more.
SELECT type, COUNT(*) AS n FROM entities
GROUP BY type ORDER BY n DESC;Live output (top 5):
type n
---------- ----
ORG 1195
ACTOR 613
EVENT 426
SECTOR 352
POLICY 318
SELECT type, COUNT(*) AS n FROM relations
GROUP BY type ORDER BY n DESC;Live output (top 5):
type n
---------- ---
INFLUENCE 747
CONTROL 714
DEPENDENCY 534
CAUSAL 472
SUPPLY 453
Why ORG dominates entities (1195 of ~4000): geopolitical news has many more organizations (companies, agencies, NGOs, ministries) than countries or markets. Every article mentions multiple ORGs as the actors doing things.
Why INFLUENCE dominates relations (747 of ~3800): INFLUENCE is the softest causal vocabulary — when the LLM is uncertain whether to call something CAUSAL or DEPENDENCY, it falls back to INFLUENCE. That makes INFLUENCE a catch-all that accumulates faster than the more committed types. Production analysts often re-tag specific INFLUENCE edges as CAUSAL after manual review.
SELECT c.id,
substr(c.text, 1, 60) AS preview,
c.confidence,
s.title AS source_title
FROM claims c
LEFT JOIN sources s ON c.source_id = s.id
WHERE c.truth_value = 'BOTH'
LIMIT 5;Live sample:
70 The closure of the Gulf waterway could lead to the greatest... MEDIUM
591 Iran is known for its oil exports. MEDIUM
592 Iran exports an estimated 2.1 million barrels of oil per day MEDIUM
The two sides of a typical disagreement: one source gives a higher number, another gives a lower number, and both got ingested. The claim is paraconsistent because Cascade refuses to pick.
The fix in production is usually source weighting: the
higher-credibility source’s value wins, the lower gets retired to
UNKNOWN. But the audit trail shows both. The
truth_history table is where that re-evaluation lands.
SELECT e1.name AS src, r.type, e2.name AS tgt, r.confidence
FROM relations r
JOIN entities e1 ON r.source_entity_id = e1.id
JOIN entities e2 ON r.target_entity_id = e2.id
WHERE e1.name = 'Iran'
ORDER BY r.confidence DESC, r.type;Live sample (top 5 HIGH-confidence rows from Iran):
src type tgt confidence
---- ----------- ------------------------ ----------
Iran SUPPLY crude oil HIGH
Iran ADVERSARIAL Strait of Hormuz HIGH
Iran ADVERSARIAL United States HIGH
Iran CONTROL Ayatollah Ali Khamenei HIGH
Iran ADVERSARIAL Dubai HIGH
The double-join pattern — joining
relations to entities twice with two aliases
(e1 for source, e2 for target) — is the
workhorse query of any graph-in-SQL system. The two indexes
idx_relations_source and idx_relations_target
keep it fast.
SELECT COUNT(*) FROM change_log WHERE reactor_handled = 0;Live output: 3467 (in this DB). Yours will differ.
A non-zero backlog means the reactor agent hasn’t processed all recent changes. Either the reactor is off (most installs don’t run it as a daemon), or it’s slow, or there’s been a flood of recent inserts.
SELECT id, change_type, item_type, item_id, created_at
FROM change_log
WHERE reactor_handled = 0
ORDER BY created_at DESC LIMIT 10;Sample output rows show change_type as
INSERT or UPDATE, item_type as
one of entity, relation, or
claim. This is your recent activity log — useful
when you’ve ingested an article and want to see exactly what landed.
Sample answer.
truth_values tableThe truth_value column on claims is a TEXT
column with values TRUE | FALSE | BOTH | UNKNOWN. Today
it’s unconstrained — typos like "true" (lowercase) or
"TRUE " (trailing space) would land silently and break
WHERE truth_value = 'TRUE'. A foreign key to a
truth_values(name) table would:
Both-Strong value (definite both-true and both-false) could
be added by inserting a row.What breaks: existing inserts that don’t go through the application layer. Migration cost: rewrite ~10 application-layer methods.
properties into typed columnsTempting because JSON-in-TEXT loses indexing. But
properties carries type-specific attributes —
sentiments on STATEs, roles on INFRA, sectors on ORGs. A
column-per-attribute would explode the schema (50+ sparse columns)
without speeding up queries that filter on the rare ones.
The principled fix is a narrow table
entity_properties(entity_id, key, value) with an index on
(key, value). But that’s its own schema revolution. For
now, JSON-in-TEXT trades indexing for flexibility, and Cascade’s queries
rarely filter on properties keys anyway.
The general principle: fix invariant violations (truth value typos); resist over-normalization (properties). Both decisions trade schema rigor for practical maintenance.
Cascade schema migrations land every few weeks. If you spot a column the chapter doesn’t mention, that’s a recent addition — usually for a new feature landing in the next minor release. Treat the chapter schema as the baseline, not the final word.
The over-use of INFLUENCE is a recognized data-quality issue in Cascade’s evaluation pipeline. A planned relation-typing audit re-promotes INFLUENCE to more specific types where evidence supports it. The fact that you can see this from the schema (top relation type by count) is itself evidence of why raw SQL inspection matters — the CLI surfaces aggregates but not always the distribution.
A naive system would resolve “one source says X, one says ¬X” by picking the higher-credibility source and marking the loser FALSE. Cascade’s choice — Belnap B4 BOTH — preserves both assertions because:
The fundamental commitment: the system models the journalist’s uncertainty, not the world’s truth.
Each JOIN entities ON ... adds a B-tree lookup per row.
With idx_relations_source and
idx_relations_target plus the entity primary key, each
lookup is O(log N) where N ≈ 4000. The whole query on a 3800-relation DB
is bounded by the result size, not the table size.
For the rare query that joins on un-indexed columns
(properties JSON values, evidence array
elements), performance degrades to full table scan. Watch out.
Many Cascade installs don’t run the reactor as a daemon. The reactor
is optional — it fires rules in response to changes, but if you
just want to ingest and query, the rules can stay un-fired indefinitely.
The backlog accumulates harmlessly. When you do want rules to fire,
cascade reactor start processes the queue and clears
it.
Every schema decision encodes a worldview. By making
truth_value a free-form TEXT field, the system
invites third-party tools to write nonstandard values (useful
for experimentation). By making relation_types a separate
table, the system forces new relation types to be named
centrally. Each choice trades flexibility for consistency.
Cascade’s overall stance: consistency at the relation-type and entity-type layer; flexibility at the properties and evidence layer. That’s why the former are foreign-keyed tables, the latter are JSON blobs. Whether you agree with the cut is itself a design conversation.
All queries verified against a live populated
cascade.db. Solutions 45.2–45.5 are runnable SQL; 45.1 and
45.6 are open-ended.
Run with -v and skim. Look for these four landmarks:
fetcher | Ingesting URL | url=...
ner | spaCy NER | N unique entities from M chars
extractor | Entity/relation LLM extraction started | ...
decomposer | Claim LLM extraction started | ...
extractor | Storing N entities to graph...
extractor | Store complete | source_id=... | ...
Things to notice:
WITH per_source AS (
SELECT source_id, COUNT(*) AS n
FROM claims
WHERE source_id IS NOT NULL
GROUP BY source_id
)
SELECT AVG(n), MIN(n), MAX(n) FROM per_source;Live output:
avg_claims min_n max_n
----------- ----- -----
31.4 1 100
Repeat for entities and relations:
avg_entities min_n max_n
----------- ----- -----
29.8 1 136
avg_relations min_n max_n
----------- ----- -----
22.7 1 74
The chapter’s stated averages (~31 claims, ~30 entities, ~23 relations) match closely. The maximum values (100 claims, 136 entities, 74 relations) come from analytical long-form pieces.
Why claims > entities > relations. Each article typically has more propositions than named things, and more named things than connections between them. A 600-word article with 30 entities might mention each entity in 2-3 claims but only connect them via 1 explicit relation each.
SELECT source_type,
COUNT(*) AS n,
AVG(credibility) AS avg_cred
FROM sources
GROUP BY source_type
ORDER BY n DESC;Live output:
source_type n avg_cred
----------- --- --------
news 317 0.80
opinion 11 0.50
analysis 1 0.70
news dominates because that’s what the URL-based
ingestion path normally feeds. Op-eds get pulled when the analyst
explicitly wants commentary. Reports typically arrive via file ingestion
(cascade ingest --file report.pdf) and aren’t visible
here.
Why the average credibility per type matches the defaults. Cascade rarely overrides credibility after auto-assignment — the field is reserved for manual analyst adjustments and most installs don’t make them yet. As verification workflows mature, the spread within each source_type will grow.
For a high-yield source (e.g. source_id = 283, which has
100 claims in the test DB):
SELECT 'entities' AS kind, COUNT(*) AS n FROM entities WHERE source_id = 283
UNION ALL SELECT 'relations', COUNT(*) FROM relations WHERE source_id = 283
UNION ALL SELECT 'claims', COUNT(*) FROM claims WHERE source_id = 283;Output:
kind n
--------- ---
entities 66
relations 1
claims 100
Striking pattern: 66 entities, 100 claims, 1 relation. This is a summary-heavy article — many named things, many propositions, but few explicit connections between them. The article’s analytical weight is in the claims, not the graph structure.
That’s a content insight you can only get by looking at the shape of an ingestion. Articles that generate few relations but many claims signal descriptive journalism; articles with more relations than entities signal connective analysis.
SELECT e1.name, e2.name, e1.type
FROM entities e1, entities e2
WHERE e1.id < e2.id
AND e1.type = e2.type
AND lower(e1.name) LIKE '%' || lower(e2.name) || '%'
LIMIT 10;Sample live output:
name1 name2 type
--------------------------------- ------------------------- ---------
coal reserves reserve COMMODITY
Islamic Revolutionary Guard Corps Revolutionary Guard Corps ORG
Ayatollah Ali Khamenei Ali Khamenei ACTOR
Central Intelligence Agency Intel ORG
Central Intelligence Agency IG ORG
Some are real duplicates:
Some are false positives:
This is why production entity resolution needs more than
LIKE — it needs semantic similarity, type constraints, and
ideally human review. The current algorithm is good enough to
suggest merges; applying them is a separate step
(cascade entity merge <id1> <id2>).
Sample answer — Stage 3 verification pass:
The improvement. After the LLM extracts entities,
relations, and claims, run a second LLM call that takes the
extracted JSON plus the original article text and asks: “For each
extracted relation, is the evidence string actually in the article?
Return a verified subset.” Relations that fail verification get
downgraded from HIGH to MEDIUM confidence, or
dropped entirely if their evidence is hallucinated.
What it adds. Catches the most common LLM failure mode — relations between entities that aren’t actually connected in the text. Reduces false- positive cascade results in Ch.43-style queries. Provides an audit log showing which relations the verifier rejected.
What it costs. One extra LLM call per article — roughly doubles Stage 3 latency from ~15s to ~30s. Doubles the LLM-per-article cost. For high-volume ingestion this matters; for analyst-curated workflows it’s worth it. A cheap-model verifier (e.g. Groq for fast Llama) keeps cost reasonable while still catching the worst hallucinations.
What it might break. The verifier itself can hallucinate the opposite error — reject true relations because the evidence string is paraphrased rather than quoted. The fix is a “soft” verification threshold (semantic match, not literal string match) — which adds more cost and complexity. The risk is compounding LLM errors: a flawed verifier reduces trust in extraction and introduces new error modes.
Real engineering tradeoff: verification gates quality at the cost of latency and complexity. The right answer depends on whether you’re optimizing for breadth (lots of mediocre coverage) or depth (fewer articles, higher trust).
NER finds candidates from raw text; the LLM filters based on the article’s focus. NER might tag every country mentioned; the LLM keeps only the meaningful mentions. The difference is the LLM’s judgment value-add — without it, every passing reference would land as an entity, drowning the graph in noise.
Mean 31 claims / max 100 is a long-tail distribution. Most articles are normal news pieces yielding 20-40 claims; a few analytical longforms yield 80-100. When designing rate limits or per- article budgets, plan for the tail, not the mean.
Most Cascade installs use the LLM-assigned source types and never adjust the credibility band. As verification workflows mature (e.g. the verifier proposed in 45.6, or an analyst-driven “claim contested” workflow), per-source credibility will diverge from defaults. The schema supports the divergence; the workflow hasn’t demanded it yet.
The (66 entities, 1 relation, 100 claims) example shows you can characterize an article by its ingestion footprint:
A future Cascade feature could cluster sources by ingestion shape for editorial-style analysis.
The LIKE join finds candidate pairs cheaply but
introduces three failure modes:
Real entity resolution uses embedding similarity, type constraints, co-occurrence with related entities, and (often) human review. The substring query is a first pass — useful for surfacing candidates, not for deciding.
Pipeline improvements always trade three things:
Against:
The right answer depends on which axis your users care about. For Cascade’s geopolitical-intelligence audience, quality and auditability matter more than raw throughput — so the verifier-pass design is plausibly a good fit.
All solutions verified against live cascade.db or
checked against the B4 tables in cascade/core/truth.py.
Solutions 46.1–46.5 are runnable; 46.6 is open-ended.
| Expression | Result | Explanation |
|---|---|---|
| TRUE ⊔ UNKNOWN | TRUE | UNKNOWN is identity for join |
| FALSE ⊔ BOTH | BOTH | BOTH is absorbing |
| TRUE ⊔ FALSE | BOTH | Contradiction → paraconsistent |
| UNKNOWN ⊔ UNKNOWN | UNKNOWN | Both empty stays empty |
The two interesting patterns: (1) UNKNOWN disappears in joins (it’s the identity), and (2) TRUE/FALSE combine to BOTH rather than picking a winner.
aggregate_truth([TRUE, TRUE, BOTH])Result: BOTH.
Why not TRUE (the “majority”)? Because B4 aggregation is not voting — it’s repeated lattice join:
step 0: TRUE
step 1: TRUE ⊔ TRUE = TRUE
step 2: TRUE ⊔ BOTH = BOTH ← contamination
Once BOTH enters the chain, it absorbs subsequent joins. One paraconsistent claim out of N contaminates the aggregate. This is the paraconsistent commitment: the system refuses to average away contradiction.
If you wanted majority-vote semantics instead, you’d write:
from collections import Counter
def majority_truth(values):
return Counter(values).most_common(1)[0][0]But that’s not what Cascade does — and it’s deliberately not what it does.
SELECT id, substr(text, 1, 60) AS preview, confidence
FROM claims WHERE truth_value = 'BOTH' LIMIT 5;Sample from live DB:
70 The closure of the Gulf waterway could lead to the... MEDIUM
591 Iran is known for its oil exports. MEDIUM
592 Iran exports an estimated 2.1 million barrels of oil... MEDIUM
The audit-history query:
SELECT claim_id, old_truth_value, new_truth_value, reason
FROM truth_history ORDER BY changed_at DESC LIMIT 10;Live output:
7849 TRUE BOTH The assessment shifts to BOTH because...
7849 BOTH UNKNOWN No rules matched this claim
Only two rows. That’s the actual state of the production install — the audit table is currently sparse because automated re-evaluation is opt-in. A fresh install will show zero rows.
SELECT printf('%.2f', s.credibility) AS cred,
c.truth_value, COUNT(*) AS n
FROM claims c
LEFT JOIN sources s ON c.source_id = s.id
WHERE s.credibility IS NOT NULL
GROUP BY printf('%.2f', s.credibility), c.truth_value
ORDER BY cred DESC, n DESC;Sample from live DB:
cred truth_value n
---- ----------- ----
0.80 TRUE 4039
0.80 BOTH 48
0.80 UNKNOWN 29
0.80 FALSE 18
0.50 TRUE 127
0.50 UNKNOWN 5
Reading the table:
The data shows higher-credibility sources do produce more TRUE outcomes, but the relationship is not perfectly monotonic — opinion-source claims are sparse enough that we don’t see BOTH at all (only one source per claim usually means no contradiction chance).
The empirical takeaway: credibility bands correlate with TRUE-rate, validating the band’s operational use. To strengthen the claim you’d want data with multiple credibility tiers at similar volumes — which Cascade’s news-dominated corpus doesn’t currently provide.
NEG = {"TRUE": "FALSE", "FALSE": "TRUE",
"BOTH": "BOTH", "UNKNOWN": "UNKNOWN"}Output:
¬TRUE = FALSE
¬FALSE = TRUE
¬BOTH = BOTH ← fixed point
¬UNKNOWN = UNKNOWN ← fixed point
Why BOTH is a fixed point. BOTH means “evidence for both sides.” Its negation is evidence for both sides of the negation — which is the same shape. Negation just relabels which assertion is “positive” and which is “negative”; the two-sided structure survives.
Why UNKNOWN is a fixed point. UNKNOWN means “no evidence.” Negating nothing gives you nothing. There’s nothing to swap.
The pattern: fixed points of negation are the informationally symmetric values (BOTH = full info on both sides; UNKNOWN = no info on either side). TRUE and FALSE are one-sided and therefore move under negation.
cancel land? (open)Sample answer.
A cancel(claim_id, reason) mechanism should
preserve the original truth_value rather than mutate
it, by writing a suppression marker in a new column. Proposed
schema change:
ALTER TABLE claims ADD COLUMN canceled INTEGER DEFAULT 0;
ALTER TABLE claims ADD COLUMN canceled_reason TEXT;
ALTER TABLE claims ADD COLUMN canceled_at TEXT;The cancellation function:
def cancel_claim(conn, claim_id, reason):
conn.execute(
"UPDATE claims SET canceled = 1,"
" canceled_reason = ?,"
" canceled_at = datetime('now')"
" WHERE id = ?",
(reason, claim_id))
record_truth_change(conn, claim_id,
old_truth=current_truth,
new_truth=current_truth, # unchanged
old_confidence=current_conf,
new_confidence=current_conf,
reason=f"CANCEL: {reason}")Why preserve truth_value. Cancellation
isn’t the same as flipping FALSE — it’s saying “this claim is
structurally non-defensible.” The original truth state matters for:
How queries respect cancellation. Add
AND canceled = 0 to every default query:
SELECT ... FROM claims WHERE truth_value = 'TRUE'
AND canceled = 0;Provide a --include-canceled CLI flag that drops the
filter for analyst review. Same pattern as the B4 canceled
builtin from Vol I Ch.21 — the engine knows about cancelations
but normal usage filters them out.
The audit trail stays in truth_history
— the cancellation lands as a truth_history row with old ==
new (because truth itself didn’t change) and a CANCEL: ...
prefix on the reason. An uncancel function would write
another row with UNCANCEL: .... The append-only history is
the complete record.
The general principle: cancellation is metadata, not a truth-value transition. Letting them live in separate columns keeps the B4 lattice operations pure while still giving downstream queries a way to hide non-defensible claims.
Most multi-valued logics have an identity element for their primary combinator, but B4’s choice of UNKNOWN ↦ identity for join is what makes incremental ingestion work. Each new article contributes its own truth value, joined with whatever the system already knows. UNKNOWN ⊔ X = X means the absence of prior evidence doesn’t bias the new evidence. This is exactly what you want for a knowledge graph that grows over time.
A vote-based aggregator would let majority drown out a single dissenting source. That’s appropriate for noisy crowdsourcing but inappropriate for journalism where the dissenting source might be the only one who got it right. B4 BOTH says: “we have contradiction; surface it for analyst review.” That’s the journalistic ethic of the system — don’t suppress dissent, surface it.
The truth_history table only fills up when truth-flip
events occur. In a fresh install where no analyst evaluation has run,
all claims stay at their initial truth and history is empty. Production
installs running the reactor (Ch.50) accumulate dozens of rows per claim
as the system re-evaluates against incoming evidence.
The trade: automated history = useful audit trail, also = a lot of rows. Cascade’s choice is to make automated re-evaluation opt-in so the table doesn’t bloat on installs that don’t need it.
The crosstab from §46.4 shows a correlation, not a causal relationship. Higher-credibility sources also tend to be:
Each of those independently reduces the BOTH rate. Untangling them would require A/B-testing extraction quality across credibility bands — outside what Cascade currently does.
Mathematically, the negation operator on B4 is the unique map satisfying:
¬¬x = x (involutive)¬⊥ = ⊥ (UNKNOWN fixed)¬⊤ = ⊤ (BOTH fixed)¬TRUE = FALSE and ¬FALSE = TRUEThis is a Galois connection — the lattice has a duality between join and meet under negation:
¬(a ⊔ b) = ¬a ⊓ ¬b (De Morgan, one direction)¬(a ⊓ b) = ¬a ⊔ ¬b (De Morgan, other)Belnap proved these in 1977. They’re the reason the operations are useful for handling contradiction — the algebraic structure aligns with how natural-language negation behaves.
Earlier KR systems often overload truth values to encode
cancellation (e.g., a fifth value “RETIRED”). The downside: any code
that branches on truth_value needs to handle the fifth case explicitly.
With a separate canceled column, all existing B4 code keeps
working — it just sees the original truth value. Cancellation is a
filter, not a truth state.
The architectural lesson: orthogonal concerns deserve orthogonal columns. Truth is what we believe; cancellation is what we’re willing to publish. Conflating them makes both harder to reason about.
All commands verified against a live populated Cascade install. Solutions 47.1–47.5 are runnable shell; 47.6 is open-ended (drawing required).
for metric in degree betweenness pagerank; do
uv run cascade discover centrality --metric $metric \
--top 10 --json
doneLive output (top 5 from each, real Cascade DB):
DEGREE BETWEENNESS PAGERANK
------------------ ------------------ ------------------
Iran (293) United States Iran
United States Iran United States
Donald Trump Donald Trump Medallia (!)
China China Israel
Israel Strait of Hormuz China
The big-three overlap: Iran, US, China, Israel, Donald Trump appear in all three lists — they’re universally important across local, global, and recursive measures. These are the entities you’d mention in any executive summary.
The metric-specific surfaces:
Strait of Hormuz (a piece of infrastructure) is high — it
sits on many shortest paths between energy entities.Each metric catches a different sense of “important.” Production analysts run all three and consume the union.
uv run cascade discover bridges --json \
| jq 'group_by(.type) | map({type: .[0].type,
count: length})
| sort_by(-.count)'Live output:
ORG 149
ACTOR 63
STATE 46
EVENT 34
TECHNOLOGY 34
COMMODITY 25
...
ORG dominates bridges because ORGs sit at interfaces in the geopolitical graph: companies mediate between governments, between sectors, between regions. A single company can be the only path connecting two otherwise-disconnected subgraphs.
ACTOR comes second because individual leaders bridge governments and orgs.
STATE is third — fewer than expected. Why? STATES are hubs (high degree) but not necessarily bridges (articulation points). Many STATES are embedded inside connected subgraphs; removing them fragments their local neighborhood but not the larger graph.
The empirical takeaway: STATEs are the things journalism talks about; ORGs are the things that hold the graph together.
uv run cascade discover clusters --json \
| jq '.[] | {size: .size, dominant_type: .dominant_type}'Sample live output (head):
size=1996 dominant=ORG
size=4 dominant=ACTOR
size=3 dominant=ORG
size=3 dominant=STATE
size=2 dominant=ACTOR
size=2 dominant=COMMODITY
size=2 dominant=ACTOR
...
One giant component (~1996 entities) + many tiny isolated pairs/triples. This is the small-world giant component phenomenon — most entities are connected to most others through some chain of relations, while a long tail of orphan pairs sits disconnected.
The orphan pairs are typically:
Cleaning these up is part of the entity-resolution maintenance loop. After consolidation, the small clusters often merge into the giant component.
uv run cascade discover connections "Iran" "crude oil"Live sample (top 5 paths from the real DB):
score=1.0 path=Iran -> crude oil
score=0.8 path=Iran -> Abbas Araghchi -> Strait of Hormuz -> ... -> crude oil
score=0.75 path=Iran -> ... -> crude oil
...
The direct connection (1 hop, SUPPLY relation) is the highest-scored. Path scoring rewards domain-transitions per hop — but a direct STATE→COMMODITY connection is already 1.0 by construction (1 transition / 1 hop).
Walking the longer paths reveals why the connection is interesting: even with a direct SUPPLY relation, multiple longer paths exist through different intermediaries (officials, infrastructure). Each is a different causal story.
Real journalism uses both: the direct fact AND the chain of intermediaries.
for d in 1 2 3 4 5; do
cascade discover cascade "Iran" --depth $d --json \
| jq '.tiers | length'
doneSample output:
depth=1 tiers=2 (origin + Tier 1)
depth=2 tiers=3 (origin + Tier 1 + Tier 2)
depth=3 tiers=4
depth=4 tiers=5
depth=5 tiers=5 ← saturation
The count stops growing between depth 4 and 5 — the causal diameter of the graph from Iran is 4. Beyond 4 hops via causal-typed edges, you don’t reach any new entities (everything reachable has been visited).
This is graph-shape data: a tightly-connected graph saturates at depth 3-4; a sparse one might need depth 7-10. Cascade’s default depth of 3 is chosen because Tier 3 is the analytical sweet spot: deep enough to be non-obvious, shallow enough to stay traceable.
The classic example:
A ── A1
│
A ── A2
│
A ── A3
│
A ── A4
│
A ── B ── C
Drawn more carefully:
A1
│
A2 ── A ── A4
│
A3
│
B
│
C
In NetworkX terms:
import networkx as nx
G = nx.Graph()
G.add_edges_from([
("A","A1"), ("A","A2"), ("A","A3"), ("A","A4"),
("A","B"), ("B","C"),
])
print(nx.betweenness_centrality(G))
# {'A': 0.333, 'A1': 0, 'A2': 0, 'A3': 0, 'A4': 0,
# 'B': 0.4, 'C': 0}B (degree 2) has betweenness 0.4 > A (degree 5) has betweenness 0.333. The intuition: being in the middle matters more than having many neighbors. A is a hub; B is a bridge. Removing A fragments only the A-cluster; removing B disconnects C from everything.
The deeper lesson: a “popular” node and a “strategic” node are different things. Production KR systems track both, and the divergence between the two scores is itself a content signal — when they agree, you’ve found a universally important entity; when they disagree, you’ve found two different kinds of importance.
The Cascade implementation runs all three by default (returning all three scores per node) because no single metric captures the whole story. A purist purist might run only PageRank (the most “correct” recursive measure); production journalism needs degree (fame) and betweenness (broker role) too. Cheap to compute, all three.
The result that ORGs dominate bridges is not a universal graph-theoretic truth — it’s a fact about the geopolitical-journalism corpus. A graph built from medical literature would probably show GENE or DISEASE as the dominant bridge type. The algorithm is generic; the result depends on the data.
The chapter (§47.6) discusses the choice. Briefly: Louvain produces modularity-optimized communities — finer-grained than connected components. With Cascade’s small-world structure, Louvain would slice the giant component into 5-15 sub-communities that aren’t disconnected but are internally denser. That’s a more useful slicing for analyst review.
The reason Cascade hasn’t migrated: connected components are
deterministic and trivial to explain. Louvain is randomized and harder
to interpret. The roadmap calls for Louvain to be exposed as an opt-in
flag (--algorithm louvain) without replacing the
default.
The scoring formula is transitions / path_length. A
1-hop path between two entities of different types has 1
transition in 1 hop → score 1.0. A 4-hop path with 4 transitions also
scores 1.0 (every hop transitions between domains).
The score is not a measure of interestingness — it’s a measure of cross-domain-density. Both direct connections and richly-traversing chains can score high. Reading the results means looking at both score and length.
Tier 1 is obvious (it’s what the article said directly). Tier 2 is plausibly inferrable by a careful reader. Tier 3 is non-obvious — something a human would have to follow the chain to see. Tier 4+ is speculative — the chain might or might not hold; the further you go, the more the conditional probabilities decay.
This is why Cascade defaults to depth 3 — it’s the editorial frontier where the system adds value beyond what a careful human reader gets.
The toy graph teaches the general principle that shows up everywhere in network analysis:
Hub = many neighbors Bridge = few but strategically-placed neighbors
In Cascade, this is why both centrality --metric degree
and centrality --metric betweenness are first-class — they
answer different questions about importance, and the difference is
itself a signal.
All exercises verified against a live Cascade install + Axioma MCP bridge. Solutions 48.1–48.5 are runnable shell; 48.6 is open-ended.
$ time uv run cascade axioma exec 'println(42)'
KB service started at /tmp/axioma-kb.sock
KB opened: /Users/.../cascade.db
Axioma MCP server starting on stdio...
{"output": "42\n", "result": "null", "success": true,
"type": "NULL"}
uv run cascade axioma exec ... 0.67s user 0.19s system 91% cpu 0.944 totalCold start: ~0.9 seconds. That includes:
The second invocation from a fresh shell takes the same ~0.9
seconds because each uv run cascade ... is its own process.
The warm path only applies within a single process — in
practice that means the Cascade dashboard, scheduled-task runner, or
REPL.
If you want a fast inner loop, run uv run cascade repl
and keep the process open; subsequent commands within the REPL hit the
warm path (~50-100ms per evaluation).
uv run cascade axioma query list-relations \
| jq -r '.relations | .[]' | grep '^[a-z]'Live output:
commodity
test_fact
The 8 uppercase relations are Cascade’s canonical ontology. The
lowercase relations come from .ax rules and ad-hoc
axiom/persist statements:
commodity — likely came from a
.cascade/rules/axiomalang/*.ax file asserting facts like
axiom commodity("Bronze").test_fact — from the Ch.43 §43.5
walkthrough where we added a test fact via the Cascade-Axioma
bridge.Reading
.cascade/rules/axiomalang/oil_gold_positive_correlation.ax:
concept oil_gold_correlation {
domain: "economic";
type: "correlation";
direction: "positive";
}
rule oil_spike_gold_rise {
when entity("Crude Oil") and entity("Gold")
then with_context("inflationary_hedge", ...)
}This rule encodes the empirical observation that oil prices and gold prices correlate positively (both rising under inflation pressure). It’s the kind of domain expertise that doesn’t appear in any article — an analyst codified it once and now the system applies it everywhere.
for eng in python prolog axioma cascade; do
uv run cascade evaluate <id> --mode rules \
--engine $eng --test
doneSample output across engines (claim 1 from test DB, abstract text about Irish-US relations):
engine=python → UNKNOWN (SPECULATIVE) — "No rules matched"
engine=prolog → UNKNOWN (SPECULATIVE) — "No rules matched"
engine=axioma → UNKNOWN (SPECULATIVE) — "Axiomalang B4: NEITHER"
engine=cascade → UNKNOWN (SPECULATIVE) — "No rules matched"
For most claims in the test database, all four engines agree
on UNKNOWN/SPECULATIVE — because the shipped rules are
domain-specific (oil/gold, monetary tightening, capital flows)
and don’t fire on out-of-domain claims (here: Irish St. Patrick’s Day
diplomacy).
When the engines do disagree, it’s typically because:
Hybrid mode (--mode rules --engine hybrid) takes all
four verdicts and aggregates them via B4 meet (Ch.46 §46.3). The hybrid
output is more conservative than any single engine — agreement across
engines is the strongest signal.
$ sqlite3 cascade.db \
"SELECT truth_value, confidence FROM claims WHERE id=1"
TRUE|HIGH
$ uv run cascade evaluate 1 --mode rules --engine axioma --test
... (verdict computed, displayed)
Updated: UNKNOWN (SPECULATIVE)
(test_only: true — not persisted)
$ sqlite3 cascade.db \
"SELECT truth_value, confidence FROM claims WHERE id=1"
TRUE|HIGH ← unchangedThe --test flag suppresses the
update_claim(...) call in step 6 of the orchestrator.
Useful for:
The --test output still includes the full reasoning
chain, so analysts can review what would have happened before
committing.
A claim like “The Irish are bringing $6.1 billion in planned investments to the U.S.” might have clear propositions; a claim like “The situation remains tense” may decompose to zero propositions because:
When decompose_claim returns
{"propositions": []}, the orchestrator
(cascade/core/axiomalang.py: 171):
if not propositions:
log.info("No propositions extracted from claim %d",
claim_id)
return NoneReturns None. The downstream evaluate CLI displays
UNKNOWN (SPECULATIVE) — "no propositions extracted".
The fix is on the decomposer side: better NSM prime
coverage, LLM fallback for hard cases, or hand-written .ax
rules that match the abstract predicates. Production Cascade evolves the
decomposer organically as analysts encounter specific abstract-claim
failure modes.
Sample answer: Improve step 3 (truth-seeding) by consulting related-entity truth values, not just the claim’s own truth.
The improvement. Currently, step 3 binds each proposition’s seed truth to the claim’s own truth value. This is wasteful — if the claim already asserts TRUE, the proposition gets TRUE, and the B4 meet collapses immediately. A smarter truth-seeder would look at related claims: for each proposition’s entities, find other claims mentioning those entities, take the B4 join of their truth values, and use that as the seed.
def smart_truth_seed(prop, conn):
related_claims = find_related_claims_by_entities(
conn, prop["entities"])
truth_values = [TruthValue(c["truth_value"])
for c in related_claims]
return aggregate_truth(truth_values)What it adds. Cross-claim evidence aggregation at evaluation time. A proposition about “Iran + oil” gets seeded by all known facts about Iran + oil, not just the current claim’s verdict. This makes the B4 evaluation richer and catches contradictions that span multiple claims.
What it costs. One extra SQL query per proposition (potentially expensive: O(N) over all claims). And a more subtle risk: the seed truth biases the evaluation, potentially making it self-referential (related claims got their truth from the same rules; now those rules see their own output as input). The fix is to weight the seed truth lower than rule-derived truth — but that adds yet another parameter to tune.
What it might break. Existing tests that expect specific truth values for specific claims — the cross-claim aggregation might flip an answer that was UNKNOWN to BOTH. Production deployment would need careful migration, possibly with a feature flag.
The 0.9s cold-start cost is not an MCP problem — most of it is Python+Cascade import latency. Profiling shows:
total: ~900ms
Python startup: ~150ms
Cascade imports: ~250ms
MCP setup: ~400ms
Actual work: ~10-100ms
Optimizations like --no-import-cascade (skipping some
imports) reduce this. The fundamental fix is to keep the Python
process alive — which is what the dashboard, scheduled tasks, and
REPL do.
The lowercase relations (commodity,
test_fact) aren’t hacks. They’re the same as the
uppercase ones at the SQL level — same relation_types
table, same relations table for facts. The distinction is
cultural:
This distinction lets downstream queries filter: “show me only Cascade-grade relations” or “show me everything including the Axioma extensions.”
A live example of inter-engine disagreement (not shown above) is on claims about prices. Python’s correlation rules fire for “oil rose” claims; Cascade’s structural rules fire for the same. The two engines may produce different verdicts because they’re looking at different facts:
Hybrid mode joins both, surfacing the disagreement as
BOTH and prompting analyst review. This is what
hybrid mode is for — catching the rare-but-important cases
where engines that should agree don’t.
The --test flag is the bridge between
development (where you want to iterate quickly on rules) and
production (where you don’t want broken rules to corrupt the
DB). Typical workflow:
.ax rule file.cascade evaluate <id> --engine axioma --test on
representative claims.--test.The flag is the separation of trial and effect — a small UX investment that pays off massively in debug cycles.
When decompose_claim returns zero propositions, the
whole pipeline silently produces UNKNOWN. This is graceful but
also misleading — the analyst sees “no rules matched” when
actually “no propositions extracted.”
The fix would be to surface this distinction in the
reasoning string. A future enhancement: change the verdict from
UNKNOWN/SPECULATIVE/"no rules matched" to
UNKNOWN/SPECULATIVE/"decomposition failed: no propositions"
when the issue is parsing, not matching. Small change;
big debug-quality win.
The six-step pipeline is purposely shallow — each step does one thing, so each step is easy to swap. This is a textbook example of the pipeline pattern: linear sequence of transformations, each with a clear input/output contract. Extending step 3 doesn’t touch step 2 or step 4. Compare to a tangled monolithic evaluator where every change risks every output — the modularity is what makes Cascade safe to evolve in production.
All .ax files in solutions 49.1, 49.3, 49.4 are verified
to parse + run via axioma --no-kb. Solution 49.2 is
pure-syntax (no execution); 49.5 requires a live Cascade install; 49.6
is open.
relation greeting(speaker :: String -> "speaker",
text :: String -> "text")
axiom greeting("Cascade", "Hello, knowledge graph!")
println({(S, T) | greeting(S, T)})Verified run:
$ axioma --no-kb ex_41_1_first_rule.ax
{("Cascade", "Hello, knowledge graph!")}
After dropping into ~/.cascade/rules/axiomalang/, the
next cascade axioma exec call logs:
Loaded Axiomalang rule file: ex_41_1_first_rule.ax
The relation now persists in the Axioma session for all subsequent calls.
# 1. "X is a teammate of Y if X plays for Y's team."
# Two-step: find Y's team, check X plays for it.
teammate(X, Y) <= plays_for(X, T) and plays_for(Y, T)
# 2. "X is a senior officer of Y if X is an officer of Y
# and X has tenure >= 10."
senior_officer(X, Y) <= officer(X, Y) and tenure(X, T) and T >= 10
# 3. "X depends on Y if X needs Y or X is supplied by Y."
# Two strict rules — the disjunction is encoded as two heads.
depends(X, Y) <= needs(X, Y)
depends(X, Y) <= supplied_by(X, Y)Three patterns surfaced:
T (team) twice — the unifier forces both
plays_for calls to bind to the same team.T >= 10 directly in the body. Axioma’s Horn-clause body
accepts both relation calls and comparison expressions.relation sanctions(target :: String -> "sanctioned_state")
relation supplies(source :: String -> "state",
good :: String -> "commodity")
relation at_risk(item :: String -> "commodity")
axiom sanctions("Iran")
axiom supplies("Iran", "crude oil")
axiom supplies("Iran", "natural gas")
axiom supplies("Saudi Arabia", "crude oil")
at_risk(C) <= sanctions(S) and supplies(S, C)
println("Sanctioned:", {S | S <- sanctions(S)})
println("Supply pairs:", {(S, C) | supplies(S, C)})
println("At risk:", {C | C <- at_risk(C)})Verified output:
Sanctioned: {"Iran"}
Supply pairs: {("Iran", "crude oil"),
("Iran", "natural gas"),
("Saudi Arabia", "crude oil")}
At risk: {"crude oil", "natural gas"}
After dropping into
~/.cascade/rules/axiomalang/sanctions_risk.ax,
cascade axioma query list-relations shows the three new
relations.
The pattern: defeasible rule for the default, strict rule for the override. Analysts query both and compute the difference.
axiom has_strategic_reserve("crude oil") # US SPR
at_risk_defeasible(C) <~~ sanctions(S) and supplies(S, C)
not_at_risk(C) <= has_strategic_reserve(C)
println("Defeasibly at risk:", {C | C <- at_risk_defeasible(C)})
println("Protected by reserves:", {C | C <- not_at_risk(C)})Output:
Defeasibly at risk: {"crude oil", "natural gas"}
Protected by reserves: {"crude oil"}
The defeasible derivation puts crude oil in the risk
set; the strict override puts it in the protected set. Downstream
analysis subtracts — final at-risk list is
{"natural gas"}.
The <~~ syntax creates conjecture-grade
derivations (Vol I Ch.18 epistemic grounding) that get capped lower than
<=-derived strict facts. This is the defeasibility
cap — even though both rules fire, the strict rule’s verdict
outranks the defeasible one.
Workflow:
# Install the rule
cp ex_41_3_sanctions_risk.ax \
.cascade/rules/axiomalang/sanctions_risk.ax
# Confirm load
uv run cascade axioma exec 'println("ok")' 2>&1 \
| grep "Loaded Axiomalang"
# → Loaded Axiomalang rule file: sanctions_risk.ax
# Find a relevant claim
sqlite3 data/cascade.db \
"SELECT id, substr(text, 1, 60) FROM claims
WHERE text LIKE '%Iran%' LIMIT 3;"
# → e.g. claim 70 about Strait of Hormuz
# Evaluate (dry run, doesn't write to DB)
uv run cascade evaluate 70 --mode rules \
--engine axioma --testThe reasoning chain output should mention sanctions,
supplies, or at_risk if the rule fires on the
claim. If it doesn’t, two diagnoses:
decompose_claim couldn’t bind
propositions to the rule’s premises.SUPPLY); your rule uses
lowercase (supplies). They’re different relations
at the Axioma level.The fix for #2 is to either rename your relation to match (use
SUPPLY in your .ax file) or to add a
bridge rule:
supplies(A, B) <= SUPPLY(A, B)This propagates from Cascade’s canonical relation into your rule-private one.
Sample answer — software engineering domain:
# software_security_risk.ax — sketch (not yet run)
create SecurityRisk {
purpose: "Conditions under which a software dependency
poses a security threat to its consumers",
formed_by: "stipulation",
default_grounding: "conjecture"
}
relation dependency(parent :: String -> "package",
child :: String -> "package")
relation has_cve(pkg :: String -> "package",
cve_id :: String -> "cve")
relation transitive_dep(parent :: String -> "package",
descendant :: String -> "package")
relation at_risk_pkg(pkg :: String -> "package")
# Facts (sketch — would normally come from package manifest scan)
axiom dependency("my-app", "log4j")
axiom dependency("log4j", "log4j-core")
axiom has_cve("log4j-core", "CVE-2021-44228")
# Strict transitive closure
transitive_dep(P, D) <= dependency(P, D)
transitive_dep(P, D) <= dependency(P, M) and transitive_dep(M, D)
# Strict risk: if a transitive dep has a CVE, parent is at risk
at_risk_pkg(P) <= transitive_dep(P, D) and has_cve(D, _)
# Defeasible: usually-true unless the CVE is mitigated
at_risk_pkg(P) <~~ transitive_dep(P, D) and has_cve(D, _)
and not mitigated(P, D)
println("At-risk packages:", {P | P <- at_risk_pkg(P)})TODOs noted in the sketch:
has_cve(D, _) — verify wildcard _ in rule
bodies works (Vol I Ch.31 noted it doesn’t in strict rules — use a named
unused variable instead).not mitigated(P, D) — negation in Horn clause bodies
isn’t classical Axioma; the right idiom is closed-world failure
via a separate cancellation builtin.To debug:
$ axioma --typecheck software_security_risk.ax
# fix the wildcard / negation issues
$ axioma --no-kb software_security_risk.ax
# verify the transitive closure depthThis is how a real rule file matures: sketch → typecheck → run → fix → iterate.
define relation is the contractWithout define relation, calling
greeting(...) would either fail (relation undeclared) or
silently create an untyped relation. The declaration is the
type-and-arity contract — Cascade’s downstream tooling knows
what to expect when querying.
The Lojban-style place labels (speaker,
text) make the relation self-documenting. Future
maintainers (including future-you) can read the relation declaration and
know exactly what each position means — no need to trace through the
extraction code.
Horn clauses are conjunctive by definition — the body is “all premises must hold.” Disjunction requires multiple rules with the same head. This is a feature, not a bug:
Production rule sets often have 3-5 rules with the same head, each representing a different way the fact could be derived.
Real Cascade has eight UPPERCASE canonical relations (CAUSAL, SUPPLY, …) plus any lowercase Axioma-injected ones. The convention:
This is purely a cultural convention — at the SQL level both are the same. The convention helps analysts distinguish “official” relations from “experimental” ones at a glance.
Two engineering patterns for “rule with exception”:
cancel(at_risk, "crude oil") to suppress specific
derivations. The provenance survives but queries filter the cancelled
facts.Both are valid; pick based on what’s easier to reason about. For category-level exceptions (strategic reserves), the cap pattern is cleaner. For case-by-case exceptions, the explicit cancellation is more flexible.
Three common reasons:
supplies; Cascade has SUPPLY. Use a bridge
rule (supplies(A, B) <= SUPPLY(A, B)) or match the
canonical name directly.decompose_claim couldn’t relate the claim text to your
rule’s entities. Try a more concrete claim with explicit named
entities.sanctions(S) only fires if S can be bound. If
no sanctions(...) facts exist in the Axioma session at
evaluation time, the rule silently fails.Debug by running the rule file standalone with sample facts (Ch.49 §49.3’s approach) — that removes the decomposer from the loop.
Real-world rule authoring follows this loop:
rules
table’s applied_count / success_count).Production Cascade installs accumulate dozens of rules this way. Each represents a piece of domain expertise that the extraction pipeline can’t infer. They’re the system’s learned knowledge.
All exercises verified against a live Cascade install. Solutions 50.1–50.3 + 50.5 are runnable; 50.4 and 50.6 are open-ended.
Sample run:
$ uv run cascade agent -v \
"What does Cascade know about Iran's connections to oil markets?"Verbose output shows:
Agent round 1/5 | messages=1
Agent tool call: search_entities({"query": "Iran"})
Agent tool call: search_entities({"query": "oil"})
Agent round 2/5 | messages=4
Agent tool call: get_entity_relations({"entity_name": "Iran"})
Agent round 3/5 | messages=6
Agent tool call: cascade_chain({"entity_name": "Iran", "depth": 2})
Agent round 4/5 | messages=8
[final synthesized response]
Typical pattern: 3-4 tool calls across 3-4 rounds. The LLM:
Iran,
oil).The choice of tools reflects the system prompt — the analyst
persona biases toward cascade_chain and
centrality_ranking over search_claims. A
different prompt would surface different tool patterns.
Cold (reactor not running):
{
"running": false,
"events_received": 0,
"rules_fired": 0,
"actions_executed": 0,
"errors": 0,
"pending": 0
}After cascade reactor start in another terminal, then
triggering a change:
{
"running": true,
"events_received": 1,
"rules_fired": 0,
"actions_executed": 0,
"errors": 0,
"pending": 0
}events_received increments per change_log row;
rules_fired increments per matched rule;
actions_executed per action that ran successfully. If
rules_fired == 0 after a change, no rule’s
preconditions matched — expected if the test DB doesn’t have rules
targeting the specific change type.
Why the daemon must be separately started. The
reactor is a long-running process; it can’t auto-start from a
synchronous CLI invocation without backgrounding (which would lose the
foreground status display). Production deployments run it under systemd
or nohup ... &.
$ cascade monitor add "AI safety" --query "AI safety regulation" --interval 24
✅ Monitor added: ID=2, entity="AI safety", interval=24hAfter adding, the monitors table now has:
id search_query interval_hours
-- --------------------- --------------
1 Strait of Hormuz 24
2 AI safety regulation 24
Running cascade monitor run triggers:
sources.url.cascade ingest <url> → Stage
1-4 pipeline (Ch.45).last_run_at and last_result.Live result: typically 2-5 new sources, ~30-150 new entities, ~30-100 new claims per run. The exact numbers depend on news activity and dedup hit rate.
Sample answer — four implementations of “find new evidence about Iran”:
a) Tool-use agent (synchronous)
cascade agent "Find recent claims about Iran in cascade.db"Runs once per invocation, returns synthesized prose. Best for: ad-hoc analyst queries where the answer is needed now and the question is one-off.
b) Reactor (event-driven)
Write an .ax rule that fires on
SOURCE_INGESTED events containing Iran-related entities,
and writes a flag to a notifications table. The analyst
checks notifications when they log in. Best for:
passive-discovery workflows where the analyst wants alerts but not
constant polling.
c) Monitor (scheduled)
cascade monitor add "Iran" --query "Iran latest news" --interval 6.
Every 6 hours, Cascade scrapes new articles about Iran and ingests them.
Fresh evidence accumulates automatically. Best for:
keeping the knowledge graph fresh without analyst intervention —
continuous operation.
d) MCP server (cross-process)
The analyst uses Claude Desktop with Cascade’s MCP plugged in. They
ask Claude: “What’s new about Iran?” Claude calls Cascade’s
search_claims + cascade_chain tools.
Best for: when the analyst’s primary tool is
another LLM client (Claude Desktop, custom agent) rather than
Cascade’s own CLI.
The choice:
Production deployments use all four — each for its slice.
From cascade/core/events.py:
class ChangeType(str, Enum):
ENTITY_CREATED = "entity_created"
ENTITY_UPDATED = "entity_updated"
ENTITY_DELETED = "entity_deleted"
RELATION_CREATED = "relation_created"
RELATION_UPDATED = "relation_updated"
RELATION_DELETED = "relation_deleted"
CLAIM_CREATED = "claim_created"
CLAIM_UPDATED = "claim_updated"
CLAIM_DELETED = "claim_deleted"
SOURCE_INGESTED = "source_ingested"a) Ten ChangeType values — three operations (created/updated/deleted) × three item types (entity/relation/claim) + one source event.
b) Synchronous — uses Python’s
threading.Lock for thread-safety but emits
synchronously. The caller blocks until all subscribers have
processed the event. No async/asyncio.
c) Multiple subscribers — yes. The bus keeps a
_subscribers: list[Callable] and dispatches sequentially to
each. The reactor is the primary subscriber; tests or extensions can add
more.
The synchronous + multiple-subscriber design is fine because:
If you needed cross-process event subscription (e.g. a dashboard process reacting to changes), the right pattern is poll change_log rather than extend the bus. SQLite + WAL gives you that cheaply.
Sample answer — the Curator agent:
What it’s for: Periodic quality maintenance of the knowledge graph. The curator agent reviews recently-ingested entities and relations, flagging:
It produces a review queue — not auto-fixing, just surfacing — for an analyst to approve or reject.
Where it fits in the architecture:
curator_queue table — the analyst’s review queue.This sits between the reactor (event-driven, narrow rules) and the tool-use agent (on-demand, broad reasoning). It’s the periodic-deep-think slot in the architecture — slower than the reactor, more thorough than monitors, less interactive than the agent.
The deeper principle: Cascade’s swarm has slots for every cost/latency profile in the agent-engineering matrix:
| Latency | Cost | Today’s slot |
|---|---|---|
| Sync, fast | Free | Reactor |
| Sync, slow | LLM | Tool-use agent |
| Async, free | Schedule | Monitor |
| Async, LLM | (gap) | Curator |
The curator is the missing fourth quadrant.
The system prompt biases the LLM’s tool selection. A
generalist analyst will reach for search_claims
first; a graph theorist will prefer
centrality_ranking; an intelligence analyst
(Cascade’s actual prompt) will favor cascade_chain. To get
different behavior, modify the prompt — not the tool catalog.
The reactor is a classic production-rule system (in the Newell/Simon sense). Rules have:
Firing rules can themselves trigger more change events → more rule firings → indefinite forward chaining. The debounce parameter prevents infinite loops; an explicit termination check (seen rules N times) would be more robust.
This is the same architecture as Drools, JRules, CLIPS, and OPS5 — just embedded in Cascade rather than freestanding.
Free. No API key required. Comprehensive enough for most topics. Rate-limited but not throttled to zero. The tradeoff: less control over the source (you can’t filter by source quality or recency window).
For paid alternatives, see cascade/core/monitor.py’s
search_google_news_rss and search_duckduckgo
functions — both swap-in points for NewsAPI/Newsdata.io/Bing News.
| Mechanism | Cost | Latency | Quality |
|---|---|---|---|
| Tool-use | $$$ | seconds | high |
| Reactor | free | sub-second | medium |
| Monitor | $ (ingest) | hours | depends on ingestion |
| MCP | $$$ (external) | seconds | high |
Cost-latency-quality is the engineering trilemma for agent systems. Cascade’s four mechanisms each pick a different point on the triangle — there’s no Pareto-dominant option; each is right for some task.
The bus emits synchronously into the reactor, but the
reactor’s _handle_event method just enqueues work — actual
rule firing happens in a separate worker thread. The pattern:
the bus is the notifier; the reactor is the worker.
This decouples publication latency from work latency.
For very high-throughput ingestion, an intermediate queue (Redis, RabbitMQ) might replace the in-process bus. Cascade’s design avoids that complexity for normal single-machine use.
Cascade’s architecture exhibits a useful pattern: each agent mechanism owns one corner of the async/sync × free/LLM matrix. Adding the curator fills the last corner. More than five would start to overlap responsibilities.
The deeper architectural lesson: agent mechanisms aren’t generic agents — they’re specialized for cost/latency tradeoffs. Right- sizing each to its slot makes the whole swarm maintainable.
All exercises verified against a live Cascade install. Solutions 51.1–51.5 are runnable shell; 51.6 is open.
$ uv run cascade reactor start # in terminal 1
$ uv run cascade reactor status --json # in terminal 2
{
"running": true,
"events_received": 0,
...
}
$ uv run cascade entity add STATE "Atlantis"
✅ Added entity 4001 STATE "Atlantis"
# ~500ms later (debounce window):
$ uv run cascade reactor status --json
{
"running": true,
"events_received": 1, ← incremented
"rules_fired": 0, ← no rule matched ENTITY_CREATED
"actions_executed": 0,
"errors": 0,
"pending": 0 ← debounce window elapsed; drained
}
Why rules_fired = 0 even though
events_received = 1: the shipped rules don’t have
preconditions matching ENTITY_CREATED for arbitrary STATE entities. The
event was seen but no rule wanted it. That’s expected
— most events in a working system pass through without firing any
rule.
After setting max_cascade_depth = 5 and triggering a
multi-hop ingestion:
SELECT id, change_type, item_type, created_at
FROM change_log
ORDER BY id DESC LIMIT 20;You’d see chains like:
SOURCE_INGESTED → source 401
ENTITY_CREATED × 30 → new entities from extraction
RELATION_CREATED × 23 → new relations
CLAIM_CREATED × 31 → new claims
(reactor fires)
CLAIM_UPDATED × 5 ← truth values flipped
RELATION_UPDATED × 2 ← derived from claim flips
(reactor fires again, depth 2)
CLAIM_UPDATED × 1 ← cascaded down
(depth 3 reached; further changes recorded but not re-evaluated)
The cascade saturates when either the depth limit hits or no rule’s preconditions are satisfied by the latest changes. The depth bound guarantees termination even with mutually-modifying rules.
In a production install with rich rule sets, cascades typically stop at depth 2-3 — most rules produce one or two derivable consequences, not ten.
SELECT change_type, COUNT(*) AS n
FROM change_log GROUP BY change_type ORDER BY n DESC;Sample live output:
change_type n
------------------ ----
RELATION_CREATED 1820
ENTITY_CREATED 1156
CLAIM_CREATED 890
SOURCE_INGESTED 328
ENTITY_UPDATED 45
CLAIM_UPDATED 12
RELATION_UPDATED 5
Three patterns:
The ratio CREATEs:UPDATEs ≈ 100:1 is a healthy sign — the graph mostly accumulates rather than churns. Heavy UPDATE ratios would suggest entity-resolution problems (Ch.45 §45.5).
$ cascade monitor install-cron
# Cascade monitor cron entry:
0 */6 * * * cd /path/to/axiomacascade && \
/home/user/.local/bin/uv run cascade monitor run --quiet
Reading the schedule: 0 */6 * * * = run
at minute 0 of every 6th hour, every day. So 00:00, 06:00, 12:00, 18:00
each day.
Without --quiet: stdout/stderr from
each monitor run (search results, ingestion logs, LLM debug
output) would go to the cron mailer and produce ~5-10 emails per day.
The --quiet flag silences progress noise; errors still go
to the Cascade logs at data/logs/cascade.log.
The cron interval (6 hours) is a politeness default. Production deployments often raise to 12 hours for less time-sensitive topics, or drop to 1 hour for breaking-news monitoring.
$ uv run cascade reactor stop
✅ Reactor stopped
$ sqlite3 cascade.db \
"SELECT COUNT(*) FROM change_log WHERE reactor_handled = 0;"
3467 ← existing backlog (likely from before reactor was ever started)
$ uv run cascade entity add STATE "TestState1"
$ uv run cascade entity add STATE "TestState2"
$ sqlite3 cascade.db \
"SELECT COUNT(*) FROM change_log WHERE reactor_handled = 0;"
3469 ← +2 from the two ENTITY_CREATED events
$ uv run cascade reactor start # foreground
# After ~500ms debounce + processing:
$ sqlite3 cascade.db \
"SELECT COUNT(*) FROM change_log WHERE reactor_handled = 0;"
0 ← backlog clearedThe reactor’s first batch on restart drains the entire backlog, processing all unhandled rows. For a 3500-row backlog, this can take ~30 seconds to a few minutes depending on how many rules match.
The recovery property: stopping the reactor never loses events. The change_log persists across restarts; the WAL pattern is what makes the reactor robust to crashes and intentional downtime.
Sample answer:
from collections import defaultdict, deque
import time
class RuleRateLimiter:
"""Per-rule firing budget over a sliding window."""
def __init__(self, budget=100, window_seconds=60):
self.budget = budget
self.window = window_seconds
# rule_name -> deque of firing timestamps
self._history: defaultdict[str, deque] = defaultdict(deque)
def allow(self, rule_name: str) -> bool:
"""Return True if the rule is within budget;
False if rate-limited."""
now = time.monotonic()
cutoff = now - self.window
history = self._history[rule_name]
# Prune old entries
while history and history[0] < cutoff:
history.popleft()
if len(history) >= self.budget:
return False # over budget
history.append(now)
return TrueIntegration into the reactor:
def _process_batch(self, events):
...
for rule in matching_rules:
if not self.rate_limiter.allow(rule.name):
log.warning("Rule %s rate-limited "
"(>%d/min); skipping",
rule.name, self.budget)
continue
rule.fire(...)What happens when budget exceeded? Three options, each with a different cost-quality profile:
_pending with a delay. Eventually catches up. More complex,
preserves correctness.Recommended: option 3. Rate limiting that silently drops work is a footgun; rate limiting that alerts is a feature.
Production rule systems typically have:
The reactor is designed for this skew — the no-match path is the fast path. Rule matching is cheap (a few SQL queries); rule firing is expensive. The 80% no-match case returns in microseconds.
Empirically, multi-hop rule cascades in Cascade saturate at depth 2-3:
Setting max_cascade_depth = 5 lets the system explore
deeper chains for research purposes but rarely produces useful
new facts. The default of 3 captures the productive depth.
A graph where INSERTs vastly outnumber UPDATEs is additive — new evidence accumulates; existing facts rarely flip. This is good.
The pathological alternative — UPDATE-dominant — would mean facts constantly flipping between TRUE/FALSE/BOTH as competing evidence lands. That signals either:
Cascade installs in steady state have CREATE:UPDATE ≈ 50:1 to 200:1. Anything below 10:1 deserves investigation.
Most Cascade production deployments rely on cron for monitor scheduling. The alternative — a Python scheduler inside the reactor — duplicates what cron already does well.
The principle: OS-level scheduling for periodic work; in-process daemon for event-driven work. Both are right; neither should replace the other.
The change_log WAL pattern is intentional.
Cascade chose append-only logging precisely to make crash recovery
boring. The alternative (in-memory event buffer with periodic flush)
would lose events on crash — unacceptable for a knowledge-graph system
where every event encodes new information.
The deeper lesson: every rule firing has a cost (CPU, DB writes, LLM if the action does inference). Without rate limits, a bug in a rule (over-eager preconditions) can melt the system. Rate limits are the safety mechanism that protects the reactor from its own rules.
This is the same pattern as web-server request limits, API quotas, and Kafka consumer rate caps. The general principle: any system with feedback loops needs a governor.
This chapter is more design-and-discuss than code-and-run, since operating-Cascade decisions are deployment-specific. Solutions 52.1–52.3 have verifiable shell outputs; 52.4–52.6 are open-ended with sample answers.
Live output from the test install:
Graph stats:
entities: 3956, relations: 3810, claims: 8416,
sources: 329, rules: 85
Reactor metrics:
running: false, events: 0, rules_fired: 0
Sync metrics:
Supabase: not configured
Rule files: 4 in .cascade/rules/axiomalang/
Logs: data/logs/cascade.log
Config: ~/.cascade/cascade.toml
A one-page deployment summary for this install would say:
Local single-machine install. ~4000 entities, ~3800 relations, ~8400 claims accumulated from 329 articles. 85 rules registered across 5 engines (python/prolog/axioma/cascade/ axiomalang). Reactor not currently running; sync not configured. Test/development install — not production-grade.
For a production install, the same query would return a very different summary: reactor running, sync active, dozens of rules firing per hour, daily briefing pipeline producing output.
The inventory query is your baseline diagnostic. Run it
weekly; track the deltas. Big jumps in sync_outbox row
count, or errors in reactor status, are early warning
signs.
The seven formats serve different audiences:
| Format | Tool to open | Audience |
|---|---|---|
| HTML | Browser | Casual readers, email recipients |
| GraphML | Gephi (free) | Academic visualization |
| GEXF | Gephi (native) | Same as GraphML, more features |
| DOT | Graphviz dot CLI |
Programmatic typesetting |
| Cypher | Neo4j Desktop | Graph-database imports |
| Tree | Plain text terminal | Briefing inserts |
| Chart | Image viewer | Reports, slides |
What each shows that the CLI doesn’t:
The lesson: export per use case, not per preference. No format is “best”; each optimizes for a different audience.
$ uv run cascade briefing --output today.md
$ head -50 today.md
# Cascade Daily Digest — 2026-04-15
## Headlines
- 12 new entities ingested overnight
- 8 new high-confidence relations
- 3 claims flipped TRUE → BOTH
- Top cascade impact: Iran → crude oil → ...
## New Developments
...
## What to Watch (LLM synthesis)
The convergence of [X] and [Y] suggests...
...Three questions answered:
1. Is the data-only summary useful as-is? Mostly yes for daily skim — counts, top entities, truth-flip events. The headlines + new developments sections are what changed since yesterday — directly useful for an analyst’s morning review.
2. Does the LLM section add insight or just paraphrase? Mixed. On busy news days it genuinely synthesizes patterns the analyst would have to read all 12 entities to spot. On quiet days it pads with generic statements (“continue to monitor…”). The cost-benefit is worth it for weekly publication; daily can feel padded.
3. Where would you edit before publishing?
The briefing is a first draft, not a final product. The pipeline’s value is reducing daily writing from 1-2 hours to 15 minutes of editing — not eliminating the analyst.
Sample answer:
Mode: Networked single-tenant (§52.1 middle tier).
Hardware: One EC2 t3.medium instance (2 vCPU, 4 GB RAM, ~$30/mo) running Cascade in a Docker container. Sufficient for ~50 articles/day plus the dashboard.
Daemons (all on the EC2):
Restart=on-failure).Team auth: Dashboard set-password for each analyst (3 passwords). For higher-security needs, add an OIDC layer (Okta or Google) in front via nginx.
Daily briefing: Cron entry at 06:00 UTC to run
cascade briefing daily --output briefings/today.md, then a
Python script emails the file to the team’s distribution list.
~$0.30/day in LLM cost ≈ $9/mo.
Monthly cost estimate:
Comparable products (managed intelligence dashboards) charge $500-2000/month per analyst — so this is 10-100× cheaper at the cost of running your own infra.
Sample walkthrough for a corrupted cascade.db:
Source of truth depends on what was running:
cascade sync pull --all reconstructs the entire
cascade.db from the Postgres mirror. Recovery
time for 10k entities: ~10 minutes.sources
table (if recoverable) or from the URLs stored in
~/.cascade/ingested_urls.log. Recovery
time for 10k entities: ~1-2 hours and ~$10-50 in LLM cost.What’s lost forever vs recoverable:
| What | Recoverable? |
|---|---|
| Entities, relations, claims | Yes (via re-ingest or Supabase) |
| Truth-flip history | Yes if Supabase synced; lost otherwise |
| Manual analyst edits | Lost unless the change_log was synced |
| Pinned items | Yes if synced; lost otherwise |
| Reactor’s rule-firing log | Acceptable to lose |
The lesson: Supabase sync is your disaster-recovery insurance. The marginal cost ($25/month for Supabase Pro) buys you full recoverability. Skipping it means each local corruption is a fresh re-ingestion run.
Procedure for production recovery:
cascade.db aside.cascade sync pull --all.cascade stats against the pre-corruption
baseline.Total downtime: ~15-30 minutes if Supabase synced.
Sample answer — Backup-and-restore drill:
The §52.9 punch list covers operational practices but says nothing about practicing recovery. A monthly drill:
cascade.db and Supabase to a
separate location.entities table).cascade stats and spot-checks on key entities.Why it matters: untested backups are hypothetical backups. Without a drill, the first time you find out your recovery procedure is broken is during an actual disaster.
The cost is one analyst hour per month. The benefit is confidence that your recovery works. For any system with operational criticality (publication deadlines, customer SLAs), this is mandatory; for hobby deployments, optional.
Other gaps worth considering:
The general principle: operational practices compound. Each one prevents a class of failure mode. Together they’re what makes “production” different from “running on a laptop.”
Production deployments often automate the inventory: a daily cron job
emails the team the previous day’s stats. Drift detection then catches
anomalies — sudden drops in ingestion rate, sudden spikes in
sync_outbox size, sudden errors in reactor.
Most knowledge-graph tools lock you into their own visualization. Cascade’s commitment to portable exports (GraphML, GEXF, Cypher, DOT) means you can take your KB anywhere. This matters for academic citation and for escape-hatch portability (“if I stop using Cascade tomorrow, I can still use my data”).
The deeper lesson: the briefing pipeline isn’t about
producing prose; it’s about focusing attention. An
analyst reading 50 raw events from change_log would miss
the patterns; the briefing reduces the cognitive load to “what changed;
what matters; what to watch.”
This is the synthesis layer of operational intelligence work. Cascade automates the mechanical parts (counting, ranking, citing); the analyst’s value-add is the narrative overlay.
A 3-analyst team for $100-150/mo is the breakeven point against managed services. Any team smaller (1-2 people) probably doesn’t need this infrastructure; any team larger (5+) is probably hitting the Cascade limits documented in §52.10 and should consider sharding or upgrading to enterprise tier.
The temptation is to skip Supabase sync for a “simple” deployment. The cost-benefit:
The expected-loss calculation: if you expect one corruption over a year, you save money and time with sync. The lower bound is breakeven; the upper bound is “you’ll thank yourself” — production deployments overwhelmingly recommend sync.
The progression of operational maturity:
Cascade today supports Level 2-3 deployments out of the box; reaching Level 4 requires the multi-tenant work Cascade itself is still maturing on. Most readers will deploy at Level 2-3; that’s plenty for serious work.
#language axioma/hm
id: func(x) [x]
expect("id int", id(1), 1)
expect("id bool", id(true), true)id :: a -> a
Commentary. id does not inspect
x, so nothing pins a. Each expect
is a separate instantiation. That is let-polymorphism in the smallest
possible file. If you wrote two functions id_int and
id_bool, you would have missed the point: one generalized
binding, two uses.
println would refuse
(println has no known type here). expect is in
the catalog and types as (String, a, a) -> () — the
actual and expected values must agree with each other, which is why the
assertion itself is typed.
#language axioma/hm
greet: func(name) [str_concat("hello, ", name)]
expect("greet", greet("Ada"), "hello, Ada")greet :: String -> String
Commentary. Host Axioma would have accepted
"hello, " + name. The island types + as
numeric, so that spelling is a rejection
(string and array concatenation use str_concat and array_concat).
The catalog function both concatenates and tells the type that
both sides are String, which is how name
becomes String without an annotation.
#language axioma/hm
func pow(b, 0) [1]
func pow(b, n) when n > 0 [b * pow(b, n - 1)]
expect("pow 2 10", pow(2, 10), 1024)
expect("pow 3 4", pow(3, 4), 81)pow :: (Integer, Integer) -> Integer
Commentary. A multi-clause group is one function.
The literal 0 grounds the second parameter at
Integer; * and - ground the
first. Recursion is monomorphic: every call to pow is at
that one pair of types. Compare fib in §54.6 of the chapter
— same pattern, one argument.
7 / 2 is still illegal here. Integer shrinking uses
n - 1 and div is not required for this
body.
#language axioma/hm
nums: [-(2), -(1), 0, 1, 2, 3]
positives: filter(func(n) [n > 0], nums)
doubled: map(func(n) [n * 2], positives)
expect("doubled positives", doubled, [2, 4, 6])Commentary. -(2) is unary minus
(Chapter 1); writing -2 inside an array is easy to mis-read
against subtraction. filter keeps an
Array of Integer; map sends it to another.
Neither function is recursive in your code — the catalog
already recurses. That is the Chapter 10 move, now inside a checker that
will refuse a heterogeneous nums.
#language axioma/hm
expect("concat", str_concat("a", "b"), "ab")
expect("div", 7 div 2, 3)
expect("if", if true then 2 else 3, 2)
x: 1
y: 2
expect("two names", x + y, 3)Commentary. Four host habits, four island spellings:
+ on strings → str_concat/ on integers → div (the result stays
Integer)1 → a real Booleanx → a new name yThe last is the Chapter 2/16 convention inverted. On the host,
x: 2 after x: 1 is an update. On the island it
is re-binding x, and x = 2 is
reassignment. Both are refused. Two names is not a
workaround for mutation; it is the program you actually meant (two
values).
One answer, compose at two types:
#language axioma/hm
compose: func(f, g) [func(x) [f(g(x))]]
inc: func(n) [n + 1]
double: func(n) [n * 2]
notb: func(b) [not b]
h: compose(double, inc)
expect("compose num", h(3), 8)
expect("compose bool", compose(notb, notb)(true), true)compose :: (a -> b, c -> a) -> c -> b
inc :: Integer -> Integer
double :: Integer -> Integer
notb :: Boolean -> Boolean
Another, product via foldl:
#language axioma/hm
product: func(xs) [foldl(func(acc, n) [acc * n], 1, xs)]
expect("product empty", product([]), 1)
expect("product", product([2, 3, 5]), 30)product :: Array[Integer] -> Integer
What the type made impossible. On the host,
product(["a", "b"]) might concatenate-repeat or error at
run time, depending on how you wrote *. On the island the
foldl body pins n to Integer, so
a string array is a type error before any element is visited.
That is the whole reason to step onto the island: the bad call does not
become a skip, and it does not become a value.
The book ends, but the field is enormous. This appendix points at the most worthwhile next reads, organized by what they extend.
Felleisen, Findler, Flatt, Krishnamurthi — How to Design Programs (2nd ed., MIT Press, 2018). Free online at https://htdp.org/. The book this port follows. Reading the original after this one is the natural next step: you’ll see the same design recipe applied to the same kinds of problems, in a different surface language (Racket / BSL). The contrast is itself an education in what’s accidental to a language and what’s essential.
Abelson and Sussman — Structure and Interpretation of Computer Programs (2nd ed., MIT Press, 1996). Free online at https://mitp-content-server.mit.edu/books/content/sectbyfn/books_pres_0/6515/sicp.zip/index.html. “SICP” is the deeper, harder cousin of HtDP. Same family (Scheme), same recipe-style thinking, but pushed all the way to a full meta-circular evaluator, streams, register machines, and compilers. Read it after this book if Ch.22 left you wanting more.
Friedman and Felleisen — The Little Schemer (4th ed., MIT Press, 1995). A small book that teaches structural recursion through a Socratic dialog. Companions: The Seasoned Schemer and The Reasoned Schemer (the latter introduces logic programming the same way Ch.21 does — facts and rules first, syntax second).
The Axioma Manual —
resources/manual/Axioma Manual.md in this repository, also
exported to HTML and PDF. The exhaustive reference for every language
construct, including ones this textbook deliberately skipped:
Treat the manual as a lookup — you don’t read it end-to-end, you flip to it when you’ve forgotten exactly how some form works.
Pierce — Types and Programming Languages (MIT Press, 2002). The standard graduate-level introduction to type systems. If you found yourself wishing the type system in Ch.19 were more powerful, this is the book that explains how to build one. Covers simply-typed lambda calculus, subtyping, parametric polymorphism, recursive types, and dependent types. Difficult but worth it.
Sipser — Introduction to the Theory of Computation (3rd ed., Cengage, 2012). Automata, formal languages, Turing machines, computability, NP-completeness. Pairs naturally with Ch.22 — once you’ve built an interpreter, you start wondering what’s the upper limit of what an interpreter can do, and this book answers that.
Mitchell — Concepts in Programming Languages (Cambridge, 2002). A survey of language design decisions: type-checking strategies, scoping rules, evaluation orders, object models. After this book, you’ll understand why Axioma made each choice it did; Mitchell shows you the landscape of choices that were available.
Bratko — Prolog Programming for Artificial Intelligence (4th ed., Pearson, 2011). The classical Prolog textbook. Chapter 21 of this book ports a slice of Prolog’s capabilities; Bratko shows you the rest — pattern matching with backtracking, custom unification, constraint logic programming, expert systems.
Antoniou and van Harmelen — A Semantic Web Primer (2nd ed., MIT Press, 2008). Where logic programming meets the real-world web. RDF, OWL, SPARQL, and the rule-based reasoners that power knowledge graphs at scale.
Belnap — “A Useful Four-Valued Logic” (in Modern Uses of Multiple-Valued Logic, Reidel, 1977). The original paper introducing B4. Short and readable; the foundation of Ch.20’s paraconsistent material.
Łukasiewicz — Selected Works (North-Holland, 1970). The collected papers introducing many-valued logic, including L3. Of historical interest; the Wikipedia article on many-valued logic is a faster way in.
Nystrom — Crafting Interpreters (self-published, 2021). Free online at https://craftinginterpreters.com/. The modern successor to SICP’s evaluator chapter. Builds two full interpreters (one tree-walking, one bytecode) for a toy language. Excellent companion to Ch.22 — same architecture, two orders of magnitude more code.
Appel — Modern Compiler Implementation in ML (Cambridge, 1998). If “tree-walking interpreter” left you wanting to build a compiler, Appel is the bridge. Lexer, parser, type-checker, intermediate representations, register allocation, code generation. Available in ML, Java, and C flavors.
Cooper and Torczon — Engineering a Compiler (3rd ed., Morgan Kaufmann, 2022). A more practical, more recent take on compiler engineering than Appel. Less theory-heavy, more focused on the choices that actually matter in production compilers.
Polya — How to Solve It (Princeton, 1945). The patriarch of all problem-solving books. The Design Recipe is partly Polya’s “understand the problem → devise a plan → carry out the plan → look back” applied to programming. Twenty pages of Polya rewards a lifetime of programming.
Knuth — The Art of Computer Programming (multiple volumes, Addison-Wesley, 1968–present). The encyclopedia. You don’t read TAOCP; you consult TAOCP when you need to understand an algorithm in real depth. Pairs well with Chapter 14 (generative recursion) and Chapter 15 (accumulators).
Hofstadter — Gödel, Escher, Bach (Basic Books, 1979). Not a programming book, but a book about the structures underneath programming — self-reference, recursion, formal systems, meaning emerging from rules. If Chapter 22’s meta-circular evaluator delighted you, GEB is the next delight.
The Racket forum — https://forum.racket-lang.org/ — remarkable community discussion of HtDP and the Lisp/Scheme tradition more broadly. Even when the answer involves a Racket form Axioma doesn’t have, the thinking transfers.
lambda-the-ultimate.org — long-running blog and forum covering programming-language research. Slower-paced but deeper than most language communities.
Programming Language Theory wikis — the Wikipedia articles for operational semantics, denotational semantics, lambda calculus, Hindley–Milner type inference, and Curry–Howard correspondence are unusually well-written and a fine free introduction to each topic.
Don’t read all of these. Pick one based on what you found most interesting in this book:
| If Chapter NN delighted you… | …go to |
|---|---|
| 14 (generative recursion) | TAOCP, Cormen Introduction to Algorithms |
| 16 (mutation, state machines) | Concurrency: State Models and Java Programs, Magee & Kramer |
| 17 (streams) | SICP Chapter 3, Why Functional Programming Matters, Hughes 1989 |
| 19 (enums, subranges) | Pierce TAPL, Cardelli’s Type Systems survey |
| 54 (closed Hindley–Milner island) | Pierce TAPL ch. 22 (type reconstruction); the Wikipedia article Hindley–Milner type inference; Damas & Milner 1982 |
| 20 (multi-valued logic) | Belnap, Stanford Encyclopedia of Philosophy article on “Many-Valued Logic” |
| 21 (logic programming) | Bratko, Concepts, Techniques, and Models of Computer Programming (CTM) |
| 22 (meta-circular evaluator) | Nystrom Crafting Interpreters, SICP §4 |
Most languages, most ideas, most paradigms — they’re all variations on the small set of moves you now know. Go explore.
“You can know the name of a bird in all the languages of the world, but when you’re finished, you’ll know absolutely nothing whatever about the bird… So let’s look at the bird and see what it’s doing — that’s what counts.” — Richard Feynman.
This appendix is the lookup card for Axioma’s introspection and debugging machinery — the tools that let you peer inside a running program. Most of these were skipped in the main chapters to keep the pedagogical line clean; they live here so you know they exist when you need them.
The book covers many of these in passing — this appendix pulls them together in one place.
| Form | What it does | Where it’s at home |
|---|---|---|
println(x) |
Print x + newline |
Scripts; clear and explicit |
println(x, y, z) |
Print multiple values space-separated | Multi-arg output |
print(x) |
Print without newline | Building lines incrementally |
expr . |
Trailing-dot inspect sigil — evaluate and print | Scripts, blocks, anywhere |
inspect(x) |
Print name = value form |
Variable inspection |
The REPL prints results automatically; scripts don’t. Use
println for explicit, named output, and the trailing
. for quick “show me this” probes — particularly useful
when you want to peek at intermediate values without rewriting a
line.
The trailing . works at the top level and
inside any [...] block — while-loop bodies, if-then/else
branches, function bodies, lambda bodies. That makes it the most
lightweight tracing tool Axioma has:
total: 0
i: 1
while (i <= 5) [
i . # show me i each iteration
total = total + i . # show me total each iteration
i = i + 1
]
total . # show me the final totalThis prints 1, 1, 2, 3, 3, 6, 4, 10, 5, 15, 15 — every
intermediate value, no println(...) calls cluttering the
code. Delete the dots when you’re done debugging, and the program runs
silently again.
For deeper tracing — full call/return logging across a recursive
function — see the trace statement below.
| Form | Returns |
|---|---|
type(x) |
The runtime type Concept, such as Integer,
String, or Array; compare with
type(x) == Integer, not a String |
x is Concept |
Whether x is an instance of Concept (also
walks is chain) |
x is Type |
Type predicate (alternative form) |
Examples:
type(42) # Integer
type([1, 2, 3]) # Array
type({1, 2, 3}) # Set
type("hi") # String
type(func(x) [x*x]) # FunctionType-of is essential for defensive functions that handle multiple input types, and for debugging when a value isn’t what you expected.
Since July 2026 the reference lives in the runtime: the language answers its own reference questions, so “check the manual” is the second resort, not the first. One row per question:
| Question | Ask | Notes |
|---|---|---|
| What is this word or type? | doc Integer, doc(word),
doc("if") |
full type cards for the 12 core types; a real statement, script-legal |
| What is this value? | describe(x) |
per-type fact card (parity/digits, bytes-vs-runes, element types, …) + pointers onward |
| Where is anything about …? | apropos("term") |
searches names AND documentation text |
| What exists at all? | builtins(), concepts(),
bindings(), keywords() |
sorted catalogs; 1-arg forms are membership checks |
| What can I do with a type? | functions(Integer) ≡ methods(Integer) |
curated, verified per-type builtin catalog |
| How do I call this? | signature(f), arity(f),
parameters(f) |
spec-backed for builtins, user functions too |
| What is this function’s definition? | source(f) |
reconstructed source String — eval(source(f))
round-trips |
| What does this source parse to? | ast("src") |
parse a runtime string without running it |
| Does this compile to the VM? | compile(src) |
callable on success; catchable Error names the boundary |
A ninety-second tour (every line runs):
describe(42) # Value/Type/Parity/Sign/Digits + pointers
signature(round) # "round(x, [digits])"
first(functions(Integer), 5) # ["abs", "add1", "bin", "bit_not", "choose"]
double: func(x) [x * 2]
source(double) # "double: func(x) [(x * 2)]"
eval(source(double))(21) # 42 — the definition round-trips
ast_string(ast("a + b * c")) # "(a + (b * c))" — parsed, not run
apropos("shuffle") # every name + doc line mentioning itThe split to remember: doc documents names,
describe inspects values, source returns
definitions, ast parses, compile
compiles. The '(…) quote (next section) is the
lexical spelling of code-as-data; ast() is its
runtime twin.
Quote/parse give you the AST as data. To see what’s inside, use the rendering family:
| Form | Output |
|---|---|
fullform(ast) |
Mathematica-style prefix form:
If(>(x, 0), *(x, 2), -(x)) |
treeform(ast) |
Vertical ASCII tree (default) |
treeform(ast, "horizontal") |
Horizontal ASCII tree |
headof(ast) |
The operator/keyword at the root: "+",
"If", "sqrt" |
argsof(ast) |
Array of immediate children as strings |
hold(expr) |
Capture an AST without evaluating it (like quote) |
ast_type(ast) |
Node-kind label: "InfixExpression",
"IfExpression" |
ast_string(ast) |
Source-style rendering (close to how you wrote it) |
ast_eval(ast) |
Evaluate the AST and return the value |
Example session:
a: parse("if x > 0 then x * 2 else -x")
fullform(a)
# If(>(x, 0), *(x, 2), -(x))
treeform(a)
# If
# ├── >
# │ ├── x
# │ └── 0
# ├── *
# │ ├── x
# │ └── 2
# └── -
# └── x
headof(a) # "If"
argsof(a) # [">(x, 0)", "*(x, 2)", "-(x)"]
ast_type(a) # "IfExpression"fullform and treeform are the two
views of the same tree — pick whichever reads better for the
expression you’re looking at. fullform is compact (good for
short ASTs and for comparing structure:
fullform(2 + 3) == fullform(+(2, 3))).
treeform shows the recursive shape clearly (good for deep
nested expressions where the prefix form gets line- wrapped). They’re
both implemented as one-pass walks over the AST.
fullform / headof / argsof
read an AST. A second family rebuilds and
rewrites it — using the same uniform head[args]
shape that Mathematica is built on.
| Form | What it does |
|---|---|
head(ast) |
operator/functor as a String — uniform across infix/prefix/postfix/call/statement |
operands(ast) |
Array of AST — the arguments, functor excluded (like
argsof, but AST not strings) |
make_expr(h, ops) |
rebuild a node from a head + operands Array — the inverse |
match_pattern(pat, subj) |
structural match of a ?x-pattern → bindings Dictionary
or none |
subst(tmpl, bindings) |
fill a template from bindings |
replace_all(subj, rules) |
bottom-up rewrite to a fixpoint |
rules(p1, t1, …) |
flat-pairs sugar for a rule list |
head('(2 + 3)) # "+"
operands('(2 + 3)) # [<AST: 2>, <AST: 3>]
make_expr(head('(2 + 3)), operands('(2 + 3))) # <AST: (2 + 3)> — round-trips
match_pattern('(?a + ?b), '(2 + 3)) # {a: <AST: 2>, b: <AST: 3>}
replace_all('(x + 0), rules('(?a + 0), '(?a))) # <AST: x> (additive identity)Patterns reuse the ?x variable syntax; a rule’s optional
third element is a guard (Mathematica’s
/;), and ?xs__ / ?xs___ match
variable-arity argument runs. That’s enough to write a symbolic
differentiator or algebraic simplifier as a rule list — see Chapter 22
(the meta-circular evaluator) and Manual §19.9 for the worked CAS.
A quoted form also behaves as the sequence of its operands, so generic collection code traverses code like data:
len('(2 + 3)) # 2
'(add(7, 9))[1] # <AST: 7>
[op | op <- '(a + b)] # comprehension over a formTwo converters cross between code and value:
to_data('(2 + 3)) # 5 — evaluate a form to its value
from_data(5) # <AST: 5> — lift a value into an AST literalfrom_data is the friction-free way to turn any value
(scalar, array, set, tuple) into an AST, sidestepping the
'(…) quote-form bracket gotchas.
factorial: func(n) [
if n <= 1 then 1 else n * factorial(n - 1)
]
trace func control
factorial(3)
# → Evaluating Call Expression
# → Applying Function
# → Evaluating If Expression
# condition_result: false
# is_truthy: false
# executing: alternative branch
# → Evaluating Call Expression
# → Applying Function
# ...
# ← Function Application: 1
# ← Call Expression (user function): 1
# ← If Expression: 2
# ← Function Application: 2
# ← Call Expression (user function): 6Tracing is a statement, not a call:
trace <domain> switches on logging for a whole
class of operation, and untrace switches it off.
trace func covers function application and
trace control covers if/while/
foreach, so together they print the full call/return chain
with depth-aware indentation. Name several in one statement, as above,
to widen the trace; untrace func control mirrors it. A typo
rejects the whole statement rather than half-applying it.
The other domains follow the same shape — trace binding
(every value as it is assigned), trace sets,
trace comprehension, trace epistem,
trace quantifiers, trace relations (queries
and answer counts), trace reasoning (rule firing),
trace concepts, trace stack, and
trace all. Add the /verbose refinement
(trace/verbose func) for bound variables, condition
sources, and result types; /debug for internal state.
Note that Axioma selects by domain, not by named
function: there is no per-function trace(factorial). In
call position trace(m) is the linear-algebra matrix trace
(the sum of a matrix diagonal), which is a different facility
entirely.
Each step shows:
→ enter, ← returnCall Expression,
If Expression, Function Application,
etc.)condition_result, is_truthy, branch
chosen)This is the structured-trace equivalent of
print-debugging: you can see exactly what the evaluator
did, not just end-to-end inputs and outputs. Particularly valuable for
debugging recursion (where the call stack is the interesting part) and
for teaching — students can read the trace output and follow
Felleisen’s “design recipe in motion.”
To stop tracing, write untrace (bare, to clear every
domain) or untrace func (to clear just that one).
Better still, don’t stop it by hand. Give trace a
bracket block and it traces only that region, restoring whatever was in
force when it exits:
trace func [ result: factorial(3) ]The domain is optional here too — trace [ ... ] traces
every domain for the block’s extent. Because the block restores rather
than switching off, one nested inside an outer trace leaves
that outer domain running; and because it opens no scope, bindings made
inside it survive. That second property is the one that matters for a
debugging tool: wrapping code in trace must never change
what the code does.
The transcript outlives the block, so you can assert on it after the fact:
clear_trace_log()
trace binding [ total = 7 ]
trace_log() # → [" total = 7"]When you’re at the axioma> prompt, the colon-prefixed
commands are meta-operations — not Axioma syntax, but commands
to the REPL itself.
| Command | Effect |
|---|---|
:help |
List available REPL commands |
:exit |
Quit the REPL (also :q) |
Restart with --no-image |
Start a clean environment |
:save filename |
Save the session to a file |
:load filename |
Load a previously-saved session |
:stack (or :s) |
Show the current interpreter stack |
:stack trace |
Toggle automatic stack tracing |
doc <name> |
Show the docstring for a builtin or user-defined name |
Example:
axioma> doc parse
Word: parse
Definition: Built-in function: parse
Source: builtin
Type: BUILTIN
Value: builtin function: parse
Signature: parse(code, [mode])
Documentation:
Low-level parse of a source String to AST; mode "expression" (default — FIRST statement only), "statement", or "program" (ast() is the no-truncation front-end). Not a typed value reader — that is parse_as(Integer, s) / Integer.parse(s).
The doc command is your fastest path from “what does
this function do?” to a usable answer.
A Concept (Russell-style class) prints itself in a one-line summary when you ask for its value directly:
concept Animal
Mammal extends Animal
Dog extends Mammal
println(Dog)
# concept Dog extends Mammal { properties: cardinality: ∞, doc: "concept of Dog" }The output shows the parent (via extends), declared
properties, cardinality, and any attached documentation. For deeper
inspection:
| Form | Purpose |
|---|---|
x is Concept |
Does x belong to this Concept (walks the chain)? |
x.slot |
Read a slot value |
x's slot |
Same, possessive form |
(Note: the natural-language forms
Concept show properties and
Concept display hierarchy only apply to conceptual
graphs — a separate Axioma construct that the textbook doesn’t
cover — not to ordinary Russell-style concepts. For the latter, use
println(Concept) and the is / is
predicates.)
Chapter 21 covers the basics. The full toolkit:
| Form | Purpose |
|---|---|
grounding("rel", args...) |
The grounding level of a stored fact ("axiom",
"theorem", "conjecture", "datum",
etc.) |
truth("rel", args...) |
The Belnap B4 value attached to a fact (or
"unknown") |
set_truth("rel", args..., "true"/"false"/"both"/"neither") |
Annotate a fact with a B4 value |
proof("rel", args...) |
Array of (fact_string, grounding, depth) tuples walking
back to axioms |
why fact() |
Keyword form — print a human-readable explanation |
challenge("rel", args...) |
Mark an axiom as suspect (Q1 requirement) |
challenged("rel", args...) |
Check if a fact has been challenged |
cancel("rel", args...) |
Suppress a defeasible conclusion (canonical exception pattern) |
uncancel("rel", args...) |
Remove a cancellation marker |
canceled("rel", args...) |
Check if a fact is canceled |
When you want to ask about facts, not just for them:
| Form | Returns |
|---|---|
predicates_of(X) |
All facts mentioning X in any position, any
arity: [(rel, args_tuple, grounding), ...] |
predicate_names() |
The set of all relation names with at least one fact |
?P(X) |
Per-position arity-specific meta-call (P is a
PatternVariable). Like predicates_of but with a fixed arity
and bound position |
?P(X, ?Y) |
2-arity facts starting with X, Y free |
?P(?X, "carol") |
2-arity facts ending with "carol" |
The ?Name form uses Axioma’s existing pattern-variable
parser (Ch.21). The evaluator intercepts a CallExpression
whose head is a PatternVariable and routes to a meta-call handler.
Beyond the static fact base, Axioma supports runtime mutation:
| Form | Effect |
|---|---|
insert("rel", args..., [grounding]) |
Assert a new fact at runtime; grounding defaults to
"datum" |
forget("rel", args...) |
Retract a fact from all grounding buckets |
transaction_begin() |
Open an atomic mutation block |
transaction_commit() |
Persist mutations since begin |
transaction_rollback() |
Undo mutations since begin |
cardinality(Concept, "prop", min [, max]) |
Register a min/max bound on a concept property |
forget rather than delete or
retract because both of those are reserved as keywords for
separate features. The transaction trio gives you atomicity for groups
of mutations — useful when “this set of changes should all succeed or
none of them should.”
none and omAxioma has two null-like values with different semantics — a distinction the book mentions only in Chapter 20 (Ω as Kleene unknown). Here’s the full picture:
| Value | Spelled | Meaning | Truthiness | Display |
|---|---|---|---|---|
none |
none |
Absent value, of type None |
falsy | none |
om |
om, Ω |
SETL-inspired unknown / undefined — value exists but its identity is not yet determined | falsy | Ω |
The old spelling null is retired and produces a syntax
error.
Not interchangeable. om == none returns
false. Use none for “this slot was never set.”
Use om for “this slot has a value but we don’t know what it
is yet” — database unknowns, partially-known data, K3 propagation.
The K3 unknown shorthand om (used throughout Chapter 20)
is the same value — it just doubles as the Kleene three-valued unknown
when the operator dispatcher sees it.
postulate and axiom| Keyword | Meaning |
|---|---|
axiom <name>: ... |
Foundational truth, accepted by the system |
postulate <name>: ... |
Tentative claim, may be verified or refuted |
axiom/persist <name>: ... |
With refinement — persist across sessions |
axiom/transient <name>: ... |
With refinement — session-local only |
declare/persist x = ... |
Persistent variable (writes to
.axioma_session.json) |
declare/transient x = ... |
Session-local (default for scripts) |
The refinement system lets you control persistence on a per-binding basis. REPL mode persists by default; script mode is transient by default.
A practical rule of thumb for which tool to reach for:
| Situation | Use |
|---|---|
| One-off “what is this value right now?” | expr . |
| Production logging | println(...) |
| Tracing a function call chain | trace func … untrace |
| Looking at AST structure | fullform (compact) or treeform (deep) |
| Debugging a logic-program derivation | why <fact> |
Auditing which facts mention X |
predicates_of(X) |
| Probing runtime types | type(x) |
| “I forgot what this builtin does” | doc name — or apropos("…") when you forgot
the name too |
| “What IS this value?” | describe(x) |
| “What can I do with this type?” | functions(Integer) (alias methods) |
| “How do I call this?” | signature(f) |
| “Show me that function’s definition” | source(f) |
| Stepping the global interpreter stack (REPL) | :stack |
| “What’s the type of this unannotated function?” | axioma --infer (Ch.54); on the island, that is the
checker |
The . and trace cover most day-to-day
debugging. Everything else is for the rare-but-important cases —
metaprogramming, KB-fact auditing, AST manipulation. Keep this page
nearby and reach for the right tool.
--infer and axioma/hm--typecheck (Chapter 19) honors annotations you wrote.
--infer reads an unannotated body. The two jobs
are easy to mix up:
axioma --infer file.ax on axioma/all |
#language axioma/hm |
|
|---|---|---|
| Job | print an arrow, or a skip reason | type the whole file |
| Untypable | skip, exit 0 | refuse, exit 1 |
println |
“not inferred (world)” | no known type — fatal |
On an island file, --infer is the
run-time checker, not a second opinion. Chapter 54 is the tutorial; the
Manual section #language axioma/hm — the closed
Hindley–Milner island is the reference. inferred(f) on
the host is the same lens as --infer for one function (it
returns none when the body is outside the fragment).
“To find a fault is easy; to do better may be difficult.” — Plutarch, Moralia. Axioma’s introspection tools won’t write your code for you, but they’ll show you exactly where the trouble is.
The running language can describe its own vocabulary. Use local help when checking a spelling or an available operation:
signature(map)
describe([1, 2, 3])
oracle Float
manual "ranges"oracle supplies local vocabulary hints;
manual searches or reads the manual embedded in the binary.
Neither needs a model call. The REPL can enable unknown-word hints with
:oracle on; oracle/on is the source form. An
installed binary’s embedded help can lag a newer website, so record the
binary revision when reproducing an example.
ask is different: it contacts a configured model
provider and can send context about the user’s words and definitions. It
returns a suggestion, not a proof and not an automatic language
extension. Use that route deliberately; local help and ordinary
evaluation never need to invoke it.
A command literal groups process arguments without executing them. Interpolated text remains one argument, including embedded spaces.
report_name: "quarterly report.txt"
job: c"printf '%s' ${report_name}"
println(job.executable) # printf
println(job.args) # ["printf", "%s", "quarterly report.txt"]On a system providing printf, os.run(job)
executes that command and returns captured stdout,
stderr, and code. A nonzero exit status is
data in code; failure to start or a timeout is an Error.
Commands do not expand shell wildcards, variables, or redirections.
When a shell pipeline is the intended program, an explicit shell block runs it immediately. This example uses Unix shell syntax:
sorted_lines: [shell |
printf 'pear\napple\n' | sort
]
println(sorted_lines.stdout)
# apple
# pear
println(sorted_lines.code) # 0This | belongs to the shell; Axioma’s |>
passes a value to a function. Shell blocks do not implicitly capture
Axioma variables. Prefer Command arguments for text supplied as data.
Filesystem operations live under io and
working-directory/process operations under os; a relative
path uses the process working directory, not automatically the source
file’s directory. Browser builds can construct command values but cannot
launch local processes. Run the shell example only in a local
environment.
The proof library distinguishes a formula, a proposed derivation, and a checked theorem. Certification checks the steps and the requested goal; it does not certify an arbitrary record merely because its field says “theorem”.
import "proof"
atom_a: kpred("A", [])
atom_b: kpred("B", [])
checked: certify([
assume(kand(atom_a, atom_b)),
andEL(1),
impI(1, 2)
], kimp(kand(atom_a, atom_b), atom_a), [])
println(proved(checked)) # true
println(len(hyps(checked))) # 0This proves (A ∧ B) → A with no undischarged
assumptions. A proof that retains assumptions is conditional; those
assumptions must remain visible. An invalid proof is rejected with a
reason.
Certification owns a private copy of the conclusion and assumptions. Changing the original formula data afterward does not alter what was proved. Accessors return detached data, so editing an accessor result cannot rewrite the theorem either. This protection is different from making all arrays or dictionaries immutable. A relation engine’s derived fact, a neural suggestion, and a sealed checked theorem also have different justifications; never substitute one for another based on a similar label.
What this appendix is. Axioma is a learning language — most of its readers arrive already knowing one or more other languages. The cross-language translation feature is built for that audience: it lets you look at a Python (or JavaScript, Julia, R) snippet next to its Axioma equivalent, inside the language, without leaving your editor. Three operators and one builtin do all the work.
You’ve already seen [python | …] in the polyglot interop
chapter — it executes foreign code from inside an
.ax file. Two arrow-form siblings sit next to it:
| Form | Direction | What you get back |
|---|---|---|
[L | body] |
execute body in L | the foreign runtime’s value |
[A -> B | body] |
translate body from A to B | a String of B’s source |
[A --> B | body] |
translate, then execute | typed value (or scope leak) |
[nl -> B | description] |
symbolize a description into B | a String of B’s source |
[nl --> B | description] |
symbolize + execute | typed value |
Read the arrows as “more arrow = more action.” Zero arrows runs. One arrow renders source. Two arrows goes all the way through.
-> arrowsrc: [axioma -> python | [n*n | n <- range(10)]]
println(src)
# [n * n for n in range(10)]The body is read raw (multi-line, embedded brackets, indentation all preserved — the same reader the execute form uses). No foreign runtime is touched; what comes back is a String holding the equivalent target-language source. Useful when you want to see the translation, not run it — copy it into a Python file, compare two languages side by side, then test that the translation preserves the behavior you require.
--> arrowsum_sq: [axioma --> python | sum([n*n | n <- range(1, 11)])]
# → 385 (Python computed it; marshaled back as Axioma Integer)Same Axioma input, but now the translated Python actually runs in Python’s runtime and the typed result lands back in Axioma. Useful when you trust Python’s library for a specific operation but want the surrounding code to stay in Axioma.
A particularly nice variant is
B == "axioma" — the translated source is
parsed and evaluated in the current scope, so definitions leak
into the surrounding program:
[python --> axioma | def double(x): return x * 2]
println(double(5)) # → 10You can lift small functions, comprehensions, or whole modules out of a Python tutorial and continue writing Axioma against them without manually rewriting.
nl sourceThe source-language slot also accepts nl (alias
english, natural), which treats the body as a
natural-language description rather than source code. The LLM
symbolizes it into idiomatic target-language code:
# Just show me the syntax
src: [nl -> axioma | double the value 7]
println(src)
# double: 7 * 2 (the LLM picks the Axioma form)
# Just give me the value
v: [nl --> axioma | sum of squares from 1 to 10]
println(v)
# 385 (Axioma value after Eval)This is the closest Axioma has to a “look it up in the textbook” interface from inside the language. Useful when you have an intent in mind (“I want to filter the even numbers”) but don’t yet know the construct Axioma uses for it.
The catch: nl is always LLM-required.
There’s no deterministic path for natural language → code, so every call
hits the announce-print billing line and burns API budget. The
--> variant also inherits the LLM’s non-determinism — if
the model leans on training data from another language and emits a
foreign idiom (Elixir’s |> pipe-forward, Rust’s
let mut, Haskell’s <- in a do-block), the
resulting Axioma source won’t parse. Re-run, or fall back to writing the
syntax by hand once you’ve learned it.
The block form needs its body to be a literal in your source. For code that lives in a String variable (read from a file, pulled from an API, copy-pasted at runtime), reach for the function-call counterpart:
py_src: read_file("script.py")
ax_src: translate(py_src, "python", "axioma")
# Forward direction works on AST expressions too:
py: translate([n*n | n <- range(10)])
# → "[n * n for n in range(10)]"Signature is translate(code, source_lang, target_lang) —
English “from X to Y” order. Defaults: source="axioma",
target="python", so translate(EXPR) is enough
for the common Axioma → Python case.
Translation is hybrid:
println, calls. Output is byte-for-byte deterministic, runs
entirely offline, and requires no API key. Unsupported AST forms can
fall back to an LLM even in this direction; inspect the provider
announcement instead of assuming the arrow alone promises offline
execution.python -> axioma, axioma -> javascript,
and forms outside the deterministic subset — routes to an LLM. The
translated output is idiomatic but non-deterministic; you’ll
get slightly different phrasings between runs.Dedicated domain translators can have their own offline rules: Chapter 53 shows SQL-to-algebra and SQL-to-calculus translation. The general-program description here does not classify those separate domain paths.
Deterministic output also does not establish semantic equivalence.
Range endpoints, division, indexing and numeric types differ between
languages. For example, passing a call named range through
to Python does not prove that it enumerates the same endpoints as an
Axioma range. Review generated code and compare boundary cases before
using it for a calculation.
Before any LLM call goes out, Axioma prints one line to stderr so you know what’s about to be billed:
[axioma translate] python → axioma provider=openrouter model=google/gemini-2.5-flash-lite endpoint=https://openrouter.ai/api/v1 (paid API)
Local Ollama gets (local, no billing). The print is on
stderr, so it doesn’t pollute the String returned to your
script. Suppress it with AXIOMA_TRANSLATE_QUIET=1 once
you’ve chosen a provider you’re comfortable with.
The translator auto-selects the first provider whose API key is visible in the environment, in this order:
OpenRouter → Anthropic → Gemini → Grok → OpenAI → Groq → Ollama
OpenRouter sits at the top because it routes to many backend models
behind one billing account — set
OPENROUTER_API_KEY_AXIOMALANG (project-scoped, takes
precedence) or OPENROUTER_API_KEY (generic), and the
default model is google/gemini-2.5-flash-lite. To use a
different OpenRouter slug, set
OPENROUTER_MODEL=anthropic/claude-3.5-sonnet (or any other
OpenRouter model name).
If you’d rather not put a key in your shell at all, install Ollama and pull a model — the default
hardcoded is llama2, but most local setups want
OLLAMA_MODEL=deepseek-r1:7b (or whatever you actually have
installed). Local Ollama is the only provider labeled
(local, no billing) in the announce line.
| You have… | You want… | Reach for |
|---|---|---|
| An Axioma expression | the Python equivalent (just to read) | [axioma -> python | EXPR] |
| An Axioma expression | Python to compute it and give back the value | [axioma --> python | EXPR] |
| A Python snippet | the Axioma rewrite (just to read) | [python -> axioma | BODY] |
| A Python snippet | to USE it inside Axioma | [python --> axioma | BODY] |
| Source code in a String variable | any of the above | translate(src, "python", "axioma") |
| An intent in your head, no syntax yet | the Axioma source for it | [nl -> axioma | description] |
| An intent in your head, no syntax yet | just the value | [nl --> axioma | description] |
Two negative cases worth flagging:
[python --> axioma] with a multi-statement
Python body works (the translated Axioma source is Eval’d as a
program in scope). But [axioma --> python] with
multi-statement bodies doesn’t — Python’s eval()
is expression-only. For statement execution and captured stdout, use a
separate [python | …] block. Wrapping a multi-statement
body in print does not make that body a Python
expression.axioma → python path (or convert by hand).# Forward — deterministic, no LLM
src: [axioma -> python | doubled: map(func(x) [x * 2], [1, 2, 3])]
println(src)
# doubled = list(map(lambda x: x * 2, [1, 2, 3]))
# Compute via Python's runtime
result: [axioma --> python | sorted([3, 1, 4, 1, 5, 9, 2, 6])]
println(result)
# [1, 1, 2, 3, 4, 5, 6, 9]
# Pull a Python idiom into Axioma
[python --> axioma | def square(x): return x * x]
println([square(n) | n <- range(5)])
# [0, 1, 4, 9, 16]That last block — definitions from foreign code becoming first-class Axioma values you compose against — is the load-bearing trick. Translation isn’t a one-way export: it’s a two-way bridge that lets you stand inside Axioma and reach for any concept you already know, in whichever language taught it to you.
The manual covers the same material in reference form, including the
full provider table and edge cases: Axioma Manual § 27.1b. The
in-language tests in tests/axioma/translation/ are also
readable as worked examples — they exercise every operator with
byte-for-byte assertions.