Axioma Programming Language Manual
Version 0.9 · Calculemus! — Leibniz A multi-paradigm language for mathematics, logic, knowledge, and reasoning.
Calculemus! is Latin for "Let us calculate!" — the motto of the philosopher and mathematician Gottfried Wilhelm Leibniz (1646–1716), co-inventor of the calculus. Leibniz dreamed of a characteristica universalis: a formal language in which any idea could be written down exactly, paired with a calculus ratiocinator, a mechanical procedure for reasoning over it. Then, he wrote, two people who disagreed would no longer need to quarrel — they could take up their pens and say "Calculemus" ("let us calculate") and settle the matter by reckoning. Axioma is a step toward that vision: a language where logic, mathematics, and knowledge are things you compute with.
1. Introduction
Axioma is the language in which formal philosophy and symbolic logic become executable: a natural-language-readable surface over a first-class epistemic kernel — concepts, epistemic grounding, defeasible rules, and checkable proof. Where most languages compute plain values, Axioma computes values that carry their epistemic status: a fact's derivation provenance (its grounding), whether it is contradicted, and whether an answer is decidable at all. Its flagship demonstration is a runnable formalization of praxeology (the Rothbard corpus), carried end-to-end by that kernel with no special-case machinery.
Around that kernel, Axioma blends styles freely — set theory, first-order logic, multi-valued logics, lambda calculus, frame-based concepts, relational logic programming, stack-based programming, and natural-language definitions live side by side and compose with each other. The surface is deliberately consolidating, not accreting: redundant spellings are retired over time toward one canonical form per idea (see §3), so expressive breadth does not buy a second way to say the same thing.
Key features
- Concept system with natural-language syntax
(
concept Stock,Stock has price: 150). - Logic programming through deterministic, set-based pattern queries — Prolog-like relations without implicit depth-first backtracking.
- Six-grade epistemic grounding: every fact carries a
grade on the ladder
axiom > postulate > theorem > conjecture > hypothesis > datum, propagated through derivation as provenance. - Five first-class logics: Boolean, Kleene K3, Łukasiewicz L3, Belnap B4, Gödel G3 (intuitionistic), automatically dispatched by operand type.
- Strict and defeasible rules in both backward and forward directions.
- Bilattice truth values with paraconsistent contamination (Belnap B4).
- SQLite-backed knowledge base shared with Cascade.
- Stack programming with both a user-accessible
Stacktype and a global interpreter stack. - MCP server exposing tools for AI-assistant integration.
- Tools:
axiomadoc(literate programming), VM mode, Wails GUI, web GUI, Jupyter kernel.
Influences
Axioma stands in a long line of languages and gratefully adapts the best of them, just as most languages do. For the record, the main lineages are:
- REBOL — the
:value binding, the family of scalar value literals (URL, email, file, money, pair, issue, …), the get-word (:w), refinements (name/ref), and the value-returning (non-throwing) error model. - Forth / Pop-11 — the stack model: the global interpreter stack, the postfix sequence notation, and the stack-shuffle verbs.
- SETL — set-theoretic data structures,
comprehensions, bags, and the
om/Ωundefined value. - Lisp / Scheme — the
'quote (the'wword literal), homoiconicity (code as data), and the S-expression view of the AST; Mathematica —fullformfor viewing the AST as a symbolic expression. - Prolog / Datalog — relational facts, rules, and queries.
- F-logic & Description Logic — the concept system: frame slots and the concept↔︎relation↔︎rule duality (F-logic), and subsumption / satisfiability reasoning over a tableau (Description Logic / ALC).
- Python, Haskell, Racket, Rust, Swift — assorted surface conveniences (comprehension spellings, string-escape and byte syntax, list accessors).
2. Getting Started
Installation
# Source checkout. If the repository is not publicly reachable yet, use the
# browser playground for zero-install evaluation until release archives are linked.
git clone https://github.com/vevenhar/axiomalang.git
cd axiomalang
go build -o axioma ./cmd/axioma/
./axioma --help
./axioma # Start REPL
Run a script
./axioma path/to/script.ax # Run a script
./axioma --no-kb script.ax # Skip Cascade KB preload (~10× faster startup)
./axioma --vm script.ax # Run in VM mode
./axioma --mcp # Start MCP server (stdio JSON-RPC)
Feature status at a glance
The browser column means the WebAssembly playground core: no host file system, no subprocesses, and no local SQLite KB. The native tree-walker is the reference runtime for the whole public language surface. The VM is useful for the compiled core, but some higher-level forms are intentionally still tree-walker-only.
| Feature surface | Browser playground | Native tree-walker | VM | Status |
|---|---|---|---|---|
| Core syntax, bindings, functions, arithmetic | yes | yes | yes | stable core |
| Collections, 1-based indexing, list comprehensions | yes | yes | yes | stable core; sets/dicts remain unordered |
| SQLite knowledge base, Cascade sharing, persistence | no | yes | use native tree-walker | native-only operational surface |
| File I/O and host/process integrations | no | yes | partial by feature | native-only where the host OS is required |
Foreign-language blocks (python, lisp,
sql, etc.) |
no | yes | yes for supported blocks | local runtimes/binaries required |
| Errors as values | yes | yes | partial | try / otherwise / attempt are
evaluator-only today |
| Macros, quasiquote-heavy code-as-data workflows | yes | yes | no | run under the tree-walker |
| ADT declarations and constructor-pattern matches | yes | yes | no | evaluator-only; --typecheck adds static
diagnostics |
First steps
axioma> a: {1, 2, 3}
{1, 2, 3}
axioma> b: {2, 3, 4}
{2, 3, 4}
axioma> a union b
{1, 2, 3, 4}
axioma> concept Person
axioma> Person has name: "Alice"
axioma> parent("John", "Mary")
axioma> {Y | Y <- parent("John", Y)}
{"Mary"}
3. Language Fundamentals
Bindings — one canonical form
Axioma has a single canonical value-binding
operator: :. It's the shortest binding form of any
mainstream language (two characters of overhead), puts the name first,
and composes naturally with type annotations.
x: 5 # bare binding
radius: 3.14 * 2 # expression on the RHS
a, b, c: 1, 2, 3 # multi-assignment (parallel)
(x, n): (3, 2) # pattern binding (nested / constants / `_`)
let (x, n) = (3, 2) # same, fresh declaration (ML `val`)
d :: Day: Mon # with type annotation
All variants compile to the same LetStatement AST node.
A multi-assignment evaluates every right-hand side first and then binds
(so a, b: b, a swaps), updates each name wherever it
already exists within the enclosing function frame (declaring it here
otherwise — exactly the single-name rule), and evaluates to the
tuple of all assigned values, so the REPL echoes
(1, 2, 3). A multi-assignment may also open a
bracket block —
if val > best then [ best, at: val, idx ] — the leading
name, name run is recognized as a statement, not array
elements, so branch bodies can start with a parallel update (both the
: and = spellings; plain [a, b]
stays an array). The axioma/beginner subset uses only the
bare form; multi-assign and type annotations show up later in the
curriculum.
Pattern binding (ML-style tuple patterns)
When the left-hand side is a tuple pattern in
parentheses, the right-hand side is matched as a whole and the
identifiers in the pattern are bound — the same idea as
match pair with | (x, n) => …, but as a declaration:
let (x, n) = (3, 2) # x ↦ 3, n ↦ 2 (Fresh)
(x, n): (3, 2) # find-or-update (`:`)
(x, n) = (3, 2) # find-or-update (`=`)
[head | tail] = [1, 2, 3] # array/cons pattern (Elixir)
let [h | t] = xs # fresh
{a, b} = {a: 1, b: 2} # hash-shape (implicit names)
{a: x, b: y} = dict # hash-shape (renamed)
(^tag, result) = ('ok, 13) # pin: slot equals current `tag`, does not rebind it
# `2^3` is still POWER. Pin is only `^name` in a pattern.
# Nested patterns, wildcards, and constant sub-patterns:
let ((a, b), c) = ((1, 2), 3)
((_, flag), _, done): ((1, true), junk, false)
let (payload, 0) = (lookup(), 0) # only matches when the second slot is 0
('ok, result) = ('ok, 13) # Word tag — result ↦ 13
# `:ok` is a get-word (it fetches a binding), not a tag. Write `'ok`.
A failed match (wrong arity, non-tuple value, or a constant that does
not equal the corresponding component) raises a catchable error — SML's
Bind exception. No name is left partially bound. A paren
pattern matches only a Tuple (not an Array). An
array/cons pattern [h | t] matches an Array or a List (the
rest of a List stays a List), not a Dictionary — bind keys with
{a, b} = dict, or pairs with
[h | t] = items(dict). A hash-shape pattern
{a, b} / {a: x} matches a Dictionary (extra
keys allowed; a missing key is Bind). A pin
^x in a pattern constrains the slot to the current binding
of x and does not bind or rebind x (unbound
^x is an error). The same identifier may not appear twice
in a binding pattern (let (x, x) = … /
[x | x] = … is a SyntaxError). Under --vm,
pattern bindings are refused at compile time (evaluator-only, like
match). Array literals, cons expressions, and
comprehensions that are not followed by =
/ : keep their ordinary readings.
With --typecheck, a known product RHS (tuple literal or
Tuple of (…) annotation) assigns each pattern variable its
slot type and reports static arity mismatches, a tuple pattern against a
non-tuple, and literal sub-patterns that cannot match their slot
type.
=is a find-or-update synonym of:.=declares a name if it is absent and updates it if present — for any name and case — so textbook mathematics reads verbatim:B = {2, 4, 6},C = A union B,Pi = 3.14159.:stays the canonical binding (and the form to learn first);=is the math/textbook spelling. (=is no longer strict-update-only.)Casing is context-local, not a global type tag. Both
:and=admit a name of any case —B: {2, 4, 6}andB = {2, 4, 6}are set variables. The right-hand side decides concept-vs-value: aconcept { ... }RHS names a Concept (uppercase required —Stock: concept { ... }; a lowercase LHS with aconceptRHS errors), while any other value binds an ordinary variable. Uppercase still means a logic variable inside rules (the Prolog convention) and is the Concept-naming convention — it just no longer forces a top-level binding to be a Concept.
AandEare ordinary identifiers, not quantifier shorthands. Quantify withforall/exists, the glyphs∀/∃, the backtick digraphs`forall/`exists, or∃!(unique existential).E!(the free-logic existence predicate) is a separate token.
There is no
:=, andletis not an assignment. Bind with:(canonical) or=;x := valueis a syntax error with an inline hint pointing atx: value.let x = valueis a different construct entirely — a fresh, immutable declaration that shadows rather than updates (below), exactly theletof mathematical prose: "let x = 5" fixes x for the rest of the argument. Its mutable twin isvar x = value(let mut x = value/let mutable x = valueare the Rust / F# spellings of the same declaration), andval x = valueis a second spelling ofletfor readers who arrive from Scala, Kotlin, or Standard ML. Ordinary:/=names remain cells that find-or-update can write.
Binding model at a glance
One page for the whole story. The rest of this section is detail; if something surprises you, it is almost always one of the rows below.
| Kind | Spelling | What it does |
|---|---|---|
| Value bind / update | x: 5 or x = 5 |
Find-or-update within the enclosing function frame: declare if absent, write if present in this frame. Does not reach outer functions. |
| Definition (equation) | f(x) = … or fun f(x) = … |
A parenthesized head makes = a
function definition, not a value bind. Same lowering as
func f(x) […]. |
| Binder | parameters, foreach x in …, x <- xs,
match captures, logic vars |
Always a fresh binding that shadows any outer name of the same spelling. |
| Fresh declaration, immutable | let x = 5, val x = 5, or
let (x, n) = pair |
Declare a new binding here, shadowing any outer
name — never updates, and the cell itself then refuses every write
(:, =, rebind). A closure made
before the let keeps the old binding. = only.
Pattern form binds every name the pattern introduces (on
let only — val does not take the pattern
form). val is the same declaration under the Scala/SML
spelling. The dual of rebind. |
| Fresh declaration, mutable | var x = 5, let mut x = 5,
let mutable x = 5, or var (x, n) = pair |
Exactly let, minus the write-refusal: a fresh,
shadowing binding whose cell stays writable. let mut /
let mutable are the same declaration under the Rust / F#
spellings. For the rare case that needs to shadow an outer name
and mutate the shadow — plain x: 5 already covers
most mutable code. |
| Pattern binding | (x, n): pair or (x, n) = pair |
Match the RHS against a tuple pattern; bind (find-or-update) every
identifier the pattern introduces. Nested tuples, _, and
literal constants allowed. |
| Explicit local, mutable | local x = 5, local x :: Float = 3, or
local (x, n) = pair |
Fresh mutable binding in the current lexical scope, sharing
var semantics. Initializers use =;
local x :: T creates a typed hole. |
| Outer / captured write | rebind x: … (or rebind x = …) |
Walks out of the current frame to the nearest existing binding. Never declares. Typo → error. |
Module write (Julia global) |
global x = …, global x :: T = …, or
global x then x = … |
Writes this file (or this nested
module body), skipping enclosing function locals. May
declare. Honors the module cell's :: (converts / refuses),
like other writes. Not an alias of rebind. |
| Immutable name | const LIMIT = 10 |
A program-level commitment: one name, one value — cannot be
reassigned or shadowed, in either direction, and is
top-level only (inside a function the immutable local
is let). Uses = only
(const LIMIT: 10 is a SyntaxError). The binding is
constant, not deep-frozen values. |
Why this shape (and not :=). Axioma is
a teaching and multiparadigm language: : is the short
Rebol-style binder; = must read as textbook mathematics
(B = A union C, area(r) = pi * r * r) without
a second “assignment only” operator. Unifying introduction and update
under find-or-update is what makes accumulators work
(total: total + n inside a loop). Around that default sit
the explicit keywords for the directions it doesn't take:
rebind when a closure or nested function
must update a name outside its own frame,
global when the write is Julia's module
cell (this file / this module body), and the fresh pair —
let for a genuinely new binding that then
stays fixed (the let of mathematical prose, and of most
functional languages), var for the rare
fresh binding that must keep varying. let fixes here;
var varies here; rebind reaches the nearest
cell; global reaches this unit; bare
:/= does the sensible middle.
val is a second spelling of
let. Readers arriving from Scala, Kotlin, or
Standard ML write val for the immutable binding, and Axioma
accepts it: val x = 5 builds exactly what
let x = 5 builds — same fresh cell, same shadowing, same
write-refusal. It is not a spelling of var;
var is the mutable twin and stays a distinct word.
let remains the canonical spelling, and it is the majority
one — Swift, Rust, Nim, F#, OCaml, Haskell, Elm, and Gleam all read
let as immutable, as does mathematical prose ("let x = 5"
fixes x for the rest of the argument).
One asymmetry, and it is deliberate: the pattern
form stays on let. let (x, n) = pair
destructures; val (x, n) = pair does not, because
val(...) is already a function call and a language may not
quietly take a shape that already parses. Write let when
you are destructuring.
let mut / let mutable are second
spellings of var. Readers arriving from Rust write
let mut x = 5; readers arriving from F# write
let mutable x = 5. Both build exactly what
var x = 5 builds — same fresh cell, same shadowing, same
writable cell. They are not spellings of let.
mut and mutable are reserved words (a bare
mut: 99 is a SyntaxError); guard the name
($mut) to use it as data. val mut is refused
(val means immutable); var mut is refused as
redundant; let/mut is not a refinement. The pattern form
works: let mut (x, n) = pair is
var (x, n) = pair.
# Value bind (canonical) and textbook synonym
radius: 5
area = pi * radius ^ 2
# Equation — definition, because of the parenthesized head
double(x) = 2 * x
fun triple(x) = 3 * x
# Binder shadows; body assignment stays in-frame
sum_all: func(xs) [
total: 0
foreach n in xs [total: total + n] # writes this frame's total
total
]
# Outer write must be spelled
counter: 0
bump: func() [rebind counter: counter + 1]
# Fresh declaration — a NEW, immutable binding; the closure above keeps its
# own counter, and `counter: 1` from here on is refused
let counter = 0
# Fresh AND mutable — `var` is `let` minus the write-refusal
var scratch = 0
scratch: scratch + 1
# The same declaration, Rust / F# spellings
let mut scratch2 = 0
scratch2: scratch2 + 1
let mutable scratch3 = 0
scratch3: scratch3 + 1
# Immutable name — program-level, top-level only
const LIMIT = 100
Not part of this table (see below): lazy
declare / definitional define, persistence
refinements, multi-assign, type annotations on bindings. Cross-frame
mistakes that used to be silent outer writes are reported by
axioma --lint (see rebind).
Regression pins:
tests/axioma/binding/test_scoping_shadowing.ax,
tests/axioma/binding/test_let_fresh_binding.ax,
tests/axioma/binding/test_let_immutable_var.ax,
tests/axioma/binding/test_let_var_vm_parity.sh,
tests/axioma/binding/test_let_mut_alias.ax.
Fresh declarations with
let and var
let NAME = value declares a new,
immutable binding in the current scope. It never updates an
existing name — where bare x = value finds-or-updates and
rebind updates-only, let
declares-only, shadowing any outer binding of the same spelling
— and the binding it makes then refuses every write: x: 6,
x = 6, and rebind x = 6 all error with a hint.
This is the let of mathematical prose — "let x = 5" fixes x
for the rest of the argument — and of most functional languages.
var NAME = value is let minus the
write-refusal: the same fresh, shadowing, block-scoped declaration, with
a cell that stays writable. It is deliberately the marked
spelling, because it is rarely needed — : is already the
mutable workhorse (accumulators never involve let), so
var exists for the narrow case that must shadow an outer
name and mutate the shadow. let mut NAME = value
and let mutable NAME = value are the same declaration (Rust
and F# spellings):
let fixed = 5
fixed: 6 # ERROR: Cannot reassign 'fixed': `let` bindings are
# immutable — declare it with `var fixed = ...` if it
# needs to change
var counter = 0
counter: counter + 1 # fine — var cells vary
let mut n = 0
n: n + 1 # same cell as var
Everything below applies to both spellings. A closure made before the declaration keeps the binding it captured:
x: 1
peek: func() [x]
let x = 2
peek() # 1 — the closure still sees the old binding
x # 2 — everything after the let sees the new one
Because the right-hand side evaluates before the new
binding exists, let xs = split(xs) reads the outer
xs and then takes over the name — the classic
rebind-by-shadowing idiom. Every let makes a fresh binding,
even for a name already let in the same scope, so
consecutive let x = … lines each leave the previous binding
alive inside anything that captured it.
The details:
=only, exactly asconst:let x: 5is a SyntaxError with a hint. Annotations and multi-targets use the same grammar as bare bindings —let d :: Date = today(),let a, b = 1, 2(all values evaluate before any binding, solet a, b = b, aswaps).- Named declarations may omit their initializer:
let xandlet x :: Treserve unreadable cells, exactly like their= _spellings. If=is written, its RHS must be an expression or an allowed marker (_or typeddefault). Invented bottoms are refused; name what you mean withnone,om,_,default, or a real value (see Uninitialized slots and identity defaults below). - Reserved word (since August 2026).
let,var, andvalare keywords, so a barelet: 42is a SyntaxError that names the fix. To use one of them as an ordinary name, guard it:$let: 42,func($val),h.$val,$val() = 1— the same$guard every reserved word takes. POP-11 word lists are the exception that needs no guard:[let x var y]is still the array["let", "x", "var", "y"], because there the words are data. - Scoped to its block. In a loop body the binding is per-iteration; in a branch or value block it dies with the block; at the top level and in a function body it lasts to the end of that scope.
- Shadowing a
constis refused — the program-level name stays one thing. Shadowing aletis allowed, in both directions:let's immutability is cell-level, so an innerlet,var, parameter, or loop variable of the same spelling is a new cell, not a write. varis the same grammar throughout — patterns (var (x, n) = pair), annotations, multi-targets, markers, the soft-keyword gate (var: 42binds the namevar), and the same loud limits below.- Current limits, each a loud error rather than a quiet difference:
letat the top level of an imported file,letinside atraceblock, and — under--vmonly —letinside a nested branch/value block.
Uninitialized slots and identity defaults
Three different intents people often collapse into one:
| Intent | Spelling | Readable immediately? | Example |
|---|---|---|---|
| Hole — assign later | let x :: T = _ |
No — read errors until first assign | let n :: Integer = _ |
| Empty container — identity | let x :: T = default |
Yes — real empty value | let xs :: Array = default → [] |
| Explicit value (incl. zero) | let x :: T = expr |
Yes | let n :: Integer = 0 |
= _ (uninitialized). Requires a fresh
named binding spelling; a :: type is optional. The slot
holds binding state, not a third bottom: it is not
none, not om, not 0. Reading
before the first successful assignment is a hard error. Later
x = v / x: v fills the same cell and re-checks
any snapshotted annotation. Without an annotation, the first value does
not impose a permanent type on a mutable cell. A failed RHS or
conversion leaves the cell uninitialized. Under let the
hole takes exactly one write — the fill — and is
immutable from then on (deferred initialization, as in a proof that
names a quantity before computing it); a slot you intend to keep
refilling is a var hole. Under --vm,
let x :: T = _ is a loud compile refusal (the one-write
allowance cannot be checked statically); var x :: T = _
compiles.
The marker does not construct a value of T, so it needs
no default value or empty constructor. Function and arrow annotations,
user concepts, records, variants, schemas, and aliases can describe
holes as well as primitive types. val shares
let's one-fill rule; local,
let mut and let mutable share
var's refill rule.
let age :: Integer = _
# println(age) # error: uninitialized
age = 89 # the fill — allowed once
# age = 90 # ERROR: Cannot reassign 'age'
var lifespan :: Integer | "ongoing" | "uncertain" = _
lifespan = 89
lifespan = "ongoing" # var holes refill freely
let callback :: Function = _
callback = func(n) [n + 1]
println(callback(2)) # 3
concept Person
let person :: Person = _
person = a Person {}
println(person is Person) # true
var amount :: Float = _
amount: 3
println(amount is Float) # true: the fill performs numeric conversion
The snapshotted type belongs to that cell: shadowing it creates a
different cell and does not replace an earlier closure's contract. Fills
through =, :, rebind,
global, or a writable binding reference retain the
destination's type checks and conversions. Persistent Array element and
schema contracts govern later mutations. Existing source-sensitive rules
still apply: a schema annotation on a fresh dictionary literal rejects
extra keys, while ordinary assignment can accept them (access remains
restricted to the schema).
--typecheck tracks fills along reachable paths.
Sequential reads and both if/else arms are
checked; an arm that returns need not fill a cell read only after the
branch. Unreachable statements contribute no initialization facts, while
their ordinary type/annotation checks remain active. Short-circuit and
fallback operands supply a definite fill only when execution requires
it; unknown conditions retain a possible skipped path. This preserves
the runtime distinction between Boolean short-circuiting and multivalued
logic.
Loop bodies and conditions are checked in execution order. Pre-test
loops retain a zero-iteration path unless the checker knows the initial
condition is true. That proves a first iteration, not nontermination:
the body may change the condition. Literal while true and
infinite loops exit normally through break. Post-test
repeat [body] until condition checks the body first;
continue reaches the condition, while break
bypasses it. Labeled exits reach their target loop. A match can join
common fills across an unguarded catch-all, both Boolean cases, or all
closed constructors with irrefutable fields. Other matches retain their
implicit no-write fallthrough.
A fresh branch declaration, match pattern, or loop variable names a
separate cell; filling it does not fill an outer namesake. Get-word
:x fetches a value and refuses an uninitialized cell, just
like ordinary x. Taking &x obtains the
cell without reading its value; *p still requires a filled
cell.
The checker recognizes positive literal repetition counts, simple
bindings holding those counts, and known nonempty literal collections,
strings and ascending closed integer ranges. Every reachable
break/continue path must still supply the fill. It follows direct
reference aliases by cell identity: p = &x; *p = 1
fills x, even if a later fresh declaration hides that cell.
Caught errors preserve writes completed before failure;
try, attempt, otherwise, catch
handlers and finally blocks join their actual exit paths. A potentially
failing conversion retains its pre-fill error path.
Defining a function or lazy computation does not execute captured
reads or writes. Body-local holes are checked independently. Simple
known function calls also check captured reads and summarize writes
through rebind or reference parameters, including normal
returns and errors. Ordinary assignment inside a function still follows
that function's local-write rule.
Known calls support fixed positional parameters, defaults, named
arguments (including positional tags), and positional rest arguments.
Defaults are analyzed at the call, in declaration order, only for
omitted arguments; earlier converted parameters are available to later
defaults. A default that fails before the body contributes no body
writes. Exact Integer +, -, * and
comparisons can supply evidence for a count or condition; folding is
bounded and does not invoke user operators.
local answer
fill = func(n = 42) [rebind answer = n]
fill()
println(answer) # 42; passes --typecheck
Initialization diagnostics distinguish a definite early
read (the tracked cell is unfilled) from a possible
early read (some paths may leave it unfilled). An
analysis incomplete note marks an opaque call; a later
read may also receive a possible-read diagnostic. Both read diagnostics
stop --typecheck; the note alone is informational. Default
execution warns and retains runtime read checks. This distinction
changes the explanation, not the rule that a read requires a successful
fill.
Repeated known calls can reuse a bounded context-specific effect summary when argument values, captured cells, readiness and alias facts agree. Normal and error exits are recorded separately. Changed facts trigger fresh analysis; opaque effects and escaping fresh cells prevent summary reuse.
This is bounded initialization analysis, not a complete safety proof.
Unknown calls, recursion, multiple clauses, partial applications, named
arguments combined with rest parameters,
lazy/refinement/structural-pattern parameters, function-local
global marks and arbitrary contracts remain conservative.
Opaque effects discard uncertain alias/callable and collection facts;
writes are never guessed. Loop-carried aliases and arbitrary
nonemptiness require runtime enforcement. These rules are shared by all
six fresh binding spellings and by typed or untyped bare declarations
and explicit = _. An explicit annotation remains valid even
when the checker cannot model it precisely (for example, a user concept
or Function). This is gradual checking, not proof that
every later value fits; runtime checks remain authoritative. Unknown
annotation names are still errors. VM support remains narrower:
immutable holes, concept/record declarations and persistent Array
element contracts retain their explicit refusals.
= default (identity element). Requires
let and a :: type. Fills the slot now
with the same empty that nullary constructors return for types that have
a genuine identity element:
Allowed :: type |
default yields |
|---|---|
| Array | [] |
| Set | {} |
| Tuple | empty tuple |
| Dictionary / Dict | empty dict |
| Bytes | empty bytes |
| Bag, Stack, Graph | empty forms |
Refused for scalars and unions — including Integer,
Float, String, Boolean, Money, Date, literal types, and
Integer | String. Those have no language-level silent
default. Write the value you mean:
let xs :: Array = default # Ok → []
let n :: Integer = 0 # Ok — you asked for zero
let n :: Integer = default # error — use `= 0` or `= _`
let s :: String = "" # Ok — you asked for empty string
let s :: String = default # error
Why Integer is not default → 0. Zero is
real data: it compares, adds, and branches like any other integer.
Auto-zeroing would make “forgot to assign” look like a successful
program that happens to use zero — the same §911 class that refuses
nullary date() inventing an epoch. Axioma’s rule matches
nullary constructors: containers may start empty; scalars do not
get silent zeros. 0, "", and
false remain fully legal as explicit
initializers.
Bottoms vs holes vs empties.
let a = none # absence (value)
let b = om # undetermined (value); a == b is false
let c :: Integer = _ # hole (not a value); reading errors
let d :: Array = default # empty array (value); len(d) is 0
For TypeScript readers. Axioma
local/varcreates a fresh mutable lexical binding; Axiomalet/valis immutable after initialization. The spellingvardoes not import function-wide hoisting. Axiomaconstis a top-level name commitment that also prevents shadowing; it is not a general translation of a block-local TypeScript constant. A bare Axioma assignment may create a binding; an outer-frame write usesrebind.local x :: Numberreserves a typed cell without an implicitundefined. Use= 0only when zero is the intended value.
Eager bindings, deferred bindings, and definitions
let/val,
var/local, and bare
:/= bindings evaluate their RHS now.
declare creates a fresh immutable lazy
binding: reading its name runs the computation and caches a
successful result. define records a definition in the
knowledge system; it is not an alias of declare.
declare Answer :: Float = 6 * 7
println(type(Answer)) # Float: conversion occurs on first demand
A repeated declare creates a new cell, as
let does. Earlier closures keep older cells. Assignment,
rebind, and reference writes cannot replace the new cell;
an Array obtained from it can still be mutated. A const
name cannot be shadowed. Ordinary value names, including uppercase
names, are accepted, with the same protection for seeded type names as
other fresh bindings. global and a fresh declaration cannot
select the same name in a function frame.
Recursive groups. Unlike an eager initializer, every deferred RHS sees the new names in its own declaration group. Annotations resolve in the preceding scope. Capture retains lexical cells: mutations to a captured cell before the first demand are visible; a later fresh shadow does not replace that cell.
declare left, right = right + 1, 41
println(left) # 42
declare ones = stream_cons(1, lazy ones)
println(first(ones, 4)) # [1, 1, 1, 1]
declare x = x + 1 therefore refers to its own new cell,
not an earlier x. Demanding it reports a cyclic-demand
error. Names introduced in a later fresh statement are not retroactively
in scope; put mutually dependent names in one group. Each name requires
its own RHS; lazy pattern destructuring is not part of this form.
Duplicate names in a group are refused.
Value contracts. declare x :: T = e
checks and converts the computed value before caching it. Array element
and dictionary-schema constraints travel with the value. An unused
computation is not evaluated just to check its result;
--typecheck may still diagnose statically evident
mismatches. declare x = [42] now means a one-element Array,
exactly as it does in ordinary expressions. _ and
default initialization markers belong to
let/var, not declare.
Failures and effects. Only successful results are
cached. A failed force can be retried, and effects from the failed
attempt are not rolled back. Forcing never creates a binding in the
reader's scope. declare, explicit lazy, and
/lazy parameters share this demand behavior and remain
evaluator-only.
declare/persist and declare/transient
select persistence policy independently of evaluation. They do not make
an unevaluated closure serializable; see the persistence limitations
below. Saving must not run a computation implicitly.
define retains its distinct definition/speech-act forms:
define concept, define axiom,
define postulate, define theorem,
define word, and define dialect. Computational
value-form define currently evaluates in the reader's
environment and caches an ordinary binding there; a cached binding can
hide a subsequent definition version. Its lexical capture and
version-aware cache are a separate design follow-up, not guarantees of
declare.
Bare declarations — the same hole, with less syntax
Each fresh named binder allows omission of = _, with or
without a type:
| One successful fill | Refillable after the first fill |
|---|---|
let x, let x :: T |
var x, var x :: T |
val x, val x :: T |
local x, local x :: T |
let mut x, let mut x :: T |
|
let mutable x, let mutable x :: T |
local result :: Integer
if true then [result = 10] else [result = 20]
println(result) # 10
let callback :: Function
callback = func(n) [n + 1]
println(callback(2)) # 3
The declaration creates a fresh, optionally typed, uninitialized cell
when execution reaches it. It creates no zero, none,
om, or other default and does not hoist the name. Use
assignment to fill that cell; another local result = 10
would create a fresh shadow. An earlier closure keeps its earlier
cell.
A bare named batch permits independent annotations:
var a, b :: String. b is constrained to
String; a is unannotated. Bare patterns remain refused: a
pattern decomposes an existing value, so it needs an initializer. A
newline, semicolon, block terminator, or end of input ends the
declaration, except that a following = continues an
initializer even across a newline. const,
declare, and rebind gain no valueless form;
they serve different purposes. Detached x :: T retains its
existing annotation role. The parser lowers the new forms to the
existing hole node. Untyped holes remain explicitly unsupported under
--vm; the earlier typed-hole VM limits are unchanged.
local —
fresh mutable binding in this scope
local NAME = value shares var's fresh
mutable binding semantics. It emphasizes where the
binding lives; var emphasizes that it can change. Both use
Axioma's lexical scopes. An initializer evaluates before the new binding
exists, and earlier closures retain the cell they captured. Repeating
local x = ... creates another fresh cell; it does not
update the previous one.
function local_example()
x = 10
if true
local x = 20
x += 1
println(x) # 21
end
x
end
println(local_example()) # 10
Replacing local x = 20 with bare x = 20
updates the function's existing x, so the final result
becomes 21. Plain accumulators keep their usual rules. A loop-body
local is fresh each iteration and ends with that body.
Forms include local x :: Float = 3,
local a, b = 1, 2, local (a, b) = pair, and
the array/hash patterns accepted by var. Existing type
annotations convert and constrain later writes as usual. Initializers
take = only.
local x :: Integer and its explicit spelling
local x :: Integer = _ create a refillable typed hole.
local x and local x = _ create the same kind
of cell without a type constraint. Declaration compound assignment
local x += 1 remains refused. An explicitly typed
= default is allowed when the type supplies an identity
value: this initializes a real value, rather than leaving a hole.
An active local binding and global cannot select the
same name in one function frame, in either order; this includes fresh
let/var bindings and patterns.
global marks the function frame, so a nested block cannot
override that mark with local. A nested
function has its own frame and may select its own local
or global binding. At file/module level local binds in that
unit, lasts to the end of that scope, and does not change export
visibility.
This is an Axioma teaching spelling, not Julia's whole-scope
declaration analysis or its interactive soft-scope rules. No declaration
is hoisted and ordinary assignment still stops at a function boundary.
local is reserved; use $local as an ordinary
identifier. [local x] remains a POP-11 word list. Canonical
AST printing uses var. The evaluator supports these forms;
the VM shares var support and explicitly refuses
unsupported nested-block bindings.
Scoping & shadowing
Axioma is block-structured: every block form pushes
a nested lexical environment — function bodies,
if/then/else branches,
comprehensions, foreach loops. A fresh
name bound inside a block dies with the block (stricter than Python,
where if-branch and loop variables leak out):
if true then [z: 42]
z # ERROR: undefined — the branch scope is gone
doubled: [n * 2 | n <- [1, 2, 3]]
n # ERROR: undefined — comprehension-local
foreach w in [10, 20] [t: w]
w # ERROR: undefined — loop-local
A binder binds; a body assignment stops at its frame. That is the whole rule, and the two halves are worth stating separately.
Body assignment — name: value (and its
= synonym) — is find-or-update, but the search
stops at the enclosing function frame: it walks the
blocks around it, and if the name exists anywhere within that frame it
is updated in place; otherwise it declares here. So one
function can never disturb another function's binding by accident:
v: 1
h: func() [v: 2] # declares h's OWN v; the outer one is untouched
h()
v # → 1
x: "outer"
f: func(x) [x + "-inner"] # a PARAMETER is always a fresh binding
f("param") # → "param-inner"
x # → "outer" — untouched
The boundary is the frame, not the block, and that
distinction is doing all the work. Every block inside one function — an
if branch, a loop body, a value block — is transparent to
the search, which is what keeps accumulators and counters working:
sum_all: func(xs) [
total: 0
foreach n in xs [total: total + n] # writes the frame's own total
total
]
sum_all([1, 2, 3, 4]) # → 10
countdown: func(n) [
i: 0
while i < n [i: i + 1] # a block-local i would never terminate
i
]
Binder positions, by contrast, always introduce a fresh
binding that shadows any outer name of the same
spelling. A binder is any position where a form names the variable it is
about to bind for you: a parameter, a loop variable, a comprehension
generator, a quantifier variable, a relation logic variable, a
match capture.
i: "outer"
foreach i in [1, 2, 3] [ ] # the loop variable is the loop's own
i # → "outer" — untouched
sq: func(xs) [{i * i | i <- xs}]
sq([1, 2, 3]) # → {1, 4, 9}
i # → "outer" — a comprehension is an expression,
# and expressions have no side effects on you
rebind —
writing a name in an enclosing frame
Because a body assignment stops at its own frame, reaching
out of a function is spelled, not assumed.
rebind walks the whole chain, updates the nearest binding
it finds, and takes : or = like any other
binding:
counter: 0
bump: func() [rebind counter: counter + 1]
bump()
bump()
counter # → 2
rebind never declares. A name that is bound nowhere is
an error, which is the point: rebind
states that the name already exists, so a typo in it is caught rather
than quietly becoming a second variable.
bump2: func() [rebind countr: 1]
bump2() # ERROR: 'countr' is not bound in any enclosing scope
Two details worth knowing. rebind targets the
nearest enclosing binding, not the outermost — so a function
that has its own d and calls an inner function that rebinds
d updates that middle one, not a top-level d.
And none counts as bound, so x: none then
rebind x: 5 works; declaring a slot empty and filling it
later stays spellable.
global — Julia's
module write
global is not an alias of
rebind. It skips enclosing function locals and may
declare the name on this file (or this nested
module M … end body). rebind still updates the
nearest existing cell and never declares. On a file-level accumulator
they agree; they diverge when an enclosing function has the same name.
The word is reserved: global: 5 is a SyntaxError; guard as
$global. Word lists take it bare
([global x]).
If the module cell already exists,
global total = total + x updates that cell
(it does not shadow it the way let would). If it does not,
a RHS that needs no prior read (global fresh = 7)
declares it there. A loop does not redeclare: every
iteration writes the same box. The module cell's :: applies
— counted :: Integer = 0 then
global counted = counted + 1 converts or refuses like any
other write, and global ratio :: Float = 3 snapshots the
annotation on that cell.
total = 0
function addall(t)
for x in t
global total = total + x # same numbers as `rebind total = total + x`
end
total
end
addall([1, 2, 3, 4]) # → 10
total # → 10
x = "module"
function outer()
x = "outer"
function inner()
global x = "inner" # file x becomes "inner"; outer's x stays
end
inner()
x # → "outer"
end
counted :: Integer = 0
function bump()
global counted = counted + 1
end
bump() # counted is 1, still Integer
# global counted = "nope" # type error
function seed()
global ratio :: Float = 3 # converts; ratio is 3.0
end
axioma --lint finds the writes that no longer
reach. A body that meant to update an outer name now
declares a local instead, and nothing errors:
acc: 0
bump: func() [acc: acc + 1] # reads the outer 0, writes a NEW local
bump()
bump()
acc # → 0
axioma --lint script.ax
Nothing is run: the file is parsed and each function body is read for
one shape — a plain assignment whose target name is bound outside the
function and is neither a parameter nor a binder variable. A path may be
a file or a directory; # lint: ok on the offending line, or
the line above it, says the local was deliberate. Exit status is
non-zero when anything is reported, so it works as a gate.
The trade: closures mutate captured state, but say so. A closure still reaches the cell its factory declared — it just spells the reach:
make_counter: func() [
acc: 0
func() [
rebind acc: acc + 1 # reaches the CAPTURED acc
acc
]
]
c: make_counter()
c() # → 1
c() # → 2
c() # → 3
Each call to make_counter() still gets its own
acc; two counters do not share a cell. (acc
here is just a name; count also works — it stopped being a
reserved word in July 2026 and is now a soft keyword, recognized only in
the constrained-language shape
count <<pattern>>.)
Builtins vs constants. The lowercase math names
(pi, e, tau, im, …)
are shadowable fallback builtins — pi: 3 wins
locally and leaves the system untouched. The canonical UPPERCASE
constants (PI, TAU, EULER, …) are
seeded immutable: PI: 3 reports
Cannot reassign constant 'PI' (non-fatal) and
PI keeps its value. im is the imaginary unit
(complex(0, 1)); there is no uppercase IM.
const — your own named constant. The
same protection the seeded constants have, applied to a name you
choose:
const LIMIT = 10
LIMIT # → 10
LIMIT: 20 # → ERROR: Cannot reassign constant 'LIMIT'
const is a program-level commitment:
one name, one value, everywhere. It refuses reassignment, refuses to be
declared over any existing name, and refuses to be shadowed —
let LIMIT = 99 is the same error. It is also
top-level only (file, module top level, REPL): inside a
function the immutable local is let, and a function-body
const says so:
f: func() [ const MAX = 1 ]
f() # → ERROR: `const` declares a program-level constant and is top-level
# only — inside a function write `let MAX = ...` for an immutable local
That division of labor is the whole design: let
protects a cell, const protects a name. Use
let for everyday working bindings, const for
the SCREAMING_CASE handful whose point is that the name is
globally unambiguous.
Two more things to know. First, const is the one place
= and : are not
interchangeable — the declaration takes = only, and
const LIMIT: 10 is a SyntaxError. Second, the
binding is constant, not the value: a const array
is still a mutable place.
const xs = [1, 2]
push(xs, 3)
xs # → [1, 2, 3] — the name is pinned, the array is not
Reassignment is fatal (exit 1), unlike the non-fatal report on the
seeded PI. So const pins which value a
name denotes; if you also need the value itself to hold still,
reach for an immutable type — a List rather than an
Array (§5).
given is retired. The natural-language
alias of const duplicated it exactly and was removed
(August 2026); the retired spelling errors with the migration:
given G = 5 # → SyntaxError: `given` was retired — write `let G = value`
# for an immutable binding, or `const G = value` for a
# top-level named constant
The word keeps its one living meaning: the premise line of a
[solver| …] block —
find d … given a = 3, b = 4 … condition … — which is
Pólya's "the given" and is unchanged.
Regression pins:
tests/axioma/binding/test_scoping_shadowing.ax,
tests/axioma/constants/test_constants_comprehensive.ax,
tests/axioma/binding/test_given_retired.sh.
Unicode identifiers
Names are not limited to ASCII. Any Unicode letter
can start an identifier — Greek, Cyrillic, CJK, Arabic, Hebrew, Egyptian
hieroglyphs — and emoji from the standard emoji blocks work too. Digits
and combining marks may follow. Identifiers are NFC-normalised as they
are read, so the precomposed and decomposed spellings of
café are the same name:
α: 3.14159 # Greek letters are ordinary names
Δx: 0.5 # Δ is a letter (the symdiff glyphs are △ ∆ ⊖)
файл: "report.txt" # Cyrillic
σύνολο: {1, 2, 3} # a Greek word, diacritics included
😀: "happy" # emoji from the standard blocks
add1: func(β) [β + 1] # parameters too
πλήθος: 3 # words may START with π — see below
Five Greek letters double as built-ins — π,
φ, τ (constants), Σ (sum), and
Ω (om). Each keeps its built-in meaning when it stands
alone, and is an ordinary first letter otherwise:
π is 3.14159… while πλήθος is your variable.
The one letter that always keeps its operator meaning is λ
— λx.x is a lambda abstraction, so a name cannot
start with λ. Operator glyphs (∪ ∩ ∈ ∀ → …) are
never identifier characters, and two conventions stay ASCII on purpose:
the TitleCase-means-Concept heuristic (Файл is an ordinary
word, not a Concept) and the uppercase-initial logic-variable convention
in relations and rules.
Guarded identifiers and atoms
To use a reserved word, a multi-word name, or
punctuation as an identifier, guard it with $:
$forall: 5 # `$name` — a reserved word used as a plain name
$"interest rate": 0.0525 # `$"..."` — spaces / punctuation in a name
$"GDP growth %": 2.1
$ disambiguates by what follows it: a
digit keeps the money literal ($5,
$5.00); a " opens a quoted guard; a letter
opens a bare guard. The ${...} interpolation form inside
strings is untouched — guards never use it.
Symbolic set elements (atoms) use the self-denoting
word literal 'name, and cardinality is
len(...):
A = {'p, 'q, 'r} # a set of three atoms
'q in A # → true
len(A) # → 3 (the cardinality of A)
Persistence refinements
The : operator deliberately has no refinement
slot — adding /persist to : would
force three-token lookahead on every identifier parse, and
: is one of the highest-frequency tokens in the language.
For persistent value bindings, use the dedicated declare
form:
declare/persist counter = 0 # Mark for saving; demand before saving
declare/transient temp = 42 # Discarded at session end
declare scratch = 0 # No refinement — uses the mode default
Default: REPL persists, scripts are transient.
Note the operator: declare uses = (the
ASSIGN token), not :.
Persistence is a policy flag, not an implicit force. An unevaluated
declaration is currently reported and skipped by session saving; demand
it explicitly first. A successfully forced serializable value is saved
and restored as an immutable binding, without rerunning its computation.
Typed scalar results retain their converted value. Contracted compound
results (such as Array of Integer) are reported and skipped
because their value contracts cannot yet be serialized. Explicit
lazy closures are also unsupported. A save warning means
that named value was not included in the image; /persist
alone is not a durability proof.
This keeps the canonical : form refinement-free and
concentrates the persistence vocabulary in one place. The same
/persist / /transient refinements apply to
axiom, postulate, and define:
axiom/persist gravity_constant = 9.81
postulate/transient working_hypothesis = "..."
Persistence-controlled bindings are written
declare/persist counter = 0(anddeclare/transient).
The public contract is the syntax above plus the persistence notes in §15.
Statement vs. expression syntax
Most language constructs come in dual forms:
| Operation | Statement form | Expression form |
|---|---|---|
| Property assignment | usa.gdp: 27000 |
usa[gdp -> 28000] (returns the value) |
| Concept definition | concept Stock |
Stock: concept {} (concept-literal RHS) |
| Fact assertion | assert parent("a", "b") (relation declared) |
insert("parent", "a", "b") |
| Retraction | retract [...] |
forget("parent", "a", "b") |
Note that s: a Stock {} is not a
concept-definition form — the indefinite article creates an
instance (a ConcreteEntity) of an already-defined
concept, a different operation entirely (see §13).
Use the natural-language statement form for concept lifecycle and properties, the functional/expression form for values and computation.
Typing the glyphs
Axioma's math notation (∈ ∪ ∩ ⊆ ⊇ ∀ ∃ λ ∧ ∨ → ≠ ≤ ≥ ⊻ …)
is always optional — the operators have plain word
twins (in, union, intersect,
subset, superset,
subsetneq/supsetneq for the proper forms
⊂ ⊃, forall, exists,
lambda, and, or,
implies, xor for ⊻,
!=), so no setup is ever required. The exceptions are the
description-logic pair ⊓ ⊔ and the NAND/NOR pair
⊼ ⊽, which stay glyph/digraph-only as infixes (the words
nand/nor are their prefix builtins,
deliberately unreserved). When you do want the glyphs, pick
whichever input layer fits where you're typing — all of them resolve the
same names from the same catalog (the one symbols()
prints), so a spelling learned once works everywhere:
| Where you're typing | How | Example |
|---|---|---|
| Any source file, any editor | backtick digraph — pure ASCII that lexes as the glyph | C `sqcap D ≡ C ⊓ D (no word form exists);
`forall ≡ ∀ |
| Canonicalize a whole file | axioma --glyphify file.ax (inverse:
--asciify) |
x in U and P → x ∈ U ∧ P |
| The REPL (and wizards) | type \in then Tab |
\forall ⇥ → ∀ |
| VS Code / Cursor / Neovim / Helix | \in + accept the completion (served by
axioma-lsp) |
\cup → ∪ |
| Browser playground | \in then Tab |
\subseteq ⇥ → ⊆ |
| Anywhere on your system | the generated espanso pack
(resources/espanso/axioma-symbols.yml) |
:in: → ∈ |
| macOS, no installs | System Settings → Keyboard → Text Replacements; or Unicode Hex Input
(⌥2208 → ∈) |
Names are LaTeX first (`cup, \subseteq),
then Axioma's canonical names (`union), then word aliases —
~100 spellings. Discover them from inside the language:
symbols() lists the whole catalog with LaTeX names and
meanings; glyph("cup") looks one up.
The MVL truth-value literals (§9) live in the same catalog —
symbols("mvl") lists all 13 (⊤⊥ᵇ,
?ᵏ, ½ł, ?ⁱ, …), each with
digraphs from the same table: `belboth ≡ ⊤⊥ᵇ,
`klunknown ≡ ?ᵏ, `lhalf ≡
½ł, plus Priest's `glut / `gap.
The REPL/LSP/playground/espanso layers above pick them up automatically
(\belboth + Tab → ⊤⊥ᵇ).
Notes. The digraph leader is a backtick, not the
Agda/Julia backslash, because \ is Axioma's set-difference
operator; a backtick outside strings and comments was previously a
syntax error, so the form is collision-free. A digraph is byte-identical
to its glyph at the token level, so it works in every construct, in the
VM, and in the playground. A digraph is unpaired — a
matched pair of backticks is the unrelated infix-application
form of §19.3
(3 `mysum` 4 ≡ mysum(3, 4)). The two are told
apart by the closing backtick alone, never by looking the name up in
this table, so the glyph catalog can grow without ever changing what an
existing `name` means. --glyphify is
token-aware (strings and comments are never touched) and verifies the
rewrite lexes and parses identically before writing; conversions only
happen where both spellings produce the same token, so
not/¬ and value literals like
true/false/om are deliberately
left alone.
Formatting a file —
--fmt
--glyphify canonicalizes how an operator is
spelled; --fmt canonicalizes where it
sits. The two compose, and together they are the
canonical print form.
axioma --fmt script.ax # rewrite in place
axioma --fmt-check script.ax # report only; exit 1 if not canonical
What it normalizes — whitespace, and nothing else:
| indentation | bracket depth × 2 spaces; a line opening with ],
) or } de-dents; end-form
elseif/else/end sit at their
if/while/function token's column
— including f(n) = if mid-line, so the arms do not flatten
to column 0 |
| trailing whitespace | stripped |
| blank lines | runs capped at 2 |
| end of file | exactly one newline |
What it never touches: the bytes of any token, the contents of a
string, and comments — including a trailing
# … and the body of a /**md block. Intra-line
spacing is left as you wrote it, so 2, 3, + and
1..9 and a's survive unchanged.
Two safety rules, both borrowed from --glyphify:
- Unparseable input is refused. Like
gofmt, a canonicalizer will not guess at a broken file. - The result is verified before it is written. The reformatted source must re-lex to an identical token sequence — same types and same literals — and must still parse. If it does not, nothing is written and the command reports why. A formatter that silently alters a program is worse than one that refuses.
--fmt-check writes nothing and exits 1 when any file
would change, which is the form to use in CI or a pre-commit hook.
--fmt-diff is the same gate with the
detail — it prints the unified diff instead of just the filename:
axioma --fmt-check src/*.ax # which files would change
axioma --fmt-diff src/*.ax # exactly which lines, as an applicable patch
Both write nothing. The diff is a genuine patch —
patch -p0 and git apply accept it, and
applying it reproduces what --fmt would have written, byte
for byte, because both render from the same result. Since trailing
whitespace is most of what the formatter strips and is invisible in a
diff, a whitespace-only change is flagged with a note so the output does
not read as a formatter bug.
In the editor. axioma-lsp serves the
same engine as textDocument/formatting, so Format
Document (and format-on-save) in VS Code, Cursor, Neovim or Helix
produces bytes identical to axioma --fmt. It also serves
textDocument/rangeFormatting, so Format Selection
(and the extension's Format Axioma Selection) changes only the
selected lines — at the indentation the whole file gives them, because
the engine still runs on the whole buffer and only those lines are
handed back. Rebuild the server to pick it up:
go install ./cmd/axioma-lsp # or: cd vscode-axioma && ./reinstall.sh
Two things the editor path does deliberately: a buffer that does not
parse yields no edits and no error (format-on-save
fires on half-typed lines, and the parse error is already showing as a
diagnostic), and the editor's tabSize /
insertSpaces are ignored — a canonical
formatter that varied by editor setting would defeat its own
purpose.
4. Data Types
Primitives
| Type | Examples | Notes |
|---|---|---|
Integer |
42, -17, 0,
0xFF, 0b1010_1100, 0o755,
1_000_000, 2^100 |
Arbitrary precision — integers never overflow (see below); hex/binary/octal prefixes; underscore separators |
Float |
3.14159, -2.5, 0.75,
1.5e6, 3.14e-2, 1E+9,
0x1p-1, 0xA.Bp2 |
IEEE 754 double — the type is Float,
not Float64 and not FLOAT.
:: Float64 names no type (did-you-mean
:: Float). Scientific notation
e/E ± optional sign; hexadecimal
floats (Go/C99 syntax — binary exponent
p/P required:
0x1p-1 = 2⁻¹ = 0.5, 0xa.bp2 =
42.75, 0x2.p3 = 16.0). The
exponent is what distinguishes a hex float from a hex integer
(0x10 stays Integer 16), keeps
0xFE/0x1E reading
e/E as digits, and keeps 0x15e-2
a subtraction. Lua's exponentless fraction 0x0.2 is
deliberately rejected with a hint (write 0x0.2p0, or
evaluate the Lua form via [lua/eval | 0x0.2 ]).
Display is the shortest decimal that round-trips the
bits: a whole value keeps .0 (1.0 not
1), IEEE remainders are not rounded away
(sin(3.1415926/2) is 0.9999999999999997,
0.1+0.2 is 0.30000000000000004), infinities
print Inf/-Inf, and
eval(string(x)) recovers a Float |
Rational |
1/3, rational(2, 6) →
1/3 |
Exact p/q on big integers, GCD-reduced — /
on integers stays exact (1/3 + 1/6 → 1/2,
never 0.4999…); accessors numerator(r) /
denominator(r) |
Complex |
complex(3, 4) → 3.0 + 4.0i;
im → i |
The top of the numeric tower — every other numeric
type embeds, so complex(3, 4) + 1/2 →
3.5 + 4i. The unit is the shadowable builtin
im (complex(0, 1)); write 1 + im,
(1 + im)^2 → 2i, 2 * im. No
juxtaposed literal: 2im is diagnosed (same as
2x). Full arithmetic incl. ^ (exact at integer
exponents: im^2 → -1) and unary minus;
sqrt/exp/log/sin/cos/abs/conjugate
all accept one. No ordering. Embedding is via float64, so
exactness stops here. Coefficients use the same Float printer |
String |
"hello", "unicode: ∀∃",
"\u{2203}", r"raw \n" |
UTF-8; escape sequences + r"..." raw prefix — see Strings |
Boolean |
true, false |
Classical two-valued |
Byte |
byte(0xFF) |
Single byte 0..255; distinct from Integer. See Binary data |
Bytes |
b"hello", b"\xff\x00",
bytes(0x48, 0x69) |
Immutable byte sequence |
Integers don't overflow
Integer is arbitrary-precision (the
Python/Mathematica model, not C/Lua's fixed 64-bit wrap-around): when a
result outgrows the machine word it promotes transparently, so
9223372036854775807 + 1 is
9223372036854775808, 2^100 is exact, and
3^39 has every digit right. This holds identically in the
evaluator and under --vm (the VM delegates its integer core
to the reference runtime, so the semantics agree by construction), and
across the numeric builtins (succ/pred,
sum, abs,
divmod/quotient/remainder,
floor/ceil/round/trunc/int,
gcd/lcm, factorial, …).
Consequences of the design:
- There are no
maxinteger/minintegerconstants — a largest integer does not exist. (Python 3 removedsys.maxintfor the same reason; Julia has notypemax(BigInt); Haskell'sIntegerhas noBoundedinstance.) - Hex/binary/octal literals are magnitudes, not bit
patterns:
0xFFFFFFFFFFFFFFFFis 2⁶⁴−1 (a positive number), not Lua's-1. - Bit operators
(
band/bor/bxor/bshl/bshr) use Python's infinite-two's-complement reading:bshlis exact at any count (1 bshl 63= 2⁶³,1 bshl 100= 2¹⁰⁰), andbshris an arithmetic shift that drains to0(or-1for negatives) past the width. - Memory is the only bound. An operation whose
single result would be astronomically large (a shift count or
exponent past the ~67-million-bit budget) raises a catchable error
rather than exhausting memory:
try(1 bshl 10^12)is anErrorvalue, not a crash. - The representation is invisible:
type(2^100)is"Integer"like any other integer. Mixing with aFloatconverts to float64 (precision may be lost past 2⁵³ — the universal int-meets-float rule).
Missing data cells:
na and Na
na is the missing-cell value; Na is its
programming type. NA remains a compatibility alias of the
same value. Values and DataFrames display it as na; the
internal tag and CSV's default external missing marker remain
NA.
type(na) == Na # true
@na == Na # true
na is Na # true
Na is DataType # true
Na == na # false — type versus value
NA == na # true — compatibility alias
na == none # false
na == om # false
if na then true else false # true
na ?? 5 # na (evaluator-only operator)
na ??? 5 # na (evaluator-only operator)
This is a data sentinel, not a third language bottom: it remains
truthy and neither coalescing operator replaces it. Arithmetic such as
na + 1 is a type error, not missing-value propagation. The
foreign /eval bridges still map R NA, Julia
missing, and JavaScript null/undefined to
none.
Dual null types
Axioma has two distinct null-like values with different semantics — they are not interchangeable.
| Value | Semantics | Truthiness | Display | Use for |
|---|---|---|---|---|
none |
Absent / missing | Falsy | none |
Uninitialized data, missing fields |
om, Ω |
SETL "omega" — a value that exists but is undetermined | Falsy | Ω |
Database unknowns, undefined computations |
none == om # false — never interchangeable
none is None # true — type of none, parallel to om is Om
None == none # false — type vs value, like Integer == 5
if none then ... # never executes
if om then ... # never executes (om is falsy)
# `null` was retired — write `none`. The type is None (`none is None`).
# `nothing` was retired — write `none`. DL's empty concept remains `Nothing`.
# `Null` was retired — the type of `none` is `None`. Option's empty tag is `Absent`.
Operational rule: none means absence;
om/Ω means an explicit undetermined value.
They compare differently (none == om is false). Both are
falsy for control flow, so if alone cannot
tell them apart — use v is None / v is Om (or
== none / == om). Because om
folds onto the designation law (designated(om) is false),
it agrees with typed Kleene unknown ?ᵏ (see §9). null and
nothing are retired — they are SyntaxErrors, not aliases.
JSON null stays the wire spelling. Option's empty
constructor is Absent (truthy); Absent == none
is false.
Strict assignment. Annotated T does not
accept none — write T | None (and/or
| Om) when absence is allowed. Prefer holes for “not yet
set”: let x :: String = _ (read before assign is a hard
error). Nullish defaults use ?? (none only)
and ??? (none or om); not
truthy-coalesce — see the section ?? / ???
— the absence railway. Safe property access is ?..
Full scorecard vs TypeScript-style strict null checking:
resources/docs/claude/NULLABILITY_STRICT.md.
Multi-valued logic types
Written as literals (the same forms the interpreter prints) or built via constructors; both participate automatically in operator dispatch (priority Belnap > Intuit3 > Łukasiewicz > Kleene > Boolean). See §9.
glut: ⊤⊥ᵇ # Belnap glut literal (≡ belnap("both"))
half: ½ł # Łukasiewicz half (≡ lukasiewicz(0.5))
unknown_g3: ?ⁱ # Gödel G3 unknown (≡ intuit3("unknown"))
score: lukasiewicz(0.73) # constructor — any value in [0, 1]
(contradiction itself is a reserved word — hence
glut, which is also Priest's term for the value.)
Strings — escape sequences, raw form, codepoint builtins
String literals are written between double quotes
("…"). A single-quoted 'x' is a Character.
Strings are stored as UTF-8, so Unicode characters can appear directly
in source:
greeting: "hello, world"
math: "∀x ∃y. x + y = 0"
greek: "λ φ Ω"
For cases where the character can't be typed directly, escape sequences decode at parse time:
| Escape | Result |
|---|---|
\n \t \r |
LF, TAB, CR |
\\ \" \' \0 |
literal \, ", ', NUL |
\u{H...H} |
Unicode codepoint, 1–6 hex digits, full U+0000–U+10FFFF |
"line1\nline2" # 2 lines (LF in the middle)
"tab\there" # 3 fields separated by TAB
"quote: \"hi\"" # quote: "hi"
"\u{2203}" # → ∃ (BMP — 4 hex digits)
"\u{1D54A}" # → 𝕊 (math S — 5 hex digits, supplementary plane)
"\u{1F600}" # → 😀 (emoji — 5 hex digits)
"\u{10FFFF}" # → (max valid Unicode codepoint)
The braced \u{H...H} form takes 1–6 hex digits. The
4-hex "\uXXXX" form is intentionally not
supported — it only reaches the Basic Multilingual Plane and forces a
second \U00000000 escape for everything above U+FFFF. The
single braced form handles the entire codepoint range.
Surrogate codepoints rejected. \u{D800}
through \u{DFFF} are rejected at parse time because they
cannot appear in valid UTF-8. chr(N) enforces the same
check at runtime.
Unknown escapes are preserved verbatim.
"regex \d+" keeps \d as a literal
\ followed by d — a back-compat hatch for
strings that contain regex metacharacters. Define a proper escape only
when needed; everything else passes through.
Literal prefix naming
Axioma uses one letter for each basic quoted literal
prefix: r for raw text, f for
formatted strings, b for bytes, and c for
commands. A two-letter prefix combines two existing
prefixes with compatible behaviors; it is never a longer
abbreviation for a single feature. This naming rule applies to future
quoted literal prefixes as well.
Each combination must be explicitly supported. Currently,
rf and fr combine raw text with formatting; no
other combinations are supported. Commands keep the single-letter
c prefix.
Raw strings — r"..." and
r'...'
The r prefix bypasses escape decoding entirely. Useful
for paths, regex patterns, and any genuinely-literal content:
winpath: r"C:\Users\alice\new" # literal path — no \U, \n decoding
pattern: r"\d+\.\d+" # literal regex
raw_unicode: r"\u{2203}" # → 8 chars: \, u, {, 2, 2, 0, 3, }
The result is byte-identical to the source between the quotes.
r"…" and "…" produce the same
*ast.StringLiteral AST node — the raw form just skips the
decode pass.
Triple-quoted and interpolated strings
Both also decode escapes:
multi: """
line one
line two \u{2203} ${some_var}
"""
x: 42
interp: "value = ${x}, glyph = \u{2200}"
Formatted strings —
f"...", rf"...", and fr"..."
Formatted strings evaluate Axioma expressions, using
{expression:spec}. Ordinary strings keep their existing
${expression} syntax. Both produce String values; neither
invokes Python.
price: 12.345
name: "Ada"
f"Total: {price:.2f}" # "Total: 12.35"
f"{42:05d}" # "00042"
f"{name:*^9}" # "***Ada***"
f"{{{name}}}" # "{Ada}"
rf"C:\reports\{name}" # backslashes stay literal; name interpolates
f"{len("abc")}" # nested quotes belong to the Axioma expression
rf and fr combine raw text with formatting.
Single-quoted prefixed forms are also accepted. Fields evaluate once,
left to right. Use {{ and }} for literal
braces. Unclosed/empty fields and invalid expressions are syntax errors;
incompatible values or unsupported format specifications are Errors.
The supported static format grammar is
[[fill]align][sign][#][0][width][.precision][type]:
| Form | Meaning |
|---|---|
<, >, ^,
= |
Left, right, center, or padding after the numeric sign |
*^9, 05d |
Custom fill/alignment; numeric zero padding |
+, -, space; # |
Numeric sign control; alternate numeric notation |
.2f, .3e, .4g |
Fixed decimals, scientific, significant digits |
d, b, o, x,
X |
Integer-value decimal, binary, octal, hexadecimal |
s, .3s |
String, optionally truncated by Unicode codepoints |
.1% |
Multiply a numeric value by 100 and append % |
F, E, and G are uppercase
variants. Omit the type for ordinary display; precision without a type
selects String truncation or numeric g formatting. Width
counts Unicode codepoints, not terminal cells. Width and precision are
bounded at 1,000,000. Integer formats accept integral Float/Rational
values, following Axioma's existing format rules;
fractional values are rejected. Numeric formatting may round, as with
format.
This is a focused formatting surface: Python conversions
(!r, !s, !a), debug
=, grouping separators, dynamic/nested format
specifications, combined command-format prefixes, and triple-quoted
prefixed forms are not supported. Parenthesize expressions whose
top-level colon belongs to Axioma syntax, including Time values. Braced
Unicode escapes in non-raw text keep their ordinary meaning.
Codepoint builtins —
chr and ord
For programmatic codepoint construction (when the literal form can't help because the value comes from runtime):
chr(8707) # → "∃"
chr(0x1F600) # → "😀"
ord("A") # → 65
ord("∃") # → 8707
ord(chr(N)) == N # round-trip for any valid codepoint
Both accept the full Unicode range U+0000..U+10FFFF; surrogates are
rejected; ord("") errors with "empty string has no
codepoint".
Case
mapping — upper / lower / title
(and Julia aliases)
upper("Hello") # → "HELLO"
uppercase("Hello") # → "HELLO" Julia spelling, same function
lower("Hello") # → "hello"
lowercase("Hello") # → "hello"
title("hELLO wORLD") # → "Hello World"
titlecase("hELLO wORLD") # → "Hello World"
capitalize("hELLO WORLD") # → "Hello world" not titlecase — one word's worth of shift
uppercase / lowercase /
titlecase are aliases of upper /
lower / title. A Character in is a Character
out (uppercase('a') is 'A').
uppercasefirst is not capitalize (Julia leaves
the rest of the string alone) and is not shipped.
Operators
"ab" + "c" # → "abc" concatenate
"ab" * 3 # → "ababab" repeat
"abc" - "ab" # → "c" bag difference (Miranda `--`)
"miranda" - "mira" # → "nda"
"aa" - "a" # → "a" a bag, not a set — one `a` remains
"abc" - "ccar" # → "b" right-hand order does not matter
- walks the left string and drops a character whenever
the right still has a remaining occurrence of it. It is not substring
removal ("abab" - "ab" is "", not
"ab").
| Form | In Axioma |
|---|---|
"\n" "\t" etc. |
✅ decoded |
"\u{H...H}" (1–6 hex, braced) |
✅ decoded |
"\uXXXX" (4-hex, no braces) |
❌ use "\u{XXXX}" |
"\xHH" (byte hex) |
❌ use b"\xHH" for bytes |
"\N{NAME}" (Unicode name) |
❌ (future, optional) |
r"..." / r'...' |
✅ raw |
chr(N) / ord(s) |
✅ |
Unicode normalization —
normalize(s, [form])
The same visible text can arrive in different codepoint spellings:
é is either the single codepoint U+00E9 or e
followed by the combining acute U+0301. The two render identically but
are invisible to == and count
— one is 1 codepoint, the other 2. normalize rewrites a
string to a canonical form so comparison and counting behave:
"e\u{301}" == "\u{E9}" # false — the problem is real
normalize("e\u{301}") == normalize("\u{E9}") # true — NFC composes both
count("e\u{301}") # 2
count(normalize("e\u{301}")) # 1
normalize("\u{E9}", "NFD") # decomposes → "e\u{301}"
normalize("\u{FB01}", "NFKC") # "fi" — compatibility fold (fi)
normalize("\u{2460}", "NFKC") # "1" — ① folds too
try(normalize("x", "NFX")) # catchable Error naming the forms
Forms: NFC (default — the W3C/web recommendation and
JavaScript's String.normalize default),
NFD (decomposed),
NFKC/NFKD (additionally fold
compatibility characters — ligatures, circled digits, full-width forms).
The form name is case-insensitive. Normalize both sides
before comparing text from mixed sources (file systems, user input,
copy-paste — macOS file names, for instance, arrive decomposed).
Grapheme clusters remain out of scope: count counts
codepoints, so a combining sequence with no precomposed form still
counts per codepoint.
Substring search —
index_of and span_of
Two builtins locate a substring, both in 1-based character
(rune) coordinates — the same coordinates
substring, s[a..b] slicing, and
nth use, so a found position feeds extraction directly:
index_of("hello Lua users", "Lua") # 7 — 1-based START position
index_of("hello", "zz") # none — a miss is the falsy `none`
# (composes with if/??; never 0)
s, e: span_of("hello Lua users", "Lua") # (7, 9) — the (start, end) PAIR,
substring("hello Lua users", s, e) # "Lua" 1-based INCLUSIVE
span_of("abc", "z") ?? "absent" # miss → none (falsy; composes)
index_of("aXbXc", "X", 3) # 4 — optional init: search FROM there
span_of("aXbXc", "X", -2) # (4, 4) — negative init counts from
# the end; result is ABSOLUTE
index_of(collection, target [, init]) answers where
does it start — on strings, arrays, and tuples (a Set
errors: no positions — use in).
span_of(s, sub [, init]) answers where does it
live: a Tuple of inclusive (start, end)
positions, or none when absent. The optional
init starts the search at that position (below 1 clamps to
1; an empty needle matches at the clamped init, up to one
past the end), and because returned positions are absolute, the
scan-all-occurrences loop is direct:
p: index_of(haystack, needle)
while p > 0 [
# ... use p ...
p: index_of(haystack, needle, p + 1)
]
Rune coordinates mean multibyte text is safe by construction:
span_of("hällo wält", "wält") → (7, 10), and
substring with those positions returns exactly
"wält". For pattern-based (not literal) search, use the
regex family below; for a membership test alone,
contains(s, sub) / sub in s.
Regular expressions
— native regex_* builtins
Five native builtins wrap Go's RE2 engine (linear-time, no
catastrophic backtracking). Argument order is
(subject, pattern) throughout. An invalid
pattern returns a catchable Error value rather than
crashing the host.
| Builtin | Returns | Example → result |
|---|---|---|
regex_match(s, pat) |
Boolean |
regex_match("a1b2", "[0-9]") → true |
regex_find_all(s, pat) |
Array<String> |
regex_find_all("a1b2c3", "[0-9]") →
["1","2","3"] |
regex_replace(s, pat, repl) |
String |
regex_replace("John Smith", "(\\w+) (\\w+)", "$2 $1") →
"Smith John" |
regex_split(s, pat) |
Array<String> |
regex_split("one two", "\\s+") →
["one","two"] |
regex_captures(s, pat) |
Array<String> |
regex_captures("2026-06-14", "(\\d+)-(\\d+)-(\\d+)") →
["2026-06-14","2026","06","14"] |
regex_replace supports $1/$2
backreferences; regex_captures returns group 0 (the whole
match) followed by each capture group, or [] on no match.
Use a raw pattern (r"\d+\.\d+") to avoid double-escaping. A
typical tokenize-then-convert pipeline:
nums: regex_find_all("temp 38.5 hr 72 sat 0.97", "[0-9.]+") # ["38.5", "72", "0.97"]
float(nums[1]) > 38.0 # → true
Binary data — Byte and
Bytes
Distinct from Integer and String so the
type system can dispatch byte-specific operations and so
bs[0] == 0xff reads as a
Byte/Byte comparison rather than implicit
coercion. The cost is verbosity, the win is no silent UTF-8
corruption.
# Three construction forms
b1: byte(0xFF) # single byte, 0..255 — errors otherwise
b2: bytes(0x48, 0x69) # variadic — each arg 0..255 or Byte
b3: bytes([72, 105]) # from Integer array
b4: bytes("Hi") # from String (UTF-8 byte view)
b5: b"Hi" # b"..." literal
b6: b"\xff\x00\x01" # hex escapes (also \n \r \t \\ \" \0)
| Form | Result |
|---|---|
byte(0xFF) |
Byte(255) — explicit narrowing |
bytes(...) |
Bytes from variadic, array, or String |
b"..." |
Literal — escape-decoded at parse time |
int(b) |
Widen Byte → Integer |
Operations
# Length, indexing (1-based), slicing, concat, equality
len(b"hello") # 5
b"hello"[1] # Byte(104) — that's 'h'
b"hello"[2:4] # b"ell"
b"AB" + b"CD" # b"ABCD"
b"AB" == bytes(0x41, 0x42) # true
Conversions (explicit + fallible)
| Function | Returns | Errors when |
|---|---|---|
bytes_to_array(bs) |
Array of Byte (≡ bs[i]
elements; bytes(arr) round-trips) |
never |
bytes_to_hex(bs) |
"ff00ab" |
never |
hex_to_bytes(s) |
Bytes |
input has odd length or non-hex chars |
bytes_to_string(bs, "utf-8") |
String |
bytes aren't valid UTF-8 |
string_to_bytes(s, "utf-8") |
Bytes |
encoding unknown |
base64_encode(bs) / base64_decode(s) |
round-trip | decoder errors on bad input |
read_bytes(path) /
write_bytes(path, bs) |
file I/O | path missing / permission |
Bitwise ops — word-form infix (v3) + functional form
Symbolic bitwise operators (& |
^ << >>) all conflict
with existing Axioma syntax (& is address-of,
| is comprehension separator, ^ is
POWER). Word-form operators sidestep the conflict and match
Axioma's pattern of and / or /
not / union / intersect.
# Word-form infix (precedence: SUM — same as +/-, binds tighter than ==)
0xF0 band 0x0F # 0
0xF0 bor 0x0F # 255
0xFF bxor 0x0F # 240
1 bshl 4 # 16
0x80 bshr 4 # 8
0xFF band 0x0F == 0x0F # true — (0xFF band 0x0F) == 0x0F
# On two Bytes: stays Byte for &|^, also for shifts
byte(0xF0) band byte(0x0F) # Byte(0x00)
byte(1) bshl byte(4) # Byte(0x10)
# Mixed Byte/Integer: widens to Integer
byte(1) bshl 4 # Integer(16)
# Functional form (still available — useful when you need a callable)
bit_and(byte(0xF0), byte(0x0F)) # Byte(0x00)
bit_or(byte(0xF0), byte(0x0F)) # Byte(0xFF)
bit_xor(byte(0xFF), byte(0x0F)) # Byte(0xF0)
bit_not(byte(0x0F)) # Byte(0xF0)
bit_shl(byte(1), 4) # Byte(0x10)
bit_shr(byte(0x80), 4) # Byte(0x08)
reduce(bit_or, byte(0), bytes_to_array(bs)) # fold OR over bytes → Byte
The functional forms are ordinary builtins, and builtins are valid
higher-order callables — map / filter /
reduce / df_filter accept them directly
(map(bit_not, xs), reduce(bit_or, 0, xs)), the
same way the Enumerable verbs (sort_by,
detect, …) always have. The one exception is
partial, which needs declared parameters to curry and so
takes user functions only.
Shift counts must be 0..63. Shift by a larger amount errors rather than wrapping.
Arithmetic on Byte widens to Integer (no
overflow surprise — explicit byte((a+b) % 256) to
wrap):
byte(200) + byte(100) # Integer(300) — wider than 255
byte((int(byte(200)) + 100) % 256) # Byte(0x2c) = 44 — wrap explicitly
Integer literal prefixes
Adding bytes also brought standard hex / binary / octal literals to the language:
0xFF # 255
0xFF_AB # 65451 — underscores for readability
0b1010_1100 # 172
0o755 # 493
1_000_000 # 1000000 — underscores work in decimal too
These produce Integer, not Byte — wrap with
byte(0xFF) for the byte form.
AST inspection
fullform(b"AB") # "Bytes(65, 66)"
headof(b"AB") # "Bytes"
argsof(b"AB") # ["65", "66"]
fullform(byte(0xFF)) # "byte(255)" — AST of the call, not the value
Design notes
Bytedisplays as decimal.byte(0xFF)prints as255— the Python / Go / Rust convention for byte values. For a hex rendering usebytes_to_hex(bytes(b)). (Byteskeeps theb"..."literal form with\xffescapes.)- Encoding-aware separation.
Bytesdoesn't carry encoding metadata; conversion toStringis explicit and fails on invalid input. - Immutable. Operations return new
Bytesrather than mutating in place — composes cleanly with comprehensions, rule derivation, and the VM's bytecode constant pool. - 1-based indexing matches
Array/String/Tuple. - A
Stringindexes by CHARACTER (rune), aBytesby byte."ääb"[2]→"ä"(the 2nd character; negative indices count characters from the end,"résumé"[-1]→"é"), consistent withnth/substring/ the slice forms / the ordinal accessors — sos[i] == nth(s, i)always, under both runtimes. Byte-level access on a String is explicit:string_to_bytes(s)[i]→ the i-th byte. (Before July 2026,s[i]selected the i-th byte and could return mojibake on multi-byte characters; the VM rejected string indexing entirely.) - Colon-slice
x[lo:hi]is 1-based and INCLUSIVE acrossBytes/Array/String/Tuple— identical tox[lo..hi], so"hello"[2:4]→"ell"(3 elements). This is not Python's 0-based half-open slice ("hello"[1:3]→"el", 2 elements); pasting a Python slice yields a different, silently-valid result. Use..(inclusive) or..<(half-open) when you want to be unambiguous about the bound style. - Slice edge policy (all spellings —
lo:hi,lo..hi,lo..<hi): a negative bound counts from the end, exactly as a negative single index does (xs[-1]is the last element):xs[-3..-2]→ the 3rd- and 2nd-from-last elements,xs[-3..]→ the last three,"cynic"[2..-2]→"yni",xs[: -2]→ all but the last (the end is inclusive, soxs[: -1]is the whole sequence;xs[1..<-1]stops before the last). In the colon spelling put a space before a negative end —xs[2: -2]— because:-lexes as the rule neck. Out-of-range bounds clamp ("hello"[5:10]→"o",xs[2..9999]→ the tail,xs[-10..2]→xs[1..2]) and a reversed window yields the empty value ("hello"[4:2]→"",a[2..1]→[],xs[-2..-3]→[]— so the recursion idiomarr[2..len(arr)]is safe on a 1-element array with no guard). A slice never raises an out-of-bounds error. The two-bound../..<forms lower to the same slice node as:at parse time, so all three run identically under--vm; an end-lessxs[2..]slices to the end (≡xs[2:]— arrays, strings, tuples). Descending selection spells its step explicitly —xs[5..1..-1]→ reversed (the steppedx[lo..hi..step]form keeps the directional range path, evaluator-only); a step-lessx[4..2]is an empty window, not a reversal. $in[]is last-index of the collection being indexed (1-based, so$equalslen(xs)):xs[$]is the last element,xs[$-1]the second-to-last,"Julius Caesar"[8:$]→"Caesar","Julius Caesar"[$-3:$]→"esar". Nesteda[b[$]]binds$tob. On a Matrix,$is last-index of the axis it sits on (A[:, 2:$]is columns 2 through last;A[$, 1]is the last row).$5is money and$nameis the identifier guard — those are not last-index.endis the block closer (xs[end]is a SyntaxError).$is not a length prefix on a named collection ($xsis the identifierxs);for i in 1..$is a SyntaxError. Loop with an index asfor e at i in xs.- No infix bitwise ops in v1 — adding them would touch lexer + parser + VM. Functional form unblocks bit work now; infix sugar can come later.
- Bit pattern matching (Erlang's
<<v:4, len:16, payload/binary>>— the most expressive byte primitive of any language) is provided in v2 aspack/unpackbuiltins using Python'sstruct-style format strings (see next subsection).
See tests/axioma/bytes/test_bytes.ax for a 48-assertion
smoke test that runs identically under both the evaluator and
--vm.
Binary serialization —
pack / unpack
Python-struct-style format strings. pack
serializes values into Bytes; unpack reads
Bytes back into a Tuple of typed values. The
format spec is small enough to memorize:
bs: pack(">BBH", 1, 2, 256) # b"\x01\x02\x01\x00" (4 bytes)
header: unpack(">BBH", bs)
println(header[1], header[2], header[3]) # 1 2 256
# TCP-header-style parse: port (u16), length (u16), flags (u16), seq (u32)
packet: b"\x04\xd2\x00\x10\x00\x20\x00\x00\x00\x01"
parts: unpack(">HHHI", packet)
println(parts[1], parts[2], parts[3], parts[4]) # 1234 16 32 1
Format string grammar:
Endian prefix (optional):
<little,>big,!network (= big),="native" (= big). Default is big-endian.Type codes (each optionally preceded by a count):
Code Size Pack from Unpack to Notes b/B1 Integer / Byte Integer / Byte signed / unsigned 8-bit h/H2 Integer Integer signed / unsigned 16-bit i/I4 Integer Integer signed / unsigned 32-bit q/Q8 Integer Integer signed / unsigned 64-bit f4 Float Float IEEE 754 single d8 Float Float IEEE 754 double sN String / Bytes Bytes counted byte field, pads/truncates c1 Bytes(1) Bytes(1) single-byte field x1 (none) (none) pad byte — consumes 0 values Count prefix:
4B= four unsigned bytes (consumes 4 args). Fors, the count is the byte-length of the field:4spacks/unpacks a 4-byte string field.
Out-of-range values error rather than silently wrap:
pack(">B", 300) # ERROR: pack 'B': value 300 out of range 0..255
pack(">b", 200) # ERROR: pack 'b': value 200 out of range -128..127
Round-trip identity:
unpack(">d", pack(">d", 3.141592653589793))[1] # 3.141592653589793
unpack(">q", pack(">q", -9_000_000_000))[1] # -9000000000
See tests/axioma/bytes/test_pack_unpack.ax for the full
48-assertion smoke test.
Slicing (v2)
Bytes slicing now works under --vm (the
OpSlice opcode added in v2 also unlocked slicing for
String, Array, and Tuple in the
bytecode engine):
b"Hello World"[1:5] # b"Hello"
b"hello"[3:] # b"llo" — open end
b"hello"[:3] # b"hel" — open start
Endian-aware read/write at offset (v3)
Offset-style protocol parsing sugar over
pack/unpack. Each builtin reads or writes a
single field of fixed width at a given 1-based offset
(matching Axioma's indexing convention). Reads return values; writes
return a new Bytes (originals are
immutable).
| Builtin | Returns | Notes |
|---|---|---|
read_u16_be(bs, off) /
read_u16_le(bs, off) |
Integer 0..65535 | unsigned 16-bit |
read_i16_be / read_i16_le |
Integer ±32767 | signed 16-bit (sign-extended) |
read_u32_be / read_u32_le |
Integer 0..2^32-1 | unsigned 32-bit |
read_i32_be / read_i32_le |
Integer ±2^31 | signed 32-bit |
read_u64_be / read_u64_le |
Integer | may wrap to negative for > int64 max |
read_i64_be / read_i64_le |
Integer | signed 64-bit |
read_f32_be / read_f32_le |
Float | IEEE 754 single |
read_f64_be / read_f64_le |
Float | IEEE 754 double |
write_* (same suffix family) |
Bytes | new copy with field overwritten |
# Parse a 10-byte packet (u16 port, u16 length, u16 flags, u32 seq)
packet: b"\x04\xd2\x00\x10\x00\x20\x00\x00\x00\x01"
port: read_u16_be(packet, 1) # 1234
length: read_u16_be(packet, 3) # 16
flags: read_u16_be(packet, 5) # 32
seq: read_u32_be(packet, 7) # 1
# Build a response — start with zeros, overwrite fields
resp: bytes(0, 0, 0, 0, 0, 0, 0, 0)
resp1: write_u16_be(resp, 1, port)
resp2: write_u16_be(resp1, 3, length)
# resp is still b"\x00\x00\x00\x00\x00\x00\x00\x00" — original untouched.
These compose with pack/unpack: round-trip
identity holds. Use them when you want to read individual fields at
known offsets without computing slice ranges, or when you want to mutate
a buffer in protocol-style.
See tests/axioma/bytes/test_bitwise_endian.ax for a
39-assertion v3 smoke test that runs identically under both engines.
Scalar value types & literals
Axioma has a family of lexer-recognized scalar
literals — you write a URL, an e-mail address, a file path, a
date, money, or a 2-D pair directly, and the lexer gives it a
first-class type. type() returns a TitleCase string that
agrees with the @ sigil and the primitive-type concepts
(@v, type(v), v is T and
x :: T all match).
Two of these names do double duty.
URLis also the knowledge-graph link entity you build withentity URL { url: … }, andTimeis also Kant/Schopenhauer's pure form of intuition (theformargument totranscendental). Both senses answer:https://a.com is URLandmyLink is URLare eachtrue, and neither steals the other's answer — a scalar settles on its type, an entity on the concept it was built from.
| Type | Literal | type() |
Predicate | Notes |
|---|---|---|---|---|
URL |
https://example.com/path |
"URL" |
is_url |
http:// / https://; read()
fetches it |
Email |
[email protected] |
"Email" |
— | local@domain shape |
File |
%data/file.txt |
"File" |
— | % + path; relative (see the gotcha
below) |
Date |
2026-05-02, 1-Jan-2024,
7/2/26 |
"Date" |
— | several spellings |
Time |
12:34:56 |
"Time" |
— | HH:MM:SS |
Money |
$123.45 |
"Money" |
is_money |
$ + digit |
Pair |
'X' => 10, pair(k, v) |
"Pair" |
is_pair |
key–value cell; not a Tuple, not a Dictionary. 100x200
is retired |
Percent |
42% |
"Percent" |
is_percent |
number + % |
Word |
'hello |
"Word" |
is_word |
self-evaluating symbol (the label) |
GetWord |
:name |
"GetWord" |
— | reads a value without evaluating |
type(https://example.com) # → "URL"
type($123.45) # → "Money"
type('X' => 10) # → Pair
type(42%) # → "Percent"
is_url(https://example.com) # → true
is_pair('X' => 10) # → true
is_url($5) # → false
12:34:56 is Time # → true
"12:34:56" is Time # → false
https://a.com is URL # → true
Both duties of a double-duty name answer, and neither shadows the other:
myLink: entity URL { url: "https://example.com" }
myLink is URL # → true (the graph entity)
https://a.com is URL # → true (the scalar)
myLink is Integer # → false (no over-classification)
Sigil disambiguation — position and the next character
decide. $ + digit is Money
($5), otherwise $name / $"…" is a
guarded identifier. % + alphanumeric is a File
(%data) — but only in prefix position: a
% glued to the tail of a preceding value is the modulo
operator (7%2 → 1), and a number takes a
trailing % as a Percent literal only when no
letter or digit follows (50% is a percent;
100%7 is 100 mod 7). # is the
line comment wherever it appears outside a string, glued or spaced —
#xs and # xs both comment to end of line,
exactly like //. The 2026-09-03 flip retired both the
#123 Issue literal and the #xs prefix length;
write len(xs). HashWord labels were retired earlier — write
'hello.
Arithmetic on Money /
Percent
$100 + $25 # → $125
$100 * 1.5 # → $150
10% * 200 # → 20 (percent of a number)
10% * $200 # → $20 (percent of money)
10% + 5% # → 15% (percent ± percent stays a Percent)
$200 + 10% # → $220 (base + p% — increase by p percent of itself)
$200 - 10% # → $180 ; 100 + 10% → 110 (plain numbers too)
Percent ± percent stays in percent space
(10% + 5% == 15% → true; comparisons return
Booleans). base ± p% adjusts the base by p percent
of itself — the percent goes on the right
($200 + 10%, not 10% + $200), the money result
is exact and displays to cents (so $19.99 + 7.25%
shows $21.44 though its value is exactly
$21.439275 — see "Money is exact" below), and left-assoc
chaining compounds sequentially ($200 + 10% + 5% →
$231). Compound assignment inherits:
price += 10%. Multiplication is still the only
other operator that crosses Percent with numbers or Money —
50% * 25%, 10% / 2, and mixed equality
(10% == 0.1) remain errors.
Money is exact
Money carries an exact rational value —
$1/3 is exactly one third of a dollar. Arithmetic never
rounds; rounding happens at exactly two points —
display and the explicit
round(money, places) — and nowhere
else.
($1 / 3) * 3 # → $1 EXACTLY — the third comes back
$1234567.07 + $0.01 # → $1234567.08 (no float drift)
($0.01 / 3) * 3 # → $0.01 a penny split three ways survives
$5 == $5.00 # → true ; len({$5, $5.00}) → 1 ; $5 in {$5.00} → true
$100 * 0.1 # → $10 — a Float scalar is read by its DECIMAL spelling,
# so no binary-0.1 drift (use a Rational for a
# value a decimal cannot spell)
Display rounds to the cent (half-to-even), so a non-terminating amount looks like an ordinary price — but the stored value is exact, and comparison uses it:
"" + ($1 / 3) # → "$0.33" — DISPLAYS rounded…
($1 / 3) == $0.33 # → false — …but a third of a dollar is not 33 cents
round($1 / 3, 2) # → $0.33 — round() materializes cents as an exact value
round($1 / 3, 2) == $0.33 # → true
round($1 / 3, 2) * 3 # → $0.99 — rounded, then tripled, loses the third
So $1/3 and $0.33 print alike yet are
different amounts (and key differently in a set) — the same "display is
a rounded view" that Float already has. When you genuinely
want cents, round(m, 2) is the one operation that rounds
the value. (Cross-currency arithmetic still errors; display is
two decimals for every currency.)
Quantity — units of measure
A Quantity is an exact rational tagged with a
physical (or custom) dimension — the science twin of Money. Currency
stays Money; wall-clock time stays Duration; the Schlick
magnitude(value, "kg") entity stays a float-based KR value.
They do not mix.
5 * kg # everyday form
qty(9.8, metre / sec^2)
qty(1, kg) == qty(1000, gram) # true — same SI base
qty(1, kg) == 1 # error
$5 + 5 * kg # error
unit share # custom dimension
unit m = metre # short alias
as_unit(5 * kg, gram) # displays as 5000 gram; still == 5*kg
mag(as_unit(5 * kg, gram)) # 5000
dim(metre / sec^2) # "m/s^2"
unit is a soft keyword (unit: 5 still
binds). There is no juxtaposition 5 kg (already a
SyntaxError) and no F# 5.0<kg>. Convert with
as_unit, not in (in is
membership).
The prelude seeds kg,
metre/meter, sec (the SI second),
gram, newton, joule,
watt, ampere, kelvin,
mol, ohm, and a few prefixed names
(km, cm, mm, ms).
Short letters
m/s/A/N/g
are not seeded — they would silently change
2 * A. Write unit m = metre or use the long
name. The identifier second is the ordinal accessor
(second(xs), xs.second) — first..tenth — not
the SI unit; write sec or qty(1, "second").
min / h / day stay unseeded (they
collide with min the function and with Duration).
DateTime &
Duration — the datetime package
The lexer's Date / Time literals above are
date-only / time-of-day scalars. Full timestamps and elapsed-time values
are the DateTime and Duration types, built by
the datetime package — ambient since July
2026 (datetime.now() works with no import; see
§27.2), with import … as remaining the renaming
spelling:
import "builtin:datetime" as Dt
bday: Dt.date(1990, 5, 15) # midnight timestamp
type(bday) # → "DateTime"
Dt.year(bday) # → 1990 (month / day / hour / minute / … too)
Dt.format(bday, "2006-01-02") # → "1990-05-15" (Go layout strings)
gap: Dt.time_between(Dt.date(1990, 6, 15), bday)
gap # → 744h0m0s (later argument FIRST — earlier-first is negative)
type(gap) # → "Duration"
Dt.format(Dt.add(bday, gap), "2006-01-02") # → "1990-06-15" (add a Duration to a DateTime)
type(Dt.now()) # → "DateTime" (Dt.utc() for UTC)
is_datetime(bday) # → true ; duration?(gap) → true
The package also carries
Dt.datetime(y, mo, d, h, mi, s), Dt.from_unix
/ Dt.unix, Dt.from_string /
Dt.strftime, calendar accessors (weekday /
yearday / week), and
Dt.add_date(t, years, months, days).
Words — 'w, :w
A word is a value (a symbol), distinct from a variable you look up.
'hello # → 'hello (natural word — self-evaluating)
x: 42
:x # → 42 (get-word — reads the bound value, no call)
:xis the get-word;@x/:: xare type-of.:: x(and@x) return"Integer"(the DataType tag ofx), while:xreturns42(the value). Use:xto read a value without evaluating it as a call.#is a comment.#xs(prefix length) and#123(Issue) are retired as of 2026-09-03 — writelen(xs). HashWord labels (#helloas a value) were retired earlier — write'hello.
read and file I/O
read(source) is the file/URL read verb — one argument,
returns a String:
source |
Example | Behavior |
|---|---|---|
String path |
read("/tmp/notes.txt") |
read the file |
String / URL http(s) |
read(https://example.com) |
HTTP GET → body |
%file literal |
read(%data/notes.txt) |
read the (relative) file |
f: "/tmp/notes.txt"
write(f, "Hello from Axioma!") # → true
file_exists(f) # → true
read(f) # → "Hello from Axioma!"
append(f, " More.") # → true
remove(f) # → true (delete the file)
file_exists(f) # → false
write / append return a
Boolean; non-string content is stringified. A failed
read (missing file, network error) returns a catchable
Error value, not a crash. For a richer path/line API,
import "builtin:io" as IO exposes
IO.read_file, IO.read_lines,
IO.join_path, etc.
The built-in os namespace is the preferred home for
directory operations. The io spellings remain supported as
exact aliases:
| Preferred name | Compatibility aliases | Result |
|---|---|---|
os.pwd() |
io.pwd(), io.get_cwd() |
Current working directory as a String |
os.cd(path) |
io.cd(path), io.change_dir(path) |
Change the process working directory; return true |
os.ls() or os.ls(path) |
io.ls(), io.list_dir() (also accept a
path) |
Array of entry names, sorted by name; defaults to the
current directory |
os.mkdir(path) |
io.mkdir(path), io.create_dir(path) |
Create a directory and missing parents; return
true |
These are exact aliases; the longer names remain supported. Failures
return the same Error values and diagnostics as the longer
names. pwd and ls return values without
printing. os.ls() lists the current directory at the time
of the call, as does os.ls("."); the io
aliases share this default. An explicit path must be a String or File.
An empty String remains an error. cd and mkdir
each require a path. mkdir is recursive, like shell
mkdir -p; these functions do not interpret shell flags or
expand wildcards.
println(os.pwd())
println(os.ls())
Naming gotchas (reserved-word collisions).
- Existence is
file_exists(path), notexists(...)— the bare wordexistslexes as the existential quantifier (∃), soexists("/p")is a parse error.file_existsmatches the io package'sIO.file_exists.- File deletion is
remove(path), notdelete(...)— prefixdelete a[i]/delete h[k]retracts a collection place;remove(path)deletes a file.Tag(<tag>) is shadowed by the natural-language<…>literal, sotype(<html>)is"NaturalLanguage", not"Tag"— treat<tag>as unavailable from source.- Absolute
%/abs/pathdoes not lex (the character after%must be alphanumeric); use theStringformread("/abs/path")for absolute paths.
Runnable tour:
tests/axioma/rebol/rebol_data_types_showcase.ax. Assertion
tests: tests/axioma/rebol/test_rebol_datatypes_and_read.ax
and tests/axioma/io/test_io_functions.ax. Full reference:
resources/docs/Data Types.md.
Ranges — ordered
a..b, exclusive ..<, by step,
open n..
a..b is a first-class ordered
Range value — it knows its direction, its step, and
(optionally) that it has no end. It displays compactly, tests membership
in O(1), and iterates in source order everywhere:
r: 1..5 # an ordered Range value (displays as 1..5)
type(r) # → "Range"
5..1 # descending — walks 5, 4, 3, 2, 1
1..<5 # exclusive upper bound — 1, 2, 3, 4
1..10 by 3 # stepped — 1, 4, 7, 10 (`by` takes a positive
10..1 by 3 # MAGNITUDE; direction comes from the operands:
# 10, 7, 4, 1)
1..10..3 # SIGNED-step spelling — 1, 4, 7, 10 here, but NOT a
10..1..-3 # mere alias of `by`: the sign gives the direction
# (→ 10, 7, 4, 1), and a sign that contradicts the
# bounds yields an EMPTY range (10..1..3 → {}) where
# `by` refuses outright (1..10 by -3 → error).
# `range(a, b, step)` follows the SIGNED rule.
# Both spellings read bare in a loop header:
# `for i in 10..1..-3 […]`, `repeat i <- 1..10..3 […]`.
"a".."e" # character ranges too (single-character bounds)
2.. # OPEN-ENDED — lazy and infinite: 2, 3, 4, …
50 in 1..100 # → true — O(1), never materializes
3 in 1..10 by 2 # → true; 4 in 1..10 by 2 → false (the step grid counts)
(1..5).length # → 5 — the sequence accessors work (§5)
array(5..1) # → [5, 4, 3, 2, 1] — materialize in SOURCE order
Ordered in, ordered out. Every consumer walks a
range in its own order: foreach/for loops,
comprehensions ([x * x | x <- 5..1] →
[25, 16, 9, 4, 1]), and the collection builtins —
map/filter over a range return an ordered
Array, reduce/foldr fold in
source order, and
nth/second…tenth/sample/sort/min/max/sum/product/zip/
enumerate all take ranges directly. (Before July 2026 a
range materialized as an unordered Set, which is how
zip(1..len(a), a) could silently scramble — the motivating
bug for the Range type.)
Set operations decay to Set. A range used where a
set is meant becomes the set of its points:
(1..5) union {9}, (1..9) intersect (4..12),
set(1..3), powerset(1..3), and the
{1..5, 99} set-literal splice all produce ordinary
Sets.
Open-ended ranges are lazy. 2.. never
materializes; bounded consumers work and unbounded ones refuse
loudly:
first(2.., 4) # → [2, 3, 4, 5] (pull as many as you ask for)
zip(1.., ["a", "b"]) # → [(1, "a"), (2, "b")] — truncates to the finite side
9 in 2.. # → true (arithmetic, still O(1))
first((x * x | x <- 1..), 5) # → [1, 4, 9, 16, 25] — lazy comprehensions stream
foreach k in 1.. [ … if done then [break] ] # loop until YOU stop it
sum(1..) # catchable Error — as are len, array, map, sort,
# set-op decay, `.last`, and every other materializer:
# an open range never hangs the interpreter
Open ranges are exact past machine precision —
first(2^100.., 2) returns [2^100, 2^100 + 1]
exactly. A finite range with bounds beyond int64 errors
loudly instead of clamping (spell it open if you need the big
start).
Two spelling notes. The end of a range must sit on the same
line as the .. — a .. that ends the
line reads as an open range, so a line-broken 1.. +
5 is two statements. And in index position
a missing end means "to the end": a[2..] slices from
position 2 (≡ a[2:]), following the slice clamping rules in
the Bytes design notes above.
The literal replaces most uses of the older builders, which remain:
range(5) → the Range 1..5
(1-based, inclusive) and setrange(1, 5) → a Set.
range(a, b[, step]) is the call spelling of the same value
the .. operator builds, so range(1, 5) == 1..5
— it returned an eager Array until July 2026, which made it the one
constructor whose result type disagreed with its name. Write
array(range(…)) where you want the materialized list.
Runtimes agree — the VM compiles ranges to the same Range
value (byte-identical output, pinned by
tests/axioma/cli/test_ordered_range_vm.sh).
A Range reads like an Array wherever reading makes sense:
len, sum, sort,
min/max,
map/filter/reduce,
first/last/nth, in,
zip, indexing r[2] (negative counts from the
end), slicing r[2:4], reverse,
contains, count, index_of,
unique, rest, and [h | t]
destructuring. Every one of them answers exactly what it answers for
array(r). Writing is not in the surface — push
on a Range is an error, by design. An open range
answers where arithmetic can ((1..)[4],
contains(1.., 400)) and otherwise raises a catchable
"cannot materialize an open-ended range" rather than walking
forever.
Tests: tests/axioma/types/test_ordered_range.ax,
test_range_step_by.ax,
test_range_open_ended.ax,
tests/axioma/builtins/test_range_builtin_consumers.ax.
5. Collections & Stacks
Arrays
xs: [1, 2, 3]
xs[1] # 1
len(xs) # 3
An Array is a place: it has identity over time,
aliases see writes and growth, and (since the July 2026 flip)
the growth verbs mutate in place — push/append
grow, insert_at/remove_at splice,
pop / shift remove and return an end, all at
amortized O(1) for the tail forms. Because a place is something you can
command, an Array also answers the same message
verbs a Stack does. A verb is a statement, and it
dispatches wherever a statement lives — bodies, loop bodies, and (since
July 2026) then/else branches and match arms,
whose brackets read as bodies:
a: [1, 2, 3]
a push 4 # in place, alias-visible (append / add are synonyms)
a extend [5, 6] # concat in place — append would NEST [5, 6] as one element
a pop # drops the last element (capture it with pop(a))
a shift # drops the first element (capture it with shift(a))
a unshift 0 # prepends; the front twin of push
if len(a) > 2 then [ a pop ] # a branch takes commands too — and the
# branch's value is the popped element
a clear # empties the place — a is [] and still an Array
sort!(a) # in-place sort; sort(a) is the copy. Aliases see it.
reverse!(a) # in-place reverse; reverse(a) is the copy
unique!(a) # drop later duplicates in place; unique(a) is the copy
shuffle!(a) # in-place shuffle; shuffle(a) is the copy
The front of the array is the same place. shift /
unshift are verbs, so they mutate without bang — Julia
writes shift! / unshift!; Axioma does not
(push! stays Undefined word). splice! keeps
the bang because unbanged splice is quasiquote
splicing.
b: [1, 2, 3, 4]
shift(b) # → 1 — b is now [2, 3, 4]
unshift(b, 0) # → [0, 2, 3, 4]
splice!(b, 2) # → 2 — b is now [0, 3, 4]
mid: [2, 3]
ast_eval(quasiquote([1, splice(mid), 4])) # → [1, 2, 3, 4]
shift(a) is delete a[1];
unshift(a, v) is insert_at(a, 1, v);
splice!(a, i) is delete a[i]. Capture the
removed value from the call; remove_at(a, i) still yields
the array.
Positional surgery keeps the function spellings
(insert_at(a, i, x), or two-arg
insert_at(a, x) which appends;
remove_at(a, i)), plus prefix delete and slice
assignment:
xs: [10, 20, 30, 40]
delete xs[2] # → 20 — xs is [10, 30, 40]
delete xs[2:3] # → [30, 40] — xs is [10]
xs[1:1] = [1, 2] # replace a window; length may change
extend(xs, [3, 4]) # concat in place; a extend other too
delete yields the removed value, so
old: delete xs[1] binds it. remove_at(a, i)
still mutates and returns the array (not the element).
copy(a) is the escape hatch when you need a value snapshot.
Contrast the List below, which answers no imperatives
at all — that asymmetry is the doctrine: Array is a place; List is a
value.
delete is a prefix expression over a place
(a[i], a[i:j], h[k]). It is not a
file delete (remove(path)), not variable unbind (REPL
word x delete), and not postfix Foo delete
(deprecated concept destroy — write Foo destroy). Bare
delete x is refused. An open-map miss yields
none; h["k"]: none is not
deletion — the key stays in keys(h).
Index assignment (a[i]: v) writes a
live slot. A write past the end is an error — growth is
a verb, not a stray index. The error names the two ways forward: grow
with append(a, v) or insert_at(a, v), or
pre-size and then fill (fill(n, none) or
[none,] * n, after which a[j]: v is an
ordinary in-bounds store). The comprehension
[none | i <- 1..n] is the same pre-size, written as a
generator.
[](5) # [none, none, none, none, none]
[](5, 0) # [0, 0, 0, 0, 0]
fill(5, none) # same as [](5)
fill(5, 0) # same as [](5, 0)
[0,] * 5 # sequence repeat; 5 * [0,] too
[1, 2] * 3 # [1, 2, 1, 2, 1, 2]
[1, 2] + [3] # → [1, 2, 3] concatenate
[1, 2, 3] .+ [4, 5, 6] # → [5, 7, 9] elementwise (not concat)
[1, 2, 3] .* 2 # → [2, 4, 6] elementwise (not repeat)
[1, 2, 3] .^ 3 # → [1, 8, 27]
[1, 2, 3] ./ 2 # → [1/2, 1, 3/2] exact — integer / stays Rational
[7, 8, 9] .% 2 # → [1, 0, 1] elementwise mod
[7, 8, 9] .÷ 2 # → [3, 4, 4] elementwise floor div (not ./)
# The dotted twins bind like their plain ones, above `..`: 1..5 .^ 2 is
# 1..(5 .^ 2) and refuses. Write array(1..5) .^ 2, or [x ^ 2 | x <- 1..5].
# Nested arrays are not a matrix: [[1,2],[3,4]] .^ 2 is an error.
# Spell matrix(xs) for linear algebra, or map(f, xs) for a function.
# Word operators have no dotted infix: map(x => x div 2, xs), not xs.div 2.
[1, 2, 3] - [2] # → [1, 3] bag difference
[1, 1, 2] - [1] # → [1, 2] a bag, not a set — one `1` remains
[1, 2, 3] - [3, 1] # → [2] right-hand order does not matter
xs -= [2] # compound assignment inherits: `xs = xs - [2]`
set(32) # empty set, capacity 32
{}(32) # same
dict(32) # empty dict, capacity 32
a: [10, 20, 30]
a fill 0 # [0, 0, 0] — paint live slots
fill(a, 7) # same command, function form
# not the matrix constructors (those need two dimensions):
# zeros(5) / ones(5) / array(5) error and point at fill / [](n, v)
A trailing comma is allowed, and it is the way to
write a one-element array unambiguously:
[x,]. This matters because [...] is also a
block body (in if cond then [body] else [body] the branch
brackets are blocks, so a bare single-statement [x]
evaluates to x). The comma forces the array
reading in every position:
[5,] # → [5] (a 1-element array)
["w1",] # → ["w1"] (len 1 — NOT the string "w1")
[1, 2, 3,] # → [1, 2, 3] (trailing comma in n-arrays too)
if c then ["a", "b"] else ["w1",] # else-branch is a 1-element array
if c then [42] else [0] # bare [x] → the scalar 42 / 0 (block body)
Since the PiL ch5 parity round (July 2026) the trailing comma is
valid in all three literal forms — {1, 2,}
(set) and {a: 1, b: 2,} (dict) parse like
[1, 2,] — so generated constructors never special-case the
last element. It also holds inside a function body,
where [x,] used to be a SyntaxError.
What a bracket means, by position
A body is a statement sequence; a
branch and a value binding are
expression positions. They agree except on a bare comma run, which
reduces as Pop-11 postfix in a body (2, 3, + →
5, the third notation for the same identity) and builds an
array in the other two:
| written | body func f() […] |
branch then […] |
value v = […] |
|---|---|---|---|
[e] |
e |
e |
[e] array |
[stmt ⏎ stmt] |
last value | last value | last value |
[a, b] |
Pop-11 sequence | [a, b] array |
[a, b] array |
[e,] |
[e] array |
[e] array |
[e] array |
[s pop] |
message send | message send | ["s", "pop"] word list |
[] |
none |
[] |
[] |
The e reading belongs to the bracket that
is the branch, body, or arm — then [e],
else [e], => [e],
| p => [e]. A bracket nested anywhere inside that
expression is an ordinary array: if c then f(1, [2]) else 0
passes [2] to f,
(p) => f(1, [p]) passes [p], and
then len([2]) + 10 is 11 (before 2026-08-23 the nested
bracket took the block reading and f received
2).
The difference is deliberate and load-bearing in both directions:
making the branch a body slot costs the [x,] idiom and
every array-returning branch, while making a body's commas build arrays
removes Pop-11 postfix from function bodies. Write [e,]
when you want the array in any position, and parenthesize a tuple
([(a, b)]) when you want one in a body.
A bracket that opens with something only a statement
can be — if, match, case,
cond, return, break,
continue, rebind, let x = …,
global, a where section, a multi-assignment, a
destructuring binding, or a named function definition
(f(x) = …, func f(x) = …,
func f(x) [ … ]) — is a block wherever it is written,
because none of those shapes has an array reading at all:
[ f(x) = x + 1 f(1) ] # → 2 — a definition may open a block
r: [ double(n) = n * 2 double(4) ] # → 8
[ f(x) ] # → an ARRAY holding one call result
[ func(x) [x] ] # → an ARRAY holding an anonymous lambda
The = after the head is what decides it: a bare call is
an ordinary array element, and func claims the block only
when a name follows (func( is the
anonymous lambda). Before 2026-08-23 a definition parsed only if it was
not the block's first statement.
The [s pop] row is the July 2026 alignment: a branch
bracket (and a match arm's) that opens with a lowercase word
pair reads as a statement, so the message verbs
dispatch there exactly as in a body — then [ s pop ] pops
instead of silently building the word list ["s", "pop"].
The Pop-11 quoted word list keeps its meaning in value and argument
position, in any bracket nested deeper inside the branch, and whenever
the first word is capitalized ([Mary likes wine]).
Positional access on sets, list surgery, and range loops (PiL ch5 round)
Four additions from the Programming in Lua chapter-5
comparison (July 2026), each with full --vm parity:
s: {30, 10, 20}
s[1] # → 10 — the index operator now answers on sets,
s[-1] # over the SAME canonical sorted order that
s[1..2] # nth/first/`.second` always used (s[i] ≡
# nth(s, i) by construction); slices come back
# source-shaped ({10, 20}); s[1] = x stays a
# loud error — sets have no positions to WRITE
insert_at([10, 20, 30], 1, 15) # → [15, 10, 20, 30] — Lua's exact
remove_at([10, 20, 30], 1) # → [20, 30] table.insert/remove:
remove_at([10, 20, 30], -1) # → [10, 20] they MUTATE in place
# and return the same array (the July
# 2026 dynamic-array flip; copy() first
# for a functional version; pop removes
# AND returns the last element)
foreach i in 1..len(xs) [ … ] # bare ranges after `in` — the Lua/Python
foreach i in 2..n [ … ] # numeric-for idiom, no parens needed
foreach i in 1..xs.length [ … ] # property access works in bare bounds
foreach v in d.xs [ … ] # … and as the iterable itself; chains
# (d.inner.ys, len(d.xs)) compose too
The range spelling also fixed a latent ordering bug: ranges (and set
iteration generally) used to walk in randomized Go-map
order — foreach i in (2..n) could visit 3, 4, 2 on
one run and 2, 3, 4 on the next. Ranges now iterate
numerically and sets iterate in their canonical
sorted order, so every loop is deterministic run to run. (Since
July 2026 that guarantee is structural: a..b is a
first-class ordered Range value with
direction, step, and open-ended forms — see Ranges in
§4.)
Classic pitfall
— seeding an accumulator with 0
The most common bug in a hand-rolled maximum loop is invisible on the data you first test with:
maximum: func(a) [
best: 0 # ← the bug: a hidden claim that
at: 0 # every element is ≥ 0
for i, e in enumerate(a) [
if e > best then [
best = e
at = i
]
]
return (best, at)
]
maximum([8, 10, 23, 12, 5]) # (23, 3) — looks perfect
maximum([-4, -2, -9]) # (0, 0) — silently WRONG: no element
# beats the seed, and 0 isn't even
# a valid position
The seed of an accumulator loop must be either the
operation's identity element or a value from the data
itself. 0 is the identity of + (and
1 of *) — the identity of max is
negative infinity, which is why the 0 seed
smuggles in an assumption. Three correct forms, most idiomatic
first:
max(a) # the builtin — max([-4, -2, -9]) → -2;
(max(a), index_of(a, max(a))) # pair it with index_of for the position
best, at: a[1], 1 # seed FROM THE DATA — works for any
# numbers; empty input now errors loudly
# (guard with a.empty) instead of
# returning a plausible wrong answer
best: -inf # or seed with max's true identity —
# every number beats -inf
The transferable habit: a constant seed encodes a claim about
the data — test the claim, not just the happy path. Two inputs
kill this whole bug class in review: a list of negatives and the empty
list. (Note max([]) returns none — the
builtins follow the query-never-crashes convention; a hand-rolled
version must choose its own empty-input answer.) Axioma can also make
the claim executable: declare
ensures: result[1] in a on the function and every call —
and check maximum over random inputs — polices it. See Contracts —
requires / ensures /
check f.
Sequence
accessors — xs.length, xs's first,
xs.indexed
Array, Tuple, String, Set, and Range carry a small closed set
of read-only accessors, available in both the dot and the
possessive spelling. Each mirrors its builtin twin
(length/size ≡ len,
first/last ≡
first()/last(), indexed ≡
enumerate(xs)):
| Accessor | Returns | Notes |
|---|---|---|
length / size |
Integer | ≡ len(xs) (String: byte length) |
first / last |
edge element | none when empty — a query never crashes |
second … tenth |
k-th element | ordinals ≡ second(xs)…tenth(xs);
none when out of range |
empty |
Boolean | the guard for first/last |
indexed / enumerated |
Array of (i, e) pairs |
1-based, index-first — the indexed view for loops and comprehensions |
nums: [10, 20, 30]
nums.length # → 3 ; nums's length → 3 (same accessor)
nums.first # → 10 ; nums.last → 30
nums.second # → 20 ; nums's third → 30 (ordinals second..tenth)
[].first # → none (empty: no crash — check xs.empty first)
[99].second # → none (out of range → none, where second([99]) errors)
"hello".last # → "o" (String edges — and ordinals — are whole runes)
{3, 1, 2}.first # → 1 (sorted order, same as first(set))
{3, 1, 2}.second # → 2 (set.second == second(set))
(1, 2, 3).size # → 3 ; {}.empty → true
nums.indexed # → [(1, 10), (2, 20), (3, 30)] — the pairs feed the
# indexed loop forms (§6) and comprehension destructure
"ab".indexed # → [(1, "a"), (2, "b")] (runes, like the edges)
{30, 10, 20}.indexed # → [(1, 10), (2, 20), (3, 30)] (canonical sorted order)
(3..1).indexed # → [(1, 3), (2, 2), (3, 1)] — ranges keep DIRECTION
(1..).indexed # catchable Error — an open range can't materialize;
# loop it with `for e at i in 1..` + break instead
Parentheses apply a function, so nums.size() is not the
length: nums.size is an Integer, and calling it is an error
that names the property and the source line. Write the property with no
parentheses (nums.size, nums.len,
nums.length), or len(nums) /
size(nums) in function position.
The accessors are read-only
(nums.length: 5 is an error) and the accessor table is
deliberately closed — but an unknown name on one of these receivers now
falls back to the unary vocabulary (next section), so
xs.sum answers sum(xs). Multi-argument
transformation stays function-first (map(f, xs), not
xs.map(f) — the fallback is strictly unary). Hashes
are not included: a hash's dot namespace is its own keys
(d.length reads the user key length), so hash
metadata stays with len(d) / keys(d) /
values(d). count is not an accessor name (it
is the reserved comprehension keyword), and nth has no
accessor form (it takes an argument — nth(xs, k) stays a
builtin). The dot spelling has full --vm parity; the
possessive is evaluator-only, like all possessive forms.
Lists and recursion —
[h | t]
Arrays are the sequence. [h | t] conses
onto one and destructures one, so the classic recursive list algorithms
are written directly on arrays — there is no separate list type to
convert to and from.
func total([]) [0]
func total([h | t]) [h + total(t)]
total([1, 2, 3, 4]) # → 10
The same spelling works in four positions:
# 1 — value position: cons
[1 | [2, 3]] # → [1, 2, 3]
[1 | []] # → [1]
# 2 — multi-clause function head (above)
# 3 — match arm
tot: func(xs) [
match xs with
| [] => 0
| [h | t] => h + tot(t)
]
# 4 — relation head, where it UNIFIES in both directions
relation append(a, b, c)
append([], Ys, Ys) :- true
append([X | Xs], Ys, [X | Zs]) :- append(Xs, Ys, Zs)
{Z | Z <- append([1,2], [3], Z)} # forward → {[1, 2, 3]}
{(A,B) | (A,B) <- append(A, B, [1,2,3])} # reverse → all four splits:
# {([1,2,3],[]), ([1,2],[3]),
# ([1],[2,3]), ([],[1,2,3])}
Prolog's multi-head cons [X, Y | T] (several elements,
then a tail) is the same ListPattern with more than one
head. It constructs, destructures in a func head, and
unifies in a relation head:
[1, 2 | [3, 4]] # → [1, 2, 3, 4]
func twohead([a, b | t]) [(a, b, t)]
twohead([1, 2, 3, 4]) # → (1, 2, [3, 4])
lsplit([X, Y | T], [X | L], [Y | R]) :- lsplit(T, L, R)
A nested [a | [b | t]] in a function
head is a SyntaxError (the rest binder is a name); write
[a, b | t]. Nested cons in a relation head or in
value position was already legal. A bare _ as a whole
relation-head argument is the anonymous wildcard
(lpart(_, [], [], []) matches any pivot); _
inside [H | T] always was.
Reverse-mode list unification — splitting a list given only the result — is the one thing here that pattern matching alone cannot do. It comes from the relation engine, not from the array.
Because the cons tail may be a call (July 2026), a recursive constructor reads the same way it destructures:
func lmap(_, []) [[]]
func lmap(f, [h | t]) [[f(h) | lmap(f, t)]]
lmap(func(x) [x * x], [1, 2, 3]) # → [1, 4, 9]
func lapp([], ys) [ys]
func lapp([h | t], ys) [[h | lapp(t, ys)]]
lapp([1, 2], [3, 4]) # → [1, 2, 3, 4]
func ins(x, []) [[x,]]
func ins(x, [h | t]) [ if x <= h then [x | [h | t]] else [h | ins(x, t)] ]
func isort([]) [[]]
func isort([h | t]) [ins(h, isort(t))]
isort([5, 2, 8, 1, 9, 3]) # → [1, 2, 3, 5, 8, 9]
The tail may be an identifier, a list, a call, a
property read, an index, or a whole comprehension. It may
not be an infix expression — [h | a + b]
still reads as a comprehension clause, which is where the genuine
ambiguity with a filter lives. The tail must also evaluate to a list:
[1 | 2] is rejected with "list-cons tail [.. | t] must
be a list, got INTEGER", so there are no improper lists or dotted
pairs.
Two traps worth naming:
first/rest/last, nothead/tail.headandtailare the homoiconicity AST accessors —head(xs)on an array errors with "argument must be an AST value".first(xs)/rest(xs)are the list accessors, andrestdoes not deep-copy, so a recursivefirst/restdescent is linear. The short spellingshdandtlare exact aliases offirstandrest. Note thattlisrest, notlast— the tail of a list is a list:tl([1,2,3])is[2,3]wherelast([1,2,3])is3. Andhdis partial wheretlis total:hd([])raises,tl([])is[], so write the base case asempty?(xs)rather than leaning on the tail to fail.- In a
matcharm,[h]is a block, not a one-element array — it evaluates toh. Everywhere else (binding right-hand side, call argument, operand, function-body last expression)[h]is the array[h]. Write[h,]when you mean a one-element array: it reads as an array in every position.
For a mutable chain — sharing, cycles, O(1) splice —
use hash nodes rather than arrays; {val: v, lnk: nd} is a
node and hashes are reference types. See test_doubly_linked_list.ax
for a full doubly linked list with O(1) unlink from a bare node handle.
Note that next is a reserved word, so either spell the
field lnk/nxt or keep the conventional name
with the guarded form $next, which stores the key bare:
xs: none
xs: {$next: xs, value: 1}
xs: {$next: xs, value: 2}
l: xs
while l [ println(l.value) ; l = l.$next ] # `none` is falsy, so no != test
Unary dot fallback —
xs.sum ≡ sum(xs) ≡ xs's sum
On a slot-less value — Array, Tuple, String, Set, Range, scalars, Bytes, Bag, AST — a dot or possessive read whose name is not an accessor applies the named unary operation to the receiver. One name, three positions, for the whole unary vocabulary:
xs: [3, 1, 4, 1, 5]
xs.sum # → 14 ≡ sum(xs) ≡ xs's sum
xs.sort # → [1, 1, 3, 4, 5]
xs.sort.reverse.first # → 5 chaining falls out of naming
{1, 2, 3}.sum # → 6 ; (1..5).sum → 15
"hello".upper # → "HELLO" ; " hi ".trim → "hi"
16.sqrt # → 4 scalars are slot-less, so eligible
double: func(x) [x * 2]
7.double # → 14 user functions participate identically
bytes("abc").len # → 3 Bytes/Bag/AST get accessor parity free
The name resolves exactly as in function position — lexical
environment first (user bindings shadow builtins), then the builtin
table — and the accessor table above keeps precedence
(xs.len answers the count even if len is
rebound). Records and namespaces never fall back: a
hash keeps miss → none (data wins — d.sum
reads the key sum), entities and Concepts keep their
property-not-found errors, and modules stay name qualification
(math.sqrt is a member lookup, never
sqrt(math)). Because a miss on every eligible receiver was
already an error, the fallback only turns errors into answers.
The fallback for reads is strictly unary:
xs.push surfaces the builtin's arity error. Registered
collection calls, described next, add explicit receiver
placement. On collection receivers, a lexical user-function call
xs.f(a) passes xs first, as described next. To
call a function returned by unary application, write
f(xs)(a). Scalar receivers retain the earlier
unary-result-call behavior. A non-callable name errors; an unresolved
name produces the usual miss error ending with "…, or any unary
operation in scope". Variadic operations remain unary-callable
(xs.max → 5, the fold reading; "abc".max →
"abc", max-of-one). The original unary-dot reads have
--vm parity; new collection method calls are
interpreter-side.
Collection method calls
The interpreter provides explicit method spellings for collection operations:
xs: [1, 2, 3]
xs.length # → 3, existing property
xs.length() # → 3, method call
xs.map(x -> 2 * x).reverse() # → [6, 4, 2]
xs.filter(x -> x > 1) # → [2, 3]
xs.reduce(func(a, b) [a + b], 0) # → 6
xs.contains(2) # → true
The method registry fixes the receiver position:
xs.map(f) is map(f, xs),
xs.filter(p) is filter(p, xs), and
xs.reduce(f, init) is reduce(f, init, xs).
Other registered methods put the receiver first: push,
append, reverse, rev,
sort, sorted, sum,
prod, mean, min,
max, length, len,
size, count, first,
last, rest, nth,
contains, collect, elements,
each, transpose, and shape.
Builtin domain and mutation rules are unchanged: m.length()
still refuses a Matrix; use m.shape(). Builtin receiver
positions come from this registry.
On a collection property miss, a user function in scope receives the collection as its first argument, followed by the written arguments:
scale(values, factor) = values .* factor
xs: [1, 2, 3]
xs.scale(2) # → [2, 4, 6], scale(xs, 2)
This is one call, so overload selection sees the complete argument list. It supports the function's ordinary named/default/rest arguments and contracts. No candidate function is executed to guess the argument order. The rule covers existing collection receiver kinds; it does not add receiver insertion to scalar values or change record/module lookup.
Bare xs.scale keeps unary application. For the earlier
two-stage call meaning, explicitly call the returned function:
maker(values) = (n -> length(values) + n)
maker([1, 2, 3])(10) # → 13
This replaces the earlier xs.maker(10) spelling. Extra
parentheses around xs.maker alone do not select the old
rule; use maker(xs)(10) or store the returned function in a
binding before invoking it.
The receiver and written arguments are each evaluated once. Records,
entities and modules keep their own fields. A callable field or accessor
remains callable: [func() [99]].first() still returns 99.
Lexical user functions on a property miss receive the collection first,
including when they shadow a registered builtin name. Known accessors
retain priority. Empty .first is a harmless absence query;
.first() delegates to the builtin and errors on an empty
Array. On lazy iterators use first(it) for extraction;
.first() retains the old callable-result reading. For
labels or refinements, use the builtin function form. Unsupported VM
method forms refuse; existing callable-field calls continue to work.
Tuples
pair: (3, 4)
trip: ("Alice", 30, "engineer")
k => v is a Pair, the key–value cell
— not a Tuple and not a Dictionary; k -> v is
the same Pair (the thin arrow is the second spelling, since
2026-09-04, and a Pair always prints as =>).
{k: v} stays the dict literal. dict(...) and
arrays consume Pairs as values. Bare x => body /
x -> body and (params) => body stay
lambdas: a name on the left is a function of it, whichever
arrow. Match / cond arm arrows are unchanged, and the Unicode
→ is implication, not a Pair. {'X' => 10}
is a set of one Pair. The REBOL coordinate 100x200
is retired.
p: 'X' => 10
p.first # X
p.second # 10
@p # Pair
p == ('X', 10) # false
pair('X', 10) == p # true
1 => 2 => 3 # 1 => 2 => 3 — right-associative, nested Pairs
'X' => 10 |> second # 10
1 -> "one" # 1 => "one" — the thin arrow builds the same Pair
dict('I' => 1, 'X' => 10) == {I: 1, X: 10} # true
dict([1 => "one", (2, "two")]) # one Array / List / Set of Pairs or 2-tuples
dict(zip([1, 2], ["one", "two"])) # zip() and items() produce that shape
Write (x, 10) when the left side is a name whose
value you want — x => 10 is a function of
x. pair() with no arguments is refused.
Lists (persistent cons lists)
Where an Array is a place (mutable, reference-semantic,
growable in place), a List is a value: an immutable,
singly-linked cons list whose cells can be shared safely. Write
`[1, 2, 3] for a List literal and `[] for the
empty List. The backtick touches the opening bracket; there is no
closing backtick. [] remains an Array. The conversion
constructor list retains the
array()/set()/tuple() zero-or-one
arity:
e: `[] # the empty List — a TRUTHY value, not a bottom
l: `[1, 2, 3] # evaluate elements and construct a List
m: cons(0, l) # O(1) persistent prepend — l is untouched
n: 0 cons l # the same call, written infix
first(l) # → 1 O(1) head
rest(l) # → `[2, 3] O(1) SHARED tail — no copy
l == list([1, 2, 3]) # → true (elementwise value equality)
l == [1, 2, 3] # → false (type-strict against Array)
l[2] # → 2 — O(n) courtesy read; l[2] = 9 is an ERROR
map(lambda x => x * x, l) # → `[1, 4, 9] — map/filter preserve the type
{x | x <- l, x > 1} # lists feed comprehensions, foreach, quantifiers
array(l) # → [1, 2, 3] — cross the boundary explicitly
List literals evaluate each element once, from left to right. Each
expression contributes one value: nested Lists, Arrays and Ranges stay
nested. A trailing comma is allowed, and `[x] always makes
a singleton List, including in a function or branch body. No
intermediate Array or lookup of the name list is needed.
The spine is immutable; a mutable value stored inside retains its
identity. Printing uses the same literal notation: `[],
`[1] and `[1, 2, 3]. This is the primary form
in examples and display; list() and
list(collection) remain fully supported with their existing
behavior.
println(`[1 + 2, `[4], [5]]) # `[3, `[4], [5]]
println(len(`[1..3])) # 1 — one Range value, not three elements
println(`[1, 2] :+ 3) # `[1, 2, 3]
println(1 +: 2 +: `[]) # `[1, 2]
This prefix is construction, not quotation: quote(...)
and '[...] retain their AST meanings. Named backtick glyphs
and infix calls are unchanged. The new literal does not introduce
comprehensions or new pattern forms; continue to use
[h | t] and [] in sequence patterns. Evaluator
and VM construct Lists; unsupported VM element expressions still refuse.
HM --infer reports List literals outside its inferred
fragment. --typecheck traverses the elements and checks
explicit List of T annotations.
Spreading Arrays and Lists. A direct literal element
prefixed with ... inserts the source sequence's elements in
order. The outer literal determines whether the result is an Array or a
List:
let source = [1, 2]
let linked = `[...source]
let copied = [...linked]
println(linked) # `[1, 2]
println(copied) # [1, 2]
println(`[0, ...source, 3]) # `[0, 1, 2, 3]
println(`[source]) # `[[1, 2]] — the Array is one element
println([...source, ...linked]) # [1, 2, 1, 2]
Each operand evaluates once, left to right. A spread copies the
source's outer elements immediately, before the next operand runs;
nested values retain their identity. The result does not inherit an
Array source's element contract: an explicit annotation on the result
supplies its contract. Both evaluator and VM support this construction;
--typecheck checks known operands and element types, while
HM --infer explicitly skips spreading.
Only Arrays and Lists can be spread. For another collection, convert
explicitly with list(value) or array(value)
first. `[x] always wraps one value; `[...xs]
inserts its elements. [...xs] is an Array even in a
function or branch body. Spreading adds no call-argument or pattern
syntax. Existing ...rest, set progressions, and the Array's
syntactic range expansion remain unchanged; a Range directly inside a
List remains one element.
The display change does not rename existing collection keys or canonical fact identities: those retain the legacy List spelling internally.
cons is the one new word (no car/cdr aliases). Infix
h cons t is the same call, right-associative:
4 cons 2 cons 3 cons list() packs as
cons(4, cons(2, cons(3, list()))). : is
binding and :: is ascription; they are not cons. The empty
List is `[] or list(); [] is an
Array. cons requires a List second argument — proper lists
only. Variadic list(1, 2, 3) is refused with a hint: the
primary build spelling is `[1, 2, 3], the one-value wrap is
`[x] (also cons(x, list())), so
list(a) can never be ambiguous. is List,
is_list(l) and list?(l) test the type
(list? no longer aliases array?; an Array
argument errors with a migration hint). The accumulate idiom is
acc: cons(x, acc) in a loop, then reverse(acc)
— every step O(1).
l1 + l2 concatenates, constructing: the left spine is
copied and the right operand is shared as the result's tail
(the persistent append); list() is the identity on either
side, and List + Array errors — crossing the boundary is
always explicit. sort(l) returns a sorted List, and lists
take their place in the canonical order componentwise (numeric-aware, a
proper prefix before its extension, never interleaved with Arrays), so
sets of lists walk deterministically and
sort_by/min_by/max_by order them
the way a reader expects.
Why a List answers no message verbs.
a push 4 is an imperative sentence: it commands a
place — something with identity over time — to be different
afterwards. A List has no such identity; like the number 5,
it simply is its content. Every reading of
l push 3 fails on its own terms: mutating the cell would
silently edit every list sharing that spine (structure
sharing is exactly what makes cons/rest O(1) —
cells must never change); rebinding l under a verb would be
assignment wearing a message costume; and constructing a new list only
to discard it would be a silent no-op — precisely the bug class the
retired discarded-grow lint existed to catch. So the refusal is loud and
teaching:
l: `[1, 2]
l push 3 # ERROR: a List is a value and 'push' is an imperative —
# build a NEW list with cons(x, l) or l1 + l2, ...
A place takes imperatives (a push 4,
s push 4); a value takes expressions
(cons(0, l), l + m,
first/rest). The grammar itself tells you
which one you are holding.
[h | t] is shape-generic. The cons
pattern — in relation heads, match arms, and multi-clause
func heads — destructures a List as well as an Array, and
on a List the tail binds as the shared spine: O(1) per step, so
a recursive walk is linear where the same walk over an Array copies its
tail every step. One definition serves both sequence types, and a
body-position [f(h) | rest] rebuilds in the tail's own
type:
func total([]) [0]
func total([h | t]) [h + total(t)]
total([1, 2, 3, 4]) # → 10 (Array walk — tails copied)
total(list([1, 2, 3, 4])) # → 10 (List walk — tails shared, linear)
relation app(a, b, c)
app([], Ys, Ys) :- true
app([X | Xs], Ys, [X | Zs]) :- app(Xs, Ys, Zs)
{Z | Z <- app(list([1, 2]), list([3]), Z)} # → {`[1, 2, 3]} — List in, List out
Sequence prepend and append
x +: xs prepends one element; xs :+ x
appends one element. The sequence operand must be a List or Array, and
the result has that same kind. Both operators construct a new sequence
without modifying the original. An Array or List used as the element
stays nested: these operators do not concatenate.
println(1 +: 2 +: `[]) # `[1, 2]
println([] :+ 1 :+ 2) # [1, 2]
println([1, 2] :+ [3, 4]) # [1, 2, [3, 4]]
println((1 +: [2]) :+ 3) # [1, 2, 3]
println((1 + 2) +: [4]) # [3, 4]
+: groups from the right; :+ groups from
the left. They share the multiplicative precedence band with word
cons, above addition and comparisons. Use parentheses
around arithmetic elements and to mix +: with
:+: 1 +: [2] :+ 3 is rejected until its
grouping is explicit. This precedence is Axioma's existing
cons convention, not ML's lower-than-addition precedence.
Both operands are evaluated once, left to right. Binary function
spellings (+:)(x, xs) and (:+)(xs, x) are also
available.
List prepend is O(1) and shares the tail; List append is O(n) and
copies the spine. Both Array operations are O(n): they copy outer
storage, preserve nested object identity and retain explicit element
constraints such as Array of Integer. They are shallow
operations, not deep copies. Use repeated List prepend followed by
reverse to accumulate linearly. cons(x, xs)
stays List-only; Strings, Bytes, Tuples, Sets and Dictionaries are not
sequence operands for this pair.
The adjacent characters +: and :+ are now
reserved operator tokens. Write 2 + :x for addition of a
get-word and n: +3 for a binding to a positive number; the
former compact spellings 2+:x and n:+3 now
mean sequence operations. Positive slice bounds such as
xs[1:+2] keep their slice meaning. :: remains
reserved for type annotations.
Sets
Sets are unordered collections of
unique elements, written with braces. Duplicates
collapse and order is irrelevant — {3, 1, 2, 2} is
{1, 2, 3}, and {1, 2, 3} == {3, 2, 1}. The
empty set is {} (equivalently set(),
emptyset, or ∅); len(s) is the
cardinality.
{3, 1, 2, 2, 1} # → {1, 2, 3} (deduped, unordered)
{1, 2, 3} == {3, 2, 1} # → true (order-independent)
{} == emptyset # → true ; set() == ∅ → true
len({1, 2, 3}) # → 3
Unordered as a value, deterministic as a walk. A set
has no intrinsic order — that is what
{1, 2, 3} == {3, 2, 1} means. But anything that
walks a set has to pick some order, and Axioma guarantees it is
always the same one: the set's canonical order, which
is what first, nth, s[i], and
display all show. Every consumer agrees with them — every loop form
(foreach, repeat, loop),
map, filter, array(s), the folds
(reduce, foldl, foldr), and the
Enumerable verbs (take_while, sort_by,
min_by, group_by, flat_map,
…).
s: {30, 4, 100, 2, 77}
first(s) # → 2 (canonical order is NUMERIC for numbers)
array(s) # → [2, 4, 30, 77, 100]
reduce(func(a, x) [a + str(x)], "", s) # → "243077100" — the SAME on every run
This matters because a fold is order-sensitive: without the
guarantee, reduce over a set would return a different
answer each time you ran the program. Sets built by different insertion
orders are indistinguishable to every consumer, which is the point.
The order itself is numeric for numbers. Elements compare at their exact mathematical value, so the whole exact tower — Integer, Float, Rational — sorts as one line by magnitude, at arbitrary precision:
array({2, 10}) # → [2, 10] (not [10, 2])
array({1/2, 0.25, 2, 3/2}) # → [0.25, 1/2, 3/2, 2]
array({2^100, 5, 2^64}) # → 5, then 2^64, then 2^100 — by magnitude,
# at arbitrary precision (printed in full)
array({inf, 5, -inf}) # → [-Inf, 5, Inf]
array({"b", "a", "c"}) # → ["a", "b", "c"] (strings by codepoint)
Non-numeric elements keep a stable order of their own, so a set
mixing types still walks the same way every time. The law is checkable —
axioma lib/laws/determinism.ax runs it as a property
test.
Converting — array(s) materializes a
set as an Array in canonical order, and set(xs)
deduplicates any collection into a Set. They join the bare type-name
coercions (str, int, float,
bytes).
tuple(c) completes the family, materializing any of them
as a fixed-arity Tuple. All three also answer the
no-argument call with their empty value — the explicit
spellings of the literals [], {} and
().
array({3, 1, 2}) # → [1, 2, 3]
set([1, 2, 2, 3]) # → {1, 2, 3}
tuple([1, 2, 3]) # → (1, 2, 3)
array(set([9, 1, 9])) # → [1, 9] (round trip)
array() # → [] (≡ the literal; a FRESH array each call)
set() # → {} (≡ ∅)
tuple() # → ()
make_tuple is a different facility — it builds an AST
node, not a value.
Algebra — each operation has a word form and a glyph, and yields a set:
| Operation | Word | Glyph | a · b → |
|---|---|---|---|
| union | union |
∪ |
{1, 2, 3, 4, 5, 6, 7} |
| intersection | intersect |
∩ |
{3, 4, 5} |
| difference | difference |
\ |
{1, 2} |
| symmetric difference | symdiff |
△ (also ∆ ⊖) |
{1, 2, 6, 7} |
a: {1, 2, 3, 4, 5}
b: {3, 4, 5, 6, 7}
a union b # ∪ → {1, 2, 3, 4, 5, 6, 7}
a intersect b # ∩ → {3, 4, 5}
a difference b # \ → {1, 2}
a symdiff b # △ → {1, 2, 6, 7} (in exactly one of the two)
Relations & membership — each returns a
Boolean:
| Test | Word | Glyph | True when |
|---|---|---|---|
| membership | in |
∈ |
the element is in the set |
| non-membership | notin |
∉ |
the element is not in the set |
| subset | subset |
⊆ |
every element of the left is in the right |
| superset | superset |
⊇ |
the right is a subset of the left |
| proper subset | subsetneq |
⊂ (⊊) |
subset and not equal |
| proper superset | supsetneq |
⊃ (⊋) |
superset and not equal |
| equality | == |
— | same elements (order-independent) |
2 in a # → true ; 9 notin a → true
{1, 2} subset a # → true ; a superset {1, 2} → true
{1, 2} subsetneq a # → true (proper) ; a subsetneq a → false
2 ∈ a and {1, 2} ⊆ a # → true (glyphs work everywhere the words do)
in / notin are not set-only. Membership
spans the whole container family, and the answer never depends on the
element's type:
2 in [1, 2, 3] # → true Array
"b" in ["a", "b"] # → true Array of String
2 in (1, 2, 3) # → true Tuple
2 in 1..5 # → true Range
2 in bag([1, 2, 2]) # → true Bag
"a" in dict("a", 1) # → true Dictionary — tests the KEY
"ell" in "hello" # → true String — substring, not element
'e' in "hello" # → true Character in String
A right operand outside that family is refused, and the refusal names
it: 2 in 3 →
right operand of 'in' must be a set, infinite set, bag, array, tuple, string, range, or dict; got INTEGER.
Powerset & Cartesian product:
powerset({1, 2}) # → all 4 subsets: {}, {1}, {2}, {1, 2}
{(x, y) | x <- {1, 2}, y <- {3, 4}} # Cartesian product via a comprehension →
# {(1, 3), (1, 4), (2, 3), (2, 4)}
See also. Set comprehensions and
quantifiers — {x | x <- s, p(x)},
forall / exists over a set — are covered in §7. The Ellipsis
sets subsection just below covers textbook and
infinite sets ({2, 4, 6, ...}) and the
built-in number sets ℕ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ. Draw set
relationships as a diagram with venn(...) — see §23.
Ellipsis sets —
textbook {2, 4, ..., 100} /
{2, 4, 6, ...}
The ... ellipsis writes an arithmetic progression the
way a textbook does. A bounded form materializes a set; an open form (no
upper bound) is a lazy infinite set. The step is
inferred from the leading terms — a third term, if given, must confirm
it.
{2, 4, ..., 100} # → {2, 4, …, 100} — a 50-element set
{1, ..., 10} # step defaults to 1 → {1..10}
{10, 8, ..., 2} # descending (step −2)
B: {2, 4, 6, ...} # infinite (lazy)
first(B) # → 2 ; tenth(B) → 20 ; nth(B, 50) → 100
first(B, 5) # → {2, 4, 6, 8, 10}
100 in B # → true ; 7 in B → false
first((x | x <- B, x > 10), 3) # → [12, 14, 16] (a filtered lazy stream)
Integer progressions only (non-integer or non-linear → a clear error
pointing at an explicit generator like
{x | x <- range(...)}); bounded sets cap at 1,000,000
elements. For an ordered infinite sequence use this form or
infinite_set("naturals") — a bare naturals /
ℕ generator enumerates unordered.
Pulling elements. first(s) is the first
term; the ordinals second(s) … tenth(s) and
the general nth(s, k) give the k-th — on a finite
collection or an infinite set (tenth({2,4,6,...}) → 20).
last(s) errors on an infinite set (no last element).
Membership uses in: ellipsis and named sets test it exactly
and instantly, while a custom function-defined set
(infinite_set(func(n) [...])) does a bounded
generate-and-check (up to 100k terms, with an early exit once an
ascending sequence passes the target) — so
9 in infinite_set(func(n) [n * n]) is true
without ever running away.
The standard number sets. The chain ℕ ⊂ ℤ ⊂
ℚ ⊂ ℝ ⊂ ℂ is built in. The identifiers rationals /
reals / complexes and the glyphs
ℚ / ℝ / ℂ are membership
sets:
3 in reals # true (an integer is real…)
2.5 in reals # true ; 2.5 in rationals → false (a decimal reads as a real)
rational(1, 2) in rationals # true
complex(0, 1) in complexes # true ; complex(0, 1) in reals → false (nonzero imaginary part)
first(rationals, 5) # {0, 1, -1, 1/2, -1/2} (ℚ is countable → enumerable)
first(reals, 5) # ERROR: ℝ is uncountable — use a membership test instead
Membership is type-faithful: an Integer belongs to all
four; a RationalNumber to ℚ/ℝ/ℂ; a Float reads
as a real, not a rational (decimals approximate reals —
assert rationality with a rational(p, q) literal); a
ComplexNumber belongs to ℂ, and to ℝ only when its
imaginary part is 0. ℚ is countable, so first(rationals, n)
enumerates it (Calkin–Wilf order, signs interleaved); ℝ and ℂ are
uncountable and so are membership-only (enumeration is a clean error).
infinite_set("rationals" | "reals" | "complexes") builds
the same sets.
Bags (multisets)
A Bag is an unordered collection in which the same value may appear multiple times. Bags sit between Sets (no duplicates) and Arrays (ordered duplicates):
| Order | Duplicates | Multiplicity | |
|---|---|---|---|
| Array | yes | yes | positional |
| Set | no | no | n/a |
| Bag | no | yes | counted |
words: bag(["the", "quick", "the", "fox", "the"])
count(words, "the") # 3 — multiplicity of an element
len(words) # 5 — total Σ multiplicities
"the" in words # true
distinct(words) # {the, quick, fox} — underlying support set
Operators. Standard multiset algebra is wired infix:
b1 ∪ b2 # union — max counts per element
b1 + b2 # additive sum — counts add (independent observations)
b1 ∩ b2 # intersection — min counts
b1 - b2 # difference — clamped at zero
b1 == b2 # multiset equality
∪ and + are genuinely different:
bag([a,a]) ∪ bag([a]) == bag([a,a]) but
bag([a,a]) + bag([a]) == bag([a,a,a]).
Comprehension iteration is over distinct elements
(the underlying Set support), since {...} cannot carry
multiplicities. Use to_array(b) to iterate every
occurrence.
As slot values. Bags can be slot values on Concepts;
the unify machinery uses additive sum as
the merge policy — two partial observations of the same entity combine
their evidence.
Typical use cases: word-frequency counts, inventory, voting tallies,
evidence merging. Bags are first-class in SETL, Z, B, VDM-SL, Smalltalk,
Python's Counter, C++ std::multiset, and Guava
Multiset; they fill the same role in Axioma.
Tests: tests/axioma/collections/test_bag_basic.ax, test_bag_operators.ax, test_bag_use_cases.ax, test_bag_slot_value.ax.
Dictionaries
({key: value} literals)
person: {name: "Anna", age: 24} # Unquoted keys
obj: {"first-name": "Bob", "age": 30} # Quoted keys
nested: {user: {address: {city: "NYC"}}}
person.name # "Anna" (dot access)
person["name"] # "Anna" (bracket access)
person's name # "Anna" (possessive — same as dot; miss → none)
dict() # Empty dictionary ({} is the empty SET ∅)
dictionary() # same value — spelled-out twin of dict()
dict([1 => "one", (2, "two")]) # from one Array / List / Set of Pairs or 2-tuples
dict(zip(ks, vs)) # zip() and items() produce that shape; later entries win
delete person["age"] # retract a key; yields 24. Miss on an open map → none
person \ {"name"} # NEW dict without those keys (pure). Right side is a
# Set or Array of keys — keys(other) composes.
type(person) # "Dictionary"
person is Dictionary # true
A dictionary is a {key: value} map
keyed by value: any value is a key, compared by ==, so
1 and 1.0 are one key and "1"
another, and dict(1, "one"),
dict(1 => "one") (or 1 -> "one") and
{1: "one"} build the same entry. Build one with the
{...} literal or, for the empty case, dict() —
dictionary() is the spelled-out twin (constructor name =
lowercase of the type), the same relationship as integer()
/ int(). type() and @ report
"Dictionary", and x is Dictionary classifies
it.
Naming note. This type was previously named
ObjectMap(June 2026 standardized it toDictionaryand fully retired the old name —ObjectMapis now an undefined word, not an alias).Dictis accepted as a short alias in type annotations (x :: Dict) andischecks, matching thedict()constructor, buttype()/@always report the canonicalDictionary.
Schema field writes
A dictionary annotated with a row schema keeps its field contract after initialization. Property writes, index writes and writes through field references use the same checks in the evaluator and VM. The contract follows the dictionary object, so another name or a previously captured field reference cannot bypass it. A rejected write leaves the old value intact.
type WineRow = {alcohol :: Float | Na, quality :: Integer, note? :: String}
let wine :: WineRow = {alcohol: na, quality: 7}
let other = wine
other.alcohol = 12.5 # accepted; wine sees the same update
let p = &wine.quality
*p = 8 # accepted; wine.quality is now 8
wine.note = "checked" # an absent optional field may be added
delete wine["note"] # an optional field may be removed
wine.alcohol = "twelve" # Error: expected Float | Na
wine["quality"] = na # Error: expected Integer
*p = "eight" # Error: expected Integer
delete wine["quality"] # Error: required field
wine.typo # Error in both runtimes, also for wine["typo"]
Optional means the key may be absent; it does not admit
none or na as a present value unless the
declared field type includes them. delete and
del_key reject removal of required fields. Earlier
field-type and requiredness constraints remain attached if another alias
is annotated with a wider schema. Open dictionaries retain their
ordinary insert/update/delete behavior.
copy(row), {row with field: value},
dictionary +, and dictionary \ retain schema
contracts. Functional updates validate all replacement fields before
attaching any new nested schemas. Dictionary + preserves
both inputs' contracts. Removing a required field with \ is
refused; removing an optional field produces an independent dictionary.
copy preserves cycles and sharing among copied dictionary
and array nodes.
Bindings, ascriptions, typed parameters and typed returns attach
schemas in both runtimes, including schemas on nested dictionaries.
--typecheck and the LSP diagnose incompatible writes when
the receiver and field type are known; dynamic keys and other unknown
types remain runtime checks.
This is a dictionary-field contract. Element mutation inside a field
such as Array of Integer or
Dictionary of String to Integer remains a separate
collection-contract limitation; replacing that whole field is
checked.
Unified type declarations
The unified header is
type Name = kind-or-type-expression. Existing
data, enum, concept, aliases,
products, unions and opaque wrappers keep their meaning. In particular,
concept Person remains the concise domain declaration.
| Kind | Meaning |
|---|---|
| Synonym | Another name for an existing type |
| Union | Any member of the existing arm types |
| Product | Tuple with the declared element types |
| Opaque | Nominal wrapper with explicit construction |
| Schema | Structural Dictionary contract |
| Struct | Immutable nominal programming record |
| Struct mutable | Mutable closed programming record |
| Variant | Tagged alternatives with payloads; data is accepted in
this slot as the same declaration |
| Enum | Ordinal members |
| Concept | Domain kind with entities and inheritance |
type Distance = Float
type NumericCoordinate = Integer | Float
type Coordinates = (Float, Float)
type PersonId = opaque Integer
type Row = {x:: Integer, label?:: String}
type Point = struct {x:: Float, y:: Float}
type PointCursor = struct mutable {x:: Float, y:: Float}
type Shape = variant Circle(radius:: Float) | Origin
type Move = data Rock | Paper | Scissors
type Direction = enum North, East, South, West
type Person = concept
Records and mutation
Spellings: struct is the primary
documented spelling; record is an alias in the
type Name = kind slot. Both are immutable by default.
Either mut or mutable after either word
selects the same mutable struct: struct mutable,
struct mut, record mutable and
record mut are identical apart from spelling. Every form
uses the existing struct AST, accepts braces or an end
field body, and supports the same generic parameters.
record means a nominal struct, not a
structural Dictionary schema. It is contextual; ordinary
record bindings and functions remain ordinary
identifiers.
In the kind slot, record always starts a declaration,
including an empty record … end body. If a variable named
record holds a type value, write
type Alias = (record) to reference it instead. The
$record guard alone does not bypass a contextual kind
marker; ($record) also works.
type Label = record {value:: Integer}
type Cell[T of Number] = record mut
value:: T
end
type Counter = struct mut {value:: Integer}
type Gauge = record mutable {value:: Integer}
let cell = Cell(1)
let shared = cell
shared.value = 2
println(Label(1) == Label(1))
println({value: 1} is Label)
println(cell.value)
println(cell == shared)
println(cell == Cell(2))
println(Counter(3).value + Gauge(4).value)
This prints true, false, 2,
true, false, 7, one per line:
immutable values compare by value, a matching Dictionary is not a Label,
shared mutable values observe the same checked field writes, and
separate mutable constructions have distinct reference identity. All
spellings retain the VM refusal below.
A struct is constructed with its own name, for example
Point(1.0, 2.0). Fields must have explicit names; the
:: annotation is optional. An annotated field is checked at
construction and on every write, and an Integer given for a
Float field converts to that Float the way an annotated
parameter does, so P(1).x / 2 and P(1.0).x / 2
agree (a type-parameter slot keeps the argument's type). A bare
lowercase name such as z declares an open field that
accepts any value — the gradual reading every other unannotated position
has, and the same rule as a bare data slot. A bare
TitleCase word is refused, because a struct field needs a name.
type Pnt = record
x :: Float,
y :: Float,
z # open field: any value
end
Pnt(1, 2, "three").z # "three"; x and y are 1.0 and 2.0
A matching Dictionary is not a Point. Structs do not register
instances in a concept extent and do not support domain inheritance;
has and had on a struct name refuse, as the
article form does, because the field list is fixed by the declaration;
P show properties lists the declared fields. An empty
struct is constructed by Empty(); the bare name remains its
type.
Immutable structs reuse single-constructor data values: structural equality, existing value identity and pattern matching, closed fields and shallow freezing. Nested Arrays, Dictionaries or mutable structs retain their own mutation rules.
Mutable structs use reference identity for both == and
is/same. Two separate constructions are distinct, even with
equal fields. Aliases share writes; copy(p) is the way to
get an independent record, and the copy keeps the field validator. Set
keys use stable reference identity, independent of field contents.
Ordering refuses on every surface: the four operators,
sort, sort!, sorted,
min, max, and the keyed verbs when a key is or
contains such a record. Order by a field key instead,
sort_by(func(c) [c.x], cursors). Immutable structs keep
their structural order in all of those, min and
max included. An immutable let binding can
hold a mutable struct: changing p.x does not rebind
p.
A declared mutable field accepts p.x = value and writes
through &p.x. Unknown fields and incompatible types
refuse before modifying the value. Positional/index writes and index
references are unsupported and refuse. {p with x: value}
reconstructs a validated value for either struct kind; for a mutable
struct it creates a distinct record. Untouched nested values stay
shared. The validator belongs to the value, so updates also work after a
local constructor leaves scope. Generic structs and variants reuse
[T of Number] and the existing whole-field parameter rules:
repeated T fields must agree in each construction or update; there is no
implicit numeric promotion or specialized Point[Float].
Optional declaration bodies
Braces and end bodies parse field declarations into the same semantic nodes. Newlines separate fields; commas remain available. They do not execute arbitrary statements or introduce a class-body scope.
type Point = struct
x :: Float
y :: Float
end
type PointCursor = struct mutable
x :: Float
y :: Float
end
type Row = schema
x :: Integer
label? :: String
end
type Person = concept with
name: ""
age: 0
end
concept Student with
university: ""
end
Student extends Person
schema explicitly opens a schema body. Concept bodies
require with; bare concept Person and
type Person = concept are complete declarations, including
inside a surrounding function or loop.
type Person = concept {name: ""} uses the existing concept
slot/default rules, including their current enforcement limits. Struct
field enforcement does not tighten ordinary concept slots. Aliases,
opaque wrappers, tuple products, compact enums and variants do not
require end. Variant alternatives may continue on lines
beginning with |, and the first alternative may start on
the next line as a TitleCase name; a header with no alternative refuses
rather than reading the following statement as one. The kind words are
contextual rather than new globally reserved identifiers.
data in the kind slot is the same declaration as
variant, so every standalone header's keyword is accepted
after type Name =:
type Move = data Rock | Paper,
type Move = variant Rock | Paper and
data Move = Rock | Paper are one declaration, with the same
constructors, payloads, type parameters, tag,
match and exhaustiveness checking. variant is
the descriptive kind word in this section. Standalone
variant, struct, record and
schema headers are not forms. Bare TitleCase arms under
type join existing types rather than declaring
constructors; the refusal for type Move = Rock | Paper
names type Move = variant Rock | Paper and
type Move = enum Rock, Paper first, and the standalone
spellings as the same declarations.
Execution surfaces
The parser and evaluator accept these forms, and
--typecheck uses their existing semantic nodes, constructor
annotations and parameter checks. A struct write is checked statically
as a schema row's is — an undeclared field, a write to an immutable
struct, or a value the field's annotation does not admit warns before
the run — and a type declared inside a function body is
visible to the checker from that point on. Runtime mutation checks
remain authoritative; the static checker is not a proof of all writes.
HM inference remains its existing supported fragment.
Top-level transparent schemas/products keep their existing VM support. Structs and variants lower to data declarations and explicitly refuse in the VM, as do enums and unsupported concept forms; opaque/local synonyms retain their existing refusal. No new VM record implementation is implied by a parser accepting a form.
Runnable tour:
tests/axioma/showcase/type_declaration_family.ax.
Record update —
{ record with field: value, … }
{ r with … } returns a copy of
r with the listed fields overridden. The original is never
mutated, and later writes to the same field win.
Three kinds of value accept it, and they differ in how closed they are — that is, in what happens when you name a field the value does not have.
h: {name: "Ada", year: 1815}
{h with year: 1816} # → {name: "Ada", year: 1816}
{h with nickname: "AL"} # → adds the key — a hash is OPEN
A schema-annotated dictionary retains its contract and rejects undeclared or wrongly typed replacement fields. Dictionary record updates work in both the evaluator and VM; entity and constructor record updates remain evaluator-only.
A concept entity is closed: every field named must already be a property, so a typo is refused instead of quietly becoming a new slot. The copy is a fresh, independent individual, registered in its concept's extent.
concept Person { name: "", year: 0 }
ada: a Person { name: "Ada", year: 1815 }
older: {ada with year: 1816}
older.year # → 1816
ada.year # → 1815 — the source is untouched
{ada with nope: 1} # → Error: field "nope" is not a property of concept Person
A data value is closed hardest: its
slot list is fixed by the declaration and, unlike a concept, cannot
later grow through has. This is also the only
update path such a value has, because its slots refuse direct assignment
— the tag and payload are the object, so p.x: 99 is an
error by design.
data Point = P { x, y }
p: P { x: 1, y: 2 }
{p with y: 9} # → P(1, 9)
{p with z: 9} # → Error: field "z" is not a field of constructor P
p.x: 99 # → Error — slots are frozen; rebuild instead
Because a rebuild carries the same tag and arguments, a no-op update
is the original — identity falls out of the is/same
rule rather than being special-cased:
{p with y: 2} is/same p # → true — same tag, identical arguments
{p with y: 9} is/same p # → false
Positional constructors whose slots are declared by type —
data Shape = Circle(Float) — have no field name to update
by, and say so. Name the slots (Circle(radius)) to use the
form.
Update rebuilds the spine, not the contents: an untouched field still holds the very same object, so a shared mutable payload stays shared.
JavaScript / TypeScript objects ↔︎ Axioma
| JS / TS | Axioma | Notes |
|---|---|---|
Plain object { born: 1935, name: "…" } |
Dictionary
{ born: 1935, name: "…" } |
Same job: string-keyed bag. Nested maps and comments inside
{…} work. |
poet.born / poet["name"] |
poet.born / poet["name"] /
poet's name |
Three spellings; miss on a dict → none (JS-ish). |
Empty object {} |
dict() /
dictionary() |
Bare {} is the empty set ∅, not an
empty map. |
class Person { … }; new Person() |
concept Person +
a Person { … } →
entity |
Typed individual (KR), not an OO object with methods. |
| Methods on the object | Functions / behavior interfaces
(interface + implement … for +
dispatch) |
Do not put methods on dictionaries; attach behavior by type. |
| Former name | ObjectMap |
Fully retired; use Dictionary. |
# JS object literal → Dictionary
poet: {born: 1935, name: "Mary Oliver"}
poet's name # "Mary Oliver"
@poet # "Dictionary"
# Typed individual → entity (not a dict)
concept Poet
Poet has name: ""
mary: a Poet { name: "Mary Oliver" }
mary is Poet # true
keys(mary) # ["name"] — declared public slots
mary["name"] # "Mary Oliver" — same hard miss as mary.name
poet["nosuch"] # none (open hash)
mary["nosuch"] # error (checked record)
A checked record is a concept instance. The hash
literal is the open map. Choose the instance when you want undeclared
fields to raise; keys / items /
values / r[k] work on both, with that one
difference on a miss.
REBOL’s influence in Axioma is mainly binding
(:), refinements, and scalar value types — not a separate
object! type. The hash is the JS/Monkey bag; entities own
classification and kind.
Behavior interfaces — attach methods by type
Typeclass-style behavior without putting methods on dictionary values (no JS prototype / Ruby open classes on maps). Soft keywords; not reserved.
interface Showable {
render: none # required op names (use non-reserved words)
}
implement Showable for Integer as {
render: func(n) [ str(n) ]
}
implement Showable for Dictionary as {
render: func(d) [ "dict:" + str(len(keys(d))) ]
}
dispatch(Showable, "render", 42) # "42"
dispatch(Showable, "render", {a: 1}) # "dict:1"
42 is Showable # true after implement
# Concepts can implement interfaces; missing ops error at declaration
interface Drawable { draw: none }
concept Shape implements Drawable {
draw: func(self) [ "shape" ]
}
| Form | Role |
|---|---|
interface Name { op: none, … } |
Declare required operations |
implement I for Type as { op: fn, … } |
Register methods for a type name |
dispatch(I, "op", value, …) |
Call the implementation for value's type |
concept C implements I { … } |
Entity concepts; conformance checked |
Not the same as Carnap
protocol_language / is_protocol (observational
frameworks in the grounding layer). Not overridable:
Eq / container keying stay structural
(ObjectKey); use these interfaces for
Showable-like ops, not for redefining ==.
Seeded at startup:
| Interface | Use |
|---|---|
Showable |
render(x) /
dispatch(Showable, "render", x) for core data types |
Numeric |
marker — 5 is Numeric, 1.5 is Numeric (not
String/Dictionary) |
Enumerable |
marker — arrays, sets, dicts, strings, … |
Iterable |
elements(x) /
dispatch(Iterable, "iterate", x) — custom types participate
in for / comprehensions and the Enumerable
verbs (sort_by, map, tally,
take_while, …). Matrix is seeded and walks individual cells
row-major; it is not Sized. The method is
iterate; the global is elements.
iterate(fn, start[, count]) is the list verb (Haskell
unfold), a different function. |
Sized |
len / length / size — native
count, then dispatch(Sized, "size", x). Slot is
size. Not cardinality (F-logic
registrar). |
Indexable |
a[i] / nth(a, i) — native ordinal read,
then dispatch(Indexable, "at", a, i). Optional
put for a[i] = v; optional slice
for a[i:j] (else gather via at). Integer keys
only. Entity r["name"] stays r.name.
p[slot -> val] is a different form (frame write).
Dictionary is keyed, not Indexable. |
Semigroup |
combine(a, b) — seeded for
String/Array/List/Tuple/Bytes (concatenation). Not Integer. |
Monoid |
Semigroup plus mempty(x) identity.
mconcat(xs) folds a non-empty collection.
implement Monoid for T also registers Semigroup. |
render(42) # "42"
42 is Showable # true
let x :: Showable = 42 # ok — same membership as `is`
5 is Numeric # true
[1, 2] is Enumerable # true
[1, 2] is Iterable # true
[1, 2] is Sized # true
[1, 2] is Indexable # true
{a: 1} is Indexable # false — Dictionary is keyed, not ordinal
elements([10, 20]) # [10, 20] — not iterate([10, 20])
[10, 20][2] # 20
dispatch(Indexable, "at", [10, 20], 2) # 20
dispatch(Indexable, "slice", [10, 20, 30], 2, 3) # [20, 30]
check Showable # ⊤ᵇ when all implements satisfy structural laws
combine("a", "b") # "ab"
mempty("x") # ""
mconcat(["a", "b"]) # "ab"
5 is Monoid # false — + and * are both lawful; wrap one:
data Sum = MkSum(Integer)
implement Monoid for Sum as { combine: add_sums, empty: func(s) [ MkSum(0) ] }
check on a behavior interface verifies registered
implements (e.g. render returns String) and
any custom laws: predicates on the interface.
Under --vm, seeded names (Iterable,
Sized, Indexable, Showable, …)
and elements / dispatch on built-in types
compile. implement … for and
interface Name { … } refuse at compile time — run those
without --vm.
Traits — shared method
packs (trait / uses)
A trait is a named pack of methods (function slots
only). It is not a kind: no extent, no
a Trait {}, no extends Trait. Install packs
onto concepts with soft-keyword uses (same house gate as
interface / implement).
trait Printable {
display: func() [ "display:" + it.title ]
greet: func(who) [ "hi " + who + ", I am " + it.name ]
}
concept Document {
title: ""
name: ""
}
Document uses Printable
d: a Document { title: "Spec", name: "Doc1" }
d.display() # → display:Spec
d.greet("Ada") # → hi Ada, I am Doc1
d is Printable # false — traits are not kinds
| Form | Role |
|---|---|
trait Name { m: func… } |
Declare method pack |
trait Name uses A, B { … } |
Compose packs |
Concept uses T1, T2 |
Install methods into Concept.Actions |
concept C uses T { … } |
Declare concept and install in one form |
Conflict rules. Local methods on the concept
win (trait skipped for that name). Two traits in one
uses list that both define the same method → error (no
silent last-wins). Trait bodies may only hold functions.
With interfaces.
concept Button implements Drawable uses BoxDraw —
implements requires the ops; uses can supply
the bodies.
Function-valued concept slots → methods (Actions)
A function value in a concept block or has is a
method, not instance data:
concept Dog {
name: ""
bark: func(n) [ it.name + " barks " + str(n) ] # → Actions, not Properties
}
rex: a Dog { name: "Rex" }
rex.bark(3) # → Rex barks 3
Dog has shout: func() [ upper(it.name) ] # late-bound; existing instances see it
Methods live on the concept (shared, late-bound). They are not copied
onto entities as rewritable fields. The explicit form
Concept action name(params) [ … ] remains for the same
table — required for operator overload
(Concept action "+"(other) [ … ]) and still valid as dual
spelling.
Manual: this section. Test: tests/axioma/concepts/test_traits.ax, test_function_slot_auto_promote.ax.
--typecheck residual for schema unions.
When a name is a finite union of named dict schemas,
"key" in x (and not ("key" in x)) narrows
x to the alternatives that declare (or do not declare) that
field:
type PoemPages = { name :: String, pages :: Integer }
type PoemRhymes = { name :: String, rhymes :: Boolean }
type Poem = PoemPages | PoemRhymes
f: func(p :: Poem) [
if "pages" in p then needsPages(p) else needsRhymes(p) # residual arms
]
A key present on all arms does not split. Non-schema
unions are not refined. Tagged sum types still belong
to data + match, not dict
discriminants.
Stacks
First-class stack data type. See §18.
s: a Stack
s push 1
s push 2
s pop # 2
Matrices, tensors & dataframes
Three numeric/data containers, constructed by builtins (no literal
form). All three are first-class types — type(v),
@v, and v is Matrix / is Tensor /
is DataFrame agree.
Matrices — 2-D, with linear-algebra infix operators:
m: matrix([[1, 2], [3, 4]]) # 2×2 from nested arrays
type(m) # → "Matrix"
m + matrix([[10, 20], [30, 40]]) # elementwise + / -
m * matrix([[5, 6], [7, 8]]) # MATRIX PRODUCT ; m * 10 → scalar scale
m ^ 2 # matrix power (integer exponent)
transpose(m) # rows ↔ columns
det(m) # → -2 ; trace(m) → 5
reshape(matrix([[1, 2, 3], [4, 5, 6]]), 3, 2) # 2×3 → 3×2
zeros(2, 2) # 2×2 of 0s ; ones(2, 3) → 2×3 of 1s
solve(matrix([[2, 1], [1, 3]]), [5, 10]) # Ax = b → column vector (1, 3)
z: zeros(2, 3)
z[1, 2] = 1 # row 1, column 2 (1-based)
z[2, 3] = 1
z[:, 2:$] # columns 2 through last — a 2×2 Matrix
m[1, :] # row as 1×n Matrix ; m[:, 2] → column
m[1] # first cell, row-major ; m[0] errors
shape(z) # [2, 3] ; tuple(shape(z)) → (2, 3)
Index is 1-based on both axes. Last-index inside
[]is$(z[:, 2:$]), notend(endcloses a block).size(z)is cardinality and errors on a Matrix — writeshape(z). Nested arrays keepa[i][j];a[1, 2]on an Array is refused.matrixcells must be numbers (matrix([["", ""]])errors).
Iteration visits cells, row by row. for
and foreach over a Matrix use the same order as its linear
indices and m[:]. They do not yield row arrays. The
iterator pulls one cell at a time, preserving its Integer, Rational, or
Float type; starting another loop starts a fresh walk. No flattened copy
is needed.
m: matrix([[-19, 23, 0], [-1, 22, -17]])
for v in m [println(abs(v))] # 19, 23, 0, 1, 22, 17
collect(m) # [-19, 23, 0, -1, 22, -17]
[v * 2 | v <- m] # flat Array, same cell order
first(elements(m), 2) # [-19, 23], bounded lazy walk
for v in transpose(m) [println(v)] # explicit column-major cell order
elements(m) and each(m) return a fresh
single-use Generator; collect(m) and finite collection
verbs such as map, filter, and
reduce gather the same ordered cells. collect
retains its allocation limit. Materializing is explicit and does not
make len(m) / size(m) valid: dimensions still
come from shape(m).
Loop binders are fresh on each iteration. Assigning to the binder
does not change the matrix; an explicit write such as
m[2] = 7 is visible when that cell is reached. Rebinding
the name m does not redirect an already-started walk. To
visit columns without constructing a transpose, use nested loops over
shape(m)[2] then shape(m)[1] and read
m[row, column]. Matrix iteration is supported in the
interpreter; unsupported VM loop/iterator paths refuse.
Two naming gotchas.
identityis the identity function (identity(42)→42), not an identity-matrix constructor — writematrix([[1, 0], [0, 1]]). And there is no matrix-inverse builtin (inverseis the relation/set inverse) — for linear systems usesolve(A, b)directly.
Tensors — n-dimensional generalization:
t: tensor([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) # rank-3 from nesting
shape(t) # → [2, 2, 2] ; ndim(t) → 3
tensor([2, 3], 7) # shape + fill → 2×3 of 7s
tensor_reshape(tensor([1, 2, 3, 4, 5, 6]), [2, 3]) # → 2×3
squeeze(tensor([[1, 2, 3]])) # drop size-1 axes → vector [3]
expand_dims(tensor([1, 2, 3]), 0) # add an axis → 1×3
DataFrames — column-oriented tables (pandas-style):
df: dataframe({name: ["ada", "bob", "eve"], age: [36, 25, 30]})
type(df) # → "DataFrame"
df_select(df, ["name"]) # column projection
df_filter(df, func(row) [row["age"] > 26]) # row predicate — row is a Dictionary
df_sort(df, ["age"]) # ascending (optional 3rd arg: ascending?)
df_head(df, 2) # first rows ; df_tail(df, 1) → last rows
df_info(df) # schema: dtypes, non-null counts, memory
df_describe(df) # count / mean / std / min / quartiles / max
read_csv(path) loads a file into a
DataFrame, and write_csv(df, path [, options])
writes one back (2026-08-27) — options
{delimiter: ";", header: false, na: "NA"}; it returns the
path, and integer cells keep their exact digits however
large, so write∘read∘write is a fixed point.
df_groupby(df, cols) buckets rows. (For plain in-memory
grouping of arrays/hashes, group_by in §7 needs no DataFrame at
all.)
Typed DataFrames. A row schema uses the existing
dictionary type syntax; Float | Na permits a missing cell,
while an optional field? permits an absent column. A
present cell still has to satisfy its field's type.
type WineRow = {
sample_id :: String,
alcohol :: Float | Na,
quality :: Integer
}
let wines = read_csv("wines.csv", {delimiter: ";", schema: WineRow})
let selected = df_filter(wines, lambda (row) => row.quality >= 7)
let revised = df_mutate(wines, "alcohol", [12.0, na]) # for a two-row table
dataframe(columns, {schema: WineRow}) checks in-memory
columns against the same contract. The first version supports scalar
String, Integer, Float,
Boolean, None, Na, transparent
aliases and unions of those types. Nested mutable cells are refused
explicitly. Input column arrays are copied for typed tables so later
writes through those arrays cannot violate the table.
With a CSV schema, String fields preserve text such as
00123; other fields parse as their declared type, with
union alternatives tried in declaration order. Missing markers (by
default NA, na, N/A,
null, NULL, and the empty string, after
trimming) become na and require a Na arm. Use
na_strings: [] to preserve all those strings literally.
Required columns, duplicate headers, invalid cells and unexpected
columns are rejected with source/record/column context.
extra_columns: "ignore" discards undeclared columns; the
default with a schema is "reject". on_error
currently accepts only "fail"; misspelled/unsupported
options are errors. An empty file without headers cannot establish a
schema; a header-only typed table is valid.
df_mutate(df, name, values) returns a new table with
that column added or replaced, validates the complete candidate, and
leaves df unchanged on failure or success. The row schema
survives filtering, sorting, head/tail and column replacement; selection
projects it onto the selected columns. df_filter requires a
Boolean predicate result and reports predicate errors with a row number.
The CSV writer validates typed tables before opening the output
file.
These operations and top-level transparent type
declarations work in both the evaluator and VM. Local type declarations
and type ... = opaque ... remain evaluator-only. This adds
no new schema grammar. Standalone row field writes also preserve their
contracts; see Schema field writes above.
JSON is the value↔︎string pair
to_json(v [, pretty]) / from_json(s)
(2026-08-27) — pure functions, so they work in the browser build, and
the file genre composes from shipped parts:
from_json(read(%data.json)) and
write(path, to_json(v, true)).
to_json({b: 2, a: 1}) # → {"a":1,"b":2} — keys SORTED, bytes stable
to_json({3, 1, 2}) # → [1,2,3] — sets in canonical order (one-way)
to_json(2 ^ 100) # → exact digits, never a float
from_json("{\"k\": null}") # → {k: none} — JSON's null IS none
to_json(om) # → Error: om has no JSON value (two bottoms,
# one null — the round trip would lie)
to_json(1 / 3) # → Error: no finite JSON number — write
# float(x) or a string if that loss is meant
6. Operators & Control Flow
Arithmetic
2 + 3 2 - 3 2 * 3 6 / 3 7 % 2 2 ^ 10
100 ÷ 7 10.0 ÷ 3.0 # floor division (glyph)
100 div 7 100 idiv 7 # same operator (keyword)
1 rdiv 8 # word form of `/` → 1/8
Each binary operator has a symbolic/glyph form, a keyword form, and a prefix-builtin form. They produce identical AST and identical results — pick by readability:
| Operation | Glyph / symbolic | Keyword (infix) | Prefix builtin |
|---|---|---|---|
| True (exact) division | / |
rdiv |
rdiv(a, b) |
| Always Float | — | fdiv |
fdiv(a, b) |
| Quotient (floor) | ÷ |
div / idiv / quotient |
div(a, b) / idiv(a, b) /
quotient(a, b) |
| Remainder | % |
mod / modulo / remainder /
rem |
mod(a, b) / remainder(a, b) /
rem(a, b) |
| Both at once | — | — | divmod(a, b) → (q, r) |
True (exact/real) division is / and its word form
rdiv — there is no Unicode glyph for it.
÷ is the floor form (Julia-style), not the
elementary-school obelus for true division. Digraph: `div →
÷. fdiv is not an alias of /: it
always returns a Float.
// is a line comment (full alias of
#), pairing with block comments /* … */. It is
not floor division.
100 ÷ 7 # → 14 (glyph)
100 div 7 # → 14 (keyword)
100 idiv 7 # → 14 (word alias of div)
100 quotient 7 # → 14 (keyword longhand)
div(100, 7) # → 14 (prefix, keyword spelling)
idiv(100, 7) # → 14 (prefix, idiv spelling)
quotient(100, 7) # → 14 (prefix, long spelling)
1 rdiv 8 # → 1/8 (word form of /)
rdiv(1, 8) # → 1/8 (prefix)
8 rdiv 2 # → 4 (whole quotient stays Integer)
100 % 7 # → 2 (symbolic)
100 mod 7 # → 2 (keyword)
100 modulo 7 # → 2 (keyword longhand)
100 remainder 7 # → 2 (keyword longhand)
mod(100, 7) # → 2 (prefix, keyword spelling)
remainder(100, 7) # → 2 (prefix, long spelling)
rem(100, 7) # → 2 (prefix, short spelling — twin of div)
100 rem 7 # → 2 (keyword short)
divmod(100, 7) # → (14, 2) (combined — one division, both halves)
// this whole line is a comment
x: 10 // trailing comment (x is 10, not floor-div)
Each keyword has a prefix twin under its own name, so a reader
holding mod never has to know that remainder
is where it lives. mod / div /
idiv / rdiv are not a second implementation:
they hand their operands to the % / floor
(div/÷) / slash (/) operators
unchanged, which is why no example above can drift from its
neighbour.
All four floor. The quotient rounds toward −∞ and
the remainder takes the sign of the divisor, not the
dividend — on integers exactly as on floats, so
q * b + r == a holds for negative operands too:
-23 mod 7 # → 5 (NOT -2 — the sign follows the divisor)
23 mod -7 # → -5
-7 div 3 # → -3 (floor, NOT -2)
-7 ÷ 3 # → -3 (same)
divmod(-7, 3) # → (-3, 2)
mod(-23, 7) # → 5 (prefix agrees, by construction)
/ / rdiv vs
div/idiv/÷ for floats.
/ and rdiv are true/exact division (int/int
may yield a Rational); floor division always returns an integer-valued
result (floor toward −∞). The two differ for all float cases
and for negative-result cases in particular:
100.0 / 7.0 # → 14.2857… (real value)
100.0 ÷ 7.0 # → 14 (floor)
-7.0 / 3.0 # → -2.333… (real value)
-7.0 div 3.0 # → -3 (floor toward -∞, NOT -2)
mod / modulo / div /
idiv / rdiv / fdiv are
soft keywords: lexed as plain identifiers and
recognized as infix operators only when they sit between two expressions
on the same line. Variables, hash keys, and function names with these
spellings keep working (mod: 99, h.div,
rdiv: 9).
Comparison
== != === !== < > <= >=
3 ≤ 5 # glyph twin of <= (≥ for >=; ≠ for !=; slanted ⩽ ⩾ also lex)
0 ≤ x < 10 # ordering comparisons CHAIN: ⇒ (0 ≤ x) and (x < 10)
1 == 1.0 # true exact tower — mixed Integer/Float at the exact value
1 === 1.0 # false type-strict (infix equal?); 1 !== 1.0 is true
'a' == "a" # true a Character is its one-character String as a VALUE
'a' === "a" # false …and a distinct TYPE — the Byte precedent (byte(65) === 65 is false)
== is the exact-tower equivalence: 1 and
1.0 are the same number, and collections key by
==. === / !== are Elixir-style
strict equality — same as equal? / not equal?
— so an Integer never strictly equals a Float, at any depth
([1] === [1.0] is false). The same line separates the
equal as values, distinct as types pairs:
byte(65) == 65 and 'a' == "a" hold,
byte(65) === 65 and 'a' === "a" do not, at any
depth (['a'] === ["a"] is false), while collections still
key by == ({'a', "a"} has one element).
Ordering comparisons (< <=
> >= and their glyphs) chain like the
mathematician's interval notation — a < b < c
desugars to (a < b) and (b < c), reusing the middle
operand. Equality (== != ===
!==) deliberately does not chain,
preserving the (a == b) == c boolean idiom. Digraphs:
`leq / `geq.
Compound assignment
i: 5
i += 1 # ≡ i = i + 1 (also -= *= /=)
s: "log"
s += ":entry" # + concatenates strings/arrays, so += inherits that
c.n += 1 # property, index, and dereference targets work too
xs[2] += 7 # — the same target set the `=` form supports
target op= value is statement-level
sugar: it desugars at parse time to the same AST as
target = target op value (find-or-update), so the evaluator
and --vm inherit semantics byte-for-byte. It is not an
expression — y: (x += 1) is a parse error, so there is no
value-of-assignment ambiguity. Only += -= *= /= exist;
%= and **= error with a hint naming the
rewrite. There is deliberately no
++/-- (the C-family increment):
adjacent ++/-- are parse errors with a rewrite
hint — before this, --i silently parsed as double negation
-(-i) and returned i unchanged. Spaced forms
keep their math meaning: -(-x) and - -x are
still double negation. For a pure, value-returning step use
succ(n) / pred(n) (§22).
Logical (auto-dispatched on operand type)
and or not implies iff xor
&& and ∧ are exact aliases for
and; || and ∨ are exact aliases
for or. All spellings have the same precedence
(and binds tighter than or) and evaluation
rules: Boolean false on the left of and skips
the right operand; Boolean true on the left of
or skips it. A non-Boolean left operand retains the
existing multivalued dispatch and evaluates the right operand. These
aliases do not introduce operand-returning or truthiness-based logic;
use andalso / orelse for the latter. A single
& remains address-of, and | retains its
structural roles.
println(1 < 2 && 2 < 3) # true
println(true || false && false) # true
println(false && (1 / 0 == 0)) # false; right operand is skipped
println(true || (1 / 0 == 0)) # true; right operand is skipped
When operands are multi-valued logic instances (Belnap, Intuit3, Łukasiewicz, Kleene), the operators dispatch automatically. See §9.
xor is exclusive or — true when exactly one
side is true (iff is its negation, i.e. XNOR). The glyph
⊻ canonicalizes to xor (byte-identical, VM
parity): true ⊻ true → false. Digraphs:
`xor / `veebar.
andalso
/ orelse — the strictly two-valued pair
Both and and andalso short-circuit, so that
is not what separates them. The difference is what happens when an
operand is not a Boolean: and and
or dispatch to the operand's logic, while
andalso and orelse read its truthiness and
hand back a plain Boolean.
om and true # → Ω (dispatched: the SETL bottom propagates)
om andalso true # → false (om is falsy — collapsed to Boolean)
kleene("unknown") and true # → ?ᵏ (dispatched: Kleene's third value)
kleene("unknown") andalso true # → false
belnap("both") and true # → ⊤⊥ᵇ (dispatched: the glut survives)
belnap("both") andalso true # → true (a glut is truthy — collapsed)
Reach for andalso / orelse when a branch
needs a yes-or-no answer whatever the operand's type — a guard, a filter
predicate, an if test fed by data of unknown provenance.
Reach for and / or when the third (or fourth)
truth value is information you want to keep. Neither evaluates its right
operand when the left already decides the result:
boom: func() [ raise("evaluated!") ]
false andalso boom() # → false (right operand untouched)
true orelse boom() # → true
Logical negation —
not / ¬ / ! (and what
~ is not)
Logical negation has three equivalent spellings:
not true # → false word form (Python / ML / SQL family)
¬ true # → false math glyph
!true # → false C / TypeScript / Go bang (prefix only)
!!flag # double negation
if !(x is String) then … else …
map(!, [true, false]) # → [false, true] operator-as-value, same as `not`
Prefix ! is disambiguated from postfix factorial by
position, the same way unary - is
disambiguated from subtraction:
!true # prefix → logical not
5! # postfix → factorial (120)
x: 6
x! # → 720 (the name holds a number; factorial still)
!5! # → false ≡ !(5!) (postfix binds tighter into the operand)
1 != 2 # digraph not-equal (not "prefix ! applied to =")
s !~ pat # digraph regex non-match
sort!(xs) # ident immediately followed by `!(` is ONE identifier —
# the in-place twin of sort, not factorial of the sort builtin
splice!(xs, i) # same glue; unbanged splice is quasiquote, so the array
# mutator has to be a different identifier
The third reading of ! is a name, not
an operator. sort!(xs) is the call form of
sort! because the lexer glues ! onto an
identifier only when the next character is (. Without the
paren, x! stays factorial. sort! with no call
is postfix on the sort function and yields the
sort! builtin as a value. sort(xs) is a copy;
sort!(xs) mutates the array in place and returns it.
Tuples, lists, and sets stay on sort. The same pair exists
for the other copy-named array functions:
reverse/reverse!,
unique/unique!,
shuffle/shuffle!. Bang is a twin marker, not a
decorator — push already mutates, so
push!(t, x) is Undefined word, not an alias. Same for
shift! / unshift!: the verbs are
shift / unshift. splice! is the
one mutator that keeps the bang, because unbanged splice is
already quasiquote splicing (§5 Arrays).
Prefix ~x was retired (July 2026): it was an
undocumented alias of not, and every C-lineage reader (C,
Python, JS, Lua) reads ~5 as bitwise NOT
(= -6), so the valid reading was a silent cross-language
trap. ~5 is now a loud SyntaxError whose hint names the
rewrites. Likewise a ~= b (Lua/MATLAB's not-equal) is a
SyntaxError with a hint — write a != b (or
a ≠ b). It will not become an alias: =~ is the
regex-match operator, and mirror-image operators one
transposition apart with unrelated meanings would turn typos into silent
bugs.
Where ~ does appear: the defeasible markers
(rule~, defines~, unify~,
boundary~) and defeasible rule arrows (<~~,
~~>), and the Perl-borrowed regex pair (=~
match / !~ non-match). Bitwise NOT is
bit_not(x) — bit_not(5) → -6,
bit_not(0) → -1 — alongside band,
bor, bxor, bshl,
bshr.
nand
/ nor — the Sheffer stroke ⊼ and Peirce arrow
⊽
The two singly functionally-complete connectives (Post's theorem:
each alone defines ¬, ∧, ∨ — the company here is Mathematica's n-ary
Nand/Nor and APL's primitive
⍲/⍱). Two spellings each:
true ⊼ false # → true NAND glyph infix: ¬(p ∧ q)
false ⊽ false # → true NOR glyph infix: ¬(p ∨ q)
nand(true, true) # → false prefix builtin
nand(a, b, c) # n-ary: ¬(a ∧ b ∧ c), like Mathematica's Nand
nor([p1, p2, p3]) # collection fold — Wittgenstein's N-operator
# (TLP 5.502): true iff EVERY proposition is false
(p ⊼ p) == (not p) # → true ¬ recovered from NAND alone
nand/nor are not keywords
— a local nand: func(p, q) [...] binding shadows the
builtin, so existing definitions keep working. The glyphs sit at
xor/or precedence and are MVL-dispatched (each
logic computes ¬∘∧ / ¬∘∨ with its own tables: om ⊼ true →
Ω, belnap("both") ⊼ belnap("true") stays a
glut). Digraphs: `nand / `barwedge,
`nor / `barvee. Empty folds:
nand([]) → false, nor([]) →
true (the negated ∧/∨ identities). ⊼/⊽ chains are not
associative with themselves — parenthesize, or use the n-ary builtin for
¬(a ∧ b ∧ …).
therefore /
∴ — the conclusion connective
p therefore q (glyph p ∴ q) draws a
conclusion, marking it with [logical] grounding at full
strength — distinct from the truth-functional implies. The
glyph and the word are byte-identical:
p ∴ q # prints: p →[logical] q (strength: 1.00)
p therefore q # identical to the glyph form
Type ∴ directly, with the backtick digraph
`therefore / `qed / `thus, or via
the REPL \therefore+Tab expansion.
Set operations
union intersect difference symdiff in notin
subset superset subsetneq supsetneq # ⊆ ⊇ ⊂ ⊃ word twins
∪ ∩ \ △ ∆ ⊖ ∈ ∉ # glyph forms
⊆ ⊇ ⊂ ⊊ ⊃ ⊋ # subset family (neq = proper/strict)
Conditional
if x > 0 then "positive" else "non-positive"
if x > 10 then [
println("big")
] else if x > 0 then [
println("small")
] else [
println("non-positive")
]
Ternary cond ? a : b. The same
expression as if cond then a else b — one AST, one
evaluator path, --vm included. Right-associative;
or / comparisons / arithmetic bind tighter. Only one branch
runs.
sum(n) = n > 1 ? sum(n - 1) + n : n # → 15 for sum(5)
fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
true ? 1 : 2 # → 1
0 ? "T" : "F" # → "T" (zero is truthy, as with `if`)
The ? of the ternary is an infix after
a complete expression. It does not steal the predicate suffix:
empty?([]) is still the identifier empty?.
Prefix ?X is still a HiLog pattern variable.
?? / ??? / ?. still win the
longer token.
Two compact spellings are not the ternary, and they are loud:
| written | why |
|---|---|
n?1:0 |
? glues onto the identifier → undefined word
n? |
1<2?3:4 |
3:4 lexes as a Time literal, so the ternary never sees
a : |
Write spaces around ? and :, as the
examples above do. axioma/beginner refuses the ternary and
points at if cond then a else b.
One-arm match. if pat = e then … else …
(and the : spelling of =) desugars to
match e with | pat => … | _ => …. The pattern must be
a shape — a tuple, array, hash, constructor, pin, literal, or
as/in pattern — not a bare lowercase name
(if x = 5 then stays a SyntaxError; write
if x == 5 then). Bindings live in the then-arm only.
if (1, x, 3) = pair then x else 0
if Circle(r) = s then r else 0
pair matches (1, _, 3) # Boolean; bindings do not escape
matches is a soft keyword (matches: 5 still
binds the name). --vm refuses match,
if pat = e, and matches.
case is the noun form.
case e | p => … is the same matcher as
match e with, without a preposition: keyword, value, then
| arms. switch is the ReasonML spelling of the
same form (switch e | p => … ≡
case e | p => …; same CASE token). Reason's
{ } around the arms is not used — {} is the
empty set ∅. case, switch, and
cond are reserved words ($case /
$switch / $cond to bind those spellings).
case/strict / switch/strict is
match/strict. case e of / with /
do are SyntaxErrors. On match, the first
| after with is optional:
match e with p => … | _ => …. On case /
switch, the first arm starts with | (the value
is an expression). Postfix e match | p => … is the
value-first spelling. Arm arrows are => or
->, the same pair as lambdas.
cond is chained if.
cond | x > 0 => "pos" | else => "zero" desugars to
if/else if and has --vm parity.
Arms may wrap; the first | is optional. Catch-all arms:
else, otherwise, _. No catch-all
→ none.
case pair | (1, x, 3) => x | _ => 0
case pair | (1, x, 3) -> x | _ -> 0
switch pair | (1, x, 3) => x | _ => 0
pair match | (1, x, 3) => x | _ => 0
cond | n > 0 => "pos" | n < 0 => "neg" | else => "zero"
cond
| n > 0 -> "pos"
| else -> "zero"
area: function | Circle(r) => r * r | _ => 0
function | pat => … (also func | /
fn | / fun |) is a one-argument match lambda.
It requires the | so function f(x) stays a
named func.
Open vs closed hashes. {name, age}
matches if those keys are present; extra keys are allowed.
{name, age} exactly requires those keys and forbids extras.
{} exactly matches only dict().
{name, ..rest} binds leftover keys as a Dictionary.
match h with | {name, age} exactly => name | _ => none
match dict() with | {} exactly => "empty"
match h with | {name, ..rest} => rest
ADT leftover fields. P { x, ..rest }
binds the unlisted constructor fields as a Dictionary.
P { x, y } exactly requires every field of P
to be listed. Unlisted fields are ignored when there is no
.. / exactly.
data Point = P { x, y, z }
match pt with | P { x, ..rest } => rest.y
match pt with | P { x, y, z } exactly => x
exactly is a soft keyword (exactly: 5 still
binds). {a, ..} exactly is a SyntaxError.
| String(s) is not a type pattern.
Bracketed if-bodies are blocks, not arrays. Inside
then [...] and else [...], a single-expression
body returns the expression's value, not a single-element array —
if cond then [42] else [99] returns 42, not
[42]. Comma-separated [1, 2, 3] and empty
[] still parse as array literals everywhere. The same
contextual rule applies to match … | pat => [x]
arms.
What counts as true
Only the bottoms are false. false and
none are falsy; a typed multi-valued truth value is falsy
when its logic does not designate it. Every other value is
truthy — including 0, 0.0, "",
[], {} and dict().
if 0 then "T" else "F" # → "T" — zero is a number, not a bottom
if "" then "T" else "F" # → "T"
if [] then "T" else "F" # → "T"
if {} then "T" else "F" # → "T" — ∅ is a set, not falsehood
if none then "T" else "F" # → "F"
if ⊥ᵇ then "T" else "F" # → "F" — undesignated
if ⊤⊥ᵇ then "T" else "F" # → "T" — a glut IS designated
empty?([]) # → true — this is how you ask about emptiness
This is the SETL/Lisp policy rather than the C/Python/JavaScript one,
and the reason is specific to Axioma. ∅ is a first-class
mathematical object here, so a rule making the empty set false would
leave set theory unable to state its own theorems:
if (A intersect B) would silently mean "if they
intersect" — a different proposition from the one written.
Emptiness is a question you ask with empty?, which says
what it means.
One consequence worth internalizing: truthiness is not a "did
it work?" check. A caught Error is
truthy — handle it with
try/otherwise, not if.
(none and om are both falsy, and a total
read's absence is none, so you rarely need to
if-test an error at all — but is_error(e) is
the explicit test when you do.) The coalescing operators ??
/ ??? are not a third option here: they
fire on the bottoms, and an Error is a present
value that passes straight through them — see §29.
Truthiness is a total function of the value, and
if, while, filter, comprehension
guards, the postfix if/unless modifiers and
the logical operators all read the same one — in both runtimes. That is
not a free-standing promise: before July 2026 the interpreter decided
Booleans by comparing against its cached
true/false objects, so a Boolean produced by,
say, an FFI call was truthy whatever its value, and the same program
could branch differently under --vm.
Loops
Three keywords. for is an alias of foreach,
and loop of repeat, so there are three
constructs and five spellings.
# ── while — condition-driven; the counter is YOURS and outlives the loop
n: 1
while n <= 5 [ println(n) n = n + 1 ]
while (n < 10) [ n = n + 1 ] # parens optional
# ── foreach ≡ for — source-driven, and the variable is SCOPED
foreach v in [10, 20, 30] [ println(v) ]
for v in [10, 20, 30] [ println(v) ] # same keyword
for _ in 1..3 [ println("hi") ] # `_` DISCARDS: three trips,
# and the body cannot read it
for _i in 1..3 [ println(_i) ] # `_i` binds — use it when the
# element is actually wanted
for v at i in xs [ println(i, v) ] # 1-based index; three spellings
for v in xs with index i [ println(i, v) ] # of one idea
for i, v in xs.indexed [ println(i, v) ]
foreach [x, y] in pairs [ println(x + y) ] # destructuring; three spellings
foreach (x, y) in pairs [ println(x + y) ]
foreach x, y in pairs [ println(x + y) ]
for n in 1..3, m in 1..2 [ println(n * m) ] # several generators: ONE loop over
for n in 1..3, m in 1..n # the product, outer-major; the inner
println(n, m) # generator sees the outer variable;
end # break leaves the whole header
# ── repeat ≡ loop — source- OR condition-driven, one optional variable
repeat 5 [ println("hi") ] # count only, no variable
loop 5 [ println("hi") ] # same keyword
repeat i <- xs [ println(i) ] # bound variable over a source
repeat i <- 3 [ println(i) ] # a count IS a source — 1-based
repeat i 3 [ println(i) ] # older no-arrow spelling
repeat xs [ println("tick") ] # bare source, no variable
n: 0
repeat [n < 3] [ n = n + 1 ] # PRE-test block condition
n: 0
repeat [ n = n + 1 ] until n >= 3 # POST-test; body runs ≥ once
n: 0
repeat # same post-test, end-form dual
n: n + 1 # (`loop … until … end` too)
until n >= 3
end
# `repeat` is an expression too, so it can sit on the right of a binding —
# same headers, and the bound value is a by-product only.
r: repeat i <- 1..10 by 3 [ println(i) ]
End-form
blocks — if, while, for,
loop, repeat, function,
module … end
A second, keyword-terminated spelling of control-flow, function, and
in-file module bodies (2026-08-27; module 2026-08-29;
loop/repeat 2026-08-30). The rule: a header
with no then and no [ whose
next token sits on a later line opens a statement block
that runs to a closing end. It lowers at parse time to
exactly the AST the bracket form builds, so the two spellings cannot
differ in meaning — pick per construct, and mix freely.
module Name is the exception that needs a matching
end to be nested: without one it is the file header, and a
later function … end does not close it. Same-line empty
while false end is the empty body.
grade: if score >= 90 # an if-EXPRESSION, so it can sit on
"A" # the right of a binding
elseif score >= 80
"B"
else
"C"
end
n: 3
while n > 0 # while … end — and an expression, so
println(n) # `y = while false end` binds none
n: n - 1
end
loop 3 # loop/repeat … end — dual of `[ … ]`
n: n + 1
end
n: 0
repeat # post-test dual of `repeat [body] until cond`
n: n + 1 # later-line `until` closes the body; `end`
until n >= 3 # closes the block. Same-line `p until q` in
end # the body stays the LTL operator.
for i in 1..9 by 2 # for … end — every header clause works:
println(i * i) # destructuring, `at i` / `with index i`, `by`
end
function countdown(n) # function … end — all four spellings
if n <= 0 # (func/fn/fun/function), the anonymous form,
println("Blastoff!") # `:: ReturnType`, doc strings, `when`-guard
else # clauses and pattern clauses all take it;
println(n) # `f(x) = expr` stays the equation short form
countdown(n - 1)
end
end
module Color # module … end — dual of `module Color [ … ]`
name(c) = "c:" + c # a file-header `module Name` with no matching
end # `end` stays a header (see §34)
The body is a plain statement sequence — newlines or ;
separate statements, the last statement's value is the block's value,
and an if … end with no true branch is none.
elseif chains arms under ONE closing end;
writing else if (two words) nests a fresh if
instead, which carries its own terminator — exactly Julia's distinction,
and why elseif exists. A parenthesized condition may span
lines; an unparenthesized one ends with its line.
end and elseif are reserved
words. A variable spelled end takes the guard
($end: 42), POP-11 word lists still read the bare word
([begin end] is ["begin", "end"]), and
xs[end] is a SyntaxError whose hint points at
xs[$] / xs[-1] / last(xs).
Everything else is untouched: if c then …,
then [ … ], while c [ … ],
for x in xs [ … ], module M [ … ], a
file-header module M with no matching end, and
every bracket reading mean what they always meant, --fmt
indents end-blocks without rewriting them, and source()
prints the canonical bracket form.
Loop sources — the same set in for … in
and repeat … <-:
for v in [1, 2, 3] [ … ] # array
for v in (1, 2, 3) [ … ] # tuple
for v in {3, 1, 2} [ … ] # set — ONE canonical order, numeric
for v in bag([3, 1, 2, 1]) [ … ] # bag — a multiset, WITH multiplicity
for c in "abc" [ … ] # string, by character
for k in keys(d) [ … ] # dictionary keys, sorted
for k, v in items(d) [ … ] # dictionary pairs, destructured
for d in Day [ … ] # enum, in declaration order
for x in SomeConcept [ … ] # a concept's instances
for i in 1..3 [ … ] # range, inclusive
for i in 1..<3 [ … ] # exclusive end
for i in 3..1 [ … ] # descending, from the operands
for i in 1..10 by 3 [ … ] # `by` = positive MAGNITUDE
for i in 10..1..-3 [ … ] # `..n` = SIGNED step
for i in range(1, 10, 3) [ … ] # the call form — the same Range value
for i in 1.. [ if i > 3 then break ] # OPEN range — infinite, so break
for v in (n * n | n <- 1..) [ … ] # lazy comprehension
for i in iterate(func(v) [v * 2], 1) [ … ] # arbitrary-step stream
for i in take_while(func(x) [x < 20], iterate(func(v) [v * 2], 1)) [ … ]
break and continue work in every loop form,
and return escapes the whole function from inside one.
repeat-style loops without until are pre-test
(the count or condition is checked before each pass); the
until form is post-test —
repeat [body] until cond or the end-form
repeat … until cond end /
loop … until cond end. A later-line until
closes that body; same-line p until q remains the LTL
operator.
Infinite loop / repeat, and
break with a value. loop is a hard
alias of repeat — every counted and source form already
accepted both words. A one-block form with no count, no source, and no
until is the infinite loop (Rust's
loop { … }). break v is the value that loop
yields; a bare break yields none. The value
starts on the break's own line, the same rule
return uses:
n: loop [
break 123
]
# n is 123
k: 0
n: loop [
k: k + 1
if k == 3 then break k * 2
]
# n is 6 — `if c then break v` is the same sugar as `then [break v]`
while is an expression too:
y = while false end binds none, and
r: while true [ break 2 ] binds 2. A completing while
(condition goes false, no break) yields none;
break v is how it yields a value. The end-form dual of
loop [ … ] is loop / repeat with
a newline then body then end. --vm agrees on
the no-break forms and refuses the infinite form by name until
break compiles.
Loop labels. name: already binds the
loop's result (n: loop [ break 123 ]). The loop's
own name is a Word — 'outer — so Java/Go
outer: for and Rust 'outer: loop have an
Axioma twin that does not steal the binder or break v:
found: 'search: loop [
for row in [[1, 2], [10, 30]] [
for cell in row [
if cell > 25 then break 'search cell
]
]
]
# found is 30
'rows: for n in 1..3 [
for m in 1..3 [
if m == 2 then continue 'rows
]
]
'name: before loop / repeat /
while / for / foreach names that
loop. break 'name / continue 'name target it;
break 'name v still yields v. Unlabeled
break leaves only the innermost loop. A combined header
takes one name for the whole product
('prod: for n in 1..3, m in 1..3 [ break 'prod ]). Unknown
and duplicate labels error. To yield a Word, parenthesize it:
break ('ok). --vm refuses break
(labeled or not).
A loop scopes what the loop binds.
for/foreach and every source form of
repeat bind the loop variable themselves, so it does not
outlive the loop. The condition-driven forms — while,
repeat [cond] […], repeat […] until cond,
infinite loop [ … ] — bind nothing: the counter is yours,
lives in your scope, and survives. A body still reads and updates
enclosing names normally, so accumulator loops are unaffected — that is
a body assignment, which is find-or-update. The loop variable
itself is a binder, so one that matches a pre-existing outer
name shadows it rather than writing it (see §Scoping & shadowing).
A lazy stream is what a lazy comprehension, an open
range, and iterate(f, x) produce (@g →
"Generator"). The loop pulls one element at a time, so an
unbounded source is fine as long as the body breaks — or as
long as take_while bounds it, which stops at the first
failing element. iterate(f, x) without a count is how you
get a counter whose step is an arbitrary function while keeping it
scoped to the loop: for i in 1..n steps by a constant, and
while takes any step but leaves its counter bound after the
loop ends.
A stream is walked once. Stopping partway and
looping again resumes where you left off; walking a stream that
has already been drained (by force, by first,
or by a loop that ran to the end) raises a catchable error rather than
silently running zero iterations.
A complete, executable inventory of every form above — each one
asserted, so it cannot rot — lives in
tests/axioma/showcase/loops.ax.
Statement branches — if c then break,
if c then a: 0. A branch may be a bare
statement: break, continue,
return, or a binding. Each is sugar for
the block form the brackets already spelled (then [break]),
built by the same parse, so the two spellings are the same AST and
cannot drift. Both arms take all of them:
while true [
if done then break # ≡ if done then [break]
if skip then continue
if x < 0 then "note it" else break # the else arm too
]
if a < 0 then a: 0 # bindings too — `=` spells it as well
if a < 0 then a = 0 else a: 100 # …in either arm
if hit then tally[k] += 1 # every assignment target the brackets
if hit then rec.total: 0 # accept: multi-assign, index, property,
m, n: 1, 2 # compound
if swap? then m, n: n, m
Two things to know. A binding branch scopes a fresh name to
the block, exactly as if c then [z: 1] always did
— so if c then z: 1 on a new z binds
something that vanishes at the branch's end, while an existing
name is found-and-updated (the common case). And a postfix guard is
return-only: return -1 if x < 0 works,
break if x < 0 does not (write
if x < 0 then break).
Indexed iteration
— at i, with index i,
.indexed
When a loop needs the element and its position,
three equivalent surfaces attach a 1-based counter (element-first, so
the common element-only loop stays the short one). for and
foreach are one keyword — every form below works with
either spelling:
fruits: ["fig", "plum", "lime"]
for f at i in fruits [ println("${i}. ${f}") ] # at-clause
for f in fruits with index i [ println(i) ] # with-index clause
for i, f in fruits.indexed [ println(f) ] # destructure the pairs
for i, f in enumerate(fruits) [ println(i) ] # builtin spelling
pairs: [(10, "x"), (20, "y")]
for v, tag at i in pairs [ … ] # composes with destructure: v, tag
# from the element, i from the counter
for n at i in 2..100 by 2 [ # any iterable — ranges included; break
if i == 3 then [break] # and continue see the same i
]
The counter is always 1-based and counts iterations
(for a String it counts runes; for a Set it follows the canonical sorted
order — the same order .indexed and enumerate
report). at and index are soft
keywords, recognized only in these clause positions — variables
named at or index, and loops like
for at in xs, keep working. Writing both clauses at once
(for x at i in xs with index j) is a pointed error: pick
one.
An open-ended range makes the classic search-until loop:
s: 0
foreach k in 1.. [ # 1, 2, 3, … forever — until break
s = s + k
if s > 20 then [break]
]
s # → 21
Like all loops, the indexed forms are evaluator-only under
--vm (the pre-existing boundary). Tests:
tests/axioma/control/test_indexed_iteration.ax,
tests/axioma/properties/test_indexed_accessor.ax.
Operator precedence (high → low)
This table is for ordinary expression position.
Parentheses explicitly group an expression. “Left” means
(a op b) op c; “right” means a op (b op c).
These are parsing rules, not mathematical associativity or a side-effect
schedule. The Textbook's Appendix
A operator reference expands the operator families, specialist forms
and worked examples.
| Higher to lower | Forms | Grouping |
|---|---|---|
| Postfix | Factorial ! |
Attaches to the expression on its left |
| Call/access | f(x), f.(xs), [i],
.field, ?.field, possessive access,
record-constructor call |
Leftward chain; same priority |
| Prefix | unary +, not / ! /
¬, E!, &, dereference
* |
Operand includes calls/access/factorial |
| Power | ^, **, .^ |
Right |
| Negation | unary - |
Operand includes power, stops before multiplication |
| Multiplicative | * / % ÷, dotted counterparts;
mod/modulo/remainder/rem,
div/idiv/quotient/rdiv/fdiv; intersection, ⊓,
⊗; ∘ << >>; backtick call;
cons, +:, :+ |
Left, except cons and +: right; mixed
+:/:+ requires parentheses |
| Additive | + - .+ .-; union, difference, symmetric difference,
⊔, ⊕;
band bor bxor bshl bshr |
Left |
| Range | .., ..<, optional ..step /
by step |
Special range grammar |
| Ordering | < <= > >= and glyph aliases |
Ordering chains |
| Equality/relations | == != === !== =~ !~; membership/subset/DL relations,
is, isa; matches |
Ordinary binary forms left; matches takes a
pattern |
| Conjunction | and / && / ∧,
andalso |
Left |
| Disjunction | or / || / ∨, xor
/ ⊻, ⊼, ⊽,
orelse |
Left; or else exception below |
| Implication/pair | implies / impl / → /
⟹, iff / ↔︎ / ⟺; pair
=> / -> |
Implication/iff left; pair right |
| Fallback/ternary | otherwise, ??, ???;
c ? a : b |
Fallbacks left; ternary special |
| Pipe/postfix match | |>, |?>; e match | … | Pipes left; match introduces arms |
Grouping examples. 2 ^ 3 ^ 2 =
2 ^ (3 ^ 2) = 512; (2 ^ 3) ^ 2 =
64, so exponentiation is not mathematically associative.
-2 ^ 2 = -(2 ^ 2) = -4, while
(-2) ^ 2 = 4. not a == b is
(not a) == b; use not (a == b) to negate the
comparison. 1 cons 2 cons list() is
cons(1, cons(2, list())). 1 => 2 => 3 is
pair(1, pair(2, 3)); lambda heads and match/cond arms claim
arrows before the infix pair parser. Unicode → is
implication, not a pair.
Calls and composition. f(x)[i].field
groups as ((f(x))[i]).field. 3 `f` 4 * 2 is
f(3, 4) * 2. f ∘ g(x) is
f ∘ (g(x)); write (f ∘ g)(x) to apply the
composition. x |> f(a) inserts the value as the last
argument, f(a, x); _ selects a position.
<</>> compose functions; bit
shifting uses bshl/bshr. See Forward pipe and
Function composition below.
Special chains. a < b <= c
rewrites to (a < b) and (b <= c); the shared middle
expression can execute twice, so bind it once when necessary.
==/!= chains do not get this rewrite.
1..5..2 is start/end/signed-step, not a nested binary
range. a ? b : c ? d : e is
a ? b : (c ? d : e); the yes-branch accepts a whole
expression up to its matching :, while the no-branch
accepts another ternary/fallback but stops before a following pipe.
Parenthesize mixes.
Two-word fallback exception. or else
has the error trigger of otherwise, but currently enters at
disjunction priority and reads its right side down to fallback priority.
Thus a ?? b or else c is a ?? (b otherwise c),
unlike a ?? b otherwise c, which is
(a ?? b) otherwise c. Repeated or else nests
right. Use (a ?? b) or else c when the whole coalescing
expression is to be protected. It is not a precedence-identical
replacement for otherwise.
Evaluation is a separate rule. Boolean
false and rhs / true or rhs (and their
glyph/ASCII aliases) skip rhs; multi-valued left operands
may need both sides. andalso/orelse use
truthiness and return a Boolean. otherwise selects its
right operand for an Error, ?? for none,
??? for none or om. Conditionals
run only the selected branch. Right-grouping power does not mean its
rightmost operand runs first. The evaluator's ordinary pipe insertion
can evaluate explicit call arguments before the appended input
expression; bind side-effectful inputs first when order matters.
Broadcasting has its own fusion/order rules. Precedence alone neither
adds nor removes evaluations.
Specialist entry priorities, above postfix
factorial, high to low: Manchester some/only;
ontology/definition; analogy; argument; binary temporal; deontic;
epistemic; causal/aspect. Same-band binary forms generally group left,
but knows/believes/doubts and
must/may/must_not/should take a whole expression on the
right; causes/caused by take a right side tighter than
equality, including another causation. R some C ⊓ D is
(R some C) ⊓ D, not R some (C ⊓ D). Prefix
modal/temporal forms use the ordinary prefix operand boundary instead.
See Appendix A's complete specialist table and §10 for operand
semantics.
Grammar boundaries. Rule arrows parse whole
heads/bodies; show and graph operations use clause grammar
even though entered at equality priority. Assignments, ::,
type/pattern syntax, statement messages, comprehensions, commas and
semicolons are not extra ordinary binary rows. A semicolon ends an
ordinary expression. Newline and delimiter rules can stop an expression
before precedence applies. ought/permitted/forbidden have
no ordinary infix handler; satisfies,
with_probability, infix probably/typically,
denotes, normally, by_default
have no active ordinary infix priority. Their presence in parser
metadata does not make them supported expression operators.
Negative integer exponents stay exact.
int ^ -n returns the exact Rational 1/int^n
(an Integer when integral), the same way / keeps
1/2 exact — so 2 ^ -3 → 1/8,
2 ^ -1 → 1/2, 1 ^ -5 →
1. (0 ^ -3 is a division-by-zero error.) Use a
Float base/exponent or pow(...) if you want a Float result
instead.
A fractional exponent is a root, and exactness follows the
operands. A rational exponent p/q takes the
q-th root. When that root is exact you get an exact answer,
at arbitrary precision — no detour through float64:
4 ^ (1/2) # 2 — an Integer, not 2.0
(8/27) ^ (1/3) # 2/3 — numerator and denominator each rooted
27 ^ (2/3) # 9
4 ^ (-1/2) # 1/2 — negative exponent inverts, as ever
(2^100) ^ (1/2) # 1125899906842624 (= 2^50, exactly)
When no exact root exists, ^ still answers — as a Float,
so it is never a dead end:
2 ^ (1/2) # 1.4142135623731 — √2 is irrational
This is the same exactness rule + and *
follow: two exact operands can give an exact result; one inexact
operand makes the result inexact. So the exponent's spelling
decides the type, and the two spellings still agree on the
value:
4 ^ (1/2) # 2 Integer — exact exponent
4 ^ 0.5 # 2 Float — inexact exponent
4 ^ (1/2) == 4 ^ 0.5 # true — same number either way
A negative base has no real power at a non-integer
exponent. It raises a catchable error rather than producing
NaN:
(-1) ^ 0.5 # ERROR: negative base has no real power at a non-integer exponent: -1^0.5
(-2) ^ 3 # -8 — an INTEGER exponent is fine, whatever the sign
(-2.0) ^ 2.0 # 4
The rule keys on the exponent, not on the sign of the base
alone. Erroring is deliberate: a NaN would flow silently
through every later computation, and the neighbouring spelling
sqrt(-1) has always errored — two spellings of one question
should not disagree about whether it has an answer.
Floats keep IEEE semantics where the two systems differ:
0.0 ^ -1.0 is Inf, while the exact
0 ^ -1 is a division-by-zero error. The exact tower answers
with mathematics; Float answers with IEEE.
The complex plane is the explicit opt-in.
sqrt(-1) and (-1) ^ 0.5 stay real-domain
errors, and both name the way through:
sqrt(complex(-1, 0)) # i
complex(-1, 0) ^ 0.5 # i — the same value; the two spellings agree
Complex sits at the top of the numeric
tower: every other numeric type embeds into it, so mixed
arithmetic just works, and ^ is exact at integer
exponents.
im # i — the unit, shadowable like pi
1 + im # 1 + i
(1 + im)^2 # 2i
im^2 # -1 — exactly, not -1 + 1.2e-16i
2 * im # 2i — not 2im (juxtaposition is diagnosed)
complex(3, 4) + 1/2 # 3.5 + 4i — Rational embeds
complex(3, 4) + byte(2) # 5 + 4i — so does Byte
conjugate(complex(3, 4)) # 3 - 4i
-complex(1, 2) # -1 - 2i
sqrt, exp, log,
sin and cos accept a Complex
alongside the reals. The embedding runs through float64, so
exactness stops at the complex boundary —
exact?(complex(1, 0)) is false, and
complex(0, 1) + 1/3 is inexact. Keeping exactness inside
the plane would need exact Gaussian rationals, which Axioma does not
have.
Function broadcasting
A dotted call applies an ordinary runtime function element by element:
f(x) = 2 * x
f.([1, 2, 3]) # → [2, 4, 6]
broadcast(f, [1, 2, 3]) # → [2, 4, 6]
add(a, b) = a + b
add.([1, 2, 3], 10) # → [11, 12, 13]
add.([1, 2, 3], [10]) # → [11, 12, 13]
length.([[1, 2], [3]]) # → [2, 1]
add.(matrix([[1, 2]]), matrix([[10], [20]])) # → 2×2 Matrix
Array, Tuple, List and finite Range inputs have one axis. Matrix and Tensor keep their explicit axes. Other ordinary values, including String and Dictionary, are scalar arguments. Nested Arrays remain elements: they do not create additional axes. With only scalars the function is called once; with an empty output it is not called. Unbounded/lazy iterators must be bounded and collected before broadcasting.
Axes align from the right. Equal sizes match; a
singleton axis expands to the other size. A vector therefore matches the
last Matrix/Tensor axis. Incompatible shapes error before callbacks. The
output is Array for ordinary sequences, Matrix if a Matrix participates,
and Tensor if a Tensor participates. Matrix cells retain exact numeric
types; nonnumeric Matrix results refuse (use collect(m) as
input for an unrestricted Array result). Tensor output keeps its Float
storage and refuses silent loss of exact numeric precision.
The callable and arguments evaluate once, then calls run in row-major order with ordinary type, contract and effect checks. Named arguments are scalar options shared across calls. To use slash refinements, put the refined call inside a normal function and broadcast that function. Failures stop the walk without retry or rollback. Input shapes are captured; inputs are not repeated into temporary arrays. Eager output is limited to one million elements.
Nested dotted calls and supported dotted arithmetic use loop fusion:
square(x) = x * x
increment(x) = x + 1
increment.(square.([1, 2, 3])) # → [2, 5, 10], one output walk
increment.([1, 2, 3] .^ 2) # → [2, 5, 10]
Fusion builds a plan of the nested operations and creates only the final result collection. Leaf expressions and function expressions evaluate once; for each output cell, nested functions execute in expression order. Functions with side effects participate too: nested calls interleave per cell rather than completing every inner call before any outer call. A singleton or nullary inner operation can therefore execute repeatedly across the output shape. Empty outputs execute no scalar callbacks. Shape errors are checked before scalar callbacks; a callback error stops the walk without retry.
Ordinary function calls, including broadcast(...), break
fusion. Bind an intermediate result when separate evaluation is
intended. Ordinary pipe stages are also boundaries; nested dotted
expressions within a stage can fuse without changing pipe argument
insertion.
Only the final container is constructed: intermediate scalar values
keep their types, without the promotions that a separately materialized
Matrix or Tensor could introduce. Final Matrix/Tensor conversion remains
strict. Existing dotted arithmetic retains its own numeric and shape
rules; fusion does not grant Array operators singleton expansion or
Matrix/Array mixing. Dotted calls and broadcast are
interpreter features; the VM explicitly refuses them, including builtin
aliases and higher-order use. Connected dotted arithmetic requiring
fusion also refuses in the VM; standalone dotted arithmetic and explicit
materialization boundaries retain their support.
Forward pipe |>
The forward pipe threads a value into a function call, so a chain of
transformations reads left-to-right in the order it
runs — instead of the inside-out nest that
map/filter/reduce otherwise
force:
xs |> map(g) |> filter(p) |> reduce(f, 0) # ≡ reduce(f, 0, filter(p, map(g, xs)))
21 |> double # bare name → double(21) → 42
The piped value is appended as the last argument —
Axioma's higher-order functions are function-first / collection-last
(map(fn, coll), filter(pred, coll),
reduce(fn, init, coll)), so the everyday chain just works.
For the collection-first builtins
(str_join, first, push), an
explicit _ hole says where the value lands:
["a", "b", "c"] |> str_join(_, "-") # ≡ str_join(["a","b","c"], "-") → "a-b-c"
myset |> first(_, 3) # ≡ first(myset, 3)
| Form | Means | |
|---|---|---|
x |> f |
f(x) |
bare name |
x |> f(a, b) |
f(a, b, x) |
last-arg insertion |
x |> f(a, _, b) |
f(a, x, b) |
_ hole |
a |> f |> g |
g(f(a)) |
left-associative |
x |> h.fn |
h.fn(x) |
function stored in a slot |
|> has the loosest precedence (so
20 + 1 |> double is (20+1) |> double),
is left-associative, and composes in expression position — inside
function bodies, if conditions, bindings, and across
multiple lines (a leading |> continues the
pipeline):
result: data
|> filter(func(r)[r.valid])
|> map(transform)
A natural fit for Axioma is post-processing a relational query or
rendering with the *form family:
{Y | Y <- ancestor("tom", Y)} |> sort |> first(_, 3)
txns |> filter(func(t)[t.amount > 50]) |> tableform |> println
The same placement rule applies to broadcasting:
add(a, b) = a + b
[1, 2, 3] |> add.(10) |> sum # → 36
[1, 2, 3] |> add.(10, _) |> sum # → 36
[1, 2, 3].map(x -> 2 * x) |> sum # → 12
The pipe passes the whole value; the dotted call
then broadcasts it. Only the first bare _ among top-level
positional arguments is replaced. There is no recursive hole search or
implicit lambda: xs |> _.length() and
xs |> map(_ * 2) are not receiver or lambda shorthand.
Use xs |> length, xs.length(), or an
explicit function argument. The pipe's VM support does not make
unsupported broadcast/method operations VM-compatible.
Error-propagating pipe
|?>
|?> is the safe sibling of
|>: it short-circuits on a failed,
absent, or undetermined value. If a stage yields an Error
(e.g. from try(...) / error(...)),
none, or om, the remaining stages are skipped
and that value becomes the result — railway-oriented error flow without
nested ifs:
input |?> parse |?> validate |?> compute # stops at the first failing stage
5 |?> double |?> double # → 20 (nothing fails)
5 |?> boom |?> double # → the boom Error (double is skipped)
5 |?> nada |?> double # → none (double is skipped)
0 |?> double # → 0 (0 is a valid value, not a failure)
It shares everything else with |> — last-argument
insertion, the _ hole, left-associativity, dotted callables
— and differs only in the short-circuit. A plain value (even a falsy one
like 0 or "") is not a failure and
flows through; only Error / none /
om stop the pipeline. Use |> when every
stage always succeeds, |?> when a stage may fail and you
want the failure to propagate.
|?> propagates a failure;
otherwise / ?? / ???
replace one, and ?. guards a single member
read. They are one family seen from opposite ends — §29 has the table of
which form fires on which bottom, and the two compose well: pipe the
failure along, then coalesce it at the end. One practical difference:
|?> is the only member with full --vm
parity. The other four are evaluator-only and refuse at compile time,
whereas |?> compiles and runs byte-identically in both
runtimes.
7. Set Theory & Comprehensions
Comprehensions
{x * 2 | x <- {1, 2, 3, 4, 5}} # {2, 4, 6, 8, 10}
{x | x <- range(1, 10), x mod 2 == 0} # {2, 4, 6, 8, 10}
{(x, y) | x <- {1, 2}, y <- {3, 4}} # {(1,3), (1,4), (2,3), (2,4)}
[1 | _ <- [7, 8, 9]] # [1, 1, 1] — `_` DISCARDS:
# walks, but binds nothing
[a | (a, _) <- [(1, 2), (3, 4)]] # [1, 3] — a bound, slot dropped
The generator clause is target <- source — or, in the
set-builder slot described below, target in source /
target ∈ source.
A _ target is a discard, exactly as in
a for loop or a match arm: the generator still
yields one row per element, but nothing can read _. Name
the target (or use _i) when the element is wanted.
British/ISO colon separator
Math texts write set-builder two ways: {x | P(x)}
(American) or {x : P(x)} (British/ISO). Set comprehensions
accept both separators — the two forms are identical down to the
AST:
{x : x <- range(1, 10), x mod 2 == 0} # same set as the pipe form
{2 * k : k <- range(1, 5)} # {2, 4, 6, 8, 10} — the {2k : k = 1..5} style
{(x, y) : x <- {1, 2}, y <- {3, 4}} # multi-generator works too
Hash literals are unaffected: {k: v, ...} stays a hash —
the colon is read as a comprehension separator only when a generator
clause (target <- source, or a gated
target ∈ source) provably follows it. The colon form
applies to set comprehensions only; dict comprehensions
keep | (their head already uses :), and list
comprehensions keep | (inside [...] a colon
means a block binding).
ISO membership generators
— {x : x ∈ U, P(x)}
Real ISO/British texts put membership in the binder slot.
in / ∈ is accepted as a generator alias for
<- under one rule: the clause
x in S is a generator iff x is not already
bound in the comprehension and occurs in the head; otherwise it keeps
its membership-filter meaning. This makes the textbook form
executable verbatim while leaving every membership test untouched:
{x : x ∈ {1, 2, 3, 4}, x > 1} # {2, 3, 4} — full ISO form
{x | x in range(1, 10), x mod 2 == 0} # word form, pipe separator
[x * 2 | x in [1, 2, 3]] # lists too → [2, 4, 6]
{(x, y) : x ∈ {1, 2}, y ∈ {8, 9}} # multi-generator Cartesian
{a + b : (a, b) ∈ pairs} # tuple-destructure target
# Membership FILTERS keep their meaning (the gate):
{x | x <- s, x in t} # x bound → filter (s ∩ t idiom)
{c | c <- cs, g in c.allies} # g is outer/global → filter
{x | x <- s, x ∉ t} # ∉ never converts
[x for x in xs if x in t] # Python if-clause: always a filter
Works in all four comprehension flavors (set / list / dict / lazy),
with both spellings (in, ∈) and both
separators (|, :). One reinterpretation to
know about: the hash {x: x in s} — the same name
as key and membership subject — now reads as a comprehension; write
{x: (x in s)} (parenthesized value) or quote the key to
keep the hash.
Relational comprehensions (Prolog-style)
{X | X <- parent(X, _)} # All parents
{(X, Y) | parent(X, Y)} # All parent-child pairs
{X | X <- parent(X, _), age(X, A), A >= 18} # With filter
Concept-extent comprehensions
When the iterable is a bare concept name, comprehension iterates the
concept's auto-maintained Instances map:
usa: a Country {}
china: a Country {}
{X | X <- Country} # All countries
Tag-filter comprehensions
Filter relational-source comprehensions by epistemic grounding tag — in the set, list, dict, multi-variable, and multi-generator forms:
{X @theorem | X <- derived_relation(X, _)} # Theorems only
{X @conjecture | X <- flies(X)} # Defeasibly derived
[X @axiom | X <- parent(X, _)] # list form
{X: 1 @datum | X <- parent(X, _)} # dict form
{(X, Y) @axiom | rel(X, Y)} # multi-variable form
{(X, Y, n) @axiom | (X, Y) <- rel(X, Y), n <- [1, 2]} # multi-generator
Accepts @axiom, @postulate,
@theorem, @conjecture,
@hypothesis, @datum, @canceled,
@all, @* (@standalone is a legacy
alias of @datum). The tag filters facts drawn from
relational sources by their stored grounding. On a
non-relational source — a plain array/set, a concept extent
(X is Country), or a chain query
(rel1(...) and rel2(...)) — the tag is inert: those
elements carry no per-fact grounding to filter on.
Dict comprehensions
Produce a hash by emitting a key/value pair per iteration. Pipe form and pipe-less form are both supported:
xs: [1, 2, 3, 4, 5]
{x: x * x | x <- xs} # {1:1, 2:4, 3:9, 4:16, 5:25}
{x: x * x | x <- xs, x > 2} # {3:9, 4:16, 5:25}
{x: x * x for x in xs if x > 2} # pipe-less form, same result
Walrus bindings (compute once, reuse)
Bind a name mid-clause to avoid recomputing. The canonical form is
:, the same operator used for ordinary value binding:
{y | x <- xs, y: f(x), y > 0} # bind y once, filter on it
Bindings are iteration-local — they do not leak out of the comprehension (unlike Python's walrus, which escapes into the enclosing function scope).
A comprehension binding clause is written
y: expr; there is noy := exprorlet y = exprform.
Multi-filter folding
Multiple bare-expression filters in one comprehension are folded with
and:
{x | x <- 1..10, x > 3, x < 8, x % 2 == 1} # {5, 7}
Keyword aliases & the pipe-less form
Axioma also accepts for x in iter and
if cond as aliases for <- and the bare
filter inside the standard pipe form, plus a fully pipe-less form for
all three comprehension flavors (so a comprehension copied from Python
pastes in unchanged):
{x * 2 | for x in xs, if x > 2} # keywords inside pipe form
[x * 2 for x in xs if x > 2] # pipe-less list comp
{x * 2 for x in xs if x > 2} # pipe-less set comp
{x: x * x for x in xs if x > 2} # pipe-less dict comp
The first generator may use either for x in iter or
x <- iter. Subsequent clauses are equally flexible.
Deferred values —
lazy, force, memoize
lazy <expr> defers a computation and hands you the
thunk itself — a first-class value whose successful
result is cached when you force it. A failed attempt may
retry; recursive forcing reports cyclic demand.
t: lazy (6 * 7) # nothing has run yet
force(t) # → 42 computed now…
force(t) # → 42 …and memoized: not recomputed
This is the expression twin of the
declare binding:
| Form | Kind | Forced by |
|---|---|---|
declare x = e |
binding — transparent | reading x (automatic) |
t: lazy e |
expression — a first-class value | force(t) (explicit) |
The difference is what you can do with it. A declare
binding has already forced itself by the time it reaches an argument; a
lazy value can be passed, stored, and
returned still unevaluated — so a computation that is never
needed never runs at all:
pick: func(flag, thunk) [if flag then force(thunk) else "skipped"]
pick(false, lazy slow()) # → "skipped" — slow() never ran
Thunks are ordinary values, so they compose:
ts: [lazy (1 + 1), lazy (2 + 2)]
map(force, ts) # → [2, 4]
force covers the whole family and is
idempotent: a thunk computes (once), a generator drains, and anything
else comes back unchanged — R7RS Scheme's rule, which is what makes
force safe to map over mixed data.
memoize(f) is the per-call companion.
Where lazy defers one expression, memoize
caches every call, keyed by argument value:
fast: memoize(slow)
fast(9) # computes
fast(9) # cached — slow() is not called again
lazyis a soft keyword — it is still usable as an ordinary name (lazy: 5,f(lazy)). It binds at prefix precedence, solazy 1 + 2is(lazy 1) + 2; parenthesize to defer the whole expression.lazyanddeclareare both evaluator-only — rejected at compile time under--vm.
Lazy generator expressions
Comprehensions wrapped in parens produce a lazy
Generator instead of materializing. The source is pulled
one element at a time on demand.
g: (x * 2 | x <- [1, 2, 3, 4, 5]) # Axioma pipe form
g: (x * 2 for x in [1, 2, 3, 4, 5]) # pipe-less form
g: (x + 1 | x <- xs, x > 15) # with filter
g: (x + 1 for x in xs if x > 15) # Python with filter
Driver builtins:
| Builtin | Effect |
|---|---|
first(gen, n) |
Pull the first n elements (the
memorable spelling — the same verb works on arrays,
strings, sets, and infinite sets). |
first(gen) |
Pull one element (the first). |
force(gen) |
Pull all remaining elements into an Array. (Same verb
as force on a lazy thunk — see above.) |
gen_take(n, gen) |
Pull first n elements — the explicit, lower-level alias
of first(gen, n). |
gen_drop(n, gen) |
Advance past n elements (mutates gen in place, returns
it). |
gen_next(gen) |
Pull one element. Returns Ω when exhausted. |
Prefer first(gen, n) — it's the same
"first n" verb you already use for arrays and infinite sets. The
gen_* prefix exists only because take is a
reserved keyword (natural-language selective import) and
drop is the Stack op.
g: (x | x <- [1, 2, 3, 4, 5])
first(g, 3) # [1, 2, 3] (same as gen_take(3, g))
force(g) # [4, 5] — remainder after partial take
Phase 2b — full multi-clause surface. Lazy comprehensions now accept the same clause vocabulary as eager list/set/dict comprehensions:
# Multi-generator (Cartesian, streamed — inner is re-eval'd each outer step)
pairs: (x * y | x <- [1, 2, 3], y <- [10, 20])
py_pairs: (x + y for x in [1, 2] for y in [100, 200])
# Walrus binding (`:` — the canonical form)
g: (s | x <- xs, s: x * x)
# Tuple destructure in first OR subsequent generators
dest: (n + 1 | (n, _label) <- tagged)
lab: (s + ":" + name | (s, name) <- pairs)
# Concept-extent source — bare concept name OR is form
gdps: (c.gdp | c <- Country)
gdps2: (c.gdp | c is Country)
# Relational predicate source — a fact-store query as the iterable
kids: (c | c <- parent("John", c)) # single generator
gkids: ((p, g) | p <- parent("John", p), g <- parent(p, g)) # chained
Streaming semantics. Multi-generator lazy form
materializes inner sources once per outer combination (so the inner can
reference outer-bound names — same as the eager path). The outermost
source can still stream from a true Generator or
InfiniteSet. Inner positions cannot use
InfiniteSet — they'd re-materialize infinitely on each
outer advance. gen_take(N, ...) bounds total pulls,
naturally cutting the outer loop short when N is reached.
Relational sources. When a generator's iterable is a
relational predicate call (x <- parent(x, _)), the lazy
paths fall back to the relational machinery — eager evaluation of such a
call fails, so this is detected by probe. The relation extent is
bounded, so it is resolved once per entry; downstream pulls stay lazy.
Works in single-generator, chained, inner, and filtered positions. The
same bridge serves list and dict comprehensions (July
2026), and a bare relation name is a valid source in
every flavor ([x | x <- node],
{x: y | (x, y) <- parent}). Because a relation extent is
a set with no intrinsic order, materialization into an ordered
collection is deterministic by construction: elements
come out sorted (lexicographically by display form), so
[Y | Y <- parent("John", Y)] and
force((Y | Y <- parent("John", Y))) return the same
array on every run — no sort(...) wrapper needed.
Limitations. OrderBy and
Having clauses are accepted by the parser inside lazy form
but silently discarded — both are intrinsically eager (sorting requires
full materialization). For an ordered lazy stream,
force(gen) then sort, or sort eagerly first.
Tests: tests/axioma/comprehensions/test_lazy_generators.ax (Phase 2a — single gen), tests/axioma/comprehensions/test_lazy_generators_phase2b.ax (Phase 2b — multi-clause), tests/axioma/comprehensions/test_lazy_relational_source.ax (relational sources)
ORDER BY clause
List comprehensions accept an orderby clause that sorts
the result. Sets and dicts ignore orderby (they're
unordered by nature). Direction defaults to asc; add
desc to reverse:
[x | x <- xs, orderby x] # asc by default
[x | x <- xs, orderby x desc] # descending
[p.name | p <- people, orderby p.age] # sort by computed key
[x for x in xs orderby x desc] # pipe-less form works too
The sort key is evaluated in the iteration environment (where the loop variable refers to the source element), so referring to fields of the source element works as expected.
Aggregation:
group_by, items, keys,
values
Four builtins fill the SQL-style aggregation gap.
group_by(fn, coll) partitions a collection into a hash;
items(hash) exposes it as (key, value) pairs;
keys/values return the parts individually. All
three enumerators walk the hash in sorted key order —
the same canonical order println(h) shows — so repeated
calls (and keys/values/items
against each other) always agree.
orders: [
{country: "US", amount: 100},
{country: "UK", amount: 50},
{country: "US", amount: 200},
]
by_country: group_by(func(o) [o.country], orders)
# {"US": [{...}, {...}], "UK": [{...}]}
# Iterate the hash with tuple destructure + ORDER BY:
pairs: items(by_country)
counts: [(p[1], len(p[2])) | p <- pairs]
sorted: [pair | pair <- counts, orderby pair[2] desc]
# [("US", 2), ("UK", 1)]
Tuple destructuring in
<-
In any generator (first or subsequent), the iteration target can be a
tuple pattern instead of a single variable — and since July 2026 the
parentheses are optional, matching the loop form
(for i, e in …):
pairs: [(1, "a"), (2, "b"), (3, "c")]
# First-generator tuple destructure (canonical form)
[n + len(s) | (n, s) <- pairs] # list comp
{n: s | (n, s) <- pairs} # dict comp
{n * 2 | (n, s) <- pairs} # set comp
# BARE destructure — same meaning, no parens (all flavors, any clause)
[n + len(s) | n, s <- pairs]
xs: ["p", "q", "r"]
{e | i, e <- xs.indexed, i > 1} # → {"q", "r"} — the .indexed idiom
[e for i, e in enumerate(xs) if i > 1] # pipe-less Python spelling too
# Idiomatic with items() over a hash
by_country: group_by(func(o) [o.country], orders)
{k: len(g) | (k, g) <- items(by_country)}
Source elements must be Tuples or Arrays of the matching arity.
Mismatched arity is a hard error. The bare form commits only when a run
of two-plus names closes with <-/in, so a
bare-identifier filter
({x | x <- xs, flag, x > 1}) keeps its filter
meaning.
A call as the source works like any other iterable —
{(i, e) | (i, e) <- enumerate(xs)} binds by the pattern.
(Before July 2026 this specific shape — tuple head + tuple pattern + a
call source — was silently misread as a relation query over a
relation named enumerate and returned {};
genuine relation sources like
{(X, Y) | (X, Y) <- edge(X, Y)} still take the
relational path.) Tests:
tests/axioma/comprehensions/test_bare_destructure.ax.
Hash destructure in
<-
First-generator targets can also be a hash pattern
that binds named fields directly. Two surface forms — implicit binding
(var name = key name) and explicit rename (key: var):
people: [
{name: "Alice", age: 30, dept: "Eng"},
{name: "Bob", age: 25, dept: "Sales"},
{name: "Carol", age: 35, dept: "Eng"}
]
# Implicit binding
[name | {name, age} <- people, age >= 30]
# → ["Alice", "Carol"]
# Explicit rename
[(n, a) | {name: n, age: a} <- people]
# → [("Alice", 30), ("Bob", 25), ("Carol", 35)]
# Mixed implicit + rename in same pattern
[name | {name, age: a, dept} <- people, a > 27 and dept == "Eng"]
# pipe-less form
[name for {name, age} in people if age > 27]
{n for {name: n} in people}
{name: age for {name, age} in people}
# Lazy form
g: (name | {name, age} <- people, age > 25)
force(g) # → ["Alice", "Carol"]
# Combined with orderby + limit
[name | {name, age} <- people, orderby age desc, limit 2]
# → ["Carol", "Alice"]
Skip-on-missing-key. When a pattern key is absent from a source hash, that row is silently dropped — destructure acts as filter+extract in one step:
mixed: [{name: "Alice", age: 30}, {name: "Bob"}, {name: "Carol", age: 35}]
[n | {name: n, age: a} <- mixed]
# → ["Alice", "Carol"] (Bob has no `age` key — dropped)
Wrong-type elements (anything not a
Dictionary hash) surface a runtime error.
Scope. Hash destructure is currently supported only
in the first generator position. Subsequent generators
(after a , in pipe form) use the single-var or
tuple-pattern forms — {...} in that position would be
ambiguous with set/hash filter expressions.
Multi-column ORDER BY
orderby accepts a comma-separated list of sort keys,
each with its own direction. Sort is lexicographic across columns:
[p.name | p <- people, orderby p.dept, p.age desc]
# Sorts by dept ascending; within each dept, by age descending.
HAVING clause — terminal filter
having EXPR is a terminal filter that runs in the
iteration environment (so it can reference the loop variable). Distinct
from , EXPR filters only in position and intent — HAVING
reads as "row filter after aggregate computation" by convention:
[o.amount | o <- orders, having o.amount > 70]
# Combined with orderby (having runs at iteration time, not after sort):
[o.amount | o <- orders, orderby o.amount desc, having o.amount > 70]
LIMIT / OFFSET clauses
SQL-style row caps. limit N keeps at most N output rows;
offset N skips the first N post-filter rows. Both apply to
all four comprehension flavors (list, set, dict, lazy) and to both
surface forms (pipe and pipe-less). They are terminal-ish: once seen,
only the other of the pair may follow.
# Basic
[x | x <- xs, limit 3] # first 3 rows
[x | x <- xs, offset 5] # skip first 5
[x | x <- xs, offset 5, limit 3] # paging: skip 5, then take 3
# Order is free — these are equivalent
[x | x <- xs, limit 3, offset 5]
# Combined with orderby (SQL convention — limit/offset run AFTER sort)
[x | x <- unsorted, orderby x desc, limit 5]
# Combined with orderby + having
[t.amount | t <- txns, orderby t.amount desc, having t.amount > 50, limit 2]
# pipe-less form (no commas between clauses)
[x * 3 for x in xs if x > 2 limit 4]
[x for x in xs offset 6 limit 2]
[x for x in unsorted orderby x desc limit 2]
# Lazy generator — limit caps total Next() pulls, offset skips post-filter rows
g: (x * 2 | x <- big_source, x > 100, offset 10, limit 5)
gen_take(3, g) # yields up to 3 elements (lazy stops emitting at limit anyway)
Soft keywords. limit and
offset are NOT reserved at the lexer level — they remain
ordinary identifiers usable as variable names, property names, and DSL
slot names (bindings.limit, {limit:number}).
The parser recognizes them as clause keywords only when they appear as
the first token of a comprehension clause.
Caveat. Inside a comprehension clause list, you
cannot use limit or offset as the LHS of a
filter expression. [x | x <- xs, limit > 5] parses
limit as the LIMIT clause keyword and chokes on
> 5. Parenthesize to disambiguate:
[x | x <- xs, (limit > 5)].
Validation. Both clauses evaluate to non-negative
integers. Negative or non-integer values surface as runtime errors.
limit 0 yields the empty result; offset N with
N greater than the source length yields the empty result.
Sets/dicts. Limit/offset apply and slice the materialized result in its canonical order (July 2026 — previously Go-map order, which made the slice a different random sample per run): a set comprehension's prefix is its canonically-least members, and a dict comprehension slices its sorted key walk. Deterministic paging works in every flavor.
Tests: tests/axioma/comprehensions/test_tier15_first_gen_multi_having.ax
Tests: tests/axioma/comprehensions/test_tier1_orderby_groupby.ax
Range generators
Any range expression is a valid generator source, and iteration
follows the range's own order (see Ranges in §4 —
direction, ..<, by steps):
[x * x | x <- 1..10] # inclusive 1..10 → [1, 4, 9, 16, …, 100]
[x | x <- 1..<10, x % 2 == 0] # half-open → [2, 4, 6, 8]
[x | x <- 10..1 by 3] # descending, stepped → [10, 7, 4, 1]
{x | x <- 1..100, prime?(x)} # set of primes ≤ 100
first((x * x | x <- 1..), 5) # OPEN-ENDED range in a LAZY comprehension —
# streams on demand → [1, 4, 9, 16, 25]
[x | x <- 1..] # eager comprehension over 1.. → catchable
# Error (it would never finish)
An open range (1..) is the natural unbounded integer
source: use it in a lazy (parenthesized) comprehension
and pull with first(gen, k) / gen_take. For
non-arithmetic infinite sources ("primes",
"fibonacci", …) infinite_set(...) remains the
tool.
Intensional-class generators (Russell iota)
Define an intensional class once with
the <Concept> where <pred> and use it as a
generator source. Membership is computed by combining the base concept's
extent with the predicate restrictor:
adults: the Person where age >= 18
all_adult_names: [p.name | p <- adults]
This is the Russell-faithful form of "the things that satisfy P." Useful when the same restrictor predicate is consulted from several comprehensions — define it once, reuse by name.
The same question, many ways
Comprehensions, quantifiers, higher-order builtins, and the membership operator all answer overlapping questions. The cleanest illustration is the existential question — "is there an X in S satisfying P?" — which has 18 working surface forms in Axioma:
persons: {mike, alice}
# Quantifier family — three spellings, same AST
exists x in persons | x.name == "Mike" # keyword
`exists x in persons | x.name == "Mike" # backtick digraph (ASCII)
∃ x in persons | x.name == "Mike" # Unicode glyph
# Set-comprehension family — "the matching set is non-empty"
len({x | x <- persons, x.name == "Mike"}) > 0 # Axioma pipe
{x | x <- persons, x.name == "Mike"} != {} # Axioma pipe
not ({x | x <- persons, x.name == "Mike"} == {}) # De Morgan
len({p for p in persons if p.name == "Mike"}) > 0 # Python
{p for p in persons if p.name == "Mike"} != {} # Python
# List-comprehension family — "indicator sum > 0"
sum([1 | p <- persons, p.name == "Mike"]) > 0 # Axioma pipe
sum([1 for p in persons if p.name == "Mike"]) > 0 # Python
len([1 for p in persons if p.name == "Mike"]) > 0 # Python
# Higher-order — reductions over the collection
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, reuse it
{x | x <- persons, n: x.name, n == "Mike"} != {}
# Lazy + force — produce a generator, then materialize
g: (x | x <- persons, x.name == "Mike")
len(force(g)) > 0
# Lazy + gen_take(1) — SHORT-CIRCUITED existence (asymptotically cheaper)
len(gen_take(1, (x | x <- persons, x.name == "Mike"))) > 0
# 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 so many. Each form aligns with a different
intellectual tradition: math ({x | …}), Python
(for x in xs if …), SQL
(orderby/having/limit/offset), Haskell (walrus), Prolog
(<- over relational predicates), F-logic (concept
extents, @tag filters), and indicator-sum probability. The
right form is the one your reader will find most natural.
Working showcase: tests/axioma/showcase/method.ax.
Short-circuit note. Quantifier forms
(exists / ∃) and
gen_take(1, lazy) stop at the first match. Eager
comprehension forms scan the full collection. For large collections the
difference matters — pick the short-circuiting form when the predicate
is cheap and the collection is large.
8. First-Order Logic
Quantifiers
a: {1, 2, 3, 4, 5}
forall x in a: x > 0 # true
forall x in a: x > 3 # false
exists x in a: x > 3 # true
exists x in a: x > 10 # false
Bounded Range & Counting Quantifiers
Axioma supports advanced bounded range quantifiers (which operate over integer intervals without requiring an explicit set domain) and comparison-based counting quantifiers (which assert that a specific number of elements satisfy a predicate):
Bounded Range Quantifiers
- Double-sided range: evaluates the predicate over
the range
[lower..upper](for<=) or[lower..<upper](for<).forall 1 <= x <= 10: x > 0 # true exists 1 < x < 5: x == 3 # true - Single-sided range: defaults to a lower bound of
0and evaluates up to the upper bound.forall x < 5: x >= 0 # true (evaluates x = 0, 1, 2, 3, 4) exists x <= 0: x == 0 # true (evaluates x = 0)
Arbitrary Counting Quantifiers
Checks whether exactly, at least, or at most a specified number of
elements in a set satisfy a predicate. Uses standard comparison
operators (==, >=, <=,
>, <) directly following the
exists keyword:
domain: {1, 2, 3, 4, 5}
exists >= 3 x in domain: x > 2 # true (witnesses: 3, 4, 5)
exists <= 1 x in domain: x > 4 # true (witness: 5)
exists == 2 x in domain: x % 2 == 0 # true (witnesses: 2, 4)
exists > 1 x in domain: x < 3 # true (witnesses: 1, 2)
With predicates
nums: {1, 2, 3, 4, 5, 6}
forall x in nums: positive(x)
exists x in nums: even(x)
forall x in nums: x < 10 and x > 0
Combined
a: {2, 4, 6}
b: {1, 2, 3, 4, 5, 6}
forall x in a: even(x) and (x in b)
exists x in b: odd(x) and (x > 3)
Symbolic-mode quantifiers — textbook syntax
The bounded forms above (forall x in a: ...) iterate a
domain and return a Boolean. Axioma also accepts bare
textbook-style quantifiers with no
in <domain> clause — these produce a
Formula value, a symbolic carrier you can pass to the
resolution/CNF/satisfiability machinery in fol/,
sol/, and logic/.
# Bare textbook form — no marker between variable and body
f1: ∀x P(x) # → ∀ x. P(x) (type: formula)
f2: ∃x F(x) # → ∃ x. F(x)
# Dot form
f3: ∀x. P(x) # identical to f1
f4: ∃x. F(x)
# ASCII keyword equivalents
forall x P(x)
exists x F(x)
`forall x P(x) # backtick digraph
`exists x F(x)
# Complex bodies — quantifier binds widely
∀x P(x) → Q(x) # → ∀ x. (P(x) → Q(x))
∃x P(x) ∧ Q(x) # → ∃ x. (P(x) ∧ Q(x))
∀x ¬P(x) ∨ Q(x) # → ∀ x. ((¬P(x)) ∨ Q(x))
# Nested quantifiers
∀x ∃y P(x, y)
∃x ∀y P(x, y) → R(x, y)
Mode summary:
| Quantifier form | Mode | Returns |
|---|---|---|
∀x F(x) / ∃x F(x) |
symbolic | Formula |
∀x. F(x) / ∃x. F(x) |
symbolic | Formula |
∀x in S | F(x) |
bounded | Boolean |
∃!x in S | F(x) |
bounded uniqueness | Boolean |
∃!x F(x) |
symbolic uniqueness | Formula |
Tier 1 limitation: the connectives
¬ ∧ ∨ → ↔︎ on Formula operands still dispatch to Boolean
semantics — building compound formulas like
¬∀x P(x) ↔︎ ∃x ¬P(x) at the symbolic level requires Tier 2
Formula-aware connectives. For now, keep connectives inside the
quantifier body.
Other Tier 1 textbook additions
# Biconditional — both Unicode forms lex to IFF
true ↔ false # false (U+2194, modern textbook)
true ⟺ false # false (U+27FA, was lexed but never wired)
# Truth constants
⊥ # false (falsum, U+22A5)
⊤ # true (verum, U+22A4)
# Proper subset distinct from subset
{1,2} ⊊ {1,2,3} # true (proper subset)
{1,2,3} ⊊ {1,2,3} # false (equal, not proper)
{1,2} ⊆ {1,2} # true (regular subset allows equality)
# Set difference — Enderton/Halmos notation
{1,2,3,4,5} \ {2,4} # {1, 3, 5}
# Uniqueness quantifier
∃!x in {1,2,3} | x == 2 # true (exactly one)
Formula type + predicate
f: ∀x P(x)
@f # "formula"
formula?(f) # true
boolean?(f) # false
See tests/axioma/logic/test_textbook_parity_tier1.ax for full coverage.
9. Multi-Valued Logics
Axioma has five first-class logic kinds, each with
its own truth-value type. Operators (and, or,
not, implies, iff,
xor) automatically dispatch based on operand types. The
dispatch priority is Belnap > Intuit3 > Łukasiewicz >
Kleene > Boolean.
Truth values are first-class. The forms the interpreter prints are themselves source — every non-Boolean truth value has a lexable glyph literal, an ASCII digraph, and a dotted member on its seeded Concept, all byte-identical to the constructor call:
| Logic | Literals | Digraphs | Members |
|---|---|---|---|
| Belnap B4 | ⊤ᵇ ⊥ᵇ ⊤⊥ᵇ ?ᵇ |
`beltrue `belfalse `belboth `belneither (+
`glut / `gap) |
Belnap.true .false .both
.neither .glut .gap |
| Kleene K3 | ⊤ᵏ ⊥ᵏ ?ᵏ |
`kltrue `klfalse `klunknown |
Kleene.unknown |
| Gödel G3 | ⊤ⁱ ⊥ⁱ ?ⁱ |
`gtrue `gfalse `gunknown |
Intuit3.unknown |
| Łukasiewicz Ł3 | ⊤ł ⊥ł ½ł |
`ltrue `lfalse `lhalf |
Lukasiewicz.half |
Bare ⊤ / ⊥ stay Boolean true /
false. Each seeded Concept also carries
values — the logic's canonical domain
(Belnap.values → [⊤ᵇ, ⊥ᵇ, ⊤⊥ᵇ, ?ᵇ]) — and the
literals work as match patterns:
w: ⊤⊥ᵇ # ≡ belnap("both") — byte-identical value
m: match w with | ⊤⊥ᵇ => "glut" | ?ᵇ => "gap" | _ => "classical" # → "glut"
forall p in Belnap.values | designated(p or not p) # → false — the gap escapes LEM
Three semantic rules, uniform across the family:
- Truthiness is designation.
if/whileon a typed truth value branch by the logic's designated set — B4{⊤ᵇ, ⊤⊥ᵇ}(a glut is true-enough to act on), K3/G3{true}, Ł3{1.0}. Soif ⊥ᵇ …andif ?ᵏ …take the else-branch, andif xalways agrees withdesignated(x). (Untypedomkeeps its SETL truthiness — see the dual-null note in §4.) ==/!=are metalanguage equality — a plain Boolean, coercing the untyped spellings (belnap("true") == "true",?ᵏ == om,lukasiewicz(1.0) == 1.0); values of different logics are never equal. The object-language biconditional isiff(?ⁱ iff ?ⁱ→⊤ⁱ). Membership composes too:⊤⊥ᵇ in Belnap.values→true.- Operands are validated — no silent coercion. Logic
operators accept a typed value's own kind plus Boolean /
om/ Kleene (the canonical embeddings; Ł3 also takes raw numbers in[0, 1]) and error loudly on anything else:belnap("true") and "banana"is an error, not a quiet gap, and cross-logic mixes without a canonical embedding (⊤⊥ᵇ and ½ł,⊤ⁱ and ⊤⊥ᵇ) are rejected with a wrap hint.
Boolean
Classical two-valued. Default for
true/false.
true and false # false
true implies false # false
not true # false
Kleene K3 (three-valued)
true / false / unknown. Two
spellings of the same tables: the untyped SETL om /
Ω (the historical K3 unknown), and the typed literals
⊤ᵏ ⊥ᵏ ?ᵏ (≡ kleene("true") /
kleene("false") / kleene("unknown")).
om and true # Ω (unknown — untyped, stays om-flavored)
om or true # true
not om # Ω
?ᵏ and ⊤ᵏ # ?ᵏ (typed — results stay Kleene)
?ᵏ == om # true (canonical equality); `if ?ᵏ` AND `if om` are both
# FALSY now (designation — om folds onto it in 2.5)
Łukasiewicz L3 (real-valued)
Continuous truth values in [0, 1]; the three canonical
points have literals ⊤ł (1.0), ½ł (0.5),
⊥ł (0.0).
half: lukasiewicz(0.5) # ≡ ½ł
qrtr: lukasiewicz(0.25)
half and qrtr # min(0.5, 0.25) = 0.25
half implies qrtr # min(1, 1 - 0.5 + 0.25) = 0.75
½ł and 0.8 # ½ł — raw numbers in [0,1] embed (the fuzzy idiom)
½ł == 0.5 # true; only ⊤ł (1.0) is designated/truthy
Belnap B4 (paraconsistent four-valued)
T, F, Both,
Neither — supports contradictory information.
p: ⊤⊥ᵇ # the glut literal — ≡ belnap("both")
q: belnap("true") # constructor form, ≡ ⊤ᵇ
p and q # ⊤⊥ᵇ — contamination propagates
Belnap.gap # ?ᵇ (Priest's terms: .glut / .gap alias .both / .neither)
truth("parent", "John", "Mary") # Query stored Belnap value (relation named by string)
The four values ⊤ᵇ, ⊥ᵇ, ⊤⊥ᵇ,
?ᵇ are lexable literals (not just display forms);
designated(x) is true for ⊤ᵇ and the glut
⊤⊥ᵇ, and if/while branch
accordingly.
The two orders of B4. B4 is a bilattice:
the bare and/or/not above are the
truth order (how true?), while evidence
combination lives on the knowledge order (how
much information? — neither <
true,false < both), surfaced
as b4_join (⊕ — accept everything every source says) and
b4_meet (⊗ — keep only what all sources agree on):
w1: belnap("true") # witness 1: guilty
w2: belnap("false") # witness 2: innocent
b4_join(w1, w2) # → ⊤⊥ᵇ conflict surfaces as a GLUT (⊕)
b4_meet(w1, w2) # → ?ᵇ no consensus (⊗)
designated(b4_join(w1, w2)) # → true — a glut is true-enough to act on
w1 and (not w2) # → ⊤ᵇ truth-order `and` can NEVER build a glut
b4_join(w1, w2, om) # variadic; or fold a collection: b4_join([w1, w2])
w1 ⊕ w2 # infix glyph forms (⊗ binds tighter than ⊕,
w1 `oplus w2 # mirroring ∩/∪); ASCII via `oplus / `otimes
Operands must be Belnap/Boolean/om/Kleene (strings error
— wrap with belnap(...)). The same strictness covers the
truth-order operators
(and/or/not/implies/iff/xor)
since July 2026 — see rule 3 above.
Evidence can also accumulate on a stored fact:
set_truth_combine(rel, args..., value) ⊕-merges the
incoming value onto the fact's stored B4 truth instead of overwriting it
(contrast set_truth, which is last-write-wins):
relation rain(city)
axiom rain("seattle") # truth defaults to ⊤ᵇ
set_truth_combine("rain", "seattle", "false") # ⊤ᵇ ⊕ ⊥ᵇ → stores + returns ⊤⊥ᵇ
truth("rain", "seattle") # → ⊤⊥ᵇ — the conflict is KEPT
The fact must already be stored, and the strict-bivalence mode
(set_truth_logic("Boolean")) rejects a glut-producing
report (non-fatal false, stored truth unchanged).
Gödel G3 (intuitionistic three-valued)
true / false / unknown
(literals ⊤ⁱ ⊥ⁱ ?ⁱ) — but with
intuitionistic semantics:
p: ?ⁱ # ≡ intuit3("unknown")
not p # ⊥ⁱ (G3 collapses U to F; K3 keeps U)
p implies p # ⊤ⁱ (reflexive — always)
g3_lem(?ⁱ) # ?ⁱ — LEM is NOT a tautology
g3_dne(?ⁱ) # ?ⁱ — double-negation elimination FAILS
designated(g3_lem(?ⁱ)) # false — so LEM fails as an inference too
p == ⊤ⁱ # false — plain Boolean METALANGUAGE equality
p iff p # ⊤ⁱ — the object-language biconditional
The g3_* helpers (g3_and /
g3_or / g3_not / g3_implies /
g3_lem / g3_dne) return typed Intuit3
values, so their results flow straight back into
and / not / designated.
(== on G3 values used to return the biconditional itself —
a truthy ?ⁱ for unknown == true; it is now
Boolean equality, and the biconditional lives at iff.)
Constructors, types, and equality
The literals cover the canonical values; the
constructors are the dynamic/coercion form —
belnap() (B4), lukasiewicz() (Ł3, any value in
[0,1]), intuit3() (G3), and
kleene() (K3) — for converting a runtime
String/Boolean/om into a truth value. type(x)
and the :: x type-of sigil return the proper TitleCase
name, and each value is is-checkable against its seeded
primitive Concept:
ku: kleene("unknown") # also kleene(om), kleene("u"), kleene("?") — or just ?ᵏ
type(ku) # → Kleene (@ku is the same)
ku is Kleene # → true
ku == om # → true (coerces to its canonical Boolean/Om form)
kt: kleene("true")
kt and ku # → ?ᵏ (results stay Kleene)
not ku # → ?ᵏ
ku implies ku # → ?ᵏ (K3 — contrast G3's reflexive → ⊤ⁱ)
type(⊤⊥ᵇ) # → "Belnap"
type(½ł) # → "Lukasiewicz"
type(?ⁱ) # → "Intuit3"
# Equality coerces each value's own untyped spellings — and ONLY those:
belnap("true") == "true" # → true
⊤⊥ᵇ == "both" # → true
lukasiewicz(1.0) == 1.0 # → true (== 2 is false — no clamping)
⊤ᵇ == ⊤ᵏ # → false (cross-LOGIC values are never ==)
Note: Kleene's
unknownis still represented byom(Ω) at the operator level — the existingom and truetables are unchanged.kleene(...)/?ᵏis the typed form: it normalizes toomfor evaluation, runs the same K3 tables, then re-wraps logic results back into aKleeneso they stay introspectable (mirroring howbelnap/lukasiewicz/intuit3operators return their own type). No asymmetry any more:?ᵏ == omis true, and bothif ?ᵏandif omare falsy (designation —omfolds onto the designation path in step 2.5, matching the typed unknown it equals).
Truth tables
Kleene show truth_table for and
Lukasiewicz show truth_table for implies
Belnap show truth_table for or
Intuit3 show truth_table for implies
tableform(f, "belnap") prints the full grid of any
function over an MVL domain ("kleene" /
"lukasiewicz" / "intuit3" /
"boolean" too) — and since the cells are lexable literals,
a printed cell pastes back into the REPL as the value it shows.
10. Modal, Temporal, Epistemic & Deontic Logic
Modal operators
necessarily(p) # □p — p holds in all accessible worlds
possibly(p) # ◇p — p holds in some accessible world
Temporal logic
always(p) # G p — p holds at all future times
eventually(p) # F p — p holds at some future time
next(p) # X p — p holds at the next time step
until(p, q) # p U q — p until q
\newpage
Epistemic logic
alice: agent("alice")
b: believes(alice, "answer", 42) # record a belief; do not assert its content
println(beliefs_of(alice)) # ["answer(42)"]
assert answer(42)
k: knows(alice, "answer", 42) # requires an existing supporting fact
Stored believes(agent, relation, args...) and
knows(agent, relation, args...) use an Agent entity. Their
contents are canonical proposition strings: nested quotes, backslashes
and quoted agent names retain their identity when listed or queried.
Attributing another person's belief does not endorse its content. This
explicit storage is distinct from natural-language attitude expressions
and from evaluation in an activated possible-world model.
There is currently no public common_knowledge
builtin. The internal finite-model checker tests the
proposition at every world reachable through any finite sequence of the
selected agents' accessibility links, including the actual world.
Everyone knowing a proposition at the actual world is a weaker
condition. Neither stored reports nor nested proposition strings supply
a general calculus of common knowledge. See epistemic
model boundaries.
Deontic logic
obligatory(action)
permitted(action)
forbidden(action)
The examples above are the public modal/temporal/epistemic/deontic surface in this manual; implementation notes live with the corresponding evaluator code.
11. Fuzzy Logic & Higher-Order Logic (SOL)
Fuzzy logic
Continuous truth in [0, 1] with fuzzy set
membership:
tall: fuzzy_set("tall", lambda h => sigmoid(h - 180))
membership(tall, 175) # 0.38
membership(tall, 190) # 0.88
Second-Order Logic (SOL)
Quantification over predicates and functions:
forall_pred P: forall x: P(x) implies P(x) # Trivially true
exists_pred P: forall x in domain: P(x) # ∃P. ∀x. P(x)
Treat the SOL examples here as the public surface; the implementation is experimental and may change as the quantified logic engines mature.
12. Lambda Calculus & Higher-Order Functions
Function definitions
inc: lambda x => x + 1 # lambda arrow (multi-arg needs parens)
add: lambda (x, y) => x + y
dbl: (x) => x * 2 # parenthesized arrow-lambda (no `lambda` keyword)
sum: (a, b) => a + b # same AST as `lambda (a, b) => …`
multiply: func(a, b, c) [a * b * c] # canonical REBOL bind
fn half(x) [x / 2] # `fn`, `fun`, and `function` alias `func`
fun thrice(x) [x * 3]
function scale(x) [x * 10]
func double(x) [x * 2] # named declaration (optional keyword form)
double(x) = 2 * x # EQUATION form — same function, no keyword
fun circleArea(r) = pi * r * r # optional designator on the equation form
function area(r) = pi * r * r # long designator — same lowering
Relational generic
functions — func[T]
A constructor such as data Pair[T] = MkPair(T, T)
requires repeated direct fields to have the same runtime type. A generic
function extends this rule to its arguments and result. Each call
chooses its own type; a bound restricts the choice without promoting or
converting values.
keep_first: func[T](left :: T, right :: T) :: T [left]
println(keep_first(10, 20)) # 10
println(keep_first("a", "b")) # a
println(error?(try(keep_first(10, 20.0)))) # true
numeric_id: func[T of Number](value :: T) :: T [value]
println(numeric_id(3)) # 3
println(error?(try(numeric_id("3")))) # true
T is local to the declaration, even if an outer binding
has the same name. Different names, such as [A, B], make
independent choices. A bound uses the constructor convention
T of Number; T of Float requires an actual
Float. The return annotation uses the same choice as the arguments. A
function promising :: T cannot accept an Integer and return
a String or Float. Early returns and labeled calls enforce the same
rule.
Declaration forms. The aliases func,
fn, fun, and function all support
the binder. Named declarations and equations put it after the name;
anonymous functions put it after the function keyword. Bracket bodies
and function … end bodies have the same contract.
func echo_value[T](value :: T) :: T [value]
echo_equation[T](value :: T) :: T = value
function echo_long[T](value :: T) :: T
value
end
# Detached signatures cover every existing lambda spelling.
echo_arrow[T] :: T -> T
echo_arrow: value => value
echo_lambda[T] :: T -> T
echo_lambda: lambda value => value
println([echo_value(1), echo_equation(2), echo_long(3),
echo_arrow(4), echo_lambda(5)]) # [1, 2, 3, 4, 5]
Write the parameter list once when using a detached signature. It
transfers to the following definition, including parenthesized,
bare-arrow, backslash, and Unicode lambda forms. An inline
lambda[T] form is not introduced.
Partial application and defaults. A partial application retains the type choices made by its supplied arguments. Each subsequent call gets an independent copy of those choices, so an as-yet-unbound parameter remains polymorphic. Defaults are checked as arguments when evaluated; default expressions may refer to earlier parameters.
keep_second: func[T](left :: T, right :: T = left) :: T [right]
println(keep_second(8)) # 8
same_second: func[T](left :: T, right :: T) :: T [right]
let after_integer = same_second(1)
println(after_integer(2)) # 2
println(error?(try(after_integer("x")))) # true
Clauses and guards. Put a detached generic signature
above the first clause or pipe group. Its argument relationships are
checked before pattern/guard selection. A type error does not try
another clause. The chosen result must satisfy the return contract. For
a generic group, an unmatched call's none is also checked:
an Integer choice for T cannot silently return
none. Existing non-generic clause fallback behavior is
unchanged.
choose_value[T] :: Boolean -> T -> T -> T
choose_value(flag, left, right)
| flag = left
| otherwise = right
println(choose_value(false, 10, 20)) # 20
Explicit structural relationships. A direct
T still compares outer runtime type identity: two Arrays
can have different contents. Write Array of T or
List of T to require one element type, or
(A, B, C) for a tuple's individual component types. These
structures can nest and appear in input and result annotations.
third_same[T] :: (T, T, T) -> T
third_same(t) = t[3]
third_any[A, B, C] :: (A, B, C) -> C
third_any(t) = t[3]
println(third_same((1, 2, 3))) # 3
println(error?(try(third_same((1, 2, "3"))))) # true
println(third_any((1, true, "3"))) # 3 (String)
numeric_first[T of Number] :: Array of T -> T
numeric_first(xs) = xs[1]
println(numeric_first([4, 5])) # 4
println(error?(try(numeric_first([4, 5.0])))) # true
Different variables permit different types without requiring them. A
bound applies to each occurrence, including nested elements, without
converting a chosen T. Array of Array of T constrains
innermost elements; Array of T with T chosen as Array
checks only those elements' outer Array identity. Empty Arrays and Lists
supply no element evidence. An empty collection result is valid, but a
scalar result T is refused when no input established T's type. Partial
calls retain their evidence; later calls recheck captured Arrays.
Generic call contracts check inputs again after the body and do not
attach call-local T variables as permanent constraints on an otherwise
untyped Array. Existing concrete Array annotations keep their persistent
write checks. A detected mutation error does not roll back other effects
of the body.
Scope and execution boundaries. Every declared type
parameter must occur in an input annotation, directly or inside a
supported Array/List/product structure. T -> T as a
callback annotation, unions involving T, other generic containers and
specialized ADT annotations remain unsupported. Generic annotations on
lazy parameters are refused. Fixed generic parameters may coexist with
ordinary refinements, defaults, destructuring slots, and an untyped
variadic rest parameter where the underlying function form supports
them. Detached signatures retain their existing restrictions on
variadics and destructuring. Generic clause groups currently require one
fixed arity and one group signature.
The evaluator enforces the contract, including calls through aliases
and higher-order builtins. --typecheck and the editor
report known argument conflicts, unknown bounds, and definite return
violations; dynamic cases remain runtime checks. The bounded HM lens
additionally checks unbounded generic bodies under arbitrary rigid type
parameters and exposes fresh instantiations per call; recursion remains
monomorphic. It diagnoses bodies that cannot implement their signature,
even before a call. Bounds, defaults and refinements remain runtime
features outside that proof. --infer reports skips for
unsupported bodies; #language axioma/hm rejects skips. The
island's structural ML type rules are stricter than host outer identity
for bare T applied to containers. This does not make the host checker a
whole-language polymorphic inference engine. Explicit generics take
precedence over the observational first-result cache of
@infer. Generic activations use ordinary recursion so
return checks are preserved; tail-call optimization for them is
deferred. The VM explicitly refuses generic functions. External
statement-stepping APIs also refuse generic calls until they can retain
call-specific type constraints.
Parenthesized
and bare arrow-lambdas — (params) => body,
x => body
A parenthesized parameter list followed by a body arrow is a lambda,
with no leading lambda / λ / \
keyword. It desugars to the same function value as
lambda (params) => body — useful for dense callbacks and
for readers coming from JavaScript / TypeScript.
Body arrows for these keyword-less forms are fat =>
and thin ->. The Unicode → (and the word
implies, ⟹, -->) is
material implication after a bare identifier or a
parenthesized operand — p → q, (p) → q,
not p implies q — so it does not open a lambda here; after
a binder keyword it still does (lambda x → x + 1,
\x → x + 1, λx → x + 1), because the keyword
removes the ambiguity:
map((x) => x * 2, [1, 2, 3]) # → [2, 4, 6]
map((x) -> x * 2, [1, 2, 3]) # same
map(x => x * 2, [1, 2, 3]) # bare single-param (second wave)
map(x -> x * 2, [1, 2, 3]) # bare + thin arrow
typed: (x :: Integer) => x + 1
ret: (x :: Integer) :: Float => x * x # return type before the body arrow
hof: (f :: Integer -> Integer) => f(3) # HOF param type may use thin arrows
idF: (f) :: (Integer -> Integer) => f # function *return* type: paren the type
greet: (name, greeting = "Hello") => greeting + ", " + name
pack: (...xs) => xs
blk: (x) => [ y: x * 2 y + 1 ] # multi-statement body — use `[…]`, not `{…}`
dist: (p) => [ (x, y): p sqrt(x*x + y*y) ] # destructuring bindings are statements here too
one: (x) => [x] # `[e]` after `=>` is a block: yields e, not `[e]`
wrap: (x) => [x,] # one-element array: trailing comma, or `[[x]]`
# `case` / `match` inside that block is the body, not an array of the result
data ShirtSize = Small | Medium | Large | XLarge
price: (size :: ShirtSize) :: Float => [
case (size)
| Small => 11.00
| Medium => 12.50
| Large => 14.00
| XLarge => 16.00
]
price(Small) # → 11.00 (Float, not `[11.00]`)
A TitleCase parameter is refused — it is a signature, not a
lambda. Type names are TitleCase, so
Integer -> Integer in expression position would be a
lambda whose parameter shadows the type Integer.
That is never what an author means, and it used to be silent:
sig : Integer -> Integer bound
lambda(Integer) => Integer at exit 0, quietly discarding
the signature, while the :: twin enforced it.
sig : Integer -> Integer # SyntaxError: `Integer` is a type name, not a
# lambda parameter
sig :: Integer -> Integer # the detached signature — enforced
sig: func(n) [ n ]
x -> x * 2 # a lambda parameter is lowercase
The refusal is specific to the bare arrow form. A
function-type synonym
(type Mapper = Integer -> Integer), a match arm
(| Absent -> -1), a typed parameter
((f :: Integer -> Integer) => …) and the frame form
(usa[gdp -> 28000]) are separate arrows with their own
gates, all unaffected.
Tail calls run in constant space in every spelling.
A call in tail position — the last thing an if branch, a
switch/match arm, or a block body does — is
trampolined rather than recursed, for func,
(params) => …, x => … and
lambda alike (self- and mutual recursion, the inner-helper
idiom included), so
rep = (acc, n) => if n == 0 then acc else rep(acc + 1, n - 1)
runs rep(0, 1000000) without ever nearing the
recursion-depth guard (max Eval depth 50000). Destructuring
parameters, ...rest and refinements keep the ordinary
recursive path. A tail loop that re-enters the same call with
the same argument objects — the Ω combinator
twin = x => x(x), twin(twin), or a
zero-argument poll() that never changes state — is refused
after 1 000 000 such hops with a catchable
recursion limit exceeded (tail loop re-entered the same call …)
Error, the constant-space twin of the depth guard: caught, not hung.
Rules that differ from a blind JS port:
| Topic | Axioma |
|---|---|
| Types | :: (never a single : — that is
binding) |
| Multi-statement body | […] after the body arrow — {} is the empty
set ∅. [e] yields e (same as a match arm); an array of one
is [e,] or [[e]] |
| Body delimiters | => / -> / → (same as
lambda) |
| Function-typed return | parenthesize:
(f) :: (Integer -> Integer) => f |
| Bare form | single untyped name only: x => …; multi-param needs
(a, b) => … |
| Without a body arrow | ordinary group / tuple / section / ascription —
(2 + 3), (1, 2), (>2),
(x :: Float) |
() alone is still the empty tuple;
() => 42 and () -> 42 are nullary
functions. Partial application and over-application follow the same
rules as every other function. Defaults and rest parameters share the
evaluator path with func (and are refused under
--vm, §911).
Exhaustive exemplar: tests/typescript/src/functions.ax lists every shipped function and lambda spelling in one runnable file.
Destructuring parameters
An anonymous func or lambda may
destructure a parameter in place, using the same
pattern grammar as match. The commonest use is a
higher-order call over pairs.
fst: func((a, b)) [ a ] # tuple pattern — ONE param
both: func([a, b]) [ a * b ] # array pattern
head: func([h | t]) [ h ] # cons pattern
lfst: lambda ((a, b)) => a # lambda spells it the same way
map(func((a, b)) [ a + b ], [(1,2), (3,4)]) # → [3, 7]
Both pairs of parens are required. The outer pair is
the parameter list, so func(a, b) and
lambda (a, b) => … are the
two-parameter forms — only the inner bracket makes a
slot structural. Called with one tuple,
lambda (a, b) => a + b returns a partial application
rather than a sum.
Only ( and [ open a pattern:
func(0) and func(_) are parameters literally
named "0" and "_". Literal and
wildcard patterns, guards, and multiple clauses all live on the named
clausal form (func f(0) [...]) and on equations, both
covered just below. Patterns compose with ordinary, typed and variadic
parameters — func(k :: Integer, (a, b), ...rest) — and an
argument that does not match the pattern raises a catchable error rather
than binding silently.
Variadic parameters —
...rest
A parenthesized parameter list may end with a rest
slot: ...name collects every argument beyond the
fixed ones into an Array, empty when there are none. The
form belongs to every parenthesized header alike —
func/fn, the named declaration, the equation
head, and lambda in all of its spellings:
tail: func(a, ...rest) [ [a, rest] ]
tail(1) # → [1, []]
tail(1, 2, 3) # → [1, [2, 3]]
pack: lambda (...xs) => xs # rest-only lambda
pack() # → []
pack(1, 2, 3) # → [1, 2, 3]
count_tail: λ(a, ...t). len(t) # the λ and \ spellings take it too
count_tail(1, 2, 3) # → 2
The rest slot must be last —
lambda (...xs, a) and func(...xs, a) are
SyntaxErrors — and it lives only in a parenthesized header: the
curried binder list (λx y. e) has no rest form, exactly as
func has no unparenthesized header at all. Reflection reads
both constructs identically: parameters lists the fixed
slots, arity counts them, and signature
renders the rest slot last.
arity(tail) # → 1 fixed slots only
arity(pack) # → 0
signature(tail) # → "func(a, rest...)"
signature(pack) # → "func(xs...)"
A typical use is a fold whose seed is the first argument:
variadic_max: lambda (first_v, ...more) =>
reduce(lambda (a, b) => if a > b then a else b, first_v, more)
variadic_max(3, 1, 4, 1, 5) # → 5
Defaults order before the rest slot —
func(a, b = 2, ...rest) is the full ordering, covered under
Labeled
arguments just below. Under --vm a rest parameter is
refused at compile time, on func and lambda
alike (variadic parameter "...rest" is not supported under
--vm); variadic code runs on the evaluator.
Labeled arguments — parameter defaults and calling by name
A parameter may carry its own default, and the call site may name the slot it is filling instead of counting positions. The two halves are one feature: the name written in the signature is the name the caller uses.
split_custom: func(line, on = ",") [ split(line, on) ]
split_custom("a,b,c") # → ["a", "b", "c"] default supplies `on`
split_custom("a-b-c", "-") # → ["a", "b", "c"] still positional
split_custom("a|b|c", on: "|") # → ["a", "b", "c"] filled by name
Two spellings declare a default, one spelling calls by
name. In a signature on = "," and
on: "," are the same declaration; at the call site only
on: "|" is a labeled argument, and on = "|" is
a syntax error. The colon is read by the house rule that type
names are TitleCase — after :, a TitleCase
identifier annotates a type and anything else is a default, so
b: Integer is a typed required parameter while
b: 99 defaults. Write = when the default is
itself TitleCase. A typed slot takes both:
n :: Integer = 5.
Pun: omit the value when a local of the same name
exists. f(name:) is f(name: name) —
the slot name is also the identifier that supplies the value. Lookup is
the caller, not the parameter. Mix with explicit labels in one call;
reorder still works. A newline after : is a missing value,
not a pun. :name is a get-word (fetch), not a label.
func(name:) in a definition stays a syntax error.
greet: func(name, greeting = "Hello") [ greeting + " " + name ]
name: "Ada"
greeting: "Yo"
greet(name:) # → "Hello Ada" ≡ greet(name: name)
greet(name:, greeting:) # → "Yo Ada"
greet(name:, greeting: "Hi") # → "Hi Ada" mixed
greet(greeting: "Hi", name:) # → "Hi Ada" reorder
Every positional argument must still precede the first labeled one:
greet(name:, "Ada") is parameter 'name' assigned
twice when name is the first parameter — the same
error as greet(name: name, "Ada"). A pun is a labeled call,
so it does not partially apply (add(x:) is missing
value for parameter 'y', not a Function). Under --vm a
labeled call, pun included, is refused at compile time.
Once a call names a slot it may name them all, in any order, and a defaulted slot may simply be left out:
box: func(w, h = 1, fill = "·") [ [w, h, fill] ]
box(3) # → [3, 1, "·"]
box(3, fill: "#") # → [3, 1, "#"] skips over `h`
box(h: 2, w: 3) # → [3, 2, "·"] order is the caller's
Positional arguments bind from the left as always, so every
positional argument must precede the first labeled one —
box(w: 3, 2) binds 2 to w a
second time and raises parameter 'w' assigned twice (both positional
and keyword). A label that matches no parameter raises unknown
parameter; neither is a silent miss.
The default expression is evaluated on every call, in the frame being built, so it may read a parameter declared to its left and it cannot accumulate state between calls:
seed: func(xs = []) [ xs push 1 ]
seed() # → [1]
seed() # → [1] a fresh [] each call, never [1, 1]
pad: func(x, upto = x + 1) [ upto - x ]
pad(10) # → 1 the default reads `x`
Defaults are declared after the required parameters (a required slot
sitting to the right of a defaulted one can never be reached, and
calling raises missing value for parameter), and
...rest stays last: func(a, b = 2, ...rest) is
the full ordering.
| Spelling | Defaults | Called by name |
|---|---|---|
f: func(a, b = 2) [ … ] |
yes | yes |
f: fn(a, b = 2) [ … ] |
yes | yes |
func f(a, b = 2) [ … ] |
yes | yes |
f: lambda (a, b = 2) => … |
yes | yes |
f: (a, b = 2) => … |
yes | yes |
f(a, b) = … (equation) |
yes (b: 5 or b = 5) |
yes |
Parenthesized lambda and parenthesized arrow-lambda
accept untyped defaults (b = 2) and typed defaults
(b :: Integer = 2) the same way func does. The
equation form takes the same defaults — f(a, b: 5) = a + b
then f(2) is 7 — because : inside the parens
does not collide with the body's =. b = 5
inside the parens is the same declaration. A pun
f(name:) = body is still a SyntaxError: that is a call-site
form, not a missing default. A bare colon on a lambda parameter is still
a type annotation, not a default:
lambda (x, y: Integer) => … is two required parameters
(the second typed); write y = 2 or
y :: Integer = 2 for a default.
A builtin takes labels where it publishes parameter
names. signature reports what a builtin's slots
are called, and those are the labels it accepts —
round(3.14159, digits: 2) is
round(3.14159, 2). A builtin has no defaults, so a labeled
call may reorder its slots but may not skip one, and a variadic tail
(println(values...)) can only be filled positionally. Where
a builtin publishes no names the label is refused,
never ignored: labeling an argument that the callee cannot place is an
error, not a silent fallback to the positional reading.
signature(round) # → "round(x, [digits])" — so `digits` is the label
round(3.14159, digits: 2) # → 3.14
round(3.14159, 2) # → 3.14 the same call
round(3.14159, zzz: 2) # error: unknown parameter 'zzz' for round
Under --vm both halves are rejected at compile time — a
defaulted parameter and a labeled call alike. They are evaluator-only
(§911 — the VM rejects rather than answer differently).
Equations —
name(args) = expr
The keyword-free equation is the textbook spelling
of a function definition — f(x) = 2 * x reads exactly as
mathematics writes it (and as Julia's short form). It is pure sugar: an
all-plain-parameter equation is byte-identical to
func name(args) [expr] (so it compiles under
--vm and redefinition is last-write-wins), and an equation
with patterns or a when
guard is byte-identical to a clausal func (clauses
accumulate by name in source order; unmatched calls fall through to
none; --typecheck exhaustiveness applies). The
two spellings may be mixed in one clause group.
An optional designator — func, fn,
fun, or function (all one token) — may
introduce the equation the same way relation /
rule introduce theirs. It adds no semantics; it is for
readers who want the word visible (ML's fun, JS/Java's
function, explicit intent). Those words are full aliases of
func on every function form, not only equations: anonymous
function(x) […], named function f(x) […], and
f: function(x) […].
double(x) = 2 * x # ≡ func double(x) [x * 2]
add5(a, b: 5) = a + b # default; add5(2) → 7. `b = 5` is the same
fun circleArea(r) = pi * r * r # optional designator — same lowering
function area(r) = pi * r * r # long designator — same again
func fact(0) = 1 # designator + clausal equation
fact(n) = n * fact(n - 1) # … + catch-all: fact(5) → 120
sign(x) when x > 0 = 1 # guard sits between the head and `=`
sign(x) otherwise = 0 # ≡ `when true` — last-clause catch-all
sign(x) = 0 # unguarded clause is also a catch-all
whichsign(n) = "Positive", if n > 0
= "Zero", if n == 0
= "Negative", if n < 0 # Miranda order: body, then `, if` / `, otherwise`
# (≡ `when`; equality in the guard is `==`)
head2([h | t]) = h # cons patterns work; so do constructor,
area(Circle(r)) = 3.14 * r * r # tuple, wildcard, or- and as-patterns
A bracket after the head is the BODY, exactly as in
the func spelling — the two are one construct with one body
parser, so they cannot mean different things. An array is the
double bracket, its inner bracket sitting in value
position as the body's final expression:
boxed(x) = [2 * x] # BODY ⇒ 2 * x ≡ func boxed(x) [2 * x]
arrayed(x) = [[2 * x]] # the 1-element ARRAY ⇒ [2 * x]
norm(x) = [ # several statements ⇒ the last is the value
m: x * 2
m + 1
]
pairup(x) = [x, x + 1] # commas in a body ⇒ the tuple (x, x + 1)
plain(x) = 2 * x # no brackets needed for a one-expression body
literal(x) = ([1, 2, 3])[x] # a bracket wanted as a VALUE is parenthesized
A bare = binding is unaffected —
v = [2 * x] is still the array, because it is a value
binding, not a definition. The difference is the parenthesized head:
f(x) = … defines a function and takes a body,
v = … binds a value.
Boundaries: the head's parameters are patterns;
typed parameters (x :: Integer) and defaults
(b: 5 / b = 5) are accepted and lower to the
same node func builds. In a function parameter slot, a
capitalized name dispatches as a constructor when that constructor
exists; otherwise it binds the parameter. Match-arm patterns retain
their own constructor rules; answer = 42 (no parens)
remains an ordinary value binding; and the bare Haskell spelling
double x = 2 * x stays a SyntaxError whose hint names this
form — Axioma calls are parenthesized, so definitions are too. This
completes the keyword-optional lattice: relations may drop
relation on the bare uppercase header
(coord(X, Y)), rules may drop rule before
:-/whenever, and functions may drop
func before =.
Guard sequences and pipe clauses
A guard may contain comma-separated steps: an ordinary expression
tested by Axioma truthiness, pattern <- expression
matching one value, or
let name = expression introducing a fresh immutable local.
Steps run once, left to right. Each new name is visible to later steps
and its branch's result. The same sequences work after when
on function clauses and match-family arms.
scaled(value)
when Some(x) <- value,
x > 0,
let doubled = 2 * x,
doubled < 100
= Some(doubled)
scaled(value) otherwise = Absent
println(scaled(Some(3))) # Some(6)
println(scaled(Some(60))) # Absent
println(scaled(Absent)) # Absent
A function head can also precede a group of pipe clauses. Each pipe introduces a full guard sequence and its result; these are ordered function clauses.
sign_pipe(n)
| n > 0 = 1
| n < 0 = -1
| otherwise = 0
scaled_pipe(value)
| Some(x) <- value,
x > 0,
let doubled = 2 * x,
doubled < 100 = Some(doubled)
| otherwise = Absent
println([sign_pipe(-2), sign_pipe(0), sign_pipe(3)]) # [-1, 0, 1]
println(scaled_pipe(Some(3))) # Some(6)
A pipe separates guard branches, not clauses. The
head is written once, above the group; | never repeats it.
SML's fun f p = e | f q = e2 is therefore refused — and
refused rather than added, because under a head
| name(args) = result already reads as a guard branch that
calls name(args), so one line would have two
meanings with nothing to choose between them. Write ordered clauses on
their own lines instead (length([]) = 0 /
length([_ | xs]) = 1 + length(xs)), or keep the pipes and
move the patterns into guard steps:
length(xs)
| [] <- xs = 0
| [_ | rest] <- xs = 1 + length(rest)
println(length([10, 20, 30])) # 3
The first external | begins a new line deeper than the
head; subsequent external pipes are aligned. Unbracketed nested
match/case/switch/cond/function arms indent deeper than the
external pipes. Parentheses and bracket bodies isolate nested
boundaries. Keep the first RHS token on the = line; open
[ there for a multiline body. Existing body-versus-array
rules apply. Put where inside a branch bracket body.
Same-line external pipes and bare group-wide where are not
supported in this version. Guard steps and their result =
may span lines; ordinary unguarded equations keep their existing line
rules.
Falsey conditions and pattern mismatches try the next branch. Errors
propagate; the first successful chain commits its body even when it
returns none or an error. Effects are not rolled back.
Branch bindings do not escape, and fresh let preserves
closures made before a later shadow. Pins and pattern alternatives
retain normal match semantics. No eligible clause at a supported arity
still returns none. and/or and nested
expression commas are unchanged; ; remains a statement
separator, not guard OR. An old single-expression guard, including a
scoped block expression, keeps its previous meaning.
In a bracket-body function, write indices tightly or group the guard:
func first_positive(xs) when xs[1] > 0 [xs[1]] or
func first_positive(xs) when (xs[1] > 0) [xs[1]]. A
separated [ starts the body. An operator stranded after
that body is diagnosed.
Detached signatures and refinements work on pipe groups. Inline
typed/defaulted guarded heads are not added by this form.
--typecheck visits the steps in branch scope;
--infer types supported sequences and reports unsupported
patterns. @infer and @strict check the
observed return type as for ordinary functions. Clauses and match
expressions remain evaluator-only; --vm
refuses them. when/any and head-eliding repeated
when are separate, unimplemented proposals.
Arity overloads — one name, several parameter counts
Clauses accumulate by name and arity, so a spelling may carry several independent functions. The idiom this exists for is the accumulator: a public one-argument entry point that seeds a private worker.
sum(l, acc) = match l with # the worker — arity 2
| [] => acc
| [h | t] => sum(t, acc + h)
sum(l) = sum(l, 0) # the entry point — arity 1
sum([1, 2, 3]) # → 6
The two are not clauses of one function that shadow each other; they
are two functions, dispatched on argument count, and either may be
called directly (sum([1, 2, 3], 10) → 16). Declaration
order does not matter — the entry point may come first and call
forward.
An exact arity match always wins over currying. With
no overload, add(x, y) = x + y supplies too few arguments
by returning a partial application (add(2)(3) → 5). When a
clause of exactly that arity exists, it is dispatched instead — so
adding e(x) alongside e(x, y) makes
e(1) call the one-argument function rather than curry the
two-argument one. Introduce a narrower overload only when you don't want
the curried form for that count.
The ceiling is the widest arity declared for the
name; more arguments than that is an error at every arity
(no clause of 'e' accepts 3 argument(s)), and
--typecheck reports it ahead of the run. A
...rest slot on any one of the overloads lifts the ceiling
for the name entirely.
A same-arity redefinition replaces, and an annotated one says
so. A parameter annotation (n :: Integer) is a
contract on that parameter — it never chooses between bodies.
So two heads of the same arity are one function defined twice, and the
second wins, exactly as x: 1 then x: 2 leaves
2. When the two heads' annotations differ, both runtimes print a notice
on stderr at the redefining line, naming both heads and the spelling
that does dispatch:
func g(n :: Integer) ["int"]
func g(s :: String) ["str"] # stderr: Warning: script.ax:2: `g/1` is redefined — a parameter
# annotation is a contract and does not select a clause, so
# `func g(s :: String)` replaces `func g(n :: Integer)`. To choose
# a body by type, guard it: `func g(s) when s is String [...]`.
g("x") # → "str"
g(5) # → type error: parameter s expects type String
The dispatching form is a guarded clause set —
func g(n) when n is Integer ["int"] and
func g(s) when s is String ["str"] accumulate, and
g(5) → "int". Redefining a head with the same
annotations, redefining an unannotated head, and adding an overload of a
different arity all stay silent; the notice is for the one shape that
reads as a method table and is not.
Return values —
the last expression, and return
A function body returns its last evaluated
expression — that is the native idiom, and most Axioma
functions never write return at all. For early exits,
return [value] is a statement (bare
return yields none; in a :: Unit
/ -> Unit function it yields (), the same
as omitting the return), and it unwinds through nested blocks and loops
to the enclosing function:
func round_pil(x) [
f: floor(x)
if x == f then return f # branch-position return (guard clause)
else return floor(x + 0.5)
]
func classify_sign(x) [
return "neg" if x < 0 # postfix guards — the Perl idiom
return "zero" unless x != 0
"pos" # fall-through: last expression
]
func find_first_even(xs) [
foreach x in xs [
if x == 0 then return x # unwinds through the loop
]
none
]
if c then return v is sugar for the block form
if c then [return v] — byte-identical AST. Because
return is a statement, it cannot appear in value
position: x: return 5 is a SyntaxError (with a hint) — bind
the value directly. A return at top level (outside any
function) is a benign no-op; the return value must
start on the same line as the keyword (a line break after
return means a bare return). Under --vm, every
function-level form compiles (the frame machinery already existed);
top-level return stays evaluator-only.
Several results are one tuple. A function returns
exactly one value, and Tuple is the product type — so write
return (best, at) and destructure at the call site with a
multi-assign: m, i = maximum(xs). The comma is a
separator everywhere in Axioma (arguments, literals,
comprehension clauses) — never an operator that builds values — so the
bare spelling return best, at is a SyntaxError whose hint
names the exact rewrite:
SyntaxError: 'return' takes one value
Hint: a comma list is not a value — to return several results, return one
tuple: `return (best, at)`. The call site destructures it: `a, b = f(...)`.
Contracts —
requires / ensures / check f
A function's spec is data: the contract
statement declares it next to the function, every later call enforces
it, and check f verifies it over generated inputs. This is
the function twin of the concept formation layer
(concept X { purpose:, examples:, boundary: } +
check X — § Concept formation), and the formal home of the
accumulator-seed bug from the pitfall box:
types cannot reject a well-typed wrong answer, but a
postcondition can.
maximum: func(a) [
best, at: a[1], 1
for i, e in enumerate(a) [
if e > best then [
best = e
at = i
]
]
return (best, at)
]
contract maximum {
purpose: "Largest element of a non-empty array, with its 1-based position."
requires: len(a) > 0
ensures: result[1] in a
ensures: forall e in a | result[1] >= e
generate: func() [ map(func(k) [random(-99, 99)], range(1, random(1, 8))) ]
trials: 200
}
maximum([8, 10, 23, 12, 5]) # (23, 3) — passing calls are unchanged
maximum([]) # ERROR: contract violated: maximum requires
# clause 1 (len(a) > 0) + an args: line
v: check maximum # runs the trials → ⊤ᵇ (a Belnap verdict)
contract is a soft keyword —
contract: value bindings, hash keys, and argument uses all
keep working; the statement fires only in the exact shape
contract name { … }. The braces are the declarative
slot block (the concept surface), not an executable
[ … ] body — a contract is declared metadata. Slots, all
optional; requires:/ensures: repeat and
conjoin:
| Slot | Meaning |
|---|---|
purpose: |
Prose. Shown by describe(f) (Purpose:
line). |
requires: |
Precondition over the parameter names. Checked
before the body; a false clause is a catchable
contract violated: Error naming the clause, with an
args: line. |
ensures: |
Postcondition over the parameters plus
result (the return value — bound only inside the
clause; outer result bindings are untouched). Checked
after the body. |
generate: |
A zero-arg function returning one argument tuple per call (a Tuple splats to multiple arguments; any other value is the single argument) — or a ready-made Array/Set of argument tuples, used as-is. |
examples: |
Array/Set of argument tuples check always runs first
(deterministic anchors). |
classify: |
A function over the same arguments returning a String label;
check prints the label distribution under
CHECK PASS (see below). |
trials: |
How many generated draws check makes (default
100). |
Enforcement is always on for a contracted function —
the cost is opt-in by declaring, and it survives rebinding,
map/filter, and partial application (a partial
defers checking to the eventual saturated call). Re-declaring a contract
replaces it. Clauses evaluate in a child of the function's closure env,
so they may call outer helpers. The contract attaches to the
function object, so contract f must come
after f is defined; multi-clause functions
(func fib(0) [0] …) take one contract on the shared name,
with pattern variables bound positionally
(fib(0)/fib(n) ⇒ the contract's n
sees the argument).
when guards vs requires. A
guard selects among clauses — no clause matching is a
legitimate outcome (none, the total-match
rule). A requires clause rejects — calling outside
the precondition is a loud, catchable Error. Both compose: guards route,
requires polices.
check f — property verification. Runs
the examples: first, then trials: draws from
generate:; every input that passes requires:
is applied and every ensures: clause evaluated. The verdict
is a Belnap value, mirroring check Concept:
⊤ᵇ— every input passed (a✅ --- CHECK PASS:summary prints);⊥ᵇ— a counterexample or a call error was found; the first one stops the run and prints a report with theargs:,result:, and how many inputs preceded it;?ᵇ— nothing checkable: no contract, no input source, or every input was skipped byrequires:(a generated input failing the precondition is discarded and counted, never a failure).
Generation runs under the ambient seeded RNG (fixed
startup seed — § Randomness), so an unseeded check is
reproducible run-to-run; call random_seed(…) first to vary
the draws. Note the target parses like any check
expression, so check f == x reads as
check (f == x) — bind the verdict first
(v: check f) to compare it.
check samples the effects:
claim the same way it samples ensures. A function
that declares effects: [] and then inserts
fails the check (⊥ᵇ) and writes nothing —
check writer and writer(1) now agree. A
requires that queries the store is harness work,
not a user write. When decreases: or effects:
is present, the report adds a totality: line
(postconditions / decreases / effects / exhaustive). The returned Belnap
is still the postcondition axis: a missing Blue clause is
⊥ᵇ on that line and does not change
check paint if every sampled ensures
held. There is no total keyword.
Shrinking. A failing witness is minimized
before it is reported: check greedily simplifies
the counterexample — integers move toward 0, arrays and
strings lose halves and single elements, one argument position at a time
— accepting a simpler candidate whenever it still fails (any
clause; the input must also still satisfy requires:, so
shrinking never leaves the declared domain). The report's
args: line shows the minimal witness and a
shrunk from: (…) in N steps line shows the original
draw:
❌ --- CHECK FAIL: bad_maximum ensures clause 1 (result[1] in a)
args: ([-1])
shrunk from: ([-31, -74, -63, -10, -49]) in 6 steps
result: (0, 0)
Shrinking is deterministic (no randomness) and capped at 500 candidate evaluations; when nothing improves, the line is omitted.
Input distribution — classify:. A
passing suite can pass vacuously — the seed-bug lesson was that
all-positive test data never exercises the interesting region. The
classify: labeler makes the distribution visible: every
input that runs is labeled, and CHECK PASS prints a census
(percentages, largest first):
✅ --- CHECK PASS: maximum (0 examples + 200 trials)
67% mixed · 19% all-negative · 15% non-negative
Auto-generation from :: annotations.
When a contract declares no generate: and
every parameter carries a supported scalar annotation —
Integer, Float, String, or
Boolean — check synthesizes the draws itself
(integers and floats in ±100, strings of up to 8 lowercase letters, a
fair coin), through the same seeded source as random. The
summary says auto trials so the mode is visible:
inc: func(x :: Integer) [x + 1]
contract inc { ensures: result == x + 1 }
v: check inc # ✅ --- CHECK PASS: inc (0 examples + 100 auto trials)
Containers are deliberately not auto-drawn (annotations carry no
element type) — write a generate: for structured inputs. A
partially annotated function keeps the ?ᵇ verdict, with the
message pointing at both options.
Pre-state — old(). Inside an
ensures: clause, old(e) denotes the value
e had on entry to the call, so a
postcondition can relate the result (or a mutated argument) to the
pre-call state:
append_x: func(xs) [ push(xs, "x") ]
contract append_x { ensures: len(result) == old(len(xs)) + 1 }
append_x([1, 2]) # 3 elements — the clause compares against the ENTRY length
The capture is by value: every old(...)
argument is evaluated before the body runs, and composite results
(arrays, dictionaries, sets, tuples) are deep-copied, so an in-place
mutation by the body cannot retro-edit the snapshot (Eiffel's
old captures references and traps users exactly there;
prefer scalar captures like old(len(xs)) when the whole
collection isn't needed). old() takes exactly one
expression and cannot nest; it is meaningful only in
ensures: — in requires: the pre-state
is the arguments. A binding named old in scope
wins: the operator stands down and the call is ordinary code.
Termination — decreases:. The slot
declares a termination measure over the parameters — an Integer that
must be ≥ 0 and must strictly decrease
on every recursive activation. The check is dynamic (the static
discharge belongs to a future prove f), so it reports on
the calls that actually happen — including self-tail-call iterations,
which the tail-call optimizer runs in constant space where no recursion
limit would ever trip:
fact: func(n) [ if n <= 1 then 1 else n * fact(n - 1) ]
contract fact { requires: n >= 0, decreases: n }
fact(5) # 120 — measure 5 → 4 → 3 → 2 → 1
stuck: func(n) [ if n == 0 then 0 else stuck(n) ]
contract stuck { decreases: n }
try(stuck(3)) # catchable: … measure did not decrease (3 → 3)
# (without the contract this tail loop spins forever)
The measure may be any Integer expression over the parameters
(decreases: hi - lo for a shrinking span). A negative
measure and a non-Integer measure are loud catchable errors. Under
check f the measure stays live even while
requires/ensures enforcement is suspended for the trials — so a
generator that draws a non-terminating input turns the hang into a
failed trial (⊥ᵇ with a shrunk witness). Mutual recursion
is checked per function (each function's own measure across its own
activations).
Declared effects — effects:.
requires: and ensures: say what a function
needs and what it promises. Neither says what it does, and a
contracted function can write the fact store with its contract watching
in silence. The effects: slot is where that gets
declared:
save: func(n) [ insert("log", n) ]
contract save { requires: n > 0, effects: [kb] }
pure_calc: func(a, b) [ a + b ]
contract pure_calc { effects: [] } # a real claim: neither effect
Three effect names, and the set is closed: kb covers
fact assertion and retraction, grounding and truth metadata, and
transactions; io covers file reads and writes;
error covers producing an uncaught
Error value (1 / 0, a missing slot, …).
try(...) and error("…") (Caught) are data, not
this effect. An unrecognized tag is refused rather than tolerated,
because a tag that parsed but meant nothing would read like a
declaration and promise nothing. The tags are bare words, not
strings — effects: [kb], never
effects: ["kb"] — and a single tag may drop the brackets
(effects: kb). effects: [] is not the same as
omitting the slot: it declares none of the three, where an
absent slot declares nothing at all.
The kb half is enforced, and a
violation refuses the write rather than reporting it:
relation r(x)
writer: func(n) [ insert("r", n) ]
contract writer { effects: [] }
writer(5)
# → ERROR: contract violated: writer declares effects: [] —
# writing r(5) is a kb effect
# permit: add kb to writer's effects, or move the fact write out of writer
{X | X <- r(X)} # → {} nothing was stored
Three properties make the declaration a guarantee rather than a
comment. It is opt-in: a function with no contract, or
a contract without the slot, is unconstrained, exactly as with
decreases:. It covers the whole call
context, not just the declaring body, so moving a write into an
uncontracted helper does not escape it — while that same helper stays
free when called from outside any declaration. And it catches
every writing form, not only insert:
assert, a bare relation call, forget,
retract, the deontic and epistemic builtins, and the
metadata writers — set_truth, cancel,
challenge, and the transaction block — all reach it,
including the form with no call expression at all —
q: func() [ forall x in [50, 51] | p(x) ]
contract q { effects: [] } # refused; neither fact is written
which is why the check sits at the write rather than at the call.
Reading is not writing. A comprehension runs cleanly under
effects: [], and so does a query over a recursive relation:
forward chaining is lazy, so such a query materializes the closure into
the store, but that write caches what the rules already entail rather
than adding to them.
The io half covers the filesystem, in both spellings —
the global write and the package io.write_file
are one decision, and so are read, remove,
read_csv/write_csv and the byte pair
(to_json / from_json carry no tag: pure
value↔︎string):
save: func(path, text) [ write(path, text) ]
contract save { effects: [] }
save("/tmp/x", "data")
# → ERROR: contract violated: save declares effects: [] —
# calling write is an io effect
Reads count: a file read leaves the process, so
effects: [] refuses read as surely as
write. The two tags do not buy each other —
effects: [kb] permits no file write, and
effects: [io] permits no fact write. error is
independent of both:
boom: func(n) [ 1 / n ]
contract boom { effects: [] }
boom(0)
# → ERROR: contract violated: boom declares effects: [] —
# producing an Error is an error effect
Two exclusions are deliberate. Path helpers and metadata predicates —
file_exists, is_file, file_size,
join_path, base_name — open nothing and are
not io, so a function may inspect a path under
effects: []. And append splits on its first
argument: append(array, elem) is the pure functional push
and stays permitted, while append(path, text) is a file
write and is refused.
The unary-dot fallback is covered as well:
"/etc/hosts".read is refused under effects: []
exactly as read("/etc/hosts") is, while the fallback's
effect-free use — 16.sqrt, "abc".len — is
untouched.
The concept twin — invariant:. Concepts
carry the instance-level counterpart: one
invariant: <predicate> slot per concept
block, open in slot names like boundary:, checked
after instantiation and after every direct property
write (dot assignment, compound assignment, and the
x[attr -> val] frame form):
concept Account {
balance: 0
invariant: balance >= 0
}
acc: an Account { balance: 10 } # checked at creation
acc.balance = 5 # checked after the write — fine
poke: func() [ acc.balance = -1 ]
try(poke()) # catchable: concept invariant violated: Account (balance >= 0)
acc.balance # 5 — the violating write REVERTED
try(an Account { balance: -3 }) # stillborn: no instance created, extent unchanged
A violating write is rolled back before the error returns, so the
object never stays inconsistent (deliberately stronger than Eiffel's
exception-leaves-object-dirty), and a violating creation is stillborn.
Invariants are strict (invariant~: is rejected — spell
conjunctions with and), inherit down extends
chains (a child instance must satisfy its ancestors' invariants), and
are error-loud: a predicate that fails to evaluate or returns a
non-Boolean is itself a catchable error. Indirect writes — inverse-slot
propagation, unify coreference merges, and
Concept has default edits — are not invariant-checked in
this round.
Proof instead of sampling — prove f.
check f samples inputs; prove f decides
contracts in a supported, explicitly typed runtime domain. Every
parameter must be annotated Integer or
Boolean. It hands the verification condition
(requires ∧ result = body) implies ensures to the SMT
solver, one ensures clause at a time. A discharged clause covers every
scalar input of those types satisfying requires, subject to
the limits below:
inc: func(x :: Integer) [ x + 1 ]
contract inc { requires: x > 0, ensures: result > x }
prove inc # PROVE OK: covers the modeled Integer/Boolean scalar inputs
bad: func(x :: Integer) [ x + 1 ]
contract bad { ensures: result > 10 }
prove bad # ❌ PROVE FAIL — counterexample: result = 10, x = 9
A refutation is a proof of failure, not an unlucky draw: the
solver exhibits the input rather than stumbling on it. Inside
ensures, old(e) is rewritten to e
— in the verification condition the parameters already denote their
entry values — so pre-state contracts prove without extra machinery.
Branching bodies are in scope (if/else becomes
a conditional term, with both arms required to have the same modeled
runtime type). Parameter and result sorts are carried into the solver,
including Boolean parameters used only in equality comparisons.
The verdict is Belnap, and the third value carries the weight: ⊤ᵇ
discharged, ⊥ᵇ refuted, ?ᵇ not discharged.
Not-discharged is never reported as refuted. Runtime Float
arithmetic is not mathematical Real
arithmetic: adding 1.0 to 1e20 rounds back to
1e20. Contracts over Float, broad
Number, or unannotated parameters therefore return unknown.
So do floating literals anywhere in the body or clauses, free closure
values, and calls to helpers or builtins. An idealized solver
interpretation cannot certify those runtime operations:
push_end: func(xs) [ push(xs, 1) ]
contract push_end { ensures: len(result) == old(len(xs)) + 1 }
prove push_end # ?ᵇ - parameter domain and helper calls are not modeled
# (use `check push_end`, which runs the real ones)
inc_float: func(x :: Float) [ x + 1.0 ]
contract inc_float { ensures: result > x }
prove inc_float # ?ᵇ - floating-point runtime arithmetic is not modeled
The admitted bodies are closed expressions over the parameters: exact
integer addition, subtraction, multiplication by a constant,
comparisons, Boolean operations, and conditionals. Preconditions and
postconditions must be Boolean expressions in the same domain. In
ensures, unshadowed old(e) is supported when
its argument stays within the entry-value domain; nested snapshots and
old(result) are refused. An explicit return annotation must
match the modeled body type; return conversions are refused.
Multi-clause functions, lazy/variadic/patterned parameters, indexing,
division, bounded forall e in a |, and nonlinear
multiplication are also refused with a pointer at check. A
parameter named result is refused because it conflicts with
the postcondition result binding. Unknown is a limitation of this proof
path, not a rejection of the function itself. Standalone
[logic/smt | ...] and [logic/valid | ...]
continue to reason over mathematical formulas, including exact SMT
Real arithmetic; they do not certify floating-point program
execution.
The verdict covers scalar values, not class descriptors or entity
instances admitted by Axioma's broader concept-classification rules. For
example, an Integer annotation can also admit the
Integer concept itself at a runtime boundary; that
reflective value is outside the SMT scalar model. The success message
names this scope explicitly. These are postcondition proofs, not a
guarantee that every possible call terminates or avoids runtime
errors.
prove f is both a statement and an expression, so
v: prove f binds the verdict exactly as
v: check f does.
Functions as arguments — sub-contracts and blame. A
function-valued parameter can carry its own contract in a nested block.
Its clauses speak positionally — arg (or
arg1…argN, and args) and
result — because the contract's author does not choose the
lambda the caller passes:
twice: func(f, x) [ f(f(x)) ]
contract twice {
f: { requires: arg >= 0, ensures: result >= arg }
ensures: result >= x
}
twice(func(n) [ n + 1 ], 5) # 7
When one of these fails, the report names who is at
fault, and the two directions are opposite. A sub-contract's
requires failing means twice called the
supplied function outside that function's domain — the callee
is to blame. Its ensures failing means the function the
caller supplied broke its own promise:
contract violated: twice's f ensures clause 1 (result >= arg)
args: (5)
result: -95
blame: the caller of twice — the function it supplied for f
broke that function's own promise
The incoming function is wrapped so the guard travels with the value:
it still holds when the body passes it to map. Two
consequences worth knowing: a wrapped function is a distinct object
(identity comparison does not survive wrapping), and a recursive
supplied function is guarded at its outermost call only, since its
by-name self-calls reach the original.
Objects over time — stateful checking. A contract on
a concept describes behavior across a lifetime rather than one
call. check then runs random sequences of the listed
operations against fresh instances, with the concept's
invariant: as the oracle:
concept Account { balance: 0, invariant: balance >= 0 }
deposit: func(acc, n :: Integer) [ acc.balance = acc.balance + abs(n) ]
withdraw: func(acc, n :: Integer) [ acc.balance = acc.balance - abs(n) ]
contract Account { operations: [deposit, withdraw], steps: 12, trials: 40 }
check Account
# ❌ --- CHECK FAIL: Account (stateful)
# sequence (1 operation, shrunk from 2):
# → withdraw(1)
# concept invariant violated: Account (balance >= 0)
The deliverable is the sequence, shrunk to the
fewest operations that still break the object — an unshrunk twelve-step
transcript is unreadable. Arguments after the receiver are drawn from
the operations' :: annotations, so the same seeding and
sizing rules apply. Each trial's instance is released afterwards,
leaving the concept's extent exactly as it was.
check Concept keeps its formation meaning (over
examples: / counterexamples:) for every
concept that declares no stateful contract — the stateful reading is
chosen by declaring one, never inferred.
Sized generation. Generated inputs start small and
grow across the run, so shallow bugs surface in the first trials instead
of arriving as a large witness that then has to be shrunk. A
generate: function opts in by taking one parameter:
contract sample { generate: func(size) [ random(0 - size, size) ], trials: 5 }
# sizes drawn: 1, 25, 50, 75, 100
A zero-argument generator is called exactly as before, so existing
contracts draw identically. Auto-generation from ::
annotations is sized too — magnitudes and string lengths follow the
ramp, while Booleans do not (a coin has no magnitude). The size is a
function of the trial index alone, never a draw from the random source,
so seeded runs stay reproducible.
The seed-bug version of maximum (seeding
best with 0) fails the membership clause on
any all-negative input — maximum([-4, -2, -9]) errors at
the call with result: (0, 0) in the report, and
check maximum finds it in the first few trials.
Evaluator-only: the contract statement and
check f reject under --vm, and contracted
functions called under --vm run without
enforcement (the VM never consults the contract). Reference:
doc contract, doc check,
doc prove; tests: tests/axioma/contracts/ and tests/axioma/concepts/ for the
concept invariant and stateful-checking files.
Anonymous functions
(lambda x => x * 2)(5) # 10
Closures
makeAdder: lambda n => lambda x => x + n
addTen: makeAdder(10)
addTen(5) # 15
Closures also mutate captured state, with
rebind — a plain assignment stops at the closure's own
frame and would declare a local there instead. The counter idiom and the
full scoping rules live in §3 Scoping
& shadowing.
make_counter: func() [
acc: 0
func() [rebind acc: acc + 1
acc]
]
c: make_counter()
c() ; c() # 2
Currying & partial application
multiply: lambda x => lambda y => x * y
double: multiply(2)
triple: multiply(3)
# User functions and operator prefix-calls both partial-apply:
add(x, y) = x + y
inc: add(1) # user function
map((+)(1), [1, 2, 3]) # operator prefix-call — same idea
Function composition
Composition wires two functions end to end: the output of one becomes the input of the next, and the pair behaves as a single new function. Because the result is itself a function, compositions nest without limit — that closure property is what makes point-free style possible.
Axioma ships both directions, deliberately distinct, because mathematics and pipelines read opposite ways and neither audience can be quietly overruled.
f: func(x) [x + 1]
g: func(y) [y * 10]
# ── left-to-right (pipeline order): f runs FIRST ──
compose(f, g)(3) # → 40 the named form, n-ary
(f >> g)(3) # → 40 the infix form
# ── right-to-left (mathematical order): f still runs first ──
(g ∘ f)(3) # → 40 the glyph
(g << f)(3) # → 40 its ASCII twin
(g `circ f)(3) # → 40 the backtick digraph (`ring` also works)
The bridging identity, which holds in every spelling:
f >> g == compose(f, g) == g << f == g ∘ f
Why two directions. On functions, f ∘ g
universally means "apply g first" — a language that
reversed the glyph would be wrong to every reader who learned it from a
textbook. compose cannot follow the glyph, because it
also composes relations, and a function is its graph:
compose(R, S) is
{(x,z) | (x,y) ∈ R ∧ (y,z) ∈ S}, left to right, which fixes
compose(f, g) as x ↦ g(f(x)). Both readings
are forced; neither is a preference. They are mirrors, not aliases.
Precedence. CALL binds tighter than
every composition operator, so f >> g(3) reads
f >> (g(3)). Write (f >> g)(3) to
apply the composition — exactly as the parenthesisation works on
paper.
Composed functions are values
normalize: trim >> lower # point-free: the argument is never named
map(normalize, [" A ", "B "]) # → ["a", "b"]
pipeline: compose(f, g)
map(pipeline, [1, 2, 3]) # → [20, 30, 40]
{p: compose(f, g)}.p(1) # → 20 stored in a dict
twice: func(k) [compose(k, k)] # returned from a function
The first stage receives every argument the composition was called with, so a pipeline may open from a multi-argument function; each later stage receives the single value its predecessor produced.
add: func(a, b) [a + b]
compose(add, g)(3, 4) # → 70 add sees both arguments
The degenerate arities
Composition has an identity element, so the empty and single cases are that element and that function — not errors. This is what lets a stage list be folded at any length, including zero.
compose()(7) # → 7 the identity element
compose(f)(3) # → 4 one stage is that stage
reduce(func(acc, s) [compose(acc, s)], compose(), [f, g])(3) # → 40
Relations compose too
compose does double duty. On sets of pairs it is
relational composition:
compose({("a",1), ("b",2)}, {(1,"x"), (2,"y")}) # → {("a","x"), ("b","y")}
The operators refuse sets on purpose. Relational
R ∘ S has two incompatible conventions in the literature,
so a glyph there would have to guess; compose is the
spelling that commits to one in its name, and the error says so. Mixing
a set with a function is a category error, not a coercion.
The surrounding combinators
| Form | Meaning | Example |
|---|---|---|
identity(x) |
the identity element | identity(7) → 7 |
pipe(x, f, g) |
thread a value through stages | pipe(3, f, g) → 40 |
x |> f |> g |
the operator form of pipe |
3 |> f |> g → 40 |
partial(f, a) |
fix leading arguments | partial(sub, 10)(3) → 7 |
curry(f) |
one argument at a time | curry(sub)(10)(3) → 7 |
flip(f) |
swap the first two arguments | flip(sub)(2, 10) → 8 |
iterate(f, x, n) |
repeated self-composition | iterate(f, 0, 4) → [0,1,2,3] |
converge(c, [f, g]) |
fork: one input, several functions | see below |
on(cmp, key) |
preprocess both arguments | see below |
flip(f) returns the swapped function (so it composes);
flip(f, a, b) applies the swap immediately.
A flipped function partially applies, like any other
function — an under-saturated call fixes what it was given and waits for
the rest, so Haskell's (flip f) x idiom transfers directly.
Once two or more arguments have arrived the callee receives them all in
one call with the first two swapped
(flip(f)(a, b, c) is f(b, a, c)), and a
zero-argument call returns the function unchanged, matching
f() on a user function. The closure also carries the
callee's parameter list, swapped, whenever that shape is known — so
reflection and partial have a real arity to work from:
sub: func(a, b) [ a - b ]
minus10: flip(sub)(10) # fixes sub's SECOND argument to 10
minus10(3) # → -7 sub(3, 10)
flip(sub)(10, 3) # → -7 the saturated spelling, same call
arity(flip(sub)) # → 2 the callee's arity, known
parameters(flip(sub)) # → ["b", "a"] the callee's list, swapped
partial reaches builtin-backed callables
too — a flipped function, a composed one, or a plain builtin —
not only user functions. The residual carries the remaining parameter
list when the callee's spec pins one (and makes no arity claim
when it does not), and fixing more arguments than a known maximum is
refused at partial() itself, where the mistake is:
partial(gcd, 12)(18) # → 6
partial(flip(sub), 10)(3) # → -7 the flip example, spelled via partial
partial(identity, 1, 2) # ERROR — identity takes at most 1 argument
converge —
branching and reconverging
Composition builds a chain. Real point-free code constantly needs a branch that reconverges — a mean is the sum and the length of the same input, divided. Without this the style collapses back to naming the argument just to mention it twice.
total: func(xs) [reduce(func(a, b) [a + b], 0, xs)]
count_of: func(xs) [len(xs)]
div: func(a, b) [a / b]
mean: converge(div, [total, count_of])
mean([2, 4, 6, 8]) # → 5
Every branch receives the full argument list, so a fork can open from
a multi-argument function. The branch list is an Array so
the combiner stays in first position, matching
map/filter/reduce.
on — the
shape composition cannot express
cmp ∘ key would feed key's single result to
a two-argument function. on applies the preprocessor to
both arguments instead:
on(cmp, key)(a, b) == cmp(key(a), key(b))
longer: on(func(a, b) [a > b], func(s) [len(s)])
longer("abcd", "ab") # → true
sort_by(key, coll) covers the common case for one verb;
on generalises the key extraction to any binary
function.
|>is not composition. It threads a value, so3 |> f |> gis a one-shot computation with a3baked into it.f |> gcomputesg(f)— a type error, not a pipeline. Usecompose(f, g)orf >> gwhen you want a function you can keep.
VM: the composition operators and the returning
combinators (compose, pipe,
curry, partial, flip,
converge, on) are evaluator-only pending the
VM parity effort. Each rejects under --vm
rather than returning a wrong answer.
A full treatment — the history from group theory through the lambda calculus to modern functional languages, and how Axioma's surface compares — is in Function Composition in Axioma.
Map / Filter / Reduce
nums: {1, 2, 3, 4, 5}
map(lambda x => x * x, nums) # {1, 4, 9, 16, 25}
filter(lambda x => x > 2, nums) # {3, 4, 5}
reduce(lambda (acc, x) => acc + x, 0, nums) # 15
sum(nums) # 15 (works on arrays/sets/tuples)
Σ(nums) # 15 (Unicode alias for sum)
mean((2, 4, 6, 8)) # 5 (array, tuple, or matrix; average is an alias)
median((3, 1, 2)) # 2 (even count averages the two middle values)
Enumerable verbs
The wider collection vocabulary, beyond
map/filter/reduce/group_by.
Every verb is collection-last, so it threads through
the forward pipe |> with no _ hole
(people |> max_by(...),
xs |> each_slice(3)).
tally(["a", "b", "a"]) # → {a: 2, b: 1} count occurrences (alias: frequencies)
words: ["hi", "hello", "hey", "yo"]
max_by(func(w) [len(w)], words) # → "hello" element with the largest key (argmax)
min_by(func(w) [len(w)], words) # → "hi" argmin; first-wins on ties; none if empty
sort_by(func(w) [len(w)], words) # → ["hi", "yo", "hey", "hello"] ordered by a derived key
sort_by(func(x) [x], [10, 2, 33, 4]) # → [2, 4, 10, 33] numeric-aware (not lexicographic), stable
flat_map(func(r) [r], [[1, 2], [3]]) # → [1, 2, 3] map then flatten one level
flatten([[[1]], [[2]]]) # → [1, 2] recursively descend Arrays
flat_map(identity, [[[1]], [[2]]]) # → [[1], [2]] concatenate just one level
take_while(func(x) [x < 5], [1, 2, 9, 1]) # → [1, 2] leading run while the predicate holds
drop_while(func(x) [x < 5], [1, 2, 9, 1]) # → [9, 1] the complement
each_slice(3, [1, 2, 3, 4, 5, 6, 7]) # → [[1,2,3], [4,5,6], [7]] consecutive fixed chunks
each_cons(2, [1, 2, 3, 4]) # → [[1,2], [2,3], [3,4]] sliding windows
chunk([1, 1, 2, 3, 3]) # → [[1,1], [2], [3,3]] group consecutive-equal runs
detect(func(x) [x > 3], [1, 2, 3, 4]) # → 4 first element matching (none if no match)
compact([1, none, 2, none, 3]) # → [1, 2, 3] drop none (keeps om and everything else)
[3, 1, 2] |> sort |> tap(func(a) [println(a)]) # peek mid-pipeline, return the value unchanged
# the payoff — a readable left-to-right pipeline:
[5, 3, 8, 1] |> sort_by(func(x) [x]) |> take_while(func(x) [x < 6]) # → [1, 3, 5]
min_by/max_by return the element
whose key is extremal (not the key value). sort_by shares
sort's numeric-aware ordering, so integer keys sort by
value. tally/frequencies return a dictionary
(a string is counted character by character). Ruby's first-match is
find/detect; find is a reserved
solver keyword here, so the collection verb is detect.
tap runs its function for a side effect and returns the
value unchanged — handy for peeking inside a |> chain.
All work under --vm.
List-library verbs (Haskell / OCaml)
The rest of the classic functional list library. Same
function-first, collection-last shape as the Enumerable
verbs, so they chain through |>.
zip_with(func(a, b) [a + b], [1, 2, 3], [10, 20]) # → [11, 22] pairwise, truncates to shorter
scanl(func(a, x) [a + x], 0, [1, 2, 3]) # → [0, 1, 3, 6] left fold, keeping every step
scanr(func(x, a) [x + a], 0, [1, 2, 3]) # → [6, 5, 3, 0] right fold (f receives (x, acc))
iterate(func(v) [v * 2], 1, 5) # → [1, 2, 4, 8, 16] first n of [x, f(x), f(f(x)), …]
iterate(func(v) [v * 2], 1) # → <generator> the same sequence, unbounded and lazy
span(func(x) [x < 3], [1, 2, 3, 4, 1]) # → ([1, 2], [3, 4, 1]) (take_while, drop_while), one pass
separate(func(x) [x > 2], [1, 2, 3, 4]) # → ([3, 4], [1, 2]) partition by predicate
all?(func(x) [x > 0], [1, 2, 3]) # → true holds for every element (true on empty)
any?(func(x) [x > 2], [1, 2, 3]) # → true holds for some element (false on empty)
none?(func(x) [x > 5], [1, 2, 3]) # → true holds for no element (true on empty)
# unfold — the anamorphism, dual of reduce. The step returns Some((value,
# next_seed)) to continue or Absent to stop, using the built-in Option:
unfold(func(n) [if n > 5 then Absent else Some((n, n + 1))], 1) # → [1, 2, 3, 4, 5]
scanl/scanr are the folds
reduce/foldr with every intermediate kept
(length n+1: scanl starts with the seed, scanr
ends with it). span is exactly
(take_while(p, xs), drop_while(p, xs)) computed in one
pass; separate is partition under a different
name (partition is reserved for concept partitions).
all?/any?/none? short-circuit and
read correctly in if.
The nine verbs above unfold work under --vm
(byte-identical). unfold is
evaluator-only: its step function builds
Some/Absent, which are ADT constructors the VM
does not compile, so an unfold under --vm is
rejected at compile time rather than run.
Refinements — optional switches a function declares, in REBOL's model
A refinement is an optional switch on a function. It
is declared in the parameter list, in every function form, as
/word followed by the names it owns; it is invoked with a
tight slash chain on the function's name; its owned names are passed by
name inside the parentheses.
sum: func(a, b, /times amount) [ if times then (a + b) * amount else a + b ]
sum(1, 2) # 3 — inside, times is false and amount is none
sum/times(1, 2, amount: 10) # 30 — times is true, amount is 10
copy2: func(xs, /part n, /deep, /reverse) [ [part, n, deep, reverse] ]
copy2/deep([1]) # [false, none, true, false]
copy2/reverse/part/deep([1], n: 3) # [true, 3, true, true] — any order
[9] |> copy2/part(n: 3) # the pipe fills the positional slot; names ride along
The same declaration works on every form — func name(…),
the equation head name(a, b, /times amount) = …, and an
arrow lambda (a, /only) => … — and a list may open with
a refinement: d: func(/deep) [ deep ].
The slash rule. Only a tight chain followed
by ( is a refinement call: no whitespace on either side of
any slash. Everything else is division, as it always was —
x / y, n/d, x / q(2),
x/(2 * y).
Refusals, never silent. An undeclared refinement
(sum/nope(…)) is an error naming the declared ones; a used
refinement whose owned name is missing (sum/times(1, 2)) is
an error; an owned name without its refinement
(sum(1, 2, amount: 10)) is an error. Multi-clause functions
share one refinement set: the first clause to declare it fixes it, a
later clause repeats it exactly or declares none and inherits it.
signature(f) shows the declaration in order
(func(a, b, /times amount)). Under --vm a
refinement call refuses rather than answering from the positional
arguments alone.
13. The Concept System
A concept is Axioma's central unit of knowledge representation — far more than an object-oriented class or record. Axioma's type names are concepts, but a concept is at once:
- a type name — the built-in primitives
(
Integer,Float,Set,Stack, …) and algebraic data types (data Shape = …) are first-class Concepts under the meta-conceptDataType, so5 is Integer,Integer is DataType, andx :: Dayshare one naming universe with any concept you declare; - a frame — a named thing whose
slots carry values and metadata (inverse,
transitive, cumulative, cardinality), with an auto-maintained
extent of its instances you can iterate like a set
(
{x | x <- Country}), and a concept↔︎relation↔︎rule duality; - a description-logic concept — composable with
⊓ ⊔ ¬, ordered by subsumption⊑/≡, with role restrictions (∃R.C,∀R.C),Thing/Nothing(⊤ / ⊥), defined concepts, and partitions — all decided by a real ALC tableau reasoner (satisfiable, subsumption); - a classifier — the
iscopula tests Russell's predication (∈,rex is Dog) and class inclusion (⊆,Dog is Animal); adefinespredicate or aboundaryturns membership into a rule, and every classification carries an epistemic grounding and may be defeasible; - a designed, inspectable idea — the
concept-formation layer (
purpose,examples/counterexamples,formed_by,default_grounding) lets you state why a concept exists andcheckthat it does its job — and aninvariant:slot guards every instance's integrity at creation and on every property write (see The concept twin —invariant:under §Functions Contracts).
The surface is natural language throughout —
concept Stock, Stock has price,
rex: a Dog, rex is Dog,
Dog extends Animal. The rest of this section works through
each of these dimensions; together they make concepts the substrate of
Axioma's knowledge representation and its cognitive paradigm.
Type names,
DataType, and how is dispatches
Doctrine. Type names are Concepts; type
structure (unions, parameters, arrows, literals) is algebra
over those names. Values of Integer are not Concept
instances; they classify by a runtime tag. Domain
concepts (Country, Dog) keep extents, frames,
and F-logic.
Concept # root metaconcept
├── DataType # programming data-type *names* only (meta)
│ ├── Number # value lattice for number-ish *values*
│ │ ├── Integer, Float, Rational, Complex
│ │ └── Byte, Percent, Money
│ ├── Tuple
│ │ └── Unit # empty product: () only; @ stays "Tuple"
│ ├── String, Boolean, Array, … # other primitives (value membership = tag)
│ ├── Shape, Box, … # `data` ADTs
│ └── Error, DivByZero, … # error kinds hang under Error ⊂ DataType
├── Dog, Country, … # domain / KR concepts (not DataTypes)
├── Thing / Nothing # DL ⊤ / ⊥
└── KB, ConceptualSpace, … # system namespaces
Integer is DataType # → true
Integer is Number # → true (parent walk)
Integer is Concept # → true (DataType extends Concept)
5 is Integer # → true (tag)
5 is Number # → true (value lattice)
5 is DataType # → false (DataType is meta, not a value sort)
5 is Concept # → false
concept Dog
Dog is Concept # → true
Dog is DataType # → false (domain concept, not a data-type name)
data Shape = Circle(Float) | Dot
Shape is DataType # → true
Shape is Concept # → true
Circle(1.0) is Shape # → true
@(Circle(1.0)) # → "Shape"
is is multi-relation (one spelling,
several arms):
| Arm | Example | How decided |
|---|---|---|
| Value ∈ primitive type | 5 is Integer |
runtime tag (ObjectType) |
| Value ∈ lattice parent | 5 is Number |
ValueObjectTypes on Number |
| Value ∈ ADT | Circle(1.0) is Shape |
sealed constructor / type name |
| Entity ∈ domain concept | rex is Dog |
instance + ancestor walk (+ DL / defines) |
| Type₁ ⊆ type₂ (class inclusion) | Integer is Number, Dog is Animal |
Concept ancestor walk |
| DataType object kind | Integer is DataType, Shape is Concept |
same walk on the type object |
@x / type(x) return the TitleCase
value type name ("Integer", not
"Number") and must agree with x is T for that
leaf name.
Unit. The empty product — (), a Tuple
of length 0. @(()) stays "Tuple";
() is Unit is the refinement; none is Unit is
false. A function () -> Unit returns (). A
whitelist void tail (println, loops, empty body) is
rewritten to () so the annotation is visible in the value.
A bare return (no value) in a :: Unit function
is the same as omitting it: it yields ().
return none still fails. Unannotated procedures still
return none. :: () is not a type.
myGreeting :: () -> Unit
myGreeting() = println("Hello, World!")
myGreeting() == () # true
fn test1():: Unit
println("test1")
return
end
test1() == () # true — bare return is ()
@test1() # Tuple
() is Tuple # true
none is Unit # false
Naming. The meta-concept is
DataType, not Type.
Number is the value lattice for number-ish
values under DataType. The Numeric
behavior interface has the same default membership with an open
implement table — use Numeric for protocols,
Number for type-sort membership. Lowercase
type is reserved for a future ML-style declaration form
alongside data.
Calls. Prefer functions: abs(5),
push(s, v). Unary method sugar already exists:
5.abs, [1,2,3].len mean abs(5) /
len([1,2,3]). True Concept action methods are
for entities, not every primitive.
Defining concepts
Axioma has two canonical concept-declaration surfaces:
There is a single canonical creation surface — the
keyword-first concept X form. It absorbs four feature slots
that were previously distributed across multiple competing forms:
- bare:
concept X - with postfix doc string:
concept X "doc" - with prefix refinement:
concept/persist X,concept/transient X,concept/system X - with refinement + doc:
concept/persist X "doc" - with slot-defaults block:
concept X { slot: default, ... } - with refinement + block:
concept/persist X { ... } - namespaced:
concept FinKB.Stock,concept/persist FinKB.Bond "doc"
concept Stock # bare concept creation
concept Stock "A financial instrument" # with postfix doc
concept Stock { # with initial slot defaults
price: 0
ticker: ""
}
concept/persist Stock # force KB persistence
concept/transient Scratch # never persist
concept/system InternalRegistry # mark as system-internal
concept/persist Stock "force-persist regardless of mode"
Stock extends Asset # specialize Stock as a subclass of Asset
apple is Stock # classify apple as a Stock (predication)
The keyword-first form pairs naturally with the rest of Axioma's
speech-act family (define, axiom,
postulate). is remains the canonical operator
for instance classification (apple is Stock) and Boolean
type queries (x is Stock in expression position).
Doc strings are also accepted inside the block form:
concept TreasurySec { doc: "A marketable US Treasury security" }
concept TreasurySec "A marketable US Treasury security"
TBill extends TreasurySec "Treasury bill, max 365 days"
TreasurySec has issuer: "US Treasury"
TBill has max_maturity_days: 365
Name-inference form — an anonymous
concept { ... } literal on the RHS of a :
binding picks up its name from the LHS identifier. The resulting Concept
is fully named and behaves identically to
concept X { ... }:
Widget: concept { price: 0, ticker: "" } # name inferred from LHS
Gadget: concept { color: "red", count: 5 }
Foo: concept FooBar { rate: 0.05 } # explicit RHS name wins; Foo aliases FooBar
Casing rule (enforced): the inferred name must start with an uppercase letter — the same
isValidConceptNamecheck every other concept-creation surface uses.tc: concept { ... }errors withconcept name 'tc' should start with an uppercase letter. UseTC: concept { ... }. The bare-RHS form is also the only form that infers a name; anonymousconcept { ... }literals nested inside a call argument or array element error cleanly at eval time.
Creating concepts — the canonical surface. A concept
is created with the keyword-first concept form. There is no
create-prefixed, postfix, or is Concept
creation form:
concept Stock # bare
concept Stock "doc" # with a doc string
concept/persist Stock "doc" # with a /persist | /transient | /system refinement
concept Stock { price: 0 } # block form with slot defaults
concept FinKB.Stock "doc" # namespaced
x: a Stock # instance creation, via the indefinite article
y: a Stock with price: 150 # the same instance; provisional spelling of `a Stock {price: 150}`
Stock extends Asset # class inclusion ⊆
Stock has price: 150 # property (auto-creates Stock on first verb use)
Stock has ticker: "", sector: "" # several properties in one statement, comma-separated
Stock has volume :: Integer # a typed property, no default: starts as none
Stock has fee :: Float: 2 # a typed property with a default; stores 2.0
Stock had price # remove a property declared with has
One has statement may declare several properties,
separated by commas, and the list may continue on the next line after a
comma. Each value is an ordinary expression, so
Calculator has sum: 2 + 3, product: 4 * 5 stores 5 and
20.
Typed slots. A slot may carry a type annotation in
every declaration surface — the block form, the with … end
body and the has verb — and the annotation is a contract,
not a comment. The type is recorded on the concept and checked on every
write of that slot: the instance block at creation, dot assignment, a
{e with …} record update and a later concept-level default.
A refused write reports the slot and the concept and leaves the old
value in place. An Integer converts for a Float slot, as at every other
:: site. A typed slot without a default starts as
none. The type travels the same live slot chain as the slot
itself, so a type added to the parent later reaches the child and its
instances. show properties prints the type after the
default. An untyped slot still takes any value, and a typed concept is
not a closed record — has may add slots later; use
struct for a closed record.
concept Measure { ratio :: Float: 1.0, label: "free" }
type Person = concept with
name :: String: ""
age :: Integer # no default: starts as none
end
let ada = a Person {name: "Ada", age: 36}
ada.age = "old" # error: slot age of concept Person expects type Integer, got String
(a Measure {ratio: 3}).ratio # 3.0 — Integer converts for a Float slot
(a Person {}).age # none
Person has name: 5 # error: a later default is checked against the type
Measure show properties # ratio: 1.0 :: Float, label: "free"
--vm refuses a typed slot rather than compile instances
the evaluator would refuse; the untyped forms compile as before.
is is a predication operator, never a
creation one — using it to create a concept would conflate two speech
acts (constitutive declaration vs. descriptive predication, the
distinction Russell draws on the copula). So aapl is Stock
classifies an instance, X is Concept (in expression
position) asks "is this a registered concept?", and a statement-level
Stock is Concept is a parser error pointing at
concept Stock.
Define-family form (define concept) —
fills the typed-define slot left open in the existing family of
define axiom / define postulate /
define theorem / define word /
define dialect:
define concept Stock = { # `=` assignment
price: 0,
ticker: ""
}
define concept Bond: { yield: 0.05 } # `:` assignment
define concept Scratch = {} # empty body — bare creation
define/persist concept Pinned = { x: 1 } # refinement: force-persist
define/transient concept Tmp = { x: 1 } # refinement: skip persistence
Syntactically parallel to define dialect: uppercase
name, { ... } body parsed as a hash literal. The
implementation synthesizes an internal ConceptCreation AST
and delegates to the canonical creation pipeline, so every
formation-layer feature (boundary capture, examples auto-classification,
formed_by cross-map, default-grounding cross-map, KB
persistence) carries through transparently. Behaves identically to the
equivalent concept Stock { ... } on every dimension except
syntax.
Mapping to the rest of the family:
| Surface | Speech act | Body shape | Examples in this manual |
|---|---|---|---|
define axiom |
KB claim | bracketed expression | "axiom" section |
define postulate |
provisional claim | bracketed expression | "postulate" section |
define theorem |
derived claim | bracketed expression | "theorem" section |
define word |
Lojban/NSM word | block of slots [...] |
"words" section |
define dialect |
DSL registration | array or {cases:..., vocabulary:...} |
"dialects" section |
define concept |
concept declaration | hash literal { slot: value, ... } |
this section |
v1 limitations:
- Inheritance: no
extends/isslot inline. Follow up withStock is Asseton the next line, or useconcept Stock extends Asset { ... }if you need it co-located. - Defeasible boundary: the
~suffix on hash-literal keys is not parsed (define concept Sage = { boundary~: ... }will fail). Use the canonicalconcept Sage { boundary~: ... }block form, or follow up with the statement-levelSage defines~ { ... }rule. - Namespaced names:
define concept FinancialKB.Stock = ...is not supported (thedefineparser produces a flat[]*Identifiersymbol list). Use the prefixconcept FinancialKB.Stock { ... }for namespaced concepts.
Tests: tests/axioma/concepts/test_define_concept.ax
(happy paths) and the three matching _reject files
(lowercase, /unpersist, non-hash body).
Concept introspection at every level — what
is checks depends on syntactic position:
<Name> is Conceptat statement level (TitleCase LHS) → parser error (useconcept X [doc] [refinement]to create a concept)<Name> is <Parent>→ declares a specialization (class-inclusion ⊆)<instance> is <Concept>→ classifies the instance (membership ∈) — canonical<expr> is <C>in expression position → Boolean predication query — canonical (includingX is Conceptas a "is this a registered concept?" query)
Inheritance
concept Animal
Dog extends Animal
Dog has bark: lambda => println("Woof!")
Inheritance is live. Dog extends Animal records the link
and copies nothing: a child reads its parent's slots when they are used,
so a slot added to Animal after the extends
reaches Dog and its later instances, a changed parent
default is seen, a removed parent slot leaves, and a child's own slot
shadows the parent's. That is the same chain Dog is Animal
walks, so membership and slots agree. With several parents the
first-declared parent wins a name clash. Dog had name
refuses while name is only inherited; remove it on the
declaring concept, or shadow it with Dog has name: ….
Individuals copy their concept's effective slots when they are created,
so an instance made before a slot was declared does not carry it.
concept Animal
Dog extends Animal
Animal has legs: 4 # declared after the extends
(a Dog {}).legs # → 4
Dog show properties # legs: 4 (from Animal)
Concept formation layer (Phase 1)
Three slot names on a concept carry first-class
concept-design semantics. They turn
concept Foo { ... } from a plain class declaration into a
contract that pairs prose intent with executable regression tests over
the extent.
concept CFP_Choice # base concept for entities
positive_a: a CFP_Choice {}
positive_b: a CFP_Choice {}
negative_a: a CFP_Choice {}
concept DecisionFatigue
df1: a DecisionFatigue {}
df2: a DecisionFatigue {}
DecisionFatigue has purpose: "Explain degraded choices after repeated decisions"
DecisionFatigue has examples: [df1, df2]
DecisionFatigue has counterexamples: [negative_a]
check DecisionFatigue # → ⊤ᵇ (contract holds)
The four reserved slot names:
| Slot | Semantics |
|---|---|
purpose: |
Prose statement of why the concept exists. Pure metadata, queryable
as Concept.purpose. |
examples: |
Array of ConcreteEntitys that MUST be classified
positively. |
counterexamples: |
Array of ConcreteEntitys that MUST NOT be classified
positively. |
formed_by: |
Closed enum naming the creation mode. Validated at creation time (Phase 2a). |
The formed_by: enum accepts five string values. As of
Phase 2b-1 the cross-map is automatically derived into a
default_grounding slot at concept-creation time, and as of
Phase 2b-2 the derived grounding is actively applied to stored
is-facts at instance-creation and classification time:
formed_by: |
Meaning | Default grounding |
|---|---|---|
"abstraction" |
pattern extracted from multiple observed cases | conjecture (inductive) |
"combination" |
concept synthesized from existing concepts | theorem (derivable) |
"distinction" |
concept split out of a broader one | theorem (derivable from parent) |
"stipulation" |
concept defined by fiat for a purpose | axiom (definitional) |
"metaphor" |
concept formed by mapping one domain onto another | hypothesis (cross-domain) |
concept AlgebraicGroup {
purpose: "Algebraic structure with associative binary op, identity, and inverses"
formed_by: "stipulation"
}
concept Smartphone {
purpose: "Phone + computer + camera + internet, unified"
formed_by: "combination"
}
Invalid values are rejected at creation time:
ERROR: concept 'X': formed_by = "stipulashun" is not a recognized creation mode.
Use one of: abstraction, combination, distinction, stipulation, metaphor
The contract is verified by
check Concept (the existing
automated-reasoning check keyword, specialized for Concept
targets). It returns a Belnap B4 value:
| Result | Meaning |
|---|---|
T (⊤ᵇ) |
Every example classifies positive; no counterexample classifies positive |
F (⊥ᵇ) |
At least one example missing from the extent, or at least one counterexample wrongly classified |
Both (⊤⊥ᵇ) |
The SAME entity appears in both lists (paraconsistent declaration) |
Neither (?ᵇ) |
No examples and no counterexamples — contract vacuous |
check on a non-Concept target falls through to the
legacy AutomatedReasoningObject wrapper
(check 42 still returns the generic consistency-check
string).
Phase 2b-2 adds the boundary: slot and
the active application of default_grounding. A
boundary: value is a predicate, not an open
expression — it captures the AST before the property eval loop runs and
registers it as a defines classifier scoped to the
concept:
concept P_Person
P_Person has age: 0
P_Person has name: ""
concept P_Adult {
formed_by: "stipulation" # → default_grounding: "axiom"
boundary: age >= 18 and is P_Person
}
alice: a P_Person {name: "Alice", age: 30}
println(alice is P_Adult) # → true (auto-classified)
println(grounding("isa", alice, P_Adult)) # → "axiom"
The stored is-fact inherits axiom grounding (not the
strict-defines default of theorem) because
Stipulation-formed concepts cross-map to axiom. The same
active-grounding flow fires at object-instantiation time:
concept P_Stock { formed_by: "stipulation" } # default_grounding: "axiom"
aapl: a P_Stock {}
println(grounding("isa", aapl, P_Stock)) # → "axiom"
The instance-creation is-fact is gated: a concept
without formed_by: (or explicit
default_grounding:) keeps the pre-2b-2 behavior — the
instance is registered in concept.Instances for
comprehension iteration, but no synthetic is-fact is stored. This
preserves the legacy corpus verbatim.
Phase 3 closes the loop on the Phase 1 contract by
making examples: load-bearing. When the concept has opted
into the formation layer (default_grounding set, either via
formed_by: cross-map or explicitly), the
examples: slot is treated two different ways depending on
whether a boundary: predicate is present:
No boundary — examples become the extent declaration. Each entity in the list is force-classified as a member of the concept, at the inherited
default_grounding. Useful for small enumerated concepts that are easier to list than to describe with a rule:concept VIP { formed_by: "stipulation" # → default_grounding: "axiom" examples: [alice, carol] # auto-classified as VIPs } println(alice is VIP) # → true println(grounding("isa", alice, VIP)) # → "axiom" println(check(VIP)) # → ⊤ᵇ (trivially)With boundary — the boundary owns membership; examples become test cases for the boundary.
check Conceptverifies the boundary classifies every listed example and rejects every counterexample:concept Adult { formed_by: "stipulation" boundary: age >= 18 and is Person examples: [alice, carol] # both ≥ 18 → boundary agrees counterexamples: [bobby] # < 18 → boundary correctly rejects } println(check(Adult)) # → ⊤ᵇ (boundary agrees with examples) concept AdultBad { formed_by: "stipulation" boundary: age >= 18 and is Person examples: [dave] # dave is 8 — boundary disagrees! } println(check(AdultBad)) # → ⊥ᵇ (example missing from extent)
Counterexamples are never auto-classified — there's no opponent rule
to push against. They still participate in check's negative
verification (no counterexample may be in the extent).
Phase 4 adds the defeasible
boundary form: boundary~:. The tilde parallels the
statement-level defines~ and marks the membership rule as
defeasible — classifications derived from it cap at conjecture-grade,
even on a stipulation-formed (axiom) concept. This expresses a
coherent two-level epistemic stance: the concept itself is definitional,
but specific memberships derived from an uncertain rule remain
tentative.
concept Sage {
formed_by: "stipulation" # concept is axiom-grade
boundary~: age >= 65 and wisdom >= 70 and is Person
}
alice: a Person {age: 70, wisdom: 80}
println(alice is Sage) # → true
println(grounding("isa", alice, Sage)) # → "conjecture" (cap fires)
The cap rule: defeasibility lowers grounding to
conjecture when the concept's
default_grounding is stronger (axiom, postulate, theorem).
When already weaker (hypothesis, datum), the value passes through
unchanged. Declaring both boundary: and
boundary~: on the same concept is rejected at creation
time.
See tests/axioma/concepts/test_concept_formation_phase1.ax for the contract test matrix, tests/axioma/concepts/test_concept_formation_phase2b2.ax for the boundary + active-grounding tests, tests/axioma/concepts/test_concept_formation_phase3_examples.ax for the examples-auto-classification tests, and tests/axioma/concepts/test_concept_formation_phase4_defeasible_boundary.ax for the defeasible-boundary tests.
Phase 5 surfaces the formation layer as queryable
data via the concepts_formed_by(mode_string) builtin.
Returns an array of every concept whose formed_by: slot
equals the given mode, validating the mode against the same enum used at
creation time:
concept AlgebraicGroup { formed_by: "stipulation" }
concept Group { formed_by: "stipulation" }
concept Penguin { formed_by: "distinction" }
len(concepts_formed_by("stipulation")) # → 2
stips: concepts_formed_by("stipulation")
println(stips[1].formed_by) # → "stipulation"
concepts_formed_by("stipulashun") # → ERROR (typo rejected,
# same enum as Phase 2a)
The return value is a plain Array<Concept>, so it
composes with comprehensions, len, and the rest of the
array vocabulary. Useful for KB audits and for tooling that wants to
render formation-layer choices.
Instances
usa: a Country {}
china: a Country {}
alice: a Person {name: "Alice", age: 30}
Property access
alice.name # Dot
alice's name # Possessive
alice?.name # Safe navigation — a `none`/`om` receiver
# propagates instead of faulting (§29)
alice[name -> "Bob"] # ErgoAI frame-form (returns "Bob")
is predicate
alice is Person # true
{X | X is Country} # All instances of Country
{X | X is Country, X.gdp > 20000} # With filter
Cardinality constraints
cardinality(Country, "capital", 1, 1) # Exactly one capital per country
Concept introspection
Stock show properties
Concept display hierarchy
Concept lifecycle
— suspend, unsuspend,
destroy
Three postfix verbs take a concept out of scope. They differ in whether the removal can be undone.
concept Gadget { price: 0 }
g1: a Gadget {}
Gadget suspend # the NAME stops resolving
a Gadget {} # → ERROR: concept not found: Gadget
Gadget unsuspend # …and comes back
g2: a Gadget {price: 9} # works again
g1 is Gadget # → true — the old instance never stopped being one
suspend is reversible: the concept is withdrawn from
scope, instances made before it survive untouched, and
unsuspend restores the name with its slots intact.
unfreeze is accepted in the same slot as a synonym of
unsuspend.
destroy is not a suspension:
concept Widget
Widget destroy
Widget unsuspend # → ERROR: concept 'Widget' is not suspended
Use suspend to take a concept out of play while keeping
the option of bringing it back — shadowing an imported name for one
section of a script, or proving that nothing downstream depends on it.
Use destroy when the concept should be gone.
KM-style surface syntax
Four ergonomic forms adopted from Peter Clark & Bruce Porter's KM
2.0 (The Knowledge Machine). Each composes with the existing
concept system above and adds no new semantic machinery — they are
surface forms over the canonical ObjectInstantiation AST
node, concept-level has, and the existing it
keyword.
Instances are created with the natural-language
a/an/entityforms (a Stock {},an Item {},entity Gadget {}) — all three produce the same AST and runtime value. There is noobjectkeyword.
The brace block is the canonical slot list. As a provisional dual
spelling, the same slots may follow with, the word that
already opens a concept body and takes the name: value list
has takes:
ada: an Artist with name: "Ada", city: "London" # ≡ an Artist {name: "Ada", city: "London"}
bee: entity Insect with legs: 6,
wings: 4 # the list may continue after a comma
The first slot sits on the with line, so a bare
with never reads the next statement as slots;
an Artist with alone refuses. Both spellings build the same
instance: defaults fill omitted slots, an undeclared slot refuses, and
the entity joins the concept's extent. Design note and retire path:
resources/docs/claude/ENTITY_WITH_SPELLING_DESIGN.md.
Indefinite-article instance literals —
a Concept {props} and an Concept {props} are
the canonical instance-creation expressions. The property block is
optional. The article is a soft keyword: it only triggers when
followed by a capitalized identifier (Axioma's concept convention), so
variables named a or an continue to work.
mycar: a Car {make: "Toyota", price: 26000}
cat: an Animal {species: "Felis catus"}
cheap: a Car {} # empty-property form
dyn: (a Car {price: 99999}).price # usable mid-expression
fleet: [a Car {price: 10000}, a Car {price: 20000}]
it anaphora — every fresh instance
binds it in the surrounding scope, so subsequent statements
can refer back to it without naming. Method-self semantics (when
it is bound inside an object action) take precedence; the
global binding only fires between consecutive top-level statements.
a Car {make: "Toyota", price: 26000}
println(it.make) # Toyota
mycar: it # capture by name
a Car {make: "Honda"} # rebinds it
println(it.make) # Honda
println(mycar.make) # Toyota (unchanged)
every Concept has prop: val —
universal-slot quantifier; sugar for the existing concept-level
has. Useful when emphasizing intent in literate-style
scripts.
every Car has wheels: 4
every Car has make: ""
c: a Car {make: "Toyota"}
println(c.wheels) # 4 (inherited default)
what is X? /
what is X's Y? — interrogative queries. Evaluates
the operand, pretty-prints <source> is <value>,
and returns the value (so it can be assigned). The trailing
? is required. Slot access via either possessive
('s) or dot notation is accepted.
mycar: a Car {make: "Toyota", price: 26000}
what is mycar? # → mycar is a Car {make: "Toyota", price: 26000}
what is mycar's price? # → mycar's price is 26000
what is mycar.make? # → mycar.make is "Toyota"
KM influence note: Axioma already implements most of KM's deep semantic machinery (frames, inheritance, situations via hypothetical contexts, defaults via defeasible rules, reification, persistence) and goes beyond it on truth representation (B4 bilattice, six-level epistemic grounding, five logic kinds with automatic dispatch). The natural-language surface forms above bring KM's ergonomics in alongside that deeper machinery. See tests/axioma/km/ for the complete test set.
Coreference merging —
unify x with y
Collapses two named entities into one canonical entity with the union
of their slot values. Solves entity resolution — when "Acme Inc." and
"Acme Industries" turn out to be the same company, unify
merges them in place. Composes with B4 paraconsistent truth, six-level
grounding, and atomic transactions.
concept CelestialBody
morning_star: a CelestialBody {visible_at: "dawn"}
evening_star: a CelestialBody {visible_at: "dusk"}
unify morning_star with evening_star # Russell's classical case
println(morning_star == evening_star) # → true
Defeasible: unify~ x with y records the
merge with grounding conjecture (cancellable later).
B4 paraconsistent slot merging: conflicting
single-valued slots keep the canonical's value and record a
Both truth marker. Multi-valued slots get set-unioned.
Transaction-safe: unify inside
transaction_begin() / transaction_rollback()
is reversible — both entities' pre-merge slots and identity are
restored.
Intensional
class descriptions — the Concept where <pred>
A Russell-style definite description with restrictor (KM §18.2).
the Stock where price > 1000 denotes the anonymous class
of Stocks whose price exceeds 1000. Composes with is,
comprehensions, and named classes.
concept Stock
Stock has price: 0
luxury: a Stock {price: 80000}
big: the Stock where price > 1000
println(luxury is big) # → true
# Inline membership test
println(luxury is (the Stock where price > 50000)) # → true
# Comprehension over the implicit extent
bigs: {X | X is (the Stock where price > 1000)}
Bare slot names in the predicate resolve to
it.<slot> (same convention as defines
predicate bodies). The intensional class is transient —
it's not registered in the KB, so it adds no permanent vocabulary; use
it for one-off queries or as a slot value when you want to denote a
class without naming it.
Partitions and subsumption
Concept partition Member1, Member2, … declares the
listed subclasses as mutually exclusive. An instance
can be a member of at most one. The disjointness is enforced by
auto-classification — when a defines predicate would
classify an instance into a partition member that conflicts with an
existing membership, the new classification gets the Belnap
both truth value (paraconsistent — no crash).
concept Animal
Bird extends Animal
Mammal extends Animal
Fish extends Animal
Animal partition Bird, Mammal, Fish
robin: a Bird {}
println(robin is Mammal) # → false (disjoint)
A subsumes B is the class-class subsumption infix
(Russell class- inclusion, KM §18.3) — true when A is a superclass of
B.
Animal subsumes Bird # → true
Concept subsumes Bird # → true
Bird subsumes Mammal # → false
partitions_of(Concept) returns the declared partition
tuples on a concept.
Querying a partition's live extent
Four builtins make the partition laws — disjointness and exhaustiveness — queryable over a concept's actual instances, returning witness instances (not just a verdict) when a law fails:
concept Thing
UpToUs extends Thing
NotUpToUs extends Thing
Thing partition UpToUs, NotUpToUs
opinion: an UpToUs {}
weather: a NotUpToUs {}
is_partitioned(Thing) # → true (disjoint AND exhaustive over the extent)
partition_overlap(Thing) # → {} (instances in >1 part — empty ⇔ disjoint)
partition_gap(Thing) # → {} (extent instances in 0 parts — empty ⇔ total)
partition_member(opinion, Thing) # → UpToUs (the part it falls into, or `none`)
mystery: a Thing {} # a bare Thing — in neither part
is_partitioned(Thing) # → false (exhaustiveness now fails)
mystery in partition_gap(Thing) # → true (the gap WITNESS)
This models Epictetus' dichotomy of control as a real partition rather than two hand-rolled relations. Each verdict is open-world — a statement about the facts seen so far; an empty extent is vacuously partitioned.
Description
Logic — concept algebra ⊓ ⊔ ¬ ⊑ ≡ +
satisfiable
Concepts compose into compound concept expressions
with the DL operators, and a built-in tableau reasoner answers
subsumption, equivalence, and satisfiability questions about them.
⊓ (and) and ⊔ (or) and ¬ (not)
build a first-class ConceptExpr value; ⊑,
≡, and satisfiable(...) decide.
concept Person
concept Student
concept Employee
Student extends Person
Employee extends Person
ws: Student ⊓ Employee # a compound concept (intersection)
@ws # → "ConceptExpr"
Student ⊑ Person # → true (subsumption: left is the SUBconcept)
Person ⊑ Student # → false
(Student ⊓ Employee) ⊑ Person # → true (tableau derives it from `extends`)
Student ⊑ (Student ⊔ Employee) # → true
Student ≡ Employee # → false (equivalence = mutual subsumption)
(Student ⊓ Employee) ≡ (Employee ⊓ Student) # → true
satisfiable(Student ⊓ Employee) # → true
satisfiable(Student ⊓ ¬Student) # → false (the complement clashes)
⊑ vs subsumes — converse
readings. The glyph ⊑ reads in the standard DL
direction (C ⊑ D means C is the more specific
subconcept — Student ⊑ Person is true). The English word
subsumes reads the other way (A subsumes B
means A is the more general superclass —
Person subsumes Student is true). They are converses of the
same relation; pick whichever reads naturally.
| Operator | Glyph | ASCII digraph | Word form | Result |
|---|---|---|---|---|
| conjunction | C ⊓ D |
C `sqcap D |
C and/concept D |
ConceptExpr |
| disjunction | C ⊔ D |
C `sqcup D |
C or/concept D |
ConceptExpr |
| complement | ¬C |
— | not C |
ConceptExpr |
| subsumption | C ⊑ D |
C `sqsubseteq D |
D subsumes C |
true/false |
| equivalence | C ≡ D |
— | — | true/false |
¬ / not is type-dispatched: applied to a
concept it builds the complement; applied to a boolean it is ordinary
logical negation, unchanged. satisfiable reasons over the
TBox assembled from extends declarations; it is for
concepts — boolean/propositional formulas use the separate
is_satisfiable.
The reasoner is the ALC tableau in reasoner/.
Role restrictions, partitions, and extensional membership
A role restriction quantifies a concept over a
binary relation (a role). Use the COLON form
∃role: Concept (some filler is a Concept) and
∀role: Concept (every filler is a Concept);
the role is a native relation.
concept Doctor
concept Cardiologist
Cardiologist extends Doctor
relation hasChild(x, y)
g: ∃hasChild: Doctor # a ConceptExpr (prints ∃hasChild.Doctor)
satisfiable((∃hasChild: Doctor) ⊓ (∀hasChild: ¬Doctor)) # → false (a filler can't be C and ¬C)
(∃hasChild: Cardiologist) ⊑ (∃hasChild: Doctor) # → true (reasons through the role)
(¬(∃hasChild: Doctor)) ≡ (∀hasChild: ¬Doctor) # → true (quantifier duality)
A declared partition now becomes real logic:
C partition A, B, … makes the members pairwise disjoint and
jointly exhaustive of C.
concept Animal
concept Cat
concept Dog
Cat extends Animal
Dog extends Animal
Animal partition Cat, Dog
satisfiable(Cat ⊓ Dog) # → false (disjoint)
(Cat ⊔ Dog) ≡ Animal # → true (covering)
⊑, ≡, and satisfiable answer
open-world (over declared axioms). Extensional
membership x is <ConceptExpr> answers
closed-world, over the instances and relation facts
actually present:
felix: a Cat {}
felix is (Cat ⊓ ¬Dog) # → true
{p | p <- Animal, p is ¬Dog} # compound/role concepts in a comprehension FILTER
dora: a Doctor {}
mary: a Cat {} # placeholder unrelated entity
hasChild(felix, dora) # role fillers should be ENTITIES
felix is (∃hasChild: Doctor) # → true (some child is a Doctor)
felix is (∀hasChild: Doctor) # → true (every child is a Doctor)
(Role fillers must be entities to carry concept membership — a bare string filler is not an instance of any concept.)
Defined concepts, ⊤/⊥, and more spellings
A defined concept gives a concept a
necessary-and-sufficient body with concept X ≡ <expr>
(the word equivalent works too). The definition is a
genuine axiom in both directions, so subsumptions that follow from it
decide, and membership is read off the body:
concept Person
concept Doctor
Doctor extends Person # so a Doctor is a Person
relation hasChild(x, y)
concept Parent ≡ Person ⊓ (∃hasChild: Person)
Parent ⊑ Person # → true (from the definition)
alice: a Person {}
dora: a Doctor {}
hasChild(alice, dora)
alice is Parent # → true (a Person with a Person child)
Thing (⊤) and Nothing (⊥) are built in —
the universal and bottom concepts, available without declaration (you
may still concept Thing "..."; it is an idempotent alias).
Every concept satisfies C ⊑ Thing and
Nothing ⊑ C; satisfiable(Thing) is true and
satisfiable(Nothing) is false; extensionally every
individual is a Thing and none is a Nothing. A
partition of Thing therefore says the universe is exactly
covered:
concept UpToUs
concept NotUpToUs
UpToUs extends Thing
NotUpToUs extends Thing
Thing partition UpToUs, NotUpToUs
(UpToUs ⊔ NotUpToUs) ≡ Thing # → true (the dichotomy is total)
Role restrictions have three interchangeable spellings — the COLON form above, the textbook DOT form, and the OWL Manchester words (role on the left):
(∃hasChild.Doctor) ≡ (∃hasChild: Doctor) # DOT
(hasChild some Doctor) ≡ (∃hasChild: Doctor) # Manchester ∃
(hasChild only Doctor) ≡ (∀hasChild: Doctor) # Manchester ∀
(The DOT form never disturbs the symbolic quantifier
∀x. flies(x), and some/only stay
ordinary words outside this position — some S is P
categorical syntax and an only: binding are untouched.)
A compound concept can also drive a comprehension source for the ⊓/⊔ cases:
{x | x <- (Person ⊓ ¬Doctor)} # the persons who aren't doctors
A bare ¬C / ∃R.C / ∀R.C /
Thing source has no finite extent without a domain
universe, so it is a clean error pointing you at FILTER position
({x | x <- C, x is <expr>}); those
universe-bearing sources are deferred.
The older dl_kb(...) family of builtins remains for
explicit string-keyed knowledge bases. DL operators evaluate in the
tree-walking interpreter (not under --vm, which does not
compile concept declarations).
Enumerated types — ordinal closed sets
An enum is a sealed, ordered set of named constants.
It is a DataType (under Concept), not an
open domain concept. One semantics, two spellings (not
three — ML unit-data is not auto-enum):
| Spelling | Style |
|---|---|
Day enumerates Mon, Tue, … |
Natural language (canonical) |
enum Day = Mon, Tue, … |
Keyword-first dual |
Both lower to the same machinery. Prefer enumerates in
prose and tutorials; use enum Day = … in dense /
mathematical code. type Day = (Mon, Tue) is
not an enum — that spelling is a product synonym
(type Coord = (Float, Float)).
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
# equivalent:
# enum Day = Mon, Tue, Wed, Thu, Fri, Sat, Sun
println(Mon) # Mon (inspect renders the bare name)
println(Mon.ord) # 0
println(Day.Mon == Mon) # true (shared identity)
println(succ(Wed)) # Thu (succ/pred also step Integers: succ(5) → 6)
println(pred(Fri)) # Thu
println(Day.first) # Mon
println(Day.last) # Sun
println(len(Day)) # 7
Day is DataType # true
# Ordered comparison uses ord:
println(Mon < Fri) # true
# Iteration walks members in declaration order:
foreach d in Day [
println(d.name)
]
# Strict cross-enum (Pascal/Ada-style):
Color enumerates Red, Green, Blue
# Mon < Red # ERROR: cannot compare Day and Color
Each member is a ConcreteEntity of the enum with
ord (0-based) and name. Bound bare
(Mon) and qualified (Day.Mon). Built-ins:
succ, pred, len,
Day.first, Day.last. Cross-enum comparison
errors cleanly. Member-name collisions across enums fail at declaration
— qualify to disambiguate. Soft-keyword note: type: 99
still binds the name type. The forms
enum CapName = (enum) and
type CapName = <typeexpr> (synonym) are declaration
gates only. type: 99 and enum: 99 still bind
those names.
Type synonyms —
type Age = Integer
Transparent programming type names under DataType
(not erased like TypeScript aliases; not the same as alias,
which skins without a new DataType object):
type Age = Integer
type Id = String | Integer
type Mode = "r" | "w"
type Coord = (Float, Float) # product — same as Tuple of (Float, Float)
5 is Age # true
Age is Integer # true
Age is DataType # true
let a :: Age = 40 # ok
let id :: Id = "x" # ok
(3.5, 4.6) is Coord # true
let p :: Coord = (0.0, 1.0)
let q :: (Integer, Boolean) = (1, true)
The name is TitleCase.
type scoreType = Integer is a SyntaxError
(type names are TitleCase; write type ScoreType = ...), not
a stray =. type: 7 still binds the word
type.
Union arms must name known types.
type Id = String | Integer is the designed form.
type ShirtSize = Small | Medium | Large | XLarge is an
error (Small is not a type) — that spelling does not create
constructors. Write
enum ShirtSize = Small, Medium, Large, XLarge or
ShirtSize enumerates Small, Medium, Large, XLarge or
data ShirtSize = Small | Medium | Large | XLarge.
Structural
dictionary types — type Poet = { … }
A schema over Dictionary values (TS object-type shapes), still a DataType — not a domain Concept and not an entity class:
type Poet = { born :: Integer, name :: String }
type Book = { pages :: Integer, author? :: String } # optional key
{born: 1935, name: "Mary"} is Poet # true
{born: 1935} is Poet # false — missing name
{born: 1, name: "x", extra: 9} is Poet # true for `is` (structural extras OK)
let p :: Poet = {born: 1935, name: "Mary Oliver"} # ok
# let bad :: Poet = {born: 1, name: "x", z: 0} # ERROR — excess on *fresh* literal
h: {born: 1, name: "x", z: 0}
let q :: Poet = h # ok — existing dict may carry extras
{pages: 80} is Book # true (author optional)
Self-recursive schemas
A dictionary schema may refer to its own name:
type TreeRecord = { value :: Integer, children :: Array of TreeRecord }
let tree :: TreeRecord = {value: 1, children: [{value: 2, children: []}]}
Other referenced types must already be declared; forward references, mutual recursion, and bare alias cycles remain unsupported. Cyclic dictionaries are accepted only when all reachable field checks pass; revisiting the same value/schema pair terminates that branch. Schema stamping also follows nested containers and terminates on cycles. Named schemas remain evaluator-only.
& is not a schema intersection operator: type
expressions using it are rejected with a diagnostic. Write the combined
fields explicitly.
Field access: open map vs schema-stamped
| Receiver | Missing / unknown field |
|---|---|
| Untyped Dictionary (open map) | Soft → none (JS-style) |
Schema-stamped dict (let p :: Poet = …
or (d :: Poet)) |
Hard error if the key is not in the schema |
Optional field absent (author?: String) |
none (declared optional) |
Entity (a Person {…}) |
Hard error (unchanged) |
open: {name: "Peter"}
open.age # none
let p :: Poet = {born: 1935, name: "Mary"}
p.name # "Mary"
p.missing # ERROR — not declared on schema
Under --typecheck, a hash used with a
dict-schema annotation or hash is Poet may mix field value
types (records are heterogeneous). An untargeted open map still follows
Map of K to V homogeneity.
How to choose:
concept Dog vs type Poet
Decision tree
- Are the values individuals of a kind (with
identity, slots on a class, inheritance, KB/F-logic)? →
concept Dog, thena Dog { … }entities. - Are the values plain field bags (JSON/API/config
shapes, TS object types)? →
type Poet = { field :: T, … }over Dictionaries. - Do you need variants with payloads (tagged
alternatives)? →
dataADTs +match, not dict schemas and not concepts. - Only renaming a type? →
type Age = Integeroralias.
concept Dog |
type Poet = { born :: Integer, name :: String } |
|
|---|---|---|
| Role | Domain kind (knowledge / classification) | Programming hash schema |
| Values | Entities (a Dog { name: "Fido" }) |
Dictionaries
({born: 1935, name: "…"}) |
is |
Instance / inheritance / extent | Required fields + field types |
| Extra keys | Allowed as free slots | is: allowed; ::
fresh {…}: rejected (typo guard) |
| Optional | Slot defaults / absence | author?: String (key may be missing) |
| Extent / KB | Yes | No |
| Parent | Often Concept (domain tree) | DataType ⊂ Concept |
Do use concept Person for people as
individuals in a model.
Do use
type Address = { city :: String, zip :: String } for
structured data bags.
Do not use type Poet = {…} to invent a
kind of living thing.
Do not use concept only to type-check a
hash — that creates entities and KR surface you do not need.
alias Int = Integer remains for general renaming
(including operators). Prefer type when introducing a
programming type name you want as a first-class
DataType; prefer alias for skins and non-type renames.
Opaque newtypes —
type Age = opaque Integer
A newtype is a distinct programming type over an
underlying type. Membership does not expand: bare
values of the underlying type are not members. Construct by calling the
type name; extract with match.
type Age = opaque Integer
5 is Age # false
Age is Integer # false
Age is DataType # true
a: Age(42) # wrap; underlying type checked at construction
a is Age # true
a is Integer # false
match a with | Age(n) => n # → 42
let b :: Age = Age(7) # ok
# let bad :: Age = 1 # type error — bare Integer refused
Union bodies work the same way:
type Id = opaque String | Integer
Id(5) is Id # true
Id("x") is Id # true
5 is Id # false
Use transparent type Age = Integer when you want a named
alias that still accepts bare integers. Use opaque when you want Age and
Integer to stay distinct at every membership and ::
boundary (Haskell-style newtype). Soft word opaque is not
reserved: opaque: 1 still binds.
Tests: tests/axioma/types/test_enum.ax,
tests/axioma/types/test_enum_keyword_form.ax,
tests/axioma/types/test_enum_type_pascal_form.ax (product
reading), tests/axioma/types/test_type_synonym.ax,
tests/axioma/types/test_type_opaque.ax. Design:
resources/docs/claude/TYPE_DATA_ENUM_FAMILY.md.
The type grammar
— one language in every :: slot
A type is written the same way everywhere ::
appears:
type := union ( '->' union )*
union := atom ( '|' atom )*
atom := TitleCaseName ( '.' Name )* | range | literal
literal := integer | string | true | false # not float
So a union, a subrange, a qualified name, a singleton literal, and a function arrow are all legal in every annotation position — binding, parameter, function return, lambda return, equation return, concept slot, relation argument, ascription:
u :: Integer | String: 5 # binding (categories)
mode :: "r" | "w": "r" # singleton string union
n :: 5: 5 # singleton integer
flag :: true: true # singleton boolean
f: func(x :: Integer | String) [x] # parameter
open: func(m :: "r" | "w") [m] # literal-union parameter
g: func(x) :: Integer | String [x] # function return
h: lambda (x) :: 0..9 => x # lambda return
k(x) :: Integer | Float = x * x # equation return
Holder has label :: Integer | String # concept slot
relation tagged(x :: Integer | String)
(5 :: Integer | String) # ascription
# x :: 26.218: 26.218 # refused — no float literal types
Literal annotations are enforced at runtime and by
--typecheck. A value satisfies :: 5 only if it
equals 5 (same equality as ==). Prefer
== "r" (not bare is "r") when branching on a
literal union under --typecheck.
Word-form container types — Array of T,
Map of K to V. Annotations may spell element (and
map key/value) types without square-bracket type application (body
[…] stays a body only):
xs :: Array of String: ["a", "b"]
m :: Dictionary of String to Integer: {count: 1}
grid :: Array of (Array of Integer): [[1, 2], [3, 4]]
# Fixed-arity heterogeneous product (use a tuple value, not an array)
pair :: Tuple of (Integer, Boolean): (1, true)
pair2 :: (Integer, Boolean): (1, true) # same product
# pair :: Tuple of (Integer, Boolean): (true, 1) # ERROR: slots swapped
# pair :: Tuple of (Integer, Boolean): [1, true] # ERROR: Array is not a Tuple
hom :: Tuple of Integer: (1, 2, 3) # homogeneous: every element Integer
# of binds tighter than -> ⇒ (Array of Integer) -> String
summarize :: Array of Integer -> String
summarize(xs) = str(len(xs))
# Nest function types as elements with parentheses — structural check
# (arity; declared param/return types when present)
handlers :: Array of (Integer -> Integer): [(n) => n + 1]
# handlers :: Array of (Integer -> Integer): [() => 1] # ERROR: wrong arity
# Nullary function types use () only as the parameter marker before ->
answer :: () -> Integer
answer() = 42
# TS (() => string)[] → Array of (() -> String)
stringCreators :: Array of (() -> String): [() => "hi", () => "lo"]
# TS () => string[] → () -> Array of String
createStrings :: () -> Array of String
createStrings() = [["a", "b"]]
Unary of: Array, List, Set, Bag, and homogeneous
Tuple of T. Map form: Map / Dictionary / Dict
of K to V. Product form:
Tuple of (T1, T2, …) or (T1, T2, …) (arity ≥
2) checks length and each slot — the fixed heterogeneous pair/triple
type. The paren spelling is the same product in every ::
slot and as a type synonym body
(type Coord = (Float, Float)). It is not
an enum. Array[String] is still a SyntaxError. Runtime
:: checks collection contents against the argument types —
including nested function types (arity; declared param/return types when
the value carries them) and product slots. Bare () is not a
type atom (the empty tuple is a value); write () -> T
for a zero-parameter function type.
Persistent explicit Array constraints (evaluator).
An explicit Array of T annotation retains its element
constraint on the shared Array, including nested Arrays. Initial values
and every later insertion or replacement must satisfy it. Aliases and
references captured before or after annotation obey the same constraint;
a broader annotation cannot erase an earlier one. Indexed writes,
Indexable.put, slice assignment,
push/append, insert_at,
unshift, extend, fill, and Array
message verbs check before mutation. A rejected batch leaves the target
and incoming nested contracts unchanged; argument evaluation side
effects are not rolled back. Removal retains the constraint.
scores :: Array of Integer: [1, 2]
other_scores: scores
other_scores[1] = 3
push(other_scores, 4)
println(scores) # [3, 2, 4]
# push(other_scores, "bad") # runtime error; scores stays [3, 2, 4]
This supersedes initial-only runtime validation for explicitly
annotated Arrays: programs that later violate their annotation now fail.
Arrays with no explicit element annotation remain heterogeneous at
runtime; a homogeneous initial literal does not attach a runtime
constraint. Array element checks retain the existing membership rules
without recursively converting elements. Full VM parity is deferred:
--vm explicitly refuses applicable Array of T
annotations (including nested/aliased descriptors); bare
Array and unannotated VM Arrays still work.
--typecheck and --infer remain separate static
checks.
Direct aliases keep the same Array object. Distinct conversion/view headers that historically shared slots with an unannotated Array keep that behavior until an explicit element annotation attaches. First attachment detaches the annotated Array's outer slots; later tuple conversions and optional array-package views from it copy those slots. Nested values remain shared. This prevents an unchecked view from bypassing the new contract without changing unannotated conversions.
Under --typecheck, homogeneous
set and map literals establish
Set of T and Map of K to V the same way array
literals establish Array of T. Mixed kinds
({1, "a"}, {a: 1, b: "x"}) and annotations
that disagree with a known literal are static errors. Empty collections
stay gradual.
Collection element homogeneity under
--typecheck. A non-empty array literal with one
element kind is remembered as Array of T (spoken that way —
there is no Array[T] type-application syntax). The same
rule applies to sets (Set of T) and
maps (Map of K to V) from wave-2 of-types.
An explicit xs :: Array of Integer /
s :: Set of Integer /
m :: Map of String to Integer establishes the same
contract. The checker then:
- refuses a mixed literal:
[1, "a"]is a type error - refuses an element write that disagrees: after
xs: [1, 2, 3],xs[1]: "hi"is a type error - widens Integer with Float to Float element kind
- unifies tuple elements structurally — a tuple's
type is its arity plus its slot kinds, so
[(1, 2), (3, 4)]is one element kind (Array of Tuple of (Integer, Integer)), while a differing arity ([(1, 2), (3, 4, 5)]) or a differing slot kind ([(1, 2), (true, false)]) stays a type error - leaves empty
[]unconstrained; re-binding an unannotated name to a whole new array re-types it (gradual). Without--typecheck, runtime still allows heterogeneous arrays when no explicit element constraint was attached.
nums: [1, 2, 3]
nums[1]: 99 # ok under --typecheck
# nums[1]: "no" # type error: expected Integer, got String
# bad: [1, "a"] # type error: mixes Integer and String
Local if-guard refinement under
--typecheck. A closed mini-rule set refines a name
inside if branches — not full control-flow analysis:
if x is T then …treatsxasTin the then-branch- when
xis a finite union, the else-branch treatsxas the complement not/¬/!around a barex is Tswaps those two- Truthiness residual: bare
if x then …whenxis a finite union that includes an always-falsy bottom (NoneorOm) drops those bottoms in the then-branch and keeps only bottoms in the else-branch. Matches the value law: only bottoms are falsy —0,"", and[]stay truthy and are not refined away. Boolean is mixed at the value level, so aBoolean | …alternative is kept on both sides rather than collapsed totrue/falsesingletons.
f: func(n :: String | Integer) [
if n is Integer then n + 1 else upper(n) # else: String
if !(n is String) then n + 1 else upper(n) # invert
]
# Truthiness residual — no `is None` required
g: func(s :: String | None) [
if s then upper(s) else "missing" # then: String; else: Null
if not s then "missing" else upper(s) # invert
]
Compound guards (and / or),
type(x) == "…", and path joins after the if
are not refined — the checker stays conservative there.
| binds tighter than
->. Integer | Float -> String
is (Integer | Float) -> String — a function taking
either number and returning a string — not a union containing a function
type. The other reading is never what someone writing this means.
An arrow type is a Function. It checks
that the value is a function; it does not check the operand
types, because a function value carries no structural type to check them
against:
sq: func(n) [ n * n ]
u :: Integer -> Integer: sq # binds
@(u) # → "Function"
u :: Integer -> Integer: 5 # ERROR: expects type Function, got Integer
So Integer -> Integer and
String -> Bool are the same runtime descriptor. The
arrow's job in an annotation is to say a function goes here;
its arms are documentation to the reader and, in a detached signature,
the source the parameter types are taken from.
A type is not an expression. It stops at the end of its last atom,
which is what lets a declared return type sit directly in front of a
bracket body: func(x) :: Integer [ x ] reads
Integer as the whole type and [ x ] as the
body.
That same rule is why there is no type application —
List[Integer] is not a type. A bracket after a type name is
a body, and telling the two apart would mean deciding one statement by
reading the next: with data Shape = Dot | Circle, the
lines
pick: func(n) :: Shape [ Dot ]
[ Circle ]
are a function and an unrelated statement, and no lookahead can rule
out reading them as one type application plus a body — a nullary
constructor is both a legal type name and a legal value. Parametric
types are settled at the declaration head instead, where the follow-set
is =.
System type-Concepts
— shadowing and System.*
Every canonical type name — the name @x prints — is also
a system type-Concept: a Concept seeded into every
environment and marked as a primitive type, so x is T,
x :: T, and @x are one surface over one name.
That is every constructible type (Integer,
String, Date, Money,
Percent, Pair, AST, …), not a
curated subset. Type names are TitleCase; AST and
URL are the two pinned initialisms.
@($5.00) # → "Money"
$5.00 is Money # → true
d :: Date: 2026-07-26 # annotates with the same name
Three rules govern these names:
A declaration shadows. Forms that declare a concept —
concept Time …,Time extends …,Percent ranges 0..<100,X enumerates …(the headX, not a member name), adataconstructor tag — rebind the bare name to your concept. Programs that declareconcept Moneykeep working unedited; the declaration is taken as deliberate. An enum member that still names a built-in type (enum Duo = Integer, String,Duo enumerates Integer, String) refuses — a member is a value in a list, not a declaration head, and binding it would make5 is Integerfalse for the rest of the program. Rename the member.A plain value binding refuses. A top-level
Time: 5errors instead of silently making12:30:45 is Timeanswerfalsefor the rest of the program. Inside a function the same spelling is a frame-local shadow — the parameter rule — and stays legal.has/defineson a bare seed refuse. While the name still denotes the built-in type-Concept,Integer has slot: vandArray defines { … }error rather than auto-creating a user concept under the same spelling. Silent auto-shadow used to drop5 is Integertofalseat exit 0 while@5still printed"Integer". To put slots on a colliding name, declare first, then has:concept MoneythenMoney has supply: 0. Unbound names (Person has name) still auto-create as first-use declarations.The built-in survives at
System.X. After any shadow, the seed is still reachable under the reservedSystemnamespace, in both classification and annotation positions.concept System.Anythingis refused — the namespace belongs to the built-ins.
concept Money "medium of exchange" # shadows the built-in type-Concept
Money has supply: 0.0 # ok — attaches to *your* concept
$5.00 is Money # → false — the bare name is yours now
$5.00 is System.Money # → true — the built-in, unshadowable
12:30:45 is System.Time # → true — works for every type name
Time: 5
# ERROR: 'Time' names the built-in Time type Concept — choose another
# name, or declare `concept Time ...` to shadow it deliberately; the
# built-in stays reachable as System.Time
Integer has bogus: 1
# ERROR: 'Integer' names the built-in Integer type Concept — cannot
# `has` onto it; declare `concept Integer ...` to shadow it
# deliberately, then `has`; the built-in stays reachable as System.Integer
Under --typecheck, every shadowing declaration
prints a non-fatal note: naming the escape hatch; the
default warn-pass stays silent about shadows, because a deliberate
shadow is legal code.
@x / type(x) vs is /
:: after a shadow. The sigil and
type(x) always read the value's runtime tag (so
@($5.00) stays "Money" under any binding of
the name Money). Bare is Money and :: Money
follow the current binding of the name. After
concept Money, those two surfaces disagree until you write
System.Money for the built-in sense — by design, not an
accident. Absent shadowing, @x, type(x),
x is T, and :: T agree.
Detached
signatures — f :: A -> B on the line above
A function's types may be written inline, on the slots themselves:
sayHello(x :: String) :: String = "Hello, " + x + "!"
or detached, on the line above the definition:
sayHello :: String -> String
sayHello(x) = "Hello, " + x + "!"
# Zero-parameter functions: empty () marks no parameter arms
answer :: () -> Integer
answer() = 42
These are not two features. The detached form is discharged
at parse time into the definition's own parameter and
return slots, so nothing downstream can tell them apart — the evaluator,
the VM, --typecheck, and boundary promotion all see one
ordinary annotated function. The two spellings even refuse a bad call
with the same message, character for character.
Each arm before the last is a parameter; the last is the result
(commas are the other spelling — see below). A leading
() -> is the nullary form (zero parameters). Arity is
checked at parse time, so a signature that disagrees with its definition
is refused rather than half-applied:
add :: Integer -> Integer -> Integer
add(a, b) = a + b
add(2, 3) # → 5
inc: add(1) # partial application, as always
inc(5) # → 6
Commas are the other spelling of the parameter list.
Every other parameter list in Axioma is comma-separated — the definition
head is plus(x, y) and the inline annotation is
plus(x :: Integer, y :: Integer) — so a detached signature
may list its parameters the same way, with the final ->
introducing the result:
plus :: Integer, Integer -> Integer
plus(x, y) = x + y
plus(3, 4) # → 7
A, B -> R and A -> B -> R denote
the same type and build the same node; neither is a
conversion of the other, and partial application works through both. Use
commas when the function reads as taking its arguments together, arrows
when currying is the point — add(1) has the type
Integer -> Integer, which the arrow spelling names by
pointing at part of itself.
A function-typed parameter is parenthesized in either spelling, exactly as it already must be:
twice :: (Integer -> Integer), Integer -> Integer
twice(f, n) = f(f(n))
twice(x => x * 3, 2) # → 18
Parentheses around a comma list are not a parameter
list — they are a product type, i.e. one tuple-shaped parameter. This is
where the habit carried over from Haskell and Miranda diverges:
plus :: (Integer, Integer) -> Integer describes a
one-argument function taking a pair, and above a two-parameter
plus(x, y) it is refused for arity.
One signature, one spelling. Mixing them is refused
rather than resolved, because A -> B, C -> D has two
readings and nothing in the line prefers either:
mixed :: Integer, Integer -> Integer -> Integer # SyntaxError
noRet :: Integer, Integer # SyntaxError — no return type
Every function-literal spelling takes one — the
equation, func, and all three lambda forms:
triple :: Integer -> Integer
triple: func(x) [ x * 3 ]
quad :: Integer -> Integer
quad: x => x * 4 # bare arrow
quint :: Integer -> Integer
quint: lambda x => x * 5 # lambda keyword
answer :: () -> Integer
answer: () => 42 # nullary arrow
A signature may also stand in any statement list, not only at the top level:
outer(n) = [
measure :: String -> Integer # discharged inside this body
measure(s) = len(s)
measure("abcd") + n
]
A signature must reach a definition. It is never a silent no-op — each of these is a parse error, with a hint:
| written | refused because |
|---|---|
f :: Integer -> Integer alone |
nothing follows it |
| a binding between signature and definition | not adjacent |
| two arms, one parameter | arity disagreement |
limit :: Integer above limit: 10 |
not a function type |
f :: A, B -> C -> D |
commas and arrows mixed |
f :: A, B |
commas but no return type |
The last one names the fix: to annotate a value, annotate the binding
directly — limit :: Integer: 10. Detached signatures are
for functions.
An arrow may be written on the binding too, and it means the same thing. When the value is a function literal the arms are lowered into its slots exactly as a detached signature's are, so this is checked, not decorative:
h :: Integer -> Integer: lambda x => x + 1
h(4) # → 5
h("str") # type error: parameter x expects type Integer
When the right-hand side is a value rather than a
function literal, the arrow cannot lower into slots, so it becomes a
structural function type: arity must match; declared
param/return types must be compatible; and if the return is undeclared,
the body is inferred statically under the arrow's parameter types (so
(n) => "x" is not Integer -> Integer).
Bodies too opaque to infer stay gradual.
base: func(x) [ x + 1 ]
alias1 :: Integer -> Integer: base # arity + inferred Integer return → ok
alias1(1) # → 2
base0: func() [ 1 ]
# a :: Integer -> Integer: base0 # ERROR: wrong arity
baseS: (n) => "x"
# a :: Integer -> Integer: baseS # ERROR: returns String
Nested and higher-order arrows use the same check:
handlers :: Array of (Integer -> Integer): [(n) => n + 1]
# [() => 1] → type error (wrong arity)
# [(n) => "x"] → type error (returns String)
apply3: lambda (f :: Integer -> Integer) => f(3)
apply3((n) => n * 2) # → 6
# apply3(() => 1) # type error
# apply3((n) => "x") # type error
Bare Function still means any callable
(Array of Function does not constrain arity). Write the
arrow above a definition (or on a literal RHS) when you want declared
slots checked on every call as well.
Variadic parameters are refused in a detached signature —
... has no fixed arity for the arrow to match.
Destructuring is refused on the single-clause forms above, where the
pattern list runs parallel to the parameter list; annotate those slots
inline, where the pattern is visible. A clause group, below, has only
patterns, so it takes them.
A multi-clause function takes one signature for the whole group. Write it once, above the first clause:
gcd :: Integer -> Integer -> Integer
gcd(0, n) = n
gcd(m, n) = gcd(n `mod` m, m)
gcd(48, 18) # → 6
The signature describes the function, not the clause it sits on, and that is what it does at run time: a call is checked once, before dispatch chooses a clause. So enforcement never depends on which pattern happened to match —
size :: Integer -> String
size(0) = "empty"
size(n) = "n items"
size("x") # type error, though the literal clause
# would not have matched anyway
Because the arms are positional here rather than per-slot, a clause may destructure and still carry a signature:
total :: Array -> Integer
total([]) = 0
total([hd | tl]) = hd + total(tl)
total([1, 2, 3, 4]) # → 10
Two things stay as they were. A clause group is still
total — no matching clause is none, and a
return arm does not change that, since a type should not quietly turn a
total function into a partial one. And a group with no signature is
unconstrained, exactly as before.
-> was already spoken for, and keeps
every job it had: a lambda body delimiter
(\x :: Integer -> x * x), a relation's semantic role
(relation teach(x :: String -> "teacher")), a
translation block, and an attribute set. Inside a \-lambda
the body reading always wins — \x :: Integer -> Circle
returns Circle, it does not declare a return type — because
a nullary constructor is simultaneously a legal type atom and a legal
value, and the body is what a lambda is for.
Showcase:
tests/axioma/showcase/function_signature.ax.
Type-of — :: expr
— and annotations(f)
Four questions, four spellings. They do not substitute for each other:
| Question | Spelling | Value |
|---|---|---|
| What kind of value is this? | :: expr / type(expr) /
@expr |
the DataType Concept (Function,
Integer) |
What :: arrow was written? |
annotations(f) |
FunctionType or none |
| What does the body read as? | inferred(f) · axioma --infer |
FunctionType or none / a printed
arrow |
| What is the callable shape? | signature(f) |
"func(x)", "sqrt(x)" |
--typecheck enforces written ::.
Success typing warns when inferred(f) makes a
literal call impossible. describe(f) prints
Type, Signature, and — when present —
Annotations and Inferred.
:: expr is the type-of sigil. It returns the DataType
Concept — the same object as type(expr)
and @expr:
:: 5 # Integer
::twice # Function
:: sqrt # Builtin
::'🍌' # Character
@debugInt(1) == Tuple
@debugInt(1) == "Tuple" # false — the type is Tuple, not a string
At the REPL prompt a single leading :
is a command (:help, :stack). Prefix
:: expr is this sigil, not a command — ::'🍌'
prints Character, the same as @'🍌'.
That Concept is the tag, not the detached signature.
:: twice is Function even when you wrote
twice :: Integer -> Integer. The declared arrow is a
different value:
twice :: Integer -> Integer
twice(x :: Integer) = x * 2
signature(twice) # "func(x)" — shape
a: annotations(twice)
a # Integer -> Integer — a FunctionType
a.params # [Integer]
a.result # Integer
annotations(sqrt) # none — no :: type was written
raw(x) = x * 2
inferred(raw) # Integer -> Integer — what the body is
annotations(raw) # none — nothing was written
inferred(f) is the sibling: the same
FunctionType, filled by the --infer engine
from the body. Effects, /, match, and other
constructs outside the lambda fragment answer none.
Evaluator-only (--vm refuses; the AST is compiled
away).
Prefix only. Tight or spaced (::5 / :: 5).
x :: Integer: 5 and (x :: Float) are still the
annotation and the ascription; :: is not an infix operator.
@if / {X @theorem | …} stay on
@.
describe(f) shows Type,
Signature, and — when present — Annotations
and Inferred.
Type ascription —
(expr :: T)
Every other :: position annotates a name — a
binding, a parameter, a return type, a concept slot. An
ascription annotates a value, and answers one. It
carries the same boundary rule: the value is checked against
T, then converted by the one conversion the annotation
implies (Integer → Float, where the Float admitted the Integer by
promotion).
square(x) = (x :: Float) * x # → 9.0 for square(3); @square(3) == Float
(4 :: Integer) # → 4 no promotion applies; unchanged
(5 :: Integer | String) # unions, subranges, concepts and enums all
(7 :: 1..10) # work here, on the binding's terms
("hi" :: Integer) # error: expression ascribed as Integer, got String
(1.5 :: Float64) # error: ':: Float64' names no known type — did you mean ':: Float'?
An ascription is not a cast. A name that is not a
type is not a mismatch either: :: Float64 /
:: Int fail as unknown names (the FLOAT type is
Float; Int is not Integer unless
you alias it), and the "got" side of a real mismatch uses
that same registry spelling (got Float, never
FLOAT). Return annotations use the same two diagnostics as
bindings, parameters, and ascriptions. --typecheck flags
the unknown name at the annotation, even if the function is never
called. It cannot convert across a genuine mismatch, and it cannot
launder an ordinal back into a magnitude: (rank :: Integer)
on an :: Ordinal value is flagged by
--typecheck, and the ascribed type propagates outward, so
(n :: Ordinal) + 1 trips the arithmetic rule (§26). For a
real conversion use float(x) / int(x) /
string(x) — or their postfix spellings x.float
and x's float.
The parentheses are required. :: is not an infix
operator: it introduces an annotation at a fixed set of positions, and
the group is what makes an expression one of them. A bare
x :: Float is a SyntaxError whose hint names the
parenthesized form. Two consequences: an argument list is not a group,
so an ascribed argument brings its own parens
(abs(((0 - 3) :: Float))); and the | of a
union ascription belongs to the type, so
(5 :: Integer | String) is not read as a lazy
comprehension.
Inferred polymorphic
types — axioma --infer
Annotations are optional everywhere, so most functions carry no
written type. axioma --infer reports one anyway, where one
honestly exists: every top-level function inside the lambda
fragment — pure and order-free, the same ruling
axioma-lambda-audit prints — gets a simple arrow type,
polymorphic where nothing pins it down. Every other function gets the
reason there is no type, never a guess.
axioma --infer script.ax
script.ax:1: id :: a -> a
script.ax:2: apply :: (a -> b, a) -> b
script.ax:3: twice :: (a -> a, a) -> a
script.ax:4: double :: Integer -> Integer
script.ax:6: fib :: Integer -> Integer
script.ax:8: bump — not inferred: outside the lambda fragment (store: writes acc)
script.ax:9: shout — not inferred: outside the lambda fragment (world: calls println())
script.ax:13: half — not inferred: the result type of / depends on exactness (Integer / yields Rational); not inferred
A multi-clause group is typed as one function. Every clause is inferred independently and unified with the rest, because whichever clause wins at run time must still produce the one type the group is used at. Literal patterns ground a slot that a single-clause definition would leave open:
func fib(0) [0]
func fib(1) [1]
func fib(n) when n > 1 [fib(n - 1) + fib(n - 2)]
# --infer: fib :: Integer -> Integer (the 0 and 1 patterns ground it)
func firstOf(x, _) [x]
# --infer: firstOf :: (a, b) -> a (nothing grounds it; stays polymorphic)
A guard leaves the clause order undecided, which is a question about which clause runs — never about what type the group has — so the group is typed anyway. Clauses that genuinely disagree are refused with the conflict named, not averaged into a guess.
A declared return is checked against the body. When
you write the return type yourself, inference holds the body to it. That
is the one --infer outcome that is an error rather than a
report: the declaration is already your own obligation, and the runtime
would otherwise catch the contradiction one call too late, on only the
paths that actually run.
bad :: Integer -> String
bad(n) = n + 1
# --infer: bad declares it returns String, but its body produces Integer
# → exit 1
A refusal never gates the exit code; a proven mismatch does. Functions whose parameters are annotated get their return confirmed but no printed arrow — typed and untyped parameters are merged by source position, which inference does not reconstruct, while a body names its parameters and so needs no order:
script.ax:4: greet — returns String, confirmed against its body (annotated parameters: no arrow)
One promotion is deliberately not a mismatch: a
:: Float annotation coerces an Integer result at the return
site, so func square(x :: Integer) :: Float [x * x] is
correct code. The check is directional — Float admits an Integer body,
Integer does not admit a Float one. A declared return outside the
inferred grammar (:: Rational, a concept, a union) is left
alone entirely; "I cannot see it" never becomes "it is wrong".
In the editor (VS Code / Cursor):
axioma-lsp publishes the same successful readings as
Information diagnostics
(double :: Integer -> Integer, source
axioma-infer) as you type, after a clean parse. “Not
inferred” reasons stay silent there so multiparadigm files are not
flooded. Annotated mismatches still appear as typecheck
warnings (axioma-typecheck). Rebuild
axioma-lsp and reload the window after pulling checker
changes.
A lowercase letter is a type variable:
apply :: (a -> b, a) -> b takes a function and a
value that function accepts, and returns what the function returns, for
any such pair. A function defined earlier in the file is reused
at each caller's own types — classic let-polymorphism:
id: func(x) [x]
both: func() [ if id(true) then [id(1)] else [id(2)] ]
# --infer: id :: a -> a
# both :: () -> Integer (id used at Boolean AND at Integer)
Three deliberate honesty rules:
- Nothing runs, and a refusal never gates the exit
code. The runtime stays gradual, and a function inference could
not reach is reported as a reason, not raised as an error — a genuine
internal conflict (one parameter used as
x + 1andx + "s") included. The single exception is a declared return the body contradicts: that type was inferred, and it disagrees with something you wrote, so it exits 1. - Arithmetic is structural.
+ - * %type as "operands and result are one type", soadd :: (a, a) -> ameans any one type consistently — the numeric tower's mixed-exactness coercions are looser at runtime than the printed type. Integer/is not typed at all:5 / 2is the exact Rational5/2, so the result type changes with the operands and a simple arrow would lie about it. - The gate is the fragment survey, not a fresh
opinion. Effects, writes that reach past the frame,
destructuring parameters, and variadics are each skipped with that
reason stated.
::-annotated parameters still yield no printed arrow — that is--typecheck's job — though their declared return is checked. Anifcondition is deliberately unconstrained — truthiness is total in Axioma — while the two branches must agree with each other. A few total builtins are known by their arrow —str/stringisa -> String, sotimestamp(time, message) = message + str(time)infers(a, String) -> Stringinstead of going silent. An unknown builtin is still a skip, not a guess. Tuple literals are in the fragment:quotrem(x, y) = ((x div y), (x mod y))is(Integer, Integer) -> (Integer, Integer).
#language axioma/hm
— the closed Hindley–Milner island
axioma --infer on axioma/all is a
lens: it skips what it cannot type.
#language axioma/hm is a language. A file
that declares it is a closed Hindley–Milner island: principal types,
let-polymorphism, general (monomorphic) recursion,
fatal rejection. It is not “turn
--typecheck on” for the host, and it is not the Total
island (no strong normalization). --vm is refused.
#language axioma/hm
id: func(x) [x]
double: func(x) [x * 2]
expect("id", id(1), 1)
expect("double", double(3), 6)
The first diagnostic always expands the name:
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
Closed means closed. No KB, concepts, is,
none/om, imports, println, or
host append (that builtin mutates or writes a file).
+ is numeric only; string concat is
str_concat, array concat is array_concat.
if conditions are Boolean. Integer
/ is refused (use div). len types
as Array of a -> Integer.
--infer on an hm file is the same
checker (exit 1 on error). Host --infer on
axioma/all is unchanged.
Tests: tests/axioma/hm/
Textbook: HtDKP-in-Axioma Chapter 54 — The Closed
Hindley–Milner Island
Success typing — proven-impossible calls
--infer and inferred(f) read an
unannotated body. Success typing uses that reading at call
sites, the way a success type-checker does: it warns only when a call
cannot succeed, and stays silent when it might. It is
not ML. Inferred is never treated as a written ::.
square2(x) = x * x # inferred: Number -> Number
square2("hi") # warning: cannot succeed (default run and --typecheck)
square2(z) # silent — z is unknown
z :: String: "hi"
square2(z) # warning — fully tagged String
double(x) = x * 2 # inferred: Integer -> Integer
double(2.5) # silent — Float * Integer runs
double("hi") # silent — `"hi" * 2` is string-repeat
id(x) = x # inferred: a -> a
id("hi") # silent — a type variable admits any actual
pow(s, n) = s * n
pow("a", 2) # silent — different operands, not `x * x`
axioma script.ax # warns, then runs
axioma --no-typecheck script.ax # off — same switch as the annotation warn-pass
axioma --typecheck script.ax
# script.ax: line 2: warning: call to 'square2' cannot succeed: argument 1 is String, inferred parameter is Number
# Hint: inferred(square2) is Number -> Number — this argument cannot satisfy that arm
# script.ax: type check clean.
The law: no :: and no proven crash ⇒ no new
failure. A success-typing finding is a warning. It never
changes the exit code. --typecheck still exits 0 when these
are the only findings; --typecheck --run still executes.
Plain axioma script.ax prints the same warning on the
default warn-pass (≤3 findings inline, else a summary) and then runs.
Opt out with --no-typecheck. Runtime of an unannotated call
is unchanged.
In the editor the same finding is a warning (source
axioma-success). It is not axioma-typecheck —
unannotated files stay gradual under that source. Rebuild
axioma-lsp after pulling.
+ and * are overloaded
("a" + 1, "a" * 2). A parameter used in
+, or in * with a different name,
admits Number and String — the HM arrow is too tight to treat as a
success type. Same-variable x * x stays Number-only.
v1 is per-arm and same-file: it does not unify several arguments
together (add(1, "hi") on (a, a) -> a stays
quiet), and it does not chase higher-order arguments or imported
functions.
Integer
subrange types — Concept ranges A..B (Pascal/Ada)
X ranges Low..High declares X as an integer subrange
type. Used in :: annotations, the bound is runtime-checked;
with the --typecheck static pre-pass (§26), literal
violations are caught before any code runs.
Digit ranges 0..9
Percent ranges 0..<100 # half-open
d :: Digit : 5 # OK
d :: Digit : 12 # type error
p :: 0..<100 : 50 # inline range annotation
p :: 0..<100 : 100 # type error
# Reassignment honors the binding-time snapshot:
r :: Digit : 3
r = 7 # OK
r = 99 # type error
The by step is rejected in ranges
declarations (membership becomes ambiguous). Inverted bounds
(5..0) and empty half-open (5..<5) also
error at declaration time.
Enum
subrange — Sub extends Enum in From..To (Pascal/Ada)
Workday extends Day in Mon..Fri declares a subtype of an
existing enum restricted to a contiguous slice. Every Workday IS a Day
(widening is automatic); a Day with ord outside the slice cannot be a
Workday (tightening is runtime-checked). Member identity is shared —
Mon is still Day.Mon.
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
Workday extends Day in Mon..Fri
Weekend extends Day in Sat..Sun
d :: Workday : Wed # OK
d :: Workday : Sat # type error: Sat outside Workday
any :: Day : Wed # widening — every Workday is a Day
# Iteration visits only the slice:
foreach d in Workday [
println(d.name) # Mon Tue Wed Thu Fri
]
println(len(Workday)) # 5
Endpoints must belong to the named parent enum (cross-enum endpoints error). Inverted bounds, integer endpoints with an enum parent, and non-enum parents all error at declaration time.
Slot-metadata helpers — inverse, transitive, find-or-create
Three small builtins covering common KR patterns. All ship as configuration calls; no new keywords.
# Inverse slots — auto-maintain bidirectional relations
concept Car
concept Engine
Car has parts: none
Engine has part_of: none
inverse_slot("parts", "part_of")
car: a Car {}
engine: a Engine {}
car.parts: engine # auto: engine.part_of = car
# Transitive slots — walk a chain in one builtin call
concept Person
Person has parent: none
transitive_slot("parent")
alice: a Person {}; bob: a Person {}; carol: a Person {}
alice.parent: bob
bob.parent: carol
ancestors: transitive_closure(alice, "parent") # [bob, carol]
# find_or_create — definite description with reification
concept Country
Country has name: ""
usa1: find_or_create(Country, {name: "USA"})
usa2: find_or_create(Country, {name: "USA"}) # same instance as usa1
find_or_create is the canonical KB ingestion primitive —
it makes "if this entity already exists, give me it; else make it" a
one-liner. Inverse-slot propagation is one-step (the propagation guard
prevents recursive inverse-of-inverse loops). Cycle-safe entity
rendering is automatic — ConcreteEntity.Inspect detects
already-visiting entities and emits a short reference.
Auto-classification via
defines
A fourth concept-verb that attaches a membership predicate to a class. Any instance whose slots satisfy the predicate is automatically classified; slot mutations re-evaluate and promote/demote in real time. Faithful to KM §17, with Axioma additions (B4 truth, six-level grounding, defeasibility).
concept Person
Person has age: 0
Adult defines { age >= 18 and is Person }
Senior defines { age >= 65 and is Adult }
Sage defines~ { age >= 80 } # defeasible — grounding=conjecture
alice: a Person {age: 30}
alice is Adult # true (auto-classified)
alice is Senior # false
alice.age: 70
alice is Senior # true (re-classified on slot mutation)
alice.age: 5
alice is Adult # false (auto-demoted)
Distinction from has:
Concept has prop: val is unidirectional
(is(x, C) → prop(x, val) — every member has this property).
Concept defines { body } is bidirectional
(is(x, C) ↔︎ body(x) — membership iff predicate). See KM §17
for the canonical Mexican/Square contrast.
The three logical roles, separately surfaced:
| Role | Direction | Surface |
|---|---|---|
| Slot template (structural) | — | Adult has age: 0 |
| Implication (one-way) | → |
is(X, Adult) ==> voting(X, true) |
| Equivalence (two-way) | ↔︎ |
Adult defines { age >= 18 and is Person } |
Predicate body conventions:
- Bare slot names (
age,price) implicitly refer toit.<slot>. is Conceptin prefix position desugars toit is Concept.and/or/notand comparison operators work normally; Belnap values propagate through.
Grounding & truth integration:
- Strict
defines→ grounding=theorem;defines~→ conjecture. grounding("isa", instance, Concept)reports the grounding.proof("isa", instance, Concept)reports the derivation chain.- Demoted classifications get
truth("false")(Belnap), preserving provenance for audit. Re-promotion re-fires the predicate cleanly. - Belnap
Bothresults from predicate body propagate to the is fact's truth without crashing (paraconsistent semantics).
Auto-create: if the named concept doesn't exist when
defines is evaluated, it's created automatically (extending
root Concept). No need for a separate
concept Adult line.
Value constraints —
constrain()
Write-time validation on slot writes. Where defines
classifies instances based on their slot state,
constrain gatekeeps the slots themselves: register
a predicate that every subsequent write must satisfy, and bad writes are
silently rejected (the slot retains its prior value) with a warning to
stderr.
concept Customer
Customer has age: 0
Customer has email: ""
constrain(Customer, "age", lambda v => v >= 0 and v <= 150)
constrain(Customer, "email", lambda v => contains(v, "@"))
alice: a Customer {age: 30, email: "[email protected]"} # accepted
bad: a Customer {age: -5} # WARN; age stays at default 0
alice.age: 200 # WARN; alice.age stays at 30
alice.age: 45 # accepted
# Multiple constraints AND
constrain(Customer, "age", lambda v => v != 13)
teen: a Customer {age: 13} # rejected (second constraint)
# Subclass inheritance — every constraint declared on Customer applies to VIP
VIP extends Customer
v: a VIP {age: -1} # rejected
Predicate contract:
- Takes exactly one argument — the value being written.
- Returns a Boolean-ish value (truthy = accept, falsy = reject).
- May reference other slots only via captured closure variables;
cross- slot constraints (predicates referencing
self.other_slot) are better expressed asdefines, which already sees the full entity.
Enforcement sites: every slot-write path is hooked —
a Concept {slot: val, ...}— the per-property initialization loop.entity.slot: val— direct assignment.entity[slot -> val]— ErgoAI frame-attribute set.
Distinction from neighbouring features:
| Feature | Fires when | On failure |
|---|---|---|
cardinality (B.8) |
Write to single-valued slot already populated with different value | Last-write-wins + violation marker |
defines (KM §17) |
Slot mutation triggers re-classification | Promote/demote membership in defined class |
constrain (this) |
Any slot write | Skip write + warning to stderr |
Introspection:
constraints_of(Customer, "age") # → 2 (count of predicates on this slot, walking ancestors)
constraints_of(Customer) # → {age: 2, email: 1} (all constrained slots)
KM mapping: closes the gap with KM 2.0 §12 (Value
Constraints) — the must-be-a / must-be facets
— without committing to a specific type-system facet. The predicate body
has the full expression language available, so
must-be Integer where v >= 0 and v <= 150 is just
lambda v => v >= 0 and v <= 150 when the type
check is sugar for an instanceof call.
Test: tests/axioma/concepts/test_value_constraints.ax.
English paraphrases for
why — paraphrase()
KM §19.3. Register an English template against a concept; when
why X is Concept fires, render the template instead of the
default structured proof. Closes the gap between Axioma's structured
explanation engine and the regulator-/user-friendly English output that
compliance domains require.
concept Person
Person has age: 0
Person has name: ""
Adult defines { age >= 18 and is Person }
paraphrase(Adult, "{it.name} qualifies as an Adult because their age ({it.age}) is at least 18.")
alice: a Person {name: "Alice", age: 30}
why alice is Adult
# → "Alice qualifies as an Adult because their age (30) is at least 18."
Placeholder grammar:
| Form | Resolves to |
|---|---|
{it} |
The instance — its name slot if present, otherwise
Inspect() |
{it.slot} |
Value of the named slot on the instance |
{unknown} |
Left intact (debugging aid) |
Inheritance: a subclass without its own paraphrase
inherits its parent's. Most-specific wins; the parent-chain walk mirrors
how constrain constraints are inherited.
Fallback: if no paraphrase is registered for the
concept (or any ancestor), why runs the existing
structured-proof rendering. The feature is purely additive — adopt it
for the classes where English output matters.
Composition:
defines+paraphrase→ auto-classification rendered in Englishunify+paraphrase→ the canonical entity's slot values are resolved before placeholder substitution- Subclass +
paraphraseon parent → subclass inherits the parent's template until it registers its own
Test: tests/axioma/concepts/test_paraphrase.ax.
Defined instances —
Concept identified by ...
KM §17.3 — automatic coreference by identity key. Where
defines auto-classifies instances by their slot
state, defined instances auto-merge instances by a declared
identity key. Declare which slot(s) uniquely identify an individual, and
any two instances of the concept with matching key values are
automatically unified — reactively, on every slot write, with no
explicit unify call.
The recommended canonical surface is the
has/identity slot refinement — identity-ness is slot
metadata, so it belongs on the slot declaration rather than in a
separate statement that re-names the slot. It parallels Axioma's
existing keyword refinements (declare/persist,
axiom/transient, is/same).
concept Customer
Customer has/identity ssn: "" # ssn is the identity key
Customer has name: ""
c1: a Customer {ssn: "123-45-6789", name: "Alice Smith"}
c2: a Customer {ssn: "123-45-6789", name: "A. Smith"}
println(c1 == c2) # → true (auto-unified)
println(c1.name) # → A. Smith (newest write wins)
# Composite key — mark each participating slot; all /identity slots
# on a concept jointly form ONE key (declaration order preserved)
concept Order
Order has/identity customer_id: ""
Order has/identity order_number: ""
Order has total: 0
Alternative surfaces (all valid, all lower to the same registry, nothing deprecated):
Customer identified by ssn # statement form
Order identified by customer_id, order_number # composite, explicit grouping
define_equivalence(Customer, "ssn") # functional builtin
It is the automatic dual of the two manual coreference primitives:
| Primitive | Trigger | Effect |
|---|---|---|
find_or_create(C, {...}) |
explicit call | return existing match or make fresh |
unify x with y |
explicit call | merge two named entities |
has/identity slot refinement |
every slot write | auto-merge any two C instances with matching key |
Semantics:
- Each
has/identityslot is appended to the concept's key in declaration order; multiple such statements accumulate into one composite key (re-declaration is deduped/idempotent). - An incomplete key (key slot missing, or holding
"") never matches — a partially-populated instance is not a dedup candidate yet. A later write that completes the key triggers the merge. - The triggering entity wins (newest write wins on slot conflict); the
pre-existing match redirects via
Canonical. - Reuses the full
unifymachinery — B4 paraconsistent slot-merge, transaction rollback,strength/proof/why. - The merged-away loser is removed from the concept extent, so
{X | X <- Concept}counts distinct identities, not raw records. - Subclasses inherit the parent's key.
Introspection:
equivalence_key_of(Concept) returns the key slots as an
array of strings (parent chain walked), [] if none.
KM mapping: closes KM 2.0 §17.3 (Defined Instances —
Testing for Equivalence), the equivalence half of KM's
automatic-classification duality. The membership half (§17.2 Defined
Classes) is Axioma's defines.
Test: tests/axioma/concepts/test_defined_instances.ax.
inspect
/ see — identity-passing evaluate-and-display
A prefix directive that evaluates an expression, prints
<source> = <value> to stdout, and returns the
value unchanged. Modelled on Elixir's IO.inspect / Julia's
@show — the prevailing non-imperative idiom for inline
inspection.
c: a Car {price: 150}
inspect c.price # prints "c.price = 150"
inspect c is Car # prints "(c is Car) = true"
# Identity-pass: composes inside expressions
n: inspect expensive() # binds n; the value also printed
Lowest-precedence prefix, so inspect fred is Person
consumes the whole is expression without parens.
see is a true alias — same token, same
semantics. see c.price is identical to
inspect c.price; use whichever reads better in context. The
lexer maps both inspect and see to the
INSPECT token, so there's one implementation.
The conformance harness (tests/km-conformance/) uses
inspect instead of println boilerplate, making
the Axioma side structurally parallel to KM's ;;CHECK +
form, and the harness compares the two ordered value lists
positionally.
Test: tests/axioma/concepts/test_inspect.ax.
Cumulative
slots — Concept has slot/cumulative: val
KM-conformant additive slot inheritance. By default
Axioma overrides an inherited slot when a subclass redeclares
it. The has/cumulative slot refinement opts a declaration
into KM's semantics: the newly declared value is
unioned with the inherited value into a set. Override
(default) and accumulate (opt-in) coexist.
concept Animal
Animal has legs: 4
Dog extends Animal
Dog has legs/cumulative: 3 # union with inherited 4
println((a Dog).legs) # → {3, 4}
Cat extends Animal
Cat has legs: 3 # plain decl → override
println((a Cat).legs) # → 3
The refinement attaches to the slot name
(slot/cumulative), parallel to has/identity
but on the slot. Semantics:
- The union is computed at concept-declaration time, so
ismust precede the cumulativehas(the canonical order). - A cumulative slot is always set-valued; set operands are flattened so repeated cumulative declarations down a taxonomy accumulate into one flat set; duplicates drop.
- Slots not declared
cumulativekeep the default override behaviour.
KM mapping: closes the slot-inheritance gap found by
the KM-conformance harness (tests/km-conformance/ case 06) —
KM unions slot values across a taxonomy, and has/cumulative
lets Axioma reproduce that while keeping override as its default.
Test: tests/axioma/concepts/test_cumulative_slots.ax.
Hidden slots, scanners, turtles, and concatenative extras
CLU-style hidden representation. A slot declared
has/private (or has/rep, or
items/private: in a concept block) is visible only to
actions whose defining concept declared it. External reads, writes, and
instance-literal overrides error. A subclass action cannot see a
parent's rep; an inherited parent action still can.
concept Box {
items/private: []
}
Box action push(x) [
it.items: it.items + [x]
]
b: a Box {}
b.push(1)
# b.items → Error: slot items is private to concept Box
The same refinement hides helper actions.
/private and /rep remain aliases. A client
cannot call them; a sibling action of the declaring concept can; a
subclass cannot; an inherited parent action still can.
concept Box {
items/private: []
grow/private: func() [ it.items: it.items + [0] ]
}
Box action/private grow() [ it.items: it.items + [0] ] # statement form
Box action push(x) [
it.grow()
it.items: it.items + [x]
]
Icon-style scanner. Misses are none,
never a failed if.
sc: scanner("hello world")
p: scanner_match(sc, "hello")
scanner_tab(sc, p) # → "hello"
scanner_many(sc, " ") # → 7, or none
Logo-style turtle. 0° is north;
right turns clockwise. forward is a reserved
word but is allowed as a message verb.
t: turtle()
repeat 4 [
t forward 100
t right 90
]
turtle_svg(t)
Generators.
generate [ yield 1; yield 2 ] is an eager collect: the
block runs to completion, then the values are a one-shot
Generator. produce [ yield e ] is the suspend
twin — the body does not run until the first pull, and each
yield pauses so the consumer can stop.
g: generate [
yield 1
yield 2
]
first(g, 2) # → [1, 2]
nats: produce [
n: 0
while true [
yield n
n: n + 1
]
]
first(nats, 5) # → [0, 1, 2, 3, 4]
each(xs) wraps any iterable. yield is legal
in generate or produce. A yield
from a function called by produce is an error;
generate still allows it. force on a
Generator stops at 1_000_000 elements (same cap as
Sequence).
Sequences and cursors
Sequence is the built-in immutable, replayable sequence,
previously named Stream. Stream is the same
type; stream and stream_cons remain supported
spellings of sequence and sequence_cons.
Display uses <sequence> or
<sequence: empty>. Iterable remains the
common iteration interface: Arrays, Lists, Ranges and Generators keep
their distinct types and semantics.
Construction and demand
sequence() is empty. sequence(source)
accepts Array, List, Tuple, Set, String, Bytes, Range, Generator,
Sequence, or an Iterable whose implementation resolves to a supported
source. A String yields characters; Bytes yields Bytes' individual Byte
values. Collection conversion takes a shallow snapshot: later outer
edits are invisible, but nested mutable objects remain shared. A Set's
iteration order has no ordering guarantee. Converting a Sequence returns
it.
Ranges produce their values on demand without building an
intermediate Array. Axioma's notation stays 1..9..2 for an
explicit step; this produces the same values as [1..9..2],
whose brackets materialize an Array. Finite integer, ASCII-character and
Float ranges and open integer ranges are supported.
let odds :: Sequence = sequence(1..9..2)
println(collect(odds)) # [1, 3, 5, 7, 9]
println(collect(odds)) # [1, 3, 5, 7, 9]
println(first(sequence(1..), 3)) # [1, 2, 3]
println(Stream == Sequence) # true
Converting a Generator does not pull it immediately. Demanding a cell pulls and caches that value; replay uses the cache. This is explicit consumption of the original Generator. Pulling that Generator elsewhere changes the suffix that has not yet been cached. Exhaustion and demand errors are cached too. Keeping the sequence root alive retains its realized prefix in memory.
sequence_cons(head, tail) accepts a Sequence tail or an
explicit lazy expression that produces one. An ordinary
transparent thunk is refused. first(s) reads one head;
first(s, n) returns an Array prefix; rest(s)
returns a replayable suffix. first(s, 0) never demands a
cell, and taking a prefix does not demand the tail after its last
requested element. Display and type inspection do not demand cells, so a
deferred empty source may display <sequence> until
read.
Lazy transformations
sequence_map(fn, source)appliesfnonce per demanded cell.sequence_filter(predicate, source)searches lazily using ordinary Axioma truthiness and caches accepted cells.sequence_take(source, count)returns a lazy prefix. Taking zero demands nothing.sequence_drop(source, count)returns a lazy suffix; its skipped prefix is demanded when the result is first read.
Counts are nonnegative machine-sized Integers. Ordinary
map and filter remain eager; on a Sequence
they first materialize the source. collect and
force also materialize it into an Array. Bound infinite
input before using these operations. Materialization has a
1,000,000-element safety cap; sequence_drop accepts at most
1,000,000, and a single filter demand refuses after inspecting 1,000,000
values without a match. These limits are errors, not silently truncated
answers.
let squares = sequence_map(func(x) [x * x], 1..)
let even_squares = sequence_filter(func(x) [x % 2 == 0], squares)
println(collect(sequence_take(even_squares, 4))) # [4, 16, 36, 64]
println(first(squares, 3)) # [1, 4, 9]
Annotations :: Sequence and :: Stream check
the container type. This change does not introduce
Sequence of T element contracts or a universal sequence
interface. Unannotated elements may be heterogeneous.
Cursor navigation
cursor(source) creates a mutable one-based position over
a Sequence or an ordered Array, List, Tuple, String, Bytes or Range.
Ordered collections take the same shallow snapshot as
sequence. To navigate a Generator, explicitly write
cursor(sequence(generator_value)); direct Generator input
is refused.
| Read-only field | Meaning |
|---|---|
.position |
One-based position; starts at 1, even for an empty source |
.value |
Current value, or none at exhaustion |
.done |
Whether the position is exhausted; distinguishes a none
element |
cursor_next(c), cursor_back(c) and
cursor_seek(c, positive_position) mutate and return the
same cursor. Navigation clamps at the beginning or at the position just
after the final element. cursor_copy(c) returns an
independent position sharing the memoized source. Ordinary assignment
aliases the cursor. Reading .value or .done,
and navigation, can demand source cells; .position and
copying do not. Backward navigation traverses the cached prefix from the
root. One navigation call may traverse at most 1,000,000 positions. A
failed navigation leaves the cursor position unchanged; already demanded
source effects cannot be undone.
let c = cursor([10, none, 30])
let bookmark = cursor_copy(c)
cursor_next(c)
println(c.value) # none
println(c.done) # false
println(bookmark.value) # 10
cursor_seek(c, 99)
println(c.position) # 4
println(c.done) # true
cursor_back(c)
println(c.value) # 30
Cursor navigation does not edit the source. A bounded
SequenceView and source-editing cursor operations are
deferred. Cursor is now a built-in type name; teaching
examples formerly declaring a geometric Cursor use
PointCursor instead.
Execution support
The evaluator supports all operations above. The VM supports Sequence
construction and prefix/suffix operations when their input expressions
are otherwise VM-supported. It explicitly refuses
sequence_map, sequence_filter and all cursor
functions, including calls through aliases. Existing VM refusals for
lazy, custom Iterable implementations and unsupported
generator forms still apply.
Type-parameter bounds use of beside the
parameter:
data NumBox[T of Number] = Boxed(T)
The bound is checked at construction. Function contracts keep
requires:.
Forth extras: stack_dip / stack_keep /
stack_cleave; word effect: "( n -- n^2 )" with
word_effect / check_effect; return stack
rpush / rpop / rpeek /
rdepth / rclear.
#language axioma/rpn is additive (infix still runs).
Textbook: HtDKP-in-Axioma Chapter 41. Logo selectors:
butfirst, butlast, logo_item,
sentence.
14. Logic Programming (Prolog-Like)
Axioma provides Prolog-like relational programming through set-based deterministic queries — no backtracking, complete result sets, mathematical foundations.
Facts
Declare a relation with relation (or the short hard
alias rel), then assert facts with assert (or
the graded axiom / postulate — see §15):
relation parent(x, y)
rel age(x, n) # hard alias — same token as `relation`
relation works(x, role)
assert parent("John", "Mary")
assert parent("Mary", "Alice")
assert parent("John", "Bob")
assert age("Alice", 25)
assert works("John", "Engineer")
rel and relation are one reserved word (two
spellings). Designators are optional when the bare logic-var header form
applies — coord(X, Y) still declares a relation with no
keyword (pair is the association constructor, so it is a
defined head). Refinements and show work on both spellings:
rel/persist edge(a, b), show rel parent.
Note: an undeclared bare call like
parent("John", "Mary")at statement position is read as a function call and errors withUndefined word: parent. Declare the relation first (after which a bareparent(...)call adds a fact), or prefix withassert. As a shorthand, a bare header whose arguments are all uppercase-initial and unbound —coord(X, Y)— is itself read as a relation declaration (the optional-relationform), provided the head is not already a builtin (pair(X, Y)constructs an association). A bound TitleCase argument is a real thing (a type Concept, a declared relation,Thing/Nothing), so calls likedoc(Integer),tableform(Edge), orsatisfiable(Doctor)keep their call semantics.
Pattern queries
{X | X <- parent(X, _)} # All parents
{Y | Y <- parent("John", Y)} # John's children
{(X, Y) | parent(X, Y)} # All parent-child pairs
Filtered queries
{X | X <- parent(X, _), age(X, A), A >= 18} # Adult parents
Cross-relation unification (set ops)
parents: {X | X <- parent(X, _)}
workers: {X | X <- works(X, _)}
working_pars: parents ∩ workers
Variable chain unification
# Equivalent to Prolog: grandparent(X,Z) :- parent(X,Y), parent(Y,Z)
johns_kids: {Y | Y <- parent("John", Y)}
johns_grands: {Z | Y <- johns_kids, Z <- parent(Y, Z)}
Complex term matching
relation person(x, attr1, attr2)
relation location(x, attr1, attr2)
assert person("John", ("age", 30), ("job", "engineer"))
assert location("John", ("city", "NYC"), ("country", "USA"))
{X | X <- person(X, _, ("job", "engineer"))} # All engineers
{X | X <- location(X, ("city", "NYC"), _)} # NYC residents
Relations as first-class values
A relation name resolves to a first-class Relation value
(not its fact-adder builtin), so it is type-introspectable and
iterable over its extent — symmetric with how a concept
name iterates its instances:
relation parent(x, y)
assert parent("John", "Mary")
assert parent("John", "Bob")
type(parent) # → "Relation" (@parent is the same)
for (x, y) in parent [ ... ] # iterate facts directly — no comprehension
{(X, Y) | (X, Y) <- parent} # bare relation as a comprehension source
{x | x <- node} # unary relation → bare elements
parent("Alice", "Carol") # STILL callable — adds a fact
A predicate's rules are introspectable too.
rules_of(pred) returns an Array of
RuleClause values, completing the F-logic concept →
relation → rule arc (concepts iterate instances, relations iterate
facts, rules are inspectable as data):
relation edge(x, y)
path(X, Y) :- edge(X, Y)
path(X, Y) :- edge(X, Z) and path(Z, Y)
rs: rules_of("path") # Array of 2 RuleClause
type(rs[1]) # → "RuleClause" (rs[1] is RuleClause → true)
rs[1].head # → "path(X, Y)" (.head_name / .name → "path")
rs[1].body # → "edge(X, Y)"
rs[1].defeasible # → false (.strict → true)
rs[1].direction # → "backward" ("forward" for ==> / ~~>)
{c.head_name | c <- rules_of("path")}
Note: because a relation name is now a value, the KB builtins that take a relation (
insert,forget,truth,set_truth,grounding,cancel,challenge, …) expect the relation's name as a string —grounding("parent", "John", "Mary"), notgrounding(parent, …). See §16 and §17.
HiLog meta-queries
Reflective queries over predicate names and arities:
predicates_of("alice") # All facts mentioning alice
predicate_names() # All predicate names with ≥1 fact
?P("tweety") # Prolog-style meta-call: which predicates mention tweety?
?P("alice", ?Y) # Pattern with wildcard in 2nd position
?P(?X, "carol") # Strict arity, per-position unification
Explicit Datalog and Prolog modes
Axioma supports two explicit logical contracts alongside its existing knowledge engine. Declare the contract on the relation and select the query strategy in the query block. Punctuation never selects an engine.
| Use | Declaration | Query | Result |
|---|---|---|---|
| Complete finite relational analysis | relation/datalog p(x) |
`[logic/datalog | {X |
| Ordered relational search and open terms | relation/prolog p(x) |
`[logic/prolog | X <- p(X)]` |
| Positive recursive search with variant tables | relation/prolog p(x) |
`[logic/prolog/tabled | X <- p(X)]` |
An unqualified relation retains the existing KB
contract. Its defeasible, truth-value and epistemic machinery is not
silently imported into either explicit mode. Redeclaring a relation with
a different mode or arity is an error. Explicit modes require
declarations before use; ordinary relation declarations remain optional
where the existing language permits them.
Complete Datalog answers
relation/datalog edge(x, y)
relation/datalog path(x, y)
assert edge(1, 2)
assert edge(2, 3)
path(X, Y) whenever edge(X, Y) or (edge(X, Z) and path(Z, Y))
reach: [logic/datalog | {Y | Y <- path(1, Y)}]
println(reach.result) # {2, 3}
println(reach.complete) # true
Datalog rules are strict and range restricted: every variable in the
head, a comparison, or a negative goal must occur in a positive
relational goal in each disjunctive branch. Negative dependencies must
be stratified. Positive joins run before filters, independent of their
written order. Negative goals are relational atoms; Boolean combinations
can be written as rule alternatives. Heads contain variables or fully
ground immutable constants; variable-containing list/tuple heads and
arithmetic that generates new head values are rejected. These
restrictions give a finite active domain. Finite does not mean unlimited
resources: exceeding the search budget raises
IncompleteReasoningError and returns no partial set.
Datalog may call only Datalog relations. A Prolog query may call Datalog: its reachable Datalog dependencies are completed first and read as a snapshot. An ordinary relation comprehension may read a declared Datalog relation, but an ordinary query against a Prolog relation is refused with an explicit-query hint. Use explicit blocks when the contract should be visible to readers.
Ordered search and native terms
relation/prolog append(a, b, c)
append([], Ys, Ys) :- true
append([X | Xs], Ys, [X | Zs]) :- append(Xs, Ys, Zs)
splits: [logic/prolog | (A, B) <- append(A, B, [1, 2])]
println(take_solutions(9, splits.result))
# [(`[], `[1, 2]), (`[1], `[2]), (`[1, 2], `[])]
Uppercase identifiers and _ are logical variables.
_ is fresh at every occurrence. Lowercase identifiers
capture host values when a rule or query is created. Each clause
invocation freshens its variables. Unification is structural and has an
occurs check: X == [X] fails rather than creating a
rational tree. Logical atomic equality is type-sensitive; arithmetic
comparisons use host numeric operators. == unifies,
!= tests non-unifiability immediately (it is not a delayed
disequality constraint). Arithmetic requires bound inputs and does not
solve equations backwards. Arbitrary host calls and effects are not
goals.
Terms are immutable scalar values, lists and tuples. Use a tagged
tuple such as ("node", Value, Left, Right) for a compound
term; node(...) denotes a goal, not an implicit term
constructor. Input arrays are snapshotted as logical lists; proper
answers are native List values. An open tail returns
OpenLogicTerm, with residual LogicVariable
values displayed as _G1, _G2, etc. Repeated
variables in one answer share identity; the same display name in
separate answers does not imply identity. Store an answer in a lowercase
host binding to reuse that value in another query.
Rules and clause alternatives are searched in source order, goals
left to right. Different proofs may return the same value. Repeating the
same assert is still idempotent KB insertion; duplicate
rule clauses can produce duplicate proofs. No fairness or termination
guarantee is made for depth-first search.
Words, glyphs, and control
In an explicit rule body or query goal:
| Words | Glyph | Meaning |
|---|---|---|
and |
, |
Sequential conjunction |
or |
; |
Alternative goals |
cut or prune |
standalone ! |
Prune choices made since entering this predicate |
Conjunction binds more tightly than disjunction.
a(X), b(X); c(X) means
(a(X) and b(X)) or c(X). Parenthesize alternatives within a
conjunction. Argument commas, tuple/list separators, statement
punctuation, guards, and host Boolean operators retain their existing
meanings. A trailing semicolon in an explicit goal is not a statement
terminator: it must introduce another goal. Use whenever,
:-, or <== for backward rules. Declarations
in earlier CLI or REPL inputs seed the parser for later inputs;
self-contained source files should repeat compatible mode declarations
before defining further clauses.
Cut succeeds immediately and discards remaining clauses of the
current predicate and alternatives of goals already entered in it.
Caller choices and choices created to its right remain. A cut inside
once(...) or negation is local to that nested goal.
once(goal) exposes its first solution only.
fail exposes none. These controls are refused in Datalog;
cut, once, and procedural negation are also refused in positive tabled
search.
!goal, not goal, and ¬goal
mean procedural negation in depth-first search: succeed only if the
enclosed search finitely exhausts without an answer. They do not return
bindings from that search. Non-ground negation is a test for the absence
of any solution, not an enumeration of a complement. Termination and
ordering therefore matter. Errors or limits propagate rather than
proving absence. Outside logical goals, prefix !, postfix
factorial and the existing bare operator value keep their host
meanings.
relation/prolog married(person)
assert married("Ada")
relation/prolog unmarried(person)
unmarried(X) :- married(X), !, fail
unmarried(_) :- true
answer: [logic/prolog | unmarried("Ada")]
println(take_solutions(9, answer.result)) # []
With a bound person this is the cut-fail idiom. If
married succeeds, cut removes the fallback clause and fail
rejects the call. With an unbound X, a single married
person cuts off the fallback altogether; it does not list unmarried
people. Enumerate a finite person relation before testing absence.
Tabling and completion
/tabled uses variant tables: calls equal up to variable
renaming share answers, and positive recursive consumers are revisited
until no new table answers occur. Answer variants within each predicate
table are deduplicated; a query's own alternatives or projection may
still repeat values. It supports mutual and left recursion and finite
open answers. It is not restricted to a hand-written transitive-closure
recognizer.
Scheduling is local: the first pull completes the reachable table worklist before exposing answers. Thus an infinite family of calls or answers can exhaust the budget before any answer is delivered. Tabling is not a universal termination guarantee. Depth-first order and procedural controls are intentionally unavailable under this strategy; use the depth-first mode when those are the contract.
Stream API and metadata
Every explicit query returns SolveResult. Its
.result is the set or stream. Both query results and
streams expose .semantics, .strategy,
.complete, and .status; possessive access also
works. Strategies are fixpoint, depth_first,
and tabled.
next_solution(stream)returns{done: false, value: answer}or, on finite exhaustion,{done: true, value: none}.take_solutions(n, stream)consumes up tonanswers into an Array. Reachingndoes not establish exhaustion; one further pull might find another answer.close_solutions(stream)releases retained state and marks an open streamcancelled. Repeated close is harmless; a cancelled stream cannot resume.
Status is open, complete,
cancelled, incomplete (resource limit), or
error. Only finite exhaustion makes a stream complete.
Errors remain observable on later pulls. No goroutine runs between
pulls. Current evaluator limits are 5,000,000 search steps and depth
512; these are operational limits, not semantic proof bounds. Taking a
prefix does not reset the budget.
Queries snapshot rules, captured terms, and live asserted facts at
creation. Later KB writes do not change an existing stream. Search
answers do not implicitly become asserted facts, external KB writes, or
proof-core certificates. These modes are evaluator features.
--vm refuses the unsupported declarations and blocks;
parser acceptance or a type-check pass is not a VM implementation.
Executable coverage:
tests/axioma/logic/test_explicit_modes.ax,
test_explicit_control.ax, and
evaluator/logic_modes_test.go.
15. Knowledge Base, Axioms & Postulates
Declaring knowledge — the six grounding grades
Every stored fact carries an epistemic grounding — how strongly it is held — on this ordered ladder:
axiom > postulate > theorem > conjecture > hypothesis > datum
You declare a fact at a grade with the matching keyword; derivation
and runtime insert produce the rest. Read a fact's grade
back with grounding(rel, args…).
| Grade | How you express it | Meaning |
|---|---|---|
axiom |
axiom fact |
foundational truth — the highest grade |
postulate |
postulate fact |
a tentative claim that may be refuted |
theorem |
derived by a strict rule (§16), or
insert(rel, …, "theorem") |
proven from axioms/postulates |
conjecture |
derived by a defeasible rule (§16), or
insert(rel, …, "conjecture") |
plausibly inferred; may be defeated |
hypothesis |
hypothesis fact, or
insert(rel, …, "hypothesis") |
a working assumption |
datum |
plain assert fact, or insert(rel, …) with
no grade |
a bare fact — the floor of the ladder |
axiom parent("Adam", "Cain") # foundational
postulate married("Cain", "Awan") # tentative — may be refuted
hypothesis visited("Cain", "Nod") # a working assumption
assert lives_in("Cain", "Nod") # plain fact → grounding "datum"
grounding("parent", "Adam", "Cain") # → axiom
# theorem / conjecture come from derivation (§16); or set any grade at runtime:
insert("parent", "Seth", "Enos", "theorem")
Grounding tracks derivation provenance — a strict
rule yields a theorem, a defeasible rule yields a
conjecture. The derived grade is fixed by the rule
type (strict vs. defeasible), not by the
premises' grades: the tier records how a fact was derived, not
a confidence floor over its inputs (a theorem strictly derived from a
datum-grade premise is still a theorem). See
§16 for the
mechanics, and challenge (below) / cancel ·
force_cancel (§17) for
revising graded facts.
Persistence control
axiom/persist gravity = 9.81 # Saved to cascade.db
axiom/transient demo_axiom = 42 # Session-only
postulate/persist working = "..."
postulate/transient debug = "..."
Default: REPL persists, scripts are transient.
The public persistence forms are the /persist and
/transient refinements shown above, plus the
declare persistence forms in §3.
The shared SQLite KB
Axioma and Cascade share a
single SQLite database (cascade.db) using the same schema.
Both processes can read/write concurrently.
./axioma --no-kb script.ax # Skip Cascade KB preload (~10× faster)
16. Rules, Derivation & Epistemic Grounding
Rule forms
| Form | Direction | Strictness | Produces |
|---|---|---|---|
head whenever body — primary (also
head if body; operator twins: <==, legacy
<=, Prolog :-) |
Backward | Strict | Theorem |
typically head whenever body — primary
(≡ normally, rule~ head if body,
rule/defeasible; operator twin <~~) |
Backward | Defeasible | Conjecture |
body ==> head |
Forward | Strict | Theorem |
body ~~> head |
Forward | Defeasible | Conjecture |
Strict rules
The primary spelling is the natural one — the rule
connective is the word whenever, so a clause reads as
English and wears its logic honestly: "whenever" is English's
universal conditional, exactly the ∀-bound Horn head ("there is a path
from X to Y whenever there is an edge from X to Y" —
true of every pair). The other spellings remain first-class:
head if body (Visual Prolog's word, gated — see below),
<== (and legacy <=) and Prolog's
:- in arrow/Prolog dress, and the forward
direction is operator-only (==> /
~~>):
grandparent(X, Z) whenever parent(X, Y) and parent(Y, Z) # primary
grandparent(X, Z) if parent(X, Y) and parent(Y, Z) # if-form (gated)
grandparent(X, Z) <== parent(X, Y) and parent(Y, Z) # operator twin (also <= / :-)
parent(P, Q) and parent(Q, R) ==> grandforward(P, R) # forward — operator only
All spellings store the same clause and share the fixpoint engine, multi-clause accumulation, theorem grounding, and safety diagnostics:
path(X, Y) whenever edge(X, Y) # primary natural spelling
path(X, Y) whenever edge(X, Z) and path(Z, Y) # recursive clause
path(X, Y) :- edge(X, Y) # same rule, Prolog dress
mortal("socrates") whenever human("socrates") # ground head — works bare
whenever is a soft keyword
(whenever: 5 still binds a variable) with no conditional
reading, so the shape head(args) whenever body is a rule by
fiat — no gate, any argument casing, ground heads included. The
if spelling is different: because EXPR if COND
is also the postfix conditional modifier
(cancel(d) if challenged(d)), bare if reads as
a rule only when the head call's arguments include an uppercase
logic variable and the head does not resolve
to a callable — push(s, V) if V > 3 keeps its
conditional meaning (push is a builtin), while
path(X, Y) if edge(X, Y) stores a rule clause. With the
explicit rule prefix there is no gate for either word:
rule H(args) whenever body and
rule H(args) if body are always rules. Both bare natural
forms map to the strict reading only; for the
defeasible natural spelling see typically in the next
subsection.
Complete and incomplete relation queries
Recursive relation queries must finish their derivation before
returning an answer. If the evaluator reaches a derivation limit, it
returns an IncompleteReasoningError, a direct sub-Concept
of Error, whose kind is
"IncompleteReasoningError". It does not return a partial
collection, false, or an empty result that could be
mistaken for proof of absence. This also applies to negated relation
queries, derive, result forms, and relation tables that
trigger the same derivation engine.
relation reached(n)
assert reached(0)
reached(N) whenever reached(M) and N == M + 1 and N <= 100
attempt: try({N | N <- reached(N)})
expect("derivation limit is explicit", attempt is IncompleteReasoningError, true)
expect("still an error", attempt is Error, true)
expect("kind agrees", attempt.kind, "IncompleteReasoningError")
The eager engine allows 64 passes. The demand engine allows 100,000 expansion steps and a fixpoint budget based on the demanded inputs plus 64 passes. The recursive goal solver allows 5,000,000 goal steps and 512 nested goals; an unsupported cycle through an unfinished goal also reports incompleteness. A host evaluation-step or recursion-depth limit reached inside derivation aborts it as well. These are implementation limits, not language guarantees that every finite query will complete.
Valid positive facts derived before exhaustion remain cached. A later query can use them, but succeeds only after completing its own derivation. In the example above, a second query can finish and return all 101 values. Negation cannot use an incomplete positive extent to establish absence. This contract describes the evaluator's relation-query engine; it does not claim completeness for every separate reasoning or solver API. Relation declarations remain outside the VM's supported subset.
Defeasible rules
A defeasible rule states a default — an
inference that holds unless overridden. Where a
strict rule's conclusion is a theorem that
always follows, a defeasible rule's conclusion is a
conjecture: a plausible default that can be
defeated by a stricter rule, by conflicting evidence, or by an
explicit cancel. This is how Axioma expresses "typically /
normally / by default" reasoning (non-monotonic logic) — birds
typically fly, even though penguins don't. And that literature
gloss is the primary spelling: the statement prefix
typically (synonym twin: normally) marks the
clause defeasible, in Reiter's own words:
typically flies(X) whenever bird(X) # PRIMARY — "birds typically fly"
normally sings(X) if bird(X) # synonym twin; composes with if too
The operator twins write the same default in arrow dress:
flies(X) <~~ bird(X) # backward: flies(X) holds by default when bird(X) does
bird(X) ~~> flies(X) # forward: the same default, written premise-first
(The infix use of the same word —
birds typically fly — is the separate default-logic engine
with Reiter extensions, unchanged; the rule marker is the
statement-start position only.)
The rule prefix offers two further equivalent markers,
both lowering to the same clause as <~~:
rule~ flies(X) whenever bird(X) # tilde marks defeasible (like defines~ / unify~)
rule/defeasible flies(X) if bird(X) # refinement spelling (like concept/persist)
rule/strict walks(X) if bird(X) # explicit spelling of the strict default
Every marker composes idempotently with a matching operator
(typically chirps(X) <~~ bird(X) and
rule~ sings(X) <~~ bird(X) are fine), while a
contradiction such as typically p(X) :- q(X) or
rule~ p(X) :- q(X) is a loud parse error ("conflicting rule
strength") — an explicit defeasibility marker never silently stores a
strict clause.
Both operator forms express the same rule and
produce conjecture-grade conclusions; they differ only
in reading direction — <~~ writes the conclusion first,
~~> the premise first (see the rule-forms table above).
Their strict twins <= (backward) and ==>
(forward) instead produce theorems:
| Strict (→ theorem) | Defeasible (→ conjecture) | |
|---|---|---|
| backward (conclusion ← body) | <= |
<~~ |
| forward (body → conclusion) | ==> |
~~> |
A defeasible conclusion is overridden when a strict
rule proves the contrary, or by a cancel /
force_cancel directive (§17) — and
grounding-aware cancel refuses to defeat a theorem. So
flies("tweety") is a conjecture you can retract, while a
theorem stands until you revise its premises.
Frame-logic rule bodies
Bodies can mix is, function calls, attribute paths, and
conjunctions:
TradeLink extends Concept
TradeLink has source
TradeLink has target
{tl | tl is TradeLink, tl.target.gdp_billion > tl.source.gdp_billion * 2}
Epistemic grounding (six ordered grades)
axiom > postulate > theorem > conjecture > hypothesis > datum
- axiom — declared with
axiom. Foundational. - postulate — declared with
postulate. Tentative. - theorem — derived by a strict rule from axioms/postulates.
- conjecture — derived by a defeasible rule.
- hypothesis — user-marked working assumption.
- datum — a plain
asserted fact, or a runtimeinsert(...)without explicit grounding — the floor of the ladder.
Strict rules produce theorems, defeasible rules produce conjectures —
the derived grade is fixed by the rule type, recording
derivation provenance rather than a min(body_groundings)
confidence floor. A theorem strictly derived from a
datum-grade premise is still a theorem, not a
datum.
The introspection builtin is
grounding, and the floor grade isdatum.
Grounding & Kind as first-class values
grounding(...) and truth_kind(...) return
typed values, not bare strings. A Grounding is
ordered (the ladder above); a Kind is a
flat five-member set (logical /
empirical / transcendental /
motive / metalogical). Both coerce against a
plain String, so existing == "axiom" comparisons keep
working.
relation edge(x, y)
axiom edge("a", "b")
path(X, Y) :- edge(X, Y)
g: grounding("edge", "a", "b") # → axiom (a Grounding value)
type(g) # → "Grounding" (g is Grounding → true)
grounding("edge", "a", "b") >= "conjecture" # axiom >= conjecture → true (ordered)
grounding("edge", "a", "b") >= grounding("path", "a", "b") # axiom >= theorem → true
relation color(x, y)
axiom/empirical color("apple", "red")
k: truth_kind("color", "apple", "red") # → empirical (a Kind value)
type(k) # → "Kind"
k == "empirical" # → true (String coercion — no ordering on Kind)
The possessive accessors agree with the builtins:
fact's grounding and fact's kind return the
same Grounding / Kind value types.
Bilattice truth propagation
Every stored fact carries a Belnap B4 value (T,
F, Both, Neither) that propagates
through Horn-clause bodies via lattice meet. Paraconsistent
contamination (Both) survives derivation.
set_truth("parent", "John", "Mary", "true")
truth("parent", "John", "Mary") # Returns Belnap value
Provenance & introspection
The relation is named by string (a relation name is now a first-class value — §14):
grounding("parent", "John", "Mary") # a Grounding value: axiom / theorem / conjecture / datum / ...
proof("grandparent", "John", "Alice") # Walks derivation chain back to axioms
why grandparent("John", "Alice") # Prose explanation (keyword form — takes the conclusion call)
rules_of("path") # The predicate's rules as first-class RuleClause values
Challenging axioms
Axioms must be challengeable (Q1 requirement):
challenge("parent", "John", "Mary") # Marks suspect
challenged("parent", "John", "Mary") # Returns true/false
The axiological (value) axis
Orthogonal to epistemic grounding, a fact can carry an explicit value judgment relative to an authority. The axis has three judged stances plus a distinct fourth "never judged" state:
epictetus: agent("epictetus", "Stoic — virtue is the only good")
value_good("courage", "any_agent", epictetus)
value_bad("cowardice", "any_agent", epictetus)
value_indifferent("wealth", "any_agent", epictetus) # the Stoic adiaphoron
value_kind_of("courage", "any_agent") # → "good"
value_kind_of("wealth", "any_agent") # → "indifferent" (a JUDGMENT)
value_kind_of("has_blue_eyes", "any_agent") # → "neutral" (SILENCE — never valued)
facts_by_value_kind("indifferent") # enumerate the judged-indifferent facts
value_indifferent makes "positively judged
neither-good-nor-bad" a first-class stance, distinct from a fact that
was simply never valued (neutral) — the difference between
the Stoic adiaphora and mere silence.
Aspectual facts — qua
qua (Latin "in the capacity of") makes the same
fact viewable under a handle, and rules can key on the
handle — so one fact can carry two value-laden framings with divergent
consequences (intensionality made executable):
relation departed(x)
departed("the_estate") qua "lost"
departed("the_estate") qua "given_back"
framings_of("departed", "the_estate") # → {"lost", "given_back"}
framed_qua("departed", "the_estate", "lost") # → true
lost_description(it) <~~ it qua "lost" # `it` is the canonical fact string
returned_description(X) <~~ departed(X) qua "given_back"
# Both descriptions coexist; no person or psychological state is implied.
it qua "h" binds it to every fact tagged
h; the relation-anchored form rel(X) qua "h"
instead binds X to the argument. qua stays an
ordinary identifier outside this position (a same-line soft
keyword).
A handle is metadata on an asserted fact, not an agent's viewpoint or
a time index. Add explicit relation arguments to represent who adopts an
appraisal and when. proof records the ordinary underlying
fact as the premise of an aspect rule; framings_of and the
rule body identify the selected handle.
forget and forget_cascade remove the fact's
handles as well as its stored truth and proof metadata. A later plain
assertion does not restore those handles. forget_cascade
also withdraws dependent generic and anchored aspect conclusions,
including transitive ones; an independent surviving justification can
re-derive a result. Plain forget stays non-cascading.
cancel retains handles for uncancel, but an
inactive base fact cannot seed a new aspect conclusion. Use cascading
premise revision when existing consequences must be withdrawn.
Transaction rollback restores removed handles;
drop_relation removes the dropped relation's handles.
17. Mutation, Transactions & Conflict Resolution
Runtime mutation
The relation is named by string (relation names are first-class values now — §14):
insert("parent", "Eve", "Seth") # Defaults to grounding "datum"
insert("parent", "Eve", "Seth", "axiom") # Explicit grounding
forget("parent", "Eve", "Seth") # Retract from all groundings
Note:
deleteandretractare reserved keywords. Useforgetfor the function-call form. The statement formretract [...]is a separate existing feature.
Atomic transactions
transaction_begin()
insert("parent", "X", "Y")
set_truth("parent", "X", "Y", "true")
transaction_commit() # OR transaction_rollback() — undoes ops AND metadata
A transaction belongs to the call that opened it. A function that opens one and returns without committing has its transaction rolled back on the way out, and a warning names the line it was opened on. An uncommitted transaction does not quietly become permanent:
enrol: func() [
transaction_begin()
insert("parent", "Eve", "Seth")
"done" # ← never committed
]
enrol() # → "done"
# Warning: transaction opened at line 2 was never committed
# — rolled back (1 ops undone)
A callee that merely participates is not an owner. Reads walk outward, so a function called from inside an open transaction writes into it, and may even commit it — but its own return undoes nothing:
add_one: func() [ insert("parent", "Seth", "Enos") ]
transaction_begin()
add_one() # participates; returning changes nothing
transaction_commit() # the write stays
A block is transparent and a frame
is not: transaction_begin() inside an if
branch or a loop body belongs to the enclosing function, exactly as an
accumulator assignment does (see Scoping
& shadowing).
At the top level there is no frame to leave, so a
transaction opened there stays open until the program ends and its
writes stand for the rest of the run — roll back explicitly if that is
not what you want. Transactions do not nest:
transaction_begin() while one is already open is an
error.
Defeasible-conflict resolution
relation bird(x)
flies(X) <~~ bird(X)
assert bird("tweety") # Tweety conjecturally flies
cancel("flies", "tweety") # Tweety is a penguin
canceled("flies", "tweety") # true
uncancel("flies", "tweety") # Restore
Canceled facts retain their provenance but are filtered out of comprehensions.
Grounding-aware cancellation
cancel is grounding-aware: it refuses
to defeat a derived theorem (a strict consequence),
returning a non-fatal "refused: …" string that points you
at revising a premise (challenge) or overriding with
force_cancel. Defeasible conclusions (conjectures) and base
posits (axioms / postulates / data) stay directly cancelable.
tranquil(P) <== free(P) # strict rule → derives a theorem
assert free("sage")
cancel("tranquil", "sage") # → "refused: … is a theorem …" (NOT canceled)
force_cancel("tranquil", "sage") # → "canceled: tranquil(\"sage\")" (explicit override)
forget_cascade
— justification-based retraction (a TMS)
Plain forget retracts a premise but
orphans the conclusions already derived from it.
forget_cascade walks the derivation chains forward,
withdraws the premise and every materialized fact that
transitively depends on it, then lets lazy re-derivation rebuild
whatever still has independent support:
disturbed(P) <~~ judges_bad(P)
assert judges_bad("novice")
{p | p <- disturbed(p)} # → {"novice"}
forget_cascade("judges_bad", "novice")
{p | p <- disturbed(p)} # → {} (the conclusion lifts with its premise)
A conclusion that also follows from a surviving rule re-derives
automatically on the next query. This applies to strict and defeasible
consequences: a theorem label is not immunity to premise
revision. Removing a modeled disturbance claim does not establish that a
person feels better, nor does an empty result establish an explicit
negative fact.
18. Stack-Based Programming
Axioma implements a first-class stack model:
- a first-class
Stackvalue — construct it withs: a Stackor withstack(); both build a realStack(@s→"Stack").stack(c)additionally converts an array, set, tuple or finite range, with position 1 as the top:stack([1, 2, 3])isStack[1 | 2 | 3], andarray(s)/set(s)/tuple(s)convert back. (stack_new()was the older nullary-only spelling; retired July 2026 — it now errors with a pointer atstack().) - a global interpreter stack, inspectable from the
REPL with
:stack
A Stack value has two equivalent
surfaces — use whichever reads best:
Natural-language — a message verb mutates (statement position) and a possessive reads (expression position):
s: a Stack
s push 5
s push 10
s's top # → 10 (peek; s's depth → 2, s's empty → false)
s's items # → [10, 5] (top-to-bottom)
s pop # remove the top — value discarded in statement position
v: pop(s) # …so capture a popped value with the pop() function
Mutating verbs: push pop peek
drop clear dup swap
over rot nip tuck.
Possessive reads: top/peek,
bottom/base,
depth/size/
height/length, empty,
items/elements/contents.
top on an empty stack returns none (check
s's empty first). A verb is a statement and dispatches
wherever a statement lives — including
then/else branches and match arms
(if overloaded then [ s pop ]), whose brackets read as
bodies (July 2026).
Function form (Forth / Pop-11) — every operation is
a function taking the stack as its first argument:
push(s, x), pop(s), peek(s),
dup(s), … These are the same operations as
the verbs above (kept as aliases) and are the form used in the tables
below. (The natural-language surface is evaluator-only; under
--vm, use the function form.)
Core operations
| Op | Effect | Description |
|---|---|---|
stack() |
→ s |
Create a new empty stack |
push(s, x) |
… → … x |
Push x onto s |
pop(s) |
… x → … |
Remove and return the top |
peek(s) |
… x → … x |
Return the top without removing it |
depth(s) / stacklength(s) |
→ n |
Current number of items |
clear(s) / erase(s) |
… → |
Empty the stack |
Stack-shuffle operations
All take the stack as the first argument and mutate it in place.
| Op | Effect | Description |
|---|---|---|
dup(s) |
a → a a |
Duplicate top |
swap(s) |
a b → b a |
Swap top two |
rot(s) |
a b c → b c a |
Rotate top three |
over(s) |
a b → a b a |
Copy second to top |
drop(s) |
a → |
Discard top |
nip(s) |
a b → b |
Drop second |
tuck(s) |
a b → b a b |
Copy top below second |
pick(s, i) |
→ … x |
Copy the element at index i (0 = top) to the top |
roll(s, i) |
→ … x |
Move the element at index i to the top |
Bulk & depth operations
| Op | Description |
|---|---|
dupnum(s, n) |
Duplicate top n times |
erasenum(s, n) |
Drop top n items |
Array conversion
| Op | Description |
|---|---|
stack_to_array(s) |
Snapshot the stack as an array, top first |
array_to_stack(arr) |
Build a new stack from an array |
Example
s: stack()
push(s, 1)
push(s, 5)
dup(s) # 1 5 5
swap(s) # 1 5 5 (top two swapped)
println(pop(s)) # 5
println(depth(s)) # 2
println(stack_to_array(s)) # [5, 1] (top first)
REPL inspection
The global interpreter stack is inspected with REPL commands:
:stack # Show stack contents
:s # Alias for :stack
:stack trace # Show stack with type information
:stack depth # Show stack depth
:stack clear # Clear the stack
See tests/axioma/stack/ for comprehensive examples.
19. Three Notations, One Tree
Most languages pick one notation and bury the others under syntactic
surcharge: Lisp commits to prefix, ML to infix, Forth to postfix. Axioma
treats all three as surface forms of the same AST node.
The unifying claim is concrete: for any expression you can write in two
notations, fullform returns the same string.
fullform(2 + 3) # "+(2, 3)" — infix
fullform(+(2, 3)) # "+(2, 3)" — Julia-style prefix
fullform(seq_of(2, 3, +)) # "Sequence(2, 3, +)" — postfix sequence (a different node)
The first two are identical because both parse to the same
InfixExpression. The third is a
SequenceExpression whose stack-reduction semantics happen
to produce the same value.
19.1 Prefix: operators are functions
Every binary operator can be called like an ordinary function. The
parser recognizes op(args) where op is one
of:
+ - * / % # ^ ** .* ./ .+ .- .^ .% .÷
== != < > <= >=
+(2, 3) # 5
*(4, 5) # 20
<(3, 5) # true
+(1, 2, 3, 4) # 10 — left-folded across all args
The variadic form (+(1, 2, 3, 4)) is a left-fold via the
same evalInfixExpression dispatch the infix form uses, so
multi-valued logic dispatch, set operations, and lattice operators all
behave identically. See §9.
19.2 Operators as values
A bare operator name evaluates to a synthetic two-argument function. You can bind it, pass it, and compose it like any other callable:
plus: +
times: *
lt: <
plus(2, 3) # 5
reduce(+, 0, [1, 2, 3, 4]) # 10
reduce(*, 1, [1, 2, 3, 4]) # 24
reduce(-, 20, [1, 2, 3]) # 14
The synthetic function has parameters _op_a, _op_b and
body _op_a OP _op_b — the same dispatch path the infix form
uses. One argument is partial application —
(+)(3) returns a unary function waiting for the right
operand, the same answer as op: (+); op(3). Unary
negation is a different spelling: bare -(5) is
prefix minus (parser), not a prefix-call of the operator value; use that
for unary minus.
+ and - each carry three readings,
disambiguated by what follows the operator:
| Written | Reads as | Because |
|---|---|---|
+(a, b) |
operator-prefix call — addition | a ( follows |
+ |
operator-as-value — the binary lambda above | a terminator follows (, ] )
end of line) |
+5 |
unary plus — the identity | anything else follows |
Unary plus is the identity on every numeric type and a
rejection on anything else: +"a" errors
exactly as -"a" does. It is not a cast. The one place the
twins deliberately differ is Byte: -byte(200)
widens to Integer because a negative Byte
cannot exist, while +byte(200) stays a Byte —
a no-op that changed the type would not be one.
Before unary plus existed, a bare + returned the
operator lambda regardless of what followed, so
x: +5 bound a function and silently
dropped the 5, while x: -5 bound
-5. That asymmetry is what the operator closes.
Parentheses:
(OP) is the value; (OP e) and
(e OP) are sections
Three corners, all shipped. Wrapping an operator in parentheses is the spelling that reads inside an argument list, where a bare operator would sit next to a comma:
(/)(10, 2) # 5 the operator itself
(/ 2)(10) # 5 right operand fixed — λx. x / 2
(10 /)(2) # 5 left operand fixed — λx. 10 / x
plus: (+)
reduce((*), 1, [1, 2, 3, 4]) # 24
lt: (<)
lt(2)(3) # true — the value form partially applies
A right section (OP e) is a
one-argument function with the operand fixed on the right. A
left section (e OP) is the mirror —
operand fixed on the left. Both desugar to the same lambda node
(lambda __sec_arg__ => …), so --vm inherits
them. Whitespace is irrelevant: (>2) and
(> 2) agree; (1+) and (1 +)
agree.
half: (/ 2) # λx. x / 2
gt2: (> 2) # λx. x > 2
sq: (^ 2) # λx. x ^ 2
from10: (10 -) # λx. 10 - x
inc: (1 +) # λx. 1 + x
twice: (2 *) # λx. 2 * x
half(10) # 5
map((> 2), [1, 3]) # [false, true]
map((10 -), [1, 2, 3]) # [9, 8, 7]
filter((5 <), [1, 9, 3, 7]) # [9, 7]
Non-commutative operators are where the two sections disagree, and that disagreement is the feature:
(2 ^)(3) # 8 2³
(^ 2)(3) # 9 3²
(100 /)(4) # 25
(/ 100)(4) # 4/100
| Written | Reads as |
|---|---|
(/) |
the operator value — a binary function |
(/ 2) |
a right section — λx. x / 2 |
(10 /) |
a left section — λx. 10 / x |
Right sections do not start with +,
-, or *. Those tokens already mean
something in prefix position: (-2) is the negative number,
(+2) is 2, and (*p) is a pointer
dereference. The space does not change this —
(- 2) is also just -2, not
λx. x - 2, and (* 2) is a dereference error
rather than λx. x * 2. Miranda has the same minus exception
((-1) is the number).
Left sections have no such carve-out. The operator
sits in trailing position, where nothing else claims it, so
+, -, and * all work on the left:
(1 +), (10 -), (2 *). That is how
increment, “times two”, and “subtract from ten” are spelled as sections.
For “subtract two” (λx. x - 2) write
(lambda x => x - 2) or plus(-2) — there is
no right minus-section, on purpose.
inc: (1 +) # not (+ 1) — that is the number 1
twice: (2 *) # not (* 2) — that is a dereference
dec: lambda x => x - 1 # not (- 1) — that is the number -1
For a long while the value form worked only for + - *
and the dotted family. The other eleven —
/ % ÷ ^ ** == != < > <= >= — are exactly the
operators that can open a section, and the section reading claimed them
before checking whether a right operand was actually present,
so (/) was a syntax error while f: / had
always worked. A ) immediately after the operator now
settles it: no operand, no section, it is the value.
Prefix-call arity is uniform with the value form:
(+)(3, 4) # 7 — two args: the operator call
(+)(3) # a function — one arg: partial application
(+)(3)(4) # 7
map((+)(1), [1, 2, 3]) # [2, 3, 4] — RWO-style ((+) 3) port
lt: (<)
lt(2)(3) # true — binding is optional, not required
Zero arguments still refuse ((+)()).
partial((+), 3) remains valid when you want the combinator
spelling.
19.3 Infix: functions are operators
§19.1 and §19.2 move operators into function position. This moves functions the other way. Wrap a name in a matched pair of backticks and it sits between its operands:
mysum(x, y) = x + y
3 `mysum` 4 # 7 — identical to mysum(3, 4)
5 `max` 3 # 5 — builtins too
"hello" `contains` "ell" # true
There is nothing to declare. Every function of two arguments already
has this form, in every spelling — f(x, y) = …,
func (a, b) [ … ], lambda (x, y) => … —
along with builtins, imported names, and anything bound to a function
value.
The form is a parse-time desugar to an ordinary
call, the same treatment |> gets.
a `f` b becomes f(a, b) before
anything downstream sees it, which is why there is no second set of
rules to learn: f resolves however function position
resolves it, and partial application, arity errors, multi-clause
dispatch, contracts, and operator overloads all behave exactly as they
do for the prefix spelling.
fullform(3 `mysum` 4) # "mysum(3, 4)" — one tree, as §19 claims
Precedence is that of * — left-associative, beside the
word-shaped infixes mod and div:
1 + 3 `mysum` 4 # 8 binds tighter than +
3 `mysum` 4 * 2 # 14 ties with *, left-associative
20 `mysub` 5 `mysub` 3 # 12 chains left: (20-5)-3
3 `mysum` (4 * 2) # 11 parentheses override
The closing backtick is what makes it safe
An unpaired backtick is the ASCII glyph digraph of
§3 — `in → ∈,
`cup → ∪ — and remains exactly that. Only a matched
pair is infix application.
The distinction is made by shape alone. Nothing in the path consults the glyph catalog, so the two namespaces cannot overlap and a name can occupy both at once with no interference:
cup(a, b) = a * 1000 + b
2 `cup` 3 # 2003 the function
{1, 2} `cup {3} # {1, 2, 3} the ∪ glyph
Had the decision been made by asking "is this a known glyph name?",
adding a glyph named cup later would have silently changed
the meaning of code already written. The closer removes that possibility
by construction rather than by care.
Without backticks, nothing changes
3 mysum 4 is still three statements — Axioma has no
implicit multiplication and no bare-word infix, so the binding takes
3 and the rest is discarded:
juxt: 3 mysum 4 # juxt is 3, not 7
juxt: 3 `mysum` 4 # juxt is 7
The form is opt-in at the use site precisely so that adding it could
not disturb any existing reading. A declaration form
(infix mysum) was considered and rejected for the opposite
reason: it would have made println(x) mysum(3, 4) on one
line mean mysum(println(x), (3,4)), silently changing text
that already appears in the corpus.
Declared binary symbolic operators
Define an ordinary function, then give it a symbolic spelling at file top level:
minInt :: Integer -> Integer -> Integer
minInt(x, y) = if x > y then y else x
operator (&&&) = minInt
println(8 &&& 3) # 3
println((&&&)(8, 3)) # 3
Both uses lower to ordinary calls. (symbol) is the
target's ordinary function value, usable in bindings and higher-order
functions. Types, contracts, guards, dispatch, errors and evaluation
rules belong to that function. The declaration adds no short-circuiting
and is not a type form. The target is a bare name or dotted module
member, never a computed expression.
The short declaration defaults to product precedence and
left grouping. For a different static fixity, use:
subtract(x, y) = x - y
operator (⊛) = subtract with
precedence: sum
associativity: right
end
println(20 ⊛ 5 ⊛ 2) # 17
Allowed precedence bands, from loose to tight: pipe,
fallback, implies, or,
and, equality, comparison,
range, sum, product,
power. No numeric binding powers or executable metadata
expressions are accepted. Fields may be omitted (their defaults apply);
unknown or repeated fields fail. Associativity is left,
right, or none. Non-associative chains at the
same band require parentheses. Mixed custom operators with different
associativity at one band also require explicit grouping.
The spellings && and || are
built-in aliases for and and or, including
precedence, short-circuiting and multivalued dispatch. They cannot be
redeclared as custom operators. Longer new runs such as
&&& remain available.
Names use runs of ASCII !%&*+-/<=>?^|~ or
unreserved Unicode mathematical symbols such as ⊛. Existing
complete operators, reserved glyphs, comments, graph delimiters,
identifier characters and structural punctuation are protected. Longest
declared spellings are recognized at token boundaries; adding a shorter
spelling never steals a longer built-in token. Existing literal and
identifier suffixes still apply, so use spaces around operators when
needed. Without a matching declaration, 2+-3 keeps its
original -1 result. Strings and comments do not activate
declarations.
Declarations precede uses and apply only to that source file.
Imported functions require an explicit local declaration, for example
operator (&&&) = Helpers.minInt; ordinary
imports never export syntax. Nested modules, functions and runtime
blocks cannot declare operators in this release. Uses retain their
parsed target and grouping, with ordinary lexical name lookup when
executed.
Native REPL sessions retain declarations across submissions and
:source / :refresh. Repeating the same symbol,
target and fixity is idempotent; conflicts are rejected. Rebinding the
target body remains an ordinary function operation. Failed parsing
installs nothing; evaluation must reach a declaration for it to persist.
Images do not serialize the syntax table; source it again.
Binary infix and parenthesized callable forms work in both runtimes
when the target does. Unsupported target features retain their explicit
VM errors (including guarded clauses, contracts, and apply
on a VM closure). Custom binary sections are supported; custom unary and
postfix operators are not added. Existing built-in operator sections
remain unchanged. See Chapter 10 for
the optional lesson, exercise and solution; doc("operator")
gives offline help.
Generic targets,
sections, and declared ++
A detached signature belongs to the target function. The operator
uses that same contract; it does not need a second signature.
++ has no built-in meaning, but an explicit declaration may
now give it a binary meaning:
join_lists[T] :: List of T -> List of T -> List of T
join_lists(xs, ys) = xs + ys
operator (++) = join_lists with
precedence: sum
associativity: right
doc: "Concatenate two Lists with the same element type."
end
println(`[1] ++ `[2] ++ `[3]) # `[1, 2, 3]
println((`[1] ++)(`[2])) # `[1, 2]
println((++ `[2])(`[1])) # `[1, 2]
println(join_lists(`[], `[2])) # `[2]
println(error?(try(join_lists(`[1], `["s"])))) # true
Without a declaration, ++ still reports the existing
increment diagnostic. This supersedes only its previous blanket
reservation: no increment operation or built-in concatenation spelling
is introduced. -- remains reserved.
Custom sections (left OP) and (OP right)
are unary functions. Like existing built-in sections, they evaluate the
fixed operand on each invocation, then call the declared target with
operands in their original order. (OP) remains the ordinary
target value; (OP)(left) uses its ordinary partial
application. These are binary sections, not custom unary or postfix
operators.
doc("++") reports the active declaration's target,
precedence, associativity, current target signature and operator
documentation (falling back to the target), without calling it. An
unbound target is reported as such. Editor hover and completion also
include a locally available target signature; imported signatures may be
unavailable.
The metadata slots are precedence,
associativity, and doc. Fixity values are a
fixed parser vocabulary: both sum and "sum"
are accepted, but a variable holding that text is not evaluated. These
labels are not runtime enum values. doc accepts a literal
String, including triple-quoted and raw strings; expressions and
interpolated strings are rejected.
doc("++") prints the operator's documentation and
returns none. An omitted, empty, or whitespace-only
doc falls back to the target function's documentation.
Operator documentation does not change doc("join_lists"),
and it is available even before the target is bound. A compatible
redeclaration replaces the doc text (omitting it restores the fallback);
changes to the target or fixity still conflict. Editor hover and
completion show the declared documentation.
Unannotated Lists remain heterogeneous. Explicit
List of T recursively checks its elements, including nested
Array/List/product structures. Empty Lists supply no evidence for T. Two
empty inputs can return an empty List; they cannot justify a nonempty
result whose element type is an unresolved T. Partial calls retain
established types and recheck captured values, including mutable Arrays
nested inside Lists. A bare T still checks only its outer runtime
type.
Generic List contracts and generic operator targets are
evaluator-only; the VM refuses them. Custom sections work in the VM when
their ordinary target does. --typecheck checks supported
List evidence; the optional HM --infer lens does not yet
model Lists and must not be read as a proof of their bodies.
19.4 Postfix: comma sequences
A comma-separated chain whose elements include at least one operator
or stack word is parsed as a SequenceExpression and
evaluated by stack reduction:
2, 3, + # → 5
2, 3, +, 4, * # → 20 (push 2, push 3, +, push 4, *)
10, 4, +, 6, 2, -, * # → 56
Sequence semantics: walk the elements left-to-right; numeric/string/array literals push onto a parse-time stack; an operator pops the top two and pushes the result; a stack-shuffle word rewrites the stack (see below). The whole sequence reduces to the single remaining stack value.
A sequence is the right-hand side of an ordinary expression — so it composes:
r1: 2, 3, + # r1 = 5
r2: 2, 3, +, 4, * # r2 = 20
r3: 10, 4, +, 6, 2, -, * # r3 = 56
2, 3, +, . # prints 5 — Forth `.` pops and prints
println(r1) # prints 5 (inside a CALL, commas separate
# arguments: println(2, 3, +) passes THREE
# args — bind the sequence first)
Postfix sequences live inside the expression grammar rather than seizing the whole program, so they coexist peacefully with infix and prefix in the same line.
Multi-bind still works
The parser only collapses a chain to a sequence when at least one element is an operator or stack word. The classic multi-bind pattern is unchanged:
a, b: 5, 6 # a = 5, b = 6 — not a sequence
19.5 Stack-shuffle words inside sequences
Inside a SequenceExpression, the stack-shuffle
vocabulary is in scope and operates on the local parse-time stack rather
than the global interpreter stack from §18:
| Word | Effect | Stack effect |
|---|---|---|
dup |
Duplicate top | a → a a |
swap |
Swap top two | a b → b a |
rot |
Rotate top three | a b c → b c a |
over |
Copy second to top | a b → a b a |
drop |
Discard top | a → |
nip |
Drop second | a b → b |
tuck |
Insert top under second | a b → b a b |
20, 4, swap, / # 4 / 20? no — swap before /: 4, 20, / → 0
20, 4, /, 2, + # 20 / 4 + 2 → 7
1, 2, 3, rot, -, + # rot: 2,3,1 → 3-1=2 → 2+2 = 4
These names shadow the same-spelled global-stack operations only inside a sequence; outside sequences the global-stack semantics from §18 apply.
19.6 The . (dot)
inspect sigil
A trailing . prints the value of the preceding
expression — a compact "print-and-go" — and works in two positions:
Statement-trailing (after any top-level expression):
2 + 3 . # prints 5
x: 42 . # prints 42
fullform(2 + 3) . # prints "+(2, 3)"
The dot may also sit tight against the expression —
a sentence-style full stop. It works after every literal family and
closer: 2 + 3., 2.5., 10% + 5%.,
$100 + $25., 1 => 2.,
type(x)., [1, 2]. all print. A .
not followed by a digit never reads as a decimal point, so
$100.50 and 2.5 stay intact while the trailing
dot lexes as the sigil. The one exception: literals whose own syntax
contains dots — URLs, emails, file paths, dates — absorb a tight
trailing dot into the literal (https://example.com. is a
valid trailing-dot FQDN), so use the spaced form . after
those.
Sequence-internal (pops the current top of the
parse-time stack and prints it — the . is consuming, not
duplicating):
2, 3, +, . # prints 5 (stack empty after)
20, 4, /, 2, +, . # prints 7 (stack empty after)
2, 3, +, ., 10, 5, -, . # prints 5, then prints 5 (two checkpoints)
What prints when the value is none. A
value read prints it — a property access, an index, or a bare
name is a question, and none is a real answer:
cher: { firstName: "Cherxilyn" }
cher.middleName. # prints none — the key is absent
cher["nope"]. # prints none
n: none
n. # prints none
v: cher.middleName. # prints none, and binds v
Statements that merely have nothing to say stay silent, because in
Axioma they return none too — println, a loop,
an untaken if, and an empty function body all evaluate to
it:
println("a"). # prints just: a
foreach i in [1] [ i ]. # prints nothing
if false then [ 1 ]. # prints nothing
So the rule reads the shape of the statement, not the value:
none is one value doing two jobs, and only the shape says
which job it was doing. om is unaffected — no void
construct returns it, so om. always prints
Ω.
The dot is parsed as the existing PERIOD token wrapped
in a ScopedStatement for the trailing form, and as a
stack-print step for the sequence-internal form. It is interchangeable
with inspect / see in spirit but more
compact.
19.7 Tracing sequence reduction
The existing trace keyword accepts a
sequence (or seq) category that prints a
step-by-step view of stack reduction:
trace sequence
20, 4, /, 2, +, .
# push 20 → [20]
# push 4 → [20, 4]
# / → [5]
# push 2 → [5, 2]
# + → [7]
# . (inspect) → [7]
Bare trace is equivalent to trace all —
every category enables, including sequence. The same
untrace / untrace sequence disable mirror.
The domains
Tracing selects by domain — a whole class of operation — not by named function. Every domain answers to several spellings; an alias always selects the whole domain, never a narrower slice of it.
| Domain | Reports | Also spelled |
|---|---|---|
binding |
every value as it is assigned (: and =
distinguished) |
bindings, assign,
assignment |
comprehension |
set / list comprehensions | setcomp, listcomp |
concepts |
concept declaration and instantiation | concept, object,
instantiate |
control |
if / while / foreach |
if, while, foreach |
epistem |
assert / axiom / postulate /
derivation, with the grounding tier |
the whole ladder — axiom, postulate,
theorem, conjecture, hypothesis,
datum — plus assert, retract,
derive |
func |
function application and closure creation | lambda, closure |
quantifiers |
forall / exists |
quantifier, forall,
exists |
reasoning |
rule firing and inference, including the recursive walk | rule, inference |
relations |
relation queries and unification, with answer counts | relation, unification, query,
queries |
sets |
set operations | set, union, intersection,
difference, diff, symdiff |
stack |
stack / sequence reduction | sequence, seq, push,
pop |
probabilistic |
probabilistic operations | — |
transform |
term transformation | — |
all |
every domain at once (what bare trace means) |
— |
An unrecognized domain is a catchable error naming the recognized set — a typo, a domain that prints nothing, and "the traced operation never ran" would otherwise be indistinguishable.
The epistem domain reports the grounding
tier each fact is written at, which is the thing worth
watching: assert writes datum, the bottom of
the ladder, while assert/axiom routes to the axiom path and
writes at the top.
trace/verbose epistem [ assert likes("alice", "bob") ]
# → Asserting Fact
# arity: 2
# grounding: datum
# relation: likes
# ← Fact Asserted (relation): Epistem{type=datum, value="likes(...)", ...} (EPISTEM)
Detail lines are sorted by key, so a /verbose transcript
is byte-reproducible and can be asserted on through
trace_log().
Scoped trace blocks —
trace <domain> [ ... ]
trace has two forms, and the domain list is optional in
both:
| session-scoped | lexically scoped | |
|---|---|---|
| all domains | trace |
trace [ ... ] |
| one domain | trace control |
trace control [ ... ] |
| several | trace func control |
trace func control [ ... ] |
Naming several domains in one statement widens the trace in one go;
untrace func control mirrors it. Every name is validated
before any is applied, so a typo rejects the whole
statement rather than half-enabling it:
trace binding contrl # ERROR: unknown trace domain "contrl" — and
# `binding` is NOT switched on either
The block form traces only its body and restores the previous
state on exit, so a forgotten untrace cannot flood
the rest of the run:
trace binding [ total = 42 ]
outside = 7 # NOT traced — the block restored on exit
println(total) # → 42
Three properties make it composable:
- Additive. A block widens the trace for its
extent; it does not replace what is already on. So
trace control [ ... ]nested inside an activetrace bindingshows both, and leavesbindingrunning afterwards —restoreputs back the prior state, not "off". - Scope-transparent. Bindings made inside the block survive it. A debugging lens must never change the program it observes; if the block opened a scope, switching tracing on would silently alter the result.
- The transcript survives. As with
untrace, you read the log after the traced region —trace_log()still returns it once the block has exited. Clear it withclear_trace_log().
Refinements compose, and are restored along with the domain:
trace/verbose binding [ v = 7 ] # v = 7 (INTEGER)
trace binding
w = 8 # w = 8 ← /verbose did not survive
untrace
The block is same-line gated, so a [
opening the next line is still its own statement — bare
trace followed by an array literal keeps its old
meaning.
Equational view —
trace/equational
trace func shows what called what.
trace/equational func shows what this expression
becomes. Two printed forms share the same spine:
Reduction (the default) —
trace/equational or
trace/equational/reduction. A rewrite chain with
==>. Substitution is shown as written; an argument a
clause pattern demands is reduced on its own line:
plus(x, 0) = x
plus(x, y) = plus(x + 1, y - 1)
trace/equational func
plus(2, 3)
untrace
plus (2, 3)
==> plus (2 + 1, 3 - 1)
==> plus (2 + 1, 2)
==> plus (2 + 1 + 1, 2 - 1)
==> plus (2 + 1 + 1, 1)
==> plus (2 + 1 + 1 + 1, 1 - 1)
==> plus (2 + 1 + 1 + 1, 0)
==> 2 + 1 + 1 + 1
==> 5
Calculation —
trace/equational/calculation. A justified chain. Literal
arithmetic is folded so each line is the call that ran:
sum([]) = 0
sum([n|ns]) = n + sum(ns)
trace/equational/calculation func
sum([1, 2, 3])
untrace
sum([1, 2, 3])
={ applying sum }
1 + sum([2, 3])
={ applying sum }
1 + (2 + sum([3]))
={ applying sum }
1 + (2 + (3 + sum([])))
={ applying sum }
1 + (2 + (3 + 0))
={ applying + }
6
The arrows of trace func are replaced, not
interleaved. A body that is not a single rewrite step reports
f has no equational form once. Axioma is strict: the
calculation form shows already-evaluated arguments; the reduction form
is a display of the equations, not a change to evaluation order. Caps at
40 steps / 200 characters.
19.8
AST inspection — fullform, treeform,
headof, argsof, hold,
seq_of
The fullform / treeform /
headof / hold family, spelled in Axioma's
call-with-commas idiom. Every builtin in this group has hold
semantics: the argument's AST reaches the builtin unevaluated,
so fullform(2 + 3) prints the tree, not 5.
| Builtin | Returns |
|---|---|
fullform(expr) |
Canonical head(arg, ...) string |
treeform(expr) |
ASCII box-drawing tree |
headof(expr) |
Operator / function / kind of the top node |
argsof(expr) |
Array of fullform-rendered child strings |
hold(expr) |
A *AST value wrapping the unevaluated tree (rebindable,
re-inspectable) |
seq_of(elts...) |
A held SequenceExpression whose elements are the
literal arguments (the postfix counterpart of hold) |
fullform(2 + 3) # "+(2, 3)"
fullform(2 + 3 * 4) # "+(2, *(3, 4))"
fullform(f(x, y + 1)) # "f(x, +(y, 1))"
treeform(2 + 3 * 4)
# +
# ├── 2
# └── *
# ├── 3
# └── 4
headof(2 + 3) # "+"
argsof(2 + 3 * 4) # ["2", "*(3, 4)"]
h: hold(a + b * c)
fullform(h) # "+(a, *(b, c))" — auto-unwraps the held AST
s: seq_of(2, 3, +, 4, *)
fullform(s) # "Sequence(2, 3, +, 4, *)"
Auto-unwrap of held AST values. When the argument to
fullform / treeform / headof /
argsof is an identifier whose value is a *AST
(produced by hold or seq_of), the builtin
walks through the binding and inspects the held node. This is what makes
h: hold(2+3); fullform(h) render +(2, 3)
rather than the identifier "h".
19.9 Why "one tree" is the load-bearing claim
The point of the three notations is not stylistic preference. It is
that the evaluator never branches on notation —
2 + 3, +(2, 3), and
reduce(+, 0, [2, 3]) all converge on the same
evalInfixExpression dispatch. Adding a new operator (or
fixing a multi-valued logic semantics bug) touches one path, and every
notation that surfaces that operator inherits the fix.
Concretely, the identity is testable:
verify("identity prefix/infix",
fullform(2 + 3), fullform(+(2, 3))) # both "+(2, 3)"
See tests/axioma/notation/ for the full assertion
battery (operator-prefix, postfix sequence, dot inspect, stack words,
sequence trace, fullform, operator-as-value, three notations) and
tests/axioma/showcase/11_three_notations.ax for a
single-file walkthrough.
19.10 Homoiconicity — building code as data
The inspection family above (fullform /
headof / argsof) lets you read code
as data. The construction family closes the loop: you can build
AST values from scratch in Axioma source, manipulate them, and
ast_eval them back into computation. That's metaprogramming
— programs that produce programs.
Constructors. Each make_* returns an
AST value. Args may be other AST values OR raw Axioma values
(auto-lifted via the same machinery quasiquote uses):
| Builtin | Returns AST of |
|---|---|
make_integer(n) |
IntegerLiteral |
make_float(x) |
FloatLiteral |
make_string(s) |
StringLiteral |
make_boolean(b) |
Boolean |
make_identifier(s) |
Identifier |
make_infix(op, l, r) |
InfixExpression |
make_prefix(op, x) |
PrefixExpression |
make_call(f, args) |
CallExpression |
make_if(c, t, e?) |
IfExpression |
make_lambda([params], body) |
LambdaExpression |
make_array(...) |
ArrayLiteral (variadic or single
Array) |
make_tuple(...) |
TupleLiteral |
make_set(...) |
SetLiteral |
make_sequence(...) |
SequenceExpression (postfix sequence) |
Round-trip identity. Constructed and quoted ASTs compare equal structurally:
make_infix("+", 2, 3) == hold(2 + 3) # true — same canonical form
ast_eval(make_infix("+", 2, 3)) # 5
make_identifier("x") == hold(x) # true
quote(x * y) == make_infix("*", make_identifier("x"), make_identifier("y")) # true
Recursive traversal via parts(ast).
Unlike argsof (which returns strings), parts
returns Array of AST values — so you can walk a tree
without re-parsing at each level:
h: hold((a + b) * c)
outer: parts(h) # [AST(a + b), AST(c)]
inner: parts(outer[1]) # [AST(a), AST(b)]
ast_string(inner[1]) # "a" — use ast_string for inline display
# (fullform has hold semantics; bind first or use ast_string)
is_ast(x) predicates the type — useful
in pattern-matching code:
is_ast(hold(2 + 3)) # true
is_ast(42) # false
The dispatch loop. Recursive AST walks dispatch on
ast_kind:
walk: func(expr) [
k: ast_kind(expr)
if k == "integer_literal" then ...
else if k == "identifier" then ...
else if k == "infix_expression" then ...
else expr
]
The killer demo: symbolic differentiation in 25 lines
Below is a complete textbook differentiator written entirely in Axioma. No interpreter extension required:
diff_infix: func(expr, var_name, dx) [
op: headof(expr)
lhs: parts(expr)[1]
rhs: parts(expr)[2]
if op == "+" then make_infix("+", dx(lhs, var_name), dx(rhs, var_name))
else if op == "-" then make_infix("-", dx(lhs, var_name), dx(rhs, var_name))
else if op == "*" then make_infix("+",
make_infix("*", dx(lhs, var_name), rhs),
make_infix("*", lhs, dx(rhs, var_name)))
else expr
]
dx: func(expr, var_name) [
k: ast_kind(expr)
if k == "integer_literal" then make_integer(0)
else if k == "identifier" then (
if headof(expr) == var_name then make_integer(1) else make_integer(0)
)
else if k == "infix_expression" then diff_infix(expr, var_name, dx)
else expr
]
d: dx(quote(x * x + 3 * x + 5), "x")
println(fullform(d)) # +(+(+(*(1,x), *(x,1)), +(*(0,x), *(3,1))), 0)
# — unsimplified; equivalent to 2x + 3
A simplify(expr) pass (fold 0*x → 0,
1*x → x, x+0 → x) is another function in the
same style; together they make a small CAS.
substitute(expr, var, value) for evaluating at a point is
five lines around make_integer. Everything you'd want from
a computer algebra system is achievable in user-space code.
Macros and template hygiene
Beyond runtime AST construction, Axioma has Julia/Elixir-style
macros — macro name(params) body
definitions that expand during evaluator preparation, before application
execution. Macro arguments arrive as unevaluated AST,
the body must return an AST (typically built via
quasiquote(...) + unquote(...)), and generated
code subsequently executes in the calling context.
macro double(x) quasiquote(unquote(x) * 2)
double(21) # → 42
# Inspect the expansion without running it:
ast_string(macroexpand(double(21))) # → "(21 * 2)"
# Multi-arg, conditional, nested all work:
macro unless(test, body) quasiquote(if not unquote(test) then unquote(body) else none)
macro addAndDouble(a, b) quasiquote((unquote(a) + unquote(b)) * 2)
addAndDouble(3, 4) # → 14
double(double(3)) # → 12 (nested expansion)
Automatic hygiene. Template-local bindings receive fresh names. Template helpers, builtins, and nested macros resolve in the macro's definition environment; inserted argument identifiers retain the caller's meaning through structural copies and nested expansion. The macro body itself also runs in its definition environment, with its parameters bound to the unevaluated argument ASTs. A local macro declaration shadows an outer declaration without replacing it.
macro twice(e) quasiquote([tmp: unquote(e); tmp + tmp])
tmp: 100
twice(21) # 42; evaluates its argument once
tmp # 100; caller binding is untouched
macro count_items(e) quasiquote(len(unquote(e)))
shadowed: func() [
len: func(xs) [999]
count_items([1, 2, 3])
]
shadowed() # 3; template uses builtin len
Hygiene handles ordinary block bindings, function/lambda parameters,
pattern bindings, match arms, loop variables, comprehension clauses, and
catch binders. Field labels remain labels: a template's local
value does not rename row.value or the key in
{value: 42}. Function annotations, named arguments, call
refinements, and source tokens survive traversal.
gensym() and gensym("tmp") remain available
for explicit AST construction. They return fresh identifier
spellings as Strings, such as "tmp__7";
use make_identifier when an identifier AST is needed.
Ordinary templates do not require manual gensyms. Julia likewise
supplies automatic hygiene, though its explicit caller-scope escape
mechanism is esc rather than Axioma's identifier context
transfer with make_identifier and
ast_with_context.
Nested templates. quasiquote traverses
syntax children throughout an expression or block, including dictionary
keys/values, property/index access, record updates, functions, and
control flow:
macro getx(e) quasiquote(unquote(e).x)
macro box(e) quasiquote({value: unquote(e)})
getx({x: 42}) # 42
box(42).value # 42
pieces: [2, 5, 3]
do(quasiquote(max(splice(pieces)))) # 5
do(quasiquote((0, splice(pieces), 4))) # (0, 2, 5, 3, 4)
splice inserts an Array's elements into an array, tuple,
set, sequence, or positional call-argument list, or block statement
list. Statement positions also accept statement/program ASTs. A
non-Array input or a splice outside one of these positions is an error;
no elements are silently discarded. unquote requires an
expression AST or a value that can be lifted to expression syntax.
A nested quasiquote defers its own holes until that
quotation is evaluated. Each nested unquote crosses one
quotation level. Plain quote is opaque: its contents are
data, including any written unquote.
stage_value: 1
staged: quasiquote(quasiquote(unquote(stage_value)))
stage_value = 2
do(do(staged)) # 2
Expansion and runtime boundary. Macros expand
recursively before evaluator execution, including inside function
bodies. Put definitions before their uses. An earlier call to a later
macro is an expansion error. Ordinary application statements are not
executed during preparation. Simple function/lambda helpers can compute
with syntax and local values; same-unit runtime bindings are not
available for interpolation during expansion. Keep value-dependent
generation at runtime with quasiquote and do.
Previously evaluated REPL bindings remain available in later units.
macro values(first, second = 2, ...tail)
quasiquote([unquote(first), unquote(second), splice(tail)])
values(1) # [1, 2]
values(1, 3, 4, 5) # [1, 3, 4, 5]
values(second: 8, first: 7) # [7, 8]
Defaults are syntax in the definition context. Rest receives an Array
of ASTs. Missing, extra, duplicate, and unknown arguments are errors.
Required parameters precede defaulted parameters, and rest is last. Use
named arguments rather than macro call refinements. Macro values have
the registered type Macro.
macroexpand(expr) is recursive.
macroexpand(expr, {recursive: false}) performs one pass,
leaving generated calls for another pass. {trace: true}
returns a Dictionary with ast and steps; each
step has name, call, definition,
before, and after. Locations contain
file, line, and column.
report: macroexpand(double(21), {trace: true})
report.steps[1].name # "double"
do(report.ast) # 42
Explicit context. Identifier origin and definition
context survive structural copies. Runtime syntax can outlive the
function or module that produced it. Rendering and reparsing
deliberately loses lexical context. To introduce an identifier with a
caller argument's context, pass that identifier AST as a witness.
make_rebind constructs Axioma's explicit cross-frame
write:
macro assign(name, value) make_rebind(name, value)
x: 1
assign(x, 42)
x # 42
macro caller_x(context) make_identifier("x", context)
caller_x(x) # 42
ast_with_context(syntax, identifier_context) copies
syntax and transfers that context to its identifiers.
make_identifier validates the name spelling.
make_lambda accepts String names or identifier ASTs,
preserving context and rejecting duplicate parameters. Ordinary
automatic hygiene covers the programming binders listed above; advanced
domain declarations retain their own name rules.
Modules, limits, and tooling. Macros use existing
module export and import forms; exported macros retain
access to private helpers. Preparation reads module syntax without
running application code, detects import cycles, and reuses the prepared
artifact. Source changes between preparation and execution require a new
preparation. Failed preparation installs no partial REPL definitions;
external effects intentionally performed by a transformer cannot be
rolled back.
Default limits are 128 expansion levels, 10,000 replacements,
1,000,000 visited syntax nodes, and 1,000,000 evaluator steps. Embedders
can configure these with PrepareMacros /
MacroOptions. These are work counters, not an IO or native
allocation sandbox. Diagnostics include call/definition sites and
expansion chains. --typecheck checks generated evaluator
code. The LSP uses restricted expansion and reports an informational
notice when a macro requires operations outside that subset, including
IO/FFI; it never executes them as a fallback. Definition-scoped
references are conservatively unknown to the static checker.
Macros and quasiquote remain evaluator-only; --vm
refuses them explicitly. VM expansion and lexical context preservation
through compile(AST) are deferred. Ordinary self-contained
quoted AST/compile behavior is unchanged. See
docs/macro-system-design.md for the detailed evaluator
contract and migration.
Implementation files:
ast/rewrite.goevaluator/quasiquote.goevaluator/macro_hygiene.goevaluator/macro_prepare.goevaluator/macro_expansion.go
Runnable chapter examples are in the metaprogramming test directory:
tests/axioma/metaprogramming/
test_chapter7_templates.ax
test_macro_system.ax
The
head / operands / make_expr
normal-form algebra
Mathematica unifies every expression under one shape —
head[args]. Axioma renders its three surface notations
(infix, prefix, postfix) and call syntax to that same normal form, and a
small algebra reads and rebuilds any quoted expression
uniformly, regardless of which syntax produced it.
| Builtin | Returns |
|---|---|
head(ast) |
the operator / functor as a String — infix
"+", a call's functor name, a statement keyword
(":" for a binding, "if", a rule operator
"<~~"), or an atom's kind ("Integer",
"Symbol") |
operands(ast) |
an Array of AST — the arguments with the functor
excluded (the uniform companion to the older
parts, which includes the functor for calls) |
make_expr(head, operandsArray) |
rebuilds the node from a head String + an operands Array — the inverse of the two above |
head('(2 + 3)) # → "+"
operands('(2 + 3)) # → [<AST: 2>, <AST: 3>]
make_expr(head('(2 + 3)), operands('(2 + 3))) # → <AST: (2 + 3)> (round-trips)
head('(add(7, 9))) # → "add" (a call's functor)
head('(if x then 1 else 2)) # → "if"
make_expr(head(e), operands(e)) == e holds for infix /
prefix / postfix / call / binding / rule / if. Fixity is recovered from
a curated operator table; an unknown head builds a
CallExpression.
Pattern
rewriting — match_pattern / subst /
replace_all / rules
The algebra above is the substrate for term
rewriting — Mathematica's expr /. rule and the
heart of a computer-algebra system. Patterns reuse Axioma's existing
?x variable syntax; no new lexer.
| Builtin | Does |
|---|---|
match_pattern(pattern, subject) |
structural match → a Dictionary of bindings, or
none |
subst(template, bindings) |
instantiate a template from a bindings Dictionary |
replace_all(subject, rules) |
bottom-up rewrite to a fixpoint |
rules(p1, t1, p2, t2, …) |
flat-pairs sugar for a rule list (the keyword rule is
reserved) |
match_pattern('(?a + ?b), '(2 + 3)) # → {a: <AST: 2>, b: <AST: 3>}
subst('(?a * ?a), {a: '(7)}) # → <AST: (7 * 7)>
replace_all('(x + 0), rules('(?a + 0), '(?a))) # → <AST: x> (additive identity)
A nonlinear pattern (?x + ?x) enforces same-subtree
consistency. Each rule may carry a guard — a third
element, a quoted predicate over the ?vars (Mathematica's
/;); the rule fires only when the guard holds, and guards
see the builtins and the caller's definitions. Pattern
variables in head position (?f(?x)) bind the functor, and
sequence patterns match variable-arity argument runs —
?xs__ (one or more) and ?xs___ (zero or more),
in call-argument position. Together these are enough to write algebraic
simplification and symbolic differentiation as a rule list
rather than a hand-written dispatch.
A form is a collection — the "Julia-Plus" tier
A quoted expression also behaves as the sequence of its operands for the universal collection operations, so generic code traverses code the same way it traverses data:
len('(2 + 3)) # → 2 (was an error before)
'(add(7, 9))[1] # → <AST: 7> (1-indexed into the operands)
[op | op <- '(a + b + c)] # comprehension over a form's operands
foreach op in '(2 + 3) [ println(op) ]
map(func(x) [x], '(2 + 3)) # map / filter accept a form
The code ↔︎ data
bridge — to_data / from_data
Two converters cross the line between a quoted AST and an ordinary value:
to_data('(2 + 3)) # → 5 (evaluate a form to its value)
from_data(5) # → <AST: 5> (lift a value into an AST literal)
to_data(from_data(v)) == v # round-trips for scalars, arrays, sets, tuples
from_data understands the set-theory core (Set and Tuple
values lift, not only scalars and arrays). The bridge is the
frictionless path between the two worlds: where the
'(…) quote-forms carry bracket-overloading gotchas
('[…] is a block quote,
'([…]) is an array-literal quote),
from_data(theValue) is unambiguous.
Where Axioma sits in the homoiconicity taxonomy
Per Wikipedia's taxonomy of homoiconic languages, Axioma is in the "weaker tier" alongside Julia, Elixir, and Nim — full toolkit (quote, quasiquote, AST construction, AST evaluation, macros, macroexpand, hygiene primitive) but source code is parsed rather than being a literal data structure.
| Tier | Languages | Defining property |
|---|---|---|
| Purest (S-expressions) | Lisp, Scheme, Clojure, Racket | source = uniform list literal |
| Weaker (data structures for code) | Julia, Elixir, Nim, Axioma | AST as value + macros |
| Other recognized | Mathematica, REBOL, Red, Tcl, Io, Prolog | various |
| eval-on-string only | Python, Ruby, JavaScript | no in-language AST manipulation |
The article explicitly notes "no consensus exists on a precise definition" of homoiconicity — under a lenient definition Axioma qualifies fully; under the strict "source IS data" definition it doesn't. Reaching the strict tier would require abandoning the multi-syntax three-notations design, which is intentional (see §19.1–§19.5).
The head / operands /
make_expr algebra and the match_pattern /
replace_all rewriter above add a facility usually
associated with Mathematica — a uniform
head[args] normal form plus structural pattern matching and
term rewriting — onto the reified-AST base. That strengthens the
practical tier (think "Julia-Plus") without changing where Axioma sits
in the strict sense: a quoted form is still a dedicated AST
value, not source-as-list, so '[1, 2, 3] == [1, 2, 3]
remains false. The algebra unifies the operations over
code-as-data; it does not unify the representation.
Reaching the strict "source is data" tier would require a different
language kernel and is deliberately out of scope for the current
design.
See docs/code-as-data.md for comparison tables and
cookbook patterns.
Limits
| Limit | Workaround |
|---|---|
fullform(inline_call(...)) captures the call literally
(hold semantics) |
Bind to local first: r: call(...); fullform(r). Or use
ast_string(call(...)) which doesn't hold. |
ast_eval doesn't see local bindings |
Use quasiquote to splice values into the AST
before evaluating:
ast_eval(quasiquote(unquote(x) + 1)) |
| Computed ASTs lose direct caller provenance | Splice argument ASTs directly with unquote; use
gensym for intentionally constructed names |
quasiquote not yet compiled in --vm |
Run quasiquote-heavy / macro-heavy scripts under the tree-walker |
| No reader macros — can't extend the parser itself | Out of scope for v1 |
| AST nodes are heterogeneous, not Lisp-uniform | Trade-off against rich surface syntax; use ast_kind +
headof + parts to dispatch |
See tests/axioma/notation/test_homoiconic.ax for the
46-assertion smoke test, and demo_differentiate.ax for the
full symbolic-differentiation example.
Aliasing the vocabulary
— alias / unalias
alias new = target gives an existing spelling a second
name; unalias new withdraws it. One statement covers four
kinds of target:
| Target kind | Example | Mechanism |
|---|---|---|
| keyword | alias whilst = while |
lexer skinning — whilst tokenizes as
while |
| operator | alias plus = + |
lexer skinning — plus tokenizes as
+, canonical literal included |
| builtin | alias mapp = map |
eval-time redirect when the word is looked up |
| bound word | alias squared = sq |
eval-time redirect when the word is looked up |
The alias name must be a fresh word. Aliasing extends the vocabulary; it never shadows. A reserved keyword or an already-bound word is refused as the name outright, and for the eval-resolved target kinds (builtin and bound word) a name that collides with a builtin is refused too:
alias while = repeat # ERROR: it's a reserved keyword
alias each = map # ERROR: it already names a builtin — pick a fresh
# word, or shadow it with a plain binding (`each: map`)
sq: func(n) [n * n]
alias sq = abs # ERROR: a word with that name exists
The builtin refusal is the newest leg (August 2026 — it used to
report success and silently never take effect, because eval-tier alias
resolution is a fallback that a resolvable name never reaches). When
shadowing is what you want, a plain binding does it genuinely:
each: map makes each mean
map.
A plain binding shadows every builtin,
including the environment-aware ones (combine,
dispatch, render, elements,
grounding, proof, …):
each: map
each(func(x) [x * 2], [1, 2, 3]) # → [2, 4, 6]
combine: func(a, b, c) [ a + b + c ]
combine(1, 2, 3) # → 6 your function, your arity
Only a bound function takes the name over. Binding a
value leaves the builtin callable, so count: 5 does not
make count(xs) stop working, and without a binding of your
own the builtin is what you get — combine("a", "b") is
still "ab", Semigroup's concatenation.
The two skinned target kinds are exempt from the builtin
check: a keyword or operator target rewrites the name at the token
level, so alias iterate = for and
alias add = + genuinely work even though
iterate and add name builtins — for the rest
of the script the word IS the keyword or operator, and the same-named
builtin is unreachable. That is a deliberate choice you can see, not a
silent no-op.
The two skinned kinds are the interesting ones: from the next token onward the word IS the target token, so everything about the target — its precedence, every syntactic position it works in, even its value form — is inherited rather than reimplemented:
alias plus = +
2 plus 3 # → 5
2 plus 3 * 4 # → 14 — inherits +'s precedence below *
padd: plus # a bare operator in value position builds the
padd(2, 3) # → 5 section function; the skinned word does too
alias u = ∪
{1, 2} u {3} # → {1, 2, 3} — glyph targets work the same way
Word-operator keywords (and, or,
not) alias like any other keyword:
alias moreover = and
true moreover false # → false
Type names are ordinary bound words (every canonical type name is a
seeded type-Concept), so a type alias is the eval-tier case — and the
two copular surfaces agree about it: what is accepts,
:: accepts.
alias Int = Integer
5 is Int # → true
n :: Int: 7 # the annotation resolves through the alias too
(41 :: Int) + 1 # → 42
Rules and edges, each of them loud:
The alias name is a plain identifier or a guarded name. Guarded forms
$nameand$"…"let the key contain dots, spaces, or keyword spellings — the same guards used for ordinary bindings:alias $"console.log" = println $"console.log"("hi") # use the same guard at the call site unalias $"console.log" alias $"print line" = println $"print line"("hi")Bare dotted paths are refused with a hint: write
alias $"console.log" = …, notalias console.log = …(the.would be property access). A bare string is also refused — use$"…", not"…". Multi-word / dotted keys resolve at eval time only (they cannot be lexer-skinned as a single bare token). Simple names still skin keywords/operators as before.The name must not be reserved or already bound (as a bare keyword).
alias ⊕ = +is a SyntaxError (new glyph spellings belong to the symbols/digraph table, notalias), andalias when = iffails to parse becausewhenis itself a keyword (use$"when"if you truly need that spelling as a non-keyword key).The target must denote something real — a keyword, an operator, a builtin, or a bound word. Anything else is refused:
cannot create alias 'q': 'nosuchword' is not a keyword, operator, builtin, or defined word. Value literals fall under the same rule (alias five = 5errors).A refinement is not a target.
alias same2 = is/sameis a SyntaxError — an alias target is a single token.Aliases are not forward-referenced. Skinning starts at the token after the declaration; a use above it is just an undefined word.
unaliaswithdraws by the spelling you wrote, and from that point the word is an ordinary identifier again, free to be bound.Chains flatten at declaration time.
alias add = pluswhileplusis skinned recordsadd = +directly, so a laterunalias plusdoes not strandadd.The REPL carries aliases across lines — every line re-injects the session's alias table into its lexer, all four target kinds included.
Evaluator-only. Like every adaptive-grammar feature, an
aliasfile is rejected at compile time under--vm— a refusal, never divergence.
The grammar-classification essay below is about why this one statement makes Axioma's surface grammar formally non-context-free.
Grammar classification — is Axioma context-free?
Not as a single fixed context-free grammar. Axioma has a context-free skeleton, but the language the parser actually accepts steps outside CFG-land in one decisive way and two softer ways:
aliasmakes the grammar adaptive (the decisive one). The parser'sparseAliasStatementinstalls the mapping into the live lexer (Lexer.AddAlias), and identifier lexing consults that table on every subsequent token — so afteralias myif = if, the wordmyiftokenizes as theifkeyword for the rest of the same source stream (verified:alias myif = if+alias mythen = then+alias myelse = elsemakesmyif 1 > 0 mythen "a" myelse "b"parse as a full conditional). How a program's suffix parses depends on the content of its prefix. A language whose sentences extend their own grammar mid-stream is not context-free (the same argument that puts Lisp reader macros and Forth word definitions outside CFGs — declared-name ↔︎ used-as-keyword dependencies are the classic cross-serial pattern). Note this is keyword and operator remapping, not a general reader-macro facility:aliasre-lexes identifiers onto existing tokens (alias plus = +makesplusthe+token itself); it cannot introduce new syntactic forms (see the limitations table above).unaliaswithdraws one, and the word goes back to being an ordinary identifier from that point in the stream onwards:sq: func(x) [ x * x ] alias squared = sq squared(5) # → 25 unalias squared squared(5) # → ERROR: Undefined word: squaredRecognition is PEG-style, not CFG-style. The hand-written recursive descent + Pratt parser carries a dozen-plus
SaveState/RestoreStatespeculative-parse sites —colonHeadsSetComprehensiontrial-parses to split the British set comprehension{x : x <- s}from a hash literal{k: v};pipeStartsConsTailsplits the cons pattern[H | T]from a comprehension[h | h <- xs];dotBodyIsConceptsplits the DL restriction∃hasChild.Doctorfrom the quantified formula∀x. flies(x). Backtracking with ordered choice is parsing-expression-grammar semantics; PEGs and CFGs are incomparable classes, and "valid Axioma" is defined operationally by what this parser commits to — no equivalent CFG is maintained.Layout and token-shape sensitivity — inelegant but CF-encodable. Dozens of same-line gates make the soft keywords (
mod,div,unless,whenever,qua,only,limit,offset, …) operators only between same-line expressions not followed by:; the lexer decides token identity from local context ($5Money vs$nameguard,%dataFile vs7 % 2modulo,50%Percent vs100%7modulo). These could be encoded in a (much larger) CFG by promoting NEWLINE to a terminal and splitting identifier classes, so they don't change the classification by themselves.
What genuinely is context-free: the Pratt expression
core (operator- precedence grammars are a subset of deterministic CF),
all bracket-balanced structure including [python | …]
foreign-language blocks (bracket matching is the textbook CFL), nested
string interpolation — and, worth stressing, macros do not break
the surface grammar: unlike Lisp reader macros, Axioma macros
expand post-parse on the AST, so
macro double(x) quasiquote(…) never changes how source
tokenizes.
The standard footnote applies as it does to every real language: the
set of programs that run (no undefined words, arity checks, the
uppercase-concept rule, --typecheck's
:: Ordinal discipline) is context-sensitive anyway — true
of C and Python too — which is why the question is conventionally asked
of surface syntax only. Honest one-line classification: a
deterministic context-free core, recognized with PEG semantics, made
formally non-context-free by one deliberate feature (alias)
in the Lisp/Forth adaptive-grammar tradition.
(Runtime-registered dialect grammars don't affect the
surface class — their blocks are just balanced brackets to the parser;
the dialect matching happens at evaluation time.)
20. Russell's Three Meanings of "is"
Axioma distinguishes the three classical meanings of the copula "is":
| Meaning | Syntax | Semantics |
|---|---|---|
| Identity | x is same as y / x is/identical y |
Ontological identity |
| Predication | sky is blue / sky is/property blue |
Property attribution |
| Existence | there is x / exists x |
Existential claim |
hesperus is same as phosphorus # Identity
sky is blue # Predication
there is x in {1, 2, 3}: x > 2 # Existence
Refinement operators: is/same,
is/identical, is/property.
Identity is Leibniz identity, not address comparison
is/same asks whether two expressions denote one
object. It is a refinement of ==, not a
synonym: == asks whether two values denote the same thing,
is/same asks whether they are the same thing.
5 is/same 5 # → true — there is only one 5
1 == 1.0 # → true — same number
1 is/same 1.0 # → false — `type` and `exact?` tell them apart
[1, 2] is/same [1, 2] # → false — two arrays
[1, 2] == [1, 2] # → true — with equal contents
The answer follows Leibniz's two principles, with "property" read as any predicate Axioma can express:
Identity of Indiscernibles — indiscernible therefore identical — holds for immutable scalars: numbers, strings, booleans, dates, URLs and the rest of the REBOL scalars. These are abstract objects with no haecceity; nothing distinguishes one 5 from another, so
5 is/same 5is true.It fails for concrete particulars and containers. Two dogs with the same name are two dogs; two arrays with equal contents are two arrays, because you can push onto one and observe that the other did not change. Identity there is reference identity — which is what makes the Hesperus/Phosphorus case come out right, since two names for one object are identical and the difference of sense belongs to the name:
concept Planet { name: "" } hesperus: a Planet { name: "Venus" } phosphorus: hesperus hesperus is same as phosphorus # → true — one planet, two names twin: a Planet { name: "Venus" } hesperus is same as twin # → false — two planetsIt holds again for
datavalues. A tagged product has no field cell —p.x: 99is refused — so aliasing cannot be observed through one, and two constructions with the same tag and identical arguments are one object. The recursion is on the arguments' identity, never on their equality, so a mutable payload still confers haecceity upward:data Point = P { x, y } P { x: 1, y: 2 } is/same P { x: 1, y: 2 } # → true — one Point P { x: 1, y: 2 } is/same P { x: 1.0, y: 2 } # → false — exactness discerns P { x: 1, y: 2 } == P { x: 1.0, y: 2 } # → true — `==` coerces data Box = B { xs } B { xs: [1, 2] } is/same B { xs: [1, 2] } # → false — two arrays inside xs: [1, 2] B { xs: xs } is/same B { xs: xs } # → true — one array insideOnly the slots are frozen.
xs[1]: 99still writes through the slot, which is why the rule cannot be structural==: two==Boxes over separate arrays would be "one object" right up until a write drove them apart.Indiscernibility of Identicals — identicals agree on every predicate — runs the other way and is what keeps
is/samefiner than==.1 is/same 1.0is false becauseexact?andtypeare predicates that disagree on them: it is settled by observation, not by fiat.
Two IEEE corners fall out of the same reasoning.
nan is/same nan is true even though
nan == nan is false — no predicate tells one NaN from
another, and identity must be reflexive. -0.0 is/same 0.0
is false even though -0.0 == 0.0 is true —
str renders them "-0" and "0", so
they are discernible. (Scheme's eqv? gives both the same
answers.)
The executable statement of these laws is lib/laws/equality.ax;
run it with axioma lib/laws/equality.ax.
21. Pointers & References
C-style pointers
x: 42
p: &x # Take address
*p # 42 — dereference
*p = 100 # Write through pointer
x # 100
arr: [10, 20, 30]
p2: &arr[2] # Pointer to array element
*p2 # 20
# `for item in &arr` is Rust's `&[T]` walk: each `item` is a
# pointer to that slot, so `*item` reads it and `*item = v` writes.
# Use the end-form, or parenthesize: `for item in (&arr) [ … ]`.
# `for item in &arr [` is `&arr[…]` (address-of an index), not a loop body.
for item in &arr
*item = *item + 1
end
# arr is [11, 21, 31]
# `for item in arr` is unchanged — `item` is the value, not a pointer.
alice: a Person {age: 30}
p3: &alice.age # Pointer to property
*p3 = 31
alice.age # 31
Pointers work in both interpreted and VM modes.
Deep copy
copy(value) returns an independent deep copy:
a: [10, 20, 30]
b: copy(a) # deep copy — mutating b leaves a untouched
Note —
@is type-of, not a reference get-word.@xreturns the type ofx(@42→"Integer"), identical totype(x). Plainxalready yields the value, and the address/dereference operators&x/*p(above) are Axioma's actual reference mechanism.
22. Mathematical Constants & Built-ins
Mathematical constants
| Constant | Value | Description |
|---|---|---|
pi |
3.141592653589793 | π |
e |
2.718281828459045 | Euler's number |
phi |
1.618033988749895 | Golden ratio |
sqrt2 |
1.4142135623730951 | √2 |
sqrt3 |
1.7320508075688772 | √3 |
ln2 |
0.6931471805599453 | ln 2 |
ln10 |
2.302585092994046 | ln 10 |
im |
i (complex(0, 1)) |
Imaginary unit. Shadowable like pi. Write
1 + im, (1 + im)^2 → 2i,
2 * im. Not a juxtaposed literal (2im is
diagnosed) and not the loop index i |
Set constants
| Constant | Description |
|---|---|
emptyset |
{} (glyph ∅) |
naturals |
{1, 2, 3, ..., 100} (finite; glyph ℕ) —
lazy ∞ form: infinite_set("naturals") |
integers |
{-50, ..., 50} (finite; glyph ℤ) — lazy ∞
form: infinite_set("integers") |
rationals |
ℚ — lazy infinite set, countable/enumerable (glyph
ℚ) |
reals |
ℝ — lazy infinite set, membership-only (glyph ℝ) |
complexes |
ℂ — lazy infinite set, membership-only (glyph ℂ) |
universe |
Universal set for demos |
Mathematical functions
| Function | Description |
|---|---|
abs(x) |
Absolute value |
sqrt(x) |
Square root |
floor(x) |
Floor |
ceil(x) |
Ceiling |
pow(b, e) |
Exponentiation |
sin(x) / cos(x) / tan(x) |
Trigonometry — arguments in radians (like Lua/C);
convert with rad/deg |
asin(x) / acos(x) /
atan(x) |
Inverse trig, radians (asin/acos
domain-checked). atan(y, x) 2-arg is the full-quadrant form
(Lua 5.3+ math.atan) |
atan2(y, x) |
Full-quadrant arctangent — the C/Python spelling of
atan(y, x) (atan2(1, 0) →
pi/2) |
sinh(x) / cosh(x) /
tanh(x) |
Hyperbolic functions |
deg(x) |
Radians → degrees (deg(pi) → 180; Lua
math.deg) |
rad(x) |
Degrees → radians (rad(180) → pi;
sin(rad(90)) → 1; Lua
math.rad) |
quotient(a, b) / a quotient b |
Floor division, rounding toward −∞ (same as a ÷ b /
a div b) |
div(a, b) |
The div keyword's own prefix spelling — same operator,
same answer |
remainder(a, b) / a remainder b |
Floor remainder, taking the sign of the divisor
(same as a % b / a mod b) |
rem(a, b) / a rem b |
Short spelling of remainder — the prefix twin of
div(a, b). Floor, not Julia/Elixir truncated rem.
reminder is a different word and is not this function |
mod(a, b) |
The mod keyword's own prefix spelling — same operator,
same answer |
divmod(a, b) |
Combined: returns (quotient, remainder) tuple in one
call |
signum(x) |
Sign as -1 / 0 / 1,
preserving type (Int→Int, Float→Float, Rational→Int). A
non-number errors. (sign(x) always returns an
Integer.) |
square(x) |
x * x, preserving numeric type |
add1(x) / sub1(x) |
x + 1 / x - 1 (Lisp 1+ /
1-) |
succ(x) / pred(x) |
Successor / predecessor over the ordinal types (Pascal/Ada
'Succ/'Pred): Integers (succ(5) →
6) and enum members (succ(Mon) → Tue, erroring at the
ends). On Integers ≡ add1/sub1; the
ordinal-typed, enum-symmetric spelling |
isqrt(n) |
Integer floor square root of a non-negative integer (exact, big-int aware) |
numerator(r) / denominator(r) |
Rational accessors; an integer n is n/1
(so denominator(5) → 1) |
to_number(s) |
Parse a string to a number — Integer if integral, else Float; a number passes through |
Randomness —
random / random_seed / shuffle /
sample
Lua's math.random surface, natively:
random() # Float in [0, 1)
random(6) # Integer 1..6 — a die roll
random(10, 20) # Integer 10..20 inclusive
random_seed(42) # select a deterministic stream (returns 42)
random_seed() # entropy opt-in — reseed from the OS, RETURNS the chosen seed
shuffle(xs) # new shuffled array (the input is untouched)
shuffle!(xs) # in-place; aliases see the write. Same seed as shuffle.
sample(xs) # one random element (arrays, tuples, sets)
sample(xs, 2) # 2 distinct elements, without replacement
values(sample(normal(0, 1), 3)) # distribution draws — values() unwraps a Sample
The seeding model (deliberate). The generator starts
with the fixed seed 1, so every run of an unseeded
program replays the same sequence — reproducibility is the default,
matching the rest of the language (doctests, expect() pins,
the deterministic test sweep). This is the classic Programming in
Lua (≤5.3) model; modern Lua 5.4+ auto-seeds at startup instead — a
deliberate divergence. When you want fresh randomness (a game,
a real simulation), call random_seed() once at the top: it
seeds from OS entropy and returns the seed it chose, so even a "fresh"
run can be logged and replayed exactly. One seedable source backs the
scalar family and the probability-distribution
samplers, so random_seed(42) also makes
sample(uniform(0, 1), n) reproducible.
The long tail. Python's random module
breadth (expovariate, gammavariate, weighted
choices, getrandbits, …) stays one spelling
away through the FFI block:
[python/eval | __import__("random").expovariate(1.5) ]
with one caveat: Python-routed draws live in the subprocess's own
generator and do not obey random_seed —
only the native family and the prob distributions join the
seed contract. When a distribution earns a native home, it lands in
prob's seedable source (the
normal/uniform/
binomial/beta path) precisely so it does.
Formatted output —
printf / stringf / format
C-style format verbs; printf writes to stdout,
stringf returns the String (use it inside expressions).
format is an exact alias of
stringf — the spelling Lua, Ruby, Java/C#, and
Rust hands reach for, and in every one of those languages it
returns the string rather than printing (Lua's
string.format, Ruby's format ≡
sprintf; printing stays printf, or compose
println(format(...))). All three interpret escape sequences
in the format and share one verb-aware argument adapter
(also behind the log_*f family):
printf("%05d|%6.2f|%-6s|\n", 42, 3.14159, "ab") # 00042| 3.14|ab |
stringf("%d %d", floor(x), floor(x + 0.5)) # ints under %d
format("%x", 255) # "ff" — exact alias of stringf (returns, never prints)
stringf("%d", 35.0) # "35" — an INTEGRAL Float converts
stringf("%.2f", 1/3) # "0.33" — Rationals format under the float verbs
stringf("%f", 5) # "5.000000" — Integers too
stringf("%s", [1, 2]) # "[1, 2]" — %s/%q take anything (display form)
stringf("%d", 2^100) # big-int aware
stringf("100%% sure") # "100% sure" — %% is the literal percent
stringf("%d", 35.7) # ERROR: %d needs an integer value, got 35.7 —
# use floor(x) or round(x), or format with %g
Verbs: %d %b %o %x %X %c %U (integer class —
%x/%X also hex-dump a String),
%f %e %g + upper (float class), %s %q %v,
%t (Boolean only), %%; flags/width/precision
(%-6s, %05d, %6.2f,
*) follow Go's fmt. The adaptation
rule is the numeric tower's own stance (35.0 == 35
is already true): an integral value converts across the verb
classes; a fractional value under an integer verb is a loud,
catchable error with a rewrite hint — never silent junk. Every
mismatch errors this way (wrong type, missing or extra arguments,
unknown verbs, a bare trailing %), and a Go-style
%!… badge can never appear in output.
Predicates
even(4) # true
odd(3) # true
positive(5) # true
negative(-3) # true
Scheme/Lisp-style
predicates (? suffix)
Lisp/Scheme spell the test marker ? rather than Common
Lisp's p. A trailing ? is part of the
identifier (zero? is one word), so these read naturally.
The sign predicates treat a non-number as false (matching
even/positive).
# sign / parity (over Integer, Float, Rational)
zero?(0) # true plus?(5) # true (strictly positive)
positive?(5) # true minus?(-1/3) # true (strictly negative)
negative?(-5) # true even?(4) # true odd?(3) # true
# type predicates (join number?/integer?/string?/boolean?/null?/pair?/array?…)
list?([1, 2, 3]) # true procedure?(func(x) [x]) # true
empty?([]) # true — also "" / set() / {} / dict() ; an infinite set is never empty
symbol?('hello) # true — a lit-word is a symbol ; symbol_name('hello) → "hello"
# float domain (`inf` / `nan` are builtins producing ±∞ / NaN floats)
# `is_X` ≡ `X?` — both spellings, same function (`is_nan` ≡ `nan?`)
nan?(nan) # true is_nan(nan) # true
infinite?(inf) # true is_infinite(inf) # true
finite?(3.14) # true is_finite(3.14) # true
integral?(5.0) # true — the VALUE is whole (5.01/NaN/±Inf → false; the TYPE test is integer?)
# equality: eq? / eql? / eqv? are shallow identity (collections by reference);
# equal? is deep, type-strict (`===` is the infix spelling)
eq?(1, 1) # true eq?([1,2], [1,2]) # false (distinct objects)
equal?([1,2], [1,2]) # true equal?(1, 1.0) # false (type-strict)
1 === 1.0 # false 1 == 1.0 # true (exact tower)
equal?('a', "a") # false 'a' == "a" # true (Character ≠ String as a TYPE, like Byte ≠ Integer)
Forgot which spelling? Every shape of a name is answered:
isEmpty(xs), isempty(xs),
emptyp(xs) and empty(xs) all fail with
Did you mean \empty?` (also spelled `is_empty`)?` — see §25
The word oracle.
Higher-order functions
map(fn, set) # Transform
filter(pred, set) # Select
reduce(fn, init, set) # Aggregate (left fold)
sum(coll) # Numeric sum over array/set/tuple (Σ is Unicode alias)
mean(coll) # Arithmetic mean of an array, tuple, or matrix (`average` is the alias)
median(coll) # Middle value of a sorted array, tuple, or matrix (even count averages the two middle)
# Scheme/Lisp folds & combinators
foldl(fn, init, coll) # left fold, fn(acc, elem) — like reduce (also fold_left)
foldr(fn, init, coll) # right fold, fn(elem, acc) (also fold_right)
append_map(fn, coll) # map then concatenate the per-element collections (flatmap / mapcat)
for_each(fn, coll) # apply fn for side effects; returns none
constantly(x) # → a function that ignores its args and returns x
negate(pred) # → a predicate that logically negates pred (≠ set `complement`)
neg(n) # → -n (exact alias of negate; also neg(pred))
List & string helpers
butlast([1, 2, 3, 4]) # → [1, 2, 3] (all but the last)
dedupe([1, 2, 2, 3, 1]) # → [1, 2, 3] order-preserving (remove_duplicates / unique)
unique!([1, 2, 2, 3, 1]) # → [1, 2, 3] in-place; unique(xs) is the copy
chars("abc") # → ["a", "b", "c"] (string → char array)
explode("abc") # → ["a", "b", "c"] (SML spelling; exact alias of chars)
implode(["a", "b", "c"]) # → "abc" (inverse of explode/chars)
implode(explode("日本語")) # → "日本語" (round-trip over runes)
rev([1, 2, 3]) # → [3, 2, 1] (SML List.rev; exact alias of reverse)
member([1, 2, 3], 2) # → true (SML-shaped name; exact alias of contains)
# member(collection, target) — Axioma order, not SML List.member(element, list)
char_alphabetic?("a") # → true (also char_numeric? / char_whitespace?)
Knowledge-base builtins
rel is the relation name as a string
("parent", not the bare value parent);
args are the fact's arguments.
| Builtin | Description |
|---|---|
insert(rel, args, [grounding]) |
Assert a new fact (default grounding datum) |
forget(rel, args) |
Retract a fact |
set_truth(rel, args, value) |
Attach Belnap B4 value |
truth(rel, args) |
Query Belnap value |
grounding(rel, args) |
Query grounding → a Grounding value (ordered) |
truth_kind(rel, args) |
Query truth-kind → a Kind value (flat) |
proof(rel, args) |
Walk derivation chain |
rules_of(pred) |
A predicate's rules as RuleClause values |
challenge(rel, args) |
Mark axiom as suspect |
challenged(rel, args) |
Check if challenged |
cancel(rel, args) |
Suppress a derived fact |
uncancel(rel, args) |
Remove cancellation |
canceled(rel, args) |
Check cancellation |
transaction_begin/commit/rollback |
Atomic mutation |
predicates_of(X) |
All facts mentioning X |
predicate_names() |
All known predicate names |
cardinality(C, "prop", min, max) |
Register cardinality |
MVL constructors
intuit3("true"/"false"/"unknown")
belnap("true"/"false"/"both"/"neither")
lukasiewicz(0.0..1.0)
23. Venn Diagrams & Visualization
a: {1, 2, 3, 4}
b: {3, 4, 5, 6}
venn(a, b) # 2-set diagram
c: {3, 4, 5}
venn(a, b, c) # 3-set diagram with all intersections
Diagrams auto-pop in the default image viewer and are saved to
diagrams/venn_diagram_TIMESTAMP.png.
Run ./scripts/clean_diagrams.sh to clean accumulated
PNGs.
The
*form family — rendering expressions & relations
A family of introspection builtins renders a value to text.
fullform / treeform show an expression's AST;
tableform / graphform render a
relation's extent (rule-derived facts included, since
they walk the same fact store as comprehensions); tableform
also renders a function's truth table. Relations pass
as a held bare identifier or a string.
| Function | Renders |
|---|---|
fullform(expr) |
AST as a symbolic string |
treeform(expr) |
AST as an ASCII tree |
tableform(rel) |
relation extent as an ASCII table |
tableform(func, [domain]) |
the function's truth table (default domain
{true, false}; name a logic or pass a collection) |
graphform(rel, [fmt]) |
relation as a graph — "ascii" (default) /
"dot" / "cg" / "png" /
"svg" |
relation edge(x, y)
assert edge("a", "b")
assert edge("b", "c")
println(tableform(edge))
# relation: edge (2 facts)
# ┌─────┬─────┐
# │ x │ y │
# ├─────┼─────┤
# │ "a" │ "b" │
# │ "b" │ "c" │
# └─────┴─────┘
println(graphform(edge)) # default ASCII adjacency
# graph: edge (2 edges)
# "a" → "b"
# "b" → "c"
graphform(edge, "dot") # Graphviz DOT — paste into `dot -Tsvg`
graphform(edge, "cg") # Sowa conceptual-graph linear notation [a]→(edge)→[b]
graphform(edge, "png") # force-directed PNG → visualizations/graph_<ts>.png
graphform(edge, "svg") # scalable SVG (preferred for web / VS Code)
Headers come from the declared slot names; output tuples are sorted deterministically. Higher-arity relations fall back to positional notation (binary is the natural graph case).
Truth tables —
tableform(func, [domain])
Give tableform a function instead of a relation and it
prints the function's truth table: one row per
assignment of domain values to the parameters, leftmost parameter
varying slowest (the textbook layout). Input columns are headed by the
parameter names and the result column by the function's actual
body expression (p and q — a multi-statement body
falls back to result); a bound function's name appears in
the title. The companion truth_table(f) returns the same
rows as a Set of tuples — truth_table is the data,
tableform the display.
println(tableform(func(p, q) [p and q]))
# truth table: (p, q) (4 rows)
# ┌───────┬───────┬─────────┐
# │ p │ q │ p and q │
# ├───────┼───────┼─────────┤
# │ true │ true │ true │
# │ true │ false │ false │
# │ false │ true │ false │
# │ false │ false │ false │
# └───────┴───────┴─────────┘
The optional second argument selects the value domain — this is how
the multi-valued logic tables print. Name a logic
("boolean", "kleene", "belnap",
"lp", "lukasiewicz", "g3") to
enumerate its canonical value set, or pass an explicit Array/Set for a
general function table:
println(tableform(func(p, q) [p ⊼ q], "kleene")) # strong-Kleene NAND, 3×3
println(tableform(func(p, q) [p and q], "belnap")) # the full B4 4×4 ∧ table
println(tableform(func(p) [not p], "g3")) # G3's ¬U = F, visible
println(tableform(func(x) [x * x], [1, 2, 3])) # function table over a domain
Rows are capped at 4096 (domain^parameters); bodies
dispatch per operand type as usual, so the same ⊼ body
prints classical, K3, or B4 tables depending only on the domain.
24. Comments & Literate Programming
Basic comments
# Single-line comment (hash)
// Single-line comment (C-family; full alias of #)
x: 5 # Inline hash comment
y: 6 // Inline double-slash comment
/* block comment — may span lines */
// is only a comment. Floor division is
div or ÷ (see §6 Arithmetic).
# is a comment wherever it appears outside a
string, glued or spaced — #note,
#TODO: later, #---- banner ----,
### heading, and #1. all comment to end of
line, exactly as // does (the 2026-09-03 flip; before it a
tight #xs was prefix length and #123 an Issue
literal — both retired, write len(xs)). The only
# lines read before the comment rule are the
#! shebang on line 1 and the #language header
pragma.
x: 5#glued trailing comment
#TODO: a glued comment line
#---------------------------
xs: [1, 2, 3]
len(xs) # → 3 (was #xs)
Markdown documentation blocks
/**md ... */ blocks are extracted by
axiomadoc for HTML/Markdown/PDF rendering:
/**md
# Function: calculateDistance
## Purpose
Euclidean distance between two points.
## Math
$d = \sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$
## Cross-references
- {@link otherFunction}
- {@concept Point}
*/
calculateDistance: func(x1, y1, x2, y2) [
sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
]
Generating documentation
./axiomadoc generate -input . -output docs -format html -template default
./axiomadoc generate -input . -output docs -format html -template academic
./axiomadoc generate -input . -output docs -format markdown
./axiomadoc serve -port 8080 -watch -input . # Live-reload server
./axiomadoc validate -input . -check-links -run-examples
Doctests — examples that test themselves
A documentation example can also be a test, so the examples
in your docs can't silently rot. A fenced
axioma doctest block — written inside a
/**md … */ literate block in a .ax file, or
directly in a .md file — is executed line by line:
| Line shape | Meaning |
|---|---|
expr # → value |
assert that evaluating expr yields
value |
expr # -> value |
same assertion — the keyboard-typeable ASCII spelling of
# → |
expr # raises: substr |
assert that expr errors, with a message containing
substr |
| anything else | setup — run for effect (bindings, declarations, asserts) |
Lines in one fence share an environment; each fence is isolated. The
expected value is compared by canonical value form (so
(14, 2), {1, 2, 3}, "hi",
true compare as values, not as strings).
The fence opens with the info string axioma doctest
(shown here inside a literate block):
/**md
## divmod — quotient and remainder together
```axioma doctest
divmod(100, 7) # → (14, 2)
divmod(10, 0) # raises: by zero
n: divmod(9, 2) # setup line — run for effect
n[1] # → 4
n[2] # -> 1
```
*/
divmod: func(a, b) [(a div b, a % b)]
Run them from the CLI, or with the Playground's ✓ Doctest button:
axioma --doctest path.ax # one file
axioma --doctest docs/ # walk a directory for .ax / .md fences
--doctest prints ok: / not ok:
per assertion and a N passed, M failed summary, exiting
non-zero if any assertion fails or if
no fence is found (so a mistyped axioma doctest tag can't
pass silently). The engine is shared between the CLI and the WebAssembly
build, so the in-browser Playground ✓
Doctest button gives identical results. For assertions inside
ordinary scripts (rather than docs), see the expect builtin
(§26).
25. Interactive Features (REPL)
Line editing
- ↑/↓ — command history (persistent across sessions)
- ←/→ / Home/End — cursor movement
- Tab — keyword completion
Special commands
help # Language help
doc <topic> # Topic documentation
:stack # Inspect interpreter stack
:s # Alias for :stack
:stack trace # Step-by-step stack trace
exit # Quit
Execute a
file with :source and replay it with
:refresh
In the native CLI REPL, both at the interactive
terminal and with piped input, :source file.ax reads the
file and executes it as one complete input in the current session.
:refresh reads and executes that file again, so saved edits
take effect. These commands do not start a fresh environment or a
watcher. Bindings, aliases, educational restrictions and ordinary side
effects behave as they do for REPL input; a new top-level
let remains visible afterward. Each loop keeps its usual
result display.
The whole trimmed remainder after :source is the
filename. Paths with spaces work both as
:source my examples/demo.ax and as
:source "my examples/demo.ax". Matching outer single or
double quotes are removed once; their contents are literal, including
leading/trailing spaces. Empty or unmatched outer quotes produce usage
help. There is no shell expansion, argument list or escape processing.
The path is made absolute when :source is entered. Changing
cwd, including with !cd, cannot redirect
:refresh to a different same-named file. This remembers a
path, not an inode: replacing the file at that path is an edit.
The latest source attempt with a nonempty, resolvable path is
remembered before reading it, even if reading, parsing
or execution fails. Fix or create the file and use :refresh
to retry. A missing argument or arguments after :refresh
produce usage help and leave the previous path intact. Before any source
attempt, :refresh asks you to use :source
first. The remembered path belongs to this running REPL, not its saved
image.
The complete file is parsed before execution, so a parse error executes none of it. A runtime error stops subsequent statements but keeps earlier effects and bindings; retrying repeats those effects. Existing rebinding and type rules still apply. Errors name the sourced file. The file contains Axioma source, not colon commands or shell commands.
This is execution in the existing REPL context:
sourcing does not change cwd or the REPL's import base. In an ordinary
fresh REPL, import "./x.ax" still resolves from cwd, even
when the sourced file lives elsewhere. Imports keep their existing
loading behavior; refresh adds no dependency-reload rule. This differs
from running a standalone file, whose relative imports use the importing
file's directory (§34).
These commands are separate from the language builtin
source(f), which renders source from a function/AST rather
than executing a disk file, and from :load, which restores
a saved session. They are native CLI commands; the embedded
repl.Session, browser Playground and GUI command handlers
do not gain them. They use the native REPL evaluator, not a VM or a
separate --typecheck / --infer run.
doc — statement and
call form
doc is a language statement, not just a REPL command —
it works in scripts too, and it has a call twin that
prints the identical text through the same renderer:
doc Integer # statement form
doc(Integer) # call form — byte-identical output
doc("if") # keywords/operators can't be call arguments — use a String
doc # the full topic catalog (sorted); also doc()
The call form holds its bare-identifier argument un-evaluated
(doc(random) documents the name
random, not the function value), prints like
println, and returns none. It is
evaluator-only — under --vm it rejects with a clear
message.
Runtime reflection — the self-describing surface
Axioma answers its own reference questions at the prompt.
doc <Type> on a built-in primitive type prints a
type card — bounds, literal forms, operations,
conversions, and related functions — and the headline limits are also
live members on the seeded type concepts:
doc Float # the full card: IEEE 754 binary64, literal forms, ==
# pitfalls, conversion family, related functions
Float.max # → 1.79769313486232e+308 (Ruby Float::MAX, native;
# exactly: Float.max == 1.7976931348623157e308)
Float.min_positive # → 4.94065645841247e-324 (smallest subnormal)
Float.epsilon # → 2.22044604925031e-16 (the gap above 1.0)
Float.digits # → 15 (guaranteed significant decimal digits)
Integer.bounded # → false — and asking anyway teaches:
Integer.max # ERROR: Integer has no max — integers are
# arbitrary-precision … see `doc Integer`
Byte.bounded # → true; Byte.min → 0, Byte.max → 255
# Typed string readers (not parse() — that is source → AST):
Integer.parse("32") # → 32
parse_as(Integer, "32") # → 32 (the same function)
Integer.parse("0x20") # → 32 (0x / 0b / 0o prefixes)
Integer.parse("20", 16) # → 32 (optional digit base, Integer/Byte)
Float.parse("3.2") # → 3.2
Boolean.parse("true") # → true
Byte.parse("255") # → byte(255)
Rational.parse("7/2") # → 7/2
Complex.parse("3.2 + 7.1i") # → 3.2 + 7.1i
attempt Integer.parse("nope") # → none
parse("32") still yields an AST
(eval(parse("32")) → 32). A Julia-shaped
parse(Integer, "32") is a catchable error that names
parse_as / Integer.parse. The readers consume
the whole string (trim and _ allowed):
Integer.parse("3.2") errors, while the coercion
int("3.2") still truncates a prefix. Base is Integer/Byte
only.
Cards exist for Integer, Float, String, Boolean, Rational, Complex,
Byte, Bytes, Array, Tuple, Set, and Dictionary. Every card line is
verified by running the interpreter, and doc X ≡
doc(X) stays byte-identical.
Four enumeration/search builtins complete the surface:
keywords() # every reserved word (sorted); keywords("if") → true
builtins() # every builtin function name (sorted; 1000+);
# builtins("map") → true — works under --vm too
concepts() # every Concept visible here — seeded + user-declared
bindings() # what YOU have bound this session (fresh session → [])
apropos("substr") # search names AND documentation text; prints
# name — category — summary lines, sorted
builtins() is precise by design: it lists the standard
(VM-shared) table; env-aware builtins (tableform,
grounding, …) are documented individually and surface
through apropos. concepts(),
bindings(), and apropos() walk the live
environment, so they are evaluator-only (clean rejection under
--vm) and shadowable — a user binding of the name wins.
Function signatures are data (the REBOL model — help renders from the spec). Spec-registered builtins carry a runtime-true parameter list, and three introspection functions read it — for user functions too:
signature(round) # → "round(x, [digits])" ([x] = optional, xs... = variadic)
signature(max) # → "max(values...)"
arity(map) # → 2 (fixed arity from the spec; -1 when not one number)
parameters(round) # → ["x", "[digits]"]
adder: func(a, b) [a + b]
signature(adder) # → "func(a, b)" · arity(adder) → 2
doc round # the doc card now opens with Signature: and the summary
A builtin without a spec yet renders honestly as
name(...) — the spec table backfills incrementally, and a
drift-guard test pins every registered spec to the live implementation
(its arity claims are exercised against the real argument checking, so
the metadata cannot rot).
Three families answer with their own declared shape rather than a table entry. A data constructor's spec is its declaration — the field list is written in the source, and reflection reads it back (a nullary constructor is refused instead: it is not a callable, it is the value). A flipped function reports its callee's parameters, swapped. And a partial reports what remains:
data Shape = Circle(radius) | Dot
arity(Circle) # → 1 the declared field count
signature(Circle) # → "Circle(radius)"
signature(Some) # → "Some(value)" the seeded prelude, same path
arity(Dot) # ERROR — a nullary constructor is already its value
sub: func(a, b) [ a - b ]
parameters(flip(sub)) # → ["b", "a"]
signature(partial(flip(sub), 10)) # → "partial(flip(func))(a)"
Per-type function catalogs —
functions(Type) (Julia's methodswith
view). Each card-bearing type carries a curated, verified list of the
builtins that operate on it (or construct it) — the runtime answer to
"what can I do with this type?":
functions(Integer) # → ["abs", "add1", "bin", …, "zero?"] (sorted)
functions("String") # same catalog by name (this spelling works under --vm)
"divmod" in set(functions(Integer)) # → true
functions() # → the cataloged type names (the doc-card set)
Functions only — operators (+, band,
union, …) stay documented on the type card, which remains
the home of the full operations story. Every (type, function) pair in
the catalog was verified by running before being listed, and a drift
test pins each entry to a live builtin — so the catalog deliberately
excludes the lookalikes (choose is the binomial
coefficient, not a set picker; drop is the Forth stack
verb; abs rejects Rationals).
Value inspection — describe(x) (the
Elixir i/1 / Common Lisp describe model).
Where doc documents names, describe
inspects values: it prints a card with the display form, the
type, and the type-specific facts you would otherwise probe by hand,
then points at doc <Type> and
functions("<Type>"):
describe(42) # Parity/Sign/Digits + the card pointers
describe("héllo") # Bytes: 6 (len) vs Runes: 5 (count)
describe(round) # the R3 Signature: + Summary: lines
describe(none) # the none-vs-om teaching card (both falsy for if; distinct types)
describe(my_relation) # arity + live fact count + tableform pointer
Collections report size and element types; MVL values report their
logic and designation; relations report their extent (which is why
describe is evaluator-only — it reads the live environment;
--vm rejects it with a clear message). It returns
none — the output is the effect — and it is shadowable: a
user binding named describe wins.
Function source — source(f) (the Python
inspect.getsource slot). Where doc documents
names and describe inspects values, source
returns the definition — as a String, not printed, so it
composes:
double: func(x) [x * 2]
source(double) # → "double: func(x) [(x * 2)]" — a re-evaluable statement
eval(source(double)) # the homoiconicity round-trip: re-yields the function
source(func(a, b) [a + b]) # anonymous literal — no binding, no prefix
func zz(0) [1]
func zz(n) when n > 1 [n * 2]
source(zz) # multi-clause: one clausal statement per clause
source(round) # ERROR (catchable) — builtins are Go; see signature/doc
The text is a canonical reconstruction from the live
AST — comments, type annotations, and original formatting are
not preserved (the same printer the '(…) quote +
ast_string pair uses). Evaluator-only by construction:
under --vm function bodies are compiled to bytecode and the
AST is gone. Shadowable. Distinct from
sources_of(rel, args...), which is fact provenance
(attesting entities), not code.
methods — the Ruby/Smalltalk spelling of
functions. An exact alias
(methods(Integer) ≡ functions(Integer),
byte-identical including under --vm). The
methods keyword — reserved-but-dead since the
method → action retirement — was un-reserved
in the same round, so it also works as an ordinary identifier, hash key,
and property name again.
Runtime compilation — compile(src).
Compiles a self-contained program (a source String, or
a quoted AST from '(…)) to bytecode for the VM once, and
returns a callable that runs it on a fresh VM per call:
c: compile("fib: func(n) [if n < 2 then n else fib(n-1) + fib(n-2)]
fib(25)")
c() # → 75025 — runs the bytecode; call repeatedly
compile('(6 * 7))() # a quoted AST compiles too → 42
try(compile("relation r(x)")) # catchable Error: outside the VM subset
try(compile("x + 1")) # catchable: free identifiers error at
# COMPILE time — never a silent capture
Two deliberate boundaries. The program is a sub-program (the
lang-block model), not a closure over your session: it sees no session
bindings and keeps no state between calls — bake values into the source,
or define and call functions inside it. And it covers the
VM-compilable subset — constructs that are
evaluator-only (relations, rules, set comprehensions, …) return a
catchable Error naming the boundary, which makes
try(compile(src)) the first in-language probe of "is this
in the compiled subset?". Results normalize onto the evaluator's values
(a compiled false is falsy, a compiled none is
the Null singleton). eval runs the full language;
compile runs the compiled subset. Available wherever the VM
is linked (the CLI, both runtimes — under --vm too); the
wasm playground omits the VM and says so.
Runtime parsing — ast(x). The
'(…) quote is lexical — it quotes the expression
you are writing. ast() is the runtime front-end:
parse a dynamically-built String without executing it
(eval parses and runs in one step; ast splits
that), pass an AST value through unchanged, or get a user function's
reconstructed definition as an AST:
a: ast("x + 1") # parse, don't run — ast_string(a) → "(x + 1)",
head(a) # the same shape '(x + 1) gives, so the AST
eval(ast("2 + 3")) # algebra (head, ast_type, replace_all) composes
eval(ast("a: 5\na * 2")) # → 10 — a String parses as the WHOLE program
eval(parse("a: 5\na * 2")) # → 5! parse()'s default mode keeps only the
# FIRST statement — ast() never drops code
eval(ast(func(x) [x * 2]))(5) # → 10 — the function-definition twin of source(f)
try(ast("x +")) # parse errors are catchable Errors
A single expression unwraps to its expression node, a single
statement stays a statement, and multi-statement input wraps the whole
Program, so eval(ast(src)) ≡
eval(src) always. The lower-level
parse(code, [mode]) keeps its explicit
"expression"/"statement"/"program"
modes. The String and AST forms work under --vm too;
ast(fn) is evaluator-only by construction (compiled bodies
are bytecode). With this, the reflection wishlist that started the
campaign is fully closed: eval, properties,
arity, bindings, methods,
source, ast, and compile all
exist.
Multi-line
entry — continuation and the dangling else
A line continues (the ... prompt) while a delimiter is
open, after a trailing operator or continuation word (then,
else, and, …), or after a trailing
\. One more rule makes the statement-form
if/else typeable line by line: an entry that
already spans multiple lines and parses as a complete if
(or else if chain) with no final
else stays open — because else is
optional, the branch would otherwise evaluate eagerly and a following
else line would be an orphaned SyntaxError. Type the
else to continue, or press Enter on a blank
line to submit the if-without-else
as-is (the Python compound-statement convention):
axioma> if n > 0 then
... println("n is positive")
... else
... println("n is not positive")
n is positive
A single-line if c then e still evaluates immediately.
(Under a pipe the simple line-by-line REPL is used instead — feed
complete constructs per line, or run a file.)
Reading a
line — input() / readline() /
readlines() / eachline()
input() reads one line from stdin. An optional argument
is printed first as a prompt (no newline added). readline()
is the same stdin read with no prompt; with a path it returns the
first line of that file (opens, reads one line, closes
— calling it twice on the same path returns the first line twice). A
path is a String or a %file literal, never a
prompt.
Both strip the trailing newline. EOF is
none (falsy). A blank line is ""
(truthy). An empty file has no first line, so
readline(path) is none too. That is the line
loop:
line: readline()
while line
println(line)
line: readline()
end
readlines() / eachline() return every
remaining line as an Array — from stdin with no argument, from a file
with a path. eachline is the for-loop spelling; both agree
with io.read_lines (no phantom trailing empty line; an
empty file is []).
name: input("Your name? ")
line: readline()
first: readline("notes.txt")
print("Your name? "); who: readline()
for w in eachline("words.txt") [
println(w)
]
Reading
a value — read_integer() / read_float() /
read_string()
These read the next token from stdin —
whitespace-separated, newlines included in the skip — not a whole line.
That is Pascal read, Java nextInt, Go
fmt.Scan, not Julia
parse(Int, readline()).
n: read_integer() # 10 20 on one line → 10; call again → 20
x: read_float()
w: read_string() # next word; a full line is readline()
ok: read_boolean() # true / false
b: read_byte() # 0..255
q: read_rational() # 3/4 as one token
z: read_complex() # compact 3+4i
EOF is none. A token that is not the requested type is
an Error. input_number() still retries until
the line is a number.
input_number, prompt, choice,
confirm, and menu share the same persistent
stdin reader, so sequential calls consume successive lines under a pipe.
The token readers share it too.
Detecting
interactivity — is_interactive() /
isatty()
A script can ask whether it is attached to a terminal, so the same
source can prompt a human interactively yet stay silent (or take
defaults) when driven from a pipe, a test harness, or CI. Both return a
Boolean:
if is_interactive() then
name: input("Your name? ")
else
name: "anonymous" # piped / non-interactive run
isatty() is the lower-level alias (true iff stdin is a
TTY); is_interactive() is the same check phrased for the
common prompt-or-default idiom. Under a pipe both are
false.
Error handling
axioma> x:
Parser errors:
expected expression after `:`, got EOF
axioma> 5 / 0
ERROR: division by zero
axioma> unknown_function(5)
ERROR: identifier not found: unknown_function
The word oracle — discovering the language by bumping into it
Start Axioma with -o (or --oracle), or type
:oracle on in the REPL, and an unknown word stops being a
dead end. The oracle is a hint provider: it changes
nothing about what runs. With it off, every message is the plain
interpreter's.
The hint depends on where the word sat. A bare word gets all five roles a word can take, one canonical sentence each; the position decides which comes first:
> ogabund
ERROR at 1:1: Undefined word: ogabund
Hint: I don't know "ogabund" yet. It could be:
1. a value ogabund: 42
2. a thing ogabund: a Concept (no kinds defined yet — make one with: concept Drum)
3. a kind of thing concept Ogabund
4. a relation relation ogabund(x, y)
5. a way to compute ogabund: func(n) [ n * 2 ]
Still stuck? ask "how do I define ogabund?" (the AI door — sends that question to the configured model)
Type a number to use that form, or keep typing.
> 2
ogabund: a Concept
ogabund(1) puts a way to compute first;
ogabund's price puts a thing first ("not a thing
yet, so it has no slots"); ogabund is Drum explains that
is asks and does not create; msft: a Ogabund
puts a kind of thing first. Once you have made a kind, the
thing row names it (ogabund: a Drum). A near-miss is
offered when one exists — your own words first, then the documented
dictionary (aple → apple, filtr → filter).
A word that is a builtin's name in another shape is
respelled, flag or no flag: isEmpty, isempty,
IsEmpty, emptyp and empty are all
empty? written the way another tradition writes it,
foldRight is fold_right, and a word written
with a capital is a kind's name by rule, so INTEGER answers
the type Integer (a capitalised Shape is never
pushed toward the function shape). Without the flag the
answer is one sentence under the error —
Did you mean \empty?` (also spelled `is_empty`)? If not:
check the spelling, …` — and nothing is aliased: the word stays
undefined. With the flag the corrected statement is a numbered row, and
the manual pointer is the search for the word:
> isEmpty(xs)
ERROR at 1:1: Undefined word: isEmpty
1 | isEmpty(xs)
^
Hint: "isEmpty" is a builtin's name in another shape. Did you mean:
1. empty? empty?(xs) (also spelled is_empty)
Offline: manual "empty?" (the manual built into this binary)
Type a number to use that form, or keep typing.
> 1
false
"(also spelled …)" is said only when the two names are one function;
a shape that belongs to several names (isNone →
none?, is_none) gets a row each, with no claim
about how they differ.
The oracle also speaks on one shape that runs without error: a pipe
ending in a bare operator with a loose value after it on the same line.
x |> float |> ^2. is not "square it": ^
applied to one value is a partial application (a function),
2 is a separate statement, and the trailing .
prints it — exit 0, answer 2. With the flag on, a note on stderr after
the line has run names the form meant,
(x |> float) ^ 2 ., and the REPL offers to run it;
stdout is byte-identical. A bare operator with nothing after it is left
alone: that is a partial application on purpose. A dotted operator that
meets the wrong operand (1..5 .^ 2 is
1..(5 .^ 2), so .^ meets a number) always
carries its contract, flag or no flag; with the flag on, the oracle
names the precedence trap and offers array(1..5) .^ 2 and
the comprehension, runnable by number.
The same voice answers three routes. what is X? and
doc X give the hint for an unknown word, and for a
known word they add what can be asked of it:
> what is msft?
msft is a Stock {price: 380}
You can ask:
msft is Stock # what kind of thing is it?
msft's <slot> # what does it have? (slots: price)
msft is/same other # is it the same one?
what is msft? # describe it
predicates_of("msft") # which facts mention it?
{ x | x is Stock } # everything of its kind
In the REPL a bare number after a hint runs that form. In a script
the hint is printed under the error, without the picker line, and the
run still fails — tests/axioma/showcase/knowledge.ax is the
standing example.
Finding a word, operator or command
doc("for"), doc("loop") and
doc("end") describe loop syntax rather than calling it
undefined. Quote punctuation when looking it up:
doc("|>"), doc("/"),
doc(".+"). For a live user-defined word, doc
keeps that word's value, attached documentation and annotations; a
shadowing binding does not inherit an unrelated builtin's help.
The source doc forms and REPL :doc share a
lookup with the Playground help panel and builtin editor hovers. The
catalog includes registered builtin names, reserved keywords, documented
constructs, types and catalog symbols. It combines authored cards,
verified reflection signatures, excerpts from the embedded Manual, and
extracted library reference notes. Extracted notes
quote existing implementation comments and literal call diagnostics:
different diagnostics can describe different overloads, and these notes
are not a complete signature or a stability guarantee.
Reserved-but-unimplemented and retired words remain labeled; listing a
name does not promote its support tier.
manual("for loop") searches the book included in the
executable. Whole words and code spellings in headings rank ahead of
incidental substrings such as “formatting.” If a name has no Manual
passage but has local reference help, Manual lookup shows that
reference. oracle("for") also recognizes existing syntax
instead of suggesting that you define a reserved word. These local
lookups never contact a model. ask can use relevant
reference entries as context, but still contacts a provider only when
explicitly invoked.
In the REPL, :man and :manual are aliases.
man is not a source-level function alias:
write manual("topic") in a program. doc
without an argument lists available language-help topics;
apropos("topic") searches names and available descriptions.
The online
language reference is generated from the same lookup. The online
textbook search defaults to This book and offers
All docs for the Manual, reference and studies. Both
website scopes index complete sections, not only their opening text.
Four words:
oracle, ask, ask/any,
manual
The oracle never calls a model on its own; the hint only points at two routes. Four words open them, each in a statement form and a call form that lower to the same node:
oracle ogabund # the local parent, as a word — never a model
oracle msft # a known word: what it is, what you can ask
oracle/on # enable hints for this run
manual # the manual built into this binary: the chapters
manual 7 # a chapter, with its sections
manual "7.1" # one section in full
manual comprehension # search: headings first, then the text
manual/raw "7.1" # the markdown source, unrendered — for copying
ask "how do I make msft a Stock?" # the language door — the reply must PARSE
r: ask("how do I state a fact?") # call form, bound
ask/any "one sentence on Leibniz" # the general door — any topic, plain text
ask/any("…", {provider: "ollama"}) # the scope is a refinement; options ride along
ask (the same as ask/axioma) sends a
generated card — the roles above and the slot forms; for each word of
your question that you have defined, what it is and what can be asked of
it (a thing brings its kind, since slots are declared there); your own
words; and the dictionary entries for the remaining words of the
question — and runs the model's suggestion through the interpreter's own
parser, repairing it with the parser's errors for at most three rounds.
Only a suggestion that parses is shown: it is printed after
ask suggests:, the model's sentence after
ask says:, and it is returned as a String you can bind; in
the REPL a bare 1 runs it. Nothing is inserted for you. An
English function word is not looked up in the dictionary
(how do I … is not a question about how);
quote a word — `how` — to ask about the word itself.
ask/any sends the question as written and returns the
reply. Every call prints where it went and what it cost:
[axioma ask] provider=openrouter model=google/gemini-2.5-flash-lite endpoint=https://openrouter.ai/api/v1 (remote — billed to your API key) estimated ~600 tokens
[axioma ask] used 610 input + 20 output tokens (session total: 610 + 20)
In the REPL a remote provider asks Continue? [y/N]
first; a script announces and never prompts.
AXIOMA_TRANSLATE_QUIET=1 silences the two lines.
:manual and :man are the REPL twins of
manual. The words are ordinary bindings if you make them
yours: oracle: array({…}) keeps working, and
ask or manual bound to a value are your
values. Under --vm the doors refuse rather than answering
differently. (ai "…" was retired on the day it shipped: it
is ask/any now.)
26. CLI Flags, MCP Server & Tooling
CLI flags
| Flag | Effect |
|---|---|
| (none) | Start REPL |
<file.ax> |
Run script |
--vm <file> |
Run in VM mode |
--mcp [start|stop|restart|status] |
MCP server |
--no-kb |
Skip Cascade KB preload (~10× faster startup) |
--verbose |
Verbose output |
--language <subset> |
Restrict surface to a subset: axioma/all (default),
axioma/knowledge, axioma/knowledge-core
(monotonic proof-core), axioma/beginner,
axioma/rpn (additive Forth return stack),
axioma/hm (closed Hindley–Milner island).
axioma/core and axioma/functional are known
names with no AST gate (they run as host — do not treat
them as dialects) |
-o / --oracle |
The word oracle: position-aware hints for unknown words,
what is / doc guidance for known ones, REPL
number picker (:oracle on|off|status). Off by default —
with it off, messages are byte-identical to the plain interpreter. See
§25 The word oracle |
--mode <name>[,<name>…] |
Educational mode: functional, logic,
stack, mathematical, linguistic,
imperative, beginner |
--glyphify <file> /
--asciify <file> |
Token-aware in-place canonicalizer between word operators and
Unicode glyphs (in ↔︎ ∈, and ↔︎
∧, forall ↔︎ ∀; digraphs
`cup → ∪). Strings/comments untouched;
verifies the rewrite lexes+parses identically before writing (see §3
"Typing the glyphs") |
--fmt <file> /
--fmt-check <file> |
Layout canonicalizer: indents to bracket depth (2 spaces), strips
trailing whitespace, caps blank runs at 2, ends with exactly one
newline. Whitespace only — token bytes, string contents
and comments are preserved, and the result must lex to an identical
token sequence and still parse or nothing is written. Unparseable input
is refused. --fmt-check reports without writing and exits 1
if any file is not canonical (CI/pre-commit). Pairs with
--glyphify, which canonicalizes spelling |
--fmt-diff <file> |
Print the unified diff --fmt would apply, without
writing; exits 1 if any file is not canonical. --fmt-check
names the files, --fmt-diff shows the lines. The output is
a real patch (patch -p0 / git apply accept
it), and applying it reproduces --fmt's bytes exactly. A
trailing-whitespace-only change is invisible in a diff, so those carry
an explanatory note |
--infer |
On axioma/all: print HM arrow types for top-level
functions in the lambda fragment (name :: Type); skip
reasons for the rest; a declared-return mismatch exits 1, a refusal does
not. On axioma/hm (per file): the same checker as a run —
untypable programs exit 1 |
--typecheck (alias --check) |
Static pre-pass: walk annotated code, report type errors, exit non-zero on violations. Also prints success-typing warnings on proven-impossible unannotated calls; those never fail the check |
--strict |
With --typecheck, also flag undefined-identifier
references |
--strict-bottoms |
With --typecheck, also flag bottoms
(none/om) in unannotated bindings and
parameters — require explicit | None /
| Om |
--run |
With --typecheck, execute the script if (and only if)
the check is clean |
--no-typecheck |
Plain axioma file.ax runs a default non-fatal
warn-pass — annotation/ADT findings and
success-typing (proven-impossible unannotated calls) print as
warnings (stderr; a large count is summarized, not dumped),
then the script executes regardless (warnings never change the exit
code). This flag turns that whole warn-pass off. The fatal linter stays
behind --typecheck |
--learn |
Start the interactive learning wizard (single-shot Q&A) |
--learn-list / --learn-task <n> /
--learn-path <dir> |
Wizard navigation + custom lessons |
--recipe |
Start the HtDP-style Design Recipe wizard (six stages per task) |
--recipe-list / --recipe-task <n> /
--recipe-stage <n> /
--recipe-path <dir> |
Recipe wizard navigation + custom recipes |
--annotate / -a
<file> |
Generate a literate walkthrough:
<file>.annotated.md (pure markdown) +
<file>.annotated.ax (executable literate source).
Groups statements into blocks (relation / fact / rule / func / …) and
explains each from a pedagogical pattern library |
--annotate-html |
Also emit a self-contained <file>.annotated.html
(embedded CSS, light/dark, inline SVG diagrams for binary
relations) |
--annotate-llm |
Fill gaps the canned patterns don't cover via the configured AI
provider (off by default — -a alone runs offline at
$0) |
--annotate-terse / --annotate-verbose |
One sentence per block / 4–6 sentences with analogies |
--annotate-no-run |
Skip the auto-executed ## Output snapshot embedded at
the bottom |
--doctest <file-or-dir> |
Run the axioma doctest example fences in a file or
directory (.ax / .md); reports
ok: / not ok: and exits non-zero on any
failure or if no fence is found (see §24) |
Literate annotation:
--annotate
axioma --annotate path.ax parses the script, groups
consecutive statements of the same kind into blocks, and emits a
step-by-step walkthrough — markdown for reading plus a still-executable
literate .ax. Binary relations with ≥2 facts get an inline
diagram (mermaid in the markdown, inline SVG in the HTML). It runs fully
offline by default; --annotate-llm adds an AI fallback for
unrecognized constructs.
axioma -a prolog-like.ax # MD + AX, offline
axioma --annotate --annotate-html --annotate-verbose script.ax
Educational subset:
axioma/beginner
--language axioma/beginner (or the
#language axioma/beginner source pragma) restricts the
surface to one canonical form per operation — x: value (or
the textbook x = value), func,
if/then/else, while,
for/foreach,
repeat/loop, println,
concept Foo [doc] [refinement] [block],
Day enumerates … — and rejects the alternatives
(lambda, fn, \x ->,
match, print, printf,
display, subrange ranges declarations) with
educational guidance pointing at the canonical equivalent. Setting this
subset also auto-activates --mode beginner for the
behavioral overlay (no metaprogramming / FFI / proofs). Loops are three
constructs, not three spellings of while; beginner allows
all three.
Concept declaration in beginner mode. The single
canonical creation surface is concept Foo — bare, with
optional postfix doc string (concept Foo "doc"), prefix
refinement (concept/persist Foo), or slot-defaults block
(concept Foo { slot: default }). The non-canonical
Foo is Concept / Foo exists /
Foo create / create Foo shapes error uniformly
— beginners get the same hint as expert users. is is
canonical for instance classification (apple is Stock) and
Boolean type queries (x is Stock in expression position),
including X is Concept as a "is this a registered concept?"
query.
Binding forms in beginner mode. Both live binding
forms are accepted (x: 5 and the textbook
x = 5 find-or-update), with x: 5 taught as the
canonical form in the HtDP-in-Axioma textbook
(x := 5 is a syntax error). This relaxation trades strict
canonicalisation for compatibility — the textbook teaches one preferred
form while existing tests continue to work unmodified.
Static --typecheck
pre-pass
The checker walks the AST in two passes: pass 1 gathers concept /
enum / subrange / function-signature declarations (so forward references
work); pass 2 walks every annotated binding, every assignment to an
annotated binding, and every call site with literal arguments, emitting
errors when both sides are statically known and disagree. A function is
a function whatever its spelling: name: func(x :: T) […],
name: (x :: T) => …,
let name = (x :: T) :: R => … and lambda
all hoist their signature in pass 1 and have their bodies walked with
the parameters in scope in pass 2, so
f: (x :: Integer) => x followed by f("s")
is flagged exactly like the func form, and a lambda called
with more arguments than it takes is flagged before the runtime refuses
it. Only annotated code is checked as errors. Unannotated code
stays dynamic at runtime. Success typing (above) adds
warnings on an unannotated call whose
inferred type makes a literal argument impossible — on
--typecheck and on the default
axioma file.ax warn-pass. Those never fail
--typecheck and never change the script exit code.
--no-typecheck turns the whole warn-pass off. Pass
--strict-bottoms to also require bottoms (none
/ om) to appear under an explicit | None or
| Om annotation (unannotated s: none,
reassignment of none into an inferred String,
and bottom args to unannotated parameters are flagged). Free re-typing
of non-bottom values stays legal
(t: 0; t = [1, 2]).
Nine violation classes covered: annotation/literal mismatch, subrange
over/under bounds, inline subrange, cross-enum literal, reassignment
violation, call-site arg type, arity mismatch, ordinal arithmetic
(below), (with --strict) undefined-identifier references,
and (with --strict-bottoms) unannotated bottoms.
:: Ordinal — the ordinal discipline. An
Ordinal is an integer used as a position label (a
rank on a value scale, a preference order): it may be compared and
selected — <, >, ==,
min, max, sort, nth
— but never subjected to arithmetic. Adding, differencing, or scaling
ranks is the category mistake the annotation exists to catch, and
assigning an ordinal to an :: Integer binding (laundering
it back into a magnitude) is flagged too — including when the laundering
is written as an ascription, (rank :: Integer).
The annotation is runtime-neutral — at runtime an ordinal is carried by
an ordinary Integer — and applies to bindings, function parameters and
ascriptions alike:
rank :: Ordinal: 3
next :: Ordinal: 7
rank < next # fine — comparison is the licensed operation
max([rank, next]) # fine — selection
rank + 1 # --typecheck: arithmetic '+' on ordinal — flagged
better: func(a :: Ordinal, b :: Ordinal) [if a < b then b else a] # fine
axioma --typecheck script.ax # check only
axioma --typecheck --run script.ax # check then run if clean
axioma --check --strict script.ax # alias + undefined-ref detection
axioma --typecheck --strict-bottoms script.ax # bottoms need | None / | Om
Learning wizards
--learn is the single-shot Q&A wizard from
tools/learn/*.json (12 built-in tasks).
--recipe is the multi-stage HtDP-style wizard from
tools/recipes/*.json (5 built-in recipes), walking the
student through data → signature → examples → template → body → tests.
Both wizards inherit --mode and --language
from the parent invocation —
axioma --learn --language axioma/beginner enforces the
beginner subset on student code.
MCP server
Start the server (stdio JSON-RPC) for Claude Desktop / Code / Cascade integration:
./axioma --mcp # Start
./axioma --mcp status # Check
./axioma --mcp stop # Stop
11 tools exposed:
| Tool | Purpose |
|---|---|
parse_axioma |
Parse to AST |
validate_syntax |
Syntax check |
analyze_code |
Semantic analysis |
translate_code |
AST-aware translation (Go, Python, Axioma) |
symbolize_nl |
Natural language → Axioma |
execute |
Run code, return result + output |
query_kb |
Query the persistent KB |
inspect_env |
Inspect session environment |
run_file |
Execute .ax files |
decompose_claim |
Break claim into propositions |
evaluate_b4 |
Belnap B4 / K3 / L3 evaluation |
resolve_entities |
Match entities against KB |
KB location: ~/.axioma/axioma.kb (SQLite). Logs:
~/.axioma/mcp.log. PID: ~/.axioma/mcp.pid.
Claude Desktop config
{
"axioma": {
"command": "/path/to/axioma",
"args": ["--mcp"]
}
}
Other front-ends
- Web GUI —
web-gui/start.sh(Vite/React + Go REST API) - Wails GUI —
wails-gui/(desktop) - Jupyter kernel —
jupyter/install_kernel.sh - VS Code extension —
vscode-axioma/
Testing — the expect
builtin
expect(label, actual, expected) is a real assertion: it
passes iff actual == expected (regular value equality —
cross-type numerics, deep array/set/map, MVL coercion). It prints
go test-style markers —
--- PASS: <label> (<duration>) on a match,
--- FAIL: <label> (<duration>) plus the
actual/expected values on a mismatch; the parenthesized duration is the
time spent evaluating actual/expected
(adaptive µs/ms/s), so a slow
assertion stands out and can flag an optimization regression the way
go test shows per-test durations. A mismatch increments a
run-level counter and continues
(accumulate-and-continue); at end-of-run the CLI prints a Go-style
summary to stderr — PASS <script> (N assertions) or
FAIL <script> (N of M failed) — and exits
non-zero if any expectation failed. (The summary prints
only for scripts that ran ≥1 expect(), so ordinary scripts
stay silent.) This is what lets the parallel test runner (which keys on
exit code) actually detect wrong answers — unlike the older
if cond then println("✅") else println("❌") idiom, which
exits 0 even when every check is wrong.
expect("two plus two", 2 + 2, 4) # --- PASS: two plus two (0µs)
expect("oops", 100 // 7, 13) # --- FAIL: oops (0µs) (actual 14, expected 13) → exit 1
expect("slow path", slowfib(30), 832040) # --- PASS: slow path (1.42s) ← flags slow code
Because it's a function call (not a keyword), a local
expect: func(...) definition shadows it — so adding it was
zero-regression for the files that previously hand-rolled their own
expect.
For documentation examples that double as tests, see
Doctests (§24): axioma --doctest runs the
runnable axioma doctest fences embedded in your
.ax / .md files, and the Playground's
✓ Doctest button runs them in the browser.
27. Examples
Set theory
numbers: {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens: {2, 4, 6, 8, 10}
primes: {2, 3, 5, 7}
evens union primes # {2, 3, 4, 5, 6, 7, 8, 10}
evens intersect primes # {2}
numbers difference evens # {1, 3, 5, 7, 9}
primes subset numbers # true
venn(evens, primes)
Concept system + frame queries
Country extends Concept
Country has gdp_billion
Country has population_million
usa: a Country {}
usa.gdp_billion: 27000
usa.population_million: 332
china: a Country {}
china.gdp_billion: 18000
china.population_million: 1410
big_economies: {C | C is Country, C.gdp_billion > 20000}
Logic programming with rules
relation parent(x, y)
assert parent("Adam", "Cain")
assert parent("Adam", "Abel")
assert parent("Cain", "Enoch")
assert parent("Enoch", "Irad")
# Strict rule: ancestors
ancestor(X, Y) <= parent(X, Y)
ancestor(X, Z) <= parent(X, Y) and ancestor(Y, Z)
# Query
{Y | Y <- ancestor("Adam", Y)}
# {"Abel", "Cain", "Enoch", "Irad"}
# Provenance
proof("ancestor", "Adam", "Irad")
why ancestor("Adam", "Irad")
Defeasible reasoning
relation bird(x)
assert bird("tweety")
assert bird("polly")
assert bird("opus") # A penguin
flies(X) <~~ bird(X) # Birds typically fly
cancel("flies", "opus") # But opus doesn't
{X @conjecture | X <- flies(X)} # {"polly", "tweety"}
Multi-valued logic
# Belnap B4 — paraconsistent reasoning under contradictions
relation parent(x, y)
assert parent("X", "Y")
set_truth("parent", "X", "Y", "both") # Contradictory source
truth("parent", "X", "Y") # ⊤⊥ᵇ
# Gödel G3 — intuitionistic
p: ?ⁱ # ≡ intuit3("unknown")
g3_lem(p) # ?ⁱ — LEM not valid
g3_dne(p) # ?ⁱ — DNE fails
p implies p # ⊤ⁱ — reflexive
# Łukasiewicz L3 — fuzzy-like truth
half: lukasiewicz(0.5)
half implies lukasiewicz(0.7) # 1.0 (truthier consequent)
Functional programming
data: {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
# Sum of squares of odd numbers
result: reduce(
lambda (acc, x) => acc + x,
0,
map(lambda x => x * x,
filter(lambda x => odd(x), data)
)
)
# 1 + 9 + 25 + 49 + 81 = 165
Stack-based RPN
3 4 + 2 * # ((3 + 4) * 2) = 14
:s # [14]
5 dup * # 5² = 25
2 swap # [2, 25]
Atomic mutation
relation parent(x, y)
transaction_begin()
insert("parent", "Eve", "Seth")
insert("parent", "Seth", "Enos")
set_truth("parent", "Eve", "Seth", "true")
# Oops, mistake
transaction_rollback() # Undoes all three operations
Opened at the top level, as here, the transaction stays open until you close it. Opened inside a function, it belongs to that call and is rolled back if the call returns without committing — see §17.
28. Embedding Other Languages
Axioma can call out to peer languages from inside an .ax
script. The mechanism is built around a single block form and
language-prefixed namespaces (python.math.sqrt,
julia.exec(…)) — the prefix is what says a call crosses
into a foreign runtime, while a BARE package name
(math.sqrt) is always native (§27.2). The goal is
adoption, not performance: developers coming from
Python (or Julia, R, JS) keep familiar call sites one prefix away while
the rest of the program is written in Axioma, then migrate piece by
piece as native equivalents land.
28.1 The
[<dialect> | … ] block
mean: [python |
xs = [1, 2, 3, 4, 5]
print(sum(xs) / len(xs))
]
println(mean) # 3.0
- The opening
[pythonis a registered dialect tag. Recognized tags:python(aliaspy); the secondary subprocess runnersjulia,rlang(R),js,lua,lisp(Common Lisp via sbcl; aliasescl/commonlisp), the compiled runnerspascal(fpc; aliaspas),clang(the system C compiler) andhaskell(GHC; aliashs); plus the in-process/translation tags (axioma,monkey,sql,model,solver,sympy/cas,nl). Every interpreted runner (Julia/R/Node/Lua/Lisp) also takes/eval, as does Haskell (see §28.2). - Two families. Most tags are foreign: the
body goes to a runtime you installed, and the block fails on a machine
without it. A few are native —
axioma,monkey,sql,sparql,cypher,logic,solver,modelare implemented inside Axioma itself, need no toolchain, and work in the browser playground.monkey(§28.3) is the only one of those that is a general-purpose foreign language rather than a query surface or a lens. - One-letter tags are banned by design — R goes by
rlang(renamed fromr, July 2026) and C byclang, neverc: whilerwas a dialect tag,[r | r <- xs]was not a comprehension (the whole body shipped to Rscript).[r | …]and[c | …]comprehension heads are ordinary again. - After the
|the parser switches to a raw-source reader: every character up to the matching]is sent verbatim to the foreign runtime. Indentation, embedded brackets, multi-linedefs, and string literals all survive intact. - The block is an expression — its value is the
foreign runtime's captured stdout as an Axioma
String. - Both the tree-walker and the bytecode VM execute the block; switch
with
--vmand behavior is identical.
Mode refinement:
[python/eval | … ]
The default form ([python | … ]) is "exec mode": the
body is run as a Python script, and whatever it prints to stdout becomes
an Axioma String. To get a typed value back without writing
print(...), use the eval mode refinement — the
body is treated as a single Python expression and the result is
JSON-decoded into a typed Axioma value:
n: [python/eval | 2 + 3] # 5 (float)
arr: [python/eval | [x*x for x in range(5)]]
# [0,1,4,9,16] (array)
person: [python/eval | {"name": "ada", "age": 36}]
# Dictionary
flag: [python/eval | 3 > 2] # true (boolean)
Trade-offs:
| Form | Body kind | Returns | Use when |
|---|---|---|---|
| `[python | … ]` | full script | String |
| `[python/eval | …]` | single expression | typed value |
eval mode rejects multi-statement bodies (Python's
eval() only accepts an expression). For a multi-statement
body that should still return a value, fall back to exec and
print(json.dumps(...)) your result.
Sharing scope with Python
Free identifiers in the block body that resolve to a JSON-marshallable Axioma binding are auto-injected as Python locals before the body runs. The result is that the natural form just works:
xs: [1, 2, 3, 4, 5]
factor: 10
mean: [python/eval | sum(xs) / len(xs)] # 3
scaled: [python/eval | [n * factor for n in xs]]
# [10,20,30,40,50]
Capture rules:
- A name is captured if it appears in the body, isn't a Python keyword
or common builtin, isn't a
for X in …loop target, and resolves to an Axioma value of a marshallable type (Integer, Float, Boolean, String, Array of marshallable, Tuple, Null). - Names of types we can't safely cross the wire (
Set,Dictionary,Concept,Reference, MVL values, lambdas) are silently skipped — the body sees nothing for that name and Python errors normally if it tries to use it. - Reassigning a captured name inside the body is fine and has no effect on the Axioma scope. Capture is one-way: Axioma → Python.
- Identifiers inside Python comments and string literals don't trigger capture.
For names you specifically don't want captured (e.g. an Axioma
len that would shadow Python's builtin if it weren't
already on the reserved list), the simplest workaround today is to alias
the value through a name that doesn't collide.
28.2 Secondary runners: Julia / R / Node.js / TypeScript / Lua / Common Lisp / Free Pascal / C / Haskell
Beyond Python, nine foreign runtimes run as per-call subprocesses.
Each spawns the host once per block and returns its captured stdout as
an Axioma String (exec mode, the default).
Every interpreted runtime — Julia, R, Node.js, TypeScript, Lua,
Common Lisp — plus Haskell also offers an /eval
mode ([julia/eval | … ],
[rlang/eval | … ], [js/eval | … ],
[ts/eval | … ], [lua/eval | … ],
[lisp/eval | … ], [haskell/eval | … ]) that
returns the body's value as a typed object — see their specifics below.
Free Pascal, C and Haskell are the COMPILED runtimes in
the family: the body is compiled (fpc / the system
cc / ghc) and the produced binary is run —
Haskell is a hybrid whose main-less bodies go through
ghc -e instead — see their specifics below. In exec mode
the body must print its result (Haskell's -e path
prints showable values itself, and R auto-prints visible top-level
values in its own [1] 8 format).
| Tag(s) | Runtime | Binary (override env) |
|---|---|---|
julia |
Julia | julia (AXIOMA_JULIA) |
rlang |
R | Rscript (AXIOMA_RSCRIPT) |
js |
Node.js | node (AXIOMA_NODE) |
ts (alias typescript) |
TypeScript via tsx |
tsx (AXIOMA_TSX) |
lua |
Lua | lua (AXIOMA_LUA) |
lisp (aliases cl,
commonlisp) |
Common Lisp | sbcl (AXIOMA_LISP) |
pascal (alias pas) |
Free Pascal (compile-and-run) | fpc (AXIOMA_PASCAL) |
clang |
C (compile-and-run) | cc (AXIOMA_CC) |
haskell (alias hs) |
Haskell (compile-or-eval) | ghc (AXIOMA_GHC) |
[julia | println(sum(1:10)) ] # → "55"
[julia/eval | [x * 2 for x in [1, 2, 3]] ] # → [2, 4, 6] (typed Array)
[rlang | cat(mean(c(1, 2, 3))) ] # → "2"
[rlang/eval | mean(c(1, 2, 3)) ] # → 2 (typed — R's length-1 vector is a scalar)
[js | console.log(2 + 3) ] # → "5"
[js/eval | await Promise.resolve(6 * 7) ] # → 42 (async bodies compose)
[ts | const n: number = 6 * 7; console.log(n) ] # → "42"
[ts/eval | (() => { const n: number = 40; return n + 2 })() ] # → 42
[typescript/eval | 3 * 3 ] # → 9 (alias)
[lisp | (format t "~a" (+ 1 2)) ] # → "3"
[cl | (princ (reduce (function *) '(1 2 3 4 5))) ] # → "120" (factorial)
[pascal | writeln(2 + 2) ] # → "4"
[clang | printf("%d\n", 2 + 2); ] # → "4"
[lua | print(2 + 2) ] # → "4"
[lua/eval | {1, 2, 3} ] # → [1, 2, 3] (typed Array)
[haskell | 1 + 2 ] # → "3" (GHCi-style: values print)
[hs | putStrLn "hi" ] # → "hi" (IO actions run)
[haskell/eval | map (*2) [1, 2, 3] ] # → [2, 4, 6] (typed Array)
Julia / R / JavaScript / TypeScript specifics.
- One
/evalcontract, shared JS encoder for Node and tsx. Node and TypeScript encode with an explicit JS encoder (stricter thanJSON.stringify: non-finite numbers and cyclic values are errors, not silentnulls); Julia and R have no stdlib JSON, so a self-contained encoder is spliced around the body (the Lisp/Lua precedent). All decode through the same converter as Python/Lisp/Lua/Haskell: floats with no fractional part collapse toInteger, so42is the same value whichever runtime produced it. - TypeScript is a runner, not
tsc.[ts | …]writes a temp.tsfile and runs it withtsx(AXIOMA_TSXoverrides;AXIOMA_TS_TIMEOUTbounds the wall clock, default 30s). Annotations are stripped/transpiled for execution — there is no separate typecheck pass on the hot path. Install withnpm install -g tsx(or setAXIOMA_TSXto a project-local binary). Alias:[typescript | …]. - Multi-statement eval bodies. Julia bodies sit in
begin … endand R bodies in{ … }— statements run in order and the LAST expression is the value. A JS/TS body is a single parenthesized expression (the Lua rule); reach for an IIFE (or exec mode) for multi-statement work. - JS and TS are async-aware. The body runs inside an
async wrapper:
awaitis legal, and a thenable result is awaited before encoding —[js/eval | Promise.resolve(7) ]→7, same for[ts/eval | …]. - Value mapping.
nothing/missing(Julia),NULL/NA(R) andundefined/null(JS/TS) all arrive asnone. Julia tuples, ranges and sets become Arrays; Dicts and NamedTuples become dictionaries; chars and symbols become Strings; rationals encode as their float. R follows its everything-is-a-vector model: an unnamed length-1 atomic vector is a SCALAR (1 + 2→ Integer 3), other unnamed vectors/lists are Arrays, fully-named ones are dictionaries (so adata.framearrives as a dict of column arrays), and factors encode as their labels. JS/TS Sets become Arrays, string-keyed Maps become dictionaries, a Date becomes its ISO-8601 String, and BigInt is exact within ±2^53 (an error beyond). - Hermetic, bounded runs. Julia runs with
--startup-file=noand R with--no-init-file --no-site-file(a user's startup.jl/.Rprofile must not affect block output — the sbcl precedent;.Renvironstill loads so R library paths keep working). Each runtime is time-bounded byAXIOMA_JULIA_TIMEOUT/AXIOMA_RSCRIPT_TIMEOUT/AXIOMA_NODE_TIMEOUTseconds (default 30), and stdin is EOF-bound so a stray read returns instead of hanging the interpreter. - Argv trivia that used to bite. Node runs the
attached
--eval=<code>form and R runs a temp.Rfile rather than-e— both option parsers reject a body opening with-(-1) as a flag, and R's-eadditionally caps input at ~10k bytes. Julia's-econsumes the next argument verbatim and needs no workaround. - Printing bodies corrupt eval. Like Lua, eval mode
does not silence stdout: a body that also
printlns /cats /console.logs corrupts the JSON and the block errors. Use exec mode for printing bodies.
Common Lisp specifics.
- The body runs via
sbcl --noinform --no-sysinit --no-userinit --non-interactive --eval <body>— hermetic (no init files), no herald, debugger disabled, clean non-zero exit on an unhandled condition (the condition text comes back as a catchable AxiomaError). Print withformat/princ/print; a bare(+ 1 2)returns""because--evaldoes not echo a form's value — for a typed value back, use/evalmode (next bullet). - Eval mode —
[lisp/eval | … ](aliases[cl/eval | … ],[commonlisp/eval | … ]) returns the body's value as a typed Axioma object instead of captured stdout:[lisp/eval | (+ 1 4 9) ]→14(Integer),(/ 10 4)→2.5(Float),(list 1 2 3)→[1, 2, 3](Array),(> 5 2)→true,nil→none. CL has no stdlib JSON, so the runner wraps the body in a small self-contained JSON encoder (integers / ratios / floats / strings / symbols /T/NIL/ proper-lists / vectors round-trip; an integer-valued float collapses toInteger, matching[python/eval | … ]). The body's own stdout is discarded in eval mode — only the value returns. An unknown refinement ([lisp/foo | … ]) is rejected without spawning sbcl. AXIOMA_LISPoverrides the binary, but it must be SBCL-compatible — the runner passes SBCL's flags. Use it to pin a specific sbcl (e.g. a Roswell-managed one) or an alternate path, not to switch to clisp/ecl/ccl (whose flags differ). Unset →sbclonPATH.- Body-capture caveats (Lisp-aware reader). The Lisp
dialects use a Common-Lisp-aware raw reader so the quote
'(the QUOTE operator, normally unpaired as in'(1 2 3)) is not mistaken for a string delimiter —[lisp | (mapcar #'1+ '(1 2 3)) ]works. Double-quoted"…"strings,;line comments, and#\xcharacter literals are skipped, so a]inside any of them is safe. The residual limits (rare): a bare]inside a|multi-escape symbol|or a#| … |#block comment will still close the block early — avoid those in inline bodies. There is no escaping; the body is passed byte-for-byte. - VM parity: full. Both
[lisp | … ](exec) and[lisp/eval | … ]compile toOpLangBlockand run identically under--vm(the lang tag carries the mode).
Free Pascal specifics.
The Pascal runner exists for a concrete workflow: the decades of data
structures & algorithms books written in Pascal. Type the book's
code in verbatim, run it under fpc, and use its output as
the oracle for an idiomatic Axioma port — differential
testing instead of eyeballing:
original: [pascal |
program qsortdemo;
{ Wirth-style in-place quicksort — verbatim from the book }
…
end. ]
ported: str_join(map(func(x) [str(x)], sort(xs)), " ")
expect("port agrees with the Pascal oracle", ported, original)
- The mode slot selects the fpc DIALECT, not an
exec/eval split (Pascal has no value-echo semantics — stdout is the
interface, so there is no
/eval).[pascal | … ]defaults to Turbo Pascal 7 (fpc -Mtp— the Borland-era book dialect);[pascal/iso | … ]is ISO 7185 standard Pascal (Wirth-era book code,program p(output);headers);[pascal/objfpc | … ]and[pascal/delphi | … ]cover the modern dialects. An unknown mode is rejected without spawning fpc. - Three body shapes. A full
programcompiles verbatim. A headerless fragment ending inend.— declarations plus a main block, the shape book chapters print — gets aprogramheader prepended. Bare statements (writeln(2 + 2)) get a full program/begin/end.skeleton. Aunitbody is rejected with guidance (units compile to libraries, not runnables). Compile errors return as catchable AxiomaErrors carrying fpc's diagnostics, plus a note saying how many lines the auto-wrap offset fpc's line numbers by. - Runtime behavior. The binary runs with stdin at EOF
(interactive
read/readlnbook programs need their inputs replaced with constants), bounded byAXIOMA_PASCAL_TIMEOUTseconds (default 30 — book-code testing is exactly where accidental infinite loops happen). A runtime error (Runtime error 200= division by zero, etc.) comes back as a catchableErrorcarrying the FPC RTL message. - Pascal-aware body reader. Pascal strings are
single-quoted with no backslash escapes — the quote is
escaped by doubling (
'it''s') — sowriteln('C:\TP\BIN')reads correctly;"is not a string delimiter; and{ … }/(* … *)/// …comments may contain unpaired apostrophes ({ don't panic }).array[1..10]bounds anda[i]indexing balance the block's bracket depth naturally. Residual limit: a bare unbalanced]outside any string/comment closes the block early. AXIOMA_PASCALoverrides the compiler binary (unset →fpconPATH; macOS:brew install fpc). Intermediates are compiled in a throwaway dir — nothing lands in your project tree.- Compile cache. A successful compile is kept (keyed
on the wrapped source + dialect mode + compiler identity), so re-running
an identical block skips fpc entirely — ~0.8 s down to ~15 ms — and only
re-RUNS the program. That makes repeated test sweeps and book-pass
reruns cheap. Failures are never cached (an error is always freshly
diagnosed). Disable with
AXIOMA_PASCAL_NOCACHE=1; clear by deleting the cache dir (macOS:~/Library/Caches/axioma/pascal). - VM parity: full.
[pascal | … ]and every/modeform compile toOpLangBlockand run identically under--vm.
Lua specifics.
- The body runs via the system interpreter —
luaonPATH(macOS:brew install lua), overridable withAXIOMA_LUA(e.g.luajitor a pinnedlua5.4). Runs are bounded byAXIOMA_LUA_TIMEOUTseconds (default 30 —while true do endcannot hang the interpreter), and stdin is at EOF, soio.read()returnsnilinstead of blocking. - Eval mode —
[lua/eval | … ]returns the body's value as a typed Axioma object instead of captured stdout:[lua/eval | 2 + 2 ]→4(Integer),2^10→1024(Lua's float power; whole floats collapse to Integer, matching[python/eval | … ]),1.5 * 3→4.5(Float),"hi" .. "!"→"hi!",10 > 3→true,nil→none, a sequence table{1, 2, 3}→[1, 2, 3](Array), any other table → a Dictionary ([lua/eval | {x = 7} ].x→7). Lua has no stdlib JSON, so the runner prepends a small self-contained pure-Lua encoder (the Common Lisp precedent). The body must be a single expression; it is parenthesized, so a multi-value expression adjusts to its first value (Lua's own rule). An unknown refinement ([lua/foo | … ]) is rejected without spawning lua. - Body-capture caveats (Lua-aware reader).
--line comments and--[[ … ]]long comments may contain unpaired quotes (-- don't); long-bracket strings[[ … ]]/[=[ … ]=]are verbatim bodies — a lone]inside one is safe, and their square-bracket delimiters do not count toward the block's[/]depth. Ordinary"…"/'…'strings (backslash escapes) anda[i]indexing behave as expected. Residual limit: a bare unbalanced]outside any string/comment closes the block early. - VM parity: full.
[lua | … ]and[lua/eval | … ]compile toOpLangBlockand run identically under--vm.
C specifics.
[clang | … ]compiles the body with the system C compiler —cconPATH(macOS:xcode-select --install), overridable withAXIOMA_CC(e.g.gcc-14) — and runs the produced binary. The tag isclang, neverc(the one-letter ban; it reads "C language" the waygolangreads "Go language" — any C compiler works, not just LLVM's).- The mode slot selects the C STANDARD, not an
exec/eval split:
[clang/c99 | … ]→cc -std=c99; alsoc89/c90/c11/c17/c23and thegnu89/gnu99/gnu11/gnu17/gnu23variants; no mode = the compiler's default. Unknown modes are rejected without spawning the compiler. - Body shapes: a body that defines
main()compiles verbatim (the shape C books print). Bare statements get a skeleton — standard includes (stdio.h,stdlib.h,string.h,math.h) plusint main(void) { … return 0; }— so[clang | printf("%d\n", 2 + 2); ]just works, and<math.h>functions link (-lmis passed where needed). A fragment that opens with preprocessor directives but defines nomainis rejected with guidance (wrapping#includeinsidemainwould be invalid C). - Compile cache: a successful compile is cached keyed
on (source, mode, compiler identity) — identical blocks skip
ccand only re-run (~2 s cold → ~15 ms warm for a five-block script). Failures are never cached. Disable withAXIOMA_CC_NOCACHE=1; clear by deleting~/Library/Caches/axioma/clang. Runs are bounded byAXIOMA_CC_TIMEOUTseconds (default 30) with stdin at EOF, soscanf-driven book programs need their inputs replaced with constants. - Body-capture caveats (C-aware reader).
'…'character literals (']','\'') and////* … */comments may contain unpaired quotes and brackets — the reader skips them as C does, anda[i]indexing balances the block's[/]depth naturally. Residual limit: a bare unbalanced]outside any string/char/comment closes the block early. - Errors: compile failures return catchable Errors carrying the compiler's diagnostics (plus a line-offset note when the body was auto-wrapped); a nonzero exit status is a catchable runtime Error.
- VM parity: full — verified byte-identical under
--vm.
Haskell specifics.
[haskell | … ](alias[hs | … ]) runs the body via GHC —ghconPATH(install via ghcup), overridable withAXIOMA_GHC(the binary must be ghc-compatible: both the-eand-oinvocation shapes are used).- Hybrid strategy — the family's one two-headed
runner. A body that defines
main(or opens with amoduleheader) is written to a file, compiled, and run, exactly like Pascal and C. Any other body is handed toghc -e, which has GHCi semantics: a showable value prints ([haskell | 1 + 2 ]→"3"), an IO action runs ([haskell | putStrLn "hi" ]→"hi"), and a lone binding binds silently ([haskell | f x = x + 1 ]→""— the[python | x = 5]shape). GHC itself decides showable-vs-IO; the runner never guesses types. - Eval mode —
[haskell/eval | … ]returns the body's value as a typed Axioma object instead of captured stdout:[haskell/eval | 6 * 7 ] + 8→50(Integer),map (*2) [1,2,3]→[2, 4, 6](Array),(1, "a", True)→[1, "a", true],Just 3→3,Nothing→none. Haskell has no stdlib JSON, so the runner splices the body into a module carrying a small self-containedAxEncodetypeclass (numbers,Bool,Char,String, lists, tuples to arity 4,Maybe,()) — the CL/Lua encoder trick, solved statically — plusExtendedDefaultRulesso1 + 2defaults toIntegerthe way GHCi would. A body whose type has no instance is a catchable compile Error; an IO action's error adds a hint pointing back at exec mode. The wrapped module goes through the same compile cache as file mode. - The mode slot otherwise selects the
LANGUAGE EDITION:
[haskell/ghc2021 | … ]→ghc -XGHC2021; alsohaskell98,haskell2010,ghc2024; no mode = the compiler's default edition. Unknown modes are rejected without spawning ghc. - Compile cache (file mode only —
ghc -eleaves no artifact): identical (source, mode, compiler identity) blocks skip GHC and only re-run (~7 s cold → ~15 ms warm). Failures are never cached. Disable withAXIOMA_GHC_NOCACHE=1; clear by deleting~/Library/Caches/axioma/haskell. Runs — and the whole-estep — are bounded byAXIOMA_GHC_TIMEOUTseconds (default 30) with stdin at EOF, sointeract-style programs need their inputs replaced with constants, andsum [1..]cannot hang the interpreter. - Body-capture caveats (Haskell-aware reader).
'is disambiguated the way GHC's own lexer does it: after an identifier character it is an identifier prime (foldl',x''); otherwise it opens a char literal only when one verifiably closes just ahead (']','\''); in every other position it is inert (Template Haskell's'foo).--comments may hold apostrophes and brackets, while a dash run followed by a symbol character stays an operator (-->);{- … -}comments nest. List literals, ranges, and comprehensions inside the body ([x | x <- xs]) balance the block's[/]depth naturally, and a quasiquote[q| … |]balances too. Residual limit: a bare unbalanced]outside any string/char/comment closes the block early. - The block is hermetic — Axioma bindings do not leak into the body (free-variable capture remains a Python-block feature).
- Errors: compile and type errors return catchable Errors carrying GHC's own diagnostics; a nonzero exit status is a catchable runtime Error.
- VM parity: full — verified byte-identical under
--vm.
28.3
[monkey | … ] — a whole language, in-process
Every runner in §28.2 needs something installed. Monkey does not: the
language is implemented inside Axioma (monkey/ — lexer,
Pratt parser, tree-walking evaluator, six builtins), so the block spawns
nothing, has no dependency to miss, and runs in the browser
playground.
[monkey |
let people = ["ann", "bob", "cid"];
let i = 1;
puts(people[i]);
] # "bob\n" — Monkey arrays are 0-based
[monkey/eval | 6 * 7 ] + 8 # 50 — a typed value, composed natively
| Form | Returns |
|---|---|
[monkey | … ] |
everything puts wrote, as a String |
[monkey/eval | … ] |
the program's final value, typed |
Any other mode is rejected before the body runs.
The block is hermetic. Monkey cannot read an Axioma
binding and Axioma cannot read a Monkey one. This is the opposite of the
Python block's scope sharing (§28.1), and deliberately so: Monkey has a
single integer type, its own truthiness where 0 is
true, and 0-based indexing. A shared scope would silently
reinterpret values as they crossed. Values cross at the block's edges
instead, where the conversion is explicit — and where a value that
cannot cross faithfully is refused by name rather than approximated. A
Monkey function cannot come out (call it inside and return its result);
a hash whose 4 and "4" keys would collapse
onto one Axioma dictionary key is rejected rather than losing a
value.
It is Monkey, including the surprising parts.
7 / 2 is 3; an out-of-range index reads as
none; an if with no else is
none-valued; push returns a new array;
"a" == "a" reports
unknown operator: STRING == STRING, because Monkey has
exactly one string operator. There is no comment syntax. Three things
differ from the language as specified, each turning a host crash or an
unwritable program into an error value: string escapes are lexed,
argument count is checked, and recursion is capped (10,000 frames) with
division by zero caught.
- Errors: parse errors (all of them, with
body-relative line numbers), Monkey runtime errors, and a bad mode all
arrive as catchable Axioma
Errors. - VM parity: full — the VM calls the same entry point the tree-walker does, so the two cannot diverge.
[monkey | … ] replaced a --monkey CLI flag
that was never an interpreter: it rewrote source with line-based
regexes, translating only literal array indices, so
people[i] above printed "ann" and exited 0.
Running the language is the only honest way to run the language.
28.4
Cross-language translation — [A -> B | … ] /
[A --> B | … ]
Where [<lang> | body] executes foreign
code, the arrow forms translate code between
languages. Two new operators slot in next to the existing pipe so the
surface stays uniform:
| Form | Semantics | Returns |
|---|---|---|
[L | body] |
execute body in L (existing) | foreign runtime value (String for exec, typed for /eval) |
[A -> B | body] |
translate body from A to B | String of B's source |
[A --> B | body] |
translate, then execute in B | typed value from B's runtime |
[nl -> B | description] |
symbolize natural-language description into B | String of B's source |
[nl --> B | description] |
symbolize, then execute in B | typed value from B's runtime |
Every form reads the body raw via the same bracket-counting reader the execute form uses — multi-line bodies, embedded brackets, and arbitrary indentation work without quoting. Body language is named on the left of the arrow, so the lexer doesn't have to disambiguate.
# Pure execution — body is Python, runs in Python (existing)
[python | print("hi")]
# Pure translation — body is Axioma, get Python source as a String
src: [axioma -> python |
pi: 3.141592653589793
area: func(r) [pi * r * r]
primes: [n | n <- range(2, 50), all([n % k != 0 | k <- range(2, n)])]
]
println(src)
# pi = 3.141592653589793
# def area(r):
# return pi * r * r
# primes = [n for n in range(2, 50) if all(n % k != 0 for k in range(2, n))]
# Translate + execute — body is Axioma, run as Python, typed value back
sum_sq: [axioma --> python | sum([n*n | n <- range(1, 11)])]
# → 385 (Python computed; marshaled to Axioma Integer)
# Reverse — body is Python source, get Axioma equivalent as a String
ax_src: [python -> axioma |
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
]
# ax_src: "fibonacci: func(n) [ if n < 2 then [return n]; fibonacci(n-1) + fibonacci(n-2) ]"
# Reverse + execute — pull Python code into Axioma scope
[python --> axioma | def double(x): return x * 2]
println(double(5)) # → 10
# Cross-language to JavaScript
[axioma -> javascript | nums: [n*n | n <- range(10)]]
# Natural-language source — describe intent, get Axioma code (always LLM)
src: [nl -> axioma | double the value 7]
# src: "double: 7 * 2" (or similar — LLM output is non-deterministic)
# Symbolize + evaluate — describe intent, get the value
v: [nl --> axioma | sum of squares from 1 to 10]
# v: 385 (or [1, 4, …, 100] — depends on how the LLM reads "sum of")
nl source — describe an intent, get code or a
value. The nl (alias english,
natural) source language treats the body as a
natural-language description, not source code. The LLM is
invoked with a symbolize-style prompt and returns idiomatic
target-language code. Always LLM-required (no deterministic path exists
for natural-language input), and the announce-print billing line always
fires. The --> variant Eval's the LLM output in scope,
which is powerful but inherits the LLM's non-determinism — if the model
emits syntactically-foreign tokens (like JavaScript's strict-equality
=== from its training data), the resulting parse error
surfaces with the translated source attached for debuggability.
Operator mnemonic:
- Zero arrows (
|): pure execution in the named language. - One arrow (
->): pure translation — returns a String of target source, no execution, no side effects. - Two arrows (
-->): translate + execute. ForB == "axioma"the translated source is parsed andEval'd in the current environment, so definitions in the body leak into the surrounding scope (this is what makes[python --> axioma | def double(x): …]followed bydouble(5)work). For foreign B, the translated source runs through the FFI in/evalmode and the typed value comes back.
Composable / String-input form: the translate()
builtin. When the source code lives in a String variable rather
than inline (read from a file, pulled from an API, etc.), use the
builtin counterpart:
py_src: read_file("script.py")
ax_src: translate(py_src, "python", "axioma") # (code, source, target)
py: translate([n*n | n <- range(10)]) # defaults: axioma → python
# → "[n * n for n in range(10)]"
Argument order is
translate(code, source_lang, target_lang) — English "from X
to Y" order, matches the legacy LLM-only translate builtin. Defaults are
source="axioma", target="python". When the
first argument is an Axioma expression (an AST node, not a String), the
AST is passed alongside so the deterministic emitter takes the fast
path.
Engine dispatch — deterministic emitter then LLM fallback
The translation engine prefers a deterministic AST emitter when it can:
- Axioma → Python has a built-in visitor that handles the common learner constructs (binding statement, function, lambda, infix arithmetic, comparison, conditional, list literal, list comprehension, call, return, identifiers, literals). Output is byte-for-byte deterministic and runs entirely offline — no LLM, no network, no API key.
- Reverse direction (any foreign source → Axioma) and Axioma → any non-Python target route through the LLM (see provider table below). Output is idiomatic but non-deterministic and requires an API key.
- Unmapped constructs (Axioma → Python forms outside the deterministic subset — relational rules, modal logic, MVL values, etc.) fall back to the LLM automatically.
The deterministic emitter is the reason
[axioma -> python | [n*n | n <- range(10)]] works
without configuring any provider — and why the deterministic forward
direction is exercised in
tests/axioma/translation/test_translate_builtin.ax (35
byte-for-byte assertions).
LLM providers and billing transparency
When the LLM path is taken, every call prints one line to stderr before the network request goes out, naming the provider, model, endpoint, and whether it's a paid API. The line is meant to prevent "surprise on the credit card":
[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 translated source returned to scripts.
Suppress with AXIOMA_TRANSLATE_QUIET=1 once you've
confirmed your provider choice.
Provider auto-selection walks the following priority, picking the first one whose API key is set in the environment:
| Provider | Env var(s) | Default model | Endpoint | Notes |
|---|---|---|---|---|
| OpenRouter | OPENROUTER_API_KEY_AXIOMALANG (project-scoped) or
OPENROUTER_API_KEY |
google/gemini-2.5-flash-lite |
openrouter.ai/api/v1 |
Routes to many backends; project-scoped key takes precedence |
| Anthropic | ANTHROPIC_API_KEY |
claude-3-opus-20240229 |
api.anthropic.com/v1 |
Claude family |
| Gemini | GEMINI_API_KEY |
gemini-2.5-flash |
generativelanguage.googleapis.com/v1beta |
Google direct |
| Grok (xAI) | XAI_API_KEY or GROK_API_KEY |
grok-4 |
api.x.ai/v1 |
Distinct from Groq |
| OpenAI | OPENAI_API_KEY |
gpt-4o-mini |
api.openai.com/v1 |
|
| Groq | GROQ_API_KEY |
llama-3.1-70b-versatile |
api.groq.com/openai/v1 |
Inference vendor, not xAI |
| Ollama | none (local) | llama2 |
localhost:11434 |
Override via OLLAMA_MODEL=… |
OpenRouter is first because it routes to many backend models behind
one billing surface — the recommended default. Override the OpenRouter
model with OPENROUTER_MODEL=anthropic/claude-3.5-sonnet (or
any other OpenRouter slug) without recompiling.
Multi-line bodies and
"""…""" are unnecessary
A common first instinct is to wrap foreign source in a triple-quoted string for the reverse direction:
# DON'T — body is read raw, no string quoting needed:
[axioma <- python | """
def f(x):
return x * 2
"""]
# DO — bracket-counting raw reader handles multi-line directly:
[python -> axioma |
def f(x):
return x * 2
]
The raw reader closes on the matching outer ] and counts
inner brackets correctly, so [1, 2, 3] and nested
comprehensions inside the body work as long as their brackets
balance.
MCP integration
The same translation engine is exposed via the
translate_code tool on the MCP server
(axioma --mcp). Clients call it with code,
source_lang, target_lang and get back the same
engine output — deterministic when applicable, LLM-backed otherwise —
plus the parsed AST as JSON when the source is Axioma. One engine, two
surfaces.
28.5 The
[sql | … ] block — embedded SQL
Where [python | …] calls out to a foreign runtime,
[sql | …] compiles SQL down to native
Axioma. The block is an expression; its value is the result of running
the compiled comprehension/ transaction. There is no foreign process, no
marshaling — SQL is treated as another surface over Axioma's
relational substrate, sitting beside the pipe-form and Prolog-form
comprehensions covered in Chapter 7.
[sql | CREATE TABLE teacher (instructor VARCHAR, student VARCHAR)]
[sql | INSERT INTO teacher VALUES ('Socrates', 'Plato')]
[sql | INSERT INTO teacher VALUES ('Plato', 'Aristotle')]
[sql | SELECT student FROM teacher WHERE instructor = 'Socrates']
# → {"Plato"}
The same data is reachable via Axioma's native idioms:
# Set comprehension
{Y | teacher("Socrates", Y)}
# → {"Plato"}
# Prolog-form
{Y | Y <- teacher("Socrates", Y)}
# → {"Plato"}
All three surfaces produce identical results because they all lower
to the same comprehension over the _relation_ store.
DDL —
CREATE TABLE, DROP TABLE,
TRUNCATE
# CREATE TABLE — declares a new relation
[sql | CREATE TABLE emp (name VARCHAR, dept_id INTEGER, salary INTEGER)]
# Parameterized types: VARCHAR(N), DECIMAL(P, S), etc. The size
# arguments are accepted at parse time for SQL compatibility but
# discarded at emission — Axioma's relations are positionally typed,
# not nominally, so size caps are not enforced.
[sql | CREATE TABLE prices (sku VARCHAR(32), amount DECIMAL(10, 2))]
# IF NOT EXISTS — idempotent CREATE; Axioma's `relation X(...)` is
# already idempotent, so this is an emission no-op accepted for SQL
# surface compatibility.
[sql | CREATE TABLE IF NOT EXISTS emp (name VARCHAR, dept_id INTEGER)]
# DROP TABLE — removes the relation's schema, all stored facts at
# every grounding tier, and parallel metadata in one sweep.
[sql | DROP TABLE emp]
# IF EXISTS — silent no-op if missing (drop_relation is already a
# silent no-op for unknown relations).
[sql | DROP TABLE IF EXISTS doesnt_exist]
# TRUNCATE TABLE — clear the extent, preserve the schema. Faster
# than DELETE without a WHERE because no per-row predicate runs.
# The TABLE keyword is optional (Postgres-style).
[sql | TRUNCATE TABLE emp]
[sql | TRUNCATE emp] # equivalent
DML — INSERT,
UPDATE, DELETE
# INSERT … VALUES
[sql | INSERT INTO emp VALUES ('Alice', 1, 90000)]
# INSERT … SELECT (copy rows from another relation)
[sql | INSERT INTO emp_backup SELECT * FROM emp WHERE dept_id = 1]
# UPDATE with arithmetic RHS
[sql | UPDATE emp SET salary = salary * 1.10 WHERE dept_id = 1]
# DELETE
[sql | DELETE FROM emp WHERE salary < 50000]
All three DML statements wrap the read+write in a transaction so a partial failure rolls back cleanly.
Queries —
SELECT with the full join family
# Single-table SELECT with WHERE
[sql | SELECT name, salary FROM emp WHERE dept_id = 1]
# DISTINCT
[sql | SELECT DISTINCT dept_id FROM emp]
# INNER JOIN
[sql | SELECT emp.name, dept.dname FROM emp
INNER JOIN dept ON emp.dept_id = dept.id]
# CROSS JOIN — cartesian product (every row of emp with every row of dept)
[sql | SELECT emp.name, dept.dname FROM emp CROSS JOIN dept]
# Same meaning as pre-SQL-92 comma-FROM without a linking WHERE:
[sql | SELECT emp.name, dept.dname FROM emp, dept]
# LEFT [OUTER] JOIN — preserves unmatched left rows, NULL-pads right
[sql | SELECT emp.name, dept.dname FROM emp
LEFT JOIN dept ON emp.dept_id = dept.id]
# RIGHT [OUTER] JOIN — preserves unmatched right rows
[sql | SELECT emp.name, dept.dname FROM emp
RIGHT OUTER JOIN dept ON emp.dept_id = dept.id]
# FULL [OUTER] JOIN — preserves both
[sql | SELECT emp.name, dept.dname FROM emp
FULL OUTER JOIN dept ON emp.dept_id = dept.id]
# Comma-FROM with WHERE is an equijoin written as product + filter
[sql | SELECT emp.name, dept.dname FROM emp, dept
WHERE emp.dept_id = dept.id]
# Three-table comma-FROM
[sql | SELECT emp.name, dept.dname, region.rname
FROM emp, dept, region
WHERE emp.dept_id = dept.id AND emp.dept_id = region.id]
Cartesian footgun. CROSS JOIN /
FROM a, b with no linking predicate yields (|A| · |B|)
rows. With two employees and two departments that is four name/dept
pairs, including combinations that are not real assignments. Prefer
JOIN … ON (or WHERE key equality) when you
mean a join.
NULL padding for OUTER joins follows the active refinement
(/b4 default → belnap("neither"), displayed
?ᵇ; /k3 → kleene("unknown")).
Aggregates —
GROUP BY, HAVING,
COUNT/SUM/AVG/MIN/MAX
# Single-column GROUP BY with COUNT
[sql | SELECT dept_id, COUNT(*) FROM emp GROUP BY dept_id]
# SUM with WHERE
[sql | SELECT dept_id, SUM(salary) FROM emp
WHERE salary > 50000 GROUP BY dept_id]
# Multi-column GROUP BY — each distinct (col1, col2, ...) tuple
# gets its own group; result rows flatten to (col1, col2, ..., agg)
[sql | SELECT region, product, SUM(qty) FROM sales
GROUP BY region, product]
# HAVING — filter post-aggregation
[sql | SELECT dept_id, SUM(salary) FROM emp
GROUP BY dept_id HAVING SUM(salary) > 200000]
# Single-column scalar aggregate (no GROUP BY)
[sql | SELECT COUNT(*) FROM emp WHERE dept_id = 1]
# → 3
Phase 5 caveat: single aggregate per SELECT, and HAVING currently requires single-column GROUP BY.
Expressions —
CAST, CASE, LIKE,
BETWEEN, IN, EXISTS
# CAST — type conversion (CAST(expr AS type) and Postgres :: shorthand)
[sql | SELECT CAST(score AS FLOAT) FROM grades WHERE student = 'Alice']
[sql | SELECT score :: FLOAT FROM grades WHERE student = 'Alice']
# Chained postfix casts
[sql | SELECT score :: INT :: TEXT FROM grades]
# Searched CASE — independent predicates per branch
[sql | SELECT name,
CASE WHEN salary > 100000 THEN 'high'
WHEN salary > 50000 THEN 'mid'
ELSE 'low'
END
FROM emp]
# Simple CASE — subject compared by equality
[sql | SELECT name,
CASE grade WHEN 'A' THEN 'excellent'
WHEN 'B' THEN 'good'
ELSE 'fair'
END
FROM grades]
# LIKE — SQL wildcards (% any-sequence, _ single-char)
[sql | SELECT name FROM emp WHERE name LIKE 'A%']
# BETWEEN / NOT BETWEEN — inclusive range
[sql | SELECT name, salary FROM emp WHERE salary BETWEEN 60000 AND 90000]
# IN (literal list)
[sql | SELECT name FROM emp WHERE dept_id IN (1, 2, 3)]
# IN (SELECT …) subquery
[sql | SELECT name FROM emp
WHERE dept_id IN (SELECT id FROM dept WHERE dname = 'Eng')]
# EXISTS — non-empty subquery test
[sql | SELECT name FROM emp e WHERE EXISTS
(SELECT 1 FROM dept d WHERE d.id = e.dept_id)]
Set operations —
UNION, INTERSECT, EXCEPT
# UNION — set union (deduplicates)
[sql | SELECT name FROM emp_jan
UNION
SELECT name FROM emp_feb]
# UNION ALL — bag union (preserves duplicates)
[sql | SELECT name FROM emp_jan
UNION ALL
SELECT name FROM emp_feb]
# INTERSECT — set intersection
[sql | SELECT name FROM emp_jan INTERSECT SELECT name FROM emp_feb]
# EXCEPT — set difference
[sql | SELECT name FROM emp_jan EXCEPT SELECT name FROM emp_feb]
Common Table Expressions —
WITH
# Single CTE
[sql | WITH high_paid(name, salary) AS
(SELECT name, salary FROM emp WHERE salary > 100000)
SELECT name FROM high_paid ORDER BY salary DESC]
# Chained CTEs — each later CTE can reference earlier ones
[sql | WITH
eng(name, salary) AS (SELECT name, salary FROM emp
WHERE dept_id = 1),
high(name, salary) AS (SELECT name, salary FROM eng
WHERE salary > 100000),
greeted(greet) AS (SELECT 'Hi ' + name FROM high)
SELECT greet FROM greeted]
Refinement
modes — /bag, /k3, /strict,
/distinct, /explain
The block accepts SQL-shaping refinements that pre-configure how the compiled comprehension treats duplicates, NULLs, and shape strictness:
| Refinement | Effect |
|---|---|
[sql/bag | …] |
Duplicate-preserving (bag) semantics |
[sql/distinct | …] |
Force DISTINCT projection (default) |
[sql/k3 | …] |
SQL NULL lowers to Kleene K3 unknown |
[sql/strict | …] |
Bag output plus K3 NULL lowering for SELECT |
[sql/explain | …] |
Return the compiled Axioma source as a String — useful for teaching and debugging |
src: [sql/explain | SELECT name FROM emp WHERE dept_id = 1]
println(src)
# {V1 | emp(V1, 1, V3)}
SQL missing values
Default SQL NULL is belnap("neither"); /k3
and /strict SELECT expressions emit om.
Relation reads retain these types, including omitted INSERT columns,
UPDATE values and INSERT-SELECT copies. Write refinements do not select
a different storage representation. IS NULL recognizes
typed Belnap neither, Om and Kleene unknown; quoted strings with similar
spellings are not missing values. none remains
distinct.
[sql | CREATE TABLE optional (reading INTEGER)]
[sql | INSERT INTO optional VALUES (5), (NULL)]
[sql | SELECT reading FROM optional WHERE reading IS NULL] # {?ᵇ}
[sql | SELECT reading FROM optional WHERE reading > 3] # {5}
[sql | SELECT reading FROM optional WHERE NOT (reading > 3)] # {}
Ordered comparisons (<, <=,
>, >=) and arithmetic (+,
-, *, /, % via SQL
MOD) propagate missing information in supported SQL
expressions. BETWEEN uses the same comparisons. A missing
comparison stays unknown under NOT, and does not pass a WHERE filter.
Operands evaluate once; other type errors remain errors. Ordinary Axioma
operators are unchanged.
Equality retains Axioma value equality, including under
/k3: these modes are not a complete ANSI SQL NULL
implementation. NULLIF currently returns none,
and COALESCE/IFNULL still check
none. Nullable aggregate, cast and string-function behavior
is not covered by this repair. See docs/sql-null.md
for boundaries and regression tests.
Tuple relational calculus —
[trc | …]
Codd's tuple relational calculus (TRC) is executable as a language block. The spelling matches the calculus lineage emitter, so SQL, algebra, TRC, and native comprehensions share one meaning:
relation teacher(x, y)
assert teacher("Socrates", "Plato")
# Executable TRC (safe subset: free tuple vars must appear in R(t))
[trc | { t.y | teacher(t) ∧ t.x = 'Socrates' }]
# → {"Plato"}
# Alias
[calculus | { t.y | teacher(t) and t.x = "Socrates" }]
# See the compiled Axioma comprehension
[trc/explain | { t.y | teacher(t) ∧ t.x = 'Socrates' }]
# Translate only
[trc -> axioma | { t.y | teacher(t) ∧ t.x = 'Socrates' }]
Cartesian product vs equijoin in TRC (same lesson as SQL):
relation emp(id, name)
relation dept(id, dname)
# … facts …
# Product — no key link (4 pairs for 2×2 tables)
[trc | { (t.name, u.dname) | emp(t) ∧ dept(u) }]
# Equijoin — product + equality
[trc | { (t.name, u.dname) | emp(t) ∧ dept(u) ∧ t.id = u.id }]
Safety: a free tuple variable that never appears in a positive range
atom R(t) is rejected
(unsafe — not range-restricted). v1 does not lower
∀ or disjunction; prefer free multi-range joins over nested
∃. Design notes:
resources/docs/claude/TRC_EXECUTABLE.md. Tests:
tests/axioma/trc/.
Lineage
surface — [sql -> algebra] /
[sql -> calculus] / [sql -> trc]
For pedagogical traceability, SQL can be rendered as relational algebra, tuple calculus (TRC), or Axioma comprehension source:
[sql -> algebra | SELECT name FROM emp WHERE dept_id = 1]
# → π_{name}(σ_{dept_id=1}(emp))
[sql -> calculus | SELECT y FROM teacher WHERE x = 'Socrates']
# → { t.y | teacher(t) ∧ t.x = 'Socrates' }
[sql -> trc | …] # same string as calculus (TRC spelling)
[sql --> trc | …] # emit TRC then *execute* it via the TRC engine
[sql -> axioma | SELECT …] # compiled set-comprehension source
[sql --> axioma | SELECT …] # compile and run
CROSS JOIN lineage (product vs join):
[sql -> algebra | SELECT emp.name, dept.dname FROM emp CROSS JOIN dept]
# → π_{emp.name,dept.dname}((emp × dept))
[sql -> calculus | SELECT emp.name, dept.dname FROM emp CROSS JOIN dept]
# → { (t.name, u.dname) | emp(t) ∧ dept(u) }
[sql -> algebra | SELECT emp.name, dept.dname FROM emp JOIN dept ON emp.id = dept.id]
# → π_{…}((emp ⨝_{emp.id=dept.id} dept))
[sql -> calculus | SELECT emp.name, dept.dname FROM emp JOIN dept ON emp.id = dept.id]
# → { (t.name, u.dname) | emp(t) ∧ dept(u) ∧ t.id = u.id }
Algebra and pure calculus strings are notational
(->); [sql --> trc | …] and
[sql --> axioma | …] evaluate.
Identifying what's not yet covered
Phase 5 explicitly defers a few large features to follow-on landings:
window functions (OVER,
PARTITION BY, ROW_NUMBER), correlated
subqueries (subqueries that reference outer-query columns),
CREATE TABLE constraints
(PRIMARY KEY, FOREIGN KEY,
NOT NULL, DEFAULT, REFERENCES,
CHECK), ALTER TABLE,
multiple aggregates per SELECT, chained outer
joins, non-equijoin OUTER JOIN predicates, and
HAVING with multi- column GROUP BY. None of them
prevent the working examples above from running.
27.4b Three quietly important language fixes
Three small changes to the core language that came out of the comparison-tooling work and benefit every Axioma program, not just Python interop.
Short-circuit and / or.
Previously both operands were evaluated even when the first determined
the result. The natural guard pattern
if i <= len(a) and a[i] > 0 then …
errored with "index out of bounds" because a[i] ran when
i was out of range. Now and and
or short-circuit Boolean operands the way every other
modern language does: the right side is only evaluated when the left
doesn't already settle the question. The multi-valued logics (Belnap,
Łukasiewicz, etc.) are unaffected — they still need both inputs for
their lattice operations. --vm compiles and /
or to the same gate — a Boolean left operand settles the
answer before the right operand runs — so a guard like the one above
behaves identically in both runtimes, side effects and errors included
(false and (1 div 0 == 0) is false under
--vm as well).
[] in then / else is
an empty array. Previously parsed as an empty block (which
evaluates to none), silently breaking natural recursive
base cases:
to_list: func(t) [
if t == none then [] else [t.value] + to_list(t.tail)
]
Pre-fix this stack-overflowed because the base case returned
none and none + [x] fails type-check (which
the recursion never gets to anyway because the recursion never bottoms
out on a none it keeps trying to concatenate). Same fix
simultaneously enables then [x] + ys and other forms where
the bracketed clause is followed by an infix operator — both now extend
correctly past the closing bracket.
for as alias for foreach.
Python/JS/Java/Rust developers expect for x in xs [body].
Axioma already had foreach x in xs [body] doing exactly
this; for is now a lexer-level alias that produces the same
AST. Both forms are first-class and interchangeable — pick whichever
reads better. Destructuring works under both names:
for [k, v] in pairs [...].
py.eval / py.exec
auto-capture. The [python | …] block form already
auto-injected free Axioma identifiers into the Python scope. The
shim-style py.eval(string) did not — the string shipped
verbatim. Now the two paths agree:
xs: [1, 2, 3, 4, 5]
py.eval("sum(xs)") # 15 — xs is auto-captured
py.exec("print(len(xs))") # 5
For multi-statement bodies under py.eval, the runtime
lifts the prelude through exec + json.dumps
internally so the typed-result contract is preserved.
27.5a Comparison-friendly tools
Three small additions make side-by-side Axioma/Python work feel natural rather than ad-hoc.
elapsed expr
and bench "label" expr — time an operand
No required brackets. [ … ] is only for a statement
sequence, the same job it has after trace /
then. Both keyword forms run in the current
environment (a new name leaks; n: n + 1 of an
outer n write-throughs). Parenthesized
elapsed(expr) times that expression in the same
environment. If its value is a function, elapsed invokes it
as a zero-argument thunk. Both elapsed(fn) and
bench(label, fn) therefore run the function body in a new
function frame, so : inside that thunk does
not update an outer n.
d: elapsed sort([3, 1, 2]) # Duration — the sorted array is discarded
d: elapsed(square(2)) # same wrap: times square(2), not elapsed(4)
r: bench "sort" sort([3, 1, 2]) # {label, elapsed_ms, result}
n: 0
elapsed [ n: n + 1 ]
# n is 1
ax: bench("axioma sort", func() [my_sort(xs)]) # thunk call, still valid
elapsed remains an ordinary identifier when followed by
an infix operator:
elapsed: 3.0
expect("ordinary comparison", elapsed >= 0.0, true)
expect("ordinary subtraction", elapsed - 1, 2.0)
d: elapsed(-1) # explicit timing of a negative operand
Arithmetic, comparisons, pipelines, and other infix continuations take precedence over the timer reading. Use parentheses to time an operand that starts with an operator which could instead continue the variable expression.
elapsed answers how long (Duration).
bench answers how long and what it
returned. Duration composes with
datetime.sleep / datetime.add /
datetime.time_between, not with global sleep
(that takes seconds). os.monotonic() remains the snapshot
for work you cannot wrap (Float seconds since interpreter start).
time() is still the time-of-day constructor, not a
clock.
:compare
(REPL) — typed-value diff with timing
:compare 2 + 3 // 2 + 3
axioma 5 [233µs]
python 5 [368µs]
✓ values agree
:compare [1, 2, 3] // [3, 2, 1]
axioma [1, 2, 3] [17µs]
python [3, 2, 1] [54µs]
✗ values differ
Both sides are evaluated as typed expressions (the
Python side goes through py.eval, so it returns Axioma
typed values, not captured stdout). Equality is structural — arrays and
object-maps compare element-wise; integers and floats coerce freely.
lib/cs/data_structures.ax
— pure-Axioma classics
A small library of canonical data structures so you have something real to compare against rather than building each from scratch:
| Structure | Constructor | Key operations |
|---|---|---|
| LinkedList | list_create() |
list_push, list_pop,
list_size, list_from_array,
list_to_array |
| Binary Search Tree | bst_create() |
bst_insert, bst_contains,
bst_inorder, bst_size,
bst_from_array |
| Stack | stack_create() |
stack_push, stack_pop,
stack_peek, stack_size |
| MinHeap | heap_create() |
heap_push, heap_pop,
heap_peek, heap_size,
heap_from_array, heap_drain_sorted |
Style: functional/persistent for the recursive structures
(LinkedList, BST), array-backed with mutation for the index-heavy ones
(Stack, MinHeap). Each instance is a Dictionary — except
the empty BST, which is none until the first insert makes
it a node; persistent operations take the instance and return an updated
copy.
import "lib/cs/data_structures.ax"
h: heap_from_array([3, 1, 4, 1, 5, 9, 2, 6])
ax_sorted: heap_drain_sorted(h)
py_sorted: [python/eval | sorted([3, 1, 4, 1, 5, 9, 2, 6])]
println("agree?", ax_sorted == py_sorted)
HashMap is intentionally absent — Axioma's native
Dictionary is already a hash table and reads as one
(m.key, m["key"], len(m.keys)).
Wrapping it in a hashmap_* API would just add ceremony.
27.5 Walkthrough: lists and loops, three ways
Same algorithm, three styles. The point is not that any one is best — it's that mixing is cheap, so you can adopt incrementally.
Goal: given a list of numbers, compute the mean and a rough standard deviation, then print the values that are more than one σ above the mean.
Style 1 — pure Axioma
xs: [4, 8, 15, 16, 23, 42]
n: len(xs)
mean: sum(xs) / n
variance: sum([(x - mean) * (x - mean) | x <- xs]) / n
sigma: math.sqrt(float(variance)) # `/` is exact: float() before sqrt
outliers: [x | x <- xs, x > mean + sigma]
println("mean=", mean, "sigma=", sigma, "outliers=", outliers)
math.sqrt is the NATIVE ambient math package (§27.2) —
no import, no foreign runtime. Spelling it python.math.sqrt
would run CPython's instead; the call site is what says which.
Style 2 — inline Python block
xs: [4, 8, 15, 16, 23, 42]
report: [python |
import statistics
m = statistics.mean(xs)
s = statistics.stdev(xs)
outliers = [x for x in xs if x > m + s]
print(f"mean={m} sigma={s} outliers={outliers}")
]
println(report)
The Axioma xs is auto-captured into the Python scope.
The whole Python body runs as one subprocess (or one round-trip in the
persistent worker), and the captured print(...) text comes
back as an Axioma String.
Style 3 — typed-result block
(/eval)
xs: [4, 8, 15, 16, 23, 42]
stats: [python/eval |
{"mean": __import__('statistics').mean(xs),
"sigma": __import__('statistics').stdev(xs)}
]
outliers: [x | x <- xs, x > stats.mean + stats.sigma]
println("from python:", stats, " outliers:", outliers)
Now the Python side returns a typed Dictionary, and the
outliers filter is back in Axioma where it composes with the rest of the
program. This is usually the right balance: borrow Python's library for
the hard part, keep the surrounding logic native.
Comparing two styles in the REPL
:compare math.sqrt(2) // statistics.stdev([1,2,3,4,5])
Both halves run, and the REPL prints them side-by-side with a ✓ when
they match. Useful when porting a Python snippet — write the Axioma
version, paste the original on the right of //, watch them
agree (or not).
Picking the style
| Situation | Style |
|---|---|
| The library exists in Axioma | Pure Axioma |
| You need a Python stdlib function for one expression | Shim (e.g. math.sqrt,
statistics.mean) |
| Multi-line Python you don't want to translate yet | `[python |
| You want a typed value back from Python | `[python/eval |
| You want to see the Python equivalent of your Axioma | `[axioma -> python |
| You want Axioma computed via Python's runtime | `[axioma --> python |
| You're learning from a Python snippet | `[python -> axioma |
| Pull Python code into Axioma scope | `[python --> axioma |
| Code comes from a String variable | translate(src, "python", "axioma") builtin |
| You know what you want but not the Axioma syntax | `[nl -> axioma |
| You want the value but don't care about the code | `[nl --> axioma |
| You're porting and want a safety net | :compare while you write the Axioma version |
Performance notes:
- The persistent worker is on by default
(lazy-spawned on first Python use, so it costs nothing if no
[python | …]block runs). Same script reuses one long-lived subprocess at ~0.5–2ms per call. - Pass
--no-python-workerto disable. Each block then spawns its own freshpython3 -cprocess (~30-50 ms). Use this when you need hard isolation between unrelated blocks or when running in a restricted environment that disallows long-lived subprocesses.
Globals persist across blocks (worker mode)
This is the most important behavior to know about the default. With the worker on:
_setup: [python | x = 41 ]
r: [python/eval | x + 1 ] # → 42 — x carries over
The two blocks share Python's namespace. That matches REPL semantics and is what you want most of the time. But if you paste two unrelated snippets into the same script and they happen to use the same variable names, they'll see each other.
Three options when you need isolation:
py.reset()— clears worker globals between blocks without leaving worker mode:r1: [python | x = "first" ] py.reset() r2: [python | print('x' in dir()) ] # False- Use a unique prefix for variables in self-contained snippets, or wrap the snippet in a function so its locals don't escape.
--no-python-worker— fall back to per-call subprocess for the whole run. Slower, but every block starts from a fresh namespace.
27.6 Narrowing the boundary (worker-mode features)
The persistent Python worker is on by default. Four extra channels
become available that make the boundary feel less like a foreign-
function call and more like a peer (none of these work with
--no-python-worker — the per-call subprocess can't talk
back over stdio):
Unified namespaces
python.eval("math.sqrt(2)") # typed Float (py.… = exact alias)
python.exec("print('hi')") # captured stdout (String)
python.call("math.sqrt", 2) # function-style, typed result
python.import_("numpy") # make numpy available downstream
python.reset() # clear worker globals
julia.exec("println(2+3)") # same shape for julia / rlang / js
julia.eval("6 * 7") # typed Integer — all four expose .eval
The same python module carries the stdlib shim
sub-modules (python.math, python.statistics, …
— §27.2), so "how do I call Python" has one answer whatever the
granularity.
One obvious entry point per dialect, instead of "block exec / block
eval / shim — pick the right one." All four dialects (py,
julia, rlang, js) expose
.exec and .eval (typed values — §28.2);
py additionally exposes .call,
.import_, and .reset (clear globals between
blocks).
Reverse calls — Python invokes Axioma
double: func(x) [x * 2]
format: func(x, y) ["x=" + x + ", y=" + y]
[python | print(axioma.call("double", 21)) ] # 42
[python | print(axioma.call("format", 10, 20)) ] # x=10, y=20
Inside any Python block, axioma.call(name, *args)
resolves name in the surrounding Axioma scope, marshals
args, calls the function, and returns the typed result. Python errors
and Axioma errors propagate across the boundary as
RuntimeError.
Object handles — large values, no deep copy
For values too big to want to marshal whole, the
axioma.* namespace exposes lazy access:
big: [...] # imagine a 1M-element array
[python/eval | axioma.len("big")] # length only, no copy
[python/eval | axioma.at("big", 0)] # single element
[python/eval | axioma.array("big")[100:110]] # slice via proxy
axioma.array(name) returns a Python proxy whose
__len__, __getitem__, and
__iter__ round-trip per access. The underlying Axioma array
is never deep-copied; you pay a per-element fetch cost but avoid the
upfront marshalling time and the memory pressure.
Streaming progress —
axioma.emit
Long-running blocks can report intermediate values:
[python |
import time
for i in range(5):
axioma.emit(f"step {i+1}/5")
time.sleep(1.0)
print("done")
]
Each axioma.emit(value) writes a one-way stream message
that the Axioma side prints live (default formatter prefixes with
[emit]). The block's own return value is unaffected — it's
still the captured stdout (or, for /eval, the typed
expression result). Stream and return are independent channels.
What's still missing
- One-way scope only. Axioma values flow into Python; mutations inside
Python don't propagate back. Use the block's return or
axioma.callto write a setter explicitly. - Reverse calls require worker mode. Per-call subprocess
(
python3 -c) can't talk back over stdio. - Eval mode requires single-expression bodies. Multi-statement bodies
with auto-capture get lifted through exec internally; bare
multi-statement /eval errors with
SyntaxError.
Disambiguation rule: [expr | var <- iterable, …] is
still a list comprehension. The lang-block form is taken only when the
first token is a registered dialect identifier and the next token is
|.
27.2 Ambient
packages and the python.* namespace
Two namespace layers work with no import ceremony, and one prefix rule tells them apart (the namespace-resolution model, July 2026):
- a bare package name is always NATIVE —
math.sqrtis the Go implementation, never a foreign runtime; - a language-prefixed name is explicitly FFI —
python.math.sqrtruns CPython'smath.sqrtthrough the Python FFI. The prefix makes the cost model visible:python.…crosses a process boundary, the bare name does not.
Ambient native packages. The builtin packages
math, datetime, io,
logger, and os are seeded into every session
as modules — Lua-stdlib ergonomics:
math.sqrt(2) # 1.4142135623730951 — native, no import
math.pi # (also e / tau / inf, and the classic PI / TAU)
math.gcd(48, 18) # 6 (factorial is big-integer exact)
datetime.now() # DateTime — same package §4 documents
io.read_file("notes.txt") # the file layer
os.getenv("HOME") # the process layer (see below)
import "builtin:math" as M still works unchanged (it
binds an alias to the same package — use it for renaming), a user
binding (math: 5) replaces the seed like any seeded name,
and bindings() does not list the seeds. The ambient names
resolve under --vm too (the VM's identifier fallback
consults the same registry, so both runtimes agree on what exists). The
browser playground ships math and datetime
whole, and os trimmed to its two clocks —
os.monotonic() and os.clock() are pure
computation over the process's own elapsed time, so a page can answer
them, while the rest of os (environment, process identity,
the shell runner, exit) needs a host and is absent there.
io is absent entirely: the honest answer for a sandboxed
runtime. string and array remain import-only
for now.
Finding the namespaces. A prefix that carries meaning has to be findable without reading the interpreter's source, so the registry is enumerable and every namespace can be asked what it holds:
modules() # every registered namespace, sorted
modules("ambient") # → ["builtin", "datetime", "io", "logger", "math", "os"]
modules("import") # → ["array", "finance", "string"] need an import
modules("ffi") # → ["cas", "js", "julia", "python", "rlang", "ts", "typescript"] language-prefixed
functions(math) # what is callable under that prefix, sorted
functions(builtin) # the registered packages themselves
functions(String) # the TYPE catalog — same verb, other namespace kind
The ambient/import split is the one to read: modules()
lists string, but string.upper(s) does not
work, because string is import-only — and because
string is already a global builtin function (the
conversion), so string.upper(s) reaches the unary dot
fallback x.f ≡ f(x) and evaluates
upper(string). modules("ambient") is the list
of prefixes you can type bare today.
builtin.<package>.<member> —
reaching the import-only packages. The registry itself is a
namespace, so any registered package can be named inline without an
import statement:
builtin.math.sqrt(16) # 4
builtin.string.upper("hi") # "HI" — no import needed
builtin.array.product([2, 3]) # 6
builtin is a module whose members are the packages, so
this is two ordinary member reads — the VM answers identically. It is
seeded, not reserved: builtin: 5 shadows
it like any other seeded name.
A namespace with two readings is refused. Bind a
module of your own over a registered package's name and
string.length means two different functions. Axioma names
both rather than picking one:
ambiguous namespace string — the name is bound to the module imported from
"./string.ax" and there is also a builtin package string
builtin.string.length reaches the builtin package
import the module under a different alias to reach yours
The refusal fires before the member lookup, so a member only
your module has is refused too — resolving it still means resolving
string, and that is what is ambiguous. It applies only to
modules imported from a file; anything placed by a
scheme prefix (builtin:, std:,
ffi:, cas:) is already unambiguous, which is
why the python.* shims — named after the packages they
mirror — are unaffected. The refusal is only workable because
builtin. can never be your module: there is always a
spelling that says which one you meant.
functions works on a module you wrote too, and reports
the names that module's file defines — not the seeds
and builtins that were in scope while it ran:
import "./mystring.ax" as Str # a file defining length and shout
functions(Str) # → ["length", "shout"]
A member miss names the namespace you actually typed, so it is clear which layer answered:
python.math.nosuchfn(1) # member nosuchfn not found ... in module python.math
math.nosuchfn(1) # member nosuchfn not found ... in module math
The os package closes the classic
process-layer gaps (os.date itself lives in
datetime — strftime is
directive-compatible):
| Member | Returns |
|---|---|
os.getenv(name [, default]) |
String, the default, or none |
os.environ() |
Dictionary snapshot of the process env |
os.clock() / os.monotonic() |
Float CPU seconds / wall seconds since start |
os.hostname() / os.platform() /
os.pid() |
host name / "darwin/arm64" / Integer |
os.args() |
Array of args after the script path |
os.tmpdir() |
String |
os.run(cmd [, timeout_secs]) |
{stdout, stderr, code} Dictionary |
os.exit([code]) |
terminates the process |
os.run accepts a Command for direct process execution or
a String for execution through the system shell. A nonzero exit is
data (r.code), not an error; only failure
to start, or the timeout (per-call arg,
AXIOMA_OS_RUN_TIMEOUT, default 60 s), is a catchable Error.
Only the single trailing newline of
stdout/stderr is trimmed.
Command literals and shell blocks
A c"..." literal constructs an immutable
Command. It does not execute. Whitespace separates
arguments; single/double quotes group text. Interpolation uses
${Axioma-expression} outside single-quoted argument text.
Every inserted value stays in its existing argument, including spaces,
quotes, dollar signs, and semicolons. Adjacent literal text joins that
same argument. Arrays are rendered as one argument; there is no
automatic argument splicing.
filename: "quarterly report.txt"
job: c"printf '%s' ${filename}"
job.executable # "printf"
job.args # ["printf", "%s", "quarterly report.txt"]
os.run(job).stdout # "quarterly report.txt"
os.run(job, 2).code # 0; optional timeout in seconds
The command captures interpolated values when constructed and can run
again. .args returns a fresh Array including the executable
at position 1; changing that Array cannot change the command.
c'...' is also supported. Escape the outer quote when using
that quote to group arguments inside a literal. Command argument escapes
are shell-like, not Axioma string escapes: use a single-quoted argument
to preserve \n for a program such as printf.
An empty executable or an argument containing NUL is an Error.
Commands perform no shell expansion: $HOME,
*, and ~ are literal text. Unquoted shell
operators such as | and > are rejected with
a pointer to shell blocks. Existing |> still pipes
Axioma values into functions.
A [shell | ... ] block executes a script immediately. It
uses /bin/sh on Unix and cmd /C on Windows,
just like os.run with a String. The following example uses
Unix shell syntax:
result: [shell |
printf 'pear\napple\n' | sort
]
result.stdout # "apple\npear"
result.stderr # ""
result.code # 0
Shell variables, substitutions, pipelines, and redirection belong to
the shell; Axioma variables are not captured or interpolated in the
block. Quoted brackets, comments, parameter/command/arithmetic
expansions, and here-document bodies are protected while locating the
closing host bracket. Keep a multiline block's closing ] on
its own line and quote literal unmatched brackets in shell words. Shell
blocks accept no /mode refinement.
Shell blocks and both os.run forms share output capture,
exit-status and timeout rules. The browser build can construct Command
values and formatted strings, but cannot execute commands or shell
blocks.
The python.* umbrella (the relocated
shims). The curated Python-stdlib shims — thin bindings that
marshal their arguments to Python literals and round-trip through the
python_interop FFI — live as sub-modules of the ambient
python namespace (py is an exact alias, and
the call surface python.eval / exec /
call / import_ / reset lives on
the same module):
python.math.sqrt(2) # CPython's math.sqrt, explicitly
python.statistics.mean([1,2,3]) # 2
python.json.dumps([1,2,3]) # "[1, 2, 3]"
python.regex.findall("\d+", "a 12 b 345")
python.itertools.combinations([1,2,3,4], 2)
python.collections.Counter("aabbb")
python.eval("2**10") # 1024 — the general escape hatch
Before July 2026 the shims occupied the BARE names
(math.sqrt was a Python subprocess), which blocked
the native math package from the obvious name. The
relocation is loud, not silent: a leftover bare spelling
(statistics.mean(xs)) errors with a static pointer at the
python.* form — the interpreter never probes a foreign
runtime on an error path. The python.math constants
(pi/e/tau/inf) are
seeded from Go's bit-identical IEEE doubles, so startup spawns no Python
(and python.math.inf exists at all — the old JSON
round-trip silently dropped it).
(The random.* shim module was deleted
in July 2026 when the native random /
random_seed / shuffle / sample
family shipped — see §22 "Randomness". Native spellings:
random.randint(a, b) → random(a, b),
random.choice(xs) → sample(xs),
random.seed(n) → random_seed(n).)
To skip the python/py seeding (stricter
scripts, or when Python isn't on PATH — the native ambient
packages stay either way):
axioma --no-shims script.ax
27.3 REPL affordances
:hints on # surface translation tips inline as you type
:hints status # show on/off + catalog size
:hints list # print the full hint catalog
:hint range # one-shot lookup
:compare sum([1,2,3]) // sum([1,2,3])
# run the Axioma side and the Python side, compare
:compare is the side-by-side tool: type an Axioma
expression on the left of // and a Python expression on the
right; both run against the live REPL environment and a ✓/✗ flag tells
you whether they produced identical printed values.
27.4 When to use what
| Use | Tool |
|---|---|
| Quick port of a Python snippet you already trust | `[python |
| Calling a single native helper inline | math.sqrt(x) (ambient package) |
| Calling a Python-stdlib helper explicitly | python.statistics.mean(xs) |
| Tutorial / onboarding ("look how similar this is") | :compare |
| Catching pasted Python in a fresh REPL | :hints on |
The tests under tests/axioma/lang_blocks/ exercise both
the block form and the shim catalog.
29. Errors as First-Class Values
Errors in Axioma are catchable, inspectable values,
not just halts — a value model with no stack-unwinding exception.
Failure stays distinct from error: an empty query
result or none is not an error; only a genuine fault
(division by zero, undefined word, type mismatch, …) produces an
Error value.
Four failure policies, one expression
The same expression can be wrapped four ways, depending on what you want when it faults:
| Form | On success | On error | Reach for it when… |
|---|---|---|---|
EXPR |
value | halts / propagates | errors should propagate |
try EXPR |
value | the Error value (inspectable) | you want to inspect or classify it |
EXPR otherwise D |
value | D |
you have a specific fallback |
attempt EXPR |
value | none |
you just want "nothing" on failure |
trybinds tightly, like a unary operator: it grabs a single primary — a parenthesized group, a call, or member access — so a trailing operator applies to the caught result. Thustry(risky()) is Error≡(try(risky())) is Error→true, matching the bind-it-first idiome: try(risky()); e is Error. To make a whole binary computation optional, parenthesize it:try (a / b).attemptand theotherwiseoperator still bind their operand greedily to the end of the expression — parenthesize to scope those tighter.
try — catch to a value
e: try(10 / 0) # caught — does NOT halt; e IS the error value
error?(e) # → true
type(e) # → "Error" (e is Error → true — seeded primitive Concept)
e.message # → "division by zero"
e.hint # → recovery hint (also .kind / .line / .column / .source / .file)
try(2 + 3) # → 5 (success passes through untouched)
End-form try /
catch / finally / end
Prefix try (expr) is one expression. The
end-form is a statement sequence — newlines, no
brackets — last value wins, same as if / while
/ function … end. It is still a value: the
fault is captured, not unwound. try and catch
are reserved ($try / $catch to bind the name;
[catch e] stays a word list).
y: try
(-3)!
catch e is DivByZero
0
catch e is IndexError
-1
catch e
42
finally
closed: true
end
# y is 42; closed is true. (-3)! is not DivByZero, so the catch-all runs.
r: try
1 / 0
finally
closed: true
end
# r is a captured Error; cleanup still ran.
Do not write try [ 1 / 0 ] and expect a
captured zero — prefix try plus [ is an
array. Multi-statement capture is this end-form. Typed
catch e is Kind arms are first-match; an untyped
catch / catch e must be last. Unmatched
typed-only arms leave the captured Error as the form's value.
finally is this cleanup clause. Aristotle's final cause
is the four-cause infix P teleologically Q (with
materially / formally /
efficiently). Acorn finally OakTree is a
SyntaxError with that hint.
Constructing & re-raising
b: error("boom", "fix it") # build an inert error VALUE (does not stop)
b.message # → "boom" ; b.hint → "fix it"
raise("msg") # the stop — live Error, no try
try(raise(b)) # re-arm / test ceremony: try catches it back
Stopping with a message
raise("msg") stops that evaluation. A well-typed
error("msg") does not — it builds a bindable Error value.
Two uses of otherwise must not be mixed:
| Form | Reading | If the body is raise("neg") |
|---|---|---|
func f(n) otherwise [ raise("neg") ] |
last-guard (when true) |
stops |
raise("neg") otherwise "" |
infix catch (LEFT otherwise RIGHT) |
swallowed — result is "", script exit
0 |
A clause that ends with , otherwise is
last-guard. Infix expr otherwise fallback is the railway
below and prevents a halt. Do not write the infix form
if you want to stop.
func printdots(n) when n > 0 [ "." + printdots(n - 1) ]
func printdots(n) when n == 0 [ "" ]
func printdots(n) otherwise [ raise("printdots: negative input") ]
In a script, uncaught raise prints the
diagnostic and exits 1. In the REPL it prints and the
next prompt appears; that line still short-circuited.
otherwise
/ or else — the fallback railway
LEFT otherwise RIGHT (also spelled
LEFT or else RIGHT) returns LEFT's value, or —
if LEFT faults — falls back to RIGHT. It is
itself a catch point, so no try wrapper is needed.
RIGHT is evaluated lazily (only on
failure), and the operator is the loosest real operator, so both sides
form fully before it combines them.
parse_int("xx") otherwise 0 # → 0 (catches the parse fault directly)
(10 / 0) otherwise 99 # → 99
lookup(k) or else "not found" # two-word spelling, identical semantics
a() otherwise b() otherwise c() # left-assoc chain: first success wins
otherwise is a soft keyword —
otherwise: 5 is still a valid binding; it reads as the
operator only at the infix slot between two same-line expressions.
After a function head the same word is sugar for a
when true catch-all (Miranda's last-guard spelling). The
commit is the body delimiter, so the two positions cannot collide:
func sign(n) when n > 0 [1]
func sign(n) otherwise [0] # ≡ when true; first match still wins
sign(n) otherwise = 0 # equation form of the same desugar
sign(n) = 0, otherwise # Miranda order — same last-guard
risky() otherwise 0 # still the infix: fallback if risky() errors
source(sign) prints when true.
--vm refuses the clausal form, same as
when.
attempt — swallow to
none
n: attempt parse_int(user_input) # an Integer, or `none` if it didn't parse
(attempt (1 / 0)) == none # → true
(attempt (1 / 0)) == om # → false (the dual-null distinction is load-bearing)
A failed computation has no result
(none — Frege's "no Bedeutung"), not an
undetermined one (om — SETL's Ω). Use
attempt for the none-result form and otherwise
for a non-none default; they are alternatives, not
partners.
?? / ??? —
the absence railway
otherwise answers a fault. The
coalescing pair answers an absence — and because Axioma
has two bottoms (§4), it comes in two widths. attempt is a
natural producer for them: it turns a failure into none,
and ?? is what reads that none back.
none ?? 5 # → 5 `a ?? b` falls back iff `a` is `none`
om ?? 5 # → Ω `om` is a value — it passes through untouched
42 ?? 5 # → 42
none ??? 5 # → 5 `a ??? b` falls back on EITHER bottom
om ??? 5 # → 5
7 ??? 5 # → 7
The extra ? is the entire difference: it widens the
trigger from none alone to none
or om. That distinction is worth an
operator because the two bottoms claim different things. ??
says "default it when the value is missing, but leave an
undetermined value undetermined" — Ω survives the fallback instead
of being quietly papered over with a default. ??? is the
blunter "any bottom at all gets replaced."
Absence is not falsehood. These coalesce on
none, not on falsiness, so every falsy-but-present value
flows through unchanged — the same policy if uses:
0 ?? 9 # → 0
"" ?? "x" # → ""
false ?? true # → false
{} ?? 1 # → {} ∅ is a set, not an absence
That is the sharp line against orelse (§6), which reads
truthiness and answers with a Boolean:
0 orelse false is true, while
0 ?? false is 0. One pair answers which
truth, the other which value, so neither has to guess its
job.
An Error is a value, so it passes
through. These are not error handlers. A fault raised on the
left still propagates, and a caught Error is present — it is
not an absence:
e: try(1 / 0)
(e ?? "fb") is Error # → true a caught Error is present, so it flows through
(1 / 0) otherwise "fb" # → "fb" `otherwise` is the arm for faults
Chaining and short-circuit. Left-associative, first present value wins, and the right side is evaluated only when the fallback actually fires:
none ?? none ?? 7 # → 7
1 ?? 2 ?? 3 # → 1
om ?? none ??? 99 # → 99 (om ?? none) ??? 99
5 ?? (1 / 0) # → 5 the right side never runs, so nothing faults
Precedence. Both sit with otherwise at
the loosest real operator tier — looser than
implies, but tighter than the pipes:
none ?? 3 + 4 # → 7 the `+` forms first
5 ?? true implies false # → 5 the `implies` forms first, then 5 wins
3 ?? 5 |> double # → 6 pipes are looser: (3 ?? 5) |> double
The fallback family at a glance
Five forms, one question each — what do I do when there is no good value here?
| Form | Fires on | Passes through | Yields |
|---|---|---|---|
L otherwise R |
a fault in L |
none, om, any value |
R |
L ?? R |
none |
om, Error,
false/0/""/[]/{} |
R |
L ??? R |
none or om |
Error, every falsy-but-present value |
R |
o?.field |
a none/om receiver |
a present object | that bottom, unchanged |
| the error-propagating pipe (§6) | Error, none, or om |
any real value | the failure, unpiped |
The last two propagate the bottom rather than replacing it, which is why they compose with the first three instead of competing:
none?.field # → none absence propagates instead of erroring
om?.field # → Ω the SETL Ω-propagation law: unknown.x = unknown
cfg?.timeout ?? 30 # → 30 safe navigation hands the `none` to `??`
none |?> double # → none the pipe propagates; `??` would replace
--vmboundary.??,???and?.are all evaluator-only, and each refuses at compile time rather than guessing —unknown operator ??for the coalescing pair,safe navigation ?. is evaluator-only (run without --vm)for the guard. That is the same documented boundarytry/otherwise/attemptalready carry: a refusal, never a wrong answer.
?. — safe navigation
Reading a member off a bottom is a fault:
none has no name, and saying so is the right
answer rather than inventing one. ?. is the opt-in that
turns that fault into propagation — the receiver comes back unchanged
instead of erroring:
none.name # → ERROR plain `.` on a bottom is a genuine fault
none?.name # → none the guard propagates the absence instead
om?.name # → Ω the SETL Ω-propagation law: unknown.x = unknown
Note which bottom comes back: ?. returns the
receiver you gave it, so none stays
none and om stays Ω. The guard
reports why there is no value rather than flattening both
answers into one.
On a present receiver, ?. is exactly
. — same lookup, same result, and it inherits the
whole dot surface including the unary-dot fallback (§5):
p: {name: "Ada", age: 36}
p?.name # → "Ada"
p?.age # → 36
p?.name?.length # → 3 chains fine when every hop is present
[3, 1, 2]?.sum # → 6 `xs.sum ≡ sum(xs)` still applies
A missing key was never a fault, so the guard is not
what rescues you there — both spellings already answer
none, and a key that holds none is
indistinguishable from an absent one by design:
p?.missing # → none
p.missing # → none same answer; `.` needed no guard here
{inner: none}?.inner # → none
The guard is per hop (as in Kotlin), not a mode the
rest of the chain inherits. This is the one place ?. bites,
and it bites usefully — the second hop below is a plain .
applied to the none the first hop just produced:
n: none
n?.a?.b # → none every hop guarded
n?.a.b # → ERROR the `.b` hop faults on the propagated none
It guards reads, not writes. A property
assignment through ?. still requires a real
receiver; there is no silent no-op:
p?.name: "Bob" # fine — p is present, so this writes through
none?.name: "x" # → ERROR: property assignment only supported on
# concrete objects, concepts, and hashes,
# got Null
That asymmetry is deliberate. A read that finds nothing has an honest
answer (none); a write that lands nowhere has none — it
would look like it stored something. Silently discarding it is the
wrong-answer-at-exit-0 class of bug.
Where it sits in the family. ?.
propagates a bottom; ?? / ???
replace one. That is why they compose instead of competing —
the guard gets you past the hop, the coalescing operator supplies the
default:
cfg: dict()
cfg?.timeout ?? 30 # → 30 guard yields none, `??` defaults it
om?.field ??? 7 # → 7 `???` catches the Ω the guard propagated
p?.name ?? "fallback" # → "Ada" a present value is never coalesced
?. is evaluator-only on the same terms
as ?? / ??? — see the boundary note above.
Deep recursion is a catchable error
Unbounded or very deep recursion returns a clean,
catchable Error instead of crashing the
host with a stack overflow. The guard bounds Eval
re-entrancy depth, so it covers every body shape:
spin: func(n) [if n <= 0 then 0 else spin(n - 1)]
e: try(spin(100000)) # caught — interpreter stays usable afterward
e is Error # → true ("recursion limit exceeded")
recursion_limit() # → the live ceiling (50000 native; 350 under wasm)
A non-tail runaway names the looping call and collapses repeats, instead of dumping one line per frame:
fac(n) = n * fac(n - 1)
e: try (fac(5))
e.stack # → ["fac(n) (repeats … times)"]
e.hint # → "this call never reaches a base case"
e.message still names the Eval-depth ceiling.
Tail-recursive loops do not grow a stack — they hit the tail-repeat
refusal (the call never changes its arguments) instead.
Deep nesting with no user function keeps the one-liner and an empty
e.stack.
recursion_limit() reports the current ceiling. The
default is target-specific — the browser/playground stack is far tighter
than a native goroutine stack — and --vm refuses at its
capped frame stack with the same collapsed shape.
Error kinds as sub-Concepts
A caught error classifies into a kind, so you can
branch on why it failed. These built-in kinds are seeded as
direct sub-Concepts of Error:
| Concept | Classification |
|---|---|
DivByZero |
… by zero (division / modulo / divmod / quotient /
remainder) |
TypeError |
type mismatch … / unknown operator … |
NameError |
Undefined word … /
identifier not found |
IndexError |
index out of bounds / out of range |
ArityError |
wrong number of arguments |
CallError |
not callable / not a function |
IncompleteReasoningError |
Explicit tag: relation derivation did not complete because of a resource limit or unsupported recursive goal cycle |
e: try(10 / 0)
e is DivByZero # → true
e is Error # → true (the generic Error concept still matches any error)
e.kind # → "DivByZero" (agrees with the `is` test)
if e is NameError then println("typo in a name")
else if e is TypeError then println("incompatible types")
else println(e.message)
# stamp a kind authoritatively on a user error (survives raise + re-catch):
u: error("bad input", "expected a number", "TypeError")
u is TypeError # → true
An explicit kind tag takes precedence. Otherwise, the kind is
derived on demand from the error's message by a central
matcher, so existing error sites need no per-site tagging. Unrecognized,
untagged messages classify as generic Error only.
Incomplete derivations are tagged explicitly, not inferred from message
text.
Use catch e is IncompleteReasoningError to handle
incomplete relation queries specifically, or
catch e is Error to handle any error. Both inspect the same
first-class error value; the specific concept does not introduce a
separate runtime representation. An incomplete result does not prove
absence.
VM parity:
try/otherwise/attemptare evaluator-only — under--vmthey report a cleancompilation not implemented. The value-level builtinserror()/raise/error?do work under--vm, as does classification of explicitly tagged values withe is IncompleteReasoningErrorande is Error. Error field access, including.kind, remains evaluator-only. Porting the whole errors-as-values floor to the VM is a single later phase.
30. The Cognitive Kernel
After procedural, object-oriented, functional, and logical, Axioma
adds a cognitive layer whose defining primitive is
understand. Computing as the mind does — the mind,
in one word, models; the knowledge base is
that world-model, and to understand(X) is to model X into
the model of everything. The kernel adds abduction —
Peirce's third inference mode — so deduction (strict Horn
<==), induction (defeasible <~~), and
abduction (abduce) all finally have a home.
| Builtin | Role | Returns |
|---|---|---|
abduce("rel", a…) |
inference to an explanation (Peirce) | Array of
(explanation, "strict"/"defeasible") |
examine(Concept) / examine("rel", a…) |
the gate (System 2): meaning · warrant · contract · suspicion | Dictionary verdict |
understand(…) |
the engine: represent → abduce + predict → examine → graded verdict | Dictionary |
relation bird(x)
relation flies(x)
flies(X) <~~ bird(X)
assert bird("tweety")
abduce("flies", "tweety")
# → [("bird("tweety")", "defeasible")] the rule body whose head derives it
concept Phlogiston { formed_by: "stipulation" }
examine(Phlogiston)
# → {meaningful: false, pseudo: true, contract: ?ᵇ,
# verdict: "pseudo-concept — an empty word (no boundary, examples, or instances)", ...}
understand("flies", "tweety")
# → {represents, examination, explanations, predicts, coherent, verdict}
All three builtins are shadowable (a user binding of
the same name wins). Each also has a natural-language
surface that self-displays, like why:
understand Phlogiston # → println(understand(Phlogiston)) (TitleCase-concept arg)
examine "gravitates" # string arg → fact mode
abduce flies("tweety") # fact-call form — the sibling of `why <conclusion>`
The NL prefixes are soft: they fire only on a literal /
TitleCase-concept argument (or, for abduce, a fact-call);
bindings (understand: …) and ordinary calls
(understand(x)) fall through untouched.
Rule heads auto-register as iterable relations.
H(X) :- BmakesHiterable (for y in H), queryable, and introspectable (@H == Relation) with no separaterelation Hdeclaration — you can iterate whatever you can derive.
[ model | … ] —
the advisory lifecycle lens
The authoring counterpart to the kernel.
[ model | body ] runs body as native Axioma in
the current environment and prints an advisory
panel placing your code on the epistemic lifecycle of a knowledge model
— represent → ground → infer → prove → assess-truth →
apply — naming the machinery you have not reached for yet. It
is advisory, not gating: your code runs exactly as
written, and the block returns the body's last expression.
result: [ model |
relation mortal(x)
axiom mortal("socrates") # represent + ground (as an axiom)
human(X) :- mortal(X) # infer (strict backward rule)
{X | X <- human(X)} # query → {"socrates"}
]
# panel → stderr:
# ┌─ model · epistemic lifecycle ─────────────────────────────────────
# │ ✓ represented
# │ ✓ grounded
# │ ✓ inferred
# │ ✗ proved → why <conclusion> · proof(rel, args…)
# │ ✗ truth-valued → set_truth(rel, args…, "both") · truth(rel, args…)
# │ ✗ applied → check / examine(Concept) (→ B4) · understand(…) runs the whole arc
# └─ 3/6 phases present · advisory only — your code runs exactly as written.
[ model/report | … ]runs the body but returns the classification as a dot-accessibleDictionary({represented, grounded, inferred, proved, truth_valued, applied, present, total, value}) instead of printing — so code can branch on the verdict (if rep.grounded then …).- Suppress the panel with
AXIOMA_MODEL_QUIET=1. The only accepted refinement is/report. Evaluator-only, like every language block.
31. Proof Assistant
Axioma hosts a small, auditable natural-deduction proof
checker as an importable library — lib/proof. You
write a proof as a list of numbered steps, hand it to
certify against a goal, and get back a sealed
CheckedTheorem: a value that exists only
because the proof actually checks. A bad proof never crashes — it comes
back as a rejection that says why. It is the classical first-order
calculus with equality (∧ ∨ → ¬ ↔︎ ∀ ∃ =, classical RAA
/ excluded middle), and every certification is re-validated by a
second, independent checker written in Go whose theorem
value has unexported fields — so no Axioma literal can forge one.
import "proof"
atomA: kpred("A", [])
t: certify([ assume(atomA), impI(1, 1) ], kimp(atomA, atomA), [])
proved(t) # → true
showTheorem(t) # → "⊢ (A → A)"
import "proof" resolves the bundled library from
anywhere — no AXIOMA_PATH, no lib/ checkout,
and identically in the browser playground (the library is compiled into
the binary). import "proof.Core" and
import "lib/proof/Core.ax" name the same module; in a
source checkout an on-disk lib/proof/Core.ax overrides the
bundled copy so development edits are live.
A proof has three parts:
- Formulas are data, built with
k-prefixed constructors —kand/kor/knot/kimp/kiff/kall/kex/kpred/kv/keq/kin, and the valuekFalse(⊥). The prefix is required:and/or/not/forall/in/eqare Axioma keywords, so the object-language connectives can't reuse them. - A proof is a list of steps, each citing earlier steps by their 1-based line number — a Fitch/Lemmon derivation, written as data.
certify(steps, goal, catalog)is the trust boundary. It runs the proof, checks the last line equalsgoal, collects undischarged assumptions as Γ, and returnsΓ ⊢ goalsealed — or{tag: "rejected", reason}.
The sealed theorem owns private formula trees for its conclusion and
hypotheses. Mutating the original proof, goal, catalog, or nested
formula objects after certification cannot change what was certified.
Accessors proof_conclusion and proof_hyps (and
library conclusion / hyps) return fresh,
detached representations at every level, including predicate argument
arrays. Those returned values remain ordinary mutable data; editing them
does not edit the theorem.
Walk a proof of (A ∧ B) → A:
atomA: kpred("A", [])
atomB: kpred("B", [])
t: certify([
assume(kand(atomA, atomB)), # 1. A ∧ B (open assumption)
andEL(1), # 2. A (∧-elim left, from line 1)
impI(1, 2) # 3. (A ∧ B) → A (discharge line 1)
], kimp(kand(atomA, atomB), atomA), [])
proved(t) # → true
len(hyps(t)) # → 0 (CLOSED — the assumption was discharged)
showTheorem(t) # → "⊢ ((A ∧ B) → A)"
The step builders cover the whole calculus: assume /
byAxiom / refl; mp /
impI; andI / andEL /
andER; orIL / orIR /
orE; notE / notI /
raa / falseE; ui /
exI / allI (with the eigenvariable proviso);
and eqSubst / iffI / iffEL /
iffER. Inspect a result with proved,
conclusion, hyps, whyRejected,
and showTheorem.
A proof that leaves an assumption undischarged certifies as a
conditional theorem with that assumption in Γ
(A ⊢ A, not a dishonest ⊢ A). A non-proof
returns a legible reason, never a crash:
bad: certify([ assume(keq(kv("a"), kv("b"))) ], keq(kv("a"), kv("c")), [])
proved(bad) # → false
whyRejected(bad) # → "concludes a = b, not the goal a = c"
And proved recognises a real theorem by its Go type, so
a hand-written look-alike hash is rejected — certify is the
only thing that mints one.
A companion module, lib/proof/lemmas.ax, proves eleven
canonical theorems once at import and exports them as ready-made sealed
values (identity, ∧/∨ commutativity, excluded middle, double-negation
elimination, K, hypothetical syllogism, contraposition, =
reflexivity/symmetry/transitivity) plus a provenLemmas
catalog you can cite as axioms (each is a proven closed theorem = a
derived rule):
import "proof"
import "proof.lemmas"
showTheorem(lemDne) # → "⊢ (¬¬A → A)"
showTheorem(lemContra) # → "⊢ ((A → B) → (¬B → ¬A))"
Scope. The kernel proves concrete instances
over named atoms/terms (not schemata), is classical, and its trusted
base is the whole Go interpreter — ideal for pedagogy, exploration, and
integration with the grounding ladder, but not a high-assurance
substitute for Coq/Lean. The mitigation (export a proof and re-check it
with an independent verifier) is built. Full guide and reference:
lib/proof/README.md. Worked tests: tests/axioma/proof/test_lib_proof_v1.ax,
test_lib_proof_lemmas_v1.ax.
32. Executable
Logic Engines (the logic namespace)
Axioma's logic surface is a portfolio of
fragment-engines behind one namespace,
[logic/<mode> | …]. Each engine runs a different
fragment of logic, and — this is the part most tools skip —
every answer reports the decidability regime it came
from, so you always know whether a result is a genuine
decision, a bounded search, a sound-but-incomplete
heuristic, or a principled refusal. There is no single
"decide any logic" engine, because for full predicate logic there
provably cannot be one; the namespace owns the pieces and routes between
them.
The mode table below is the public decidability map: every run
reports its regime in the returned SolveResult.
32.1 Held value vs. run
[logic | φ] # the held-formula VALUE (parse-only, like hold(φ)); @[logic | φ] → "AST"
[logic/<mode> | φ] # RUN φ through an engine → a SolveResult
A run returns a SolveResult read with the possessive
accessors:
res: [logic/smt | x + 0 == x]
res's result # the verdict (a Boolean, a Set of answers, …)
res's regime # the decidability regime this answer came from
res's grounding # axiom / theorem / conjecture / datum
res's justification # a human-readable explanation
Landmine: a single-letter result variable named
rorbcollides with ther"…"/b"…"raw-string/bytes lexer prefixes when followed by's. Use a multi-letter name (res,ans, …).
32.2 The engines
| Mode | Fragment it runs | Regime |
|---|---|---|
[logic/sat | C] |
ALC description-logic satisfiability (⊓ ⊔ ¬ ∃ ∀) | decidable |
[logic/asp | P] |
answer-set programming (disjunction, NAF, choice, aggregates) | decidable (grounded) |
[logic/sld | φ] |
relational / Datalog / Prolog-style resolution | decidable (Datalog) / semidecidable (general SLD) |
[logic/holds | φ] |
does a closed formula hold in the fact store? | decidable |
[logic/smt | φ] |
quantifier-free equality + linear arithmetic + arrays + bitvectors + multi-sort (z3) | decidable |
[logic/valid | φ] |
QF-SMT validity (φ valid iff
¬φ unsat) |
decidable |
[logic/prove | φ] |
quantified SMT via z3 E-matching (sound-but-incomplete) | heuristic |
[logic/chase | P] |
the chase / Datalog± — existential rules, materializes the universal model | decidable (weakly acyclic) / bounded(N) |
[logic/answer | P ? q] |
certain answers to a conjunctive query over the (possibly infinite) chase | decidable / bounded(N) / refused |
[logic/model | Γ] |
positive finite-model finder; decides the EPR ∃*∀* class | decidable (EPR) / bounded(N) |
[logic/auto | φ] |
classify the goal and route it to the fitting engine | inherited |
32.3 SMT — =
and arithmetic finally run
[logic/smt | x + 0 == x] # → true (the algebra headline)
[logic/smt | x == y and x != y] # → false (unsatisfiable)
[logic/valid | (a == b) implies (f(a) == f(b))] # → true (EUF congruence is valid)
[logic/smt | x / 2 == 3] # → true (lifts to the reals, x = 6.0)
[logic/smt | (x band y) == x and (x bor y) == y] # → true (bitvectors, QF_BV)
[logic/valid | (bv(x, 8) + 1) > bv(x, 8)] # → false (8-bit overflow wraps)
[logic/smt | favorite(sort(a,"Person")) == sort(red,"Color")] # multi-sort EUF + Nelson–Oppen
The fragment is chosen automatically and enforced —
a nonlinear x*y, a quantifier, or a cross-sort
== is rejected with a clean error, never a wrong verdict.
Quantifiers belong to [logic/prove] (below).
32.4 The chase and certain-answer querying
[logic/chase] runs existential rules (a head variable
not in the body invents a fresh labeled null) and returns the
universal model; [logic/answer] answers a query over it —
including the decided NO over an infinite chase that
materialization can never give:
ans: [logic/answer |
person(socrates).
parent(X, Y) :- person(X). # every person has a parent (existential Y)
person(Y) :- parent(X, Y). # …who is a person → an INFINITE chase
?
goal :- person(P), parent(P, Q) # "is there a person who has a parent?"
]
ans's result # → true
ans's grounding # → "theorem" (complete — decided by query rewriting, no model built)
A rule set that is neither weakly acyclic, guarded, nor sticky has
undecidable query entailment, so
[logic/answer] refuses with the theorem
(Beeri–Vardi) and points you at the bounded [logic/chase]
materialization.
32.5 The quantified prover — honest about its limits
Quantified first-order logic with uninterpreted functions is
undecidable, so [logic/prove] is sound but
incomplete — its regime is always heuristic:
[logic/prove | (forall x, y. f(x) == f(y) implies x == y)
implies (f(a) == f(b) implies a == b)] # → true (z3 proves it)
[logic/prove | (forall x. f(x) == f(x)) implies (forall y, z. f(y) == f(z))] # → false
The crucial honesty rule: when z3 cannot decide a goal it returns
om (undetermined), never
false — absence of a proof is not a refutation. A
decided verdict is grounded conjecture, not
theorem; for a checked, theorem-grade certificate use the
Proof Assistant
(lib/proof).
32.6 Regimes — what an answer is worth
| Regime | Meaning |
|---|---|
decidable |
a true decision procedure ran to completion — the answer is final |
semidecidable |
a sound search — a positive answer is final; "not found" is not "false" |
bounded(N) |
a finite search up to size/depth N — "none up to N", not "none" |
heuristic |
sound but incomplete — a positive answer is trustworthy, absence is inconclusive |
refused(…) |
the request is provably impossible — the engine names the theorem and offers the legitimate adjacent move |
This is the operational form of Axioma is aware of its own limits: it never silently attempts the impossible (deciding arbitrary FOL validity, finding a model of any formula, deciding finite validity) — it names the wall and offers the move that is actually computable.
33. Algebraic Data Types
ML/Haskell-style sum types — a closed set of tagged
alternatives, each carrying its own positional fields. This is a second,
complementary route to variant data alongside the Concept system's
extends + partition; see the trade-offs at the
end of this section.
Declaring a sum type —
data
data Shape = Circle(Float) | Rect(Float, Float) | Dot
data Name = Ctor1(field, ...) | Ctor2(...) | Nullary
declares Name as a Concept whose instances are one of the
listed constructors. An n-ary constructor is a callable
that builds a value; a nullary constructor (Dot) is a bare
singleton — no call needed:
c: Circle(2.0) # calls the constructor
d: Dot # bare singleton, no parens
type(c) # → "Shape"
@c # → "Shape" (same as type())
c is Shape # → true
tag(c) # → "Circle" (the constructor name)
Circle(2.0) == Circle(2.0) # → true (structural equality — same tag + fields)
Circle(2.0) == Dot # → false
Ordering is structural (Haskell
deriving Ord): by constructor declaration
order first — for Shape that is
Circle < Rect < Dot — then
by fields left to right. The comparison operators and sort
/ min_by / max_by / sort_by all
agree:
Circle(1.0) < Circle(2.0) # → true (same tag, compare the field)
Circle(9.0) < Dot # → true (Circle declared before Dot)
Rect(1.0, 2.0) < Rect(1.0, 3.0) # → true (first field ties, second decides)
sort([Dot, Rect(1.0, 1.0), Circle(9.0)]) # → [Circle(9), Rect(1, 1), Dot]
Absent < Some(5) # → true (Absent is declared first)
Circle(1.0) < Some(2) # → Error (different data types don't order)
Field type annotations are advisory in v1 —
--typecheck checks that each type atom names a known type;
construction does not reject a mismatched payload. An unannotated field
(data Option = Absent | Some(value)) is also legal. A slot
is a type expression, the same grammar as
:::
data Idcode = Ncode(Number) | Scode(Array of Character)
data Wrap = Mk(String | Integer)
Number is the numeric lattice parent
(5 is Number, x :: Number). Miranda
[char] is Array of Character (or
String, because Miranda string == [char]). A
lowercase identifier as the whole slot is a field name
(Some(value)); Array of char is a type atom
and is flagged — write Character.
Same-name constructors. An n-ary constructor may
share the type's name. The type stays the Concept
(t is MyTime); construction is a call on that type — the
same shape as type Age = opaque Integer then
Age(42):
data MyTime = MyTime(hour, minute, second)
t: MyTime(9, 45, 0)
t is MyTime # true — MyTime is still the type
t.hour # 9
MyTime.MyTime(9, 45, 0) # qualified form, same value
A nullary constructor may not
(data Trivial = Trivial errors with a rename hint). Bare
Trivial cannot be both the type and the singleton value;
pick data Trivial = MkTrivial. The Mk… prefix
stays available for any arity
(data Colour = MkColour(r, g, b)).
A sum is sealed: its members are exactly the values
of its declared constructors. So a data type may not
extends a concept.
concept Figure
data Point = P { x, y }
Point extends Figure # → Error: Point is a data type — a sealed sum
# cannot extend Figure …
Value membership matches the declared type name exactly and
does not walk an ancestor chain, so such an edge would answer
Point is Figure true while p is Figure stayed
false — an inclusion the values cannot honour. Ordinary concepts are
unaffected, and their entities do inherit the classification:
concept Animal
concept Dog
Dog extends Animal
d: a Dog { }
d is Animal # → true — the chain is walked for entities
Reach for a concept with extends when you want
inheritance, and for data when you want a closed set of
tagged alternatives.
Naming and
typing a slot — MkMyTime(hour:: Integer)
A bare slot says one thing, and its casing decides which: lowercase
is a field name, TitleCase a declared
type. To say both, annotate the slot with the same
:: used everywhere else in the language:
data MyTime = MkMyTime(hour:: Integer, minute:: Integer, second:: Integer)
t: MkMyTime(11, 59, 30)
t.hour # → 11
An annotated slot is CHECKED at construction. That
is what :: means in every other position — a binding, a
parameter, a return — and field slots were the one place it could not be
written:
MkMyTime("eleven", 59, 30)
# → type error: field 'hour' of constructor MkMyTime expects type Integer,
# got String (value: "eleven")
The check follows the value wherever it is rebuilt, so a functional update cannot smuggle a wrong type past it:
{t with hour: 12} # → MkMyTime(12, 59, 30)
{t with hour: "noon"} # → the same type error
Annotations work in the brace form too, and the two forms stay interchangeable:
data Rec = R { w:: Float, h:: Float }
R { w: 2.0, h: 3.0 }.w # → 2.0
R(2.0, 3.0) # → the same value, positionally
Checking is opt-in, and a bare slot keeps its old
meaning. A slot that does not write :: claims no
type and is not checked — which is what keeps a polymorphic constructor
polymorphic:
data Opt = Nada | Wrap(value)
Wrap(1) # fine
Wrap("anything") # fine — `value` is a NAME, and names no type
A bare TitleCase slot still declares its type
advisorily: the spelling is checked under
--typecheck, the value is not. Write :: when
you want the value checked.
signature reports the declared types, and
arity counts slots:
signature(MkMyTime) # → "MkMyTime(Integer, Integer, Integer)"
arity(MkMyTime) # → 3
Both stages, like every other ::.
--typecheck reports a wrong-typed argument before the
program runs, and the run-time refusal above catches whatever static
analysis cannot see:
data MyTime = MkMyTime(hour:: Integer)
t: MkMyTime("eleven")
# --typecheck → field 1 of constructor 'MkMyTime' must be Integer, got "eleven"
The static half stays quiet unless it is sure: a non-parameter slot
with no ::, an annotation naming a type the checker does
not model, or an argument whose type it cannot infer are all passed
over, because wrongly rejecting correct code is as bad as accepting
wrong code. As elsewhere, a call inside try [ … ] is not
reported — try says the failure is expected.
Type parameters —
data Box[T] = Boxed(T) | Bare
A field slot holds a bare name, and that name can mean three things. The bracketed head is what tells them apart:
data Sack = Sacked(contents) # a field NAME
data Crate = Crated(Integer) # a declared TYPE
data Box[T] = Boxed(T) | Bare # a type PARAMETER
Casing separates the first from the other two — a lowercase slot is
always a field name. [T] binds
T over that declaration's field slots and nowhere else, so
Boxed(T) means "whatever the caller puts here":
data Sum[A, B] = Inl(A) | Inr(B) # multi-parameter head
data Tree[T] = Leaf(T) | Node(Tree, Tree) # a parameter may not shadow the type
Boxed(1) # → Boxed(1)
Boxed("s") # → Boxed("s") — T accepts anything
Type arguments are erased. Nothing on a value
records what T stood for, so Boxed(1) and
Boxed(1.0) are one value — equal under ==, one
entry in a set, one key in a dict. That is why there is no
annotation form: writing x :: Box[Integer] is
refused, in every :: slot, because it could ask nothing
that x :: Box already asks. Write the bare name.
The head is refused if the parameter list is empty
(Box[] — that is data Box = …), lowercase
(Box[t] — a type parameter names a type, and type names are
TitleCase), duplicated (Pair[T, T]), or shadows the type
(Tree[Tree]). Re-declaring with a renamed
parameter is the usual no-op; changing the parameter
count is an error, like a changed constructor list.
Preludes stay monomorphic. Seeded
Option, Result, and Either have
zero type parameters so corpus re-declarations like
data Option = Absent | Some(value) stay a safe no-op. Do
not write
data Either[A, B] = Left(A) | Right(B) against the seed —
that changes the parameter count and errors. For a parametric dual sum,
pick a new type name and tags
(data Sum[A, B] = Inl(A) | Inr(B)). Full ruling:
resources/docs/claude/PARAMETRIC_ADT_RULING.md.
What --typecheck reads it for. A
TitleCase field slot naming neither a bound parameter nor a declared
type has no reading left, and is reported:
data Box = Boxed(T) # type error: field type 'T' names no known type
data Box = Boxed(Intger) # the plain typo, same message
data Box[T] = Boxed(T) # clean
data Box = Boxed(v) # clean — lowercase is a field name
Only the spelling is checked. Whether a value matches a
declared field type is still not enforced — this is a
--typecheck diagnostic and a warning on an ordinary run, so
nothing that ran before stops running.
Parameter bounds —
T of Number
data Point[T of Number] = MkPoint(x:: T, y:: T)
let p = MkPoint(1.0, 2.0)
p.x. # 1.0
MkPoint(1, 2) # also valid; this call chooses Integer
# MkPoint(1, 2.0) # error: T must be the same runtime type
# MkPoint("a", "b") # error: T must satisfy Number
data Pair[A of Number, B] = MkPair(first:: A, second:: B)
MkPair(1, "label") # B is independent and unbounded
Each parameter has one optional named upper bound. Bounds use
membership, without numeric promotion: T of Float does not
accept an Integer. Number includes Complex; there is no
seeded Real type. Use an explicit conversion when
coordinates have different numeric types.
Every direct occurrence of a parameter in one constructor call must
have the same runtime type, including bare slots
(MkPoint(T, T)) and named slots
(MkPoint(x:: T, y:: T)). Unbounded parameters also enforce
this relationship. Each call infers its own choice; parameters with
different names are independent. A container's runtime type is Array,
Dictionary, etc.; its element types do not create a specialization.
Nested parameter annotations such as Array of T, dependent
bounds such as T of U, and parameterized annotations such
as Point[Float] are not supported.
The evaluator enforces bounds and shared types at construction.
--typecheck reports unknown bound names and statically
certain mismatches; dynamic calls remain checked at runtime.
x:: T refers to the declaration's parameter even if an
outer binding is also named T. Constructor values still erase the chosen
parameter types: equality, hashing, and outer is Point
membership are unchanged. No new specialized runtime type or automatic
conversion is introduced.
Migration: replace
data Box[T] requires Number = Boxed(T) with
data Box[T of Number] = Boxed(T). The old data-head
spelling reports a parser error with a migration hint. Function
contracts keep their enforced requires: preconditions.
Renaming a parameter on an otherwise identical redeclaration is allowed;
changing its bound or its field relationships is rejected. These data
declarations remain evaluator-only; --vm reports the
existing ADT limitation.
Constructor
patterns in match and clausal func
area: func(s) [
match s with
| Circle(r) => 3.14159 * r * r
| Rect(w, h) => w * h
| Dot => 0
]
area(Circle(2.0)) # → 12.566...
# Nested patterns, wildcard fields, literal fields, `when` guards:
match Jus(Circle(9.0)) with
| Jus(Circle(r)) when r > 5.0 => "big"
| Jus(Circle(_)) => "small"
| Jus(_) => "other"
| Absent => "empty"
# Multi-clause func — same pattern language, first-match dispatch:
func area(Circle(r)) [3.14159 * r * r]
func area(Rect(w, h)) [w * h]
func area(Dot) [0]
An applied constructor pattern (Circle(r)) matches by
tag AND arity, and binds each field to the corresponding pattern; a
nullary tag (Dot) matches only that exact singleton.
match is total — a value that hits no arm
and no _ catch-all falls through to none, not
a crash (match Dot with | Circle(r) => r →
none), so a partial match is always safe to write. A failed
match is a total read that found nothing — absence, not a genuinely
undetermined value — which is why it is none and not
om. match/strict opts into a catchable Error
on miss. Multi-clause func heads and equations agree: no
matching clause yields none too.
A nested match inside an arm only collects
| arms whose column is past the enclosing arm's
|. A following | that lines up with the outer
arm belongs to the outer match. Same-line inner arms stay inner.
Parenthesize when an outer arm must continue on the same line as a
multi-arm inner match.
Array rest holes (one per pattern): [h | t] (unchanged),
[h, ..t], [.., last],
[first, .., last]. Hash leftover:
{name, ..rest} binds the other keys as a Dictionary.
Lowercase constructor slots are field names:
data Point = P { x, y } (same as P(x, y)),
P(x: 3, y: 4), P { x: 3, y: 4 },
pt.x, and | P { x, y } =>. TitleCase slots
(Circle(Float)) stay positional.
One arm, several shapes —
| alternatives
Separate alternatives with | when one body serves
several patterns. They are tried left to right:
Day enumerates Mon, Tue, Wed, Thu, Fri, Sat, Sun
match d with
| Sat | Sun => "weekend"
| _ => "weekday"
match n with | 1 | 2 | 3 => "small" | _ => "other" # literals too
func weekend?(Sat | Sun) [true] # and in a func clause
func weekend?(d) [false]
Every alternative must bind the same variables — otherwise the body could name something that exists down one branch and not the other:
match s with | Circle(x) | Rect(x, _) => x | Dot => 0.0 # fine: both bind x
match s with | Circle(x) | Rect(w, h) => x | Dot => 0.0
# → or-pattern alternatives must bind the same variables:
# 'Circle(x)' binds x but 'Rect(w, h)' binds h, w
Names are compared as a set, so
[a, b] | [b, a] agrees; use _ for a component
one alternative has and the other doesn't. Inside a bracket the
| is already the cons pattern ([h | t]), so
parenthesise to nest an alternative group: (Sat | Sun).
Naming the whole —
pattern as name
as binds the whole matched value while the pattern keeps
destructuring the parts. It works anywhere a pattern does, nested fields
included:
match s with
| Circle(r) as c => str(c) + " has radius " + str(r) # both c and r bind
| _ as other => "not a circle: " + str(other)
match d with | (Sat | Sun) as day => "weekend: " + str(day) | _ => "weekday"
func label(Circle(r) as c) [str(c)]
Range patterns and
in patterns
A range in pattern position is membership, not identity with a Range value:
match n with
| 1..9 => "digit" # 5 matches; the value 1..9 does not
| _ => "other"
match n with | 1..<10 => … # exclusive end
match n with | 1..9 by 2 => … # step grid
match n with | 1.. => … # open, n ≥ 1
match n with | -5..5 => … # negative bounds
match s with | "a".."e" => … # character range
| n in collection binds n and requires the
same in membership the infix uses (range, array, set,
string, …). | 1..9 as n names the element.
match 7 with | n in 1..9 => n + 1 # → 8
match 20 with | n in [10, 20] => n # → 20
func digit?(n in 0..9) [true]
func digit?(n) [false]
To ask “is this the range 1..9 itself”, write
| r when r == 1..9. | 1..9 against that Range
is false — a Range is not an integer member of itself.
Extractors — a function as a pattern
A capitalized applied pattern first tries the ADT / enum / opaque constructor path. If that does not match and the name is a user function, the function is called with the scrutinee:
Mailbox(s) = match split(s, "@") with
| [u, d] => Some((u, d))
| _ => Absent
match "[email protected]" with
| Mailbox(u, d) => u + " at " + d
| _ => "no"
| Return | Fields | Result |
|---|---|---|
Absent |
any | no match |
Some(x) |
1 | bind / match the field against x |
Some((a, b, …)) |
n | match the fields against the tuple |
Some(_) |
0 | match (| Even() =>) |
Error |
any | the match yields that error (does not fall through to
_) |
A Concept is never an extractor:
| String(s) does not match a string. That is the
no-type-pattern rule. Opaque newtypes stay constructors:
| Age(n) still unwraps Age(42). Extractors
should return Absent for values they do not handle.
By-name parameters —
name/lazy
A /lazy parameter wraps the argument expression as a
transparent thunk: it is auto-forced on read and caches a successful
result. Scala's => T re-evaluates every mention; Axioma
shares successful results instead.
unless(test, body/lazy) = if not test then body
unless(false, println("hi")) # prints
unless(true, println("hi")) # silent
Works on func, equations, and lambda.
body/lazy :: Float constrains the eventual
value, checking and converting it when demanded. Defaults and
named arguments obey the same rule. Unused arguments remain unevaluated;
a statically obvious mismatch may still be reported by
--typecheck. The existing parameter binding mutability is
unchanged. --vm refuses any /lazy parameter.
Annotations containing a function's generic type variables remain
refused on lazy parameters; their relationships need a separate
demand-order contract.
twice(body/lazy :: Float) = body + body
println(type(twice(21))) # Float
Use an explicit thunk when its mutable handle should select different work:
var pending = lazy (1 + 1)
pending = lazy (3 + 4)
println(force(pending)) # 7
Import rename and hide
Selective import can rename a member or skip names on a wildcard:
import sqrt as root, pi from "math.ax"
root(25) # 5 — bound as root, not as sqrt
import * hiding sqrt, abs from "math.ax"
# every export except sqrt and abs
take sqrt as root from "math.ax" is the same rename. Two
items that would bind the same local name is an error.
Exhaustiveness +
redundancy under --typecheck
Static-only diagnostics — normal execution is completely unaffected,
whether or not --typecheck runs:
data Shape = Circle(Float) | Rect(Float, Float) | Dot
# missing Dot arm, no catch-all:
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`)"
# a `_` or bare-variable catch-all always suppresses the warning:
match Circle(1.0) with | Circle(r) => r | _ => 0.0 # clean
# a later arm whose tag an EARLIER unguarded arm already covers → unreachable
# (separate from exhaustiveness — this example also covers every constructor):
match Circle(1.0) with
| Circle(r) => r
| Circle(_) => 0.0
| Rect(w, h) => w * h
| Dot => 0
# --typecheck → "unreachable match arm: constructor 'Circle' already matched
# by an earlier arm"
# an unknown/typo'd constructor tag for the inferred type:
match Circle(1.0) with | Circl(r) => r | _ => 0.0
# --typecheck → "unknown constructor 'Circl' for type Shape
# (declared: Circle, Rect, Dot)"
A guarded arm's tag never counts as coverage (the
guard might fail at runtime, so exhaustiveness can't assume it fires) —
a match whose only Circle arm carries a when
still needs an unguarded fallback or _.
Annotate the parameter to check exhaustiveness inside a
function. --typecheck resolves the scrutinee's
type from a static shadow model, not full type inference — a direct
constructor call or bare nullary constructor resolves automatically, but
an unannotated function parameter does not, so a
match inside func(s) [ match s with ... ] is
silently skipped (no exhaustiveness check at all, not even a false
pass):
# NOT checked — `s` has no declared type, so the scrutinee's type is unknown:
area: func(s) [ match s with | Circle(r) => r | Rect(w, h) => w * h ]
# checked — the `:: Shape` annotation gives inferExprType something to read:
area: func(s :: Shape) [ match s with | Circle(r) => r | Rect(w, h) => w * h ]
# --typecheck → "non-exhaustive match on Shape: missing constructor(s) Dot ..."
# the arrow and `lambda` spellings are checked the same way (book-style
# `let f = (params) :: R => [ switch … ]` included):
let area2 = (s :: Shape) :: Float => [
switch s
| Circle(r) => r
| Rect(w, h) => w * h
]
# --typecheck → "non-exhaustive match on Shape: missing constructor(s) Dot ..."
Confluence — does the clause ORDER matter?
A multi-clause function is a term rewrite system: patterns are left-hand sides, bodies are right-hand sides, and one dispatch is one rewrite step. Axioma tries clauses top to bottom and takes the first match, so the result is always determined — which leaves the question worth asking unanswered. Two clauses that can both match one call overlap; the answers they give there are that overlap's critical pair. If every critical pair joins — both clauses answer the same thing — the order cannot matter. If one does not, moving a clause changes results.
confluence(f) # → "orthogonal" | "confluent" | "order-dependent" | "undecided"
critical_pairs(f) # → every overlapping clause pair, and what each answers
unreachable_clauses(f) # → clause numbers an earlier clause has already claimed
| verdict | meaning |
|---|---|
orthogonal |
no two clauses can match the same call. Order-independent, and nothing had to be run to know it |
confluent |
clauses overlap, every overlap was decided, and every critical pair joins |
order-dependent |
a critical pair does not join — the clause order is load-bearing |
undecided |
an overlap could not be settled, and nothing was refuted |
func amb(0, y) [1]
func amb(x, 0) [2]
confluence(amb) # → "order-dependent"
critical_pairs(amb)[1].witness # → [0, 0]
critical_pairs(amb)[1].left # → 1 (clause 1's answer there)
critical_pairs(amb)[1].right # → 2 (clause 2's answer there)
amb(0, 0) # → 1 — the order decided it
func both(0, y) [0] # same overlap, agreeing right-hand sides
func both(x, 0) [0]
confluence(both) # → "confluent" — they overlap and it cannot matter
func oops(x) [x] # the catch-all-first bug
func oops(0) [99]
unreachable_clauses(oops) # → [2] — clause 2 can never fire
Each entry of critical_pairs is a hash with
clauses (a (i, j) tuple of 1-based clause
numbers), arity, witness (the argument list
both clauses accept), left, right,
joins (true / false /
none), status, and a note.
A guard is evaluated at the witness, not treated as
opaque. Clauses 1 and 3 of fib below both accept
0 structurally; the guard is exactly what holds them apart,
and reporting an overlap there would flag the most ordinary function in
the language:
func fib(0) [0]
func fib(1) [1]
func fib(n) when n > 1 [fib(n-1) + fib(n-2)]
confluence(fib) # → "orthogonal"
A guard that reads only positions the overlap pins to one value
settles the whole region, so and-chained guards work too —
Ackermann's three clauses come out orthogonal because
m > 0 alone excludes the first clause's witness.
A probe refutes; it never confirms.
status is "decided" when the overlap holds
exactly one call, and "probe" when it holds more. Two
clauses agreeing on a probe says nothing about the rest of the region,
so a joining probe leaves the verdict undecided; a
disagreeing probe is a counterexample, and a counterexample is
a proof:
func twice([a, b]) [a]
func twice([a | rs]) [a]
confluence(twice) # → "undecided" — they agree here, but the
# overlap holds every 2-element sequence
An array pattern is one such case: [x, y] matches an
Array, a cons List and a finite Range, and those are
==-distinct, so a witness built from one can only probe. A
number literal is not: 0 and 0.0 are one value
under the exact tower, so a numeric witness decides.
Two things this does not claim. Confluence is not
termination — a non-terminating clause set can be
perfectly orthogonal, and the verdict never speaks to whether
f halts. And computing a critical pair runs both
clause bodies at the witness, so a body with effects performs
them; guards are assumed pure for the same reason dispatch already
assumes it.
Relationship to --typecheck. The static
pass above reports tag-level redundancy at compile time and
deliberately stops at multi-slot constructor patterns and variadic
clauses. unreachable_clauses is the runtime, full-pattern
counterpart: it sees literals, tuples, arrays, hashes and every slot at
once, and it answers about a function value rather than about
source.
VM: a compiled closure is always a single rewrite rule (the compiler refuses multi-clause definitions outright), so
--vmanswers"orthogonal"for every function it can build — the same answer the evaluator gives for the same function.Tests: tests/axioma/confluence/test_clause_confluence.ax (48), tests/axioma/lambda_calculus/rewrite_systems_and_confluence.ax (24)
Unions — untagged, tagged, and bottoms
Axioma has two union families plus two bottoms. They are not interchangeable.
| Kind | Form | Value carries a tag? | Typical use |
|---|---|---|---|
| Untagged | x :: String | None |
No — just a string or none | “this binding may be one of several kinds” |
| Tagged ADT | data T = A | B(x) |
Yes — constructor name | Domain cases, match, exhaustiveness |
Bottom none |
none |
Absence | Missing value in dynamic code |
Bottom om |
om / Ω |
Undetermined | Unknown / not yet fixed |
Decision tree
Need absence as a bottom in dynamic code? → none
Need ML-style maybe with match / exhaust? → Option (Some / Absent)
Need success/failure with payload? → Result (Ok / Err)
Need dual payload without “error” naming? → Either (Left / Right)
Need parametric dual sum (erased T)? → data Sum[A, B] = Inl(A) | Inr(B)
(not Either[A,B] — prelude is monomorphic)
Need “this name is Integer or String”? → :: Integer | String
Need domain cases (Circle | Rect)? → data …
Untagged example
var thinker :: String | None = none
if random() > 0.5 then
thinker: "Susanne Langer"
A value satisfies A | B if it satisfies
any arm. Reassignment re-checks the same union (a slot
meant to be reassigned is a var; a let would
refuse the second write). Under --typecheck,
if x is Integer then … narrows x in the
then-branch.
Tagged preludes — Option,
Result, and Either are seeded globally (no
declaration needed):
# Option = Absent | Some(value)
o: Some(5)
o is Option # → true
match o with | Some(v) => v | Absent => -1 # → 5
# Result = Ok(value) | Err(error)
r: Ok(5)
match r with | Ok(v) => v | Err(e) => 0 # → 5
# Either = Left(value) | Right(value)
e: Right("ok")
match e with | Left(v) => v | Right(v) => v
Absent (Option constructor) is not
none (bottom): Absent == none is
false. None is the type of
none (none is None; None == none
is also false, type vs value). Prefer explicit bridges at the boundary
(below).
Identical re-declarations
(data Option = Absent | Some(value), etc.) are a safe no-op
via the idempotent data check.
Bridges
— to_option / from_option /
to_result / from_result /
unwrap_or
Never auto-promote bottoms into ADTs:
to_option(none) # → Absent
to_option(42) # → Some(42)
from_option(Some(9)) # → 9
from_option(Absent) # → none
to_result(1) # → Ok(1)
to_result(try error("x")) # → Err(<Error>)
from_result(Ok(4)) # → 4
from_result(Err(e)) # → e (payload; use unwrap_or to substitute)
unwrap_or(Some(3), 0) # → 3
unwrap_or(Absent, 0) # → 0
unwrap_or(Ok(8), -1) # → 8
unwrap_or(Err("x"), -1) # → -1
unwrap_or(Right(2), 0) # → 2
unwrap_or(Left("e"), 0) # → 0
unwrap_or(none, 99) # → 99 (bare none accepted)
to_option(om) is Some(om),
not Absent — unknown is not absence.
Railway helpers
— map_ok / and_then / or_else /
is_*
Function first, wrapper second
(same order as map / filter):
double: func(n) [n * 2]
map_ok(double, Some(5)) # → Some(10)
map_ok(double, Absent) # → Absent
map_ok(double, Ok(3)) # → Ok(6)
map_ok(double, Right(4)) # → Right(8)
map_ok(double, Left("e")) # → Left("e") (failure unchanged)
half: func(n) [Some(n / 2)]
and_then(half, Some(10)) # → Some(5) (bind — f returns a wrapper)
and_then(half, Absent) # → Absent
or_else(func(_) [Some(0)], Absent) # → Some(0)
or_else(func(_) [Some(0)], Some(9)) # → Some(9)
is_some(Some(1)) # true
is_absent(Absent) # true
is_none(none) # true — the none value, not Option
is_ok(Ok(1)) # true
is_err(Err("x")) # true
is_left(Left(1)) # true
is_right(Right(1)) # true
| Helper | Success tags | Failure tags |
|---|---|---|
map_ok(f, w) |
rewrap f(payload) as same tag |
return w |
map_err(f, w) |
return w |
rewrap f(payload) on
Err/Left; Absent unchanged |
and_then(f, w) |
return f(payload) as-is |
return w |
or_else(f, w) |
return w |
return f(payload_or_none) |
map_err(func(s) ["E:" + s], Err("x")) # → Err("E:x")
map_err(func(s) ["E:" + s], Ok(1)) # → Ok(1)
--typecheck residual typing. After
if x is Integer then …, a union-annotated x is
treated as Integer in the then-branch (so you can call
func(n :: Integer) cleanly). That is the static half of
untagged unions; runtime still needs the branch.
Showcase: tests/axioma/showcase/unions_option_decision_tree.ax.
Agent platform
types — IDs, methods, AgentStep
Seeded ADTs for autonomous / neuro-symbolic agents (no declaration
needed). Newtypes cannot reuse the type name as constructor (v1), so
wrappers use Mk*:
agent: MkAgentId("demo")
tool: MkToolId("search")
session: MkSessionId("s1")
GET is HttpMethod # true — closed method vocabulary
POST is HttpMethod
# Plan / act / observe loop (step ≠ Observation concept)
st: Think("plan")
st: Act(tool, {q: "..."})
st: ObserveStep(Ok(payload)) # tool/sensor Result, not an axiom
st: Done("answer")
st is AgentStep # true
| Type | Constructors | Role |
|---|---|---|
AgentId |
MkAgentId(value) |
agent identity (don't mix with strings) |
ToolId |
MkToolId(value) |
tool identity |
SessionId |
MkSessionId(value) |
session identity |
HttpMethod |
GET POST PUT
DELETE PATCH |
literal-union job via data |
AgentStep |
Think Act ObserveStep
Done |
agent loop nodes |
Conventions
- IDs are newtypes —
MkAgentId("x") is ToolIdis false. - Tool outcomes are
Result—Ok/Err, never silent string success. - Neural/API/sensor input →
observation(modality, payload, source [, embedding])
with empirical grounding — do not assert asaxiom. - Score ≠ grounding — keep confidence on a parallel
field (
{value, score, grounding: "datum"}). ObserveStep≠Observation— the step is a plan node;Observationis the Anschauung record.
Showcase: tests/axioma/showcase/agent_neuro_symbolic_loop.ax.
Tests: tests/axioma/adt/test_agent_platform_prelude.ax.
Embeddings —
cosine / top_k (thin neuro retrieve)
Vector similarity for RAG-style candidate retrieval.
Not a training stack: retrieve → filter symbolically →
treat hits as observation(...), never as
axiom.
cosine([1, 0], [1, 0]) # → 1.0
cosine([1, 0], [0, 1]) # → 0.0
store: [
{id: "a", embedding: [0.9, 0.1], text: "…"},
{id: "b", embedding: [0.1, 0.9], text: "…"}
]
hits: top_k([1.0, 0.0], store, 2)
# → [{id: "a", score: …, item: …}, …] sorted by score descending
# Observation entities expose .embedding
obs: observation("text", "hi", "src", [1.0, 0.0])
cosine(obs, [1.0, 0.0]) # → 1.0
| Builtin | Args | Result |
|---|---|---|
cosine(a, b) |
two equal-length numeric arrays (or entities with
embedding) |
Float in [-1, 1] (0 if a vector is
zero) |
top_k(query, items, k) |
query vector; array of {id?, embedding, …} /
Observations / raw vectors; k ≥ 0 |
array of {id, score, item} |
Tests: tests/axioma/ai-search/test_embedding_retrieve.ax.
Tool registry and Act guardrails
reg: tool_registry()
register_tool(reg, MkToolId("search"), func(q) [{hits: [q]}])
register_tool(reg, "calc", func(_) [4])
alice: a Agent { name: "alice" }
permit(alice, "search")
forbid(alice, "exec")
call_tool(reg, MkToolId("search"), "q", alice) # → Ok(...)
call_tool(reg, "exec", none, alice) # → Err("forbidden tool: exec")
call_tool(reg, "calc", none, alice) # → Err("not permitted tool: calc")
guard_act(alice, Act(MkToolId("search"), "q")) # → Ok(Act(...))
guard_act(alice, Act(MkToolId("exec"), none)) # → Err(...)
guard_act(alice, Think("…")) # → Ok(Think(...)) non-Act passes
| Builtin | Role |
|---|---|
tool_registry() |
empty mutable registry Dictionary |
register_tool(reg, tool, handler) |
bind MkToolId or string → function |
call_tool(reg, tool, args [, agent]) |
run handler → Result; optional agent enforces
deontic |
guard_act(agent, step) |
Ok(step) or Err for Act;
other AgentSteps pass |
Deontic uses existing permit / forbid /
is_permitted / is_forbidden (actions like
"search()"). Handler return values are wrapped as
Ok unless already a Result or an
Error (Err).
Tests: tests/axioma/adt/test_tool_registry_guard.ax.
or_else passes none for
Absent, and the field for Err /
Left.
Convention (stdlib). Prefer
none for “missing” in ordinary dynamic
APIs; prefer Option / Result /
Either when the caller will match,
use |?>, or these helpers. Convert at the boundary with
to_option / to_result — never treat
Absent == none as true.
Truthiness trap. none is falsy;
Absent (the Option constructor) is truthy,
as are Some / Ok / Err /
Left / Right. So if detect(...)
works with a none miss, but would not mean
“found” if detect returned Absent. Do not
change detect / index_of / get to
Option by default.
Zero-cost Option from today’s search APIs:
to_option(detect(func(x) [x > 10], xs)) # miss → Absent; hit → Some(elem)
to_option(index_of("z", "abc")) # → Absent
to_option(get(h, "missing")) # → Absent
*_option duals — same arguments as the
base API; miss is None instead of none. Base
APIs are unchanged (miss stays falsy none):
Base (miss = none) |
Dual (miss = None) |
|---|---|
detect(pred, coll) |
detect_option(pred, coll) |
get(hash, key [, default]) |
get_option(hash, key) — no default arg |
index_of(coll, target [, init]) |
index_of_option(...) |
span_of(s, sub [, init]) |
span_of_option(...) |
detect(func(x) [x > 10], [1, 12]) # → 12
detect_option(func(x) [x > 10], [1, 12]) # → Some(12)
detect_option(func(x) [x > 99], [1, 12]) # → Absent (truthy tag — use is_absent)
get(h, "k") # → value | none
get_option(h, "k") # → Some(value) | None
# key present with value none → Some(none), not None
index_of("hello Lua", "Lua") # → 7
index_of_option("hello Lua", "zz") # → None
span_of_option("hello Lua", "Lua") # → Some((7, 9))
map_ok(func(x) [x * 2], detect_option(gt10, xs))
unwrap_or(get_option(h, "missing"), 0)
Full audit: OPTION_STDLIB_AUDIT.md.
Tests: tests/axioma/adt/test_railway_helpers.ax.
|?> —
error-propagating railway composition
The pipe-try operator x |?> f recognizes the
Option/Result/Either convention by tag
name, not by a registered type — so it works over the seeded
preludes and any user data type that
reuses the same tags:
| Tags | Role in |?> |
|---|---|
Some, Ok, Right |
success — unwrap one field, call f |
None, Err, Left |
failure — short-circuit, return wrapper unchanged |
none, om, Error |
failure — short-circuit |
double: func(n) [n * 2]
Some(5) |?> double # → 10
Ok(5) |?> double # → 10
Right(5) |?> double # → 10
None |?> double # → None
Err("x") |?> double # → Err("x")
Left("e")|?> double # → Left("e")
half: func(n) [Some(n / 2.0)]
Some(10) |?> half |?> double # → 10.0
VM policy (tagged ADTs)
Constructor values (Some, None,
Ok, Err, Left,
Right, and user data constructors) and
|?> railways over them are
evaluator-only. Under --vm, using them
fails at compile time rather than running with a
different meaning (§911: no wrong answer at exit 0). Untagged
:: A | B annotations on variables are also not enforced
under --vm today (same pre-existing annotation boundary as
other :: binds). Use the tree-walking interpreter for
ADT-heavy code; use --vm for the supported subset.
Tests: tests/axioma/adt/test_option_prelude.ax, test_result_prelude.ax, test_either_prelude.ax, test_union_bridges.ax, test_adt_vm_policy.sh.
Unwrap fires only for a Some/Ok-tagged
value with exactly 1 field — any other shape (a
different tag, a 2-arity Some, or an unrelated constructor
like Circle(5.0)) passes through to the RHS
unchanged, matching |?>'s pre-existing
non-ADT behavior over
Error/none/om.
pipe_try never re-wraps the RHS's return value; staying "in
the railway" past a stage is the RHS's own job.
Comprehension constructor-pattern destructure
A constructor pattern works as a first-generator
target, alongside the existing single-var / tuple
((a, b) <- xs) / hash ({a, b} <- xs)
forms, across all four comprehension flavors and both surface forms:
maybes: [Some(1), None, Some(3), None, Some(5)]
[v | Some(v) <- maybes] # → [1, 3, 5] (None rows silently SKIPPED)
[v for Some(v) in maybes] # → [1, 3, 5] (Python pipe-less form)
{v | Some(v) <- maybes} # → {1, 3, 5} (set comp)
force((v | Some(v) <- maybes)) # → [1, 3, 5] (lazy comp)
[v | Some(v) <- maybes, v > 2] # → [3, 5] (combines with a filter)
A source element whose tag or arity doesn't match is silently dropped
— filter+extract in one step, the same convention hash-destructure uses
for a missing key. Scope: first-generator position
only, and only the applied form (Tag(field) <-)
— a bare nullary pattern with no parens (None <- maybes)
is not recognized here (the parser gate requires capitalized-IDENT
immediately followed by (, so it never reinterprets the
pre-existing bare-uppercase-generator-variable idiom, e.g.
{Y | Y <- parent("John", Y)}).
Concepts vs. sum types — which to reach for
Concept + partition |
data sum type |
|
|---|---|---|
| Variant set | Open — a new subtype can be added later | Closed — fixed at declaration |
| Structure | Inherited fields via has up the extends
chain |
Positional fields per constructor |
| Dispatch | if x is A then ... else if x is B then ... |
match x with | A(...) => ... | B => ... |
| Exhaustiveness | Not statically checked (open by design) | --typecheck catches a missing/redundant arm |
| Runtime extensibility | Yes — concepts can be declared dynamically | No — the constructor set is fixed at the data line |
Reach for data when the variant set is genuinely closed
and you want the type-checker to catch a missed case (parsers, small
calculators, protocol messages). Reach for Concepts +
partition for open-world domains that may grow new subtypes
over time (see §13 and the
Concepts vs. Sum Types chapter of the HtDP-in-Axioma textbook
for the full comparison).
VM notes
data declarations, constructor patterns, and
comprehension constructor-destructure are all
evaluator-only — --vm doesn't compile
*ast.DataStatement (the whole family inherits this
pre-existing boundary, same as relation/rule declarations).
|?>'s ADT-awareness is mirrored in the VM's
pipe_try case for forward compatibility, but since no
ConstructorValue can reach the VM today, that mirror is
presently unreachable — the VM's non-ADT |?> behavior
(over Error/none/om) keeps its
full, verified parity, unaffected.
The --typecheck notes above are the public contract for
ADT static diagnostics; VM support for the whole ADT family is a later
implementation phase.
34. Modules
A file is a module. Optional
module Name (or module Math.Linear.Algebra) at
the top names it; the file path is what import loads.
# geometry.ax
module Geometry
pi: 3.14159
area(r) = pi * r * r
_secret(x) = x * 2
export pi, area
allow pi, area # the same statement as export
import "geometry.ax" as Geo
Geo.area(2)
Geo's pi
import area, pi from "geometry.ax"
area(2)
import sqrt as root from "math.ax"
import * hiding sqrt from "math.ax"
use "geometry.ax" called Geo
take area, pi from "geometry.ax"
Paths. ./sib.ax and
../sib.ax resolve against the importing
file, not the process working directory. Bare
foo/bar.ax still searches the working directory,
lib/, AXIOMA_PATH, then the bundled stdlib.
REPL / -e have no file, so ./ there is
cwd.
Scope — an import binds where it is written. An
import is a binder: the names it brings in are fresh bindings
in the scope holding the import statement, and they
shadow any outer name of the same spelling rather than
overwriting it. At the top of a file that is the whole file, as usual.
Inside a function body it is the body, and the outer meaning comes back
when the body ends:
name(x) = "OUTER-" + x
describe() = [
import * from "./shouty.ax" # shouty.ax also exports name/1
name("a") # → "INNER-a" — the import's
]
describe() # → "INNER-a"
name("a") # → "OUTER-a" — unchanged
That is the local open: a wildcard import written inside a body is in
scope for that body only. import x from and
import "p" as M scope the same way. import is
a statement, so it goes in a body (a statement sequence), not in an
if branch or another expression position.
What crosses. Two modes:
- No
export/allowin the file — functions,datatypes, and ALL_CAPS constants are public; plain values are not;_is never public. - At least one
export/allow— that list is the surface. Unlisted functions are hidden._stays private. A plain value still has to be named (export pi).
export Shape/transparent puts the type
and its constructors on the list (Circle
is importable bare, and Shape.Circle works).
export Shape without the refinement exports the type only —
constructors are neither module members nor properties of the exported
type object.
A top-level let in an imported file is a real binding.
With no export list, let f = func(x) [x] is
public (it is a function). With a list, name it:
export n, triple.
A module may be named after the type it defines.
module ShirtSize followed by
data ShirtSize = Small | … (or
type Age = Integer under module Age, or
Digit ranges 0..9 under module Digit) is the
OCaml/Reason ShirtSize.t idiom: inside the file the name
denotes the type; importers bind the module under their own
alias (import "shirt_size.ax" as ShirtSize then
ShirtSize.XLarge(1), ShirtSize.price(s),
:: ShirtSize.ShirtSize). Only the file's own
module name yields this way — a module bound by an
import is a foreign value, and data Foo after
import … as Foo still refuses.
A module may also be declared INSIDE a file.
module Name [ … ] runs the block in its own scope and binds
a module built from what the block defines. The Julia-style dual
module Name ⏎ … end is the same statement
(same AST); it fires only when a matching end is present,
so a file-header module Name plus the rest of the file is
not stolen. Reason writes this module Name = { … } and
OCaml module Name = struct … end; here the canonical body
is the statement block [ … ], because {} is
the empty set and {k: v} is a hash literal.
module Username [
data Username = MkUsername(String)
of_string(s) = MkUsername(s)
to_string(u) = match u with | MkUsername(s) => s
export Username, of_string, to_string
]
module Hostname
data Hostname = MkHostname(String)
of_string(s) = MkHostname(s)
to_string(h) = match h with | MkHostname(s) => s
export Hostname, of_string, to_string
end
Username.to_string(Username.of_string("bob")) # → "bob"
Hostname.to_string(Hostname.of_string("ex.com")) # → "ex.com"
The body follows the same rules a file does — an
export / allow list inside it is the surface,
_ never crosses, and with no list the functions,
data types, and nested modules are public while plain
values are not. Modules nest to any depth
(Outer.Inner.deep(x)), and a nested module may be named
after the type it defines just as a file may.
A module body is a scope boundary, so a member may share a name with
something outside — peek() inside a body defines a member
and leaves the builtin alone. The body can read the enclosing
file, but only what it defines becomes a member.
One header per file. module Name with
no body names the file, and a file is one module, so a second header is
an error — it used to bind silently and change which module the file
claimed to be. To declare a module inside the file, give it a body:
module Geometry # the file's header
module Polar [ … ] # a module inside the file — as many as you like
module Cartesian [ … ]
There is no module M = <expr> form:
[ … ] is how a module is written. A function returning a
record of operations is how a parameterised one is built — Axioma has no
functors, and sets and dicts key by value ==, so no
comparator witness has to be passed. --vm refuses
module bodies, same as import.
include re-exports.
import "Core.ax" lets this file call Core's names; whoever
imports you does not get them. include "./Core.ax"
binds those names here and publishes them, so one
import of the layer is enough:
# lemmas.ax
include "./Core.ax"
lemId: certify([ assume(atomA), impI(1, 1) ], kimp(atomA, atomA), [])
export lemId
import "lemmas.ax" as P
P.lemId
P.certify # from Core, re-exported
include shown from "./base.ax" is the selective form.
include "p" as M is a SyntaxError — flattening is the
point; bind a module with import "p" as M.
--vm refuses include, same as
import.
modules() / functions(M) list namespaces
and a module's members. math.sqrt is ambient (no import).
builtin.string.upper("hi") reaches import-only packages.
--vm refuses import; ambient
math.sqrt still runs.
Tests: tests/axioma/modules/test_import_relative.ax,
test_import_let.ax, test_opaque_type_view.ax,
test_export_transparent.ax,
test_export_exclusive.ax, test_include.ax,
test_selective_cycle.ax.
35. Reference
Keyword index
| Keyword | Group |
|---|---|
: (bind), let, var,
val, rebind, global,
const, lambda, func,
fn, define |
Bindings & functions (bind a value with :) |
if, then, else |
Control flow |
true, false, none,
om, Ω |
Literals (null is retired — write
none) |
and, or, not,
implies, iff |
Logic |
forall, exists, in |
Quantifiers |
union, intersect, difference,
subset |
Set ops |
concept, has, extends,
delete, is, is |
Concept system (creation uses concept only;
exists is reserved for the existential quantifier) |
data, match ... with, tag,
|?> |
Algebraic data types (§33) — data X = A(v) | B,
constructor patterns, railway composition |
import, include, use,
take, export, allow,
module, hiding |
Modules (§34) — files as namespaces |
axiom, postulate |
Knowledge tiers |
insert, forget, retract,
cancel, uncancel |
Mutation (relation named by string) |
transaction_begin/commit/rollback |
Transactions |
head whenever body /
typically head whenever body (primary);
head if body, rule~ head if body,
<=/<== (:-),
<~~, ==>, ~~> |
Rules (strict / defeasible × backward / forward) |
grounding, truth_kind, why,
proof, rules_of, challenge,
challenged, canceled |
Provenance & introspection |
try, catch, finally,
attempt, otherwise, or else,
error, raise, error? |
Errors as values (§29) |
understand, examine, abduce,
[ model | … ] |
Cognitive kernel (§30) |
kleene, belnap, lukasiewicz,
intuit3 |
Multi-valued-logic value constructors |
necessarily, possibly |
Modal |
always, eventually, next,
until |
Temporal |
knows, believes, beliefs_of,
believers_of |
Stored epistemic attitudes; common knowledge has no public builtin |
obligatory, permitted,
forbidden |
Deontic |
dup, swap, rot,
over, drop, nip,
tuck, stacklength, erase |
Stack |
is/same, is/identical,
is/property |
Russell's copula |
venn, fullform, treeform,
tableform, graphform |
Visualization & the *form family |
Runtime reflection — the discovery vocabulary
The self-describing surface (§25 has the full story with examples). One row per question the language answers about itself:
| Question | Ask | Notes |
|---|---|---|
| What is this word/type? | doc word / doc(word) |
type cards for the 12 core types; keywords via
doc("if") |
| What is this value? | describe(x) |
per-type fact card; points onward at doc and
functions |
| What exists at all? | builtins(), concepts(),
bindings(), keywords() |
catalogs; 1-arg forms are membership checks |
| Where is anything about …? | apropos("term") |
searches names AND documentation text |
| What can I do with a type? | functions(Integer) ≡ methods(Integer) |
curated per-type builtin catalog (12 card types) |
| 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(code, [mode]) |
parse without running; '(…) quote is the lexical
form |
| Does this compile to the VM? | compile(src) |
callable on success; catchable Error names the VM boundary |
| Which limits does a type have? | Float.max, Byte.max,
Integer.bounded |
live members; Integer.max is a teaching error |
Type
catalog — every designation type() / @ /
:: can return
The canonical TitleCase names, grouped. Each is a same-named
primitive-type Concept, so type(x), @x,
:: x, and x is Name agree
(typeNameForObject names the tag; type()
returns the Concept).
| Group | Names |
|---|---|
| Numbers | Integer, Float, Rational,
Complex, Infinity — §4 |
| Text & binary | String, Byte, Bytes — §4 |
| Booleans & bottoms | Boolean, None (none),
Om (om/Ω) — §4 |
| Multi-valued truth | Kleene, Lukasiewicz, Belnap,
Intuit3 — §9 |
| Collections | Array, Tuple, Set,
InfiniteSet, Bag, Dictionary,
Stack, Generator (one-shot lazy),
Stream (replayable) — §5, §7 |
| Math & data containers | Matrix, Tensor, DataFrame —
§5 |
| Lexer scalars | URL, Email, File,
Date, Time, Money,
Pair, Percent, Word,
GetWord, HashWord — §4 (Tag exists but is shadowed by
the <…> natural-language literal) |
| Time values | DateTime, Duration — the
builtin:datetime package, §4 |
| Callables & code | Function, Builtin, Reference
(§21), AST (§19), Symbol,
Expr, SymExpr (the CAS layer, §28) |
| Knowledge & logic | Concept, ConcreteEntity (§13), Relation,
RuleClause (§14, §16),
Grounding, Kind (§16),
Proposition, Formula (§8), ConceptExpr (DL, §13), Epistem (§15),
CheckedTheorem (§31),
SolveResult (the Pólya solver), Glyph (§3), ConceptualGraph,
Error (§29) |
| Natural language | NaturalLanguage (<…>),
ConstrainedLanguage, Dialect,
NSMExplication, CognitiveWord |
| ADT values | report their data-type name
(Circle(3.0) → "Shape") — the constructor tag
is the separate tag() axis, §33 |
| Everything else | internal subsystem objects (Kripke/epistemic models, games, fuzzy
sets, proof objects, …) currently report "Unknown" |
Refinement table
| Form | Effect |
|---|---|
declare/persist x = v |
Mark for saving; demand first, subject to serialization limits (use
=) |
declare/transient x = v |
Discard at session end |
axiom/persist |
Save to cascade.db |
axiom/transient |
Session-only axiom |
postulate/persist |
Save to cascade.db |
postulate/transient |
Session-only postulate |
Tag-filter values for comprehensions
@axiom, @postulate, @theorem,
@conjecture, @hypothesis, @datum,
@canceled, @all, @*
File locations
- Knowledge base —
cascade.db(project) or~/.axioma/axioma.kb(MCP server) - Session state —
.axioma_session.json - Diagrams —
diagrams/venn_diagram_TIMESTAMP.png - Logs —
~/.axioma/mcp.log - PID —
~/.axioma/mcp.pid - Examples —
tests/axioma/**/*.ax,tests/axioma/showcase/**/*.ax,lib/**/*.ax
Error messages
| Error | Cause | Fix |
|---|---|---|
identifier not found: X |
Undefined variable | Define or check spelling |
Parser errors: ... |
Syntax error | Check parentheses, brackets, operators |
wrong number of arguments |
Arity mismatch | Check function signature |
type mismatch |
Incompatible types | Convert or check operand types |
division by zero |
n / 0 |
Check denominator |
cardinality violation: <C>.<p> |
Slot over-assignment | Last-write-wins; check _cardviolation_* |
See also
Axioma.md— feature index and quick referenceAxioma Elements.md— high-level element-group mapdocs/f-logic-unification.md— unified frame-logic / bilattice / G3 design docdocs/core-language.md— compact core-language referencedocs/computational-core.md— verified computational subsetdocs/feature-maturity.md— maturity tiers and release-facing caveats
Axioma Programming Language v0.9 · Calculemus! — Let us calculate. (Leibniz) © 2024–2026 — Mathematical Computing, Logic, and Knowledge