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

790 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```cpp
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:
```cpp
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).
```cpp
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`.
```cpp
// 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:
```cpp
// 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).
- **`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<T>` 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<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:
```cpp
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.
```cpp
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`:
```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<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
```cpp
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.
```cpp
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):
```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<func>(5)` — while routing all execution through the one `fire_once()` code path.
```cpp
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.
```cpp
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.
```cpp
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.
```cpp
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 `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<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`:
```cpp
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:
```cpp
#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:
```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<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.
```cpp
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 `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 ~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_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<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 interface** — `Channel<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 |