# KPN++ — Kahn Process Network Library Specification ## Overview A header-only C++20 template-metaprogramming library for building Kahn Process Networks. Each node wraps a function (or callable object); its input types are inferred from the parameter list and its output types from the return type. Nodes communicate over bounded, lock-free SPSC FIFO channels. Unlike a naive "one blocking thread per node" model, KPN++ is **reactive**: a node is scheduled onto a thread pool whenever all of its input channels have data. A node that wraps a function with `Node<>` owns a private single-thread pool and behaves exactly like an independent worker; multiple nodes can instead share one `ThreadPool` for bounded-thread execution. Source nodes self-resubmit; event-driven sources (`InterruptNode`) fire on an external trigger. The library ships rich runtime diagnostics (per-node exec/CPU/throughput stats, per-channel fill/bandwidth/overflow counters, pool and shared-resource utilisation), an optional in-process web debug UI, and nanobind-based Python bindings (partially implemented). > **Note on accuracy.** This document describes the code as it exists in `include/kpn/`. Where > a behaviour is subtle the relevant header is named so the source remains the ground truth. --- ## Project Structure ``` kpn++/ ├── CMakeLists.txt ├── include/kpn/ │ ├── fixed_string.hpp # NTTP string + in<>/out<> tags + index_of │ ├── traits.hpp # function signature introspection, normalised_return_t, repeat_tuple │ ├── diagnostics.hpp # NodeStats, ChannelStats, *Snapshot, IPoolProbe, IResourceProbe │ ├── channel.hpp # lock-free SPSC ring-buffer Channel + storage policy │ ├── port.hpp # InputPort / OutputPort handles │ ├── inode.hpp # INode interface, NodeErrorHandler, NodeEvent │ ├── scheduler.hpp # IScheduler + work-stealing ThreadPool │ ├── pool_node.hpp # PoolNode / PoolObjectNode (reactive, scheduler-driven) │ ├── interrupt_node.hpp # InterruptNode (external-trigger source) │ ├── node.hpp # Node / ObjectNode (PoolNode + private 1-thread pool) + make_node │ ├── fanout.hpp # FanoutNode + make_fanout │ ├── branch.hpp # RouterNode + FilterNode + make_router / make_filter │ ├── shared_resource.hpp # SharedResource priority-arbitrated exclusive resource │ ├── main_thread_node.hpp # MainThreadNode<> (GUI / main-thread-bound nodes) │ ├── static_network.hpp # Edge<>, make_network(), StaticNetwork<> │ ├── network.hpp # runtime Network builder + watchdog + diagnostics │ ├── debug_hub.hpp # DebugHub multi-network web UI (KPN_WEB_DEBUG only) │ ├── web_debug.hpp # single-network web debug server (KPN_WEB_DEBUG only) │ ├── variant_node.hpp # runtime-typed nodes/channels for Python graphs │ ├── tmp/ │ │ ├── fanout_groups.hpp # compile-time fan-out detection + edge expansion │ │ ├── topo_sort.hpp # compile-time DFS cycle check + topological order │ │ └── repeat_tuple.hpp # repeat_tuple_t │ ├── python/ │ │ ├── bindings.hpp # PyNetwork / PyNode nanobind helpers │ │ └── auto_bind.hpp # NodeRegistry / Entry / bind_network / bind_debug │ └── kpn.hpp # umbrella header ├── src/network.cpp ├── tests/ # Catch2 v3 + GoogleTest ├── examples/ # 01–16 (see Examples) ├── benchmarks/ # bench_pipeline (optional, KPN_BUILD_BENCHMARKS) └── python/kpn_python.cpp # nanobind module definition ``` `kpn.hpp` is the umbrella header; including it pulls in the full C++ API (the Python layer is included only by the binding TU). --- ## Component 0 — `fixed_string.hpp`: NTTP String + Port Tags Named ports use C++20 non-type template parameters (NTTPs). `std::string_view` and `const char*` are not valid NTTPs, so a `fixed_string` literal type provides `constexpr` internal storage. ```cpp template struct fixed_string { char data[N]{}; constexpr fixed_string(const char (&s)[N]) { std::copy_n(s, N, data); } constexpr bool operator==(const fixed_string&) const = default; constexpr std::string_view view() const { return {data, N - 1}; } }; template fixed_string(const char (&)[N]) -> fixed_string; // deduction guide (required) ``` `fixed_string<4>` and `fixed_string<7>` are distinct types, so `input<"img">()` and `input<"sigma">()` produce different instantiations — enabling zero-overhead compile-time port dispatch. Named-port lookup uses a `constexpr` `index_of` over the name pack; it returns the sentinel `npos` on a miss so the `static_assert` fires at the `input<"img">()` **call site**, giving a readable error at the point of use: ```cpp inline constexpr std::size_t npos = std::size_t(-1); template constexpr std::size_t index_of(); // returns position or npos ``` ### Port tags `in<...>` and `out<...>` tag types disambiguate input vs. output name packs in the factory API. Both are trivial empty structs; both are optional (omit to get index-only ports). ```cpp template struct in {}; template struct out {}; ``` > There is **no `latch<>` tag.** An earlier design sketched latched (most-recent-value) > input ports; this was not implemented and the only input kind is the synchronous one. --- ## Component 1 — `traits.hpp`: Function Introspection Extracts parameter and return types from any callable at compile time, for free functions, function pointers, member function pointers (const and non-const), lambdas and `std::function`. ```cpp // function_traits::return_t, ::args (std::tuple<...>), ::arity template using return_t = ...; // return type template using args_t = ...; // std::tuple of parameters template inline constexpr std::size_t arity_v = ...; ``` The return type is normalised to a tuple so every node has a uniform output-tuple shape: ```cpp // void → std::tuple<> (sink node, 0 outputs) // T (non-tup) → std::tuple (1 output) // tuple<...> → tuple<...> (one output port per element) template using normalised_return_t = ...; template inline constexpr std::size_t output_count_v = ...; ``` `repeat_tuple_t` (also surfaced via `tmp/repeat_tuple.hpp`) builds `std::tuple` with `N` repetitions — used by `FanoutNode` and `RouterNode` to describe their N identical output ports. --- ## Component 2 — `diagnostics.hpp`: Statistics and Snapshots Shared timing types: `clock_t = std::chrono::steady_clock`, `duration_t` is a `double`-millisecond duration. - **`NodeStats`** — atomic counters updated per fire: `frames_processed`, an EMA of wall-clock exec time (`ema_exec_us`, warmup-mean for the first 5 frames then α=0.1), `max_exec_us`, `total_blocked_us`, thread CPU time (`total_cpu_us` via `CLOCK_THREAD_CPUTIME_ID`), `queue_wait_us` (pool queue latency), and `exec_start_us` (non-zero while executing; used by the watchdog to detect hung nodes). - **`ChannelStats`** — `pushes`, `bytes_pushed`, `drops`, `overflows`, `pops`, `peak_fill`. - **Snapshots** — copyable plain structs taken by the watchdog / UI: `NodeSnapshot`, `ChannelSnapshot` (with `fill_pct()`, `peak_pct()`, `bandwidth_mbs()`), `PoolSnapshot`, `ResourceSnapshot`, and `NetworkSnapshot` (used by the `DebugHub`). - **Probe interfaces** — `IPoolProbe` and `IResourceProbe` expose a `snapshot(name)` method so pools and shared resources can be registered with a network for reporting. ### `ChannelDataSize` trait `bytes_pushed` is computed from a specialisable trait, defaulting to `sizeof(T)`. Specialise it for heap-owning payloads to get accurate bandwidth: ```cpp template<> struct kpn::ChannelDataSize { static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); } }; ``` --- ## Component 3 — `channel.hpp`: Lock-free Bounded FIFO + Storage Policy ### Storage policy The type stored inside a channel is chosen by a specialisable trait. Small trivially-copyable types are stored by value; everything else as `std::shared_ptr` so fan-out copies a refcount, not data: ```cpp template struct channel_storage_policy { static constexpr bool by_value = std::is_trivially_copyable_v && sizeof(T) <= 8; }; template using channel_storage_t = std::conditional_t< channel_storage_policy::by_value, T, std::shared_ptr>; ``` Override it to force value semantics for a custom small type. Push wraps a value in `make_shared` when needed; pop dereferences it transparently, so a function taking `const T&` works naturally and immutability is compiler-enforced. ### Channel — SPSC ring buffer `Channel` is a single-producer/single-consumer ring buffer (capacity rounded up to a power of two). It uses C++20 `std::atomic::wait/notify_one` (portable futex) with a configurable **spin-before-sleep** window so the common case never touches the kernel. ```cpp template class Channel { public: using storage_type = channel_storage_t; explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200); void push(T value); // drops if disabled; throws ChannelOverflowError if full bool push_sentinel(T value); // out-of-band, non-blocking must-deliver token (EOF) T pop(); // blocks (spin then futex); throws ChannelClosedError if disabled+empty bool try_pop(T& out, std::chrono::milliseconds timeout); // polling (watchdog/display) bool try_pop_now(T& out); // immediate, non-blocking void enable(); // accept pushes void disable(); // stop accepting + unblock any waiting pop() void set_push_callback(std::function); // empty→non-empty notification std::size_t size() const; // ring occupancy (excludes any pending sentinel) std::size_t approx_size() const; // size() + 1 if a sentinel is pending (readiness checks) std::size_t capacity() const; bool is_accepting() const; const ChannelStats& stats() const; ChannelSnapshot snapshot(const std::string& name) const; }; class ChannelOverflowError : public std::runtime_error { /* capacity + optional context */ }; class ChannelClosedError : public std::runtime_error {}; ``` `head_` and `tail_`/`wake_` live on separate cache lines (`alignas(64)`) to avoid false sharing between producer and consumer. `spin_hint()` issues a `pause`/`yield` instruction (or a compiler fence on other ISAs). ### The `push_callback` — how reactivity works `set_push_callback` registers a callback fired when a channel transitions empty→non-empty. A consuming `PoolNode` installs this on each of its input channels; when an input becomes ready it re-evaluates whether **all** inputs have data and, if so, submits itself to the scheduler. This is the mechanism that replaces a dedicated blocking thread per node. ### The out-of-band EOF sentinel — `push_sentinel` `push_sentinel(T value)` delivers a **must-deliver control token** (a graceful-EOF marker) that cannot be dropped by backpressure. The value is stored in a dedicated slot **outside** the ring, so it consumes no capacity, never throws `ChannelOverflowError`, and never blocks the producer. This matters because a node's worker cannot afford to block on a downstream push: parking that thread would stop it draining its own input, cascading into a hold-and-wait deadlock under backpressure. `push_sentinel` sets a published flag (`has_eof_`) and returns immediately, keeping the worker free to keep popping. Ordering is preserved: the consumer's `pop()` / `try_pop_now()` drain the ring **first** and only surface the sentinel once the ring is observed empty — so EOF always arrives after every value pushed before it. `approx_size()` (used by node readiness checks) counts a pending sentinel as one consumable item, so a channel carrying *only* a sentinel still schedules its consumer's next fire and the token is never stranded. Same SPSC contract as `push()` (sole producer); returns `false` if the channel is already disabled (teardown in progress → the token is moot). ### Backpressure and shutdown — `accepting_` flag Each channel carries `std::atomic accepting_` (default `true`). It is the primary shutdown mechanism; the only additional signal is the out-of-band EOF sentinel above, used for *graceful* drain rather than an abrupt close. - **`push()`** on a disabled channel silently drops the value (recorded as a `drop`). On a full accepting channel it throws `ChannelOverflowError` (a sizing error). - **`pop()`** blocks while empty and accepting; `disable()` wakes it and it throws `ChannelClosedError`. The **consumer node** owns its input channels and flips the flag: `start()` calls `enable()`, `stop()` calls `disable()`. Producers never touch it. ### Ownership Input channels are owned by their **consumer node** (held as `shared_ptr>`). A producer node holds a non-owning raw `Channel*` to push into. `Network`/`StaticNetwork` are otherwise non-owning of user nodes — see Components 8–9. --- ## Component 4 — `inode.hpp`: The Node Interface Every node implements `INode`: ```cpp struct INode { virtual ~INode() = default; virtual void start() = 0; virtual void stop() = 0; virtual bool running() const = 0; virtual const NodeStats& stats() const = 0; virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0; virtual void set_name(std::string) = 0; virtual void set_network_overflow_callback(NodeEventCallback) {} // network-injected virtual void set_network_closed_callback(NodeEventCallback) {} virtual void halt() { stop(); } // immediate, discard in-flight work virtual void shutdown() { stop(); } // graceful topo-ordered drain (overridden by networks) }; ``` Supporting types: ```cpp // Per-node error policy: return true to skip the failed fire and keep running, // false to stop the node (and signal closed downstream). using NodeErrorHandler = std::function; using NodeEventCallback = std::function; enum class NodeEvent { Overflow, Closed }; ``` --- ## Component 5 — `scheduler.hpp`: Thread Pool ```cpp struct IScheduler { virtual void submit(std::function task, float priority = 0.5f) = 0; virtual void start() = 0; virtual void stop() = 0; // join workers, discard pending tasks virtual void drain() = 0; // block until in-flight tasks complete (workers keep running) }; ``` `ThreadPool` is a **work-stealing** pool implementing both `IScheduler` and `IPoolProbe`. Each worker owns a priority queue (max-heap by `priority`, FIFO within equal priority via a sequence counter). `submit()` distributes round-robin; idle workers steal from the most-loaded peer using `try_lock`, then sleep on a shared condition variable. The submit/notify path takes the CV mutex around `notify` to close the lost-wakeup window; `drain()` waits on a separate counter of in-flight tasks. `priority` lets a hot node (full input, empty output) be scheduled ahead of others — see `PoolNode::compute_priority`. --- ## Component 6 — Node Types All processing nodes share the same shape: typed input channels they own, raw output-channel pointers set at wiring time, `args_tuple` / `return_tuple` aliases used by the connect-time type check, and `static constexpr` `label()` / `unique_tag` / `input_count` / `output_count`. ### `PoolNode` / `PoolObjectNode` — reactive, scheduler-driven (`pool_node.hpp`) The core node. Instead of a blocked thread, it submits a `fire_once()` to a shared `IScheduler` whenever all inputs are ready; `queued_` ensures at most one `fire_once()` is in flight. `fire_once()` pops every input (`try_pop_now`), runs the function, pushes each normalised output, records stats, then resubmits if inputs remain ready. Source nodes (`input_count == 0`) self-submit on `start()` and after each fire. ```cpp template, typename OutputTag = out<>, fixed_string Label = "", std::size_t UniqueTag = 0> class PoolNode : public INode { ... }; auto n = make_pool_node(scheduler, fifo_capacity); // index ports auto n = make_pool_node(scheduler, in<"a">{}, out<"b">{}, cap); ``` `PoolObjectNode` is the same for a stateful callable object (introspected via `&Obj::operator()`); the object must outlive the node. Per-node configuration: `set_error_handler(NodeErrorHandler)`, `set_overflow_callback`, `set_closed_callback`, `set_max_exec_time`. Inside `fire_once()`: `ChannelOverflowError` fires the overflow callbacks; `ChannelClosedError` (or an error handler returning `false`) fires the closed callbacks and self-stops; any other exception consults the error handler. **Name-count contract** — a `static_assert` requires that the number of input names is `0` or equals arity (same for outputs): ```cpp static_assert(sizeof...(InNames) == 0 || sizeof...(InNames) == input_count, "make_pool_node: number of input names must match function arity, or provide none"); ``` ### `Node` / `ObjectNode` — convenience wrappers (`node.hpp`) `Node<>` privately owns a `ThreadPool(1)` and derives from `PoolNode<>` with the **same** template signature, so each `Node` is a self-contained worker with no external scheduler. Its `start()`/`stop()` start and stop the private pool around the base. This keeps the simple API — `make_node(5)` — while routing all execution through the one `fire_once()` code path. ```cpp template, typename OutputTag = out<>, fixed_string Label = "", std::size_t UniqueTag = 0> class Node : public PoolNode<...> { ... }; auto src = make_node(5); auto dbl = make_node(5); auto cnt = make_node(in<"words">{}, out<"count","words">{}, 4); ``` The `Label` NTTP gives a human-readable name for diagnostics; `UniqueTag` is a collision-breaker required when the **same function** is used as two distinct vertices in a `StaticNetwork` (two `make_node` would otherwise be the same type). Both default so existing code is unaffected. To share one pool across many nodes for bounded-thread execution, use `make_pool_node` directly. ### `InterruptNode` — external-trigger source (`interrupt_node.hpp`) A zero-input source driven by an external event (camera frame, timer, socket) instead of self-resubmission. `get_trigger()` returns a thread-safe callable to hand to the event source; each call increments a `pending_` counter and submits `fire_once()` on the 0→1 transition, guaranteeing one execution per trigger even under bursts. It does not busy-loop. ```cpp auto cam = make_interrupt_node(scheduler, out<"frame">{}); camera_sdk.on_frame_ready(cam.get_trigger()); ``` ### `FanoutNode` — explicit fan-out (`fanout.hpp`) Reads one item and pushes a copy to each of N outputs (per-output overflow drops independently). Runs on its own `std::jthread` blocking on `pop()`. Used directly in a runtime `Network` via `make_fanout`, and auto-inserted by `make_network()` for `StaticNetwork`. ### `RouterNode` / `FilterNode` — branching (`branch.hpp`) Both run on a dedicated `jthread`. `RouterNode` pushes each item to exactly **one** of N outputs chosen by a `selector(item) -> size_t` (out-of-range index drops). `FilterNode` forwards an item only when `pred(item)` is true. Factories: `make_router(sel)`, `make_filter(pred)`. ### `MainThreadNode, Args…>` — GUI / main-thread nodes (`main_thread_node.hpp`) For work that *must* run on the thread owning a GUI event loop (OpenCV `imshow`/`waitKey` on Wayland/Qt). It owns input channels and is registered as a normal `INode` (appears in diagnostics) but spawns **no** thread. The application drives it by calling `step()` in a loop on the main thread: `step()` does a zero-timeout `try_pop` on every input, and when all are ready invokes the derived `operator()(Args…)` (returning `false` to stop). CRTP; the derived class supplies the operator. --- ## Component 7 — `shared_resource.hpp`: Priority-arbitrated Exclusive Resource `SharedResource` wraps a singleton-like resource (an ONNX session, a CUDA stream) shared by nodes across one or more networks, and arbitrates access with a **priority + aging** waiter queue. Priority is re-evaluated at every release (so it reflects current queue state), and each waiter's effective score grows with wait time (`kAgingPerSecond`) to prevent starvation. ```cpp SharedResource res(session_args...); // inside a node functor: auto guard = res.acquire_balanced(in_channel, out_channel); // RAII; releases on scope exit guard->Run(...); ``` `acquire_balanced(in, out)` scores a waiter by `input_fill × output_headroom` — a node with a full input queue and empty output is most urgent. `acquire(fn)` takes any `()->float` priority; `acquire()` treats all waiters equally. Implements `IResourceProbe` so it shows up in diagnostics and the debug hub. The factory is `make_shared_resource(args…)`. --- ## Component 8 — `network.hpp`: Runtime Graph Builder + Watchdog `Network` is **non-owning** (nodes outlive it; `add()` stores `INode*`). A builder collects the full topology before `build()`, enabling cycle detection and topological ordering. ```cpp class Network : public INode { public: template Network& add(std::string name, NodeT& node); template Network& connect(const std::string& src, OutputPort, const std::string& dst, InputPort); Network& expose_input (std::string boundary_name, InputPort); // sub-network port Network& expose_output(std::string boundary_name, OutputPort); Network& build(); // DFS cycle check (throws NetworkCycleError) + topo sort void start() override; // start nodes in topo order; launch watchdog (+ web UI) void stop() override; // == halt() void halt() override; // immediate: stop nodes in reverse topo order void shutdown() override; // graceful: stop source layers, drain channels, descend void set_watchdog_interval(std::chrono::milliseconds); void set_error_handler(ErrorHandler); // void(node_name, exception_ptr) void set_diagnostics_handler(DiagnosticsHandler); // fired each watchdog tick void set_event_handler(EventHandler); // void(name, NodeEvent, timestamp) void register_pool(const std::string&, IPoolProbe*); void print_diagnostics(std::ostream& = std::cerr) const; // formatted table }; ``` - **`connect`** static-asserts that the source output type equals the destination input type (via the nodes' `return_tuple` / `args_tuple`), sets the consumer's input channel as the producer's output pointer, registers a `ChannelProbe` for diagnostics, and rejects a second connection from the same output port (use `make_fanout`). - **`build`** colours the graph DFS; a back-edge throws `NetworkCycleError`. It also wires each node's network-level overflow/closed callbacks to the `EventHandler` if one is set. - **`halt` vs `shutdown`** — `halt()` disables channels and stops nodes in reverse order immediately; `shutdown()` walks source layers first, polling channel probes until they drain before stopping the next layer. - **Watchdog** — a `std::jthread` that wakes on `watchdog_interval_` (default 3 s), collects snapshots, warns about nodes whose `exec_start_us` indicates an execution running > 5 s, and either calls the diagnostics handler or prints the formatted report. - **`expose_input`/`expose_output`** record boundary names (sub-network support is scaffolded; `Network` is itself an `INode` and can be `add()`ed to an outer `Network`). The formatted report includes node (frames, exec ms, max ms, blocked ms, fps, cpu ms, util%), channel (fill%, peak%, pushes, drops, overflow, MB/s, item bytes), and pool tables, plus a bottleneck hint (highest `ema_exec_ms`). --- ## Component 9 — `static_network.hpp`: Compile-time Graph Builder For C++ graphs whose full topology is known at compile time. The complete edge list is a type pack, so fan-out arity is known up front, cycle detection is a `static_assert`, and start/stop are pointer-vector traversals rather than string-map + virtual dispatch. ```cpp // edge() builds a typed Edge descriptor from two port handles. template Edge edge(OutputPort, InputPort); // make_network() takes all edges, expands fan-outs, wires channels, returns a StaticNetwork. template auto make_network(Edges&&... edges); ``` Usage — no `add`/`connect`/`build`/string names; one source port feeding two destinations auto-inserts a `FanoutNode`: ```cpp auto src = make_node(8); auto blur = make_node(8); auto detect = make_node(8); auto sink = make_node(8); auto net = make_network( edge(src.output<0>(), blur.input<0>()), edge(src.output<0>(), detect.input<0>()), // same source port → FanoutNode inserted edge(blur.output<0>(), sink.input<0>()), edge(detect.output<0>(), sink.input<1>())); net.start(); /* … */ net.stop(); ``` `make_network` performs, at compile time: fan-out detection and edge expansion (`tmp/fanout_groups.hpp`), a duplicate-`(Func, UniqueTag)` check (`static_assert` — "add a UniqueTag"), and a cycle check + topological order (`tmp/topo_sort.hpp`, `static_assert` — "graph contains a directed cycle"). At run time it heap-allocates owned `FanoutNode` storage, collects user-node pointers in edge order, sets each node's display name (`Label`, else `node[UniqueTag]`; fan-outs become `"_fanout"`), wires every expanded edge, and builds channel probes. `StaticNetwork` implements `INode` (so it can be embedded in a runtime `Network`). It owns the fan-out nodes, holds user nodes by pointer, and provides `start`/`halt`/`shutdown`, an `EventHandler`, `register_resource` / `register_pool`, `print_diagnostics`, and `network_snapshot()` (consumed by the `DebugHub`). Compile-time labels are read from each `NodeType::label()`. > `make_fanout` remains for explicit fan-out in a runtime `Network`; `make_network` users > never call it. --- ## Component 10 — Web Debugging (optional, `KPN_WEB_DEBUG`) Zero cost when disabled — guarded headers, no symbols, no dependency. Depends on **cpp-httplib** (single-header, fetched by CMake when the option is on) and loads **D3.js v7** from CDN. Enable per-target: ```cpp #define KPN_WEB_DEBUG 1 #include ``` ### Single-network server (`web_debug.hpp`) When enabled, `Network` / `StaticNetwork` gain `set_web_debug_port(uint16_t)` (default 9090) and auto-start an in-process HTTP server in `start()`. It serves an inline single-page app at `/` and a JSON snapshot at `/api/snapshot` (nodes, channels/edges, pools, resources, elapsed). The page renders a force-directed graph: node colour encodes `ema_exec_ms`, edge colour encodes fill%, with hover tooltips for the full stat set; it polls every 500 ms. ### `DebugHub` — multi-network UI (`debug_hub.hpp`) A standalone server aggregating several networks under one endpoint: ```cpp DebugHub hub(9090); hub.register_network("detect", detect_net); // disables that net's own server hub.register_network("classify", classify_net); hub.register_resource("gpu", &gpu_resource); // shows utilisation cards hub.start(); ``` The hub UI has one tab per registered network plus an "All Networks" tab with shared-resource cards and a cross-network node table. `register_network` calls `net.disable_web_server()` so the hub is the single debug endpoint; call it before `net.start()`. --- ## Component 11 — Python Bindings (partial) > Status: scaffolded and partially implemented. The variant machinery, `PyNetwork`/`PyNode`, and > the auto-binding layer exist; the demo module wires a hello-pipeline. Full sub-port read/write > and mixed C++/Python graphs are still in progress. Python graphs cannot resolve types at compile time, so a `PyNetwork` is parameterised by a `std::variant` derived (at compile time, via `unique_types`) from the port types of a **closed list of registered C++ node types**. The variant only appears at the C++/Python boundary; each node's internal `Channel` still stores raw `T` (`variant_node.hpp`: `IVariantChannel`, `VariantChannel`, `IVariantNode`, `VariantNodeWrapper`). ### Auto-binding (`python/auto_bind.hpp`) The node list is declared once with a `NodeRegistry` of `Entry`. `bind_network` registers the `PyNetwork` class, a `make_(capacity)` factory and a `Node` class per entry, and auto-registers `PythonConverter` for each port type. `bind_debug` additionally exposes each raw C++ function as a free Python callable for testing without a network. Recompiling the extension is the registration step — there is no CMake code-gen. ```cpp using DemoNodes = kpn::python::NodeRegistry< kpn::python::Entry, kpn::python::Entry, kpn::python::Entry>; // variant auto-deduced as std::variant NB_MODULE(kpn_python, m) { bind_network(m); bind_debug(m); } ``` Custom types are supported by specialising `kpn::PythonConverter` (`to_python` / `from_python`, optional `type_name`) before `bind_network`. ### GIL rules (non-negotiable) 1. **Acquire for callback** — hold the GIL only for the duration of a Python callable invocation (`nb::gil_scoped_acquire` around the call site). 2. **Release while blocking** — release the GIL before any blocking channel op (`nb::gil_scoped_release`), then re-acquire. Violating this deadlocks: a PyNode thread waiting for the GIL cannot proceed while another thread holds it and blocks on a channel waiting for that PyNode. --- ## Error Handling Contract | Situation | Behaviour | |---|---| | FIFO overflow (full, accepting) | `ChannelOverflowError` thrown in producer; node overflow callbacks fire | | Push to a disabled channel | Value silently dropped (counted as a `drop`) | | Node function throws | Routed to the node's `NodeErrorHandler` → `true` skips & continues, `false` stops the node | | Node stopped / channel closed | `ChannelClosedError` → node fires closed callbacks and self-stops | | Type mismatch (C++) | `static_assert` at `connect()` / `make_network()` | | Cycle in graph (runtime) | `NetworkCycleError` thrown at `build()` | | Cycle in graph (static) | `static_assert` at `make_network()` | | Duplicate `(Func, UniqueTag)` (static) | `static_assert` at `make_network()` — add a `UniqueTag` | | Hung node | Watchdog warning after threshold | `Network` additionally exposes an aggregate `EventHandler(name, NodeEvent, timestamp)` for overflow/closed events across all nodes. --- ## Thread Model KPN++ is **reactive**, not one-thread-per-node: - A `PoolNode` owns no thread. It registers a push-callback on each input channel; when all inputs are ready it submits `fire_once()` to a shared `IScheduler` (a `ThreadPool`). - `Node<>` wraps a `PoolNode` plus a **private `ThreadPool(1)`**, recovering "independent worker" semantics with the simple `make_node` API. Many nodes can instead share one pool (`make_pool_node`) for a bounded OS thread count. - `FanoutNode`, `RouterNode`, and `FilterNode` do run a dedicated `std::jthread` blocking on `pop()` (they are simple, latency-sensitive routers). - `InterruptNode` fires on an external trigger; `MainThreadNode` runs on the caller's main thread via `step()`. `std::jthread` (C++20) and its `stop_token` are used where a thread is owned, simplifying cooperative shutdown. Benchmarks (`benchmarks/bench_pipeline`) show ~2–7 µs/hop framework overhead for chains within the core count, rising under oversubscription. --- ## Platform and Compiler Requirements C++20 is required. | Feature | Min compiler | |---|---| | NTTP structural types (`fixed_string`) | GCC 11, Clang 13, MSVC 19.29 | | `std::atomic::wait/notify` (channel futex) | GCC 11, Clang 13, MSVC 19.29 | | `std::jthread` + `stop_token` | GCC 11, Clang 14, MSVC 19.29 | | `auto` NTTPs, fold expressions, `if constexpr`, concepts | C++20 / C++17 baseline | `CLOCK_THREAD_CPUTIME_ID` (per-thread CPU stats in `diagnostics.hpp`) is POSIX. nanobind requires Python 3.8+ (auto-fetched when `KPN_BUILD_PYTHON=ON`). --- ## Testing Strategy **Catch2 v3** for behaviour/integration tests and **GoogleTest** for unit and death tests; both are auto-fetched. Existing suites: `test_fixed_string`, `test_traits`, `test_channel`, `test_node`, `test_network`, `test_static_network`, `test_scheduler`, `test_pool_node`, `test_shared_resource`. Cases covered explicitly include: channel blocking/unblocking and overflow; shutdown races (`stop()` while blocked on `pop()`); `try_pop_now`; fan-out delivery; tuple unpacking to sub-channels; runtime cycle detection and static cycle/duplicate-tag `static_assert`s; named port lookup and wrong-name-count `static_assert`s; storage-policy by-value vs `shared_ptr`; scheduler submit/steal/drain; `PoolNode` reactive scheduling; and `SharedResource` priority + aging. --- ## Examples Self-contained programs under `examples/`, built by default (`-DKPN_BUILD_EXAMPLES=OFF` to skip). They double as documentation and smoke tests. | Example | What it shows | |---|---| | `01_hello_pipeline` | Linear pipeline, index-based wiring, `Network` builder | | `02_named_ports` | `in<>`/`out<>` tags, named port access, wrong-name `static_assert` | | `03_multi_output` | Tuple-returning node, per-element sub-port routing | | `04_storage_policy` | `channel_storage_policy` default + specialisation | | `05_error_handling` | `ChannelOverflowError`, diagnostics handler | | `06_watchdog` | Watchdog interval, stall detection | | `07_python_network` | `PyNetwork` with a pure-Python node *(pending)* | | `08_python_subport` | `net.read` / `net.write`, sub-port tap *(pending)* | | `09_opencv_cellshade` | Real-time cell-shading on webcam; named ports, fan-out, `MainThreadNode` display (requires OpenCV) | | `10_static_hello_pipeline` | `make_network()` version of 01 — compile-time topology | | `11_static_fanout` | Auto-inserted `FanoutNode` from a duplicated source port | | `12_static_cellshade` | Static cell-shading with auto fan-out and `Label` NTTPs | | `13_debug_cellshade` | One-op-per-node pipeline + variadic `DebugCanvas` tiling node | | `14_debug_hub` | Two networks sharing a `SharedResource` via `DebugHub` | | `15_node_error_handler` | Per-node `set_error_handler` (skip-and-continue vs stop) | | `16_event_callbacks` | `set_overflow_callback` + network `set_event_handler` | --- ## Future Extension Points (Heterogeneous Execution) Not implemented, but the design keeps these doors open: - **`IChannel` abstract interface** — `Channel` and a future `RemoteChannel` (socket / shared-memory) sharing one `push`/`pop` surface so nodes are agnostic to channel location. - **`Serializer` trait** — parallel to `channel_storage_policy` / `PythonConverter`, for cross-device serialisation (MessagePack for embedded, pinned memory for GPU zero-copy). - **`NodeKind` tag** — e.g. `{ Local, Gpu, Remote }` on `INode`, letting the watchdog apply per-device health-check and timeout strategies. The `IScheduler` abstraction already decouples node execution from any specific thread model, making a cooperative or device-specific executor a drop-in. --- ## Resolved Design Decisions | Question | Decision | |---|---| | Execution model | Reactive: nodes submit `fire_once()` to an `IScheduler` when inputs are ready, not one blocking thread per node | | `Node<>` vs `PoolNode<>` | `Node<>` owns a private `ThreadPool(1)`; `PoolNode<>` shares a pool for bounded threads | | Channel | Lock-free SPSC ring buffer, `atomic::wait/notify` + spin-before-sleep | | Shutdown | Per-channel `accepting_` flag; `disable()` unblocks `pop()` (→ `ChannelClosedError`) | | Overflow | `ChannelOverflowError` on full accepting channel; silent drop on disabled channel | | Node error policy | Per-node `NodeErrorHandler` returning bool (skip vs stop) | | Network ownership | Non-owning; user declares nodes, network stores `INode*` | | Fan-out | Explicit `FanoutNode` for runtime `Network`; auto-inserted by `make_network()` | | Branching | `RouterNode` (select one of N) and `FilterNode` (predicate gate) | | Static vs runtime graph | Both; `StaticNetwork` for compile-time C++ topology, `Network` for dynamic/Python; `StaticNetwork` is an `INode` so it embeds in `Network` | | Node identity (static graphs) | `Label` NTTP (name) + `UniqueTag` NTTP (collision-breaker); both default | | Shared device resource | `SharedResource` with priority + aging arbitration | | Main-thread / GUI work | `MainThreadNode<>` driven by `step()` on the main thread | | External-event sources | `InterruptNode` with a thread-safe `get_trigger()` | | Web debugging | Per-network server + multi-network `DebugHub`, behind `KPN_WEB_DEBUG` | | Mixed-rate latched inputs | **Not implemented** — no `latch<>` ports |