Files
KPN/SPEC.md
T
dtourolleandClaude Opus 4.8 3ac2242df1 docs: rewrite SPEC.md to match the implemented library
The spec had drifted far from the code. Key corrections:

- Execution model is reactive (PoolNode submits fire_once() to a
  ThreadPool when inputs are ready), not one blocking thread per node
- Channel<T> is a lock-free SPSC ring buffer (atomic wait/notify +
  spin-before-sleep), not a mutex+CV queue
- Remove latch<> ports (never implemented)
- NodeErrorHandler returns bool (skip vs stop); per-node
- Document new subsystems: scheduler, InterruptNode, Router/FilterNode,
  MainThreadNode, SharedResource, DebugHub, diagnostics/stats layer
- Update StaticNetwork, Python auto_bind layer, examples 01-16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:14:45 +02:00

37 KiB
Raw Blame History

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<T> + 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<T,N> + make_fanout
│   ├── branch.hpp           # RouterNode<T,N> + FilterNode<T> + make_router / make_filter
│   ├── shared_resource.hpp  # SharedResource<T> 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<T,N>
│   ├── 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/               # 0116 (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.

template<std::size_t N>
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<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;   // 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:

inline constexpr std::size_t npos = std::size_t(-1);

template<fixed_string Name, fixed_string... Names>
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).

template<fixed_string... Names> struct in  {};
template<fixed_string... Names> 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.

// function_traits<F>::return_t, ::args (std::tuple<...>), ::arity
template<typename F> using return_t = ...;   // return type
template<typename F> using args_t   = ...;   // std::tuple of parameters
template<typename F> inline constexpr std::size_t arity_v = ...;

The return type is normalised to a tuple so every node has a uniform output-tuple shape:

// void        → std::tuple<>        (sink node, 0 outputs)
// T (non-tup) → std::tuple<T>       (1 output)
// tuple<...>  → tuple<...>          (one output port per element)
template<typename T> using normalised_return_t = ...;
template<typename F> inline constexpr std::size_t output_count_v = ...;

repeat_tuple_t<T, N> (also surfaced via tmp/repeat_tuple.hpp) builds std::tuple<T, …, T> 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).
  • ChannelStatspushes, 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 interfacesIPoolProbe and IResourceProbe expose a snapshot(name) method so pools and shared resources can be registered with a network for reporting.

ChannelDataSize<T> trait

bytes_pushed is computed from a specialisable trait, defaulting to sizeof(T). Specialise it for heap-owning payloads to get accurate bandwidth:

template<> struct kpn::ChannelDataSize<cv::Mat> {
    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<const T> so fan-out copies a refcount, not data:

template<typename T>
struct channel_storage_policy {
    static constexpr bool by_value =
        std::is_trivially_copyable_v<T> && sizeof(T) <= 8;
};

template<typename T>
using channel_storage_t = std::conditional_t<
    channel_storage_policy<T>::by_value, T, std::shared_ptr<const T>>;

Override it to force value semantics for a custom small type. Push wraps a value in make_shared<const T> when needed; pop dereferences it transparently, so a function taking const T& works naturally and immutability is compiler-enforced.

Channel — SPSC ring buffer

Channel<T> 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.

template<typename T>
class Channel {
public:
    using storage_type = channel_storage_t<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<void()>);  // 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<bool> 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<Channel<T>>). A producer node holds a non-owning raw Channel<T>* to push into. Network/StaticNetwork are otherwise non-owning of user nodes — see Components 89.


Component 4 — inode.hpp: The Node Interface

Every node implements INode:

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:

// 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<bool(std::string_view node_name, std::exception_ptr)>;

using NodeEventCallback = std::function<void(std::chrono::steady_clock::time_point)>;
enum class NodeEvent { Overflow, Closed };

Component 5 — scheduler.hpp: Thread Pool

struct IScheduler {
    virtual void submit(std::function<void()> 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.

template<auto Func,
         typename InputTag  = in<>,
         typename OutputTag = out<>,
         fixed_string Label    = "",
         std::size_t  UniqueTag = 0>
class PoolNode : public INode { ... };

auto n = make_pool_node<func>(scheduler, fifo_capacity);                  // index ports
auto n = make_pool_node<func, "label", 0>(scheduler, in<"a">{}, out<"b">{}, cap);

PoolObjectNode<Obj, …> 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):

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<func>(5) — while routing all execution through the one fire_once() code path.

template<auto Func, typename InputTag = in<>, typename OutputTag = out<>,
         fixed_string Label = "", std::size_t UniqueTag = 0>
class Node : public PoolNode<...> { ... };

auto src  = make_node<produce>(5);
auto dbl  = make_node<double_it, "dbl">(5);
auto cnt  = make_node<count_words>(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<blur> 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.

auto cam = make_interrupt_node<grab_frame>(scheduler, out<"frame">{});
camera_sdk.on_frame_ready(cam.get_trigger());

FanoutNode<T, N> — 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<T,N>, and auto-inserted by make_network() for StaticNetwork.

RouterNode<T, N> / FilterNode<T> — 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<T,N>(sel), make_filter<T>(pred).

MainThreadNode<Derived, in<…>, 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<T> 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.

SharedResource<OrtSession> 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<T>(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.

class Network : public INode {
public:
    template<typename NodeT> Network& add(std::string name, NodeT& node);

    template<typename SrcNode, std::size_t SrcIdx, typename DstNode, std::size_t DstIdx>
    Network& connect(const std::string& src, OutputPort<SrcNode, SrcIdx>,
                     const std::string& dst, InputPort<DstNode, DstIdx>);

    Network& expose_input (std::string boundary_name, InputPort<NodeT, Idx>);   // sub-network port
    Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>);

    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 shutdownhalt() 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.

// edge() builds a typed Edge descriptor from two port handles.
template<typename SrcNode, std::size_t SrcIdx, typename DstNode, std::size_t DstIdx>
Edge<SrcNode, SrcIdx, DstNode, DstIdx>
edge(OutputPort<SrcNode, SrcIdx>, InputPort<DstNode, DstIdx>);

// make_network() takes all edges, expands fan-outs, wires channels, returns a StaticNetwork.
template<typename... Edges> auto make_network(Edges&&... edges);

Usage — no add/connect/build/string names; one source port feeding two destinations auto-inserts a FanoutNode:

auto src    = make_node<produce,    "src">(8);
auto blur   = make_node<blur_func,  "blur">(8);
auto detect = make_node<detect_func,"detect">(8);
auto sink   = make_node<display,    "sink">(8);

auto net = make_network(
    edge(src.output<0>(),    blur.input<0>()),
    edge(src.output<0>(),    detect.input<0>()),   // same source port → FanoutNode<T,2> 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 "<src>_fanout"), wires every expanded edge, and builds channel probes.

StaticNetwork<FanoutStorage, TopoNodeList> 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<T,N> 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:

#define KPN_WEB_DEBUG 1
#include <kpn/kpn.hpp>

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:

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<T> still stores raw T (variant_node.hpp: IVariantChannel, VariantChannel<T,Variant>, IVariantNode, VariantNodeWrapper).

Auto-binding (python/auto_bind.hpp)

The node list is declared once with a NodeRegistry of Entry<func, "name">. bind_network registers the PyNetwork class, a make_<name>(capacity) factory and a <Name>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.

using DemoNodes = kpn::python::NodeRegistry<
    kpn::python::Entry<produce,   "produce">,
    kpn::python::Entry<double_it, "double_it">,
    kpn::python::Entry<print_it,  "print_it">>;   // variant auto-deduced as std::variant<int>

NB_MODULE(kpn_python, m) {
    bind_network<DemoNodes>(m);
    bind_debug<DemoNodes>(m);
}

Custom types are supported by specialising kpn::PythonConverter<T> (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 NodeErrorHandlertrue 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 ~27 µ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_asserts; named port lookup and wrong-name-count static_asserts; 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<N> 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 interfaceChannel<T> and a future RemoteChannel<T> (socket / shared-memory) sharing one push/pop surface so nodes are agnostic to channel location.
  • Serializer<T> 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<T,N> for runtime Network; auto-inserted by make_network()
Branching RouterNode<T,N> (select one of N) and FilterNode<T> (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<T> 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