Skip to main content
Node is the entry point to the NTL runtime, and it is smaller than it looks. It owns storage, activation, propagation and learning — and nothing else. No socket, no thread, no timer. It decides what should happen to a signal and records the consequence; you supply the I/O and the loop that calls it.

The one thing to know first

Node is synchronous. Every method on this page returns a value, not a future. ntl-core contains no async fn at all, holds no async runtime, and reaches for no ambient clock or randomness — time and entropy arrive through the injected Clock and Rng traits. That is not an oversight to be fixed later. It is what keeps the crate building for wasm32-unknown-unknown, which CI enforces on every commit, and what lets the whole decision path be tested without a network. Async lives one layer out, in ntl-net, which owns the transport and the task that drives it. So there is no .await anywhere below.
Every sample on this page is compiled and executed by runtime/ntl-core/tests/api_reference_node.rs. If the API changes, that test stops building. cargo doc --open remains the authoritative reference; this page is an orientation for embedding a node.
The crate is ntl_core — note the underscore in Rust paths. The ntl crate this page once imported does not exist.

Building a node

Node::builder() returns a NodeBuilder. build() runs schema migrations, validates configuration and restores any persisted activation state, then hands back a Node.
A store is not optional. build() returns Error::Config when none was supplied. There is no default: a node that silently kept its state in memory when you meant to persist it would lose every learned weight on restart without saying so.

NodeBuilder methods

That is the whole builder. Six setters and a build().

Settings that are configuration, not builder calls

Three things this page used to put on the builder are NodeConfig fields. They live there because they are validated together at load, and because a node that silently ignored one of them would be worse than one that refused to start:

Deterministic construction

Because the clock and the RNG are injected rather than ambient, a node can be built to replay exactly — which is how the runtime’s own time-dependent behaviour is tested without sleeping.
ntl_core::testing::test_node(u8) packages exactly this and returns the node alongside its ManualClock, so a test can advance time to exercise decay, refractory periods and receipt timeouts.

Inspecting a node

  • identity() -> &NodeId
  • config() -> &NodeConfig
  • store() -> &Arc<dyn NodeStore> — the way to read node state; see Synapses
  • now_ns() -> u64 — this node’s clock, not the host’s
There is no node.status(). What that method claimed to return is spread across identity(), the store, and learning_health(), each of which is a real value rather than a snapshot struct that could drift out of date.

Emitting signals

The builder is handed to the node. The node stamps origin, identifier and timestamp and claims the signal in its own dedup cache, so there is no emit on the builder itself.
emit only creates the signal. It does not route it — that is the next call.

Receiving signals

There is no handler registration and no listener. SignalHandler, register_handler, node.listen() and node.wait_correlation() are not identifiers this crate defines. A node is driven by whoever owns the transport: you hand it a signal, and it returns a Disposition describing what it decided. Two entry points, for two different provenances.

receive_local — a signal this node emitted

Distinct from receive because emit already claimed the signal in the dedup cache — running it back through receive would see its own claim and drop it. A local emission also skips admission control: the node chose to send this, so throttling itself on its own traffic would be backwards.

receive — a signal that arrived from a peer

arrival_synapse: Option<&SynapseId> is the synapse the signal came in on, so the node does not route it back where it came from.

Reading a Disposition

queued is load-bearing. Retain the signal body while it is true and only while it is true. A caller that cannot tell “queued” from “dropped as a duplicate” — both otherwise look like an empty disposition — either leaks a body per duplicate or discards one it will need when the gate releases the signal.

Driving the node

There is no run_until_shutdown(). The node has no thread and no timer, so nothing below happens on its own; the loop is yours, and it must call these.
Each of the three earns its place:
  • poll_activation() — backpressure must be a delay, not an indefinite hold. The guard inside receive can only act when another signal arrives, so a node whose traffic goes quiet needs this called periodically, or a below-threshold signal waits forever while its sender times out on a path that was working.
  • sweep_timeouts(limit) — this is what makes failure observable. A path that never delivers produces no receipt, so without the sweep it looks identical to a path never tried, and the model can never learn to avoid it. Returns how many decisions were resolved.
  • checkpoint() — saves the activation snapshot, purges expired dedup entries, and flushes the store. Skipping it makes a restart a free reset for anyone flooding the node.
A signal the gate released still needs a route, because receive could not have planned one — it did not yet know the signal would fire:

Synapses

There is no node.synapses(). Synapses are store state, read through a filter. Registering one is a node method, because forming a synapse changes learning state.
Call it once a peer’s handshake completes. A reconnecting peer whose synapse had gone dormant is reactivated by the same call — the handshake is the evidence that the peer is alive. A synapse pruned for signature failures stays pruned until its cooldown elapses, so a prune costs an attacker more than one reconnect.
Prefer SynapseFilter::eligible() over active() for anything routing-related. eligible includes Weakening, and excluding it would make weight recovery impossible — a synapse that failed once could never earn its way back. That is a normative requirement in synapse-lifecycle, not a preference.
The store’s errors are StoreError, which does not convert into ntl_core::Error. A function returning ntl_core::Result cannot use ? on a store call — the sample above lives in a function returning Result<usize, ntl_core::StoreError>.

Outcomes

The half that makes a node learn. Every forwarding decision is journalled; these calls resolve one and move the synapse’s weight.
Returns Option<WeightUpdate> — None when the receipt matched no pending decision. An unmatched receipt is discarded rather than raised, since forged receipts would otherwise be the cheapest attack on the routing model. A replayed receipt has no second effect: the first outcome wins.
For a journalled decision whose peer turned out to be unreachable. Resolving it immediately teaches the model the same thing several seconds sooner than the timeout sweep would, and attributes it to the transport rather than the path.
Returns Option<SignatureFailureOutcome>, carrying the failure count in the current influence window and whether this one crossed the threshold and pruned the synapse.
Rescales outbound weights when they exceed the node’s total budget. Some(f) is the factor applied; None means no rescaling was needed. apply_receipt and fail_forward already call it after moving a weight, so you only need it when you have changed weights yourself.

Observability

LearningHealth samples that many recent decisions and reports decisions_sampled, exploration_ratio, pending_ratio and delivery_ratio. The two middle ratios are the model’s health check: exploration near zero means the node has stopped learning; a pending ratio near one means it is not receiving the receipts it needs, and its weights reflect nothing.

Methods that do not exist

Everything this page previously documented, and what to reach for instead.
Last modified on September 11, 2026