Composition axes (L1 + L4 + transport)

A libtracer node is a tree of ropes, not a rope of ropes. The tempting fused model — a single memory chain that is the graph, folds into the TLV tree, and grows every time a transport is attached — collapses three things the reference implementation keeps orthogonal (CONTEXT.md §”Two compositions”, §”Graph (address) composition”), and that orthogonality is the zero-copy story. The three axes below are exercised independently, and the example asserts they never merge.

The three axes

  • Memory composition (L1, tr::view) — a rope_t is an ordered chain of view_t windows over refcounted segments (ADR-0053). Its links may live in different backends at once: here link 0 is heap-allocated and link 1 borrows caller-owned memory, in one chain, with zero byte copies. A rope is scoped to one payload — there is no process-wide rope.

  • Address composition (L4, tr::graph) — the vertex tree is its own Composite of vertex_t linked by parent/children (ADR-0057). Each leaf holds one rope in its value slot; storing the two-link rope keeps it two-link (the tree never flattens the memory chain), and a second vertex holds a wholly separate rope. Tree-of-ropes, not rope-of-ropes.

  • A transport is an identity, not memory — mounting a transport (ADR-0027) via an in-band SPEC write to the module’s creator endpoint /net/<module>/conn adds exactly one addressable vertex at /net/<module>/<name> — here /net/can/link0, under the can module the example declares with register_module, whose staged link the creation then picks up (transport_vertex_t::provide_link, core/src/transport_vertex.cpp:transport_vertex_t::provide_link). The module declaration also fixes the role, which is why the SPEC carries no type and no role. The transport’s real bytes live outside the graph, in the FWD router’s demux; no per-peer vertex or memory is added (ADR-0044). Attaching a bus does not “grow the rope.”

What to notice

  • One rope, two backends — link_count() == 2, with btag == HEAP on link 0 and btag == BORROWED on link 1; to_iovec()[1].data() points straight into the caller’s buffer, proving the borrow never copied.

  • The store is zero-copy — after write then read, the value is still a two-link rope and the borrowed link’s backend tag survives: the L4 tree threads the L1 rope through untouched.

  • No global rope — two vertices resolve to two independent ropes; the vertex tree is walked by path (parent/children), never by rope links.

  • Mount = identity — a fresh /net/can/link0 resolves as an address but holds no value at all: the read returns NOT_FOUND. Only once the link reports state does the vertex hold a value, and that value is a single-link rope carrying a one-byte link-state VALUE TLV (link_state_value, core/src/transport_vertex.cpp:link_state_value) — categorically not a chained payload. The live transport is found in router.registry().by_name("net/can/link0"), outside the graph.

The transport half runs over the in-process loopback_channel_t so the example is deterministic and needs no hardware; the same provide_link seam accepts a real transport_can bus link on a Linux host with a (v)CAN interface, and the structural claims asserted here are identical. The transport axis is compiled only with the FWD net plane enabled (LIBTRACER_NET_PLANE).

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief The three composition axes, made visible — why a libtracer node is a
  9 *        *tree of ropes*, not a *rope of ropes*.
 10 *
 11 * A tempting mental model of libtracer is "one big rope of ropes": a single
 12 * memory chain that *is* the graph, folds into the TLV tree, and grows every
 13 * time a transport is attached. That model fuses three things the reference
 14 * implementation deliberately keeps **orthogonal** (CONTEXT.md §"Two
 15 * compositions", §"Graph (address) composition") — and that orthogonality is
 16 * the zero-copy story. This example falsifies the fused model by exercising each
 17 * axis on its own and asserting they never merge:
 18 *
 19 *   1. **Memory composition (L1, `tr::view`)** — a `rope_t` is an ordered chain
 20 *      of `view_t` windows over refcounted segments (ADR-0053). Its links may
 21 *      live in *different* backends at once; here link 0 is heap-allocated and
 22 *      link 1 borrows caller-owned memory, in one chain, with zero byte copies.
 23 *      A rope is scoped to *one payload* — there is no process-wide rope.
 24 *
 25 *   2. **Address composition (L4, `tr::graph`)** — the vertex tree is its own
 26 *      Composite of `vertex_t` linked by parent/children (ADR-0057). Each leaf
 27 *      *holds one rope* in its value slot; storing the two-link rope keeps it
 28 *      two-link (the tree never flattens the memory chain), and a second vertex
 29 *      holds a wholly separate rope. Tree-of-ropes, not rope-of-ropes.
 30 *
 31 *   3. **A transport is an identity, not memory** — mounting a transport
 32 *      (ADR-0027) via an in-band write to the `can` module's creator endpoint
 33 *      `/net/can/conn` adds exactly one
 34 *      addressable `/net/can/link0` vertex whose value is a tiny link-state rope
 35 *      (one VALUE TLV), and only once the link reports — a fresh mount holds no
 36 *      value at all. The transport's real bytes live *outside* the graph, in the
 37 *      FWD router's
 38 *      demux; no per-peer vertex or memory is added (ADR-0044). Attaching a bus
 39 *      does not "grow the rope."
 40 *
 41 * The transport half runs over the in-process `loopback_channel_t` (dev/test
 42 * only) so the example is deterministic and needs no hardware — the same
 43 * `provide_link` seam accepts a real `can_transport_t` bus link on a Linux host
 44 * with a (v)CAN interface, and the structural claims asserted here are
 45 * identical. This file self-checks and returns non-zero on any mismatch, so it
 46 * runs as the `example_tree_of_ropes` ctest smoke test. Needs the FWD net plane
 47 * (`LIBTRACER_NET_PLANE`) for the transport-mount axis.
 48 */
 49
 50#include <array>
 51#include <cstddef>
 52#include <cstdint>
 53#include <cstdio>
 54#include <span>
 55#include <string_view>
 56#include <vector>
 57
 58#include "libtracer/loopback.hpp"
 59#include "libtracer/tracer.hpp"
 60
 61namespace {
 62
 63using tr::graph::graph_t;
 64using tr::graph::path_t;
 65using tr::graph::role_t;
 66using tr::graph::status_t;
 67using tr::net::conn_role_t;
 68using tr::net::conn_spec;
 69using tr::net::fwd_router_t;
 70using tr::net::transport_vertex_t;
 71using tr::view::rope_t;
 72using tr::view::view_t;
 73
 74int g_failures = 0;
 75
 76/** @brief Print a PASS/FAIL line and tally failures (the smoke-test contract). */
 77void check(bool ok, std::string_view what) {
 78    std::printf("  [%s] %.*s\n", ok ? "PASS" : "FAIL", static_cast<int>(what.size()), what.data());
 79    if (!ok) ++g_failures;
 80}
 81
 82/** @brief Parse a known-valid path literal (deref is safe for these constants). */
 83path_t P(std::string_view s) { return *path_t::parse(s); }
 84
 85/**
 86 * @brief Axis 1 — one rope, two backends, zero byte copies.
 87 * @return The two-link rope, so the address axis can prove it stores as-is.
 88 */
 89rope_t axis1_memory_composition(std::span<std::byte> live) {
 90    std::printf("Axis 1 (memory, L1): one rope chains links from TWO backends:\n");
 91
 92    // Link 0 lives in the heap backend.
 93    tr::view::segment_ptr_t heap_seg = tr::view::heap_alloc(3);
 94    heap_seg->bytes[0] = std::byte{0xAA};
 95    heap_seg->bytes[1] = std::byte{0xBB};
 96    heap_seg->bytes[2] = std::byte{0xCC};
 97
 98    // Link 1 BORROWS the caller's bytes — no allocation, no copy.
 99    rope_t r;
100    r.append(view_t::over(heap_seg));
101    r.append(view_t::over(tr::view::borrow(live)));
102
103    check(r.link_count() == 2, "the rope has two links");
104    check(r.total_length() == 3 + live.size(), "logical length spans both segments");
105    check(r.links()[0].owner->btag == tr::mem::backend_tag::HEAP, "link 0 is heap-backed");
106    check(r.links()[1].owner->btag == tr::mem::backend_tag::BORROWED,
107          "link 1 borrows caller memory (a different backend, same chain)");
108
109    // Zero-copy egress: each iovec span points straight into the ORIGINAL segment.
110    const std::vector<std::span<const std::byte>> iov = r.to_iovec();
111    check(iov.size() == 2, "to_iovec yields one span per link");
112    check(iov[1].data() == live.data(),
113          "the borrowed link's iovec points INTO the caller's buffer (no copy)");
114    return r;
115}
116
117/** @brief Axis 3 — the vertex tree holds ropes; it is not one. */
118void axis3_address_composition(const rope_t& two_link) {
119    std::printf("Axis 3 (address, L4): each vertex HOLDS one rope; the tree is separate:\n");
120
121    graph_t g;
122    const auto temp = g.register_vertex(P("/sensor/temp"), role_t::STORED_VALUE);
123    const auto humidity = g.register_vertex(P("/sensor/humidity"), role_t::STORED_VALUE);
124
125    const auto w = g.write(temp, two_link);  // rope by value = refcount bumps, never a byte copy
126    check(w.has_value(), "write threads the L1 rope into the L4 vertex slot");
127
128    const auto rd = g.read(temp);
129    check(rd.has_value() && (*rd)->link_count() == 2,
130          "the vertex stored the rope AS-IS — still two links; the tree did not flatten it");
131    check(rd.has_value() && (*rd)->links()[1].owner->btag == tr::mem::backend_tag::BORROWED,
132          "the borrowed link survived the store (zero copy through the graph)");
133
134    // A second vertex holds a wholly separate rope — there is no global rope.
135    std::array<std::byte, 1> hbyte{std::byte{0x42}};
136    const auto wh = g.write(humidity, view_t::over(tr::view::borrow(hbyte)));
137    const auto rh = g.read(humidity);
138    check(wh.has_value() && rh.has_value() && (*rh)->total_length() == 1,
139          "humidity holds a DIFFERENT rope — two vertices, two ropes, no shared chain");
140
141    // The address axis is walked by path, independent of any rope's links.
142    check(g.find(P("/sensor/temp").key()).has_value() &&
143              g.find(P("/sensor/humidity").key()).has_value(),
144          "both leaves resolve by walking the vertex Composite (parent/children, not links)");
145}
146
147/** @brief The transport-mount axis — an identity vertex, not a memory chain. */
148void axis_transport_is_identity() {
149    std::printf("Transport mount: adds ONE identity vertex, NOT memory:\n");
150
151    graph_t g;
152    fwd_router_t router(g);
153    transport_vertex_t net(g, router);
154
155    // The application declares WHERE this kind mounts: module `can`, dialling. That one
156    // declaration mints the module's creator endpoint `/net/can/conn` and fixes the role,
157    // which is why the SPEC below carries neither a `type` nor a `role` — the path says both.
158    (void)net.register_module("can", "can", conn_role_t::DIAL);
159
160    tr::net::loopback_channel_t channel;  // in-process, deterministic, no hardware
161    net.provide_link("can", "link0", channel.a());
162
163    const auto cw = g.write(P("/net/can/conn"), conn_spec("link0", 8080));
164    check(cw.has_value(),
165          "mounting a transport is an in-band write to /net/<module>/conn (no new primitive)");
166
167    const auto link_h = g.find(P("/net/can/link0").key());
168    check(link_h.has_value(), "the mount added exactly ONE addressable vertex: /net/can/link0");
169
170    // A fresh mount is pure identity: an address with NO stored value until the link
171    // reports state (a provided link reports via set_link_state; a dialled socket
172    // auto-reports on bring-up).
173    const auto fresh = g.read(*link_h);
174    check(!fresh.has_value() && fresh.error() == status_t::NOT_FOUND,
175          "the fresh identity holds NO memory — a mount adds an address, not a value");
176
177    // Once the link reports, the value is a tiny link-state TLV — a SINGLE-link rope,
178    // categorically not the sensor's two-link memory chain.
179    (void)net.set_link_state("net/can/link0", tr::net::link_state_t::UP);
180    const auto ls = g.read(*link_h);
181    check(ls.has_value() && (*ls)->link_count() == 1 && (*ls)->total_length() <= 8,
182          "link-state is a single-link rope of a few bytes — never a chained payload");
183    check(router.registry().by_name("net/can/link0") == &channel.a(),
184          "the transport's real bytes live OUTSIDE the graph, in the router's demux");
185
186    channel.shutdown();  // join recv threads before the router/graph go away
187}
188
189}  // namespace
190
191/** @brief Run the three axes; return non-zero on any failed self-check. */
192int main() {
193    std::printf("tree-of-ropes: three orthogonal compositions, never fused\n\n");
194
195    std::array<std::byte, 4> live{std::byte{0x01}, std::byte{0x02}, std::byte{0x03},
196                                  std::byte{0x04}};
197    const rope_t two_link = axis1_memory_composition(live);
198    std::printf("\n");
199    axis3_address_composition(two_link);
200    std::printf("\n");
201    axis_transport_is_identity();
202
203    std::printf("\n%s: 'rope of ropes' is false — a node is a TREE of ropes.\n",
204                g_failures == 0 ? "OK" : "FAILURES");
205    return g_failures == 0 ? 0 : 1;
206}

See also: views module · graph model reference · transports & connections as vertices (ADR-0027).