> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cerulion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Node macro reference

> 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 <Name> { fn tick(&mut self) -> Result<(), NodeError> { ... } }` lets `tick()` read inputs and write outputs through ordinary field access (`self.<port>.…`) — reads and writes go directly to shared memory, zero-copy.

<Note>
  `#[cerulion_node_impl]` takes no arguments. The struct must appear before the impl block.
</Note>

## 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_<f>_bytes(...)?`), `#[output(promise_within_ms = N)]`.

| Key                     | Meaning                                                                                                                                                                                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| bare ident(s)           | Simple variable-length fields (`string`, `T[]`). Two write paths: `self.<port>.<f> = expr` copies your buffer into shared memory (one copy), or `self.<port>.<f>.fill_from(producer)?` hands your producer the destination buffer directly (zero copy). |
| `complex(name, ...)`    | Nested-type variable fields; write them with `set_<name>_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<T>` (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.
