Skip to main content
This page builds the mental model you need to work with Cerulion. It explains the five things you will name and reason about every day (workspace, node, graph, topic, and schema) and how the runtime turns them into a running robotics application. Read this once before the guides, then keep the reference open while you build. For why Cerulion is shaped this way, see Why Cerulion.

The five nouns

Workspace

The project container: a Cargo workspace with graphs/, nodes/, and schemas/ directories.

Node

A unit of computation: a Rust struct annotated with #[cerulion_node], compiled to a dynamic library.

Graph

A .yaml file that wires node instances together. Topology only.

Topic

A named shared-memory channel, created automatically from your graph wiring.

Schema

The shape of a message: a ROS 2 message type or a workspace-defined schema.
At a glance, here is how the nouns nest and connect: a workspace holds your node types, schemas, and graphs; a graph wires node instances into a dataflow that produces topics. Figure: A workspace contains node types, schemas, and graphs. A graph wires node instances (rounded) into a dataflow whose connections become topics.

Workspace

A workspace is the project container for everything you build. It is a Cargo workspace plus three Cerulion directories. You create one with cerulion workspace create <name> (or cerulion workspace init in place), and most commands work by discovering the workspace around your current directory.
my_robot
Cargo.toml
.gitignore
graphs
perception.yaml
nodes
The top-level Cargo.toml declares a [workspace] with members = ["nodes/*"], so every node is its own crate built by the same cargo invocation. Each directory has one job:
  • graphs/: one <name>.yaml per graph (the wiring).
  • nodes/: one crate per node type (the code).
  • schemas/: one <Name>.yaml per workspace-defined message shape.
Cerulion discovers a workspace by walking upward for a Cargo.toml that contains [workspace] alongside a graphs/ directory. Most node and graph commands need this; workspace create/init and the topic commands do not.

Node

A node is a unit of computation: it reads inputs, does work each time it fires, and writes outputs. You define one as a Rust struct annotated with #[cerulion_node], paired with an adjacent impl block annotated with #[cerulion_node_impl].
The struct fields are the ports: #[input(...)] fields are subscriptions, #[output] fields are publications. Inside tick(), reading and writing those fields reads and writes shared memory directly; there is no separate publish or subscribe call to make. Every node is imported from cerulion_core::prelude and compiled to a dynamic library (cdylib) so the runtime can load it at run time. For the full attribute set, see the node macro reference.

Node type vs. node instance

This distinction is the heart of Cerulion’s model.
  • A node type is the code: the crate under nodes/<type>/, defined once by the #[cerulion_node] macro. The type declares its ports and its behavior.
  • A node instance is a use of that type inside a graph: an entry with a unique id under nodes: in a graph .yaml. You can stage the same type into a graph more than once, each with its own id and its own wiring.
Think of the type as a class and the instance as an object of that class. The type says what a camera node does; an instance says this particular camera, wired to these inputs and outputs.

Trigger policy lives on the type, not the graph

A node’s trigger policy decides when it fires: every N milliseconds, when data arrives on a trigger input, when several trigger inputs line up in a time window, when every trigger input has an unconsumed message with no time bound at all, or on an external command. This policy is part of the node type. You set it with the #[cerulion_node(...)] macro attributes in src/lib.rs (and, at scaffold time, with the --policy flag on cerulion node create).
Trigger policy is never written in graph YAML. Graph files describe wiring only: id, type, inputs, and outputs. There is no policy: block in a graph file. To change when a node fires, change its macro attributes (or use cerulion node modify), not the graph.
This is deliberate: behavior lives in one place (the code), and the graph stays a pure description of how instances connect. See trigger policies for the full grammar and the defaulting rules.

Graph

A graph is a .yaml file under graphs/ that wires node instances together. It is the source of truth for topology: which instances exist, and how each instance’s inputs connect to other instances’ outputs.
Each entry under nodes: is an instance: id is its unique name, type is the node-type folder it comes from, and inputs/outputs declare its ports. An input’s source is written <node_id>/<output_name>. Here, detector’s image input reads camera’s image output. The optional prefix namespaces the topics this graph creates; if you omit it, Cerulion resolves it to the host name at load time. A graph is named by its file: graphs/perception.yaml is the graph perception, and that stem is what every surface uses — the run directory, the bag filename, and the graph= field on log lines. There is no name: key to keep in sync with it. Every other key is checked: a misspelling is a loud parse error naming the offending key, never a setting quietly dropped. You run a graph with cerulion graph run <name>, which loads each instance’s compiled library, wires the topics, and drives execution. See wire and run a graph for the workflow.

Topic

A topic is a named channel that carries messages from a publisher to its subscribers over shared memory. You do not create topics by hand; Cerulion derives them from your graph wiring. When detector’s image input reads camera/image, the runtime sets up the underlying shared-memory channel for you. Topics are how you observe a running system from the outside. Because discovery happens through the shared-memory transport, the topic commands work without a workspace:
  • cerulion topic list: show active topics — local ones first, then the robots and topics it finds on the network.
  • cerulion topic echo <topic>: print messages as they arrive.
  • cerulion topic hz <topic>: measure the publish rate.
A runtime topic name is absolute: /<prefix>/<node_id>/<output_name>, for example /robot1/camera/image. Inside a graph file you write the short camera/image form, which resolves under the graph’s prefix. An output can also pin an absolute topic: of its own. See inspect topics for the full set.
Conceptual illustration of one shared payload connected to three local compute devices.

Schema

A schema is the shape of a message: its fields and their types. A schema gives a topic a known layout so publishers and subscribers agree on what the bytes mean. Cerulion gives you two sources of schemas:
  • ROS 2 message types from the native_ros2_messages crate, for example sensor_msgs/Image or geometry_msgs/Vector3. Import them with use native_ros2_messages::<package>::<Type>;. These cover the common robotics message families out of the box.
  • Workspace schemas you define yourself under schemas/<Name>.yaml, created with cerulion schema create <name>, for message shapes specific to your project.
Schemas have both fixed fields (primitives written directly) and variable fields (strings and arrays). The distinction matters when you write outputs in a node; see messages and schemas.

How it runs

Putting the nouns together: you write node types, wire instances of them in a graph, and run the graph. The graph determines the topology; each node’s trigger policy determines when it fires; the runtime moves messages between instances over topics carrying typed schemas. On Unix, a live run also splits itself across worker processes — one per process group — so a crashed node takes down its group instead of the robot, with execution identical to a single process. See Run a graph across processes. Two properties of that runtime are worth understanding as benefits, even though you never configure them directly.

Zero-copy

Conceptual illustration of a camera payload branching to two local readers.
When a node writes an output, it writes once into a shared-memory slot, and subscribers read it in place; the message is not copied on its way across. The practical benefit is flat latency: moving a 16 MB camera frame between two nodes costs about the same as moving a 64-byte command, so large sensor data moves at near-hardware speed without you tuning anything. The numbers behind this are in Why Cerulion.

Determinism

Conceptual side-by-side view of the same robot pose in a recorded run and a replay.
Execution order is derived from the graph rather than from whichever thread the OS happened to schedule. A live run (--time-source real, the default) wakes within microseconds of a publish; --time-source virtual steps a deterministic clock instead, for tests and benchmarks. Either way the ordering is a property of the graph, which is what makes a recorded run re-executable: re-run your nodes against a bag and --verify byte-compares the result against what the robot actually produced. See Execution order for the levels, trigger policies, process layout, and clock choices you can inspect and control. The practical benefit is reproducibility: you debug a timing issue once and reproduce it exactly. See Record and replay a run and Why Cerulion.

Next steps

Why Cerulion

How Cerulion compares to ROS 2 and the performance story behind zero-copy.

Execution order

See how your graph determines levels, triggers, clocks, and process layout.

Define a node

Create a node type, add ports, choose a trigger policy, and write tick().

Wire and run a graph

Stage instances, wire inputs, validate, and run.

Record and replay a run

Bag a run, inspect it, and re-execute your nodes against it.

CLI reference

Every command and flag, in one place.