1000 lines
36 KiB
Markdown
1000 lines
36 KiB
Markdown
# 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<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}; }
|
||
};
|
||
|
||
// 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<std::size_t N>
|
||
fixed_string(const char (&)[N]) -> fixed_string<N>;
|
||
```
|
||
|
||
`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<fixed_string Name, fixed_string... Names>
|
||
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<fixed_string N>
|
||
auto input() {
|
||
constexpr std::size_t idx = index_of<N, InputNames...>();
|
||
static_assert(idx != npos, "unknown input port name");
|
||
return input<idx>();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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<decltype(blur)>::args == std::tuple<Image, float>
|
||
// function_traits<decltype(blur)>::return_t == Image
|
||
|
||
// For multi-output: std::tuple<Image, Mask> detect(Image in)
|
||
// return_t == std::tuple<Image, Mask> → 2 output ports
|
||
// return_t == Image → 1 output port (normalised to tuple<Image> 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<typename T>
|
||
using normalised_return_t =
|
||
std::conditional_t<is_tuple_v<T>, T, std::tuple<T>>;
|
||
// 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<typename T>
|
||
struct channel_storage_policy {
|
||
static constexpr bool by_value =
|
||
std::is_trivially_copyable_v<T> && sizeof(T) <= 8;
|
||
};
|
||
|
||
// User opt-in to value semantics for a small struct:
|
||
template<> struct channel_storage_policy<MySmallStruct> {
|
||
static constexpr bool by_value = true;
|
||
};
|
||
|
||
// Derived storage type:
|
||
template<typename T>
|
||
using channel_storage_t = std::conditional_t<
|
||
channel_storage_policy<T>::by_value,
|
||
T,
|
||
std::shared_ptr<const T>
|
||
>;
|
||
```
|
||
|
||
### Channel
|
||
|
||
`Channel<T>` stores `channel_storage_t<T>` internally. The producer calls `push(T value)`
|
||
and the channel transparently wraps it in `make_shared<const T>` when needed. All consumers
|
||
of the same channel receive the same `shared_ptr` — no copies of large objects.
|
||
|
||
`run_loop` dereferences `shared_ptr<const T>` 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<typename T>
|
||
class Channel {
|
||
public:
|
||
using storage_type = channel_storage_t<T>;
|
||
|
||
explicit Channel(std::size_t capacity = 5);
|
||
|
||
void push(T value); // wraps in shared_ptr<const T> 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<T>` 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<bool> accepting_` (default `true`). This is the
|
||
**sole shutdown mechanism** — no `try_pop` polling, no sentinel values, no drain logic.
|
||
|
||
```cpp
|
||
template<typename T>
|
||
class Channel {
|
||
std::atomic<bool> 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<Image>` to `Channel<Image>` 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<typename NodeT, std::size_t Idx>
|
||
struct InputPort { NodeT& node; };
|
||
|
||
template<typename NodeT, std::size_t Idx>
|
||
struct OutputPort { NodeT& node; };
|
||
```
|
||
|
||
Nodes expose port handles via:
|
||
|
||
```cpp
|
||
// By index — always available
|
||
node_a.input<0>() // returns InputPort<NodeA, 0>
|
||
node_b.output<1>() // returns OutputPort<NodeB, 1>
|
||
|
||
// 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<std::size_t I> auto input();
|
||
template<std::size_t I> auto output();
|
||
|
||
// Port access — by name (compile error if names were not provided)
|
||
template<fixed_string N> auto input();
|
||
template<fixed_string N> 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<>` / `out<>` tag structs:**
|
||
|
||
A flat name pack `make_node<f, "a", "b", "c">` is ambiguous (where do inputs end?).
|
||
Option chosen: `in<...>` and `out<...>` tag types that wrap the name packs unambiguously.
|
||
Both are optional; omitting either means those ports are index-only.
|
||
|
||
```cpp
|
||
// Tag types (trivial, no data):
|
||
template<fixed_string... Names> struct in {};
|
||
template<fixed_string... Names> struct out {};
|
||
|
||
// Factory:
|
||
// No names
|
||
auto node = make_node<my_func>(/*fifo_capacity=*/10);
|
||
|
||
// Input names only
|
||
auto node = make_node<my_func, in<"img","sigma">>(10);
|
||
|
||
// Both input and output names
|
||
auto node = make_node<my_func, in<"img","sigma">, out<"blurred","mask">>(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 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<typename NodeT>
|
||
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<typename SrcNode, std::size_t SrcIdx,
|
||
typename DstNode, std::size_t DstIdx>
|
||
Network& connect(const std::string& src_name, OutputPort<SrcNode, SrcIdx>,
|
||
const std::string& dst_name, InputPort<DstNode, DstIdx>);
|
||
|
||
// 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<typename NodeT, std::size_t Idx>
|
||
Network& expose_input(std::string boundary_name, InputPort<NodeT, Idx>);
|
||
|
||
template<typename NodeT, std::size_t Idx>
|
||
Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>);
|
||
|
||
// 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(std::string_view node_name, std::exception_ptr)>;
|
||
void set_error_handler(ErrorHandler);
|
||
|
||
private:
|
||
std::map<std::string, INode*> nodes_; // non-owning
|
||
std::map<std::string, std::vector<std::string>> adj_;
|
||
std::vector<std::string> 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<blur_func, in<"img","sigma">>(10);
|
||
auto detect = make_node<detect_func, in<"img">>(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<preprocess>(5);
|
||
auto stage2 = make_node<enhance>(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<display>(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<typename... Nodes>
|
||
using py_variant_t = std::variant<unique_types_t<all_port_types_t<Nodes...>>>;
|
||
```
|
||
|
||
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<NodeA, NodeB, NodeC>();
|
||
// 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</* registered nodes */>;
|
||
|
||
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<VariantChannel>);
|
||
void connect_output(std::size_t port, std::shared_ptr<VariantChannel>);
|
||
|
||
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<typename T>
|
||
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<T>::to_python` for each → **acquires GIL**
|
||
3. Calls the Python callable
|
||
4. **Releases GIL** → calls `PythonConverter<R>::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<A, B, C>` 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<C>::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<float>::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<T>` and a future `RemoteChannel<T>` (wrapping
|
||
a socket/queue) would share the same `push`/`pop` interface. Nodes never know whether
|
||
their channel is in-process or remote.
|
||
|
||
- **`Serializer<T>` trait** — parallel to `PythonConverter<T>` 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` | `<type_traits>` | C++17+ |
|
||
| `std::jthread` + `stop_token` | `<thread>` | 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<const T>`; 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<produce>(5);
|
||
auto dbl = make_node<double_it>(5);
|
||
auto sink = make_node<print_it>(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<double_it, in<"value">, 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<Image, Mask>`. Each output is routed to a different
|
||
downstream node. Demonstrates tuple normalisation, per-element channel push, and
|
||
`output<1>()` sub-indexing.
|
||
|
||
```cpp
|
||
std::tuple<Image, Mask> 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<const T>`, 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<const BigFrame> automatically
|
||
|
||
struct Tiny { float x, y; }; // 8 bytes — by value by default
|
||
template<> struct channel_storage_policy<Tiny> { 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 <kpn/kpn.hpp>
|
||
```
|
||
|
||
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 <kpn/kpn.hpp>
|
||
|
||
// ... 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 |
|