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) — 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 /net:children[] write adds exactly one addressable /net/link0 vertex 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 backendslink_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/net/link0 reads back a one-byte link-state, while the live transport is found in router.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).