# KPN++ — Kahn Process Network Library Specification ## Overview A C++20 template-metaprogramming library for building Kahn Process Networks, where each node wraps a function/method, runs in its own thread, and communicates via bounded FIFO queues. Includes nanobind bindings for Python graph construction and prototyping. --- ## Project Structure ``` kpn++/ ├── CMakeLists.txt ├── include/kpn/ │ ├── fixed_string.hpp # NTTP string type for named ports │ ├── traits.hpp # Function signature introspection │ ├── channel.hpp # Bounded FIFO channel + storage policy │ ├── node.hpp # Node wrapper + thread management │ ├── port.hpp # Input/Output port handles │ ├── network.hpp # Graph builder + orchestrator/watchdog │ ├── variant_node.hpp # Runtime-typed node for Python graphs │ └── python/ │ └── bindings.hpp # Nanobind binding helpers ├── src/ │ └── network.cpp # Orchestrator thread impl ├── tests/ ├── examples/ │ ├── 01_hello_pipeline/ │ ├── 02_named_ports/ │ ├── 03_multi_output/ │ ├── 04_storage_policy/ │ ├── 05_error_handling/ │ ├── 06_watchdog/ │ ├── 07_python_network/ │ ├── 08_python_subport/ │ └── 09_opencv_cellshade/ # optional, requires OpenCV └── python/ └── kpn_python.cpp # Nanobind module definition ``` --- ## Component 0 — `fixed_string.hpp`: NTTP String Named ports use C++20 non-type template parameters (NTTPs). `std::string_view` and `const char*` are not valid NTTPs because they are not structurally comparable. The standard solution is a `fixed_string` literal type with `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}; } }; // Deduction guide — required so fixed_string("img") works as an NTTP. // Without it the compiler cannot infer N and the named-port API does not compile. template fixed_string(const char (&)[N]) -> fixed_string; ``` `fixed_string<3>` and `fixed_string<6>` are distinct types, so `input<"img">()` and `input<"sigma">()` produce different template instantiations — this is intentional and enables zero-overhead compile-time port dispatch. Named-port lookup uses a `constexpr` function over the name pack. It returns a sentinel `npos` on miss rather than `static_assert`-ing internally, so the assertion fires at the `input<"img">()` call site — giving the user a readable error at the point of use instead of deep in template instantiation: ```cpp inline constexpr std::size_t npos = std::size_t(-1); template constexpr std::size_t index_of() { std::size_t i = 0; bool found = false; ((Name == Names ? (found = true) : (found ? 0 : ++i)), ...); return found ? i : npos; } // Used at the call site: template auto input() { constexpr std::size_t idx = index_of(); static_assert(idx != npos, "unknown input port name"); return input(); } ``` --- ## Component 1 — `traits.hpp`: Function Introspection Extracts parameter types and return type from any callable at compile time. ```cpp // For: Image blur(Image in, float sigma) // function_traits::args == std::tuple // function_traits::return_t == Image // For multi-output: std::tuple detect(Image in) // return_t == std::tuple → 2 output ports // return_t == Image → 1 output port (normalised to tuple internally) // return_t == void → 0 output ports (sink node) ``` Handles: free functions, lambdas, `std::function`, member function pointers. A helper alias normalises the return type to always be a tuple for uniform handling in `run_loop`: ```cpp template using normalised_return_t = std::conditional_t, T, std::tuple>; // void return → std::tuple<> (empty tuple, zero output ports) ``` --- ## Component 2 — `channel.hpp`: Bounded FIFO + Storage Policy ### Storage Policy The type stored in a channel depends on a specialisable trait. Users can override it for any type: ```cpp template struct channel_storage_policy { static constexpr bool by_value = std::is_trivially_copyable_v && sizeof(T) <= 8; }; // User opt-in to value semantics for a small struct: template<> struct channel_storage_policy { static constexpr bool by_value = true; }; // Derived storage type: template using channel_storage_t = std::conditional_t< channel_storage_policy::by_value, T, std::shared_ptr >; ``` ### Channel `Channel` stores `channel_storage_t` internally. The producer calls `push(T value)` and the channel transparently wraps it in `make_shared` when needed. All consumers of the same channel receive the same `shared_ptr` — no copies of large objects. `run_loop` dereferences `shared_ptr` before passing to the wrapped function, so a function declared `void f(const Image& img)` works naturally and the compiler enforces immutability — no policy enforcement or `const_cast` needed. ```cpp template class Channel { public: using storage_type = channel_storage_t; explicit Channel(std::size_t capacity = 5); void push(T value); // wraps in shared_ptr if needed; throws on overflow T pop(); // blocks (KPN semantics); unwraps shared_ptr if needed bool try_pop(T& out, std::chrono::milliseconds timeout); std::size_t size() const; std::size_t capacity() const; }; class ChannelOverflowError : public std::runtime_error {}; ``` ### Ownership A `Channel` is **owned by its consumer node** — it lives as a member of the destination node. The producer node holds a non-owning raw pointer to push into it. The channel is destroyed when its consumer is destroyed, which is the correct lifetime. The `Network` itself is **non-owning** — nodes are declared by the user and outlive the network. `net.add("name", node)` registers a raw pointer; the user is responsible for keeping nodes alive for the network's lifetime. This avoids type-erasure ownership complexity and keeps node construction explicit. ### Backpressure and Shutdown — `accepting_` Flag Each channel carries a single `std::atomic accepting_` (default `true`). This is the **sole shutdown mechanism** — no `try_pop` polling, no sentinel values, no drain logic. ```cpp template class Channel { std::atomic accepting_{true}; public: void push(T value) { if (!accepting_.load(std::memory_order_relaxed)) return; // silently drop // normal push — throws ChannelOverflowError if full } void enable() { accepting_.store(true, std::memory_order_relaxed); } void disable() { accepting_.store(false, std::memory_order_relaxed); clear(); // drop all queued items immediately cv_.notify_all(); // unblock any waiting pop() } }; ``` **Who flips the flag:** the **consumer node** — it's the channel owner. `node.stop()` calls `disable()` on all its input channels. `node.start()` calls `enable()`. The producer never touches the flag; it calls `push()` and if the channel is disabled the value is silently dropped and the producer continues. **Overflow** (`push()` on a full, accepting channel) still throws `ChannelOverflowError` — this signals a design error (undersized FIFO) and is unchanged. **Blocking `pop()`** unblocks immediately when `disable()` is called (via `cv_.notify_all()`), and throws `ChannelClosedError` if the queue is empty and the channel is disabled. ### `try_pop` Purpose `try_pop` exists for **watchdog polling only** — not for shutdown (the `accepting_` flag handles that) and not for normal processing. The watchdog uses it to probe whether a node is making progress without blocking the watchdog thread. > **Note (C++ compile-time graphs):** In a fully compiled C++ graph the variant never > appears. The compiler wires `Channel` to `Channel` directly. The variant is > a pure compile-time construct used only for type-checking and generates zero runtime > overhead. --- ## Component 3 — `port.hpp`: Port Handles ```cpp template struct InputPort { NodeT& node; }; template struct OutputPort { NodeT& node; }; ``` Nodes expose port handles via: ```cpp // By index — always available node_a.input<0>() // returns InputPort node_b.output<1>() // returns OutputPort // By name — only valid when names were provided at make_node time node_a.input<"img">() node_b.output<"edges">() ``` Named access resolves to an index at compile time via `index_of` (see `fixed_string.hpp`). Zero runtime cost — the name dispatch is fully eliminated by the compiler. --- ## Component 4 — `node.hpp`: Node Wrapper ```cpp template< auto Func, fixed_string... InputNames, // optional; count must match arity or be 0 fixed_string... OutputNames // optional; count must match output count or be 0 > class Node { public: explicit Node(std::size_t fifo_capacity = 5); void start(); void stop(); // signals thread to finish current item then exit // Port access — by index template auto input(); template auto output(); // Port access — by name (compile error if names were not provided) template auto input(); template auto output(); static constexpr std::size_t input_count; static constexpr std::size_t output_count; private: void run_loop(); // Pops each input channel, dereferences shared_ptr if needed, // calls Func, unpacks the normalised tuple return, // pushes each element to its output channel. std::thread thread_; // Input channels owned here (one per input port). // Output channel pointers (non-owning) set at connect time. }; ``` **Factory syntax — `in<>` / `latch<>` / `out<>` tag structs:** A flat name pack `make_node` is ambiguous (where do inputs end?). Option chosen: `in<...>`, `latch<...>`, and `out<...>` tag types that wrap the name packs unambiguously. All are optional; omitting either means those ports are index-only. ```cpp // Tag types (trivial, no data): template struct in {}; template struct latch {}; template struct out {}; // Factory: // No names auto node = make_node(/*fifo_capacity=*/10); // Input names only auto node = make_node>(10); // Both input and output names auto node = make_node, out<"blurred","mask">>(10); // Mixed synchronous and latched inputs auto node = make_node, latch<"setpoint">, out<"output">>(10); ``` **Wrong name count is a compile error.** The `Node` class `static_assert`s that `sizeof...(InputNames) == 0 || sizeof...(InputNames) == input_count` (and same for outputs). Without this, a mismatch between name count and arity produces an unreadable template error. ```cpp static_assert( sizeof...(InputNames) == 0 || sizeof...(InputNames) == input_count, "make_node: number of input names must match function arity, or provide none" ); ``` Multi-output functions must return `std::tuple<...>`. Single return accepted as-is. `void` return = sink node (no output ports). --- ## Component 4a — Latched Input Ports ### Motivation Control and robotics applications naturally have two kinds of inputs at different update rates: - **Synchronous inputs** (`in<>`) — the node must have fresh data on every fire. Typical for sensor readings that drive the computation (e.g. encoder RPM). - **Latched inputs** (`latch<>`) — the node uses the most recently received value, and does not block if no new value has arrived. Typical for setpoints or parameters that change infrequently relative to the control loop (e.g. bearing from a CV pipeline, PID gains). Without latched ports, a node must block on all inputs simultaneously. This forces the control loop to run at the rate of the slowest input — unacceptable when a 1kHz encoder loop must wait for a 30Hz vision update. ### Semantics A `latch<>` port: 1. **Does not block** if its channel is empty — it reuses the last successfully popped value. 2. **Does block on first fire** — there is no meaningful "default" value, so the node waits until at least one value has arrived on each latched port before firing for the first time. 3. **Consumes the value** when one is available (standard `pop()`), then holds it until the next value arrives. The node fires whenever all `in<>` ports have data, using the last known value for each `latch<>` port. ### Implementation in `run_loop` `run_loop` maintains a `std::tuple` of cached values, one slot per latched port. On each iteration: ```cpp // Synchronous ports — blocking pop (existing behaviour) auto sync_args = std::make_tuple(input<0>().pop(), input<1>().pop(), ...); // Latched ports — non-blocking try_pop; keep cached value on miss try_pop(latch_cache_, latch_channel_); // updates cache if data available // Call wrapped function with merged argument tuple auto result = std::apply(Func, merge(sync_args, latch_cache_)); ``` Latched channels are otherwise identical to synchronous channels: bounded FIFO, `shared_ptr` storage policy, same shutdown behaviour. ### Example — PID with live setpoint ```cpp // bearing arrives at ~30 Hz from CV; rpm arrives at ~1 kHz from encoder double pid_compute(double rpm, double bearing) { ... } auto pid = make_node, // synchronous — blocks until fresh encoder tick latch<"bearing">, // latched — uses last known bearing from CV out<"pwm"> >(8); Network net; net.add("tacho", tacho_node) .add("tracker", tracker_node) .add("pid", pid) .connect("tacho", tacho_node.output<"rpm">(), "pid", pid.input<"rpm">()) .connect("tracker", tracker_node.output<"bearing">(),"pid", pid.input<"bearing">()) .build(); ``` The PID node fires at encoder rate. If no new bearing has arrived since the last tick, it reuses the previous one — correct behaviour for a control loop. ### Port Ordering Contract `in<>` and `latch<>` ports together must cover all function parameters in declaration order. The `static_assert` on name count is extended to cover both tags jointly: ```cpp static_assert( sizeof...(InNames) + sizeof...(LatchNames) == input_count || (sizeof...(InNames) == 0 && sizeof...(LatchNames) == 0), "make_node: in<> and latch<> names together must match function arity, or provide none" ); ``` The function parameter at position `i` is synchronous if `i` is in the `in<>` pack, latched if in the `latch<>` pack. Mixed ordering is allowed — the tag packs define which positions are latched, not a contiguous suffix. --- ## Component 5 — `network.hpp`: Graph Builder + Orchestrator `Network` is **non-owning** — nodes are declared by the user and must outlive the network. `add()` registers a raw pointer. Graph construction uses a builder pattern so the full topology is known before `build()`, enabling cycle detection and topological ordering. ```cpp class Network : public INode { // Network is itself an INode — enables sub-networks public: // Register a node by name. NodeT must satisfy INode. Network holds a raw pointer. template Network& add(std::string name, NodeT& node); // Connect output port of src to input port of dst. // Type mismatch → static_assert at compile time. template Network& connect(const std::string& src_name, OutputPort, const std::string& dst_name, InputPort); // Expose an internal node's input/output as a boundary port of this (sub-)network. // Allows a Network to be connected into a larger Network like a single node. template Network& expose_input(std::string boundary_name, InputPort); template Network& expose_output(std::string boundary_name, OutputPort); // DFS cycle check + topological sort. Throws NetworkCycleError on cycles. Network& build(); void start() override; // starts all internal nodes in topological order void stop() override; // stops all internal nodes in reverse order; disables channels bool running() const override; void set_watchdog_interval(std::chrono::milliseconds); using ErrorHandler = std::function; void set_error_handler(ErrorHandler); private: std::map nodes_; // non-owning std::map> adj_; std::vector topo_; std::jthread watchdog_; std::chrono::milliseconds watchdog_interval_{500}; ErrorHandler error_handler_; }; ``` **Node lifetime contract:** nodes must outlive the `Network`. The typical pattern is to declare nodes and the network in the same scope: ```cpp // Nodes declared first — they own their input channels auto blur = make_node>(10); auto detect = make_node>(10); Network net; net.add("blur", blur) .add("detect", detect) .connect("blur", blur.output<0>(), "detect", detect.input<0>()) .connect("blur", blur.output<"blurred">(), "detect", detect.input<"img">()) .build(); net.start(); ``` **Sub-networks** — because `Network` implements `INode`, it can be registered inside a larger `Network` as a named node. Boundary ports declared via `expose_input` / `expose_output` make the internal nodes' ports available to the outer graph: ```cpp // Inner sub-network auto stage1 = make_node(5); auto stage2 = make_node(5); Network pipe; pipe.add("pre", stage1).add("enh", stage2) .connect("pre", stage1.output<0>(), "enh", stage2.input<0>()) .expose_input("img", stage1.input<0>()) .expose_output("result", stage2.output<0>()) .build(); // Outer network treats `pipe` as a single node auto sink = make_node(5); Network top; top.add("pipe", pipe).add("sink", sink) .connect("pipe", pipe.output<"result">(), "sink", sink.input<0>()) .build(); top.start(); ``` `NetworkCycleError` is thrown by `build()` if the graph contains a directed cycle. --- ## Component 6 — `variant_node.hpp`: Runtime-typed Node (Python graphs) ### Motivation Python graphs cannot use compile-time type resolution. A `PyNetwork` is constructed with a **closed list of C++ node types** known at binding time. The library derives a deduplicated `std::variant` from all port types across those nodes. Type safety is enforced at `connect()` time via string signatures. ### Variant Deduplication All port types from the registered nodes are collected into a flat pack, duplicates are removed via a `unique_types` TMP metafunction, then the variant is instantiated once: ```cpp template using py_variant_t = std::variant>>; ``` This is pure TMP and runs entirely at compile time. The resulting variant has no redundant alternatives at runtime. ### PyNetwork Construction `make_py_network` is a **pure C++ template** — no CMake code-gen step. The variant is derived entirely at compile time from the registered node type list. The nanobind module definition is the single place where node types are listed; recompiling the extension is the "registration" step. ```cpp // In kpn_python.cpp — list all node types that may appear in Python graphs: auto py_net = make_py_network(); // VariantValue = std::variant< /* deduplicated port types from A, B, C */ > // Registers to_python / from_python converters for each alternative. ``` ### VariantChannel ```cpp using VariantValue = py_variant_t; class VariantChannel { public: explicit VariantChannel(std::size_t capacity = 5); void push(VariantValue v); // throws ChannelOverflowError if full VariantValue pop(); // blocks (KPN semantics) }; ``` ### VariantNode Wraps a registered C++ node type. Its `run_loop` uses `std::visit` to extract the concrete type from a `VariantValue`, calls the underlying function, then wraps the result back into a `VariantValue` for the output channel. ```cpp class VariantNode { public: std::string input_type_sig(std::size_t idx) const; std::string output_type_sig(std::size_t idx) const; void connect_input (std::size_t port, std::shared_ptr); void connect_output(std::size_t port, std::shared_ptr); void start(); void stop(); }; ``` ### PythonConverter — Crossing the C++/Python Boundary Every type in the variant must provide a `PythonConverter` specialisation. This is the single mechanism used for all data crossing into or out of Python (PyNodes, `net.read`, `net.write`): ```cpp template struct PythonConverter { static nanobind::object to_python(const T&); static T from_python(nanobind::object); }; ``` ### PyNode — Pure Python Processing Node A `PyNode` holds a `nanobind::object` as its function. Its `run_loop`: 1. Pops `VariantValue` from each input channel 2. `std::visit` → calls `PythonConverter::to_python` for each → **acquires GIL** 3. Calls the Python callable 4. **Releases GIL** → calls `PythonConverter::from_python` on the return value 5. Pushes result as `VariantValue` to output channel ### Sub-value Extraction and Injection A C++ node returning `std::tuple` exposes three independent output ports. Each element is pushed to its own `VariantChannel` — sub-indexing is a first-class concept at the channel level, not an afterthought. **Python tap — read one output port into Python:** ```python value = net.read("detect", output=2) # Pops from output channel 2, calls PythonConverter::to_python. # GIL released while blocking on pop(), re-acquired before to_python call. ``` **Python inject — write a Python value into a specific input port:** ```python net.write("blur", input=1, value=my_sigma) # Calls PythonConverter::from_python(my_sigma), pushes to input channel 1. # GIL released while blocking on push() if channel is full. ``` **Python splitter node:** ```python def split(packed): img, mask, score = packed return img, mask net.add_node("split", split, inputs=["packed"], outputs=["img", "mask"]) net.connect("detect", 0, "split", 0) net.connect("split", 0, "show", 0) net.connect("split", 1, "save", 0) ``` **Direct C++ sub-output to Python node input:** ```python net.connect("detect", 1, "py_thresh", 0) # Type sig of detect:output[1] must match py_thresh:input[0] — checked at connect(). ``` ### Type-check at connect time (Python) ```python # Raises kpn.TypeError if signatures don't match net.connect("blur", 0, "thresh", 0) # (src_name, out_idx, dst_name, in_idx) ``` --- ## Component 7 — Orchestrator / Watchdog Runs in its own dedicated thread inside `Network` / `PyNetwork`. Responsibilities: - Starts nodes in topological order; stops them in reverse order - Tracks per-node execution time (exponential moving average) - Emits warning via logger callback (default: `stderr`) when a node stalls beyond threshold - Catches exceptions from node threads and routes them to `ErrorHandler` - Graceful shutdown: signals all nodes, joins with timeout, reports any that fail to stop --- ## GIL Rules (non-negotiable constraints on binding implementation) Two rules govern all interaction between node threads and the Python interpreter: 1. **Acquire for callback** — a node thread must hold the GIL only for the duration of a Python callable invocation (`nb::gil_scoped_acquire` wrapping the call site). 2. **Release while blocking** — any blocking operation on a channel (`pop()`, `push()`, `net.read()`, `net.write()`) must release the GIL before blocking (`nb::gil_scoped_release` wrapping the call site), then re-acquire after. Violating rule 2 deadlocks: a PyNode thread waiting to acquire the GIL cannot proceed while another thread holds the GIL and blocks on a channel waiting for that PyNode to produce. --- ## Error Handling Contract | Situation | Behaviour | |---|---| | FIFO overflow | `ChannelOverflowError` thrown in producer thread → `ErrorHandler` | | Node function throws | Exception pointer captured → `ErrorHandler` | | Type mismatch (C++) | `static_assert` at `connect()` compile time | | Type mismatch (Python) | `kpn.TypeError` raised at `net.connect()` call | | Cycle in graph | `NetworkCycleError` thrown at `build()` time | | Thread fails to stop | Watchdog warning after configurable timeout | | `from_python` / `to_python` fails | Exception propagated to `ErrorHandler` | --- ## Future Extension Points (Heterogeneous Execution) Not implemented now, but the design must not close these doors: - **`IChannel` abstract interface** — `Channel` and a future `RemoteChannel` (wrapping a socket/queue) would share the same `push`/`pop` interface. Nodes never know whether their channel is in-process or remote. - **`Serializer` trait** — parallel to `PythonConverter` and `channel_storage_policy`, a specialisable trait for cross-device serialisation (MessagePack for ESP32, pinned memory for GPU zero-copy, etc.). - **`NodeKind` tag** — `enum class NodeKind { Local, Gpu, Remote }` on the `INode` interface, letting the watchdog apply different health-check and timeout strategies per device type. These three extension points are sufficient to support GPU and embedded/network targets without redesigning the core. --- ## Thread Model **v1: one `std::thread` per node.** This maps directly to KPN theory and is simple to reason about. It does not scale to networks with hundreds of nodes but is appropriate for the typical use case (tens of nodes, each doing non-trivial work). `std::jthread` (C++20) is preferred over `std::thread` where available, as it provides a built-in `stop_token` that simplifies the `stop()` / `try_pop` shutdown pattern. A future executor/thread-pool model (where multiple nodes share a pool of threads and are scheduled cooperatively) is a possible v2 extension. The `INode` interface is designed to not assume a 1:1 thread mapping. --- ## Platform and Compiler Requirements C++20 is required. Specific features used: | Feature | Header / Standard | Min compiler | |---|---|---| | NTTP structural types (`fixed_string`) | language | GCC 11, Clang 13, MSVC 19.29 | | `std::is_trivially_copyable_v` | `` | C++17+ | | `std::jthread` + `stop_token` | `` | GCC 11, Clang 14, MSVC 19.29 | | `if constexpr`, fold expressions | language | C++17+ | | `auto` NTTPs | language | C++20 | | Concepts (`requires`) | language | GCC 10, Clang 10 | **Minimum supported compilers:** GCC 11, Clang 13, MSVC 19.29 (VS 2022). nanobind requires Python 3.8+ and a C++17-capable compiler (satisfied by the above). --- ## Testing Strategy Test frameworks: **Catch2 v3** (header-friendly, good async/threading support via `REQUIRE_NOTHROW` + thread join patterns) and **Google Test** (for death tests and parameterised test suites). Both are included; use Catch2 for integration/behaviour tests and GTest for unit tests where `ASSERT_*` / `EXPECT_*` macros and death tests are preferable. ### Hard cases to cover explicitly: | Case | What to test | |---|---| | Channel blocking | `pop()` blocks until a producer pushes; unblocks exactly once per push | | Channel overflow | `push()` beyond capacity throws `ChannelOverflowError` | | Shutdown race | `stop()` called while a node is blocked on `pop()` — thread must exit cleanly | | Multi-consumer | Two nodes connected to the same output channel each receive every item (fan-out) | | Tuple unpacking | Multi-output node pushes correct type to each sub-channel | | Cycle detection | `build()` throws `NetworkCycleError` for a graph with a cycle | | Named port lookup | `input<"wrong">()` fires `static_assert`; `input<"right">()` resolves correctly | | Wrong name count | `make_node` with mismatched name count fires readable `static_assert` | | GIL deadlock | PyNode + blocking `net.read()` from Python do not deadlock | | `from_python` failure | Exception propagates to `ErrorHandler`, network continues | | `channel_storage_policy` | Large type is stored as `shared_ptr`; small type by value | --- ## Examples Each example is a self-contained program under `examples/`. They are built as part of the CMake build and serve as both documentation and smoke tests. ### `examples/01_hello_pipeline` — Basic linear pipeline Two nodes connected in sequence. Demonstrates `make_node`, `Network` builder, index-based port connection, `start_all` / `stop_all`. ```cpp // producer → transform → sink int produce() { return 42; } int double_it(int x) { return x * 2; } void print_it(int x) { std::cout << x << '\n'; } auto src = make_node(5); auto dbl = make_node(5); auto sink = make_node(5); Network net; net.add("src", src) .add("dbl", dbl) .add("sink", sink) .connect("src", src.output<0>(), "dbl", dbl.input<0>()) .connect("dbl", dbl.output<0>(), "sink", sink.input<0>()) .build(); net.start_all(); ``` ### `examples/02_named_ports` — Named port access Same pipeline but using `in<>` / `out<>` name tags and named port access. Demonstrates `fixed_string` NTTP dispatch and the `static_assert` on wrong names. ```cpp auto dbl = make_node, out<"result">>(5); // ... .connect("src", src.output<0>(), "dbl", dbl.input<"value">()) .connect("dbl", dbl.output<"result">(), "sink", sink.input<0>()) ``` ### `examples/03_multi_output` — Tuple-returning node / sub-port routing A single node returns `std::tuple`. Each output is routed to a different downstream node. Demonstrates tuple normalisation, per-element channel push, and `output<1>()` sub-indexing. ```cpp std::tuple detect(Image in) { ... } void show_image(const Image& img) { ... } void save_mask(const Mask& m) { ... } // detect:output<0> → show_image, detect:output<1> → save_mask ``` ### `examples/04_storage_policy` — `channel_storage_policy` specialisation Shows the default behaviour (large struct stored as `shared_ptr`, small int by value) and a user specialisation that overrides the default for a custom type. ```cpp struct BigFrame { uint8_t pixels[1920*1080*3]; }; // stored as shared_ptr automatically struct Tiny { float x, y; }; // 8 bytes — by value by default template<> struct channel_storage_policy { static constexpr bool by_value = true; }; ``` ### `examples/05_error_handling` — Overflow and node exceptions Demonstrates `ChannelOverflowError` (producer faster than consumer, tiny FIFO), custom `ErrorHandler`, and a node that throws mid-execution. ```cpp net.set_error_handler([](std::string_view name, std::exception_ptr ep) { try { std::rethrow_exception(ep); } catch (const std::exception& e) { std::cerr << "[" << name << "] " << e.what() << '\n'; } }); ``` ### `examples/06_watchdog` — Orchestrator / watchdog A node that artificially stalls. Shows watchdog warning emission, configurable interval, and graceful shutdown after a timeout. ```cpp net.set_watchdog_interval(std::chrono::milliseconds(200)); // stall_node sleeps for 2s per item — watchdog fires warning after 200ms ``` ### `examples/07_python_network` — PyNetwork with C++ and Python nodes Python script that imports `kpn`, registers C++ node types via `make_py_network`, adds a pure Python processing node, connects them, and runs the graph. ```python import kpn net = kpn.make_network([kpn.BlurNode, kpn.DetectNode]) def py_filter(img): return img[::2, ::2] # downsample in Python net.add_node("blur", kpn.BlurNode, inputs=["img"]) net.add_node("downsample",py_filter, inputs=["img"], outputs=["img"]) net.add_node("detect", kpn.DetectNode, inputs=["img"]) net.connect("blur", 0, "downsample", 0) net.connect("downsample", 0, "detect", 0) net.start() ``` ### `examples/09_opencv_cellshade` — Real-time cell-shading with OpenCV (optional) Captures live video from a system camera and applies a cell-shading effect entirely inside a KPN++ graph. Built only when OpenCV is found at CMake time; skipped silently otherwise. **Graph topology:** ``` [capture] ──Mat──> [split] ──B──> [median_b] ──B──┐ ├──G──> [median_g] ──G──┤ └──R──> [median_r] ──R──┴──> [merge] ──Mat──> [combine] ──> [display] [capture] ──Mat──────────────────────> [detect_edges] ──mask──────────> [combine] ``` Effect steps: 1. **`split_channels`** — `cv::split` into three single-channel `cv::Mat` planes. 2. **`median_b/g/r`** — independent `cv::medianBlur(kernel=15)` per channel; large kernel posterises colours into flat cartoon-like regions and runs in parallel across channels. 3. **`merge_channels`** — `cv::merge` back to BGR. 4. **`detect_edges`** — greyscale, `cv::Canny`, then `cv::dilate` to produce thick outlines. 5. **`combine`** — zeros out BGR pixels wherever the edge mask is non-zero → black outlines drawn over the flat-colour image. 6. **`display`** — `cv::imshow`; ESC key signals shutdown via `g_running` atomic. Demonstrates: named ports, fan-out from a single node to two downstream paths, parallel per-channel processing, multi-input `combine` node, and error handler driving graceful stop. ```cpp // Build only if OpenCV is present: // cmake .. -DKPN_BUILD_EXAMPLES=ON // ./09_opencv_cellshade [camera_index] # default: 0 ``` ### `examples/08_python_subport` — Python sub-value tap and inject Shows `net.read("node", output=N)` and `net.write("node", input=N, value=v)` from Python, plus connecting a C++ tuple output sub-port directly to a Python node input. ```python # Tap only output<1> (Mask) of a C++ detect node into Python net.connect("detect", 1, "py_thresh", 0) val = net.read("detect", output=0) # blocks until Image is available net.write("blur", input=1, value=1.5) # inject sigma ``` --- ## Component 8 — Web Debug UI (optional, compile-time toggle) An optional in-process HTTP server that serves a live graph visualisation of the running network. Zero cost when disabled — no symbols compiled in, no headers pulled. ### Toggle ```cpp // Before any kpn include — enables the web debug server #define KPN_WEB_DEBUG 1 #include ``` CMake projects that want it globally: ```cmake option(KPN_WEB_DEBUG "Enable KPN++ web debug UI" OFF) if(KPN_WEB_DEBUG) target_compile_definitions(my_app PRIVATE KPN_WEB_DEBUG=1) # cpp-httplib is fetched automatically by CMake when this flag is ON endif() ``` ### Implementation `include/kpn/web_debug.hpp` — included by `network.hpp` only when `KPN_WEB_DEBUG` is defined. Depends on **cpp-httplib** (single-header, no external process, no Python required). Served on `localhost:9090` by default (configurable via `net.set_web_debug_port(uint16_t)`). When enabled, `Network` gains: ```cpp #ifdef KPN_WEB_DEBUG void set_web_debug_port(uint16_t port); // default 9090 void start_web_debug(); // called internally by start() void stop_web_debug(); // called internally by stop() #endif ``` `start()` automatically calls `start_web_debug()` when `KPN_WEB_DEBUG` is defined. ### Endpoints | Endpoint | Method | Description | |---|---|---| | `/` | GET | Serves the single-page HTML app (inline, no files needed) | | `/api/snapshot` | GET | Returns a JSON snapshot of all node and channel stats | The HTML page is embedded as a C++ string literal — no asset files to deploy. ### JSON Snapshot Format ```json { "nodes": [ { "id": "src", "frames": 120, "ema_exec_ms": 33.2, "max_exec_ms": 45.1, "blocked_ms": 0.1, "fps": 29.8 }, { "id": "quant", "frames": 120, "ema_exec_ms": 4.1, ... } ], "edges": [ { "source": "src", "target": "quant", "label": "colour", "fill_pct": 12.5, "peak_pct": 87.5, "capacity": 8, "current": 1, "pushes": 120, "drops": 0, "overflows": 0 } ] } ``` Node `id` comes from the name registered via `net.add("name", node)`. Edge `label` comes from the channel name registered via `connect()` (format: `"src:N → dst:M"`). ### Browser UI The page polls `/api/snapshot` every **500 ms** and renders a **D3.js v7 force-directed graph**: - **Nodes** — circles labelled with node name; colour encodes exec load: - green (`ema_exec_ms` < 10ms), yellow (10–50ms), orange (50–100ms), red (>100ms) - hover tooltip shows: frames, ema_exec_ms, max_exec_ms, blocked_ms, fps - **Edges** — directed arrows labelled with the channel name and fill%; colour: - green (fill < 50%), yellow (50–80%), red (≥80%) — matches the `<<<` flag in the text report - hover tooltip shows: pushes, drops, overflows, capacity D3 is loaded from CDN (`d3js.org`). The entire UI is a single inline HTML string in `web_debug.hpp` — no file serving, no build step for assets. ### Thread model `start_web_debug()` launches a `std::jthread` running `httplib::Server::listen()`. The server is stopped via `httplib::Server::stop()` called from `stop_web_debug()`. `/api/snapshot` calls `collect_snapshots()` (already thread-safe — reads atomics with relaxed ordering) and serialises to JSON using a minimal hand-rolled serialiser (no third-party JSON library required). ### Example usage ```cpp #define KPN_WEB_DEBUG 1 #include // ... build network as normal ... net.set_web_debug_port(9090); // optional, 9090 is the default net.start(); // Open http://localhost:9090 in a browser ``` --- ## CMake Layout | Target | Type | Notes | |---|---|---| | `kpn` | header-only interface library | C++20, no external deps | | `kpn_python` | nanobind shared library | links `kpn`, requires Python 3.8+ | | `kpn_tests` | executable | Catch2 v3 + Google Test | | `kpn_examples` | executables (one per example) | built by default, off with `-DKPN_EXAMPLES=OFF` | | `kpn_web_debug` | compile-time option | `#define KPN_WEB_DEBUG 1`; fetches cpp-httplib via CMake FetchContent | --- ## Resolved Design Decisions All major design questions are now closed: | Question | Decision | |---|---| | Shutdown mechanism | `accepting_` flag per channel; `disable()` clears queue and unblocks `pop()` | | Overflow behaviour | `ChannelOverflowError` thrown on full accepting channel; silently dropped on disabled channel | | Network ownership | Non-owning; user declares nodes, network holds raw pointers | | Node lifetime contract | Nodes must outlive their `Network`; declare in same scope | | Sub-networks | `Network` implements `INode`; `expose_input`/`expose_output` define boundary ports | | `make_py_network` | Pure C++ template; nanobind module recompilation is the registration step | | GIL strategy | Acquire per Python callback; release while blocking on channel ops | | Mixed-rate inputs | `latch<>` tag for ports that reuse last-seen value; blocks only on first fire; node fires at rate of `in<>` ports |