First attempt

This commit is contained in:
2026-05-08 17:48:16 +02:00
commit 5e77dc836b
36 changed files with 4806 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
#pragma once
#include "diagnostics.hpp"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <string>
#include <type_traits>
namespace kpn {
// ── Storage policy ────────────────────────────────────────────────────────────
template<typename T>
struct channel_storage_policy {
static constexpr bool by_value =
std::is_trivially_copyable_v<T> && sizeof(T) <= 8;
};
template<typename T>
using channel_storage_t = std::conditional_t<
channel_storage_policy<T>::by_value,
T,
std::shared_ptr<const T>
>;
// ── Exceptions ────────────────────────────────────────────────────────────────
class ChannelOverflowError : public std::runtime_error {
public:
explicit ChannelOverflowError(std::size_t capacity)
: std::runtime_error("channel overflow: capacity " + std::to_string(capacity) +
" exceeded") {}
ChannelOverflowError(std::size_t capacity, std::string context)
: std::runtime_error(std::move(context) + ": capacity " + std::to_string(capacity) +
" exceeded") {}
};
class ChannelClosedError : public std::runtime_error {
public:
ChannelClosedError() : std::runtime_error("channel closed") {}
};
// ── Channel ───────────────────────────────────────────────────────────────────
template<typename T>
class Channel {
public:
using storage_type = channel_storage_t<T>;
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
Channel(const Channel&) = delete;
Channel& operator=(const Channel&) = delete;
// Push a value.
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
// - If channel is full: throw ChannelOverflowError.
void push(T value) {
if (!accepting_.load(std::memory_order_relaxed)) {
stats_.record_drop();
return;
}
std::unique_lock lock(mutex_);
if (!accepting_.load(std::memory_order_relaxed)) {
stats_.record_drop();
return;
}
if (queue_.size() >= capacity_) {
stats_.record_overflow();
throw ChannelOverflowError(capacity_);
}
queue_.push(make_storage(std::move(value)));
stats_.record_push(queue_.size());
lock.unlock();
cv_.notify_one();
}
// Blocking pop. Unblocks when an item is available or the channel is disabled.
// Throws ChannelClosedError if disabled and queue is empty.
T pop() {
std::unique_lock lock(mutex_);
cv_.wait(lock, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
});
if (queue_.empty())
throw ChannelClosedError{};
T value = extract(std::move(queue_.front()));
queue_.pop();
stats_.record_pop();
return value;
}
// Non-blocking pop with timeout. For watchdog/display use only — not used in run_loop.
bool try_pop(T& out, std::chrono::milliseconds timeout) {
std::unique_lock lock(mutex_);
if (!cv_.wait_for(lock, timeout, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
}))
return false;
if (queue_.empty())
return false;
out = extract(std::move(queue_.front()));
queue_.pop();
stats_.record_pop();
return true;
}
// Enable the channel (called by consumer node on start()).
void enable() {
accepting_.store(true, std::memory_order_relaxed);
}
// Disable the channel: drop all queued items, unblock any waiting pop().
// Called by consumer node on stop(). Producer push() will silently drop after this.
void disable() {
accepting_.store(false, std::memory_order_relaxed);
{
std::lock_guard lock(mutex_);
while (!queue_.empty()) queue_.pop();
}
cv_.notify_all();
}
std::size_t size() const {
std::lock_guard lock(mutex_);
return queue_.size();
}
std::size_t capacity() const { return capacity_; }
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
const ChannelStats& stats() const { return stats_; }
ChannelSnapshot snapshot(const std::string& name) const {
std::lock_guard lock(mutex_);
return {
name,
capacity_,
queue_.size(),
stats_.peak_fill.load(std::memory_order_relaxed),
stats_.pushes.load(std::memory_order_relaxed),
stats_.drops.load(std::memory_order_relaxed),
stats_.overflows.load(std::memory_order_relaxed),
stats_.pops.load(std::memory_order_relaxed),
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
};
}
private:
static storage_type make_storage(T&& v) {
if constexpr (channel_storage_policy<T>::by_value)
return std::move(v);
else
return std::make_shared<const T>(std::move(v));
}
static T extract(storage_type&& s) {
if constexpr (channel_storage_policy<T>::by_value)
return std::move(s);
else
return *s;
}
const std::size_t capacity_;
std::queue<storage_type> queue_;
std::atomic<bool> accepting_{true};
mutable std::mutex mutex_;
std::condition_variable cv_;
ChannelStats stats_;
};
} // namespace kpn
+137
View File
@@ -0,0 +1,137 @@
#pragma once
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <string>
#include <time.h> // clock_gettime, CLOCK_THREAD_CPUTIME_ID
namespace kpn {
using clock_t = std::chrono::steady_clock;
using duration_t = std::chrono::duration<double, std::milli>; // milliseconds
// ── Per-channel statistics ────────────────────────────────────────────────────
struct ChannelStats {
std::atomic<uint64_t> pushes{0};
std::atomic<uint64_t> drops{0};
std::atomic<uint64_t> overflows{0};
std::atomic<uint64_t> pops{0};
std::atomic<std::size_t> peak_fill{0};
ChannelStats() = default;
ChannelStats(const ChannelStats&) = delete;
ChannelStats& operator=(const ChannelStats&) = delete;
void record_push(std::size_t current_fill) {
pushes.fetch_add(1, std::memory_order_relaxed);
std::size_t prev = peak_fill.load(std::memory_order_relaxed);
while (current_fill > prev &&
!peak_fill.compare_exchange_weak(prev, current_fill,
std::memory_order_relaxed, std::memory_order_relaxed))
;
}
void record_drop() { drops.fetch_add(1, std::memory_order_relaxed); }
void record_overflow() { overflows.fetch_add(1, std::memory_order_relaxed); }
void record_pop() { pops.fetch_add(1, std::memory_order_relaxed); }
};
// ── Per-node statistics ───────────────────────────────────────────────────────
struct NodeStats {
std::atomic<uint64_t> frames_processed{0};
// Wall-clock execution time EMA — warmup mean for first WARMUP_FRAMES,
// then EMA alpha=0.1. Stored as integer microseconds for atomic updates.
static constexpr int WARMUP_FRAMES = 5;
std::atomic<int64_t> ema_exec_us{0};
std::atomic<int64_t> max_exec_us{0};
std::atomic<int64_t> total_blocked_us{0};
// Thread CPU time — actual CPU consumed by this node's thread,
// measured via CLOCK_THREAD_CPUTIME_ID. Excludes time sleeping or
// blocked on mutexes/channels. Sampled once per frame.
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
NodeStats() = default;
NodeStats(const NodeStats&) = delete;
NodeStats& operator=(const NodeStats&) = delete;
// Call at the start of run_loop to capture thread CPU baseline.
// Returns the raw timespec for use in record_exec.
static struct timespec cpu_now() {
struct timespec ts{};
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts);
return ts;
}
static int64_t timespec_us(const struct timespec& ts) {
return static_cast<int64_t>(ts.tv_sec) * 1'000'000
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
}
void record_exec(duration_t exec_time, duration_t blocked_time,
const struct timespec& cpu_before, const struct timespec& cpu_after) {
frames_processed.fetch_add(1, std::memory_order_relaxed);
int64_t us = static_cast<int64_t>(exec_time.count() * 1000.0);
uint64_t n = frames_processed.load(std::memory_order_relaxed);
int64_t prev = ema_exec_us.load(std::memory_order_relaxed);
int64_t next = (n <= static_cast<uint64_t>(WARMUP_FRAMES))
? prev + (us - prev) / static_cast<int64_t>(n)
: prev + (us - prev) / 10;
ema_exec_us.store(next, std::memory_order_relaxed);
int64_t cur_max = max_exec_us.load(std::memory_order_relaxed);
if (us > cur_max)
max_exec_us.store(us, std::memory_order_relaxed);
int64_t blocked_us = static_cast<int64_t>(blocked_time.count() * 1000.0);
total_blocked_us.fetch_add(blocked_us, std::memory_order_relaxed);
int64_t cpu_delta = timespec_us(cpu_after) - timespec_us(cpu_before);
if (cpu_delta > 0)
total_cpu_us.fetch_add(cpu_delta, std::memory_order_relaxed);
}
};
// ── Snapshot for reporting (copyable, taken by watchdog) ─────────────────────
struct ChannelSnapshot {
std::string name;
std::size_t capacity;
std::size_t current_fill;
std::size_t peak_fill;
uint64_t pushes;
uint64_t drops;
uint64_t overflows;
uint64_t pops;
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
double fill_pct() const {
return capacity ? 100.0 * current_fill / capacity : 0.0;
}
double peak_pct() const {
return capacity ? 100.0 * peak_fill / capacity : 0.0;
}
// Bandwidth in MB/s: bytes transferred / elapsed seconds
double bandwidth_mbs(double elapsed_s) const {
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
}
};
struct NodeSnapshot {
std::string name;
uint64_t frames_processed;
double ema_exec_ms;
double max_exec_ms;
double total_blocked_ms;
double throughput_fps;
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
};
} // namespace kpn
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <algorithm>
#include <string_view>
#include <cstddef>
namespace kpn {
template<std::size_t N>
struct fixed_string {
char data[N]{};
constexpr fixed_string(const char (&s)[N]) { std::copy_n(s, N, data); }
constexpr bool operator==(const fixed_string&) const = default;
template<std::size_t M>
constexpr bool operator==(const fixed_string<M>&) const { return false; }
constexpr std::string_view view() const { return {data, N - 1}; }
};
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;
// ── Port name pack lookup ─────────────────────────────────────────────────────
inline constexpr std::size_t npos = std::size_t(-1);
template<fixed_string Name, fixed_string... Names>
constexpr std::size_t index_of() {
std::size_t i = 0;
bool found = false;
auto check = [&](auto n) {
if (!found) {
if (Name == n) found = true;
else ++i;
}
};
(check(Names), ...);
return found ? i : npos;
}
// ── in<> / out<> name tag structs ─────────────────────────────────────────────
template<fixed_string... Names> struct in {};
template<fixed_string... Names> struct out {};
} // namespace kpn
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Convenience umbrella header — include this to get the full C++ API.
#include "fixed_string.hpp"
#include "traits.hpp"
#include "channel.hpp"
#include "port.hpp"
#include "node.hpp"
#include "main_thread_node.hpp"
#include "network.hpp"
+186
View File
@@ -0,0 +1,186 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "node.hpp" // INode
#include "port.hpp"
#include <atomic>
#include <chrono>
#include <cstddef>
#include <memory>
#include <optional>
#include <tuple>
namespace kpn {
// ── MainThreadNode ────────────────────────────────────────────────────────────
//
// Base class for nodes that must run on the main thread (e.g. OpenCV display
// on Wayland/Qt). Registered as a normal INode in the Network so it appears
// in diagnostics and the web UI, but spawns no thread.
//
// Usage:
// class MyDisplay : public kpn::MainThreadNode<MyDisplay, in<"a","b">, TypeA, TypeB> {
// public:
// MyDisplay(...) { /* constructor runs on main thread */ }
// bool operator()(TypeA a, TypeB b) { ...; return true; /* false = stop */ }
// };
//
// MyDisplay disp(...);
// net.add("display", disp).connect(...).build();
// net.start();
// while (disp.step()) ; // drives the event loop on the main thread
// net.stop();
//
// step() behaviour:
// - try_pop on every input channel with zero timeout
// - if all inputs have data: calls operator(), records stats, returns its result
// - if any input is missing: returns true immediately (caller should yield/waitKey)
template<typename Derived, typename InputTag, typename... Args>
class MainThreadNode;
template<typename Derived, fixed_string... InNames, typename... Args>
class MainThreadNode<Derived, in<InNames...>, Args...> : public INode {
public:
static constexpr std::size_t input_count = sizeof...(Args);
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"MainThreadNode: name count must match input type count, or provide none"
);
using args_tuple = std::tuple<Args...>; // required by Network::connect type check
explicit MainThreadNode(std::size_t fifo_capacity = 8) {
init_channels(std::make_index_sequence<input_count>{}, fifo_capacity);
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_channels(std::make_index_sequence<input_count>{});
running_.store(true, std::memory_order_relaxed);
}
void stop() override {
running_.store(false, std::memory_order_relaxed);
disable_channels(std::make_index_sequence<input_count>{});
}
bool running() const override {
return running_.load(std::memory_order_relaxed);
}
void set_name(std::string) override {}
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name, frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
};
}
// ── Port access (for Network::connect) ───────────────────────────────────
template<std::size_t I>
Channel<std::tuple_element_t<I, std::tuple<Args...>>>& input_channel() {
return *std::get<I>(channels_);
}
template<std::size_t I>
InputPort<MainThreadNode, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
// ── Main-thread driver ────────────────────────────────────────────────────
// Call this in a loop on the main thread instead of net.start()'s thread.
// Returns false when operator() returns false or all channels are closed.
bool step() {
if (!running_.load(std::memory_order_relaxed)) return false;
auto t0 = clock_t::now();
auto inputs = try_pop_all(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
if (!inputs.has_value()) return true; // not all inputs ready — yield
auto cpu0 = NodeStats::cpu_now();
bool cont = std::apply(
[this](Args&&... a) {
return static_cast<Derived*>(this)->operator()(std::forward<Args>(a)...);
},
std::move(*inputs));
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
return cont;
}
private:
template<std::size_t... Is>
void init_channels(std::index_sequence<Is...>, std::size_t cap) {
((std::get<Is>(channels_) =
std::make_unique<Channel<std::tuple_element_t<Is, args_tuple>>>(cap)), ...);
}
template<std::size_t... Is>
void enable_channels(std::index_sequence<Is...>) {
(std::get<Is>(channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_channels(std::index_sequence<Is...>) {
(std::get<Is>(channels_)->disable(), ...);
}
// Try to pop one item from every channel with zero timeout.
// Returns nullopt if any channel has no data ready.
template<std::size_t... Is>
std::optional<args_tuple> try_pop_all(std::index_sequence<Is...>) {
args_tuple result;
bool all_ready = true;
// Use a fold that short-circuits on first missing item
((all_ready = all_ready &&
std::get<Is>(channels_)->try_pop(
std::get<Is>(result), std::chrono::milliseconds(0))), ...);
if (!all_ready) return std::nullopt;
return result;
}
// Build the channel tuple type
template<typename Tup, std::size_t... Is>
static auto make_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::unique_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using channels_t = decltype(make_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
channels_t channels_;
std::atomic<bool> running_{false};
NodeStats stats_;
};
} // namespace kpn
+336
View File
@@ -0,0 +1,336 @@
#pragma once
#include "diagnostics.hpp"
#include "node.hpp"
#include "port.hpp"
#ifdef KPN_WEB_DEBUG
#include "web_debug.hpp"
#include <memory>
#endif
#include <functional>
#include <iomanip>
#include <iostream>
#include <map>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
namespace kpn {
// ── Exceptions ────────────────────────────────────────────────────────────────
class NetworkCycleError : public std::runtime_error {
public:
NetworkCycleError() : std::runtime_error("network graph contains a directed cycle") {}
};
class NetworkBuildError : public std::runtime_error {
public:
explicit NetworkBuildError(std::string msg)
: std::runtime_error(std::move(msg)) {}
};
// ── Channel snapshot accessor — type-erased so Network can collect them ───────
struct IChannelProbe {
virtual ~IChannelProbe() = default;
virtual ChannelSnapshot snapshot() const = 0;
};
template<typename T>
struct ChannelProbe : IChannelProbe {
const Channel<T>& ch;
std::string name;
ChannelProbe(const Channel<T>& c, std::string n) : ch(c), name(std::move(n)) {}
ChannelSnapshot snapshot() const override { return ch.snapshot(name); }
};
// ── Network ───────────────────────────────────────────────────────────────────
class Network : public INode {
public:
using ErrorHandler =
std::function<void(std::string_view node_name, std::exception_ptr)>;
using DiagnosticsHandler =
std::function<void(const std::vector<NodeSnapshot>&,
const std::vector<ChannelSnapshot>&)>;
// ── Builder API ───────────────────────────────────────────────────────────
template<typename NodeT>
Network& add(std::string name, NodeT& node) {
if (nodes_.count(name))
throw NetworkBuildError("duplicate node name: " + name);
node.set_name(name);
nodes_.emplace(name, &node);
adj_[name];
return *this;
}
template<typename SrcNode, std::size_t SrcIdx,
typename DstNode, std::size_t DstIdx>
Network& connect(const std::string& src_name,
OutputPort<SrcNode, SrcIdx>,
const std::string& dst_name,
InputPort<DstNode, DstIdx>) {
using out_t = std::tuple_element_t<SrcIdx, typename SrcNode::return_tuple>;
using dst_in_t = std::tuple_element_t<DstIdx, typename DstNode::args_tuple>;
static_assert(std::is_same_v<out_t, dst_in_t>,
"connect: output type does not match input type");
auto* src = dynamic_cast<SrcNode*>(nodes_.at(src_name));
auto* dst = dynamic_cast<DstNode*>(nodes_.at(dst_name));
if (!src) throw NetworkBuildError("node '" + src_name + "' type mismatch");
if (!dst) throw NetworkBuildError("node '" + dst_name + "' type mismatch");
auto& in_ch = dst->template input_channel<DstIdx>();
src->template set_output_channel<SrcIdx>(&in_ch);
// Register channel probe for diagnostics
std::string ch_name = src_name + ":" + std::to_string(SrcIdx)
+ "" + dst_name + ":" + std::to_string(DstIdx);
channel_probes_.push_back(
std::make_unique<ChannelProbe<out_t>>(in_ch, ch_name));
adj_[src_name].push_back(dst_name);
return *this;
}
template<typename NodeT, std::size_t Idx>
Network& expose_input(std::string boundary_name, InputPort<NodeT, Idx>) {
exposed_inputs_[boundary_name] = boundary_name;
return *this;
}
template<typename NodeT, std::size_t Idx>
Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>) {
exposed_outputs_[boundary_name] = boundary_name;
return *this;
}
Network& build() {
topo_.clear();
std::map<std::string, int> color;
for (auto& [name, _] : nodes_)
if (color[name] == 0)
dfs(name, color);
return *this;
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
start_time_ = clock_t::now();
for (auto& name : topo_)
nodes_.at(name)->start();
start_watchdog();
#ifdef KPN_WEB_DEBUG
web_server_ = std::make_unique<web_debug::WebDebugServer>(
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
#endif
}
void stop() override {
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
stop_watchdog();
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
nodes_.at(*it)->stop();
}
bool running() const override { return watchdog_.joinable(); }
void set_name(std::string) override {}
const NodeStats& stats() const override {
static NodeStats dummy;
return dummy;
}
NodeSnapshot node_snapshot(const std::string& name, double) const override {
return {name, 0, 0, 0, 0, 0, 0, 0};
}
// ── Configuration ─────────────────────────────────────────────────────────
void set_watchdog_interval(std::chrono::milliseconds interval) {
watchdog_interval_ = interval;
}
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
void set_diagnostics_handler(DiagnosticsHandler h) { diag_handler_ = std::move(h); }
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
#endif
// Print a diagnostics report to a stream (default: stderr).
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
void print_diagnostics(std::ostream& os = std::cerr) const {
auto s = collect_snapshots();
os << format_report(s.nodes, s.channels, s.elapsed_s);
}
private:
// ── Diagnostics collection ────────────────────────────────────────────────
struct Snapshots {
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
double elapsed_s;
};
Snapshots collect_snapshots() const {
double elapsed_s = std::chrono::duration<double>(
clock_t::now() - start_time_).count();
std::vector<NodeSnapshot> nodes;
for (auto& name : topo_)
nodes.push_back(nodes_.at(name)->node_snapshot(name, elapsed_s));
std::vector<ChannelSnapshot> channels;
for (auto& probe : channel_probes_)
channels.push_back(probe->snapshot());
return {std::move(nodes), std::move(channels), elapsed_s};
}
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
std::ostringstream os;
os << std::fixed << std::setprecision(1);
os << "\n┌─ KPN++ Diagnostics ────────────────────────────────────────────────────────\n";
// Node table
os << "│ Nodes:\n";
os << "" << std::left
<< std::setw(16) << "name"
<< std::setw(10) << "frames"
<< std::setw(12) << "exec ms"
<< std::setw(12) << "max ms"
<< std::setw(14) << "blocked ms"
<< std::setw(10) << "fps"
<< std::setw(12) << "cpu ms"
<< std::setw(10) << "util%"
<< "\n" << std::string(92, '-') << "\n";
for (auto& n : nodes) {
os << "" << std::left
<< std::setw(16) << n.name
<< std::setw(10) << n.frames_processed
<< std::setw(12) << n.ema_exec_ms
<< std::setw(12) << n.max_exec_ms
<< std::setw(14) << n.total_blocked_ms
<< std::setw(10) << n.throughput_fps
<< std::setw(12) << n.total_cpu_ms
<< std::setw(10) << n.cpu_util_pct
<< "\n";
}
// Channel table
os << "\n│ Channels:\n";
os << "" << std::left
<< std::setw(40) << "edge"
<< std::setw(8) << "fill%"
<< std::setw(8) << "peak%"
<< std::setw(8) << "pushes"
<< std::setw(8) << "drops"
<< std::setw(8) << "oflow"
<< std::setw(12) << "MB/s"
<< std::setw(10) << "item B"
<< "\n" << std::string(102, '-') << "\n";
for (auto& c : channels) {
std::string flag = c.fill_pct() >= 80.0 ? " <<<" :
c.peak_pct() >= 80.0 ? " (peak)" : "";
os << "" << std::left
<< std::setw(40) << c.name
<< std::setw(8) << c.fill_pct()
<< std::setw(8) << c.peak_pct()
<< std::setw(8) << c.pushes
<< std::setw(8) << c.drops
<< std::setw(8) << c.overflows
<< std::setw(12) << c.bandwidth_mbs(elapsed_s)
<< std::setw(10) << c.item_bytes
<< flag << "\n";
}
// Bottleneck hint: node with highest ema_exec_ms
if (!nodes.empty()) {
auto it = std::max_element(nodes.begin(), nodes.end(),
[](const NodeSnapshot& a, const NodeSnapshot& b) {
return a.ema_exec_ms < b.ema_exec_ms;
});
os << "\n│ Bottleneck hint: '" << it->name
<< "' (avg exec " << it->ema_exec_ms << " ms)\n";
}
os << "└────────────────────────────────────────────────────────────────\n";
return os.str();
}
// ── Cycle detection / topological sort ───────────────────────────────────
void dfs(const std::string& name, std::map<std::string, int>& color) {
color[name] = 1;
for (auto& nbr : adj_[name]) {
if (color[nbr] == 1) throw NetworkCycleError{};
if (color[nbr] == 0) dfs(nbr, color);
}
color[name] = 2;
topo_.insert(topo_.begin(), name);
}
// ── Watchdog ──────────────────────────────────────────────────────────────
void start_watchdog() {
watchdog_ = std::jthread([this](std::stop_token tok) {
while (!tok.stop_requested()) {
std::this_thread::sleep_for(watchdog_interval_);
if (tok.stop_requested()) break;
auto s = collect_snapshots();
if (diag_handler_) {
diag_handler_(s.nodes, s.channels);
} else {
std::cerr << format_report(s.nodes, s.channels, s.elapsed_s);
}
}
});
}
void stop_watchdog() {
if (watchdog_.joinable())
watchdog_.request_stop(), watchdog_.join();
}
// ── State ─────────────────────────────────────────────────────────────────
std::map<std::string, INode*> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
std::map<std::string, std::string> exposed_inputs_;
std::map<std::string, std::string> exposed_outputs_;
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
ErrorHandler error_handler_;
DiagnosticsHandler diag_handler_;
std::chrono::milliseconds watchdog_interval_{3000};
std::jthread watchdog_;
clock_t::time_point start_time_;
#ifdef KPN_WEB_DEBUG
uint16_t web_debug_port_{9090};
std::unique_ptr<web_debug::WebDebugServer> web_server_;
#endif
};
} // namespace kpn
+565
View File
@@ -0,0 +1,565 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "port.hpp"
#include "traits.hpp"
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
namespace kpn {
// ── INode — type-erased interface for Network / watchdog ─────────────────────
struct INode {
virtual ~INode() = default;
virtual void start() = 0;
virtual void stop() = 0;
virtual bool running() const = 0;
virtual const NodeStats& stats() const = 0;
virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0;
virtual void set_name(std::string name) = 0;
};
// ── Node ─────────────────────────────────────────────────────────────────────
//
// Template parameters:
// Func — the wrapped function (auto NTTP, deduced as a function pointer)
// InputNames — optional kpn::in<"a","b"> tag type (at most one)
// OutputNames — optional kpn::out<"x","y"> tag type (at most one)
template<auto Func, typename InputTag = in<>, typename OutputTag = out<>>
class Node;
// Specialisation that unpacks the in<>/out<> tag packs
template<auto Func, fixed_string... InNames, fixed_string... OutNames>
class Node<Func, in<InNames...>, out<OutNames...>> : public INode {
public:
using F = decltype(Func);
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match function arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit Node(std::size_t fifo_capacity = 5)
: fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
~Node() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
// Disable all input channels: drops queued items and unblocks waiting pop()
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name,
frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
};
}
// ── Port access — by index ────────────────────────────────────────────────
template<std::size_t I>
InputPort<Node, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<Node, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
// ── Port access — by name ─────────────────────────────────────────────────
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
// ── Internal channel accessors (used by Network at connect time) ──────────
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
// Replace the owned input channel with an externally provided one.
// Used by VariantNodeWrapper to share a Channel<T> with a VariantChannel adapter.
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
// ── Channel storage ───────────────────────────────────────────────────────
// Input channels — shared ownership so VariantChannel adapters can share them
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
// Output channels — non-owning pointers, set at connect time
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
// ── run_loop ──────────────────────────────────────────────────────────────
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply(Func, args);
} else {
auto result = std::apply(Func, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError& e) {
std::cerr << "[kpn] overflow: " << e.what() << "\n";
}
}
}
// Pop all inputs into a tuple of argument values
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {std::get<Is>(input_channels_)->pop()...};
}
// Normalise return value to tuple (handles void and single-value returns)
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>)
return std::move(r);
else
return std::make_tuple(std::move(r));
}
static return_tuple normalise_void() { return {}; }
// Push each output element to its connected channel (if connected)
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
// ── State ─────────────────────────────────────────────────────────────────
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── ObjectNode — wraps a callable object (functor / class with operator()) ────
//
// Use this when the node needs state initialised in a constructor.
// The object must outlive the ObjectNode (stored by reference).
//
// Usage:
// MyFunctor obj(...);
// auto node = make_node(obj, in<"x">{}, out<"y">{}, capacity);
template<typename Obj, typename InputTag = in<>, typename OutputTag = out<>>
class ObjectNode;
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
class ObjectNode<Obj, in<InNames...>, out<OutNames...>> : public INode {
public:
using F = decltype(&Obj::operator());
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match operator() arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
: obj_(obj), fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
~ObjectNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name, frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
};
}
// ── Port access ───────────────────────────────────────────────────────────
template<std::size_t I>
InputPort<ObjectNode, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<ObjectNode, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
} else {
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError& e) {
std::cerr << "[kpn] overflow: " << e.what() << "\n";
}
}
}
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {std::get<Is>(input_channels_)->pop()...};
}
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>) return std::move(r);
else return std::make_tuple(std::move(r));
}
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
Obj& obj_;
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── make_node overloads for callable objects ──────────────────────────────────
template<typename Obj>
auto make_node(Obj& obj, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<>, out<>>(obj, fifo_capacity);
}
template<typename Obj, fixed_string... InNames>
auto make_node(Obj& obj, in<InNames...>, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<InNames...>, out<>>(obj, fifo_capacity);
}
template<typename Obj, fixed_string... OutNames>
auto make_node(Obj& obj, out<OutNames...>, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<>, out<OutNames...>>(obj, fifo_capacity);
}
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
auto make_node(Obj& obj, in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
return ObjectNode<Obj, in<InNames...>, out<OutNames...>>(obj, fifo_capacity);
}
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
//
// Usage:
// make_node<func>(capacity)
// make_node<func>(in<"a","b">{}, capacity)
// make_node<func>(out<"x">{}, capacity)
// make_node<func>(in<"a","b">{}, out<"x">{}, capacity)
// No names
template<auto Func>
auto make_node(std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<>>(fifo_capacity);
}
// in<> only
template<auto Func, fixed_string... InNames>
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<InNames...>, out<>>(fifo_capacity);
}
// out<> only (no input names)
template<auto Func, fixed_string... OutNames>
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<OutNames...>>(fifo_capacity);
}
// in<> and out<>
template<auto Func, fixed_string... InNames, fixed_string... OutNames>
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<InNames...>, out<OutNames...>>(fifo_capacity);
}
} // namespace kpn
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <cstddef>
namespace kpn {
// Forward-declared so port handles can reference a node without including node.hpp
template<typename NodeT, std::size_t Idx>
struct InputPort {
NodeT& node;
};
template<typename NodeT, std::size_t Idx>
struct OutputPort {
NodeT& node;
};
} // namespace kpn
+412
View File
@@ -0,0 +1,412 @@
#pragma once
// Nanobind binding helpers for KPN++ Python interface.
// Included only by python/kpn_python.cpp — do not include from core headers.
#include "../variant_node.hpp"
#include "../network.hpp"
#ifdef KPN_BUILD_PYTHON
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <functional>
#include <map>
#include <stdexcept>
#include <string>
#include <typeindex>
#include <vector>
namespace kpn::python {
namespace nb = nanobind;
// ── PyNetwork<Variant> ────────────────────────────────────────────────────────
// Runtime graph builder for Python. Holds IVariantNode instances and connects
// them via IVariantChannel adapters. The variant only lives at the boundary;
// each node's internal Channel<T> stores raw T values.
template<typename Variant>
class PyNetwork {
public:
using VNode = IVariantNode<Variant>;
using VChannel = IVariantChannel<Variant>;
// ── Builder API ───────────────────────────────────────────────────────────
void add(std::string name, std::shared_ptr<VNode> node) {
if (nodes_.count(name))
throw std::runtime_error("duplicate node name: " + name);
node->set_name(name);
nodes_.emplace(name, std::move(node));
adj_[name];
}
// connect(src_name, out_idx, dst_name, in_idx)
// Wires src's output port out_idx to dst's input port in_idx.
// Type check: both sides must carry the same T.
void connect(const std::string& src_name, std::size_t out_idx,
const std::string& dst_name, std::size_t in_idx)
{
auto& src = node_at(src_name);
auto& dst = node_at(dst_name);
if (out_idx >= src.output_count())
throw std::out_of_range(src_name + ": output index " +
std::to_string(out_idx) + " out of range");
if (in_idx >= dst.input_count())
throw std::out_of_range(dst_name + ": input index " +
std::to_string(in_idx) + " out of range");
if (src.output_type(out_idx) != dst.input_type(in_idx))
throw std::runtime_error(
"type mismatch: " + src_name + ".output[" + std::to_string(out_idx) +
"] (" + src.output_type(out_idx).name() + ") → " +
dst_name + ".input[" + std::to_string(in_idx) +
"] (" + dst.input_type(in_idx).name() + ")");
// The destination node owns the input channel — get it, then tell src to use it.
auto ch = dst.input_channel(in_idx);
src.set_output_channel(out_idx, std::move(ch));
adj_[src_name].push_back(dst_name);
}
void build() {
topo_.clear();
std::map<std::string, int> color;
for (auto& [name, _] : nodes_)
if (color[name] == 0) dfs(name, color);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
void start() {
for (auto& name : topo_)
nodes_.at(name)->start();
}
void stop() {
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
nodes_.at(*it)->stop();
}
// ── Python tap/inject ─────────────────────────────────────────────────────
// Read one value from node's output port. Releases GIL while blocking.
nb::object read(const std::string& node_name, std::size_t out_idx) {
// We need a channel that sits on the output of this node.
// read() installs a tap channel if not already present.
auto key = tap_key(node_name, out_idx);
if (!taps_.count(key)) {
auto& src = node_at(node_name);
if (out_idx >= src.output_count())
throw std::out_of_range(node_name + ": output index out of range");
// Create a tap channel matching the output type and wire it
auto tap = make_tap_channel(src.output_type(out_idx));
src.set_output_channel(out_idx, tap);
taps_[key] = std::move(tap);
}
Variant v;
{
nb::gil_scoped_release release;
v = taps_.at(key)->pop();
}
return variant_to_python(std::move(v));
}
// Write a Python value into node's input port. Releases GIL while blocking.
void write(const std::string& node_name, std::size_t in_idx, nb::object value) {
auto& dst = node_at(node_name);
if (in_idx >= dst.input_count())
throw std::out_of_range(node_name + ": input index out of range");
auto ch = dst.input_channel(in_idx);
Variant v = python_to_variant(ch->type_index(), std::move(value));
{
nb::gil_scoped_release release;
ch->push(std::move(v));
}
}
// ── Converter registration ────────────────────────────────────────────────
// Called once per type at module init time to register to/from Python converters.
template<typename T>
void register_type(
std::function<nb::object(const T&)> to_py,
std::function<T(nb::object)> from_py)
{
auto idx = std::type_index(typeid(T));
to_python_[idx] = [to_py](const Variant& v) { return to_py(std::get<T>(v)); };
from_python_[idx] = [from_py](nb::object o) -> Variant {
return Variant{ from_py(std::move(o)) };
};
}
private:
VNode& node_at(const std::string& name) {
auto it = nodes_.find(name);
if (it == nodes_.end())
throw std::runtime_error("unknown node: " + name);
return *it->second;
}
void dfs(const std::string& name, std::map<std::string, int>& color) {
color[name] = 1;
for (auto& nbr : adj_[name]) {
if (color[nbr] == 1)
throw std::runtime_error("cycle detected in graph");
if (color[nbr] == 0) dfs(nbr, color);
}
color[name] = 2;
topo_.insert(topo_.begin(), name);
}
std::string tap_key(const std::string& node, std::size_t idx) {
return node + ":" + std::to_string(idx);
}
std::shared_ptr<VChannel> make_tap_channel(std::type_index type) {
// Create the right VariantChannel<T> based on the registered type index.
// We need a factory registered per type — stored in tap_factories_.
auto it = tap_factories_.find(type);
if (it == tap_factories_.end())
throw std::runtime_error(
"no tap factory for type: " + std::string(type.name()) +
" — was register_type() called for this type?");
return it->second();
}
nb::object variant_to_python(Variant v) {
auto idx = std::visit([](auto& x) {
return std::type_index(typeid(x));
}, v);
auto it = to_python_.find(idx);
if (it == to_python_.end())
throw std::runtime_error("no to_python converter for type");
return it->second(v);
}
Variant python_to_variant(std::type_index idx, nb::object obj) {
auto it = from_python_.find(idx);
if (it == from_python_.end())
throw std::runtime_error("no from_python converter for type");
return it->second(std::move(obj));
}
public:
// Called by register_type to also register a tap channel factory.
template<typename T>
void register_tap_factory(std::size_t capacity = 5) {
auto idx = std::type_index(typeid(T));
tap_factories_[idx] = [capacity]() -> std::shared_ptr<VChannel> {
auto ch = std::make_shared<Channel<T>>(capacity);
return std::make_shared<VariantChannel<T, Variant>>(std::move(ch));
};
}
private:
std::map<std::string, std::shared_ptr<VNode>> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
std::map<std::string, std::shared_ptr<VChannel>> taps_;
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
};
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
// A pure-Python processing node. Holds a nanobind callable.
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
template<typename Variant>
class PyNode : public IVariantNode<Variant> {
public:
using VChannel = IVariantChannel<Variant>;
using ChannelFactory = std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
PyNode(nb::object callable,
std::vector<std::type_index> in_types,
std::vector<std::type_index> out_types,
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_py,
std::map<std::type_index, std::function<Variant(nb::object)>> from_py,
std::map<std::type_index, ChannelFactory> ch_factories,
std::size_t capacity = 5)
: callable_(std::move(callable))
, in_types_(std::move(in_types))
, out_types_(std::move(out_types))
, to_python_(std::move(to_py))
, from_python_(std::move(from_py))
, ch_factories_(std::move(ch_factories))
, in_channels_(in_types_.size())
, out_channels_(out_types_.size())
{
for (std::size_t i = 0; i < in_types_.size(); ++i) {
auto it = ch_factories_.find(in_types_[i]);
if (it == ch_factories_.end())
throw std::runtime_error("PyNode: no channel factory for input type");
in_channels_[i] = it->second(capacity);
}
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
for (auto& ch : in_channels_) ch->enable();
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
for (auto& ch : in_channels_) ch->disable();
if (thread_.joinable()) {
thread_.request_stop();
// Release GIL while joining — run_loop may be waiting to acquire it.
nb::gil_scoped_release release;
thread_.join();
}
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override {
IVariantNode<Variant>::set_name(std::move(name));
}
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blk_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blk_ms;
return { name, frames, exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blk_ms, elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0 };
}
// ── IVariantNode ──────────────────────────────────────────────────────────
std::size_t input_count() const override { return in_types_.size(); }
std::size_t output_count() const override { return out_types_.size(); }
std::type_index input_type(std::size_t i) const override { return in_types_[i]; }
std::type_index output_type(std::size_t i) const override { return out_types_[i]; }
std::shared_ptr<VChannel> input_channel(std::size_t i) override {
return in_channels_[i];
}
void set_output_channel(std::size_t i,
std::shared_ptr<VChannel> ch) override {
out_channels_[i] = std::move(ch);
}
private:
void run_loop() {
// This thread does not hold the GIL. It acquires it only for Python calls.
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
// Pop all inputs — no GIL needed, these are pure C++ channel ops
std::vector<Variant> inputs(in_channels_.size());
for (std::size_t i = 0; i < in_channels_.size(); ++i)
inputs[i] = in_channels_[i]->pop();
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
// Acquire GIL only for the Python call and type conversion
std::vector<Variant> outputs;
{
nb::gil_scoped_acquire acquire;
nb::list py_args;
for (auto& v : inputs)
py_args.append(variant_to_python(v));
nb::object result = callable_(*py_args);
if (out_channels_.size() == 1) {
outputs.push_back(python_to_variant(out_types_[0], result));
} else {
nb::tuple tup = nb::cast<nb::tuple>(result);
for (std::size_t i = 0; i < out_channels_.size(); ++i)
outputs.push_back(python_to_variant(out_types_[i], tup[i]));
}
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
// Push outputs — no GIL needed
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
if (out_channels_[i])
out_channels_[i]->push(std::move(outputs[i]));
}
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError&) {
// drop and continue
}
}
}
nb::object variant_to_python(const Variant& v) {
auto idx = std::visit([](const auto& x) {
return std::type_index(typeid(x));
}, v);
return to_python_.at(idx)(v);
}
Variant python_to_variant(std::type_index idx, nb::object obj) {
return from_python_.at(idx)(std::move(obj));
}
nb::object callable_;
std::vector<std::type_index> in_types_;
std::vector<std::type_index> out_types_;
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, ChannelFactory> ch_factories_;
std::vector<std::shared_ptr<VChannel>> in_channels_;
std::vector<std::shared_ptr<VChannel>> out_channels_;
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── register_py_network ───────────────────────────────────────────────────────
// Registers PyNetwork<Variant> and PyNode<Variant> with the given nanobind module.
// Call once per module, passing the Variant type derived from your registered node types.
template<typename Variant>
void register_py_network(nb::module_& m, const char* class_name = "Network") {
using Net = PyNetwork<Variant>;
nb::class_<Net>(m, class_name)
.def(nb::init<>())
.def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"),
nb::arg("dst"), nb::arg("in_idx"))
.def("build", &Net::build)
.def("start", &Net::start)
.def("stop", &Net::stop)
.def("read", &Net::read,
nb::arg("node"), nb::arg("out_idx") = 0)
.def("write", &Net::write,
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
}
} // namespace kpn::python
#endif // KPN_BUILD_PYTHON
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include <tuple>
#include <type_traits>
namespace kpn {
// ── Primary template — not defined; only specialisations match ────────────────
template<typename F>
struct function_traits;
// Free function
template<typename R, typename... Args>
struct function_traits<R(Args...)> {
using return_t = R;
using args = std::tuple<Args...>;
static constexpr std::size_t arity = sizeof...(Args);
};
// Function pointer
template<typename R, typename... Args>
struct function_traits<R(*)(Args...)> : function_traits<R(Args...)> {};
// Member function pointer (const)
template<typename C, typename R, typename... Args>
struct function_traits<R(C::*)(Args...) const> : function_traits<R(Args...)> {};
// Member function pointer (non-const)
template<typename C, typename R, typename... Args>
struct function_traits<R(C::*)(Args...)> : function_traits<R(Args...)> {};
// Callable (lambda / std::function) — delegate to operator()
template<typename F>
struct function_traits : function_traits<decltype(&F::operator())> {};
// ── Helpers ───────────────────────────────────────────────────────────────────
template<typename F>
using return_t = typename function_traits<std::remove_cvref_t<F>>::return_t;
template<typename F>
using args_t = typename function_traits<std::remove_cvref_t<F>>::args;
template<typename F>
inline constexpr std::size_t arity_v = function_traits<std::remove_cvref_t<F>>::arity;
// ── Tuple detection ───────────────────────────────────────────────────────────
template<typename T>
struct is_tuple : std::false_type {};
template<typename... Ts>
struct is_tuple<std::tuple<Ts...>> : std::true_type {};
template<typename T>
inline constexpr bool is_tuple_v = is_tuple<T>::value;
// ── Normalise return type to always be a tuple ────────────────────────────────
// void → std::tuple<>
// T (non-tup) → std::tuple<T>
// tuple<...> → tuple<...> (unchanged)
template<typename T>
struct normalise_return {
using type = std::tuple<T>;
};
template<>
struct normalise_return<void> {
using type = std::tuple<>;
};
template<typename... Ts>
struct normalise_return<std::tuple<Ts...>> {
using type = std::tuple<Ts...>;
};
template<typename T>
using normalised_return_t = typename normalise_return<T>::type;
// ── Output count from a function type ────────────────────────────────────────
template<typename F>
inline constexpr std::size_t output_count_v =
std::tuple_size_v<normalised_return_t<return_t<F>>>;
} // namespace kpn
+249
View File
@@ -0,0 +1,249 @@
#pragma once
#include "channel.hpp"
#include "node.hpp"
#include "traits.hpp"
#include <array>
#include <memory>
#include <stdexcept>
#include <string>
#include <typeindex>
#include <variant>
namespace kpn {
// ── unique_types TMP helper ───────────────────────────────────────────────────
namespace detail {
template<typename Result, typename... Ts>
struct unique_types_impl;
template<typename... Unique>
struct unique_types_impl<std::tuple<Unique...>> {
using type = std::tuple<Unique...>;
};
template<typename... Unique, typename Head, typename... Tail>
struct unique_types_impl<std::tuple<Unique...>, Head, Tail...> {
using type = std::conditional_t<
(std::is_same_v<Head, Unique> || ...),
typename unique_types_impl<std::tuple<Unique...>, Tail...>::type,
typename unique_types_impl<std::tuple<Unique..., Head>, Tail...>::type
>;
};
template<typename... Ts>
using unique_types_t = typename unique_types_impl<std::tuple<>, Ts...>::type;
template<typename Tup>
struct tuple_to_variant;
template<typename... Ts>
struct tuple_to_variant<std::tuple<Ts...>> {
using type = std::variant<Ts...>;
};
} // namespace detail
// ── IVariantChannel ───────────────────────────────────────────────────────────
// Type-erased channel surface used by PyNetwork.
// The underlying FIFO stores raw T — variant conversion happens only at push/pop.
template<typename Variant>
class IVariantChannel {
public:
virtual ~IVariantChannel() = default;
virtual void push(Variant v) = 0;
virtual Variant pop() = 0;
virtual std::type_index type_index() const = 0;
virtual std::string type_name() const = 0;
virtual void enable() = 0;
virtual void disable() = 0;
};
// ── VariantChannel<T, Variant> ────────────────────────────────────────────────
// Shares a Channel<T> with the node that owns the input slot.
// push(): std::get<T> from Variant → raw T into the queue.
// pop(): raw T from queue → wrapped into Variant.
template<typename T, typename Variant>
class VariantChannel : public IVariantChannel<Variant> {
public:
explicit VariantChannel(std::shared_ptr<Channel<T>> ch)
: channel_(std::move(ch)) {}
void push(Variant v) override {
channel_->push(std::get<T>(std::move(v)));
}
Variant pop() override {
return Variant{ channel_->pop() };
}
std::type_index type_index() const override { return std::type_index(typeid(T)); }
std::string type_name() const override { return typeid(T).name(); }
void enable() override { channel_->enable(); }
void disable() override { channel_->disable(); }
Channel<T>* raw_ptr() { return channel_.get(); }
private:
std::shared_ptr<Channel<T>> channel_;
};
// ── IVariantNode ──────────────────────────────────────────────────────────────
template<typename Variant>
class IVariantNode : public INode,
public std::enable_shared_from_this<IVariantNode<Variant>> {
public:
void set_name(std::string name) override { name_ = std::move(name); }
const std::string& name() const { return name_; }
virtual std::size_t input_count() const = 0;
virtual std::size_t output_count() const = 0;
virtual std::type_index input_type(std::size_t i) const = 0;
virtual std::type_index output_type(std::size_t i) const = 0;
// Returns the IVariantChannel wrapping this node's input slot i.
// PyNetwork calls this on the *destination* node to get the channel to wire upstream into.
virtual std::shared_ptr<IVariantChannel<Variant>> input_channel(std::size_t i) = 0;
// Wires this node's output slot i into ch (ch belongs to the downstream node's input).
virtual void set_output_channel(std::size_t i,
std::shared_ptr<IVariantChannel<Variant>> ch) = 0;
private:
std::string name_;
};
// ── VariantNodeWrapper<Func, Variant, InTag, OutTag> ──────────────────────────
// Wraps a Node<Func,...> for use inside a PyNetwork.
//
// At construction: for each input port I, builds a shared_ptr<Channel<ArgI>> and
// installs it in both the wrapped node (via set_input_channel) and a
// VariantChannel<ArgI> adapter. PyNetwork passes the adapter to the upstream
// node's set_output_channel so the upstream raw pointer points at the same Channel<T>.
//
// At connect (output side): downcasts the provided IVariantChannel to
// VariantChannel<RetI>, then calls node_.set_output_channel with the raw ptr.
template<auto Func, typename Variant,
typename InputTag = in<>,
typename OutputTag = out<>>
class VariantNodeWrapper;
template<auto Func, typename Variant,
fixed_string... InNames, fixed_string... OutNames>
class VariantNodeWrapper<Func, Variant, in<InNames...>, out<OutNames...>>
: public IVariantNode<Variant>
{
using NodeT = Node<Func, in<InNames...>, out<OutNames...>>;
public:
using args_tuple = typename NodeT::args_tuple;
using return_tuple = typename NodeT::return_tuple;
static constexpr std::size_t n_in = NodeT::input_count;
static constexpr std::size_t n_out = NodeT::output_count;
explicit VariantNodeWrapper(std::size_t fifo_capacity = 5)
: node_(fifo_capacity)
, in_channels_(n_in)
, out_channels_(n_out)
, out_type_indices_(n_out, std::type_index(typeid(void)))
{
init_inputs(std::make_index_sequence<n_in>{}, fifo_capacity);
init_out_types(std::make_index_sequence<n_out>{});
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override { node_.start(); }
void stop() override { node_.stop(); }
bool running() const override { return node_.running(); }
const NodeStats& stats() const override { return node_.stats(); }
void set_name(std::string name) override { node_.set_name(std::move(name)); }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
return node_.node_snapshot(name, elapsed_s);
}
// ── IVariantNode ──────────────────────────────────────────────────────────
std::size_t input_count() const override { return n_in; }
std::size_t output_count() const override { return n_out; }
std::type_index input_type(std::size_t i) const override {
return in_channels_[i]->type_index();
}
std::type_index output_type(std::size_t i) const override {
return out_type_indices_[i];
}
std::shared_ptr<IVariantChannel<Variant>> input_channel(std::size_t i) override {
return in_channels_[i];
}
void set_output_channel(std::size_t i,
std::shared_ptr<IVariantChannel<Variant>> ch) override {
set_output_impl(i, std::move(ch), std::make_index_sequence<n_out>{});
}
private:
template<std::size_t... Is>
void init_inputs(std::index_sequence<Is...>, std::size_t cap) {
((init_one_input<Is>(cap)), ...);
}
template<std::size_t I>
void init_one_input(std::size_t cap) {
using T = std::tuple_element_t<I, args_tuple>;
auto shared_ch = std::make_shared<Channel<T>>(cap);
// Install the shared Channel<T> in the node's input slot
node_.template set_input_channel<I>(shared_ch);
// Wrap it in a VariantChannel for PyNetwork to use
in_channels_[I] = std::make_shared<VariantChannel<T, Variant>>(std::move(shared_ch));
}
template<std::size_t... Is>
void init_out_types(std::index_sequence<Is...>) {
((out_type_indices_[Is] =
std::type_index(typeid(std::tuple_element_t<Is, return_tuple>))), ...);
}
template<std::size_t... Is>
void set_output_impl(std::size_t port,
std::shared_ptr<IVariantChannel<Variant>> ch,
std::index_sequence<Is...>) {
bool matched = false;
((Is == port && (set_output_at<Is>(std::move(ch)), matched = true)), ...);
if (!matched)
throw std::out_of_range("set_output_channel: port index out of range");
}
template<std::size_t I>
void set_output_at(std::shared_ptr<IVariantChannel<Variant>> ch) {
using T = std::tuple_element_t<I, return_tuple>;
auto* typed = dynamic_cast<VariantChannel<T, Variant>*>(ch.get());
if (!typed)
throw std::runtime_error(
"set_output_channel: type mismatch at output port " + std::to_string(I));
// The downstream VariantChannel holds a shared Channel<T>; point our output at it.
// We need a raw ptr — extract it via a getter.
node_.template set_output_channel<I>(typed->raw_ptr());
out_channels_[I] = std::move(ch);
}
NodeT node_;
std::vector<std::shared_ptr<IVariantChannel<Variant>>> in_channels_;
std::vector<std::shared_ptr<IVariantChannel<Variant>>> out_channels_;
std::vector<std::type_index> out_type_indices_;
};
// ── PythonConverter ───────────────────────────────────────────────────────────
template<typename T>
struct PythonConverter {
static_assert(sizeof(T) == 0,
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
};
} // namespace kpn
+345
View File
@@ -0,0 +1,345 @@
#pragma once
// Only compiled when KPN_WEB_DEBUG is defined. network.hpp includes this conditionally.
#include "diagnostics.hpp"
#include <httplib.h>
#include <atomic>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace kpn::web_debug {
// ── Minimal JSON serialiser ───────────────────────────────────────────────────
static std::string escape_json(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '"') out += "\\\"";
else if (c == '\\') out += "\\\\";
else if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else if (c == '\t') out += "\\t";
else out += c;
}
return out;
}
// Parse "src_name:N → dst_name:M" into {src_name, dst_name}.
// Stored separately so the browser doesn't need to regex-parse a UTF-8 arrow.
static std::pair<std::string,std::string> parse_edge_name(const std::string& name) {
// Format: "<src>:<idx> → <dst>:<idx>"
auto arrow = name.find(" \xe2\x86\x92 "); // UTF-8 for →
if (arrow == std::string::npos) return {name, name};
std::string src_part = name.substr(0, arrow);
std::string dst_part = name.substr(arrow + 5); // " → " = 5 bytes (space + 3-byte arrow + space)
// Strip ":N" index suffix
auto sc1 = src_part.rfind(':');
auto sc2 = dst_part.rfind(':');
if (sc1 != std::string::npos) src_part = src_part.substr(0, sc1);
if (sc2 != std::string::npos) dst_part = dst_part.substr(0, sc2);
return {src_part, dst_part};
}
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
std::ostringstream o;
o << std::fixed;
o.precision(2);
o << "{\"nodes\":[";
for (std::size_t i = 0; i < nodes.size(); ++i) {
const auto& n = nodes[i];
if (i) o << ',';
o << "{\"id\":\"" << escape_json(n.name) << "\""
<< ",\"frames\":" << n.frames_processed
<< ",\"ema_exec_ms\":" << n.ema_exec_ms
<< ",\"max_exec_ms\":" << n.max_exec_ms
<< ",\"blocked_ms\":" << n.total_blocked_ms
<< ",\"fps\":" << n.throughput_fps
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
<< "}";
}
o << "],\"edges\":[";
for (std::size_t i = 0; i < channels.size(); ++i) {
const auto& c = channels[i];
if (i) o << ',';
auto [src, dst] = parse_edge_name(c.name);
o << "{\"name\":\"" << escape_json(c.name) << "\""
<< ",\"source\":\"" << escape_json(src) << "\""
<< ",\"target\":\"" << escape_json(dst) << "\""
<< ",\"capacity\":" << c.capacity
<< ",\"current\":" << c.current_fill
<< ",\"fill_pct\":" << c.fill_pct()
<< ",\"peak_pct\":" << c.peak_pct()
<< ",\"pushes\":" << c.pushes
<< ",\"drops\":" << c.drops
<< ",\"overflows\":" << c.overflows
<< ",\"item_bytes\":" << c.item_bytes
<< ",\"bw_mbs\":" << c.bandwidth_mbs(elapsed_s)
<< "}";
}
o << "]}";
return o.str();
}
// ── Embedded single-page HTML ─────────────────────────────────────────────────
static const char* HTML = R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KPN++ Web Debug</title>
<style>
body { margin: 0; background: #1a1a2e; color: #eee; font-family: monospace; }
#header { padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460; }
#header h1 { margin: 0; font-size: 18px; color: #e94560; }
#header span { font-size: 12px; color: #888; margin-left: 16px; }
#graph { width: 100vw; height: calc(100vh - 50px); }
.node circle { stroke: #fff; stroke-width: 1.5px; }
.node text { font-size: 11px; fill: #eee; pointer-events: none; text-anchor: middle; }
.node .stats { font-size: 9px; fill: #aaa; }
.link { fill: none; stroke-width: 2px; }
.link-label { font-size: 9px; fill: #ccc; }
.arrowhead { fill: #888; }
#tooltip {
position: absolute; background: #0f3460; border: 1px solid #e94560;
border-radius: 4px; padding: 8px 12px; font-size: 11px; pointer-events: none;
display: none; white-space: pre; line-height: 1.6;
}
</style>
</head>
<body>
<div id="header"><h1>KPN++ Web Debug</h1><span id="status">connecting...</span></div>
<svg id="graph"></svg>
<div id="tooltip"></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodeRadius = 30;
const svg = d3.select('#graph');
const width = () => window.innerWidth;
const height = () => window.innerHeight - 50;
// Arrow marker defs
const defs = svg.append('defs');
['green','#f0c040','#e94560'].forEach((col, i) => {
defs.append('marker')
.attr('id', 'arrow' + i)
.attr('viewBox', '0 -5 10 10').attr('refX', 10).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', col);
});
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
let nodes = [], links = [];
function edgeColor(fill_pct) {
if (fill_pct >= 80) return '#e94560';
if (fill_pct >= 50) return '#f0c040';
return '#4CAF50';
}
function edgeArrow(fill_pct) {
if (fill_pct >= 80) return 'url(#arrow2)';
if (fill_pct >= 50) return 'url(#arrow1)';
return 'url(#arrow0)';
}
function nodeColor(ema) {
if (ema > 100) return '#e94560';
if (ema > 50) return '#e07040';
if (ema > 10) return '#f0c040';
return '#4CAF50';
}
function init(data) {
nodes = data.nodes.map(n => ({ ...n, x: width()/2, y: height()/2 }));
const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
links = data.edges.map(e => ({
...e,
source: nodeById[e.source],
target: nodeById[e.target],
})).filter(e => e.source && e.target);
sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(160).strength(0.5))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width()/2, height()/2))
.force('collide', d3.forceCollide(nodeRadius + 20))
.on('tick', ticked);
// Links
linkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('class', 'link')
.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.fill_pct.toFixed(0)}%`);
// Nodes
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', (e,d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
nodeG.append('circle').attr('r', nodeRadius).attr('fill', d => nodeColor(d.ema_exec_ms));
nodeG.append('text').attr('dy', 4).text(d => d.id);
nodeG.append('text').attr('class', 'stats').attr('dy', 18)
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
nodeSel = nodeG;
// Tooltips
const tip = d3.select('#tooltip');
nodeG.on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`node: ${d.id}\nframes: ${d.frames}\nexec (ema): ${d.ema_exec_ms.toFixed(2)} ms` +
`\nexec (max): ${d.max_exec_ms.toFixed(2)} ms\nblocked: ${d.blocked_ms.toFixed(2)} ms` +
`\nfps: ${d.fps.toFixed(2)}\ncpu total: ${d.total_cpu_ms.toFixed(1)} ms` +
`\ncpu util: ${d.cpu_util_pct.toFixed(1)}%`);
}).on('mouseleave', () => tip.style('display','none'));
g.selectAll('.link').on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`channel: ${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%` +
`\ncapacity: ${d.capacity} current: ${d.current}` +
`\npushes: ${d.pushes} drops: ${d.drops} overflows: ${d.overflows}` +
`\nbandwidth: ${d.bw_mbs.toFixed(2)} MB/s item: ${d.item_bytes} B`);
}).on('mouseleave', () => tip.style('display','none'));
}
function update(data) {
// Update node stats in-place (preserve simulation x/y positions)
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
nodes.forEach(n => {
const fresh = byId[n.id];
if (fresh) {
n.frames = fresh.frames; n.ema_exec_ms = fresh.ema_exec_ms;
n.max_exec_ms = fresh.max_exec_ms; n.blocked_ms = fresh.blocked_ms;
n.fps = fresh.fps; n.total_cpu_ms = fresh.total_cpu_ms;
n.cpu_util_pct = fresh.cpu_util_pct;
}
});
// Update edge stats in-place (source/target are already D3 node refs — don't overwrite)
data.edges.forEach((e, i) => {
if (!links[i]) return;
links[i].fill_pct = e.fill_pct; links[i].peak_pct = e.peak_pct;
links[i].pushes = e.pushes; links[i].drops = e.drops;
links[i].overflows = e.overflows; links[i].current = e.current;
});
// Re-color
nodeSel.select('circle').attr('fill', d => nodeColor(d.ema_exec_ms));
nodeSel.select('.stats').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
}
function ticked() {
// Clamp nodes to viewport
nodes.forEach(d => {
d.x = Math.max(nodeRadius, Math.min(width() - nodeRadius, d.x));
d.y = Math.max(nodeRadius, Math.min(height() - nodeRadius, d.y));
});
linkSel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => { // shorten to node edge
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.x - (dx/dist)*(nodeRadius+8);
})
.attr('y2', d => {
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.y - (dy/dist)*(nodeRadius+8);
});
edgeLabelSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
let initialised = false;
async function poll() {
try {
const r = await fetch('/api/snapshot');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
document.getElementById('status').textContent =
`last update: ${new Date().toLocaleTimeString()} ${data.nodes.length} nodes, ${data.edges.length} edges`;
if (!initialised) { init(data); initialised = true; }
else { update(data); }
} catch(e) {
document.getElementById('status').textContent = `error: ${e}`;
}
}
poll();
setInterval(poll, 500);
window.addEventListener('resize', () => sim && sim.force('center', d3.forceCenter(width()/2, height()/2)).alpha(0.1).restart());
</script>
</body>
</html>
)html";
// ── WebDebugServer ────────────────────────────────────────────────────────────
class WebDebugServer {
public:
using SnapshotFn = std::function<std::string()>;
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
: port_(port), snapshot_fn_(std::move(fn)) {}
void start() {
svr_.Get("/", [](const httplib::Request&, httplib::Response& res) {
res.set_content(HTML, "text/html");
});
svr_.Get("/api/snapshot", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(snapshot_fn_(), "application/json");
});
thread_ = std::thread([this] {
svr_.listen("0.0.0.0", static_cast<int>(port_));
});
}
void stop() {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
~WebDebugServer() { stop(); }
WebDebugServer(const WebDebugServer&) = delete;
WebDebugServer& operator=(const WebDebugServer&) = delete;
private:
uint16_t port_;
SnapshotFn snapshot_fn_;
httplib::Server svr_;
std::thread thread_;
};
} // namespace kpn::web_debug