First attempt

This commit is contained in:
Duncan Tourolle 2026-05-08 17:48:16 +02:00
commit 5e77dc836b
36 changed files with 4806 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Build output
build/
# Python
__pycache__/
*.py[cod]
*.pyd
*.pyo
*.egg-info/
dist/
*.egg
.venv/
venv/
# Editors
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Claude Code local settings
.claude/settings.local.json

71
CMakeLists.txt Normal file
View File

@ -0,0 +1,71 @@
cmake_minimum_required(VERSION 3.21)
project(kpnpp VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(KPN_BUILD_TESTS "Build tests" ON)
option(KPN_BUILD_PYTHON "Build Python bindings (requires nanobind)" ON)
option(KPN_BUILD_EXAMPLES "Build examples" ON)
option(KPN_WEB_DEBUG "Enable web debug UI (cpp-httplib)" OFF)
# Core library (header-only)
add_library(kpn INTERFACE)
target_include_directories(kpn INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_compile_features(kpn INTERFACE cxx_std_20)
# Threads required by node/channel implementation
find_package(Threads REQUIRED)
target_link_libraries(kpn INTERFACE Threads::Threads)
# Web debug UI (optional)
if(KPN_WEB_DEBUG)
include(FetchContent)
FetchContent_Declare(
cpp-httplib
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
GIT_TAG v0.18.0
)
FetchContent_MakeAvailable(cpp-httplib)
# httplib made available but NOT forced onto kpn interface targets opt in
# by defining KPN_WEB_DEBUG=1 and linking httplib::httplib themselves.
# This prevents tests and other examples from pulling in the HTTP server.
endif()
# Convenience function for targets that want web debug
function(kpn_target_enable_web_debug target)
target_compile_definitions(${target} PRIVATE KPN_WEB_DEBUG=1)
target_link_libraries(${target} PRIVATE httplib::httplib)
endfunction()
# Tests
if(KPN_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
# Python bindings
if(KPN_BUILD_PYTHON)
find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED)
find_package(nanobind CONFIG QUIET)
if(NOT nanobind_FOUND)
# Fall back to FetchContent if nanobind not installed system-wide
include(FetchContent)
FetchContent_Declare(
nanobind
GIT_REPOSITORY https://github.com/wjakob/nanobind.git
GIT_TAG v2.12.0
)
FetchContent_MakeAvailable(nanobind)
endif()
add_subdirectory(python)
endif()
# Examples
if(KPN_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()

257
README.md Normal file
View File

@ -0,0 +1,257 @@
# KPN++
A C++20 Kahn Process Network (KPN) library. Each node wraps a function and runs in its own thread, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
---
## Requirements
| Dependency | Version | Notes |
|---|---|---|
| CMake | ≥ 3.21 | |
| C++ compiler | GCC ≥ 11, Clang ≥ 13, MSVC 19.29 | C++20 required |
| Threads | system | `find_package(Threads)` |
| nanobind | ≥ 2.1 | auto-fetched if not installed; Python ≥ 3.8 |
| Catch2 | v3 | auto-fetched for tests |
| Google Test | v1.14 | auto-fetched for tests |
| OpenCV | ≥ 4 | optional; only for example 09 |
---
## Build
```bash
cmake -B build -DKPN_BUILD_PYTHON=OFF # core + tests + C++ examples
cmake --build build --parallel
ctest --test-dir build
```
Enable Python bindings (requires nanobind and Python dev headers):
```bash
cmake -B build -DKPN_BUILD_PYTHON=ON
cmake --build build --parallel
```
Disable examples:
```bash
cmake -B build -DKPN_BUILD_EXAMPLES=OFF
```
---
## Core Concepts
### Nodes
A node wraps any callable. Its input types are taken from the function's parameter list; its output types from the return type. Multi-output nodes return `std::tuple<...>`.
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
// Single input, single output
int double_it(int x) { return x * 2; }
// Multi-output — must return std::tuple
std::tuple<cv::Mat, cv::Mat> split(cv::Mat frame) { ... }
// Sink — void return, no output ports
void display(cv::Mat frame) { cv::imshow("out", frame); }
```
### Creating Nodes
```cpp
// No port names (index-only access)
auto node = make_node<double_it>(/*fifo_capacity=*/5);
// Named input ports only
auto node = make_node<double_it>(in<"value">{}, 5);
// Named input and output ports
auto node = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
// Named output ports only (e.g. a source with no inputs)
auto node = make_node<capture>(out<"colour","grey">{}, 5);
```
Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost.
### Building a Network
`Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network.
```cpp
auto src = make_node<produce>(in<"x">{}, out<"value">{}, 5);
auto proc = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
Network net;
net.add("src", src)
.add("proc", proc)
.connect("src", src.output<0>(), "proc", proc.input<0>()) // by index
.connect("src", src.template output<"value">(), "proc", proc.template input<"value">()) // by name
.build(); // runs cycle detection — throws NetworkCycleError on cycles
net.start();
// ... do work ...
net.stop();
```
> **Named port syntax in template context:** when the node variable is `auto`-deduced, use `.template output<"name">()` and `.template input<"name">()` to help the parser.
### Channel Semantics
- **Bounded FIFO**: default capacity 5, configurable per-node at construction.
- **Blocking `pop()`**: consumer blocks until data is available (KPN semantics).
- **Throwing `push()`**: throws `ChannelOverflowError` if the channel is full and accepting.
- **Silent drop on disabled channel**: after `node.stop()`, its input channels are disabled — producers that push into them have the value silently dropped. No exception, no blocking.
- **Source throttling**: source nodes (no inputs) must sleep or yield to avoid overflowing downstream FIFOs. See example 09.
### Storage Policy
Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr<const T>` inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value.
Override the policy for a specific type:
```cpp
template<> struct kpn::channel_storage_policy<MyType> {
static constexpr bool by_value = true;
};
```
### Shutdown
`node.stop()` / `net.stop()`:
1. Sets `accepting_ = false` on all input channels (drops in-flight pushes silently).
2. Clears any queued items from those channels.
3. Unblocks any thread blocked on `pop()` (throws `ChannelClosedError` inside `run_loop`, which exits cleanly).
4. Joins the node thread.
---
## Named Ports — Design Notes
Port names use C++20 NTTP `fixed_string`. The deduction guide is required:
```cpp
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;
```
`fixed_string<4>` and `fixed_string<7>` are distinct types — `input<"img">()` and `input<"sigma">()` resolve to different template instantiations at compile time. Wrong names produce a `static_assert` at the call site with a readable message.
---
## Sub-Networks
`Network` implements `INode`, so it can be nested inside a larger `Network`:
```cpp
// Inner sub-network
Network pipe;
pipe.add("pre", pre_node)
.add("enh", enh_node)
.connect("pre", pre_node.output<0>(), "enh", enh_node.input<0>())
.expose_input("img", pre_node.input<0>())
.expose_output("result", enh_node.output<0>())
.build();
// Outer network
Network top;
top.add("pipe", pipe)
.add("sink", sink_node)
.connect("pipe", pipe.output<"result">(), "sink", sink_node.input<0>())
.build();
top.start();
```
---
## Display / GUI Nodes
**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, wire the final output channel to a `Channel<cv::Mat>` and pop it on the main thread:
```cpp
Channel<cv::Mat> result_ch(8);
comp.set_output_channel<0>(&result_ch);
// ... build and start network ...
// Main thread display loop
while (true) {
cv::Mat frame;
if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue;
cv::imshow("output", frame);
if (cv::waitKey(1) == 'q') break;
}
net.stop();
```
---
## Python Bindings
> Python bindings are scaffolded but not yet fully implemented. See `python/kpn_python.cpp` and `include/kpn/python/bindings.hpp`.
A `PyNetwork` is constructed from a closed list of C++ node types. The variant of all port types is derived at compile time — no runtime type registration needed.
**GIL rules (non-negotiable):**
- Acquire the GIL only for the duration of a Python callable invocation.
- Release the GIL before any blocking channel operation (`pop()`, `push()`, `net.read()`, `net.write()`).
Violating the second rule deadlocks.
---
## Examples
| Example | What it shows |
|---|---|
| `01_hello_pipeline` | Linear pipeline, index-based port wiring |
| `02_named_ports` | `in<>`/`out<>` name tags, named port access |
| `03_multi_output` | Tuple-returning node, per-element sub-port routing |
| `04_storage_policy` | `channel_storage_policy` default and specialisation |
| `05_error_handling` | `ChannelOverflowError`, `ErrorHandler` |
| `06_watchdog` | Watchdog interval, stall detection |
| `07_python_network` | PyNetwork, pure Python node *(pending)* |
| `08_python_subport` | `net.read`, `net.write`, sub-port tap *(pending)* |
| `09_opencv_cellshade` | Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4 |
Run the cell-shading example:
```bash
./build/examples/09_opencv_cellshade
# Press 'q' or close the window to stop.
# Falls back to an animated synthetic pattern if no webcam is found.
```
---
## Project Structure
```
include/kpn/
fixed_string.hpp — NTTP string, in<>/out<> tags, index_of
traits.hpp — function_traits, normalised_return_t, output_count_v
channel.hpp — Channel<T>, channel_storage_policy, exceptions
port.hpp — InputPort<N,I>, OutputPort<N,I>
node.hpp — Node<Func,in<...>,out<...>>, make_node, INode
network.hpp — Network (builder, cycle detection, watchdog)
variant_node.hpp — VariantNode, PythonConverter<T>, unique_types (Python layer)
python/
bindings.hpp — nanobind helpers, GIL rule documentation
kpn.hpp — umbrella header
src/
network.cpp — non-template Network implementation
tests/
test_fixed_string.cpp
test_traits.cpp
test_channel.cpp
test_node.cpp
test_network.cpp
python/
kpn_python.cpp — nanobind module entry point
examples/
01_hello_pipeline/ … 09_opencv_cellshade/
```

999
SPEC.md Normal file
View File

@ -0,0 +1,999 @@
# 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 (1050ms), orange (50100ms), 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 (5080%), 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 |

View File

@ -0,0 +1,38 @@
#include <kpn/kpn.hpp>
#include <iostream>
#include <chrono>
#include <thread>
// A minimal linear pipeline: producer → double → print
//
// [produce] --int--> [double_it] --int--> [print_it]
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
int main() {
using namespace kpn;
auto src = make_node<produce>(5);
auto dbl = make_node<double_it>(5);
auto sink = make_node<print_it>(5);
// Wire channels
auto& dbl_in = dbl.input_channel<0>();
auto& sink_in = sink.input_channel<0>();
src.set_output_channel<0>(&dbl_in);
dbl.set_output_channel<0>(&sink_in);
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();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
net.stop();
}

View File

@ -0,0 +1,3 @@
#include <kpn/kpn.hpp>
// TODO: implement example
int main() { return 0; }

View File

@ -0,0 +1,3 @@
#include <kpn/kpn.hpp>
// TODO: implement example
int main() { return 0; }

View File

@ -0,0 +1,3 @@
#include <kpn/kpn.hpp>
// TODO: implement example
int main() { return 0; }

View File

@ -0,0 +1,3 @@
#include <kpn/kpn.hpp>
// TODO: implement example
int main() { return 0; }

View File

@ -0,0 +1,3 @@
#include <kpn/kpn.hpp>
// TODO: implement example
int main() { return 0; }

View File

@ -0,0 +1,32 @@
"""
07_python_network hello pipeline with a Python node in the middle.
Graph:
[ProduceNode] --int--> [py_double] --int--> [PrintItNode]
ProduceNode and PrintItNode are C++ nodes wrapped in VariantNodeWrapper.
py_double is a pure Python callable doubles its input using Python arithmetic.
"""
import sys
import time
sys.path.insert(0, "build/python")
import kpn_python as kpn
def py_double(x: int) -> int:
return x * 2
net = kpn.Network()
net.add("src", kpn.make_produce())
net.add_node("dbl", py_double, inputs=["int"], outputs=["int"])
net.add("sink", kpn.make_print_it())
net.connect("src", 0, "dbl", 0)
net.connect("dbl", 0, "sink", 0)
net.build()
net.start()
time.sleep(0.1)
net.stop()

View File

@ -0,0 +1,38 @@
"""
08_python_subport tap a C++ node's output from Python using net.read().
Graph:
[ProduceNode] --int--> [DoubleItNode] --int--> (tapped by net.read())
The sink is Python: instead of connecting a PrintItNode, we call net.read()
to pull values out of DoubleItNode's output directly into Python.
We also demonstrate net.write() by injecting a value into DoubleItNode's input.
"""
import sys
import time
import threading
sys.path.insert(0, "build/python")
import kpn_python as kpn
net = kpn.Network()
net.add("src", kpn.make_produce())
net.add("dbl", kpn.make_double_it())
net.connect("src", 0, "dbl", 0)
net.build()
net.start()
# Collect a few values from DoubleItNode's output via Python tap
results = []
for _ in range(5):
val = net.read("dbl", 0)
results.append(val)
net.stop()
print("values read from C++ DoubleItNode output:", results)
assert all(v == 84 for v in results), f"expected all 84, got {results}"
print("all correct (42 * 2 = 84)")

View File

@ -0,0 +1,53 @@
#include <opencv2/videoio.hpp>
#include <chrono>
#include <algorithm>
#include <numeric>
#include <vector>
#include <cstdio>
static void bench(const char* name, int device, int backend, int W, int H, int frames) {
cv::VideoCapture cap(device, backend);
if (!cap.isOpened()) {
std::printf("%-12s failed to open /dev/video%d\n", name, device);
return;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, W);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, H);
// Warm up
for (int i = 0; i < 5; ++i) cap.grab();
std::vector<double> times;
times.reserve(frames);
for (int i = 0; i < frames; ++i) {
auto t0 = std::chrono::steady_clock::now();
bool ok = cap.grab();
double ms = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - t0).count();
if (ok) times.push_back(ms);
}
cap.release();
if (times.empty()) {
std::printf("%-12s no frames captured\n", name);
return;
}
std::sort(times.begin(), times.end());
double avg = std::accumulate(times.begin(), times.end(), 0.0) / times.size();
double mn = times.front();
double mx = times.back();
double p95 = times[times.size() * 95 / 100];
std::printf("%-12s avg=%6.1fms min=%5.1fms p95=%6.1fms max=%6.1fms (%zu frames)\n",
name, avg, mn, p95, mx, times.size());
}
int main(int argc, char** argv) {
int device = argc > 1 ? std::atoi(argv[1]) : 0;
int frames = argc > 2 ? std::atoi(argv[2]) : 60;
int W = 1920, H = 1080;
std::printf("Benchmarking /dev/video%d at %dx%d, %d frames each\n\n", device, W, H, frames);
bench("V4L2", device, cv::CAP_V4L2, W, H, frames);
bench("GStreamer", device, cv::CAP_GSTREAMER, W, H, frames);
return 0;
}

View File

@ -0,0 +1,190 @@
#include <kpn/kpn.hpp>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/highgui.hpp>
#include <opencv2/videoio.hpp>
#include <iostream>
#include <tuple>
#include <thread>
#include <chrono>
// ── Cell-shading pipeline ─────────────────────────────────────────────────────
//
// [capture] --"colour"--> [quantise] ──────────────────────────┐
// ├──> [composite] ──"result"──┐
// [capture] --"grey"---> [to_gray] --> [edges] ──"edges"───────┘ │
// └──────────────────────────────"edges"──────────┴──> [display]
//
// DisplayNode derives from MainThreadNode<> — two inputs (composite + raw edges),
// two windows opened in the constructor. step() is called on the main thread.
// ── Gradient base for synthetic pattern ──────────────────────────────────────
static cv::Mat make_gradient(int W, int H) {
cv::Mat xr(H, W, CV_8UC1), yg(H, W, CV_8UC1), b(H, W, CV_8UC1, cv::Scalar(128));
for (int x = 0; x < W; ++x) xr.col(x).setTo(x * 255 / W);
for (int y = 0; y < H; ++y) yg.row(y).setTo(y * 255 / H);
cv::Mat channels[3] = {b, yg, xr};
cv::Mat grad;
cv::merge(channels, 3, grad);
return grad;
}
// ── Pipeline functions ────────────────────────────────────────────────────────
static std::tuple<cv::Mat, cv::Mat> capture() {
constexpr int W = 640, H = 480;
static cv::VideoCapture cap;
static bool opened = false;
if (!opened) {
opened = true;
cap.open(0, cv::CAP_V4L2);
if (cap.isOpened()) {
cap.set(cv::CAP_PROP_FRAME_WIDTH, W);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, H);
} else {
std::cerr << "[capture] no webcam — using synthetic animated pattern\n";
}
}
cv::Mat frame;
if (cap.isOpened()) {
auto t0 = std::chrono::steady_clock::now();
cap >> frame;
auto elapsed = std::chrono::steady_clock::now() - t0;
if (elapsed < std::chrono::milliseconds(20))
std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed);
if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3);
} else {
static int tick = 0;
static cv::Mat grad = make_gradient(W, H);
++tick;
frame = grad.clone();
int r = 150 + (tick % 80) * 4;
cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1);
cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1);
cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1);
std::this_thread::sleep_for(std::chrono::milliseconds(33));
}
return {frame.clone(), frame.clone()};
}
static cv::Mat to_gray(cv::Mat bgr) {
cv::Mat gray;
cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
return gray;
}
static cv::Mat edges_fn(cv::Mat gray) {
cv::Mat blurred, mask;
cv::GaussianBlur(gray, blurred, {5, 5}, 0);
cv::Canny(blurred, mask, 50, 150);
return mask;
}
static cv::Mat quantise(cv::Mat bgr) {
constexpr int levels = 4;
constexpr double step = 256.0 / levels;
static const cv::Mat lut = []() {
cv::Mat l(1, 256, CV_8UC1);
for (int i = 0; i < 256; ++i)
l.at<uchar>(i) = cv::saturate_cast<uchar>(
std::floor(i / step) * step + step / 2.0);
return l;
}();
cv::Mat out;
cv::LUT(bgr, lut, out);
return out;
}
// Returns both the composite frame and the original edge mask so downstream
// nodes (display) can receive both without fan-out on the edges channel.
static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour) {
cv::Mat result = colour.clone();
result.setTo(cv::Scalar(0, 0, 0), edge_mask);
return {result, edge_mask};
}
// ── DisplayNode ───────────────────────────────────────────────────────────────
//
// Two inputs: the cell-shaded composite frame and the raw edge mask.
// MainThreadNode<> provides channel ownership, INode boilerplate, and stats.
// The constructor opens both windows on the main thread (Wayland requirement).
// operator() is called by step() whenever both channels have a frame ready.
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
kpn::in<"composite", "edges">,
cv::Mat, cv::Mat> {
public:
DisplayNode() : MainThreadNode(8) {
cv::namedWindow("Cell Shade", cv::WINDOW_NORMAL);
cv::namedWindow("Edge Mask", cv::WINDOW_NORMAL);
cv::resizeWindow("Cell Shade", 1280, 720);
cv::resizeWindow("Edge Mask", 640, 360);
}
~DisplayNode() { cv::destroyAllWindows(); }
bool operator()(cv::Mat composite, cv::Mat edges) {
cv::imshow("Cell Shade", composite);
cv::Mat edges_bgr;
cv::cvtColor(edges, edges_bgr, cv::COLOR_GRAY2BGR);
cv::imshow("Edge Mask", edges_bgr);
int key = cv::waitKey(1);
if (key == 'q' || key == 27) return false;
return window_open("Cell Shade") && window_open("Edge Mask");
}
private:
static bool window_open(const char* name) {
try { return cv::getWindowProperty(name, cv::WND_PROP_VISIBLE) >= 1; }
catch (const cv::Exception&) { return false; }
}
};
// ─────────────────────────────────────────────────────────────────────────────
int main() {
using namespace kpn;
auto src = make_node<capture> (out<"colour","grey">{}, 8);
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
auto edge_node = make_node<edges_fn> (in<"gray">{}, out<"edges">{}, 8);
auto quant = make_node<quantise> (in<"bgr">{}, out<"quantised">{}, 8);
auto comp = make_node<composite>(in<"edges","colour">{}, out<"result","edges">{}, 8);
// DisplayNode: two windows opened in constructor, step() drives main thread.
DisplayNode disp;
Network net;
net.add("src", src)
.add("gray", gray_node)
.add("edges", edge_node)
.add("quant", quant)
.add("comp", comp)
.add("display", disp)
.connect("src", src.template output<"colour">(), "quant", quant.template input<"bgr">())
.connect("quant", quant.template output<"quantised">(), "comp", comp.template input<"colour">())
.connect("src", src.template output<"grey">(), "gray", gray_node.template input<"bgr">())
.connect("gray", gray_node.template output<"gray">(), "edges", edge_node.template input<"gray">())
.connect("edges", edge_node.template output<"edges">(), "comp", comp.template input<"edges">())
.connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">())
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
.build();
net.set_watchdog_interval(std::chrono::milliseconds(5000));
net.set_web_debug_port(9090);
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
std::cout << "Web debug UI: http://localhost:9090\n";
net.start();
// Main thread drives display — imshow/waitKey stay on the GUI thread.
// step() returns false when operator() returns false (q pressed / window closed).
while (disp.step())
cv::waitKey(8); // yield event loop when no frame ready
net.stop();
return 0;
}

30
examples/CMakeLists.txt Normal file
View File

@ -0,0 +1,30 @@
cmake_minimum_required(VERSION 3.21)
function(kpn_example name)
add_executable(${name} ${name}/main.cpp)
target_link_libraries(${name} PRIVATE kpn)
endfunction()
kpn_example(01_hello_pipeline)
kpn_example(02_named_ports)
kpn_example(03_multi_output)
kpn_example(04_storage_policy)
kpn_example(05_error_handling)
kpn_example(06_watchdog)
# 07 and 08 require the Python bindings only add if built
if(KPN_BUILD_PYTHON)
# These are Python scripts, not compiled targets installed alongside kpn_python
endif()
# 09 requires OpenCV only build if found
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
if(OpenCV_FOUND)
add_executable(09_opencv_cellshade 09_opencv_cellshade/main.cpp)
target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS})
if(KPN_WEB_DEBUG)
kpn_target_enable_web_debug(09_opencv_cellshade)
endif()
message(STATUS "KPN++ example 09_opencv_cellshade: OpenCV ${OpenCV_VERSION} found — building")
else()
message(STATUS "KPN++ example 09_opencv_cellshade: OpenCV not found — skipping")
endif()

175
include/kpn/channel.hpp Normal file
View File

@ -0,0 +1,175 @@
#pragma once
#include "diagnostics.hpp"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <string>
#include <type_traits>
namespace kpn {
// ── Storage policy ────────────────────────────────────────────────────────────
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>
>;
// ── Exceptions ────────────────────────────────────────────────────────────────
class ChannelOverflowError : public std::runtime_error {
public:
explicit ChannelOverflowError(std::size_t capacity)
: std::runtime_error("channel overflow: capacity " + std::to_string(capacity) +
" exceeded") {}
ChannelOverflowError(std::size_t capacity, std::string context)
: std::runtime_error(std::move(context) + ": capacity " + std::to_string(capacity) +
" exceeded") {}
};
class ChannelClosedError : public std::runtime_error {
public:
ChannelClosedError() : std::runtime_error("channel closed") {}
};
// ── Channel ───────────────────────────────────────────────────────────────────
template<typename T>
class Channel {
public:
using storage_type = channel_storage_t<T>;
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
Channel(const Channel&) = delete;
Channel& operator=(const Channel&) = delete;
// Push a value.
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
// - If channel is full: throw ChannelOverflowError.
void push(T value) {
if (!accepting_.load(std::memory_order_relaxed)) {
stats_.record_drop();
return;
}
std::unique_lock lock(mutex_);
if (!accepting_.load(std::memory_order_relaxed)) {
stats_.record_drop();
return;
}
if (queue_.size() >= capacity_) {
stats_.record_overflow();
throw ChannelOverflowError(capacity_);
}
queue_.push(make_storage(std::move(value)));
stats_.record_push(queue_.size());
lock.unlock();
cv_.notify_one();
}
// Blocking pop. Unblocks when an item is available or the channel is disabled.
// Throws ChannelClosedError if disabled and queue is empty.
T pop() {
std::unique_lock lock(mutex_);
cv_.wait(lock, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
});
if (queue_.empty())
throw ChannelClosedError{};
T value = extract(std::move(queue_.front()));
queue_.pop();
stats_.record_pop();
return value;
}
// Non-blocking pop with timeout. For watchdog/display use only — not used in run_loop.
bool try_pop(T& out, std::chrono::milliseconds timeout) {
std::unique_lock lock(mutex_);
if (!cv_.wait_for(lock, timeout, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
}))
return false;
if (queue_.empty())
return false;
out = extract(std::move(queue_.front()));
queue_.pop();
stats_.record_pop();
return true;
}
// Enable the channel (called by consumer node on start()).
void enable() {
accepting_.store(true, std::memory_order_relaxed);
}
// Disable the channel: drop all queued items, unblock any waiting pop().
// Called by consumer node on stop(). Producer push() will silently drop after this.
void disable() {
accepting_.store(false, std::memory_order_relaxed);
{
std::lock_guard lock(mutex_);
while (!queue_.empty()) queue_.pop();
}
cv_.notify_all();
}
std::size_t size() const {
std::lock_guard lock(mutex_);
return queue_.size();
}
std::size_t capacity() const { return capacity_; }
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
const ChannelStats& stats() const { return stats_; }
ChannelSnapshot snapshot(const std::string& name) const {
std::lock_guard lock(mutex_);
return {
name,
capacity_,
queue_.size(),
stats_.peak_fill.load(std::memory_order_relaxed),
stats_.pushes.load(std::memory_order_relaxed),
stats_.drops.load(std::memory_order_relaxed),
stats_.overflows.load(std::memory_order_relaxed),
stats_.pops.load(std::memory_order_relaxed),
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
};
}
private:
static storage_type make_storage(T&& v) {
if constexpr (channel_storage_policy<T>::by_value)
return std::move(v);
else
return std::make_shared<const T>(std::move(v));
}
static T extract(storage_type&& s) {
if constexpr (channel_storage_policy<T>::by_value)
return std::move(s);
else
return *s;
}
const std::size_t capacity_;
std::queue<storage_type> queue_;
std::atomic<bool> accepting_{true};
mutable std::mutex mutex_;
std::condition_variable cv_;
ChannelStats stats_;
};
} // namespace kpn

137
include/kpn/diagnostics.hpp Normal file
View File

@ -0,0 +1,137 @@
#pragma once
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <string>
#include <time.h> // clock_gettime, CLOCK_THREAD_CPUTIME_ID
namespace kpn {
using clock_t = std::chrono::steady_clock;
using duration_t = std::chrono::duration<double, std::milli>; // milliseconds
// ── Per-channel statistics ────────────────────────────────────────────────────
struct ChannelStats {
std::atomic<uint64_t> pushes{0};
std::atomic<uint64_t> drops{0};
std::atomic<uint64_t> overflows{0};
std::atomic<uint64_t> pops{0};
std::atomic<std::size_t> peak_fill{0};
ChannelStats() = default;
ChannelStats(const ChannelStats&) = delete;
ChannelStats& operator=(const ChannelStats&) = delete;
void record_push(std::size_t current_fill) {
pushes.fetch_add(1, std::memory_order_relaxed);
std::size_t prev = peak_fill.load(std::memory_order_relaxed);
while (current_fill > prev &&
!peak_fill.compare_exchange_weak(prev, current_fill,
std::memory_order_relaxed, std::memory_order_relaxed))
;
}
void record_drop() { drops.fetch_add(1, std::memory_order_relaxed); }
void record_overflow() { overflows.fetch_add(1, std::memory_order_relaxed); }
void record_pop() { pops.fetch_add(1, std::memory_order_relaxed); }
};
// ── Per-node statistics ───────────────────────────────────────────────────────
struct NodeStats {
std::atomic<uint64_t> frames_processed{0};
// Wall-clock execution time EMA — warmup mean for first WARMUP_FRAMES,
// then EMA alpha=0.1. Stored as integer microseconds for atomic updates.
static constexpr int WARMUP_FRAMES = 5;
std::atomic<int64_t> ema_exec_us{0};
std::atomic<int64_t> max_exec_us{0};
std::atomic<int64_t> total_blocked_us{0};
// Thread CPU time — actual CPU consumed by this node's thread,
// measured via CLOCK_THREAD_CPUTIME_ID. Excludes time sleeping or
// blocked on mutexes/channels. Sampled once per frame.
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
NodeStats() = default;
NodeStats(const NodeStats&) = delete;
NodeStats& operator=(const NodeStats&) = delete;
// Call at the start of run_loop to capture thread CPU baseline.
// Returns the raw timespec for use in record_exec.
static struct timespec cpu_now() {
struct timespec ts{};
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts);
return ts;
}
static int64_t timespec_us(const struct timespec& ts) {
return static_cast<int64_t>(ts.tv_sec) * 1'000'000
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
}
void record_exec(duration_t exec_time, duration_t blocked_time,
const struct timespec& cpu_before, const struct timespec& cpu_after) {
frames_processed.fetch_add(1, std::memory_order_relaxed);
int64_t us = static_cast<int64_t>(exec_time.count() * 1000.0);
uint64_t n = frames_processed.load(std::memory_order_relaxed);
int64_t prev = ema_exec_us.load(std::memory_order_relaxed);
int64_t next = (n <= static_cast<uint64_t>(WARMUP_FRAMES))
? prev + (us - prev) / static_cast<int64_t>(n)
: prev + (us - prev) / 10;
ema_exec_us.store(next, std::memory_order_relaxed);
int64_t cur_max = max_exec_us.load(std::memory_order_relaxed);
if (us > cur_max)
max_exec_us.store(us, std::memory_order_relaxed);
int64_t blocked_us = static_cast<int64_t>(blocked_time.count() * 1000.0);
total_blocked_us.fetch_add(blocked_us, std::memory_order_relaxed);
int64_t cpu_delta = timespec_us(cpu_after) - timespec_us(cpu_before);
if (cpu_delta > 0)
total_cpu_us.fetch_add(cpu_delta, std::memory_order_relaxed);
}
};
// ── Snapshot for reporting (copyable, taken by watchdog) ─────────────────────
struct ChannelSnapshot {
std::string name;
std::size_t capacity;
std::size_t current_fill;
std::size_t peak_fill;
uint64_t pushes;
uint64_t drops;
uint64_t overflows;
uint64_t pops;
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
double fill_pct() const {
return capacity ? 100.0 * current_fill / capacity : 0.0;
}
double peak_pct() const {
return capacity ? 100.0 * peak_fill / capacity : 0.0;
}
// Bandwidth in MB/s: bytes transferred / elapsed seconds
double bandwidth_mbs(double elapsed_s) const {
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
}
};
struct NodeSnapshot {
std::string name;
uint64_t frames_processed;
double ema_exec_ms;
double max_exec_ms;
double total_blocked_ms;
double throughput_fps;
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
};
} // namespace kpn

View File

@ -0,0 +1,46 @@
#pragma once
#include <algorithm>
#include <string_view>
#include <cstddef>
namespace kpn {
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;
template<std::size_t M>
constexpr bool operator==(const fixed_string<M>&) const { return false; }
constexpr std::string_view view() const { return {data, N - 1}; }
};
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;
// ── Port name pack lookup ─────────────────────────────────────────────────────
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;
auto check = [&](auto n) {
if (!found) {
if (Name == n) found = true;
else ++i;
}
};
(check(Names), ...);
return found ? i : npos;
}
// ── in<> / out<> name tag structs ─────────────────────────────────────────────
template<fixed_string... Names> struct in {};
template<fixed_string... Names> struct out {};
} // namespace kpn

10
include/kpn/kpn.hpp Normal file
View File

@ -0,0 +1,10 @@
#pragma once
// Convenience umbrella header — include this to get the full C++ API.
#include "fixed_string.hpp"
#include "traits.hpp"
#include "channel.hpp"
#include "port.hpp"
#include "node.hpp"
#include "main_thread_node.hpp"
#include "network.hpp"

View File

@ -0,0 +1,186 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "node.hpp" // INode
#include "port.hpp"
#include <atomic>
#include <chrono>
#include <cstddef>
#include <memory>
#include <optional>
#include <tuple>
namespace kpn {
// ── MainThreadNode ────────────────────────────────────────────────────────────
//
// Base class for nodes that must run on the main thread (e.g. OpenCV display
// on Wayland/Qt). Registered as a normal INode in the Network so it appears
// in diagnostics and the web UI, but spawns no thread.
//
// Usage:
// class MyDisplay : public kpn::MainThreadNode<MyDisplay, in<"a","b">, TypeA, TypeB> {
// public:
// MyDisplay(...) { /* constructor runs on main thread */ }
// bool operator()(TypeA a, TypeB b) { ...; return true; /* false = stop */ }
// };
//
// MyDisplay disp(...);
// net.add("display", disp).connect(...).build();
// net.start();
// while (disp.step()) ; // drives the event loop on the main thread
// net.stop();
//
// step() behaviour:
// - try_pop on every input channel with zero timeout
// - if all inputs have data: calls operator(), records stats, returns its result
// - if any input is missing: returns true immediately (caller should yield/waitKey)
template<typename Derived, typename InputTag, typename... Args>
class MainThreadNode;
template<typename Derived, fixed_string... InNames, typename... Args>
class MainThreadNode<Derived, in<InNames...>, Args...> : public INode {
public:
static constexpr std::size_t input_count = sizeof...(Args);
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"MainThreadNode: name count must match input type count, or provide none"
);
using args_tuple = std::tuple<Args...>; // required by Network::connect type check
explicit MainThreadNode(std::size_t fifo_capacity = 8) {
init_channels(std::make_index_sequence<input_count>{}, fifo_capacity);
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_channels(std::make_index_sequence<input_count>{});
running_.store(true, std::memory_order_relaxed);
}
void stop() override {
running_.store(false, std::memory_order_relaxed);
disable_channels(std::make_index_sequence<input_count>{});
}
bool running() const override {
return running_.load(std::memory_order_relaxed);
}
void set_name(std::string) override {}
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name, frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
};
}
// ── Port access (for Network::connect) ───────────────────────────────────
template<std::size_t I>
Channel<std::tuple_element_t<I, std::tuple<Args...>>>& input_channel() {
return *std::get<I>(channels_);
}
template<std::size_t I>
InputPort<MainThreadNode, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
// ── Main-thread driver ────────────────────────────────────────────────────
// Call this in a loop on the main thread instead of net.start()'s thread.
// Returns false when operator() returns false or all channels are closed.
bool step() {
if (!running_.load(std::memory_order_relaxed)) return false;
auto t0 = clock_t::now();
auto inputs = try_pop_all(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
if (!inputs.has_value()) return true; // not all inputs ready — yield
auto cpu0 = NodeStats::cpu_now();
bool cont = std::apply(
[this](Args&&... a) {
return static_cast<Derived*>(this)->operator()(std::forward<Args>(a)...);
},
std::move(*inputs));
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
return cont;
}
private:
template<std::size_t... Is>
void init_channels(std::index_sequence<Is...>, std::size_t cap) {
((std::get<Is>(channels_) =
std::make_unique<Channel<std::tuple_element_t<Is, args_tuple>>>(cap)), ...);
}
template<std::size_t... Is>
void enable_channels(std::index_sequence<Is...>) {
(std::get<Is>(channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_channels(std::index_sequence<Is...>) {
(std::get<Is>(channels_)->disable(), ...);
}
// Try to pop one item from every channel with zero timeout.
// Returns nullopt if any channel has no data ready.
template<std::size_t... Is>
std::optional<args_tuple> try_pop_all(std::index_sequence<Is...>) {
args_tuple result;
bool all_ready = true;
// Use a fold that short-circuits on first missing item
((all_ready = all_ready &&
std::get<Is>(channels_)->try_pop(
std::get<Is>(result), std::chrono::milliseconds(0))), ...);
if (!all_ready) return std::nullopt;
return result;
}
// Build the channel tuple type
template<typename Tup, std::size_t... Is>
static auto make_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::unique_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using channels_t = decltype(make_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
channels_t channels_;
std::atomic<bool> running_{false};
NodeStats stats_;
};
} // namespace kpn

336
include/kpn/network.hpp Normal file
View File

@ -0,0 +1,336 @@
#pragma once
#include "diagnostics.hpp"
#include "node.hpp"
#include "port.hpp"
#ifdef KPN_WEB_DEBUG
#include "web_debug.hpp"
#include <memory>
#endif
#include <functional>
#include <iomanip>
#include <iostream>
#include <map>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
namespace kpn {
// ── Exceptions ────────────────────────────────────────────────────────────────
class NetworkCycleError : public std::runtime_error {
public:
NetworkCycleError() : std::runtime_error("network graph contains a directed cycle") {}
};
class NetworkBuildError : public std::runtime_error {
public:
explicit NetworkBuildError(std::string msg)
: std::runtime_error(std::move(msg)) {}
};
// ── Channel snapshot accessor — type-erased so Network can collect them ───────
struct IChannelProbe {
virtual ~IChannelProbe() = default;
virtual ChannelSnapshot snapshot() const = 0;
};
template<typename T>
struct ChannelProbe : IChannelProbe {
const Channel<T>& ch;
std::string name;
ChannelProbe(const Channel<T>& c, std::string n) : ch(c), name(std::move(n)) {}
ChannelSnapshot snapshot() const override { return ch.snapshot(name); }
};
// ── Network ───────────────────────────────────────────────────────────────────
class Network : public INode {
public:
using ErrorHandler =
std::function<void(std::string_view node_name, std::exception_ptr)>;
using DiagnosticsHandler =
std::function<void(const std::vector<NodeSnapshot>&,
const std::vector<ChannelSnapshot>&)>;
// ── Builder API ───────────────────────────────────────────────────────────
template<typename NodeT>
Network& add(std::string name, NodeT& node) {
if (nodes_.count(name))
throw NetworkBuildError("duplicate node name: " + name);
node.set_name(name);
nodes_.emplace(name, &node);
adj_[name];
return *this;
}
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>) {
using out_t = std::tuple_element_t<SrcIdx, typename SrcNode::return_tuple>;
using dst_in_t = std::tuple_element_t<DstIdx, typename DstNode::args_tuple>;
static_assert(std::is_same_v<out_t, dst_in_t>,
"connect: output type does not match input type");
auto* src = dynamic_cast<SrcNode*>(nodes_.at(src_name));
auto* dst = dynamic_cast<DstNode*>(nodes_.at(dst_name));
if (!src) throw NetworkBuildError("node '" + src_name + "' type mismatch");
if (!dst) throw NetworkBuildError("node '" + dst_name + "' type mismatch");
auto& in_ch = dst->template input_channel<DstIdx>();
src->template set_output_channel<SrcIdx>(&in_ch);
// Register channel probe for diagnostics
std::string ch_name = src_name + ":" + std::to_string(SrcIdx)
+ "" + dst_name + ":" + std::to_string(DstIdx);
channel_probes_.push_back(
std::make_unique<ChannelProbe<out_t>>(in_ch, ch_name));
adj_[src_name].push_back(dst_name);
return *this;
}
template<typename NodeT, std::size_t Idx>
Network& expose_input(std::string boundary_name, InputPort<NodeT, Idx>) {
exposed_inputs_[boundary_name] = boundary_name;
return *this;
}
template<typename NodeT, std::size_t Idx>
Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>) {
exposed_outputs_[boundary_name] = boundary_name;
return *this;
}
Network& build() {
topo_.clear();
std::map<std::string, int> color;
for (auto& [name, _] : nodes_)
if (color[name] == 0)
dfs(name, color);
return *this;
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
start_time_ = clock_t::now();
for (auto& name : topo_)
nodes_.at(name)->start();
start_watchdog();
#ifdef KPN_WEB_DEBUG
web_server_ = std::make_unique<web_debug::WebDebugServer>(
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
#endif
}
void stop() override {
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
stop_watchdog();
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
nodes_.at(*it)->stop();
}
bool running() const override { return watchdog_.joinable(); }
void set_name(std::string) override {}
const NodeStats& stats() const override {
static NodeStats dummy;
return dummy;
}
NodeSnapshot node_snapshot(const std::string& name, double) const override {
return {name, 0, 0, 0, 0, 0, 0, 0};
}
// ── Configuration ─────────────────────────────────────────────────────────
void set_watchdog_interval(std::chrono::milliseconds interval) {
watchdog_interval_ = interval;
}
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
void set_diagnostics_handler(DiagnosticsHandler h) { diag_handler_ = std::move(h); }
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
#endif
// Print a diagnostics report to a stream (default: stderr).
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
void print_diagnostics(std::ostream& os = std::cerr) const {
auto s = collect_snapshots();
os << format_report(s.nodes, s.channels, s.elapsed_s);
}
private:
// ── Diagnostics collection ────────────────────────────────────────────────
struct Snapshots {
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
double elapsed_s;
};
Snapshots collect_snapshots() const {
double elapsed_s = std::chrono::duration<double>(
clock_t::now() - start_time_).count();
std::vector<NodeSnapshot> nodes;
for (auto& name : topo_)
nodes.push_back(nodes_.at(name)->node_snapshot(name, elapsed_s));
std::vector<ChannelSnapshot> channels;
for (auto& probe : channel_probes_)
channels.push_back(probe->snapshot());
return {std::move(nodes), std::move(channels), elapsed_s};
}
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
std::ostringstream os;
os << std::fixed << std::setprecision(1);
os << "\n┌─ KPN++ Diagnostics ────────────────────────────────────────────────────────\n";
// Node table
os << "│ Nodes:\n";
os << "" << std::left
<< std::setw(16) << "name"
<< std::setw(10) << "frames"
<< std::setw(12) << "exec ms"
<< std::setw(12) << "max ms"
<< std::setw(14) << "blocked ms"
<< std::setw(10) << "fps"
<< std::setw(12) << "cpu ms"
<< std::setw(10) << "util%"
<< "\n" << std::string(92, '-') << "\n";
for (auto& n : nodes) {
os << "" << std::left
<< std::setw(16) << n.name
<< std::setw(10) << n.frames_processed
<< std::setw(12) << n.ema_exec_ms
<< std::setw(12) << n.max_exec_ms
<< std::setw(14) << n.total_blocked_ms
<< std::setw(10) << n.throughput_fps
<< std::setw(12) << n.total_cpu_ms
<< std::setw(10) << n.cpu_util_pct
<< "\n";
}
// Channel table
os << "\n│ Channels:\n";
os << "" << std::left
<< std::setw(40) << "edge"
<< std::setw(8) << "fill%"
<< std::setw(8) << "peak%"
<< std::setw(8) << "pushes"
<< std::setw(8) << "drops"
<< std::setw(8) << "oflow"
<< std::setw(12) << "MB/s"
<< std::setw(10) << "item B"
<< "\n" << std::string(102, '-') << "\n";
for (auto& c : channels) {
std::string flag = c.fill_pct() >= 80.0 ? " <<<" :
c.peak_pct() >= 80.0 ? " (peak)" : "";
os << "" << std::left
<< std::setw(40) << c.name
<< std::setw(8) << c.fill_pct()
<< std::setw(8) << c.peak_pct()
<< std::setw(8) << c.pushes
<< std::setw(8) << c.drops
<< std::setw(8) << c.overflows
<< std::setw(12) << c.bandwidth_mbs(elapsed_s)
<< std::setw(10) << c.item_bytes
<< flag << "\n";
}
// Bottleneck hint: node with highest ema_exec_ms
if (!nodes.empty()) {
auto it = std::max_element(nodes.begin(), nodes.end(),
[](const NodeSnapshot& a, const NodeSnapshot& b) {
return a.ema_exec_ms < b.ema_exec_ms;
});
os << "\n│ Bottleneck hint: '" << it->name
<< "' (avg exec " << it->ema_exec_ms << " ms)\n";
}
os << "└────────────────────────────────────────────────────────────────\n";
return os.str();
}
// ── Cycle detection / topological sort ───────────────────────────────────
void dfs(const std::string& name, std::map<std::string, int>& color) {
color[name] = 1;
for (auto& nbr : adj_[name]) {
if (color[nbr] == 1) throw NetworkCycleError{};
if (color[nbr] == 0) dfs(nbr, color);
}
color[name] = 2;
topo_.insert(topo_.begin(), name);
}
// ── Watchdog ──────────────────────────────────────────────────────────────
void start_watchdog() {
watchdog_ = std::jthread([this](std::stop_token tok) {
while (!tok.stop_requested()) {
std::this_thread::sleep_for(watchdog_interval_);
if (tok.stop_requested()) break;
auto s = collect_snapshots();
if (diag_handler_) {
diag_handler_(s.nodes, s.channels);
} else {
std::cerr << format_report(s.nodes, s.channels, s.elapsed_s);
}
}
});
}
void stop_watchdog() {
if (watchdog_.joinable())
watchdog_.request_stop(), watchdog_.join();
}
// ── State ─────────────────────────────────────────────────────────────────
std::map<std::string, INode*> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
std::map<std::string, std::string> exposed_inputs_;
std::map<std::string, std::string> exposed_outputs_;
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
ErrorHandler error_handler_;
DiagnosticsHandler diag_handler_;
std::chrono::milliseconds watchdog_interval_{3000};
std::jthread watchdog_;
clock_t::time_point start_time_;
#ifdef KPN_WEB_DEBUG
uint16_t web_debug_port_{9090};
std::unique_ptr<web_debug::WebDebugServer> web_server_;
#endif
};
} // namespace kpn

565
include/kpn/node.hpp Normal file
View File

@ -0,0 +1,565 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "port.hpp"
#include "traits.hpp"
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
namespace kpn {
// ── INode — type-erased interface for Network / watchdog ─────────────────────
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 name) = 0;
};
// ── Node ─────────────────────────────────────────────────────────────────────
//
// Template parameters:
// Func — the wrapped function (auto NTTP, deduced as a function pointer)
// InputNames — optional kpn::in<"a","b"> tag type (at most one)
// OutputNames — optional kpn::out<"x","y"> tag type (at most one)
template<auto Func, typename InputTag = in<>, typename OutputTag = out<>>
class Node;
// Specialisation that unpacks the in<>/out<> tag packs
template<auto Func, fixed_string... InNames, fixed_string... OutNames>
class Node<Func, in<InNames...>, out<OutNames...>> : public INode {
public:
using F = decltype(Func);
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match function arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit Node(std::size_t fifo_capacity = 5)
: fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
~Node() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
// Disable all input channels: drops queued items and unblocks waiting pop()
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name,
frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
};
}
// ── Port access — by index ────────────────────────────────────────────────
template<std::size_t I>
InputPort<Node, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<Node, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
// ── Port access — by name ─────────────────────────────────────────────────
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
// ── Internal channel accessors (used by Network at connect time) ──────────
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
// Replace the owned input channel with an externally provided one.
// Used by VariantNodeWrapper to share a Channel<T> with a VariantChannel adapter.
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
// ── Channel storage ───────────────────────────────────────────────────────
// Input channels — shared ownership so VariantChannel adapters can share them
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
// Output channels — non-owning pointers, set at connect time
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
// ── run_loop ──────────────────────────────────────────────────────────────
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply(Func, args);
} else {
auto result = std::apply(Func, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError& e) {
std::cerr << "[kpn] overflow: " << e.what() << "\n";
}
}
}
// Pop all inputs into a tuple of argument values
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {std::get<Is>(input_channels_)->pop()...};
}
// Normalise return value to tuple (handles void and single-value returns)
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>)
return std::move(r);
else
return std::make_tuple(std::move(r));
}
static return_tuple normalise_void() { return {}; }
// Push each output element to its connected channel (if connected)
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
// ── State ─────────────────────────────────────────────────────────────────
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── ObjectNode — wraps a callable object (functor / class with operator()) ────
//
// Use this when the node needs state initialised in a constructor.
// The object must outlive the ObjectNode (stored by reference).
//
// Usage:
// MyFunctor obj(...);
// auto node = make_node(obj, in<"x">{}, out<"y">{}, capacity);
template<typename Obj, typename InputTag = in<>, typename OutputTag = out<>>
class ObjectNode;
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
class ObjectNode<Obj, in<InNames...>, out<OutNames...>> : public INode {
public:
using F = decltype(&Obj::operator());
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match operator() arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
: obj_(obj), fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
~ObjectNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name, frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
};
}
// ── Port access ───────────────────────────────────────────────────────────
template<std::size_t I>
InputPort<ObjectNode, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<ObjectNode, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
} else {
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError& e) {
std::cerr << "[kpn] overflow: " << e.what() << "\n";
}
}
}
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {std::get<Is>(input_channels_)->pop()...};
}
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>) return std::move(r);
else return std::make_tuple(std::move(r));
}
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
Obj& obj_;
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── make_node overloads for callable objects ──────────────────────────────────
template<typename Obj>
auto make_node(Obj& obj, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<>, out<>>(obj, fifo_capacity);
}
template<typename Obj, fixed_string... InNames>
auto make_node(Obj& obj, in<InNames...>, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<InNames...>, out<>>(obj, fifo_capacity);
}
template<typename Obj, fixed_string... OutNames>
auto make_node(Obj& obj, out<OutNames...>, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<>, out<OutNames...>>(obj, fifo_capacity);
}
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
auto make_node(Obj& obj, in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<InNames...>, out<OutNames...>>(obj, fifo_capacity);
}
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
//
// Usage:
// make_node<func>(capacity)
// make_node<func>(in<"a","b">{}, capacity)
// make_node<func>(out<"x">{}, capacity)
// make_node<func>(in<"a","b">{}, out<"x">{}, capacity)
// No names
template<auto Func>
auto make_node(std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<>>(fifo_capacity);
}
// in<> only
template<auto Func, fixed_string... InNames>
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<InNames...>, out<>>(fifo_capacity);
}
// out<> only (no input names)
template<auto Func, fixed_string... OutNames>
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<OutNames...>>(fifo_capacity);
}
// in<> and out<>
template<auto Func, fixed_string... InNames, fixed_string... OutNames>
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<InNames...>, out<OutNames...>>(fifo_capacity);
}
} // namespace kpn

17
include/kpn/port.hpp Normal file
View File

@ -0,0 +1,17 @@
#pragma once
#include <cstddef>
namespace kpn {
// Forward-declared so port handles can reference a node without including node.hpp
template<typename NodeT, std::size_t Idx>
struct InputPort {
NodeT& node;
};
template<typename NodeT, std::size_t Idx>
struct OutputPort {
NodeT& node;
};
} // namespace kpn

View File

@ -0,0 +1,412 @@
#pragma once
// Nanobind binding helpers for KPN++ Python interface.
// Included only by python/kpn_python.cpp — do not include from core headers.
#include "../variant_node.hpp"
#include "../network.hpp"
#ifdef KPN_BUILD_PYTHON
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <functional>
#include <map>
#include <stdexcept>
#include <string>
#include <typeindex>
#include <vector>
namespace kpn::python {
namespace nb = nanobind;
// ── PyNetwork<Variant> ────────────────────────────────────────────────────────
// Runtime graph builder for Python. Holds IVariantNode instances and connects
// them via IVariantChannel adapters. The variant only lives at the boundary;
// each node's internal Channel<T> stores raw T values.
template<typename Variant>
class PyNetwork {
public:
using VNode = IVariantNode<Variant>;
using VChannel = IVariantChannel<Variant>;
// ── Builder API ───────────────────────────────────────────────────────────
void add(std::string name, std::shared_ptr<VNode> node) {
if (nodes_.count(name))
throw std::runtime_error("duplicate node name: " + name);
node->set_name(name);
nodes_.emplace(name, std::move(node));
adj_[name];
}
// connect(src_name, out_idx, dst_name, in_idx)
// Wires src's output port out_idx to dst's input port in_idx.
// Type check: both sides must carry the same T.
void connect(const std::string& src_name, std::size_t out_idx,
const std::string& dst_name, std::size_t in_idx)
{
auto& src = node_at(src_name);
auto& dst = node_at(dst_name);
if (out_idx >= src.output_count())
throw std::out_of_range(src_name + ": output index " +
std::to_string(out_idx) + " out of range");
if (in_idx >= dst.input_count())
throw std::out_of_range(dst_name + ": input index " +
std::to_string(in_idx) + " out of range");
if (src.output_type(out_idx) != dst.input_type(in_idx))
throw std::runtime_error(
"type mismatch: " + src_name + ".output[" + std::to_string(out_idx) +
"] (" + src.output_type(out_idx).name() + ") → " +
dst_name + ".input[" + std::to_string(in_idx) +
"] (" + dst.input_type(in_idx).name() + ")");
// The destination node owns the input channel — get it, then tell src to use it.
auto ch = dst.input_channel(in_idx);
src.set_output_channel(out_idx, std::move(ch));
adj_[src_name].push_back(dst_name);
}
void build() {
topo_.clear();
std::map<std::string, int> color;
for (auto& [name, _] : nodes_)
if (color[name] == 0) dfs(name, color);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
void start() {
for (auto& name : topo_)
nodes_.at(name)->start();
}
void stop() {
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
nodes_.at(*it)->stop();
}
// ── Python tap/inject ─────────────────────────────────────────────────────
// Read one value from node's output port. Releases GIL while blocking.
nb::object read(const std::string& node_name, std::size_t out_idx) {
// We need a channel that sits on the output of this node.
// read() installs a tap channel if not already present.
auto key = tap_key(node_name, out_idx);
if (!taps_.count(key)) {
auto& src = node_at(node_name);
if (out_idx >= src.output_count())
throw std::out_of_range(node_name + ": output index out of range");
// Create a tap channel matching the output type and wire it
auto tap = make_tap_channel(src.output_type(out_idx));
src.set_output_channel(out_idx, tap);
taps_[key] = std::move(tap);
}
Variant v;
{
nb::gil_scoped_release release;
v = taps_.at(key)->pop();
}
return variant_to_python(std::move(v));
}
// Write a Python value into node's input port. Releases GIL while blocking.
void write(const std::string& node_name, std::size_t in_idx, nb::object value) {
auto& dst = node_at(node_name);
if (in_idx >= dst.input_count())
throw std::out_of_range(node_name + ": input index out of range");
auto ch = dst.input_channel(in_idx);
Variant v = python_to_variant(ch->type_index(), std::move(value));
{
nb::gil_scoped_release release;
ch->push(std::move(v));
}
}
// ── Converter registration ────────────────────────────────────────────────
// Called once per type at module init time to register to/from Python converters.
template<typename T>
void register_type(
std::function<nb::object(const T&)> to_py,
std::function<T(nb::object)> from_py)
{
auto idx = std::type_index(typeid(T));
to_python_[idx] = [to_py](const Variant& v) { return to_py(std::get<T>(v)); };
from_python_[idx] = [from_py](nb::object o) -> Variant {
return Variant{ from_py(std::move(o)) };
};
}
private:
VNode& node_at(const std::string& name) {
auto it = nodes_.find(name);
if (it == nodes_.end())
throw std::runtime_error("unknown node: " + name);
return *it->second;
}
void dfs(const std::string& name, std::map<std::string, int>& color) {
color[name] = 1;
for (auto& nbr : adj_[name]) {
if (color[nbr] == 1)
throw std::runtime_error("cycle detected in graph");
if (color[nbr] == 0) dfs(nbr, color);
}
color[name] = 2;
topo_.insert(topo_.begin(), name);
}
std::string tap_key(const std::string& node, std::size_t idx) {
return node + ":" + std::to_string(idx);
}
std::shared_ptr<VChannel> make_tap_channel(std::type_index type) {
// Create the right VariantChannel<T> based on the registered type index.
// We need a factory registered per type — stored in tap_factories_.
auto it = tap_factories_.find(type);
if (it == tap_factories_.end())
throw std::runtime_error(
"no tap factory for type: " + std::string(type.name()) +
" — was register_type() called for this type?");
return it->second();
}
nb::object variant_to_python(Variant v) {
auto idx = std::visit([](auto& x) {
return std::type_index(typeid(x));
}, v);
auto it = to_python_.find(idx);
if (it == to_python_.end())
throw std::runtime_error("no to_python converter for type");
return it->second(v);
}
Variant python_to_variant(std::type_index idx, nb::object obj) {
auto it = from_python_.find(idx);
if (it == from_python_.end())
throw std::runtime_error("no from_python converter for type");
return it->second(std::move(obj));
}
public:
// Called by register_type to also register a tap channel factory.
template<typename T>
void register_tap_factory(std::size_t capacity = 5) {
auto idx = std::type_index(typeid(T));
tap_factories_[idx] = [capacity]() -> std::shared_ptr<VChannel> {
auto ch = std::make_shared<Channel<T>>(capacity);
return std::make_shared<VariantChannel<T, Variant>>(std::move(ch));
};
}
private:
std::map<std::string, std::shared_ptr<VNode>> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
std::map<std::string, std::shared_ptr<VChannel>> taps_;
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
};
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
// A pure-Python processing node. Holds a nanobind callable.
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
template<typename Variant>
class PyNode : public IVariantNode<Variant> {
public:
using VChannel = IVariantChannel<Variant>;
using ChannelFactory = std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
PyNode(nb::object callable,
std::vector<std::type_index> in_types,
std::vector<std::type_index> out_types,
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_py,
std::map<std::type_index, std::function<Variant(nb::object)>> from_py,
std::map<std::type_index, ChannelFactory> ch_factories,
std::size_t capacity = 5)
: callable_(std::move(callable))
, in_types_(std::move(in_types))
, out_types_(std::move(out_types))
, to_python_(std::move(to_py))
, from_python_(std::move(from_py))
, ch_factories_(std::move(ch_factories))
, in_channels_(in_types_.size())
, out_channels_(out_types_.size())
{
for (std::size_t i = 0; i < in_types_.size(); ++i) {
auto it = ch_factories_.find(in_types_[i]);
if (it == ch_factories_.end())
throw std::runtime_error("PyNode: no channel factory for input type");
in_channels_[i] = it->second(capacity);
}
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
for (auto& ch : in_channels_) ch->enable();
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
for (auto& ch : in_channels_) ch->disable();
if (thread_.joinable()) {
thread_.request_stop();
// Release GIL while joining — run_loop may be waiting to acquire it.
nb::gil_scoped_release release;
thread_.join();
}
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override {
IVariantNode<Variant>::set_name(std::move(name));
}
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blk_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blk_ms;
return { name, frames, exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blk_ms, elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0 };
}
// ── IVariantNode ──────────────────────────────────────────────────────────
std::size_t input_count() const override { return in_types_.size(); }
std::size_t output_count() const override { return out_types_.size(); }
std::type_index input_type(std::size_t i) const override { return in_types_[i]; }
std::type_index output_type(std::size_t i) const override { return out_types_[i]; }
std::shared_ptr<VChannel> input_channel(std::size_t i) override {
return in_channels_[i];
}
void set_output_channel(std::size_t i,
std::shared_ptr<VChannel> ch) override {
out_channels_[i] = std::move(ch);
}
private:
void run_loop() {
// This thread does not hold the GIL. It acquires it only for Python calls.
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
// Pop all inputs — no GIL needed, these are pure C++ channel ops
std::vector<Variant> inputs(in_channels_.size());
for (std::size_t i = 0; i < in_channels_.size(); ++i)
inputs[i] = in_channels_[i]->pop();
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
// Acquire GIL only for the Python call and type conversion
std::vector<Variant> outputs;
{
nb::gil_scoped_acquire acquire;
nb::list py_args;
for (auto& v : inputs)
py_args.append(variant_to_python(v));
nb::object result = callable_(*py_args);
if (out_channels_.size() == 1) {
outputs.push_back(python_to_variant(out_types_[0], result));
} else {
nb::tuple tup = nb::cast<nb::tuple>(result);
for (std::size_t i = 0; i < out_channels_.size(); ++i)
outputs.push_back(python_to_variant(out_types_[i], tup[i]));
}
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
// Push outputs — no GIL needed
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
if (out_channels_[i])
out_channels_[i]->push(std::move(outputs[i]));
}
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError&) {
// drop and continue
}
}
}
nb::object variant_to_python(const Variant& v) {
auto idx = std::visit([](const auto& x) {
return std::type_index(typeid(x));
}, v);
return to_python_.at(idx)(v);
}
Variant python_to_variant(std::type_index idx, nb::object obj) {
return from_python_.at(idx)(std::move(obj));
}
nb::object callable_;
std::vector<std::type_index> in_types_;
std::vector<std::type_index> out_types_;
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, ChannelFactory> ch_factories_;
std::vector<std::shared_ptr<VChannel>> in_channels_;
std::vector<std::shared_ptr<VChannel>> out_channels_;
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── register_py_network ───────────────────────────────────────────────────────
// Registers PyNetwork<Variant> and PyNode<Variant> with the given nanobind module.
// Call once per module, passing the Variant type derived from your registered node types.
template<typename Variant>
void register_py_network(nb::module_& m, const char* class_name = "Network") {
using Net = PyNetwork<Variant>;
nb::class_<Net>(m, class_name)
.def(nb::init<>())
.def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"),
nb::arg("dst"), nb::arg("in_idx"))
.def("build", &Net::build)
.def("start", &Net::start)
.def("stop", &Net::stop)
.def("read", &Net::read,
nb::arg("node"), nb::arg("out_idx") = 0)
.def("write", &Net::write,
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
}
} // namespace kpn::python
#endif // KPN_BUILD_PYTHON

86
include/kpn/traits.hpp Normal file
View File

@ -0,0 +1,86 @@
#pragma once
#include <tuple>
#include <type_traits>
namespace kpn {
// ── Primary template — not defined; only specialisations match ────────────────
template<typename F>
struct function_traits;
// Free function
template<typename R, typename... Args>
struct function_traits<R(Args...)> {
using return_t = R;
using args = std::tuple<Args...>;
static constexpr std::size_t arity = sizeof...(Args);
};
// Function pointer
template<typename R, typename... Args>
struct function_traits<R(*)(Args...)> : function_traits<R(Args...)> {};
// Member function pointer (const)
template<typename C, typename R, typename... Args>
struct function_traits<R(C::*)(Args...) const> : function_traits<R(Args...)> {};
// Member function pointer (non-const)
template<typename C, typename R, typename... Args>
struct function_traits<R(C::*)(Args...)> : function_traits<R(Args...)> {};
// Callable (lambda / std::function) — delegate to operator()
template<typename F>
struct function_traits : function_traits<decltype(&F::operator())> {};
// ── Helpers ───────────────────────────────────────────────────────────────────
template<typename F>
using return_t = typename function_traits<std::remove_cvref_t<F>>::return_t;
template<typename F>
using args_t = typename function_traits<std::remove_cvref_t<F>>::args;
template<typename F>
inline constexpr std::size_t arity_v = function_traits<std::remove_cvref_t<F>>::arity;
// ── Tuple detection ───────────────────────────────────────────────────────────
template<typename T>
struct is_tuple : std::false_type {};
template<typename... Ts>
struct is_tuple<std::tuple<Ts...>> : std::true_type {};
template<typename T>
inline constexpr bool is_tuple_v = is_tuple<T>::value;
// ── Normalise return type to always be a tuple ────────────────────────────────
// void → std::tuple<>
// T (non-tup) → std::tuple<T>
// tuple<...> → tuple<...> (unchanged)
template<typename T>
struct normalise_return {
using type = std::tuple<T>;
};
template<>
struct normalise_return<void> {
using type = std::tuple<>;
};
template<typename... Ts>
struct normalise_return<std::tuple<Ts...>> {
using type = std::tuple<Ts...>;
};
template<typename T>
using normalised_return_t = typename normalise_return<T>::type;
// ── Output count from a function type ────────────────────────────────────────
template<typename F>
inline constexpr std::size_t output_count_v =
std::tuple_size_v<normalised_return_t<return_t<F>>>;
} // namespace kpn

View File

@ -0,0 +1,249 @@
#pragma once
#include "channel.hpp"
#include "node.hpp"
#include "traits.hpp"
#include <array>
#include <memory>
#include <stdexcept>
#include <string>
#include <typeindex>
#include <variant>
namespace kpn {
// ── unique_types TMP helper ───────────────────────────────────────────────────
namespace detail {
template<typename Result, typename... Ts>
struct unique_types_impl;
template<typename... Unique>
struct unique_types_impl<std::tuple<Unique...>> {
using type = std::tuple<Unique...>;
};
template<typename... Unique, typename Head, typename... Tail>
struct unique_types_impl<std::tuple<Unique...>, Head, Tail...> {
using type = std::conditional_t<
(std::is_same_v<Head, Unique> || ...),
typename unique_types_impl<std::tuple<Unique...>, Tail...>::type,
typename unique_types_impl<std::tuple<Unique..., Head>, Tail...>::type
>;
};
template<typename... Ts>
using unique_types_t = typename unique_types_impl<std::tuple<>, Ts...>::type;
template<typename Tup>
struct tuple_to_variant;
template<typename... Ts>
struct tuple_to_variant<std::tuple<Ts...>> {
using type = std::variant<Ts...>;
};
} // namespace detail
// ── IVariantChannel ───────────────────────────────────────────────────────────
// Type-erased channel surface used by PyNetwork.
// The underlying FIFO stores raw T — variant conversion happens only at push/pop.
template<typename Variant>
class IVariantChannel {
public:
virtual ~IVariantChannel() = default;
virtual void push(Variant v) = 0;
virtual Variant pop() = 0;
virtual std::type_index type_index() const = 0;
virtual std::string type_name() const = 0;
virtual void enable() = 0;
virtual void disable() = 0;
};
// ── VariantChannel<T, Variant> ────────────────────────────────────────────────
// Shares a Channel<T> with the node that owns the input slot.
// push(): std::get<T> from Variant → raw T into the queue.
// pop(): raw T from queue → wrapped into Variant.
template<typename T, typename Variant>
class VariantChannel : public IVariantChannel<Variant> {
public:
explicit VariantChannel(std::shared_ptr<Channel<T>> ch)
: channel_(std::move(ch)) {}
void push(Variant v) override {
channel_->push(std::get<T>(std::move(v)));
}
Variant pop() override {
return Variant{ channel_->pop() };
}
std::type_index type_index() const override { return std::type_index(typeid(T)); }
std::string type_name() const override { return typeid(T).name(); }
void enable() override { channel_->enable(); }
void disable() override { channel_->disable(); }
Channel<T>* raw_ptr() { return channel_.get(); }
private:
std::shared_ptr<Channel<T>> channel_;
};
// ── IVariantNode ──────────────────────────────────────────────────────────────
template<typename Variant>
class IVariantNode : public INode,
public std::enable_shared_from_this<IVariantNode<Variant>> {
public:
void set_name(std::string name) override { name_ = std::move(name); }
const std::string& name() const { return name_; }
virtual std::size_t input_count() const = 0;
virtual std::size_t output_count() const = 0;
virtual std::type_index input_type(std::size_t i) const = 0;
virtual std::type_index output_type(std::size_t i) const = 0;
// Returns the IVariantChannel wrapping this node's input slot i.
// PyNetwork calls this on the *destination* node to get the channel to wire upstream into.
virtual std::shared_ptr<IVariantChannel<Variant>> input_channel(std::size_t i) = 0;
// Wires this node's output slot i into ch (ch belongs to the downstream node's input).
virtual void set_output_channel(std::size_t i,
std::shared_ptr<IVariantChannel<Variant>> ch) = 0;
private:
std::string name_;
};
// ── VariantNodeWrapper<Func, Variant, InTag, OutTag> ──────────────────────────
// Wraps a Node<Func,...> for use inside a PyNetwork.
//
// At construction: for each input port I, builds a shared_ptr<Channel<ArgI>> and
// installs it in both the wrapped node (via set_input_channel) and a
// VariantChannel<ArgI> adapter. PyNetwork passes the adapter to the upstream
// node's set_output_channel so the upstream raw pointer points at the same Channel<T>.
//
// At connect (output side): downcasts the provided IVariantChannel to
// VariantChannel<RetI>, then calls node_.set_output_channel with the raw ptr.
template<auto Func, typename Variant,
typename InputTag = in<>,
typename OutputTag = out<>>
class VariantNodeWrapper;
template<auto Func, typename Variant,
fixed_string... InNames, fixed_string... OutNames>
class VariantNodeWrapper<Func, Variant, in<InNames...>, out<OutNames...>>
: public IVariantNode<Variant>
{
using NodeT = Node<Func, in<InNames...>, out<OutNames...>>;
public:
using args_tuple = typename NodeT::args_tuple;
using return_tuple = typename NodeT::return_tuple;
static constexpr std::size_t n_in = NodeT::input_count;
static constexpr std::size_t n_out = NodeT::output_count;
explicit VariantNodeWrapper(std::size_t fifo_capacity = 5)
: node_(fifo_capacity)
, in_channels_(n_in)
, out_channels_(n_out)
, out_type_indices_(n_out, std::type_index(typeid(void)))
{
init_inputs(std::make_index_sequence<n_in>{}, fifo_capacity);
init_out_types(std::make_index_sequence<n_out>{});
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override { node_.start(); }
void stop() override { node_.stop(); }
bool running() const override { return node_.running(); }
const NodeStats& stats() const override { return node_.stats(); }
void set_name(std::string name) override { node_.set_name(std::move(name)); }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
return node_.node_snapshot(name, elapsed_s);
}
// ── IVariantNode ──────────────────────────────────────────────────────────
std::size_t input_count() const override { return n_in; }
std::size_t output_count() const override { return n_out; }
std::type_index input_type(std::size_t i) const override {
return in_channels_[i]->type_index();
}
std::type_index output_type(std::size_t i) const override {
return out_type_indices_[i];
}
std::shared_ptr<IVariantChannel<Variant>> input_channel(std::size_t i) override {
return in_channels_[i];
}
void set_output_channel(std::size_t i,
std::shared_ptr<IVariantChannel<Variant>> ch) override {
set_output_impl(i, std::move(ch), std::make_index_sequence<n_out>{});
}
private:
template<std::size_t... Is>
void init_inputs(std::index_sequence<Is...>, std::size_t cap) {
((init_one_input<Is>(cap)), ...);
}
template<std::size_t I>
void init_one_input(std::size_t cap) {
using T = std::tuple_element_t<I, args_tuple>;
auto shared_ch = std::make_shared<Channel<T>>(cap);
// Install the shared Channel<T> in the node's input slot
node_.template set_input_channel<I>(shared_ch);
// Wrap it in a VariantChannel for PyNetwork to use
in_channels_[I] = std::make_shared<VariantChannel<T, Variant>>(std::move(shared_ch));
}
template<std::size_t... Is>
void init_out_types(std::index_sequence<Is...>) {
((out_type_indices_[Is] =
std::type_index(typeid(std::tuple_element_t<Is, return_tuple>))), ...);
}
template<std::size_t... Is>
void set_output_impl(std::size_t port,
std::shared_ptr<IVariantChannel<Variant>> ch,
std::index_sequence<Is...>) {
bool matched = false;
((Is == port && (set_output_at<Is>(std::move(ch)), matched = true)), ...);
if (!matched)
throw std::out_of_range("set_output_channel: port index out of range");
}
template<std::size_t I>
void set_output_at(std::shared_ptr<IVariantChannel<Variant>> ch) {
using T = std::tuple_element_t<I, return_tuple>;
auto* typed = dynamic_cast<VariantChannel<T, Variant>*>(ch.get());
if (!typed)
throw std::runtime_error(
"set_output_channel: type mismatch at output port " + std::to_string(I));
// The downstream VariantChannel holds a shared Channel<T>; point our output at it.
// We need a raw ptr — extract it via a getter.
node_.template set_output_channel<I>(typed->raw_ptr());
out_channels_[I] = std::move(ch);
}
NodeT node_;
std::vector<std::shared_ptr<IVariantChannel<Variant>>> in_channels_;
std::vector<std::shared_ptr<IVariantChannel<Variant>>> out_channels_;
std::vector<std::type_index> out_type_indices_;
};
// ── PythonConverter ───────────────────────────────────────────────────────────
template<typename T>
struct PythonConverter {
static_assert(sizeof(T) == 0,
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
};
} // namespace kpn

345
include/kpn/web_debug.hpp Normal file
View File

@ -0,0 +1,345 @@
#pragma once
// Only compiled when KPN_WEB_DEBUG is defined. network.hpp includes this conditionally.
#include "diagnostics.hpp"
#include <httplib.h>
#include <atomic>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace kpn::web_debug {
// ── Minimal JSON serialiser ───────────────────────────────────────────────────
static std::string escape_json(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '"') out += "\\\"";
else if (c == '\\') out += "\\\\";
else if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else if (c == '\t') out += "\\t";
else out += c;
}
return out;
}
// Parse "src_name:N → dst_name:M" into {src_name, dst_name}.
// Stored separately so the browser doesn't need to regex-parse a UTF-8 arrow.
static std::pair<std::string,std::string> parse_edge_name(const std::string& name) {
// Format: "<src>:<idx> → <dst>:<idx>"
auto arrow = name.find(" \xe2\x86\x92 "); // UTF-8 for →
if (arrow == std::string::npos) return {name, name};
std::string src_part = name.substr(0, arrow);
std::string dst_part = name.substr(arrow + 5); // " → " = 5 bytes (space + 3-byte arrow + space)
// Strip ":N" index suffix
auto sc1 = src_part.rfind(':');
auto sc2 = dst_part.rfind(':');
if (sc1 != std::string::npos) src_part = src_part.substr(0, sc1);
if (sc2 != std::string::npos) dst_part = dst_part.substr(0, sc2);
return {src_part, dst_part};
}
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
std::ostringstream o;
o << std::fixed;
o.precision(2);
o << "{\"nodes\":[";
for (std::size_t i = 0; i < nodes.size(); ++i) {
const auto& n = nodes[i];
if (i) o << ',';
o << "{\"id\":\"" << escape_json(n.name) << "\""
<< ",\"frames\":" << n.frames_processed
<< ",\"ema_exec_ms\":" << n.ema_exec_ms
<< ",\"max_exec_ms\":" << n.max_exec_ms
<< ",\"blocked_ms\":" << n.total_blocked_ms
<< ",\"fps\":" << n.throughput_fps
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
<< "}";
}
o << "],\"edges\":[";
for (std::size_t i = 0; i < channels.size(); ++i) {
const auto& c = channels[i];
if (i) o << ',';
auto [src, dst] = parse_edge_name(c.name);
o << "{\"name\":\"" << escape_json(c.name) << "\""
<< ",\"source\":\"" << escape_json(src) << "\""
<< ",\"target\":\"" << escape_json(dst) << "\""
<< ",\"capacity\":" << c.capacity
<< ",\"current\":" << c.current_fill
<< ",\"fill_pct\":" << c.fill_pct()
<< ",\"peak_pct\":" << c.peak_pct()
<< ",\"pushes\":" << c.pushes
<< ",\"drops\":" << c.drops
<< ",\"overflows\":" << c.overflows
<< ",\"item_bytes\":" << c.item_bytes
<< ",\"bw_mbs\":" << c.bandwidth_mbs(elapsed_s)
<< "}";
}
o << "]}";
return o.str();
}
// ── Embedded single-page HTML ─────────────────────────────────────────────────
static const char* HTML = R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KPN++ Web Debug</title>
<style>
body { margin: 0; background: #1a1a2e; color: #eee; font-family: monospace; }
#header { padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460; }
#header h1 { margin: 0; font-size: 18px; color: #e94560; }
#header span { font-size: 12px; color: #888; margin-left: 16px; }
#graph { width: 100vw; height: calc(100vh - 50px); }
.node circle { stroke: #fff; stroke-width: 1.5px; }
.node text { font-size: 11px; fill: #eee; pointer-events: none; text-anchor: middle; }
.node .stats { font-size: 9px; fill: #aaa; }
.link { fill: none; stroke-width: 2px; }
.link-label { font-size: 9px; fill: #ccc; }
.arrowhead { fill: #888; }
#tooltip {
position: absolute; background: #0f3460; border: 1px solid #e94560;
border-radius: 4px; padding: 8px 12px; font-size: 11px; pointer-events: none;
display: none; white-space: pre; line-height: 1.6;
}
</style>
</head>
<body>
<div id="header"><h1>KPN++ Web Debug</h1><span id="status">connecting...</span></div>
<svg id="graph"></svg>
<div id="tooltip"></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodeRadius = 30;
const svg = d3.select('#graph');
const width = () => window.innerWidth;
const height = () => window.innerHeight - 50;
// Arrow marker defs
const defs = svg.append('defs');
['green','#f0c040','#e94560'].forEach((col, i) => {
defs.append('marker')
.attr('id', 'arrow' + i)
.attr('viewBox', '0 -5 10 10').attr('refX', 10).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', col);
});
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
let nodes = [], links = [];
function edgeColor(fill_pct) {
if (fill_pct >= 80) return '#e94560';
if (fill_pct >= 50) return '#f0c040';
return '#4CAF50';
}
function edgeArrow(fill_pct) {
if (fill_pct >= 80) return 'url(#arrow2)';
if (fill_pct >= 50) return 'url(#arrow1)';
return 'url(#arrow0)';
}
function nodeColor(ema) {
if (ema > 100) return '#e94560';
if (ema > 50) return '#e07040';
if (ema > 10) return '#f0c040';
return '#4CAF50';
}
function init(data) {
nodes = data.nodes.map(n => ({ ...n, x: width()/2, y: height()/2 }));
const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
links = data.edges.map(e => ({
...e,
source: nodeById[e.source],
target: nodeById[e.target],
})).filter(e => e.source && e.target);
sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(160).strength(0.5))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width()/2, height()/2))
.force('collide', d3.forceCollide(nodeRadius + 20))
.on('tick', ticked);
// Links
linkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('class', 'link')
.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.fill_pct.toFixed(0)}%`);
// Nodes
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', (e,d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
nodeG.append('circle').attr('r', nodeRadius).attr('fill', d => nodeColor(d.ema_exec_ms));
nodeG.append('text').attr('dy', 4).text(d => d.id);
nodeG.append('text').attr('class', 'stats').attr('dy', 18)
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
nodeSel = nodeG;
// Tooltips
const tip = d3.select('#tooltip');
nodeG.on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`node: ${d.id}\nframes: ${d.frames}\nexec (ema): ${d.ema_exec_ms.toFixed(2)} ms` +
`\nexec (max): ${d.max_exec_ms.toFixed(2)} ms\nblocked: ${d.blocked_ms.toFixed(2)} ms` +
`\nfps: ${d.fps.toFixed(2)}\ncpu total: ${d.total_cpu_ms.toFixed(1)} ms` +
`\ncpu util: ${d.cpu_util_pct.toFixed(1)}%`);
}).on('mouseleave', () => tip.style('display','none'));
g.selectAll('.link').on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`channel: ${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%` +
`\ncapacity: ${d.capacity} current: ${d.current}` +
`\npushes: ${d.pushes} drops: ${d.drops} overflows: ${d.overflows}` +
`\nbandwidth: ${d.bw_mbs.toFixed(2)} MB/s item: ${d.item_bytes} B`);
}).on('mouseleave', () => tip.style('display','none'));
}
function update(data) {
// Update node stats in-place (preserve simulation x/y positions)
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
nodes.forEach(n => {
const fresh = byId[n.id];
if (fresh) {
n.frames = fresh.frames; n.ema_exec_ms = fresh.ema_exec_ms;
n.max_exec_ms = fresh.max_exec_ms; n.blocked_ms = fresh.blocked_ms;
n.fps = fresh.fps; n.total_cpu_ms = fresh.total_cpu_ms;
n.cpu_util_pct = fresh.cpu_util_pct;
}
});
// Update edge stats in-place (source/target are already D3 node refs — don't overwrite)
data.edges.forEach((e, i) => {
if (!links[i]) return;
links[i].fill_pct = e.fill_pct; links[i].peak_pct = e.peak_pct;
links[i].pushes = e.pushes; links[i].drops = e.drops;
links[i].overflows = e.overflows; links[i].current = e.current;
});
// Re-color
nodeSel.select('circle').attr('fill', d => nodeColor(d.ema_exec_ms));
nodeSel.select('.stats').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
}
function ticked() {
// Clamp nodes to viewport
nodes.forEach(d => {
d.x = Math.max(nodeRadius, Math.min(width() - nodeRadius, d.x));
d.y = Math.max(nodeRadius, Math.min(height() - nodeRadius, d.y));
});
linkSel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => { // shorten to node edge
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.x - (dx/dist)*(nodeRadius+8);
})
.attr('y2', d => {
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.y - (dy/dist)*(nodeRadius+8);
});
edgeLabelSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
let initialised = false;
async function poll() {
try {
const r = await fetch('/api/snapshot');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
document.getElementById('status').textContent =
`last update: ${new Date().toLocaleTimeString()} ${data.nodes.length} nodes, ${data.edges.length} edges`;
if (!initialised) { init(data); initialised = true; }
else { update(data); }
} catch(e) {
document.getElementById('status').textContent = `error: ${e}`;
}
}
poll();
setInterval(poll, 500);
window.addEventListener('resize', () => sim && sim.force('center', d3.forceCenter(width()/2, height()/2)).alpha(0.1).restart());
</script>
</body>
</html>
)html";
// ── WebDebugServer ────────────────────────────────────────────────────────────
class WebDebugServer {
public:
using SnapshotFn = std::function<std::string()>;
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
: port_(port), snapshot_fn_(std::move(fn)) {}
void start() {
svr_.Get("/", [](const httplib::Request&, httplib::Response& res) {
res.set_content(HTML, "text/html");
});
svr_.Get("/api/snapshot", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(snapshot_fn_(), "application/json");
});
thread_ = std::thread([this] {
svr_.listen("0.0.0.0", static_cast<int>(port_));
});
}
void stop() {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
~WebDebugServer() { stop(); }
WebDebugServer(const WebDebugServer&) = delete;
WebDebugServer& operator=(const WebDebugServer&) = delete;
private:
uint16_t port_;
SnapshotFn snapshot_fn_;
httplib::Server svr_;
std::thread thread_;
};
} // namespace kpn::web_debug

5
python/CMakeLists.txt Normal file
View File

@ -0,0 +1,5 @@
cmake_minimum_required(VERSION 3.21)
nanobind_add_module(kpn_python kpn_python.cpp)
target_link_libraries(kpn_python PRIVATE kpn)
target_compile_definitions(kpn_python PRIVATE KPN_BUILD_PYTHON)

164
python/kpn_python.cpp Normal file
View File

@ -0,0 +1,164 @@
#define KPN_BUILD_PYTHON
#include <kpn/python/bindings.hpp>
#include <nanobind/nanobind.h>
#include <nanobind/stl/shared_ptr.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <iostream>
#include <map>
namespace nb = nanobind;
using namespace kpn;
using namespace kpn::python;
// ── Node functions for the hello-pipeline examples ────────────────────────────
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
// ── Variant type ──────────────────────────────────────────────────────────────
// Deduplicated port types across all registered node functions.
// produce: () → int | double_it: int → int | print_it: int → void
// Unique types: int
using KpnVariant = std::variant<int>;
using Net = PyNetwork<KpnVariant>;
using ProduceNode = VariantNodeWrapper<produce, KpnVariant>;
using DoubleItNode= VariantNodeWrapper<double_it, KpnVariant>;
using PrintItNode = VariantNodeWrapper<print_it, KpnVariant>;
// ── Converter helpers for int ─────────────────────────────────────────────────
static nb::object int_to_py(const KpnVariant& v) {
return nb::int_(std::get<int>(v));
}
static KpnVariant int_from_py(nb::object o) {
return KpnVariant{ nb::cast<int>(o) };
}
// ── Type name resolver (extensible) ──────────────────────────────────────────
static std::type_index resolve_type(const std::string& name) {
if (name == "int") return std::type_index(typeid(int));
throw std::runtime_error("unknown type name '" + name +
"' — only 'int' is registered in this module");
}
// ── Ensure converters are registered on a Net instance ───────────────────────
static void ensure_converters(Net& net) {
net.register_type<int>(
[](const int& v) -> nb::object { return nb::int_(v); },
[](nb::object o) -> int { return nb::cast<int>(o); }
);
net.register_tap_factory<int>();
}
NB_MODULE(kpn_python, m) {
m.doc() = "KPN++ Python bindings — Kahn Process Network library";
// ── IVariantNode base ─────────────────────────────────────────────────────
nb::class_<IVariantNode<KpnVariant>>(m, "INode");
// ── Concrete C++ node wrappers ─────────────────────────────────────────────
// Factories return shared_ptr so Python can pass them to net.add().
nb::class_<ProduceNode, IVariantNode<KpnVariant>>(m, "ProduceNode")
.def("__init__", [](ProduceNode* self, std::size_t cap) {
new (self) ProduceNode(cap);
}, nb::arg("capacity") = 5);
nb::class_<DoubleItNode, IVariantNode<KpnVariant>>(m, "DoubleItNode")
.def("__init__", [](DoubleItNode* self, std::size_t cap) {
new (self) DoubleItNode(cap);
}, nb::arg("capacity") = 5);
nb::class_<PrintItNode, IVariantNode<KpnVariant>>(m, "PrintItNode")
.def("__init__", [](PrintItNode* self, std::size_t cap) {
new (self) PrintItNode(cap);
}, nb::arg("capacity") = 5);
// Expose factory functions that return shared_ptr — these are what net.add() accepts.
m.def("make_produce", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
return std::make_shared<ProduceNode>(cap);
}, nb::arg("capacity") = 5);
m.def("make_double_it", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
return std::make_shared<DoubleItNode>(cap);
}, nb::arg("capacity") = 5);
m.def("make_print_it", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
return std::make_shared<PrintItNode>(cap);
}, nb::arg("capacity") = 5);
// ── Network ───────────────────────────────────────────────────────────────
nb::class_<Net>(m, "Network")
.def(nb::init<>())
// add(name, c++_node) — for pre-constructed C++ nodes
.def("add", [](Net& self, std::string name,
std::shared_ptr<IVariantNode<KpnVariant>> node) {
self.add(std::move(name), std::move(node));
}, nb::arg("name"), nb::arg("node"))
// add_node(name, callable, inputs=[type_names], outputs=[type_names])
// Creates a pure Python processing node.
.def("add_node", [](Net& self,
std::string name,
nb::object callable,
std::vector<std::string> in_names,
std::vector<std::string> out_names,
std::size_t capacity)
{
std::vector<std::type_index> in_types, out_types;
for (auto& s : in_names) in_types.push_back(resolve_type(s));
for (auto& s : out_names) out_types.push_back(resolve_type(s));
std::map<std::type_index, std::function<nb::object(const KpnVariant&)>> to_py;
std::map<std::type_index, std::function<KpnVariant(nb::object)>> from_py;
std::map<std::type_index, PyNode<KpnVariant>::ChannelFactory> ch_factories;
auto int_idx = std::type_index(typeid(int));
to_py[int_idx] = int_to_py;
from_py[int_idx] = int_from_py;
ch_factories[int_idx] = [](std::size_t cap) -> std::shared_ptr<IVariantChannel<KpnVariant>> {
auto ch = std::make_shared<Channel<int>>(cap);
return std::make_shared<VariantChannel<int, KpnVariant>>(std::move(ch));
};
auto node = std::make_shared<PyNode<KpnVariant>>(
std::move(callable),
std::move(in_types),
std::move(out_types),
std::move(to_py),
std::move(from_py),
std::move(ch_factories),
capacity
);
self.add(std::move(name), std::move(node));
},
nb::arg("name"), nb::arg("callable"),
nb::arg("inputs") = std::vector<std::string>{},
nb::arg("outputs") = std::vector<std::string>{},
nb::arg("capacity") = 5)
.def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"),
nb::arg("dst"), nb::arg("in_idx"))
.def("build", &Net::build)
.def("start", &Net::start)
.def("stop", &Net::stop)
// read(node, out_idx=0) — blocking pop from a C++ node's output port into Python
.def("read", [](Net& self, const std::string& node, std::size_t out_idx) {
ensure_converters(self);
return self.read(node, out_idx);
}, nb::arg("node"), nb::arg("out_idx") = 0)
// write(node, in_idx, value) — push a Python value into a node's input port
.def("write", [](Net& self, const std::string& node,
std::size_t in_idx, nb::object value) {
ensure_converters(self);
self.write(node, in_idx, std::move(value));
}, nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
}

6
src/network.cpp Normal file
View File

@ -0,0 +1,6 @@
// network.cpp — orchestrator/watchdog implementation details.
// Most of the Network class is header-only (template-heavy).
// Non-template implementation lives here once the watchdog grows
// beyond the stub in network.hpp.
#include <kpn/network.hpp>

44
tests/CMakeLists.txt Normal file
View File

@ -0,0 +1,44 @@
cmake_minimum_required(VERSION 3.21)
# Catch2
find_package(Catch2 3 QUIET)
if(NOT Catch2_FOUND)
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.5.3
)
FetchContent_MakeAvailable(Catch2)
endif()
# Google Test
find_package(GTest QUIET)
if(NOT GTest_FOUND)
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)
endif()
# Test executable
add_executable(kpn_tests
test_fixed_string.cpp
test_traits.cpp
test_channel.cpp
test_node.cpp
test_network.cpp
)
target_link_libraries(kpn_tests PRIVATE
kpn
Catch2::Catch2WithMain
GTest::gtest
)
include(CTest)
include(Catch)
catch_discover_tests(kpn_tests)

99
tests/test_channel.cpp Normal file
View File

@ -0,0 +1,99 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/channel.hpp>
#include <thread>
using namespace kpn;
TEST_CASE("channel storage policy: small trivial type by value", "[channel]") {
STATIC_REQUIRE(channel_storage_policy<int>::by_value);
STATIC_REQUIRE(channel_storage_policy<float>::by_value);
}
TEST_CASE("channel storage policy: large type by shared_ptr", "[channel]") {
struct Big { char data[64]; };
STATIC_REQUIRE(!channel_storage_policy<Big>::by_value);
}
TEST_CASE("push and pop single value", "[channel]") {
Channel<int> ch(5);
ch.push(42);
REQUIRE(ch.pop() == 42);
}
TEST_CASE("channel respects capacity", "[channel]") {
Channel<int> ch(2);
ch.push(1);
ch.push(2);
REQUIRE_THROWS_AS(ch.push(3), ChannelOverflowError);
}
TEST_CASE("pop blocks until value available", "[channel]") {
Channel<int> ch(5);
int result = 0;
std::thread producer([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch.push(99);
});
result = ch.pop();
producer.join();
REQUIRE(result == 99);
}
TEST_CASE("try_pop returns false on timeout", "[channel]") {
Channel<int> ch(5);
int out = 0;
REQUIRE_FALSE(ch.try_pop(out, std::chrono::milliseconds(10)));
}
TEST_CASE("try_pop succeeds when value present", "[channel]") {
Channel<int> ch(5);
ch.push(7);
int out = 0;
REQUIRE(ch.try_pop(out, std::chrono::milliseconds(10)));
REQUIRE(out == 7);
}
TEST_CASE("disable unblocks waiting pop", "[channel]") {
Channel<int> ch(5);
std::thread disabler([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch.disable();
});
REQUIRE_THROWS_AS(ch.pop(), ChannelClosedError);
disabler.join();
}
TEST_CASE("push to disabled channel is silently dropped", "[channel]") {
Channel<int> ch(5);
ch.disable();
ch.push(99); // must not throw, must not enqueue
REQUIRE(ch.size() == 0);
}
TEST_CASE("disable clears existing queue contents", "[channel]") {
Channel<int> ch(5);
ch.push(1);
ch.push(2);
REQUIRE(ch.size() == 2);
ch.disable();
REQUIRE(ch.size() == 0);
}
TEST_CASE("enable re-accepts pushes after disable", "[channel]") {
Channel<int> ch(5);
ch.disable();
ch.push(1);
REQUIRE(ch.size() == 0);
ch.enable();
ch.push(42);
REQUIRE(ch.pop() == 42);
}
TEST_CASE("large type stored as shared_ptr — no copy on pop", "[channel]") {
struct Big { char data[64]; int tag; };
Channel<Big> ch(5);
Big b{}; b.tag = 123;
ch.push(b);
auto out = ch.pop();
REQUIRE(out.tag == 123);
}

View File

@ -0,0 +1,27 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/fixed_string.hpp>
using namespace kpn;
TEST_CASE("fixed_string equality", "[fixed_string]") {
constexpr fixed_string a("img");
constexpr fixed_string b("img");
constexpr fixed_string c("sigma");
STATIC_REQUIRE(a == b);
STATIC_REQUIRE(!(a == c));
}
TEST_CASE("fixed_string view", "[fixed_string]") {
constexpr fixed_string s("hello");
REQUIRE(s.view() == "hello");
}
TEST_CASE("index_of hit", "[fixed_string]") {
constexpr auto idx = index_of<fixed_string("b"), fixed_string("a"), fixed_string("b"), fixed_string("c")>();
STATIC_REQUIRE(idx == 1);
}
TEST_CASE("index_of miss returns npos", "[fixed_string]") {
constexpr auto idx = index_of<fixed_string("z"), fixed_string("a"), fixed_string("b")>();
STATIC_REQUIRE(idx == npos);
}

56
tests/test_network.cpp Normal file
View File

@ -0,0 +1,56 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/kpn.hpp>
#include <chrono>
#include <thread>
using namespace kpn;
static int increment(int x) { return x + 1; }
static int multiply2(int x) { return x * 2; }
TEST_CASE("network build and run: linear pipeline", "[network]") {
// Nodes declared first — they own their input channels and must outlive the network
auto src = make_node<increment>(5);
auto dst = make_node<multiply2>(5);
auto& src_in = src.input_channel<0>();
Channel<int> final_out(5);
dst.set_output_channel<0>(&final_out);
Network net;
net.add("src", src)
.add("dst", dst)
.connect("src", src.output<0>(), "dst", dst.input<0>())
.build();
net.start();
src_in.push(5); // 5 → increment → 6 → multiply2 → 12
int result = final_out.pop();
net.stop();
REQUIRE(result == 12);
}
TEST_CASE("network detects cycle", "[network]") {
auto a = make_node<increment>(5);
auto b = make_node<increment>(5);
Network net;
net.add("a", a).add("b", b);
net.connect("a", a.output<0>(), "b", b.input<0>());
net.connect("b", b.output<0>(), "a", a.input<0>());
REQUIRE_THROWS_AS(net.build(), NetworkCycleError);
}
TEST_CASE("stop disables input channels — producer push is silently dropped", "[network]") {
auto node = make_node<increment>(5);
auto& in_ch = node.input_channel<0>();
node.start();
node.stop();
// After stop, channel is disabled — push must not throw
in_ch.push(99);
REQUIRE(in_ch.size() == 0);
}

49
tests/test_node.cpp Normal file
View File

@ -0,0 +1,49 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/node.hpp>
#include <chrono>
#include <thread>
using namespace kpn;
static int double_it(int x) { return x * 2; }
static std::tuple<int, float> split_it(int x) { return {x, float(x) * 0.5f}; }
static void consume_it(int x) { (void)x; }
TEST_CASE("node input/output counts", "[node]") {
STATIC_REQUIRE(Node<double_it>::input_count == 1);
STATIC_REQUIRE(Node<double_it>::output_count == 1);
STATIC_REQUIRE(Node<split_it>::output_count == 2);
STATIC_REQUIRE(Node<consume_it>::output_count == 0);
}
TEST_CASE("node named port index resolution", "[node]") {
using N = Node<double_it, in<"value">, out<"result">>;
STATIC_REQUIRE(index_of<fixed_string("value"), fixed_string("value")>() == 0);
STATIC_REQUIRE(index_of<fixed_string("result"), fixed_string("result")>() == 0);
}
TEST_CASE("node processes a single item end-to-end", "[node]") {
Node<double_it> src_node(5); // used only as input source placeholder
Node<double_it> node(5);
// Manually wire: push to input channel, connect a downstream channel, run one item
auto& in_ch = node.input_channel<0>();
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
node.start();
in_ch.push(21);
int result = out_ch.pop();
node.stop();
REQUIRE(result == 42);
}
TEST_CASE("node stop unblocks cleanly", "[node]") {
Node<double_it> node(5);
node.start();
// Node is blocked waiting for input — stop() must return without deadlock
node.stop();
REQUIRE_FALSE(node.running());
}

42
tests/test_traits.cpp Normal file
View File

@ -0,0 +1,42 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/traits.hpp>
#include <tuple>
using namespace kpn;
static int free_func(int a, float b) { return a; }
static void sink_func(int a) {}
static std::tuple<int, float> multi_func(int a) { return {a, 1.f}; }
TEST_CASE("arity of free function", "[traits]") {
STATIC_REQUIRE(arity_v<decltype(free_func)> == 2);
}
TEST_CASE("return type of free function", "[traits]") {
STATIC_REQUIRE(std::is_same_v<return_t<decltype(free_func)>, int>);
}
TEST_CASE("normalised return: single value", "[traits]") {
STATIC_REQUIRE(std::is_same_v<normalised_return_t<int>, std::tuple<int>>);
}
TEST_CASE("normalised return: void", "[traits]") {
STATIC_REQUIRE(std::is_same_v<normalised_return_t<void>, std::tuple<>>);
}
TEST_CASE("normalised return: tuple passthrough", "[traits]") {
using T = std::tuple<int, float>;
STATIC_REQUIRE(std::is_same_v<normalised_return_t<T>, T>);
}
TEST_CASE("output_count: single return", "[traits]") {
STATIC_REQUIRE(output_count_v<decltype(free_func)> == 1);
}
TEST_CASE("output_count: void return", "[traits]") {
STATIC_REQUIRE(output_count_v<decltype(sink_func)> == 0);
}
TEST_CASE("output_count: tuple return", "[traits]") {
STATIC_REQUIRE(output_count_v<decltype(multi_func)> == 2);
}