The guide
Recording
Tapes, values, symbols, and shapes checked at the line that records them. Sealing, reopening, and the IR dump.
The tape#
A Tape is the construction phase: an append-only list of operations, the Wengert list of 1964. Expressions record through Value proxies — thin, Copy, borrowing the tape — and every operator appends one node. The tape is the only phase with interior mutability; after the seal nothing writes the graph.
use topos::Tape;
let tape: Tape<f64> = Tape::new();
let constant = tape.leaf(3.0);
let weight = tape.parameter(0.5);
let sample = tape.input(1.0);
let activation = (weight * sample + constant).tanh();Three kinds of source, and they are the only nodes with no operands:
- a leaf is a constant supplied at recording time;
- a parameter is trainable — the node holds a slot, and live payloads live in the caller's
Parameters; - an input is fed per run; an unfed input uses the recorded default.
0 Leaf [] 1 Parameter [] 2 Input [] 3 Mul 1, 2 [] 4 Add 3, 0 [] 5 Tanh 4 [] tape: 6 nodes, 1 parameter, 1 input
The code that produced it
use topos::Tape;
let tape: Tape<f64> = Tape::new();
let constant = tape.leaf(3.0);
let weight = tape.parameter(0.5);
let sample = tape.input(1.0);
let _activation = (weight * sample + constant).tanh();
show::spec(tape.describe())The element type is the tape's type parameter: Tape<f64>, Tape<f32>, Tape<Bf16>, or a number of your own. A bare 0.5 is a rank-0 tensor; the graph is always tensors, and a scalar is the case with no axes.
Shapes, at the line that records them#
Every node's shape is inferred when it is recorded and printed on its line. A matrix product checks its inner extents; an elementwise operation requires equal shapes; a reduction says which axis it removed. Nothing broadcasts by accident: spreading a bias over a batch is an explicit broadcast_along, and the spec shows the axis and extent it inserted.
0 Input [3, 2] 1 Parameter [2, 4] 2 Parameter [4] 3 MatMul 0, 1 [3, 4] 4 BroadcastAlong 2 axis=0 extent=3 [3, 4] 5 Add 3, 4 [3, 4] 6 Tanh 5 [3, 4] 7 SumAlong 6 axis=1 [3] tape: 8 nodes, 2 parameters, 1 input
broadcast_along, and the attribute says so.The code that produced it
use topos::{Tape, Tensor};
let tape: Tape<f32> = Tape::new();
let batch = tape.input(Tensor::filled([3, 2], 0.0_f32));
let weights = tape.parameter(Tensor::filled([2, 4], 0.1));
let bias = tape.parameter(Tensor::filled([4], 0.0));
let affine = batch.matmul(weights) + bias.broadcast_along(0, 3);
let _per_sample = affine.tanh().sum_along(1);
show::spec(tape.describe())A shape that does not fit panics at the expression that records it, with both shapes in the message. There is no deferred error, no later pass that discovers the problem: the line of Rust that wrote the bad node is the line in the backtrace.
left.matmul(right) panicked at the recording expression: assertion `left == right` failed: matmul cannot multiply [3, 2] by [4, 5] left: 2 right: 4
The code that produced it
use std::panic::{self, AssertUnwindSafe};
use topos::{Tape, Tensor};
let tape: Tape<f32> = Tape::new();
let left = tape.input(Tensor::filled([3, 2], 0.0_f32));
let right = tape.input(Tensor::filled([4, 5], 0.0_f32));
// A `[3, 2]` by `[4, 5]` product has no shape; the recording line
// itself panics, and this page catches it to print the message.
let quiet = panic::take_hook();
panic::set_hook(Box::new(|_| {}));
let outcome = panic::catch_unwind(AssertUnwindSafe(|| left.matmul(right)));
panic::set_hook(quiet);
let message = match outcome {
Ok(_) => "no panic".to_string(),
Err(payload) => payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|text| text.to_string()))
.unwrap_or_default(),
};
show::lines([
"left.matmul(right) panicked at the recording expression:".to_string(),
format!(" {message}"),
])Values are copies#
A Value is never consumed by an operator, so one value can feed any number of expressions. The graph records every read; a picture of it shows a node with several outgoing edges, and the reverse scan will later sum the contributions that flow back along them.
Copy and never consumed, so a and c each feed two expressions. The picture shows every read.The code that produced it
use topos::Tape;
let tape: Tape<f64> = Tape::new();
let a = tape.leaf(2.0);
let b = tape.leaf(3.0);
let c = tape.leaf(4.0);
let sum = a + b;
let product = sum * c;
let _expression = -product + a * c;
show::graph(Graph::from_nodes(tape.nodes()))The arithmetic operators, matmul, the maps (tanh, exp, ln, sqrt, sin, cos, erf, and the rest), the shape operations (broadcast, reshape, permute, narrow, pad, unfold, fold), gather and scatter, and the two fused log-domain primitives log_softmax and logsumexp are the closed vocabulary. Everything else on a Value — relu, softmax, transpose, mean_along, gelu — is a composition of those, and the dump shows the composition. What earns an instruction is the rule that keeps the set closed.
Names leave as symbols#
A Value borrows the tape and dies with it. To speak about a node after the seal — read it from a run, step it in a table, differentiate with respect to it — take its Symbol: a detached, copyable name that is nothing more than a family token and a position. Nodes never move, so a symbol taken before a reopen still names the same node afterwards.
w.index() = 0 doubled.index() = 2 parameters.of(w) = 0.25 run.of(doubled) = 0.5 network.node(w) = 0 Parameter []
Value is the proxy of recording; a Symbol is the name every phase after it speaks. Parameters and runs are read through symbols.The code that produced it
use topos::Tape;
let tape: Tape<f64> = Tape::new();
let w = tape.parameter(0.25);
let doubled = w * 2.0;
// Values borrow the tape; symbols are the detached names.
let (w, doubled) = (w.symbol(), doubled.symbol());
let network = tape.into_network();
let parameters = network.parameters();
let run = network.forward(¶meters, []);
show::lines([
format!("w.index() = {}", w.index()),
format!("doubled.index() = {}", doubled.index()),
format!("parameters.of(w) = {}", parameters.of(w)),
format!("run.of(doubled) = {}", run.of(doubled)),
format!("network.node(w) = {}", network.node(w).to_string().trim()),
])Tape::record folds the two steps together: the closure records, its return value is the set of values that should leave as symbols, and detach converts them — a single value, an array, a tuple, a Vec, or an Adjoints.
use topos::{Detach, Tape};
let (network, [w, loss]) = Tape::record(|tape| {
let w = tape.parameter(1.0_f64);
[w, w * w].detach()
});What construction detaches is the vocabulary later phases may mention. It is not a run's keep-set; an entry declares that separately. You may name a sampling head you never compute on the training entry.
Sealing and reopening#
into_network consumes the tape and answers the sealed spec: structure, shapes, initial payloads, input defaults — the whole architecture, runnable standalone, with no live weights and no lock. Network is deliberately not Clone; share it with &Network or Arc<Network>. into_tape consumes the network and reopens it. Both consume, so the spec's history is linear by ownership: two divergent futures of one prefix cannot be constructed.
0 Parameter [] 1 Mul 0, 0 [] appended after the reopen 2 Mul 1, 0 [] tape: 3 nodes, 1 parameter, 0 inputs
The code that produced it
use topos::Tape;
let tape: Tape<f64> = Tape::new();
let w = tape.parameter(1.5);
let square = (w * w).symbol();
let w = w.symbol();
let network = tape.into_network();
let sealed = network.len();
// Reopening consumes the network; the old symbols still resolve.
let tape = network.into_tape();
let _cube = tape.resolve(square) * tape.resolve(w);
show::spec_from(tape.describe(), sealed, "appended after the reopen")Reopening never moves a node, so plans and runs derived from the old prefix stay valid, and Parameters::carried keeps trained payloads across the reopen while filling the new slots from their initials. This is how the notebook grows a graph cell by cell, and how the language-model examples record a decode step beside a full-context one.
Reading the dump#
describe on a tape, a network, or a plan prints the same column format: index, opcode, operands, parameters, shape — and, on a plan, liveness. The plates on this site are that text, with every operand reference linked: hover a line to see what it reads and who reads it. Node numbers are stable for the life of the graph, so a number on one page is the same node on another.
The dump is also executable IR. Network::nodes walks it as Node values with a public Opcode, and Opcode::express runs one instruction over any payload that speaks the recordable vocabulary. The walkthrough replays a whole spec by hand that way.
Where next#
- Differentiation — the reverse scan and the recorded transform.
- Entries and plans — what a run may read, and the schedule that licenses.
- Names — why a symbol is thin, and what it is not.
topos