Tree of ropes, not rope of ropes (three compositions)¶
A tempting mental model of libtracer is “one big rope of ropes” — a single memory chain that is the graph, folds into the TLV tree, and grows every time a transport is attached. That model fuses three things the reference implementation keeps orthogonal (CONTEXT.md §”Two compositions”, §”Graph (address) composition”) — and that orthogonality is the zero-copy story. This example falsifies the fused model by exercising each axis on its own and asserting 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/net/link0vertex whose value is a 1-byte link-state rope. 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 —
/net/link0reads back a one-byte link-state, while the live transport is found inrouter.registry().by_name("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.
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/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/tlv_emit.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::fwd_router_t;
69using tr::net::transport_vertex_t;
70using tr::view::rope_t;
71using tr::view::view_t;
72using tr::wire::opt_t;
73using tr::wire::type_t;
74
75int g_failures = 0;
76
77/** @brief Print a PASS/FAIL line and tally failures (the smoke-test contract). */
78void check(bool ok, std::string_view what) {
79 std::printf(" [%s] %.*s\n", ok ? "PASS" : "FAIL", static_cast<int>(what.size()), what.data());
80 if (!ok) ++g_failures;
81}
82
83/** @brief Parse a known-valid path literal (deref is safe for these constants). */
84path_t P(std::string_view s) { return *path_t::parse(s); }
85
86/**
87 * @brief A connection-creation SPEC, byte-identical to the one in
88 * transport_vertex_test.cpp — the in-band payload a `/net:children[]`
89 * write carries to mount a transport (ADR-0027, reference/05).
90 */
91view_t conn_spec(std::string_view type, std::string_view name, conn_role_t role,
92 std::uint16_t port) {
93 std::vector<std::byte> cfg;
94 tr::wire::emit_name(cfg, "role");
95 const std::byte r{static_cast<std::uint8_t>(role)};
96 tr::wire::emit_tlv(cfg, type_t::VALUE, opt_t{}, std::span<const std::byte>(&r, 1));
97 tr::wire::emit_name(cfg, "port");
98 std::vector<std::byte> pb(2);
99 tr::detail::store_le(pb, port, 2);
100 tr::wire::emit_tlv(cfg, type_t::VALUE, opt_t{}, pb);
101
102 std::vector<std::byte> body;
103 tr::wire::emit_name(body, "type");
104 tr::wire::emit_name(body, type);
105 tr::wire::emit_name(body, "name");
106 tr::wire::emit_name(body, name);
107 tr::wire::emit_name(body, "config");
108 tr::wire::emit_tlv(body, type_t::SETTINGS, opt_t{.pl = true}, cfg);
109
110 std::vector<std::byte> out;
111 tr::wire::emit_tlv(out, type_t::SPEC, opt_t{.pl = true}, body);
112
113 tr::view::segment_ptr_t seg = tr::view::heap_alloc(out.size());
114 std::memcpy(seg->bytes.data(), out.data(), out.size());
115 return view_t::over(std::move(seg));
116}
117
118/**
119 * @brief Axis 1 — one rope, two backends, zero byte copies.
120 * @return The two-link rope, so the address axis can prove it stores as-is.
121 */
122rope_t axis1_memory_composition(std::span<std::byte> live) {
123 std::printf("Axis 1 (memory, L1): one rope chains links from TWO backends:\n");
124
125 // Link 0 lives in the heap backend.
126 tr::view::segment_ptr_t heap_seg = tr::view::heap_alloc(3);
127 heap_seg->bytes[0] = std::byte{0xAA};
128 heap_seg->bytes[1] = std::byte{0xBB};
129 heap_seg->bytes[2] = std::byte{0xCC};
130
131 // Link 1 BORROWS the caller's bytes — no allocation, no copy.
132 rope_t r;
133 r.append(view_t::over(heap_seg));
134 r.append(view_t::over(tr::view::borrow(live)));
135
136 check(r.link_count() == 2, "the rope has two links");
137 check(r.total_length() == 3 + live.size(), "logical length spans both segments");
138 check(r.links()[0].owner->btag == tr::mem::backend_tag::HEAP, "link 0 is heap-backed");
139 check(r.links()[1].owner->btag == tr::mem::backend_tag::BORROWED,
140 "link 1 borrows caller memory (a different backend, same chain)");
141
142 // Zero-copy egress: each iovec span points straight into the ORIGINAL segment.
143 const std::vector<std::span<const std::byte>> iov = r.to_iovec();
144 check(iov.size() == 2, "to_iovec yields one span per link");
145 check(iov[1].data() == live.data(),
146 "the borrowed link's iovec points INTO the caller's buffer (no copy)");
147 return r;
148}
149
150/** @brief Axis 3 — the vertex tree holds ropes; it is not one. */
151void axis3_address_composition(const rope_t& two_link) {
152 std::printf("Axis 3 (address, L4): each vertex HOLDS one rope; the tree is separate:\n");
153
154 graph_t g;
155 const auto temp = g.register_vertex(P("/sensor/temp"), role_t::STORED_VALUE);
156 const auto humidity = g.register_vertex(P("/sensor/humidity"), role_t::STORED_VALUE);
157
158 const auto w = g.write(temp, two_link); // rope by value = refcount bumps, never a byte copy
159 check(w.has_value(), "write threads the L1 rope into the L4 vertex slot");
160
161 const auto rd = g.read(temp);
162 check(rd.has_value() && rd->link_count() == 2,
163 "the vertex stored the rope AS-IS — still two links; the tree did not flatten it");
164 check(rd.has_value() && rd->links()[1].owner->btag == tr::mem::backend_tag::BORROWED,
165 "the borrowed link survived the store (zero copy through the graph)");
166
167 // A second vertex holds a wholly separate rope — there is no global rope.
168 std::array<std::byte, 1> hbyte{std::byte{0x42}};
169 const auto wh = g.write(humidity, view_t::over(tr::view::borrow(hbyte)));
170 const auto rh = g.read(humidity);
171 check(wh.has_value() && rh.has_value() && rh->total_length() == 1,
172 "humidity holds a DIFFERENT rope — two vertices, two ropes, no shared chain");
173
174 // The address axis is walked by path, independent of any rope's links.
175 check(g.find(P("/sensor/temp").key()).has_value() &&
176 g.find(P("/sensor/humidity").key()).has_value(),
177 "both leaves resolve by walking the vertex Composite (parent/children, not links)");
178}
179
180/** @brief The transport-mount axis — an identity vertex, not a memory chain. */
181void axis_transport_is_identity() {
182 std::printf("Transport mount: adds ONE identity vertex, NOT memory:\n");
183
184 graph_t g;
185 fwd_router_t router(g);
186 transport_vertex_t net(g, router);
187
188 tr::net::loopback_channel_t channel; // in-process, deterministic, no hardware
189 net.provide_link("link0", channel.a());
190
191 const auto cw =
192 g.write(P("/net:children[]"), conn_spec("client", "link0", conn_role_t::DIAL, 8080));
193 check(cw.has_value(),
194 "mounting a transport is an in-band :children[] write (no new primitive)");
195
196 const auto link_h = g.find(P("/net/link0").key());
197 check(link_h.has_value(), "the mount added exactly ONE addressable vertex: /net/link0");
198
199 // A fresh mount is pure identity: an address with NO stored value until the link
200 // reports state (a provided link reports via set_link_state; a dialled socket
201 // auto-reports on bring-up).
202 const auto fresh = g.read(*link_h);
203 check(!fresh.has_value() && fresh.error() == status_t::NOT_FOUND,
204 "the fresh identity holds NO memory — a mount adds an address, not a value");
205
206 // Once the link reports, the value is a tiny link-state TLV — a SINGLE-link rope,
207 // categorically not the sensor's two-link memory chain.
208 (void)net.set_link_state("link0", true);
209 const auto ls = g.read(*link_h);
210 check(ls.has_value() && ls->link_count() == 1 && ls->total_length() <= 8,
211 "link-state is a single-link rope of a few bytes — never a chained payload");
212 check(router.registry().by_name("link0") == &channel.a(),
213 "the transport's real bytes live OUTSIDE the graph, in the router's demux");
214
215 channel.shutdown(); // join recv threads before the router/graph go away
216}
217
218} // namespace
219
220/** @brief Run the three axes; return non-zero on any failed self-check. */
221int main() {
222 std::printf("tree-of-ropes: three orthogonal compositions, never fused\n\n");
223
224 std::array<std::byte, 4> live{std::byte{0x01}, std::byte{0x02}, std::byte{0x03},
225 std::byte{0x04}};
226 const rope_t two_link = axis1_memory_composition(live);
227 std::printf("\n");
228 axis3_address_composition(two_link);
229 std::printf("\n");
230 axis_transport_is_identity();
231
232 std::printf("\n%s: 'rope of ropes' is false — a node is a TREE of ropes.\n",
233 g_failures == 0 ? "OK" : "FAILURES");
234 return g_failures == 0 ? 0 : 1;
235}
See also: views module · graph model reference · transports & connections as vertices (ADR-0027).