First attempt
This commit is contained in:
commit
5e77dc836b
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
71
CMakeLists.txt
Normal 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
257
README.md
Normal 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
999
SPEC.md
Normal 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 (10–50ms), orange (50–100ms), red (>100ms)
|
||||||
|
- hover tooltip shows: frames, ema_exec_ms, max_exec_ms, blocked_ms, fps
|
||||||
|
- **Edges** — directed arrows labelled with the channel name and fill%; colour:
|
||||||
|
- green (fill < 50%), yellow (50–80%), red (≥80%) — matches the `<<<` flag in the text report
|
||||||
|
- hover tooltip shows: pushes, drops, overflows, capacity
|
||||||
|
|
||||||
|
D3 is loaded from CDN (`d3js.org`). The entire UI is a single inline HTML string in
|
||||||
|
`web_debug.hpp` — no file serving, no build step for assets.
|
||||||
|
|
||||||
|
### Thread model
|
||||||
|
|
||||||
|
`start_web_debug()` launches a `std::jthread` running `httplib::Server::listen()`.
|
||||||
|
The server is stopped via `httplib::Server::stop()` called from `stop_web_debug()`.
|
||||||
|
`/api/snapshot` calls `collect_snapshots()` (already thread-safe — reads atomics with
|
||||||
|
relaxed ordering) and serialises to JSON using a minimal hand-rolled serialiser
|
||||||
|
(no third-party JSON library required).
|
||||||
|
|
||||||
|
### Example usage
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#define KPN_WEB_DEBUG 1
|
||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
|
||||||
|
// ... build network as normal ...
|
||||||
|
net.set_web_debug_port(9090); // optional, 9090 is the default
|
||||||
|
net.start();
|
||||||
|
// Open http://localhost:9090 in a browser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CMake Layout
|
||||||
|
|
||||||
|
| Target | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `kpn` | header-only interface library | C++20, no external deps |
|
||||||
|
| `kpn_python` | nanobind shared library | links `kpn`, requires Python 3.8+ |
|
||||||
|
| `kpn_tests` | executable | Catch2 v3 + Google Test |
|
||||||
|
| `kpn_examples` | executables (one per example) | built by default, off with `-DKPN_EXAMPLES=OFF` |
|
||||||
|
| `kpn_web_debug` | compile-time option | `#define KPN_WEB_DEBUG 1`; fetches cpp-httplib via CMake FetchContent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Design Decisions
|
||||||
|
|
||||||
|
All major design questions are now closed:
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Shutdown mechanism | `accepting_` flag per channel; `disable()` clears queue and unblocks `pop()` |
|
||||||
|
| Overflow behaviour | `ChannelOverflowError` thrown on full accepting channel; silently dropped on disabled channel |
|
||||||
|
| Network ownership | Non-owning; user declares nodes, network holds raw pointers |
|
||||||
|
| Node lifetime contract | Nodes must outlive their `Network`; declare in same scope |
|
||||||
|
| Sub-networks | `Network` implements `INode`; `expose_input`/`expose_output` define boundary ports |
|
||||||
|
| `make_py_network` | Pure C++ template; nanobind module recompilation is the registration step |
|
||||||
|
| GIL strategy | Acquire per Python callback; release while blocking on channel ops |
|
||||||
38
examples/01_hello_pipeline/main.cpp
Normal file
38
examples/01_hello_pipeline/main.cpp
Normal 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();
|
||||||
|
}
|
||||||
3
examples/02_named_ports/main.cpp
Normal file
3
examples/02_named_ports/main.cpp
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
// TODO: implement example
|
||||||
|
int main() { return 0; }
|
||||||
3
examples/03_multi_output/main.cpp
Normal file
3
examples/03_multi_output/main.cpp
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
// TODO: implement example
|
||||||
|
int main() { return 0; }
|
||||||
3
examples/04_storage_policy/main.cpp
Normal file
3
examples/04_storage_policy/main.cpp
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
// TODO: implement example
|
||||||
|
int main() { return 0; }
|
||||||
3
examples/05_error_handling/main.cpp
Normal file
3
examples/05_error_handling/main.cpp
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
// TODO: implement example
|
||||||
|
int main() { return 0; }
|
||||||
3
examples/06_watchdog/main.cpp
Normal file
3
examples/06_watchdog/main.cpp
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
// TODO: implement example
|
||||||
|
int main() { return 0; }
|
||||||
32
examples/07_python_network/example.py
Normal file
32
examples/07_python_network/example.py
Normal 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()
|
||||||
38
examples/08_python_subport/example.py
Normal file
38
examples/08_python_subport/example.py
Normal 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)")
|
||||||
53
examples/09_opencv_cellshade/bench_capture.cpp
Normal file
53
examples/09_opencv_cellshade/bench_capture.cpp
Normal 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;
|
||||||
|
}
|
||||||
190
examples/09_opencv_cellshade/main.cpp
Normal file
190
examples/09_opencv_cellshade/main.cpp
Normal 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
30
examples/CMakeLists.txt
Normal 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
175
include/kpn/channel.hpp
Normal 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
137
include/kpn/diagnostics.hpp
Normal 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
|
||||||
46
include/kpn/fixed_string.hpp
Normal file
46
include/kpn/fixed_string.hpp
Normal 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
10
include/kpn/kpn.hpp
Normal 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"
|
||||||
186
include/kpn/main_thread_node.hpp
Normal file
186
include/kpn/main_thread_node.hpp
Normal 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
336
include/kpn/network.hpp
Normal 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
565
include/kpn/node.hpp
Normal 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
17
include/kpn/port.hpp
Normal 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
|
||||||
412
include/kpn/python/bindings.hpp
Normal file
412
include/kpn/python/bindings.hpp
Normal 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
86
include/kpn/traits.hpp
Normal 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
|
||||||
249
include/kpn/variant_node.hpp
Normal file
249
include/kpn/variant_node.hpp
Normal 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
345
include/kpn/web_debug.hpp
Normal 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
5
python/CMakeLists.txt
Normal 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
164
python/kpn_python.cpp
Normal 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
6
src/network.cpp
Normal 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
44
tests/CMakeLists.txt
Normal 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
99
tests/test_channel.cpp
Normal 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);
|
||||||
|
}
|
||||||
27
tests/test_fixed_string.cpp
Normal file
27
tests/test_fixed_string.cpp
Normal 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
56
tests/test_network.cpp
Normal 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
49
tests/test_node.cpp
Normal 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
42
tests/test_traits.cpp
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user