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) — arope_tis an ordered chain ofview_twindows 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 ofvertex_tlinked 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
SPECwrite to the module’s creator endpoint/net/<module>/connadds exactly one addressable vertex at/net/<module>/<name>— here/net/can/link0, under thecanmodule the example declares withregister_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 notypeand norole. 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, withbtag == HEAPon link 0 andbtag == BORROWEDon link 1;to_iovec()[1].data()points straight into the caller’s buffer, proving the borrow never copied.The store is zero-copy — after
writethenread, 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/link0resolves as an address but holds no value at all: the read returnsNOT_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 inrouter.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).