# Core concepts
Source: https://docs.cerulion.com/cerulion/concepts
The mental model behind Cerulion: workspaces, nodes, graphs, topics, and schemas, and how they fit together.
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](/cerulion/guides/define-a-node), then keep the
[reference](/cerulion/reference/cli) open while you build. For why Cerulion is
shaped this way, see [Why Cerulion](/cerulion/why-cerulion).
## The five nouns
The project container: a Cargo workspace with `graphs/`, `nodes/`, and
`schemas/` directories.
A unit of computation: a Rust struct annotated with `#[cerulion_node]`,
compiled to a dynamic library.
A `.yaml` file that wires node *instances* together. Topology only.
A named shared-memory channel, created automatically from your graph wiring.
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.
```mermaid theme={null}
flowchart TB
Workspace[Workspace] --> NodeTypes[Node types]
Workspace --> Schemas[Schemas]
Workspace --> Graph[Graph]
NodeTypes -. instances of .-> Graph
Graph --> A([camera])
Graph --> B([detector])
A -- camera/image --> B
```
*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 ` (or `cerulion workspace init` in place), and
most commands work by discovering the workspace around your current directory.
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 `.yaml` per graph (the wiring).
* `nodes/`: one crate per node *type* (the code).
* `schemas/`: one `.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 whose `tick()` method
is annotated with `#[cerulion_node_impl]`.
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::sensor_msgs::LaserScan;
use native_ros2_messages::geometry_msgs::Vector3;
#[cerulion_node(period_ms = 10)]
#[derive(Default)]
struct SafetyController {
#[input(lifo, depth = 1)]
scan: LaserScan,
#[output]
linear_velocity: Vector3,
}
#[cerulion_node_impl]
impl SafetyController {
fn tick(&mut self) -> Result<(), NodeError> {
let obstacle_close = self.scan.ranges().iter().any(|&r| r < 0.5);
self.linear_velocity.x = if obstacle_close { 0.0 } else { 0.3 };
Ok(())
}
}
```
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](/cerulion/reference/node-macro).
### Node type vs. node instance
This distinction is the heart of Cerulion's model.
* A **node type** is the code: the crate under `nodes//`, 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, 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](/cerulion/reference/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.
```yaml theme={null}
name: perception
prefix: robot1
nodes:
- id: camera
type: camera
outputs:
- name: image
schema: sensor_msgs/Image
- id: detector
type: detector
inputs:
- name: image
source: camera/image
outputs:
- name: detections
schema: geometry_msgs/PoseArray
```
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 `/`. 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.
You run a graph with `cerulion graph run `, which loads each instance's
compiled library, wires the topics, and drives execution. See
[wire and run a graph](/cerulion/guides/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.
* `cerulion topic echo `: print messages as they arrive.
* `cerulion topic hz `: measure the publish rate.
See [inspect topics](/cerulion/guides/inspect-topics) for the full set.
## 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::::;`. These cover the common robotics
message families out of the box.
* **Workspace schemas** you define yourself under `schemas/.yaml`, created
with `cerulion schema create `, 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](/cerulion/guides/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**.
Two properties of that runtime are worth understanding as benefits, even though
you never configure them directly.
### Zero-copy
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](/cerulion/why-cerulion).
### Determinism
Execution order is derived from the graph and driven by a simulated clock, so a
recorded run can be replayed and behaves the same way it did live. The practical
benefit is **reproducibility**: you can debug a timing issue once and reproduce it
exactly, which makes testing and CI for real-time systems far more reliable. More
on this in [Why Cerulion](/cerulion/why-cerulion).
## Next steps
How Cerulion compares to ROS 2 and the performance story behind zero-copy.
Create a node type, add ports, choose a trigger policy, and write `tick()`.
Stage instances, wire inputs, validate, and run.
Every command and flag, in one place.
# Define a node
Source: https://docs.cerulion.com/cerulion/guides/define-a-node
Create a node type with ports and a trigger policy, then write its tick() in Rust.
A node is a Rust crate with a `#[cerulion_node]` struct and a `tick()` method. This guide takes you from `cerulion node create` to a node that reads inputs, writes outputs, and is ready to stage into a graph.
For the full attribute and policy grammar, see the [node macro reference](/cerulion/reference/node-macro) and [trigger policies reference](/cerulion/reference/trigger-policies).
Run these commands from inside a workspace (a directory with a `[workspace]`
`Cargo.toml` and a `graphs/` folder). Create one with `cerulion workspace create `.
## Create the node type
`cerulion node create ` scaffolds `nodes//` with a `Cargo.toml` (cdylib) and a `src/lib.rs` macro template. The type name becomes the folder name and, PascalCased, the struct name. It must be non-empty, alphanumeric or underscore, and must not start with a digit.
Add ports while creating the node:
| Flag | Value names | Meaning |
| ------------------------ | ------------- | ------------------------------------------------------- |
| `-o` / `--output` | `SCHEMA NAME` | Add one output port. |
| `-i` / `--input` | `SCHEMA NAME` | Add one regular input port. |
| `-T` / `--trigger-input` | `SCHEMA NAME` | Add one trigger input (fires the node on data arrival). |
| `--policy` | `SPEC` | Set the trigger policy (see below). |
Each of `-o`, `-i`, and `-T` takes **two** values (a schema and a name), and
you may pass **at most one** of each per `create` call. Add more ports later
with `node modify`. Schemas accept both `sensor_msgs/Image` and
`sensor_msgs::Image`; both canonicalize to the slash form.
### Pick a trigger policy
The trigger policy decides *when* the node fires. Pass it with `--policy SPEC`:
| `--policy` SPEC | Fires when… |
| ------------------- | -------------------------------------------------------- |
| `period_ms=N` | Every N milliseconds (N > 0). |
| `sync_window_ms=N` | All trigger inputs have a message within an N-ms window. |
| `external` | The host triggers it explicitly. |
| `data_trigger=NAME` | The named trigger input receives data. |
How the policy defaults when you omit `--policy` depends on the node's inputs:
* **0 inputs (no `-i`, no `-T`):** a source-only node **must** declare a non-data policy. `cerulion node create` errors otherwise.
* **1+ inputs via `-i` only:** no node-level policy is written; the runtime fires on any input arrival and emits a graph-build warning.
* **`-T` set:** the policy becomes `data_trigger` for that trigger input.
A source-only node (zero inputs) has nothing to fire it, so you must give it an
explicit non-data policy with `--policy period_ms=N` or `--policy external`.
Combining a non-data `--policy` with `-T` is a conflict and errors.
A camera with no inputs needs a period:
```bash theme={null}
cerulion node create camera -o sensor_msgs/Image image --policy period_ms=33
```
```text theme={null}
Created node type 'camera'
```
A detector that fires whenever an image arrives:
```bash theme={null}
cerulion node create detector -T sensor_msgs/Image image -o geometry_msgs/PoseArray detections
```
```text theme={null}
Created node type 'detector'
```
## Write the tick()
Open `nodes//src/lib.rs`. The template pairs two macros:
* `#[cerulion_node(...)]` on the struct declares the node and its trigger policy, and generates the glue that lets `cerulion graph run` load it.
* `#[cerulion_node_impl]` on the adjacent `impl` block lets `tick()` use plain field access for ports — reads and writes go directly to shared memory, zero-copy. It takes no arguments, and the struct must appear before the impl block.
Import everything from the prelude, and import the message types you use:
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::geometry_msgs::Vector3;
use native_ros2_messages::sensor_msgs::LaserScan;
#[cerulion_node(period_ms = 10)] // period-driven; reads the latest scan each tick
#[derive(Default)]
struct SafetyController {
#[input(lifo, depth = 1)] // regular input (use `trigger` for data-triggered)
scan: LaserScan,
#[output] // Vector3 = fixed-only schema (pub x/y/z: f64)
linear_velocity: Vector3,
}
#[cerulion_node_impl]
impl SafetyController {
fn tick(&mut self) -> Result<(), NodeError> {
// Variable field read via typed accessor:
let obstacle_close = self.scan.ranges().iter().any(|&r| r < 0.5);
// Fixed field write; goes straight to shared memory:
self.linear_velocity.x = if obstacle_close { 0.0 } else { 0.3 };
Ok(())
}
}
```
### Fixed vs variable fields
How you write an output field depends on whether it is fixed-size or variable-length:
* **Fixed primitive fields** (for example `x`, `y`, `z`, `height`, `width`) are written directly: `self.linear_velocity.x = 0.3;`. The write goes straight to shared memory.
* **Variable-length fields** (`string`, `T[]`, nested types) must be listed in the `#[output(...)]` attribute; you then write them with plain assignment too.
List simple variable fields by name in `#[output(...)]`:
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::sensor_msgs::Image;
#[cerulion_node(period_ms = 33)]
#[derive(Default)]
struct Camera {
#[output(data, encoding)] // `data` and `encoding` are variable fields
image: Image,
}
#[cerulion_node_impl]
impl Camera {
fn tick(&mut self) -> Result<(), NodeError> {
self.image.height = 480; // fixed field, direct write
self.image.width = 640; // fixed field, direct write
self.image.encoding = "rgb8"; // variable field — plain assignment, listed in #[output]
Ok(())
}
}
```
For nested-typed variable fields, use the `complex(...)` form, for example
`#[output(data, complex(header))]`. See the
[node macro reference](/cerulion/reference/node-macro) for the full `#[output]` grammar.
**Zero-copy write for large payloads:** plain `=` assignment copies your buffer once. For
camera drivers or codecs that can write directly into a destination buffer, use `fill_from`
instead — your producer receives the destination buffer itself, skipping the copy:
```rust theme={null}
self.image.data.fill_from(|buf: &mut [u8]| {
let n = driver.read_frame_into(buf)?;
Ok(n)
})?;
```
`FillFrom` and `SliceSource` are re-exported from `cerulion_core::prelude`. See the
[node macro reference](/cerulion/reference/node-macro#fill_from-zero-copy-producer-writes) for details.
## Build the node
`cerulion node build ` compiles the crate into a cdylib. Add `--release` for an optimized build.
`node build` shells out to `cargo`, so `cargo` must be on your `PATH`.
```bash theme={null}
cerulion node build camera
cerulion node build detector
```
```text theme={null}
Built 'camera'
```
A successful build prints `Built ''`. On failure the CLI prints
`Error:` with the cargo output, and no cdylib is produced.
## Inspect and adjust
Use these commands to review and edit node types after creation.
`cerulion node list` prints a table of types with input/output counts and a short policy label.
```bash theme={null}
cerulion node list
```
```text theme={null}
TYPE INPUTS OUTPUTS POLICY
camera 0 1 period 33ms
detector 1 1 trigger:image
```
`cerulion node info ` prints the type, policy, and each port with its schema. Metadata is parsed from `src/lib.rs`; there is no sidecar file.
```bash theme={null}
cerulion node info detector
```
`cerulion node modify ` mutates `src/lib.rs` in place, preserving the tick body and comments. It takes the same `-i`, `-T`, `-o`, and `--policy` flags (at most one of each per call).
```bash theme={null}
cerulion node modify detector -i sensor_msgs/Imu imu
```
```text theme={null}
Added input 'imu' to 'detector'
```
Adding a trigger input with `-T` also sets the `data_trigger` policy. To make a node externally triggered, use `--policy external`.
`cerulion node delete ` removes the node crate and its workspace member entry.
```bash theme={null}
cerulion node delete detector
```
```text theme={null}
Deleted node type 'detector'
```
## Next steps
Stage these node types into a graph and run it.
The full `--policy` grammar and defaulting matrix.
# Inspect topics
Source: https://docs.cerulion.com/cerulion/guides/inspect-topics
Discover and watch live topics, open the TUI, and inspect recorded trace bags.
While a graph runs, every wired port becomes a topic you can observe. This guide covers listing and watching live topics, the interactive dashboard, and reading trace bag files after a run.
The `topic` commands use iceoryx2 service discovery, so they work from any
directory, no workspace required. Run them in a second terminal while a graph
is running.
## List and inspect live topics
`cerulion topic list` enumerates active topics, sorted by name.
```bash theme={null}
cerulion topic list
```
```text theme={null}
TOPIC
perception/camera/image
perception/detector/detections
```
If nothing is publishing, it prints `No active topics.`
`cerulion topic info ` prints details for a single topic:
```bash theme={null}
cerulion topic info perception/camera/image
```
## Watch messages with echo
`cerulion topic echo ` subscribes and prints each message until you press Ctrl+C.
```bash theme={null}
cerulion topic echo perception/camera/image
```
Echo pretty-prints `std_msgs/String` and `sensor_msgs/Image` messages (identified by their schema hash). For any other schema, it prints a hex preview of the payload.
## Measure publish rate
`cerulion topic hz ` measures how often a topic publishes, until Ctrl+C.
```bash theme={null}
cerulion topic hz perception/camera/image
```
For the periodic camera created with `--policy period_ms=33`, `topic hz`
should report roughly 30 Hz.
## Open the interactive dashboard
`cerulion tui` launches an interactive terminal dashboard. Logging is suppressed while the TUI is open.
```bash theme={null}
cerulion tui
```
## Inspect recorded traces
`cerulion trace inspect ` reads `trace_*.jsonl` bag files from a directory (in lexicographic order) and prints one line per record:
```text theme={null}
seq= t=ns schema=0x
```
| Flag | Value | Effect |
| ------------------ | ------- | --------------------------------------- |
| `-t` / `--filter` | `TOPIC` | Show only records for this exact topic. |
| `-n` / `--limit` | `N` | Print at most N records. |
| `-r` / `--reverse` | None | Reverse order (most recent first). |
```bash theme={null}
cerulion trace inspect ./traces -t perception/camera/image -n 20 -r
```
Tracing is built into the runtime: every graph run automatically produces
`trace_*.jsonl` bag files via the publish trace, so there's nothing extra to
start or configure. `cerulion trace inspect` reads those bags afterward.
## Clean up stale services
iceoryx2 keeps on-disk bookkeeping for each node. When a graph's topology changes between runs, stale entries from dead nodes can cause discovery errors.
`cerulion clean` removes bookkeeping for **dead** nodes only; live sibling processes are left untouched. It prints cleanup counts and per-cause remediation.
```bash theme={null}
cerulion clean
```
Run `cerulion clean` if you rewire a graph and then see stale-service errors on
the next `graph run`. It will not disturb a graph that is currently running.
## Next steps
Produce the topics you inspect here.
Understand the message shapes that echo prints.
# Messages and schemas
Source: https://docs.cerulion.com/cerulion/guides/messages-and-schemas
Use built-in ROS 2 message types in a node, and define a custom workspace schema.
Every node port carries a typed message. This guide shows how to use the built-in ROS 2 message types, how fixed and variable fields differ when you write them, and how to define your own workspace schema.
For the full type list and reference details, see the [message types reference](/cerulion/reference/message-types) and [graph and schema files reference](/cerulion/reference/graph-and-schema-files).
## Use a ROS 2 message type
Cerulion ships `native_ros2_messages`, generated from ROS 2 Jazzy `.msg` files. Import the type you need from its package module:
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::sensor_msgs::Image;
```
Reference a type as a port by giving it a field on the node struct:
```rust theme={null}
#[cerulion_node(period_ms = 33)]
#[derive(Default)]
struct Camera {
#[output(data, encoding)]
image: Image,
}
```
In a graph, schemas are written with a slash (`sensor_msgs/Image`). The colon form `sensor_msgs::Image` is also accepted and normalized to the slash form.
## Fixed vs variable fields
How you write a message field depends on its size:
* **Fixed primitive fields** (numbers, bytes, for example `height`, `width`, `is_bigendian`) live in a fixed section and are written directly. The macro derefs to that section and writes straight to shared memory.
* **Variable-length fields** (`string`, `T[]`, nested types) have no fixed size, so the macro routes them through a generated setter. You must list each one in the `#[output(...)]` attribute.
For `sensor_msgs/Image`, `height` and `width` are fixed, while `encoding` (a `string`) and `data` (a `uint8[]`) are variable:
```rust theme={null}
#[cerulion_node_impl]
impl Camera {
fn tick(&mut self) -> Result<(), NodeError> {
self.image.height = 480; // fixed field, direct shared-memory write
self.image.width = 640; // fixed field, direct shared-memory write
self.image.encoding = "rgb8"; // variable field — plain assignment, listed in #[output]
Ok(())
}
}
```
A variable field that is **not** listed in `#[output(...)]` cannot be written
through `self..`. List simple variable fields by name
(`#[output(data, encoding)]`), and nested-typed fields with the `complex(...)`
form, for example `#[output(data, complex(header))]`.
The ROS 2 primitive types map to Rust as follows (a representative subset):
| ROS 2 type | Rust type |
| ---------------- | --------- |
| `bool` | `bool` |
| `uint8` / `byte` | `u8` |
| `int32` | `i32` |
| `uint32` | `u32` |
| `float64` | `f64` |
| `string` | `String` |
| `T[]` | `Vec` |
## Define a custom schema
When no built-in type fits, define a workspace schema.
`cerulion schema create ` writes `schemas/.yaml` with a skeleton.
```bash theme={null}
cerulion schema create Reading
```
```text theme={null}
Created schema 'Reading'
```
The generated `schemas/Reading.yaml`:
```yaml theme={null}
schemas:
Reading:
description: ""
fields:
# Add fields: type name
```
Edit `schemas/Reading.yaml` to declare fields as `type name`. Use ROS 2
primitive types; `T[]` marks a variable-length array.
```yaml theme={null}
schemas:
Reading:
description: "A single sensor reading"
fields:
float64 value:
uint64 timestamp:
```
`cerulion schema info ` reports the name, description, field count, and a hash.
```bash theme={null}
cerulion schema info Reading
```
```text theme={null}
Schema: Reading
Description: A single sensor reading
Fields: 2
Hash: 0x0123456789abcdef
```
`schema info` first tries a ROS 2 `.msg` lookup for qualified names like
`sensor_msgs::Image`, printing fields, Rust types, and the minimum wire size.
If no `.msg` is found, it falls back to the workspace YAML schema and prints
the description, fields, and FNV-1a hash shown above.
Delete a schema you no longer need:
```bash theme={null}
cerulion schema delete Reading
```
```text theme={null}
Deleted schema 'Reading'
```
## Next steps
Put these message types to work on node ports.
Every available package and type.
# Wire and run a graph
Source: https://docs.cerulion.com/cerulion/guides/wire-and-run-a-graph
Create a graph, stage node instances, wire their inputs, validate, and run it.
A graph is a `.yaml` file that wires node instances together by their ports. This guide takes you from an empty graph to a running one, and covers the difference between validating and running.
The `perception` graph you build below wires a `camera` instance to a `detector` instance: the `detector`'s `image` input reads the `camera`'s `image` output, which you set up with `-I`.
```mermaid theme={null}
flowchart LR
camera[camera] -- "camera/image" --> detector[detector]
detector -- "detector/detections" --> out([detections])
```
*Figure: The example topology. `-I image [camera,image]` wires the detector's `image` input to the camera's `image` output.*
Run these commands from inside a workspace. You need built node types first;
see [Define a node](/cerulion/guides/define-a-node).
## Create a graph
`cerulion graph create ` writes `graphs/.yaml`. Pass `-n`/`--prefix` to set a topic prefix; if you omit it, the prefix line is left out and resolves to the machine's hostname at load time.
```bash theme={null}
cerulion graph create perception
```
```text theme={null}
Created graph 'perception'
```
Graph files are YAML with a `.yaml` extension and carry topology only. They
do **not** contain a `policy:` block. Trigger policies live on the node macro.
## Stage node instances
`cerulion node stage ` appends a node instance to a graph and auto-derives its outputs from the node's source metadata.
| Flag | Value names | Meaning |
| ---------------- | ------------- | ---------------------------------------------- |
| `-i` / `--id` | `ID` | Instance ID (defaults to the node type). |
| `-g` / `--graph` | `GRAPH` | Target graph. Omit to auto-select (see below). |
| `-I` | `NAME SOURCE` | Wire input `NAME` to `SOURCE`. Repeatable. |
The `-I` source accepts either `node/port` or the `[node,port]` form; both resolve to `node/port`.
When you omit `-g`, the CLI auto-selects the workspace's sole graph. If there
are zero graphs or more than one, it errors and asks you to name one.
Topic prefix is a graph-level setting: set it once on the graph with
`graph create -n`, and every staged instance inherits it. `node stage` accepts
a `-p`/`--prefix` flag for forward compatibility, but staging itself does not
alter prefix resolution.
```bash theme={null}
cerulion node stage camera -g perception
```
```text theme={null}
Staged 'camera' into graph 'perception'
```
Wire the detector's `image` input to the camera's `image` output:
```bash theme={null}
cerulion node stage detector -g perception -I image [camera,image]
```
```text theme={null}
Staged 'detector' into graph 'perception'
```
Each `node stage` validates the resulting graph and rejects duplicate instance IDs.
## Validate vs run
These two commands treat failures differently:
* `cerulion graph validate ` runs the full validation report (topology, node crates exist, ports parse, cdylibs exist, input bindings and schemas match) and **exits non-zero** if any check fails.
* `cerulion graph run ` only **warns** on validation problems and continues. Use `validate` as a gate in scripts or CI.
```bash theme={null}
cerulion graph validate perception
```
`graph validate` prints a report and exits `0` when every check passes. A
non-zero exit means at least one check failed; read the report to see which.
## Run the graph
`cerulion graph run ` loads the compiled cdylibs, builds the runtime, and runs the graph until you stop it with Ctrl+C or a node requests shutdown. In the default live mode (`--time-source real`) it wakes and processes messages as they arrive.
```bash theme={null}
cerulion graph run perception
```
`graph run` loads compiled cdylibs and needs `cargo` available, so build your
nodes first. It runs an iceoryx2 dead-node cleanup at start.
### Clock and validation flags
| Flag | Default | Effect |
| ----------------------- | --------- | -------------------------------------------------------------------------------- |
| `--time-source real` | (default) | Live mode — the graph wakes and processes messages as they arrive. |
| `--time-source virtual` | — | Deterministic mode — the clock steps 1 ms per tick, for replay and benchmarking. |
| `--no-validate` | off | Skip the pre-run validation check entirely. |
The default is live mode. To run deterministically (for replay or benchmarking), pass `--time-source virtual`:
```bash theme={null}
cerulion graph run perception --time-source virtual
```
Press Ctrl+C to stop a running graph.
## Smoke-test a single node
`cerulion node run ` runs one node in a hidden temporary graph in live mode (validation skipped) and deletes that temp graph on exit. It is a quick way to check that a node loads and ticks without wiring a full graph.
```bash theme={null}
cerulion node run camera
```
Press Ctrl+C to stop it.
If you change a graph's topology between runs (add, remove, or rewire nodes)
and hit stale iceoryx2 service errors, run `cerulion clean` to clear
bookkeeping for dead nodes, then run again.
## Next steps
Watch the running graph with `topic echo`, `hz`, and the TUI.
Add or adjust the node types you stage here.
# Installation
Source: https://docs.cerulion.com/cerulion/installation
Get closed-alpha access to Cerulion, then install the CLI from source and verify it works.
Cerulion is in **closed alpha** — curated early access for teams building
high-performance robotics. This page shows how to get access and, once you have
it, get the `cerulion` CLI building and verified on your machine.
Cerulion runs on **Linux and macOS**.
## Get access
During the closed alpha, we onboard teams directly so you start with the right
setup and a fast path to your first running graph. Book a 15-minute call and the
team will set you up with the source and walk you through it.
Tell us about your use case and we'll get you access to Cerulion and up and
running fast.
The prerequisites and install steps below use the Cerulion source you'll
receive during onboarding. Once you have access, continue here.
## Prerequisites
Rust **1.88 or later** (stable), with `cargo`. Install via
[rustup](https://rustup.rs).
Required at runtime. `cerulion node build` shells out to `cargo`, and
`cerulion graph run` loads the compiled node libraries it produces.
`cargo` must be on your `PATH` even after installation. The CLI shells out to
`cargo build` when you build a node, and it loads the resulting compiled
libraries (`.dylib` on macOS, `.so` on Linux) when you run a graph. Without
`cargo`, those commands fail.
Install the Rust toolchain with rustup if you do not already have it:
```bash theme={null}
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
Confirm `cargo` is available:
```bash theme={null}
cargo --version
```
## Install the CLI
Once you have access to the Cerulion repository, build the CLI from source and
install the `cerulion` binary onto your `PATH`. Building from source gives you
the full toolchain in a single `cargo install --path` step; a one-line installer
(Homebrew / `curl | sh`) is coming as Cerulion opens up.
Clone the Cerulion repository to your machine. You should have the workspace
that contains the `cerulion_cli` crate.
From the repository root, build and install the CLI with `cargo`:
```bash theme={null}
cargo install --path cerulion_cli
```
This compiles the `cerulion_cli` crate and places the `cerulion` binary in
your Cargo bin directory (`~/.cargo/bin`), which rustup adds to your `PATH`.
Open a new shell so the updated `PATH` takes effect, then check that the
binary resolves:
```bash theme={null}
which cerulion
```
Generated node crates depend on `cerulion_core` and `native_ros2_messages`,
which the CLI resolves relative to the Cerulion source tree: an absolute path
to your checkout when it can be located, otherwise a sibling `../cerulion_core`
(with a warning). Create your workspaces near the source checkout so node
crates resolve their dependencies and build cleanly. Packaged dependencies are
on the way as Cerulion opens up.
## Verify the installation
Confirm everything is wired up in three quick checks: print the version, list
the available commands, and scaffold a throwaway workspace.
```bash theme={null}
cerulion --version
```
Confirm the CLI runs and prints its help:
```bash theme={null}
cerulion --help
```
You should see the top-level commands: `workspace`, `node`, `graph`,
`topic`, `schema`, `tui`, `trace`, and `clean`.
Create a scratch workspace to confirm the CLI can scaffold a project:
```bash theme={null}
cerulion workspace create install_check
```
Expected output:
```text theme={null}
Created workspace at install_check
```
`cerulion --version` printed a version, `cerulion --help` listed the commands,
and `cerulion workspace create install_check` created a new workspace. Your
installation is working. You can delete the `install_check` directory once you
are satisfied.
## Next steps
Build and run your first two-node graph end to end.
Understand workspaces, nodes, graphs, topics, and schemas.
# Quickstart
Source: https://docs.cerulion.com/cerulion/quickstart
Build and run a two-node Cerulion graph from scratch, then watch its messages live with topic echo.
## What you'll build
In this tutorial you build a complete two-node graph:
* A **periodic publisher** (`sensor`) that emits a `sensor_msgs/Image` on a
fixed interval.
* A **data-triggered consumer** (`detector`) that fires each time a new image
arrives and publishes a `geometry_msgs/PoseArray` of detections.
You will wire them into a graph, run it, and watch the messages flow with
`cerulion topic echo` in a second terminal.
```mermaid theme={null}
flowchart LR
sensor[sensor] -- "sensor_msgs/Image" --> detector[detector]
detector -- "geometry_msgs/PoseArray" --> out([detections])
```
*Figure: The two-node graph you build. The periodic `sensor` node publishes a `sensor_msgs/Image`; the data-triggered `detector` fires on each image and publishes a `geometry_msgs/PoseArray`.*
This guide assumes the `cerulion` CLI is installed and `cargo` is on your
`PATH`. If not, follow [Installation](/cerulion/installation) first.
## Build it step by step
A workspace is the project container that holds your nodes, graphs, and
schemas.
```bash theme={null}
cerulion workspace create my_robot && cd my_robot
```
Expected output:
```text theme={null}
Created workspace at my_robot
```
Create a `sensor` node with one output port named `image` carrying a
`sensor_msgs/Image`. Because it has no inputs, a source-only node must
declare a non-data trigger policy; here, a 33 ms period.
```bash theme={null}
cerulion node create sensor -o sensor_msgs/Image image --policy period_ms=33
```
Expected output:
```text theme={null}
Created node type 'sensor'
```
The period is set with `--policy period_ms=33`. Note the underscore and
the `=`.
Create a `detector` node whose `image` input is a **trigger** (`-T`), so the
node fires whenever an image arrives. Give it an output named `detections`
carrying a `geometry_msgs/PoseArray`.
```bash theme={null}
cerulion node create detector -T sensor_msgs/Image image -o geometry_msgs/PoseArray detections
```
Expected output:
```text theme={null}
Created node type 'detector'
```
`-T` and `-o` each take **two** values in the order `SCHEMA NAME`. The
trigger input also sets the node's policy to fire on that input's data.
Open `nodes/sensor/src/lib.rs` and replace its contents with the node below.
Each tick stamps the image dimensions and bumps a frame counter.
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::sensor_msgs::Image;
#[cerulion_node(period_ms = 33)]
#[derive(Default)]
struct SensorNode {
#[output(data, encoding)]
image: Image,
frame_count: u32,
}
#[cerulion_node_impl]
impl SensorNode {
fn tick(&mut self) -> Result<(), NodeError> {
self.frame_count += 1;
self.image.height = 480;
self.image.width = 640;
self.image.encoding = "rgb8";
Ok(())
}
}
```
`height` and `width` are fixed fields, written straight to shared memory.
`encoding` is a variable-length field, so it is listed in
`#[output(data, encoding)]` — once listed, plain assignment works for it
too.
Open `nodes/detector/src/lib.rs` and replace its contents with the node
below. Each tick reads the latest image dimensions and counts a detection.
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::sensor_msgs::Image;
use native_ros2_messages::geometry_msgs::PoseArray;
#[cerulion_node]
#[derive(Default)]
struct DetectorNode {
#[input(trigger)]
image: Image,
#[output]
detections: PoseArray,
detections_seen: u32,
}
#[cerulion_node_impl]
impl DetectorNode {
fn tick(&mut self) -> Result<(), NodeError> {
let pixels = self.image.height * self.image.width;
if pixels > 0 {
self.detections_seen += 1;
}
Ok(())
}
}
```
The `#[input(trigger)]` attribute on `image` is what makes `detector` fire
on each incoming image. The node-level macro has no policy attribute
because the trigger comes from the field.
Compile each node into a loadable library. These commands shell out to
`cargo`.
```bash theme={null}
cerulion node build sensor
cerulion node build detector
```
Expected output:
```text theme={null}
Built 'sensor'
Built 'detector'
```
Create an empty graph named `perception` with an explicit topic prefix of
`perception`. The `-n`/`--prefix` flag fixes the prefix so the topic names
are predictable; without it, the prefix would default to your machine's
hostname.
```bash theme={null}
cerulion graph create perception -n perception
```
Expected output:
```text theme={null}
Created graph 'perception'
```
Cerulion composes each topic name as `{prefix}/{node_id}/{output_name}`.
With the prefix `perception`, the `sensor` instance's `image` output
publishes to `perception/sensor/image`.
Add an instance of each node to the graph. For `detector`, wire its `image`
input to the `sensor` instance's `image` output with `-I`.
```bash theme={null}
cerulion node stage sensor -g perception
cerulion node stage detector -g perception -I image [sensor,image]
```
Expected output:
```text theme={null}
Staged 'sensor' into graph 'perception'
Staged 'detector' into graph 'perception'
```
`-I` takes the input port name followed by its source. The `[sensor,image]`
form means "the `image` output of the node instance `sensor`."
Run the graph. The default is live mode — the graph wakes and processes
messages as they arrive. The run continues until you stop it with `Ctrl+C`.
```bash theme={null}
cerulion graph run perception
```
Leave this terminal running.
Open a new terminal. Topic discovery needs no workspace. List the active
topics, then echo the one carrying images:
```bash theme={null}
cerulion topic list
```
```bash theme={null}
cerulion topic echo perception/sensor/image
```
`topic echo` pretty-prints `sensor_msgs/Image`, so you will see the image
dimensions and encoding update as frames arrive. Stop echoing with `Ctrl+C`.
Because you set the prefix to `perception`, the topic is
`perception/sensor/image`. Always run `cerulion topic list` first to
confirm the exact names before echoing.
You should see image messages streaming in the echo terminal while the graph
runs. Your two-node graph is live: `sensor` publishes images on a 33 ms period
and `detector` fires on each one. Press `Ctrl+C` in the run terminal to stop
the graph.
## Next steps
The mental model behind workspaces, nodes, graphs, topics, and schemas.
Task-focused walkthroughs for defining nodes and wiring graphs.
Every command and flag, with synopses and examples.
# CLI reference
Source: https://docs.cerulion.com/cerulion/reference/cli
Complete reference for every cerulion command, subcommand, flag, and argument.
The `cerulion` binary groups all functionality under top-level commands: `workspace`, `node`, `graph`, `topic`, `schema`, `tui`, `trace`, and `clean`.
Synopsis notation uses ``, `[optional]`, `{a|b}` (choice), and `...` (repeatable). Synopsis lines are not directly runnable; each command includes a separate runnable example.
## Global
```text theme={null}
cerulion [--verbose] [args...]
```
| Flag | Long | Type | Default | Meaning |
| ------- | ----------- | ---- | ------- | ------------------------------------------------------------------------------------------ |
| verbose | `--verbose` | bool | false | Enable debug-level logging. Long form only; there is no `-v`. Applies to every subcommand. |
On error, `cerulion` prints `Error: ` to stderr and exits with a failure code.
Most commands require a workspace, discovered by walking upward for a `Cargo.toml` containing `[workspace]` plus a `graphs/` directory. `workspace create`/`init`, `tui`, `trace`, and `clean` do not require discovery. `topic *` uses iceoryx2 discovery and needs no workspace.
## workspace
Create a new workspace directory.
```text theme={null}
cerulion workspace create
```
| Arg / flag | Value name | Type | Default | Meaning |
| ----------------- | ---------- | ------ | ------- | ------------------------------------------------------- |
| name (positional) | `` | String | | Required. Workspace directory to create at `.//`. |
Creates `.//` with `graphs/`, `nodes/`, `schemas/`, `Cargo.toml` (`[workspace]`, `members=["nodes/*"]`, `resolver="2"`, workspace dependencies for `cerulion_core` and `native_ros2_messages`), and `.cargo/config.toml`. Errors if `./` already exists. Prints `Created workspace at `.
```bash theme={null}
cerulion workspace create my_robot
```
Initialize a workspace in place.
```text theme={null}
cerulion workspace init [location]
```
| Arg / flag | Value name | Type | Default | Meaning |
| --------------------- | ------------ | ------ | ------- | --------------------------------------------------------------------- |
| location (positional) | `[location]` | String | `.` | Directory to initialize in place (defaults to the current directory). |
Initializes a workspace at `location`. Errors if a `Cargo.toml` with `[workspace]` already exists there. The workspace name is the directory's file name (fallback `cerulion_ws`). Prints `Initialized workspace at `.
```bash theme={null}
cerulion workspace init .
```
## node
Subcommands: `create`, `delete`, `modify`, `build`, `stage`, `run`, `list`, `info`. All require workspace discovery.
Create a node type.
```text theme={null}
cerulion node create [-o SCHEMA NAME] [-i SCHEMA NAME] [-T SCHEMA NAME] [--policy SPEC] [--raw-ffi]
```
| Flag | Short/Long | Value names | Type | Default | Meaning |
| ------------- | ------------------------ | ------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| node type | (positional) | `` | String | | Required. Becomes folder `nodes//` and (PascalCased) the struct name. Validated: non-empty, alphanumeric/underscore, must not start with a digit. |
| output port | `-o` / `--output` | `SCHEMA NAME` | 2 values | | Add one output port. Both SCHEMA and NAME required. At most one `-o` per `create`. |
| input port | `-i` / `--input` | `SCHEMA NAME` | 2 values | | Add one regular input. Both required. At most one `-i` per `create`. |
| trigger input | `-T` / `--trigger-input` | `SCHEMA NAME` | 2 values | | Add a trigger input → field emitted as `#[input(trigger)]`, policy set to `data_trigger=`. Both required. At most one `-T` per node. |
| policy | `--policy` | `SPEC` | String | (defaulting rules) | Trigger policy. See [Trigger policies](/cerulion/reference/trigger-policies). |
| raw FFI | `--raw-ffi` | | bool | false | Generate the raw `extern "C"` FFI template instead of the `#[cerulion_node]` macro template. |
Schema is accepted in both `sensor_msgs/Image` (slash, canonical) and `sensor_msgs::Image` (colon) forms; canonicalized to slash. Output prints `Created node type ''`.
Generated files: `nodes//Cargo.toml` (cdylib, `cdylib` feature default) and `nodes//src/lib.rs` (macro or raw-FFI). Nothing is added to the workspace `Cargo.toml` if it already uses the `"nodes/*"` glob (the default).
Validation errors (clean messages, no panic): passing a flag twice; `-T` name colliding with `-i` name; `--policy data_trigger=NAME` not matching a declared input; a source-only node (no `-i`/`-T`) without an explicit non-data `--policy`.
```bash theme={null}
cerulion node create detector -T sensor_msgs/Image image -o geometry_msgs/PoseArray detections
```
Delete a node type.
```text theme={null}
cerulion node delete
```
| Arg | Value name | Type | Meaning |
| ---------------------- | ------------- | ------ | ------------------------------ |
| node type (positional) | `` | String | Required. Node type to delete. |
Removes `nodes//` (recursive) and the workspace member entry. Errors `NodeNotFound` if absent. Prints `Deleted node type ''`.
```bash theme={null}
cerulion node delete detector
```
Modify an existing node type in place.
```text theme={null}
cerulion node modify [-i SCHEMA NAME] [-T SCHEMA NAME] [-o SCHEMA NAME] [--policy SPEC]
```
| Flag | Short/Long | Value names | Notes |
| ------------- | ------------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| node type | (positional) | `` | Required. Node type to modify. |
| input port | `-i` / `--input` | `SCHEMA NAME` | Add a regular input. At most one per call. |
| trigger input | `-T` / `--trigger-input` | `SCHEMA NAME` | Add a trigger input (`#[input(trigger)]`) and set policy `data_trigger=`, clearing conflicting node-level policy args. At most one per call. |
| output port | `-o` / `--output` | `SCHEMA NAME` | Add an output. At most one per call. |
| policy | `--policy` | `SPEC` | Set/replace the trigger policy. `data_trigger=NAME` requires NAME to be an existing input or one added in the same call. |
Mutates the node's `src/lib.rs` in place (preserves the tick body, comments, and other fields). `-T` and `--policy data_trigger=NAME` are equivalent; supplying both requires they agree. Prints per-action lines, e.g. `Added input 'image' to 'detector' as the data trigger`, `Set policy=period_ms=33 on 'detector'`, `Promoted existing input 'x' to data trigger on 'detector'`.
The only user surface for setting external triggering is `--policy external`.
```bash theme={null}
cerulion node modify detector -i sensor_msgs/Imu imu --policy period_ms=33
```
Build a node crate.
```text theme={null}
cerulion node build [--release]
```
| Flag | Long | Type | Default | Meaning |
| --------- | ------------ | ------------- | ------- | ----------------------------- |
| node type | (positional) | `` | | Required. Node type to build. |
| release | `--release` | bool | false | Build in release mode. |
Runs `cargo build -p ` (adds `--release` if set) in the workspace root. Errors `BuildFailed` (with cargo stderr) on failure. Prints `Built ''`.
Requires `cargo` on PATH; the CLI shells out to it.
```bash theme={null}
cerulion node build detector --release
```
Append a node instance to a graph YAML file.
```text theme={null}
cerulion node stage [-i ID] [-g GRAPH] [-p PREFIX] [-I NAME SOURCE]...
```
| Flag | Short/Long | Value names | Type | Default | Meaning |
| ------------- | ----------------- | ------------- | -------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
| node type | (positional) | `` | String | | Required. Node type to stage. |
| id | `-i` / `--id` | | Option\ | node\_type | Node instance ID (defaults to the node type). |
| graph | `-g` / `--graph` | | Option\ | auto | Target graph. If omitted: auto-selects the sole graph; errors if 0 or more than 1 exist. |
| prefix | `-p` / `--prefix` | | Option\ | | Accepted for forward compatibility; topic prefix resolution lives on the graph, so this flag has no effect here. |
| input binding | `-I` | `NAME SOURCE` | 2 values, repeatable | | Wire input `NAME` to `SOURCE`. `[node,port]` form → `node/port`; `node/port` passes through. |
Outputs (with schemas) are auto-derived from the node's source metadata. A duplicate node ID is an error. The modified graph is validated. Prints `Staged '' into graph ''`.
```bash theme={null}
cerulion node stage detector -g perception -I image [camera,image]
```
Smoke-run a single node.
```text theme={null}
cerulion node run [-p PREFIX] [-i ID] [--release] [--no-cpu-dma-lock] [--no-monitor-wait]
```
| Flag | Short/Long | Type | Default | Meaning |
| --------------- | ------------------- | --------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| node type | (positional) | String | | Required. Node type to run. |
| prefix | `-p` / `--prefix` | Option\ | `standalone` | Topic prefix. |
| id | `-i` / `--id` | Option\ | node\_type | Instance ID. |
| release | `--release` | bool | false | Force the release-profile cdylib (`target/release`). Without this flag the freshest built cdylib between `target/debug` and `target/release` is auto-selected by modification time — the profile you most recently built wins. |
| no-cpu-dma-lock | `--no-cpu-dma-lock` | bool | false | Disable the automatic CPU C-state cap (Linux only). Pass this on laptops or power-sensitive dev boxes. See `graph run` for environment variable overrides. |
| no-monitor-wait | `--no-monitor-wait` | bool | false | Opt out of the live-loop CPU park (Linux only). By default on Linux x86\_64 and Linux aarch64 in live mode, Cerulion uses a per-core CPU park to reduce idle wake latency; when the park is active, the graph-derived auto C-state cap is not applied. Pass this to disable it. See `graph run` for environment variable overrides. |
Creates a hidden temp graph `__temp_`, stages the node, runs it in live mode (validation skipped), and deletes the temp graph YAML on exit. Ctrl+C stops it.
```bash theme={null}
cerulion node run detector -p standalone
```
List node types.
```text theme={null}
cerulion node list
```
No arguments. Prints a table with columns `TYPE INPUTS OUTPUTS POLICY` (counts plus a short policy label). Empty workspace prints `No nodes found.`
```bash theme={null}
cerulion node list
```
Show details for a node type.
```text theme={null}
cerulion node info
```
| Arg | Value name | Type | Meaning |
| ---------------------- | ------------- | ------ | ------------------------------- |
| node type (positional) | `` | String | Required. Node type to inspect. |
Prints `Node type`, `Policy`, and `Inputs:`/`Outputs:` (name plus schema or `(untyped)`). Metadata is parsed from `src/lib.rs`; the source is the single source of truth.
Policy short labels: `period ms`, `deadline ms`, `sync ms`, `sync ∞` (unbounded), `external`, `trigger:`, `-` (none).
```bash theme={null}
cerulion node info detector
```
## graph
Subcommands: `create`, `run`, `validate`, `list`.
Create a graph file.
```text theme={null}
cerulion graph create [-n PREFIX]
```
| Flag | Short/Long | Type | Default | Meaning |
| ------ | ----------------- | --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| name | (positional) | String | | Required. Graph name → `graphs/.yaml`. |
| prefix | `-n` / `--prefix` | Option\ | | Topic prefix. If omitted, the `prefix:` line is omitted from the file and resolved to the hostname (without `.local`) at load time. |
Creates `graphs/.yaml`. Errors if the graph already exists. Prints `Created graph ''`.
```bash theme={null}
cerulion graph create perception -n robot1
```
Run a graph.
```text theme={null}
cerulion graph run [--time-source CLOCK] [--no-validate] [--release] [--no-cpu-dma-lock] [--no-monitor-wait]
```
| Flag | Form | Default | Meaning |
| --------------- | --------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| name | (positional) | | Required. Graph to run. |
| time-source | `--time-source real\|virtual\|external` | `real` | Clock mode. `real`: live event-driven loop — wakes on each incoming message. `virtual`: deterministic 1 ms step loop for replay and benchmarking. `external`: accepted but not yet functional. |
| no validate | `--no-validate` | off | Skip pre-run workspace validation. |
| release | `--release` | off | Force release-profile cdylibs (`target/release`). Without this flag the freshest built cdylib between `target/debug` and `target/release` is auto-selected by modification time — the profile you most recently built wins. The pre-run validation uses the same mode. |
| no-cpu-dma-lock | `--no-cpu-dma-lock` | off | Disable the automatic CPU C-state cap (Linux only). No effect under `--time-source virtual`. Pass this on laptops or power-sensitive dev boxes. |
| no-monitor-wait | `--no-monitor-wait` | off | Opt out of the live-loop CPU park (Linux only). By default on Linux x86\_64 and Linux aarch64 in live mode, Cerulion uses a per-core CPU park to reduce idle wake latency. When the park is active, the graph-derived auto C-state cap is not applied. Pass this to disable the park. No effect under `--time-source virtual`. |
Loads cdylibs, builds the runtime, and runs the graph until Ctrl+C or a node requests shutdown. In the default live mode (`--time-source real`) the graph wakes and processes messages as they arrive. Validation failures only warn and continue; they do not block `run`.
In live mode, Cerulion automatically applies a CPU C-state cap for timing-sensitive graphs to reduce wake latency (Linux only; best-effort — warns on failure if the cap cannot be acquired). Pass `--no-cpu-dma-lock` to disable it. Environment variable overrides: `CERULION_CPU_DMA_LOCK=1` forces a full C0 pin; `CERULION_CPU_DMA_LOCK=0` disables the cap; `CERULION_CPU_DMA_LOCK_US=N` sets an explicit N-µs cap. The `--no-cpu-dma-lock` flag takes precedence over all env vars.
On Linux x86\_64 and Linux aarch64 in live mode, Cerulion uses a per-core CPU park to reduce idle wake latency. When the park is active, the graph-derived auto C-state cap is not applied (an explicit `CERULION_CPU_DMA_LOCK=1` or `CERULION_CPU_DMA_LOCK_US=N` override still takes effect). To opt out of the park entirely, pass `--no-monitor-wait`. Environment variable overrides (only honored when `--no-monitor-wait` is absent): `CERULION_MONITOR_WAIT=1` force-enables the park; `CERULION_MONITOR_WAIT=0` disables it; any other non-empty value warns and uses the auto default. `CERULION_DOORBELL=1` enables waking on incoming data as well as on the timer deadline; `CERULION_DOORBELL=0` restricts to timer-only wakes; any other non-empty value warns and uses the auto default.
`CERULION_FIRE_THREADS=N` sets the number of threads used for within-level parallel node execution (must be a positive integer; if unset or invalid, auto-sizes to `min(available_parallelism, max_level_width)`; read once at graph build). `CERULION_LIVE_SPIN_US=N` controls how long (µs) the live loop busy-waits for an incoming message before yielding the core (`--time-source real`/`external` only; no effect with `virtual`). `=0` disables busy-waiting; `=N` (N > 0) caps it at N µs (max 100,000; larger values are clamped with a warning). When unset, the duration is auto-sized to the graph's timing requirements — a tight-period graph polls briefly, a quiescent graph yields immediately. `graph run` also runs an iceoryx2 dead-node cleanup at start.
`run` loads compiled cdylibs from the cargo target directory. The default is `target/{debug,release}/` in the workspace root. If `CARGO_TARGET_DIR` is set in your shell, both `cerulion node build` and `cerulion graph run` use it, so a shared or custom target directory works without any extra flags.
```bash theme={null}
# Default: live mode
cerulion graph run perception
# Deterministic mode (replay / benchmarking)
cerulion graph run perception --time-source virtual
```
Validate a graph.
```text theme={null}
cerulion graph validate [--release]
```
| Flag | Form | Type | Default | Meaning |
| ------- | ------------ | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | (positional) | String | | Required. Graph to validate. |
| release | `--release` | bool | false | Force release-profile cdylibs (`target/release`) when resolving node libraries. Without this flag the freshest built cdylib between `target/debug` and `target/release` is auto-selected by modification time. The report shows which profile was found. |
Runs the validation report (topology, node crate exists, ports parse, cdylib exists, input bindings and schema match, data\_trigger bindings). Prints the report and exits non-zero if any check fails.
```bash theme={null}
cerulion graph validate perception
```
List graphs.
```text theme={null}
cerulion graph list
```
No arguments. Lists graph names (file stems of `*.yaml`), sorted. Empty prints `No graphs found.`
```bash theme={null}
cerulion graph list
```
## topic
Uses iceoryx2 service discovery: enumerates services ending in `/data` (Cerulion creates `{topic}/data` and `{topic}/event` per topic). No workspace required.
List active topics.
```text theme={null}
cerulion topic list
```
Lists active topic names (sorted). Header `TOPIC`. Empty prints `No active topics.`
```bash theme={null}
cerulion topic list
```
Show topic info.
```text theme={null}
cerulion topic info
```
| Arg | Value name | Type | Meaning |
| ------------------ | ---------- | ------ | --------------------------- |
| topic (positional) | `` | String | Required. Topic to inspect. |
Prints the topic info string.
```bash theme={null}
cerulion topic info robot1/camera/image
```
Print messages from a topic.
```text theme={null}
cerulion topic echo
```
| Arg | Value name | Type | Meaning |
| ------------------ | ---------- | ------ | -------------------------------- |
| topic (positional) | `` | String | Required. Topic to subscribe to. |
Subscribes and prints messages until Ctrl+C. Pretty-prints `std_msgs/String` and `sensor_msgs/Image` (by FNV-1a schema hash); hex preview otherwise.
```bash theme={null}
cerulion topic echo robot1/camera/image
```
Measure publish rate.
```text theme={null}
cerulion topic hz
```
| Arg | Value name | Type | Meaning |
| ------------------ | ---------- | ------ | --------------------------- |
| topic (positional) | `` | String | Required. Topic to measure. |
Measures the publish rate until Ctrl+C.
```bash theme={null}
cerulion topic hz robot1/camera/image
```
## schema
Subcommands: `create`, `delete`, `info`.
Create a schema file.
```text theme={null}
cerulion schema create
```
| Arg | Value name | Type | Meaning |
| ----------------- | ---------- | ------ | ---------------------------------------------------- |
| name (positional) | `` | String | Required. Schema name → `schemas/.yaml`. |
Creates `schemas/.yaml` with a `schemas:` skeleton (description plus a `fields:` comment). Errors if the schema exists. Prints `Created schema ''`.
```bash theme={null}
cerulion schema create Reading
```
Delete a schema file.
```text theme={null}
cerulion schema delete
```
| Arg | Value name | Type | Meaning |
| ----------------- | ---------- | ------ | --------------------------- |
| name (positional) | `` | String | Required. Schema to delete. |
Removes `schemas/.yaml`. Errors `SchemaNotFound`. Prints `Deleted schema ''`.
```bash theme={null}
cerulion schema delete Reading
```
Show schema details.
```text theme={null}
cerulion schema info
```
| Arg | Value name | Type | Meaning |
| ----------------- | ---------- | ------ | ------------------------------------------------------------------------------ |
| name (positional) | `` | String | Required. Schema name. Accepts qualified ROS2 names like `sensor_msgs::Image`. |
First tries a ROS2 `.msg` lookup at `../native_ros2_messages/msg//.msg` (for qualified names); prints fields, Rust types, and min wire size. Falls back to a workspace YAML schema (prints `Schema`, `Description`, `Fields`, `Hash: 0x...`).
```bash theme={null}
cerulion schema info sensor_msgs::Image
```
## tui
```text theme={null}
cerulion tui
```
Launches the interactive ratatui dashboard. Logging is suppressed in TUI mode.
```bash theme={null}
cerulion tui
```
## trace inspect
```text theme={null}
cerulion trace inspect [-t TOPIC] [-n N] [-r]
```
| Flag | Short/Long | Value name | Type | Default | Meaning |
| ------- | ------------------ | ---------- | --------------- | ------- | ------------------------------------------------- |
| dir | (positional) | `` | String | | Required. Directory of `trace_*.jsonl` bag files. |
| filter | `-t` / `--filter` | `TOPIC` | Option\ | | Filter by exact topic. |
| limit | `-n` / `--limit` | `N` | Option\ | | Limit the number of records. |
| reverse | `-r` / `--reverse` | | bool | false | Reverse order (most-recent first). |
Reads `trace_*.jsonl` bag files (lexicographic order) and prints ` seq= t=ns schema=0x` per record.
```bash theme={null}
cerulion trace inspect ./traces -t robot1/camera/image -n 100 -r
```
## clean
```text theme={null}
cerulion clean
```
Removes iceoryx2 on-disk bookkeeping for dead nodes only (live sibling processes are untouched). Prints cleanup counts plus per-cause remediation.
```bash theme={null}
cerulion clean
```
# Graph and schema files
Source: https://docs.cerulion.com/cerulion/reference/graph-and-schema-files
Exact YAML field specifications for graph files and schema files.
Workspaces store graphs in `graphs/.yaml` and schemas in `schemas/.yaml`. Both are YAML.
## Graph YAML
There is no `policy:` block in graph YAML; trigger policy lives only on the macro side.
```yaml theme={null}
name: perception # required
prefix: robot1 # optional; omitted line => default = hostname without ".local"
nodes:
- id: camera # required, unique instance ID
type: camera # required, node type = folder name (YAML key is `type`)
inputs: # optional
- name: image # input port name (matches #[input] field)
source: camera/image # "/"
outputs: # optional
- name: image # output port name (matches #[output] field)
schema: sensor_msgs/Image # optional; "::" auto-normalized to "/"
max_slice_len: 6291456 # optional usize; runtime resolves if omitted (final fallback 16 MiB)
history_size: 2 # optional usize, default 0 (volatile; >0 = transient-local replay depth)
```
### Top-level fields (`GraphConfig`)
| Field | Type | Required | Default | Meaning |
| -------- | -------------------- | -------- | ------------------------- | ----------------------------------------------------------------- |
| `name` | String | Yes | | Graph name. |
| `prefix` | String | No | hostname without `.local` | Topic prefix. Omitted line resolves to the hostname at load time. |
| `nodes` | list of node entries | No | empty | Node instances in the graph. |
### Node entry fields (`NodeDef`)
| Field | YAML key | Type | Required | Default | Meaning |
| ----------- | --------- | ------ | -------- | ------- | --------------------------------------------------------------- |
| `id` | `id` | String | Yes | | Unique instance ID. |
| `node_type` | `type` | String | Yes | | Node type = folder name. The YAML key is `type` (serde rename). |
| `inputs` | `inputs` | list | No | empty | Input bindings. |
| `outputs` | `outputs` | list | No | empty | Output ports. |
### Input fields (`InputDef`)
| Field | Type | Required | Meaning |
| -------- | ------ | -------- | ----------------------------------------------- |
| `name` | String | Yes | Input port name (matches the `#[input]` field). |
| `source` | String | Yes | `/`. |
### Output fields (`OutputDef`)
| Field | Type | Required | Default | Meaning |
| --------------- | ------ | -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | String | Yes | | Output port name (matches the `#[output]` field). |
| `schema` | String | No | empty | Schema name (`::` is normalized to `/`). Should match the output type declared in the node's macro. If it doesn't, `cerulion graph run` emits a warning and ignores this field for buffer sizing. |
| `max_slice_len` | usize | No | (runtime-resolved; final fallback 16 MiB) | Max slice length. Skipped from output when none. |
| `history_size` | usize | No | 0 | 0 = volatile; > 0 = transient-local replay depth. |
A reference fixture (`perception` graph with nodes camera/detector/imu/fusion/tracker/diagnostics, topology only) exists at `cerulion_core/fixtures/test_graph.yaml`.
## Schema YAML
`cerulion schema create Reading` writes `schemas/Reading.yaml` with a skeleton:
```yaml theme={null}
schemas:
Reading:
description: ""
fields:
# Add fields: type name
```
A populated schema the parser accepts:
```yaml theme={null}
schemas:
Reading:
description: "A single sensor reading"
fields:
float64 value:
uint64 timestamp:
```
The top-level `schemas:` map holds one entry per schema name; each entry has a `description` string and a `fields:` map keyed by `type name`.
### Schema info hash
`cerulion schema info` reports the schema name, description, field count, and `Hash: 0x<16-hex>`. The hash is the FNV-1a hash of the schema name.
# Message types
Source: https://docs.cerulion.com/cerulion/reference/message-types
The native_ros2_messages packages, representative types, the ROS2-to-Rust type mapping, and the generated type triple.
Cerulion ships ROS2-compatible message types in the `native_ros2_messages` crate, generated from ROS2 Jazzy `.msg` files at build time. There are 20 packages.
Import a type from its package:
```rust theme={null}
use native_ros2_messages::sensor_msgs::Image;
```
## Packages and representative types
The following table lists the packages with representative types from each. Type names are the PascalCase form of the source `.msg` file.
| Package | Representative types |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action_msgs` | Action-related message types. |
| `builtin_interfaces` | Time, Duration. |
| `control_msgs` | JointTrajectoryControllerState, DynamicJointState, JointJog, PidState, GripperCommand. |
| `diagnostic_msgs` | Diagnostic message types. |
| `geometry_msgs` | Point, Point32, Pose, Pose2D, PoseArray, Quaternion, Transform, Twist, Vector3, Accel, Inertia, Polygon. |
| `grid_map_msgs` | GridMap, GridMapInfo. |
| `moveit_msgs` | CollisionObject, AttachedCollisionObject, DisplayTrajectory, Constraints, Grasp. |
| `nav_msgs` | Odometry, Path, OccupancyGrid, MapMetaData, GridCells. |
| `object_recognition_msgs` | RecognizedObject, RecognizedObjectArray, ObjectInformation. |
| `octomap_msgs` | Octomap, OctomapWithPose. |
| `radar_msgs` | RadarReturn, RadarScan, RadarTrack, RadarTracks. |
| `sensor_msgs` | Image, Imu, LaserScan, JointState, PointCloud2, CameraInfo, CompressedImage, NavSatFix, BatteryState, Range. |
| `shape_msgs` | Shape primitive types. |
| `statistics_msgs` | Statistics message types. |
| `std_msgs` | Header, String, Bool, Byte, Char, ColorRGBA, Empty, Float32/64, Int8/16/32/64, UInt8/16/32/64, and the `*MultiArray` family. |
| `tf2_msgs` | Transform-tree message types. |
| `trajectory_msgs` | Trajectory types. |
| `unique_identifier_msgs` | UUID. |
| `vision_msgs` | Detection2D, Detection2DArray, Detection3D, Detection3DArray, BoundingBox2D, BoundingBox3D, Classification, ObjectHypothesis, ObjectHypothesisWithPose, VisionInfo. |
| `visualization_msgs` | Marker, MarkerArray. |
## ROS2-to-Rust type mapping
`schema info` displays Rust types using this mapping:
| ROS2 type | Rust type |
| ---------------- | --------- |
| `bool` | `bool` |
| `byte` / `uint8` | `u8` |
| `char` / `int8` | `i8` |
| `uint16` | `u16` |
| `int16` | `i16` |
| `uint32` | `u32` |
| `int32` | `i32` |
| `uint64` | `u64` |
| `int64` | `i64` |
| `float32` | `f32` |
| `float64` | `f64` |
| `string` | `String` |
| `T[]` | `Vec` |
## Fixed vs variable fields
Fixed-size primitive fields are written directly — the write goes straight to shared memory, for example `self.image.height = 480`.
Variable-length fields (`string`, `T[]`) must be listed in the `#[output(...)]` attribute; once listed, plain assignment works for them too. See the [Node macro reference](/cerulion/reference/node-macro) for the `#[output(...)]` keys.
```rust theme={null}
use native_ros2_messages::sensor_msgs::Image;
// in tick (with #[output(data, encoding)] image: Image):
self.image.height = 480; // fixed field, direct write
self.image.width = 640;
self.image.encoding = "rgb8"; // variable field — plain assignment, listed in #[output]
```
In your node struct, a message type is used by name as a port field — for
example `image: Image` — and read or written through plain field access inside
`tick()`.
# Node macro reference
Source: https://docs.cerulion.com/cerulion/reference/node-macro
Complete reference for the #[cerulion_node] and #[cerulion_node_impl] macros and the #[input]/#[output] field attributes.
A node is a Rust struct annotated with `#[cerulion_node(...)]` paired with an adjacent `impl` block annotated with `#[cerulion_node_impl]`. Together they turn your struct into a node that `cerulion node build` compiles and `cerulion graph run` loads — you never call the generated code yourself.
Import everything from the prelude:
```rust theme={null}
use cerulion_core::prelude::*;
```
The prelude re-exports `cerulion_node`, `cerulion_node_impl`, `NodeError`, `NodeResult`, and the message/transport types.
## The two macros
The macros are used as a pair:
* `#[cerulion_node(...)]` on the struct declares the node type, its ports (via field attributes), and its trigger policy.
* `#[cerulion_node_impl]` on an adjacent `impl { fn tick(&mut self) -> Result<(), NodeError> { ... } }` lets `tick()` read inputs and write outputs through ordinary field access (`self..…`) — reads and writes go directly to shared memory, zero-copy.
`#[cerulion_node_impl]` takes no arguments. The struct must appear before the impl block.
## Complete example
```rust theme={null}
use cerulion_core::prelude::*;
use native_ros2_messages::geometry_msgs::Vector3;
use native_ros2_messages::sensor_msgs::LaserScan;
#[cerulion_node(period_ms = 10)] // period-driven; reads latest scan each tick
#[derive(Default)]
struct SafetyController {
#[input(lifo, depth = 1)] // regular input (use `trigger` for data-triggered)
scan: LaserScan,
#[output] // Vector3 = fixed-only schema (pub x/y/z: f64)
linear_velocity: Vector3,
}
#[cerulion_node_impl]
impl SafetyController {
fn tick(&mut self) -> Result<(), NodeError> {
// Variable field read via typed accessor:
let obstacle_close = self.scan.ranges().iter().any(|&r| r < 0.5);
// Fixed fields are plain field writes — they go straight to shared memory:
self.linear_velocity.x = if obstacle_close { 0.0 } else { 0.3 };
Ok(())
}
}
// Build with `cerulion node build safety_controller`, then wire it into a
// graph with `cerulion node stage safety_controller`.
```
## What the macros do for you
The macros generate the glue that lets the Cerulion runtime build, load, and
tick your node — lifecycle wiring, port plumbing, and the dynamic-library entry
points. You never interact with the generated code: write the struct and the
`tick()`, then drive everything through the CLI (`cerulion node build`,
`cerulion node stage`, `cerulion graph run`).
## Node-level attributes
`#[cerulion_node(...)]` attributes are all optional. Exactly one trigger-policy hint applies (or the trigger is inferred from a field marked `#[input(trigger)]`). `tick_within_ms` is orthogonal and stacks with any trigger policy. `throttle_ms` stacks with any trigger policy except `period_ms` (mutually exclusive with it).
| Attribute | Semantics |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `period_ms = N` | Fire every N ms. N must be > 0. |
| `sync_window_ms = N` | Bounded sync: fire when all `#[input(trigger)]` ports have an unconsumed message that arrived within an N-ms window. Requires ≥2 trigger inputs. N must be > 0. |
| `unbounded_sync` | Unbounded sync: fire when every `#[input(trigger)]` port has an unconsumed message, with no time bound. Requires ≥2 trigger inputs. Best for batch and offline fusion; for control loops, use `sync_window_ms` to keep a bounded latency guarantee. |
| `external` | Externally triggered; the host calls `Scheduler::trigger_external(node_id)`. |
| `tick_within_ms = N` | QoS, not a trigger. Per-node tick-execution deadline; tick callbacks taking longer than N ms increment `tick_within_missed_count` and emit `tracing::warn!`. Stacks with any trigger policy. N must be > 0. |
| `throttle_ms = N` | Rate cap, not a trigger. Defers the node's tick while `now − last_fire < N ms`, capping the fire rate regardless of the trigger policy. Mutually exclusive with `period_ms`. N must be > 0. |
### Mutual exclusion
`unbounded_sync` is mutually exclusive with `sync_window_ms`, `period_ms`, and `external`. `unbounded_sync` and `sync_window_ms` each require ≥2 trigger inputs. `throttle_ms` is mutually exclusive with `period_ms`. `tick_within_ms` stacks with any trigger policy; `throttle_ms` stacks with any trigger policy except `period_ms`.
### Rejected attributes
These attributes are rejected at parse time:
| Attribute | Reason |
| ----------------- | ------------------------------------------------------------------------------ |
| `type_name` | Not an attribute — the node type is the folder name. |
| `inputs(...)` | Ports are declared as field attributes, not in the macro arguments. |
| `outputs(...)` | Ports are declared as field attributes, not in the macro arguments. |
| `deadline_ms = N` | Use `#[input(trigger, expect_within_ms = N)]` for per-input arrival watchdogs. |
## `#[input(...)]` attribute
Forms: `#[input]`, `#[input(trigger)]`, `#[input(trigger, lifo, depth = 1)]`, `#[input(fifo, depth = 100, backpressure = drop_oldest, max_age_ms = 200)]`, `#[input(filter = "fn_name")]`, `#[input(expect_within_ms = N)]`.
| Key | Meaning |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trigger` | This input fires the node (data trigger / sync member). |
| `fifo` / `lifo` | Queue discipline (mutually exclusive; specifying both on one input is an error). |
| `depth = N` | In-flight receive queue depth. N must be ≥ 1. |
| `backpressure = drop_oldest \| block \| sample(ms)` | Overflow policy. `drop_oldest` (default) evicts the oldest queued message. `block` defers the upstream producer's tick. `sample(ms)` decimates reads by wire timestamp. |
| `max_age_ms = N` | Drop messages older than N ms. |
| `filter = "fn"` | Named filter function. |
| `expect_within_ms = N` | QoS (subscriber-side): if N ms elapse with no new message on this input, increment `expect_within_missed_count` and warn. Not a trigger. N must be > 0. |
## `#[output(...)]` attribute
Forms: `#[output]` (fixed-only schema), `#[output(data, encoding)]` (lists simple variable fields → two write paths), `#[output(data, complex(header))]` (`complex(...)` lists nested-typed variable fields → `set__bytes(...)?`), `#[output(promise_within_ms = N)]`.
| Key | Meaning |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| bare ident(s) | Simple variable-length fields (`string`, `T[]`). Two write paths: `self.. = expr` copies your buffer into shared memory (one copy), or `self...fill_from(producer)?` hands your producer the destination buffer directly (zero copy). |
| `complex(name, ...)` | Nested-type variable fields; write them with `set__bytes(&bytes)?`. At most one `complex(...)` per `#[output]`. |
| `promise_within_ms = N` | QoS (publisher-side): if N ms elapse with no publish on this output, increment `promise_within_missed_count` and warn. N must be > 0. At most one per `#[output]`. |
Fixed primitive fields are written directly (for example `self.image.height = ...`) — no attribute entry needed.
### `fill_from`: zero-copy producer writes
For producers that write into a destination buffer — camera drivers, codecs, file readers, network sockets — `fill_from` passes the shared-memory destination buffer (`&mut [T]`) directly to the producer; the producer just fills it.
```rust theme={null}
// Closure form: producer receives &mut [u8] pointing directly into shared memory.
self.image.data.fill_from(|buf: &mut [u8]| {
let n = driver.read_frame_into(buf)?;
Ok(n) // bytes written; only the first n bytes are published
})?;
// Slice form: copy from an existing &[u8] without an intermediate Vec.
self.image.data.fill_from(SliceSource::new(&raw_bytes))?;
```
The producer type must implement `FillFrom` (re-exported from `cerulion_core::prelude`). `T` defaults to `u8`; typed-array fields use the element type directly (for example `&mut [f32]` for a `float32[]` field). If the producer returns `Err`, nothing is published for that tick.
# Trigger policies
Source: https://docs.cerulion.com/cerulion/reference/trigger-policies
The trigger policy SPEC grammar, macro-attribute equivalents, the defaulting matrix, and policy short labels.
A trigger policy decides when a node fires. It can be set on the CLI via `--policy SPEC` (on `node create` and `node modify`) or in source via a `#[cerulion_node(...)]` attribute. Trigger policy lives only on the macro side; graph YAML carries no policy block.
## `--policy SPEC` grammar
Accepted forms:
| SPEC | Behavior | Rules |
| ------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `period_ms=N` | Fire every N ms. | N must be a positive integer > 0. Bare `period_ms` (no `=N`) → error "requires a value". `period_ms=0` → error "must be > 0". |
| `sync_window_ms=N` | Fire when all trigger inputs arrive within an N-ms window. | Same N rules. |
| `external` | Fire only when triggered by the host. | Case-insensitive keyword. |
| `data_trigger=NAME` or `trigger=NAME` | Fire when the named input receives a message. | NAME must be non-empty and match a declared input. |
| anything else | error | "unknown policy spec `X`" listing the accepted forms. |
There is no `default` policy keyword — `--policy default` errors with "unknown policy spec". A 100 ms period must be written `--policy period_ms=100`.
There is no `deadline_ms` policy spec — `--policy deadline_ms=N` errors with "unknown policy spec `deadline_ms`". To watch for late data on a trigger input, use `#[input(trigger, expect_within_ms = N)]` in your node source.
## Macro-attribute equivalents
Each `--policy SPEC` has an equivalent `#[cerulion_node(...)]` attribute. See [Node macro reference](/cerulion/reference/node-macro) for full attribute semantics.
| `--policy SPEC` | Macro attribute |
| ------------------------------------ | --------------------------------------------- |
| `period_ms=N` | `#[cerulion_node(period_ms = N)]` |
| `sync_window_ms=N` | `#[cerulion_node(sync_window_ms = N)]` |
| `external` | `#[cerulion_node(external)]` |
| `data_trigger=NAME` / `trigger=NAME` | field marked `#[input(trigger)]` named `NAME` |
The `unbounded_sync`, `tick_within_ms = N`, and `throttle_ms = N` attributes have no `--policy` SPEC equivalent; they are macro-only.
## Defaulting matrix
When `--policy` is omitted, `node create` resolves the policy from the declared inputs (`resolve_create_policy`):
| Inputs declared | Result |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 inputs (no `-i`, no `-T`) | Error: source-only nodes must declare a non-data policy explicitly (`--policy period_ms=N` or `--policy external`). |
| 1+ inputs via `-i` only | No node-level policy attr is written (bare `#[cerulion_node]`). The runtime fires on any input arrival and emits a graph-build warning. No silent auto-promotion to `data_trigger`. |
| `-T` set | `DataTrigger { input_name: }`, regardless of the `-i` count. |
| Non-data `--policy` + `-T` | Error (conflict). |
The engine convenience wrapper `node_cmd::node_create` (used by tests/TUI scaffolding) supplies `Period { period_ms: 100 }` when called with `policy = None`. The CLI path uses the strict `node_create_with_options`, which enforces the 0-inputs-error rule. The "100 ms default" is a convenience-API behavior, not the CLI default.
## Policy short labels
`node list` and `node info` print a short policy label via `format_policy_short`:
| Label | Policy |
| ----------------- | -------------- |
| `period ms` | Period |
| `sync ms` | Bounded sync |
| `sync ∞` | Unbounded sync |
| `external` | External |
| `trigger:` | Data trigger |
| `-` | None |
# Why Cerulion
Source: https://docs.cerulion.com/cerulion/why-cerulion
How Cerulion compares to ROS 2 on the single-machine real-time graph: zero-copy by default, deterministic replay, and deadlines that fail loudly.
ROS 2 set the bar for robotics middleware, and Cerulion is built to clear it.
Cerulion is a ground-up rethink of the robotics stack, engineered to be
**dramatically faster and more deterministic where it counts most today: the
single-machine, multi-process real-time graph** — and it is expanding outward
toward a complete, better-in-every-way replacement. Where ROS 2 carries decades
of accumulated complexity, Cerulion starts clean and makes the fast, predictable
path the default.
This page explains the design choices that make Cerulion faster and why. For the
mental model of how it works, start with [Core concepts](/cerulion/concepts).
## Where Cerulion pulls ahead
ROS 2's architecture carries decades of accumulated complexity: a DDS networking
layer, performance features that are opt-in rather than automatic, and execution
order that depends on the executor and thread pool. Cerulion is engineered to
beat that head-on, turning the frustrations teams feel most on a single machine
into deliberate design advantages.
### Zero-copy is the default, not an opt-in
In ROS 2, zero-copy means choosing a compatible RMW, using loaned messages, and
tuning configuration, so most teams run the default copy-on-receive path and
never get it. In Cerulion, zero-copy shared memory **is** the hot path. A message
is written once into a shared-memory slot and read in place, so latency stays
flat as payloads grow, with no tuning.
### Deterministic, replayable execution
In ROS 2, execution order depends on the executor, thread pool, and DDS
scheduling, which makes reproducing a timing bug notoriously hard. In Cerulion,
execution order is derived from the graph file and driven by a simulated clock, so
a recorded run replays the same way it ran live. Debug once, reproduce exactly.
### Predictable, low-jitter tails
ROS 2 tail latency varies with executor and DDS behavior. In Cerulion's internal
benchmarks, 99th-percentile latency stays within roughly 10% of the median across
payload sizes: the tail tracks the median rather than spiking.
### Timing violations are loud, not silent
ROS 2's DDS `DEADLINE` QoS exists but is easy to misconfigure or have silently
ignored by the RMW. Cerulion tracks input, output, and tick-execution deadlines
with miss counters and emits a structured warning the moment a deadline slips,
which is critical for real-time control loops.
### One source of truth, less config sprawl
In ROS 2, behavior is spread across code, launch files, parameter YAMLs, and QoS
profiles that can silently conflict. In Cerulion, node behavior lives in the code
macro and the graph file defines only wiring. No silent overrides, no config
drift.
### A shallow learning curve
ROS 2 has a steep ramp: DDS QoS matrices, launch files, parameter plumbing.
Cerulion asks you to write a Rust struct plus one macro; the CLI scaffolds the
workspace, nodes, and graph for you.
### A memory-safe Rust foundation
ROS 2's core client library (`rclcpp`) is C++, exposed to whole classes of memory
bugs. Cerulion is built in Rust: memory safety without a garbage collector, and
predictable performance.
## The performance story
Cerulion's headline advantage is **flat latency**. Under a saturation
(back-to-back) round-trip workload, Cerulion stays in the **low-microsecond
range (about 2.4 to 2.8 µs) from a 64-byte message all the way to a 16-megabyte
message**. Standard ROS 2 (CycloneDDS over shared memory), measured at realistic
sensor rates, climbs into the milliseconds as payloads grow, because it copies
each message on receive.
The decisive difference is the **shape**: Cerulion holds flat exactly where
standard ROS 2 falls behind.
Round-trip latency, single machine, single publisher/subscriber pair, from
internal benchmarks. The Cerulion column is a saturation test; the standard
ROS 2 column is measured at sensor rates. "vs. standard" is the ratio of the
ROS 2 figure to the Cerulion figure.
| Payload | Cerulion (saturation test) | ROS 2 standard (sensor-rate test) | vs. standard |
| ------- | -------------------------- | --------------------------------- | ------------ |
| 64 B | 2.4 µs | 15.3 µs | 6.3× |
| 256 B | 2.7 µs | 25.6 µs | 9.6× |
| 1 KB | 2.8 µs | 21.3 µs | 7.7× |
| 4 KB | 2.5 µs | 29.6 µs | 12.0× |
| 16 KB | 2.5 µs | 23.0 µs | 9.4× |
| 64 KB | 2.4 µs | 19.2 µs | 8.0× |
| 256 KB | 2.5 µs | 228 µs | 91× |
| 1 MB | 2.4 µs | 1.28 ms | 537× |
| 4 MB | 2.6 µs | 6.00 ms | 2,343× |
| 16 MB | 2.4 µs | 26.2 ms | 10,807× |
All figures are **internal benchmarks**, single-machine, single
publisher/subscriber pair. The Cerulion column is a saturation (back-to-back)
round-trip test; the "standard ROS 2" column is ROS 2 Humble, CycloneDDS over
shared memory on the default receive path, measured at sensor rates (Jazzy and
Kilted measured within a few percent). ROS 2 also has an optional zero-copy
("loaned-message") receive path; against that path Cerulion's lead is steadier.
These are not guarantees and carry no error bars.
Reading the table:
* **At control-loop message sizes**, Cerulion is roughly **6 to 12× faster than
standard ROS 2**.
* **At image and lidar scale**, the gap grows into the **hundreds to
thousands× faster than standard ROS 2** (about 537× at a 1 MB depth frame and
about 10,807× at a 16 MB dense scan) because standard ROS 2 copies each
message and Cerulion does not.
**The path to a fully distributed stack.** Cerulion's vision is a complete,
better-in-every-way robotics stack, and cross-machine communication with
network-wide topic discovery is the next milestone on that road, landing in
the **next release**. Today Cerulion is purpose-built for the single-machine,
multi-process graph — and that same zero-copy, deterministic core is what
scales out to the distributed system that comes next.
## Next steps
Workspaces, nodes, graphs, topics, and schemas: the mental model.
Go from zero to a running graph you can observe with `topic echo`.
Create a node type, choose a trigger policy, and write `tick()`.
Every command and flag, in one place.
# Cerulion
Source: https://docs.cerulion.com/index
Deterministic, microsecond-scale IPC for robotics. The performance of bare shared memory with the ergonomics of a modern framework.
Built for roboticists
Robot middleware that just works.
**No serialization on the local hot path. No QoS micromanagement. No surprises. Just typed messages over shared memory at the latency of a function call.**
Try the Quickstart
## What Cerulion is
Cerulion is a Rust middleware for robotics and real-time systems. You compose
your application as a **graph** of **nodes** that pass messages over zero-copy
shared memory, and you build, wire, and run the whole thing through one CLI:
`cerulion`.
It gives you the latency of raw shared memory with the ergonomics of a typed,
macro-driven node API, plus a simulated clock that makes runs deterministic and
replayable. It's the foundation of a modern robotics stack — built to be the
faster, more deterministic successor to ROS 2.
Cerulion is in **closed alpha** — early access for teams who want a faster,
more deterministic robotics stack ahead of the public release. Everything
documented here — the CLI, node macro, and graph/schema formats — is real and
verified against the current build. Access is curated today;
[request an invite](/cerulion/installation) to start building.
## Why teams use it
Nodes exchange messages over iceoryx2 shared memory. Reads and writes go
straight to the payload, with no serialization on the local hot path.
Execution is driven by a simulated clock stepped in 1 ms increments, so the
same graph and inputs produce the same ordering. That makes runs easy to
debug and replay.
Decide when each node fires (periodic, data-triggered, synchronized, or
external) with per-node tick deadlines that warn loudly when missed.
Define a node as a Rust struct with `#[cerulion_node]`. Field attributes
declare ports; the macro rewrites `self.` access into zero-copy
shared-memory reads and writes for you.
## Start here
Set up the Rust toolchain, get the `cerulion` CLI, and verify it works.
Build and run a complete two-node graph, then watch its messages with
`topic echo`.
Learn the mental model: workspaces, nodes, graphs, topics, and schemas.