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
/net:children[]write adds exactly one addressable vertex at/net/<module>/<name>— here/net/can/link0, the module taken from theprovide_linkstaging key (transport_vertex_t::provide_link,core/src/transport_vertex.cpp:586). 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:92) — 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 `/net:children[]` write adds exactly one
33 * addressable `/net/can/link0` vertex whose value is a tiny link-state rope
34 * (one VALUE TLV), and only once the link reports — a fresh mount holds no
35 * value at all. The transport's real bytes live *outside* the graph, in the
36 * FWD router's
37 * demux; no per-peer vertex or memory is added (ADR-0044). Attaching a bus
38 * does not "grow the rope."
39 *
40 * The transport half runs over the in-process `loopback_channel_t` (dev/test
41 * only) so the example is deterministic and needs no hardware — the same
42 * `provide_link` seam accepts a real `transport_can` bus link on a Linux host
43 * with a (v)CAN interface, and the structural claims asserted here are
44 * identical. This file self-checks and returns non-zero on any mismatch, so it
45 * runs as the `example_tree_of_ropes` ctest smoke test. Needs the FWD net plane
46 * (`LIBTRACER_NET_PLANE`) for the transport-mount axis.
47 */
48
49#include <array>
50#include <cstddef>
51#include <cstdint>
52#include <cstdio>
53#include <span>
54#include <string_view>
55#include <vector>
56
57#include "libtracer/loopback.hpp"
58#include "libtracer/tracer.hpp"
59
60namespace {
61
62using tr::graph::graph_t;
63using tr::graph::path_t;
64using tr::graph::role_t;
65using tr::graph::status_t;
66using tr::net::conn_role_t;
67using tr::net::conn_spec;
68using tr::net::fwd_router_t;
69using tr::net::transport_vertex_t;
70using tr::view::rope_t;
71using tr::view::view_t;
72
73int g_failures = 0;
74
75/** @brief Print a PASS/FAIL line and tally failures (the smoke-test contract). */
76void check(bool ok, std::string_view what) {
77 std::printf(" [%s] %.*s\n", ok ? "PASS" : "FAIL", static_cast<int>(what.size()), what.data());
78 if (!ok) ++g_failures;
79}
80
81/** @brief Parse a known-valid path literal (deref is safe for these constants). */
82path_t P(std::string_view s) { return *path_t::parse(s); }
83
84/**
85 * @brief Axis 1 — one rope, two backends, zero byte copies.
86 * @return The two-link rope, so the address axis can prove it stores as-is.
87 */
88rope_t axis1_memory_composition(std::span<std::byte> live) {
89 std::printf("Axis 1 (memory, L1): one rope chains links from TWO backends:\n");
90
91 // Link 0 lives in the heap backend.
92 tr::view::segment_ptr_t heap_seg = tr::view::heap_alloc(3);
93 heap_seg->bytes[0] = std::byte{0xAA};
94 heap_seg->bytes[1] = std::byte{0xBB};
95 heap_seg->bytes[2] = std::byte{0xCC};
96
97 // Link 1 BORROWS the caller's bytes — no allocation, no copy.
98 rope_t r;
99 r.append(view_t::over(heap_seg));
100 r.append(view_t::over(tr::view::borrow(live)));
101
102 check(r.link_count() == 2, "the rope has two links");
103 check(r.total_length() == 3 + live.size(), "logical length spans both segments");
104 check(r.links()[0].owner->btag == tr::mem::backend_tag::HEAP, "link 0 is heap-backed");
105 check(r.links()[1].owner->btag == tr::mem::backend_tag::BORROWED,
106 "link 1 borrows caller memory (a different backend, same chain)");
107
108 // Zero-copy egress: each iovec span points straight into the ORIGINAL segment.
109 const std::vector<std::span<const std::byte>> iov = r.to_iovec();
110 check(iov.size() == 2, "to_iovec yields one span per link");
111 check(iov[1].data() == live.data(),
112 "the borrowed link's iovec points INTO the caller's buffer (no copy)");
113 return r;
114}
115
116/** @brief Axis 3 — the vertex tree holds ropes; it is not one. */
117void axis3_address_composition(const rope_t& two_link) {
118 std::printf("Axis 3 (address, L4): each vertex HOLDS one rope; the tree is separate:\n");
119
120 graph_t g;
121 const auto temp = g.register_vertex(P("/sensor/temp"), role_t::STORED_VALUE);
122 const auto humidity = g.register_vertex(P("/sensor/humidity"), role_t::STORED_VALUE);
123
124 const auto w = g.write(temp, two_link); // rope by value = refcount bumps, never a byte copy
125 check(w.has_value(), "write threads the L1 rope into the L4 vertex slot");
126
127 const auto rd = g.read(temp);
128 check(rd.has_value() && (*rd)->link_count() == 2,
129 "the vertex stored the rope AS-IS — still two links; the tree did not flatten it");
130 check(rd.has_value() && (*rd)->links()[1].owner->btag == tr::mem::backend_tag::BORROWED,
131 "the borrowed link survived the store (zero copy through the graph)");
132
133 // A second vertex holds a wholly separate rope — there is no global rope.
134 std::array<std::byte, 1> hbyte{std::byte{0x42}};
135 const auto wh = g.write(humidity, view_t::over(tr::view::borrow(hbyte)));
136 const auto rh = g.read(humidity);
137 check(wh.has_value() && rh.has_value() && (*rh)->total_length() == 1,
138 "humidity holds a DIFFERENT rope — two vertices, two ropes, no shared chain");
139
140 // The address axis is walked by path, independent of any rope's links.
141 check(g.find(P("/sensor/temp").key()).has_value() &&
142 g.find(P("/sensor/humidity").key()).has_value(),
143 "both leaves resolve by walking the vertex Composite (parent/children, not links)");
144}
145
146/** @brief The transport-mount axis — an identity vertex, not a memory chain. */
147void axis_transport_is_identity() {
148 std::printf("Transport mount: adds ONE identity vertex, NOT memory:\n");
149
150 graph_t g;
151 fwd_router_t router(g);
152 transport_vertex_t net(g, router);
153
154 tr::net::loopback_channel_t channel; // in-process, deterministic, no hardware
155 net.provide_link("can", "link0", channel.a());
156
157 const auto cw =
158 g.write(P("/net:children[]"), conn_spec("client", "link0", conn_role_t::DIAL, 8080));
159 check(cw.has_value(),
160 "mounting a transport is an in-band :children[] write (no new primitive)");
161
162 const auto link_h = g.find(P("/net/can/link0").key());
163 check(link_h.has_value(), "the mount added exactly ONE addressable vertex: /net/can/link0");
164
165 // A fresh mount is pure identity: an address with NO stored value until the link
166 // reports state (a provided link reports via set_link_state; a dialled socket
167 // auto-reports on bring-up).
168 const auto fresh = g.read(*link_h);
169 check(!fresh.has_value() && fresh.error() == status_t::NOT_FOUND,
170 "the fresh identity holds NO memory — a mount adds an address, not a value");
171
172 // Once the link reports, the value is a tiny link-state TLV — a SINGLE-link rope,
173 // categorically not the sensor's two-link memory chain.
174 (void)net.set_link_state("net/can/link0", tr::net::link_state_t::UP);
175 const auto ls = g.read(*link_h);
176 check(ls.has_value() && (*ls)->link_count() == 1 && (*ls)->total_length() <= 8,
177 "link-state is a single-link rope of a few bytes — never a chained payload");
178 check(router.registry().by_name("net/can/link0") == &channel.a(),
179 "the transport's real bytes live OUTSIDE the graph, in the router's demux");
180
181 channel.shutdown(); // join recv threads before the router/graph go away
182}
183
184} // namespace
185
186/** @brief Run the three axes; return non-zero on any failed self-check. */
187int main() {
188 std::printf("tree-of-ropes: three orthogonal compositions, never fused\n\n");
189
190 std::array<std::byte, 4> live{std::byte{0x01}, std::byte{0x02}, std::byte{0x03},
191 std::byte{0x04}};
192 const rope_t two_link = axis1_memory_composition(live);
193 std::printf("\n");
194 axis3_address_composition(two_link);
195 std::printf("\n");
196 axis_transport_is_identity();
197
198 std::printf("\n%s: 'rope of ropes' is false — a node is a TREE of ropes.\n",
199 g_failures == 0 ? "OK" : "FAILURES");
200 return g_failures == 0 ? 0 : 1;
201}
See also: views module · graph model reference · transports & connections as vertices (ADR-0027).