Skip to main content
Workspaces store graphs in graphs/<name>.yaml and schemas in schemas/<Name>.yaml. Both are YAML.

Graph YAML

The file stem is the graph name — graphs/perception.yaml is the graph perception. Every key the format does not define is a loud parse error, so a typo never becomes a silent default.
There is no policy: block in graph YAML; trigger policy lives only on the macro side. A leftover policy: block fails to parse.

Top-level fields

Node entry fields

Input fields

An absolute source with no in-graph producer is an external topic: the graph does not own it, other publishers attach freely, and block backpressure is rejected on it.

Output fields

Topic names

Cerulion derives every topic name as /<prefix>/<node_id>/<port>, with a leading slash. With prefix: perception, the sensor instance’s image output publishes to /perception/sensor/image, which is the name topic echo and topic hz expect. Malformed names are rejected at load: a bare /, a trailing /, empty // segments, and a prefix that starts or ends with /.

Graph name limits

The graph name — the file stem — becomes the name of the run’s shared-memory node, so it must be ASCII (an accent or an emoji is refused even though it is valid UTF-8) and at most 119 bytes; 112 bytes if you intend to re-simulate a recording of it, which carries a longer prefix. A name that breaks either rule is refused before the run starts, naming the offending name, the constraint it broke, and the fix:
Rename graphs/<name>.yaml and re-run. The refusal reaches the runs that use the name verbatim: a single-process graph run, graph profile, and bag play --resim — where a name the recording itself declares makes the bag not replay-grade. A multi-process run, the default on Unix, replaces every character outside [A-Za-z0-9_] with _ when it names its worker processes, so a non-ASCII name runs there; the byte cap still applies.

The network block

Cross-machine visibility is on by default for live runs, so this block exists to restrict exposure, not to enable it.
Validation rejects, by name: the same topic in both egress and ingress, an egress topic nobody produces, an ingress topic an in-graph node produces, a non-absolute topic in either list, non-empty lists under mode: disabled, and duplicates. Empty lists under peer/client are valid — that is an export-restricted robot. See Network a graph and reach remote robots.

ros2: entries

One graph file can describe the whole robot: native Cerulion nodes (type:) beside stock ROS 2 nodes (ros2:), brought up together by cerulion graph run on one transport. A ros2: entry names a ROS 2 process; the run spawns it as a supervised child on the same staged environment cerulion ros2 run uses, so its topics land on the shared transport beside the native nodes’.
A ros2: entry is an opaque process the run spawns and supervises. It is not scheduled — no trigger policy, no DAG level, no determinism claim — so level_assignments: and process_groups: may not name one, and cerulion graph levels lists these entries apart from the DAG, which its node count excludes. cerulion graph profile covers native nodes only.
A ros2: entry declares no ports: its topics meet native nodes on the shared transport by name, and the name is the fully-qualified ROS name verbatim — so a native #[input] whose source: is the absolute /tf reads a ROS 2 /tf publisher directly, and the wiring is simply not modelled in the graph. A graph made only of ros2: entries is refused — nothing remains for the runtime to run; use cerulion ros2 launch for a bare launch file. Each entry spawns before the graph starts, on every run shape, in its own process group; the graph process drives teardown with a SIGINT, a 10-second grace for ros2 launch to wind its own nodes down, then a SIGKILL backstop, so no orphan survives an exit path. A child that exits 0 mid-run warns and the graph keeps running — a sidecar finishing must not stop the robot. A child that dies (non-zero, or lost) follows --peer-loss: the default continue warns and runs degraded, and it is not restarted; fail stops the run and exits non-zero naming the entry. A missing librmw_cerulion.so, a missing launch or params file, and ros2 absent from PATH are all refused before anything spawns. graph run --record records a ROS 2 entry’s frames like any other live topic, and the bag’s embedded graph.yaml carries the ros2: entries verbatim, in authored order. cerulion bag info renders them as their own section. cerulion bag play --resim re-executes the native nodes against the recorded frames and skips the entries — one warning names them, --report JSON lists them under ros2_entries_skipped, and ROS 2 is never respawned.

Schema YAML

cerulion schema create <name> writes schemas/<name>.yaml — the name verbatim — holding a skeleton whose single entry key is the PascalCased name. cerulion schema create Detections writes schemas/Detections.yaml. A workspace can also carry ROS message definitions verbatim, as .msg files under schemas/<package>/msg/<Type>.msg; cerulion ros2 attach writes the types it discovered there. That store is a tier between your workspace YAML and the built-in types, and its entries are first-class for graph validate, recording, replay, and schema info — but not for node ports, which refuse a store type because it has no generated Rust type. Declaring one name in both tiers is refused outright, naming every source.
fields: is a mapping, not a list. Each key is the string "<type> <name>" and the value is left empty. A list under fields: still parses as YAML but produces a schema with zero fields and the wrong hash, with no error.

Field types

A schema is fixed when every field is fixed, and variable otherwise. That distinction decides how a node writes the field (see Messages and schemas) and how the runtime sizes the topic’s shared memory. Inspect the result with cerulion schema info Detections, which prints the field tree, the fixed wire size, the schema hash, and where the definition came from.