Add static network
🧪 Test / test (push) Successful in 6m4s

This commit is contained in:
2026-05-08 20:00:15 +02:00
parent 3c683c821d
commit 2bca2a7554
15 changed files with 1879 additions and 18 deletions
+292
View File
@@ -344,6 +344,70 @@ static_assert(
Multi-output functions must return `std::tuple<...>`. Single return accepted as-is.
`void` return = sink node (no output ports).
### Node identity: `Label` and `UniqueTag` NTTPs
Two problems arise when using `Node` as a compile-time graph vertex:
1. **Debug names** — without a label the web UI and `print_diagnostics` fall back to
`"node[0]"` placeholders. `set_name()` provides a runtime name for the runtime
`Network`, but `StaticNetwork` has no `add("name", node)` call to attach one.
2. **Same-function collision** — two nodes wrapping the same function (e.g. two
`make_node<blur_func>`) have the same type. The `StaticNetwork` topo sort and fanout
detection use node types as graph vertices, so it cannot distinguish them, causing
infinite recursion in the DFS.
Both are solved by two additional NTTPs on `Node`:
```cpp
template<
auto Func,
typename InputTag = in<>,
typename LatchTag = latch<>,
typename OutputTag = out<>,
fixed_string Label = "", // human-readable name; shown in diagnostics/web UI
std::size_t UniqueTag = 0 // collision-breaker; must differ between any two
> // same-Func nodes in one make_network() call
class Node : public INode { ... };
```
Both have defaults so all existing code (`make_node<blur_func>(5)`) compiles unchanged.
**Factory syntax extension:**
```cpp
// Label only (UniqueTag defaults to 0)
auto blur = make_node<blur_func, "blur">(8);
// Both label and unique tag — required when the same function is used twice
auto preblur = make_node<blur_func, "preblur", 0>(8);
auto postblur = make_node<blur_func, "postblur", 1>(8);
```
**Duplicate-tag detection**`make_network()` checks at compile time that no two nodes
in the edge pack share the same `(Func, UniqueTag)` pair. This fires a readable
`static_assert` at the call site before any thread is started:
```
static_assert(!has_duplicate_tags_v<AllUserNodes>,
"make_network: two nodes have identical (Func, UniqueTag) — increment UniqueTag "
"on one of them to make them distinct");
```
**Label availability**`Node` exposes the label as a `static constexpr` so
`StaticNetwork` can read it at compile time for diagnostics:
```cpp
static constexpr std::string_view label() { return Label.view(); }
```
The web debug UI and `print_diagnostics` use `label()` when non-empty, falling back to
`"node[<UniqueTag>]"` for unlabelled nodes. The runtime `Network` continues to use the
string passed to `add("name", node)``Label` is orthogonal to that mechanism.
**`ObjectNode`** gains the same two NTTPs with the same defaults. The `make_node(obj, ...)`
overloads are extended identically.
---
## Component 4a — Latched Input Ports
@@ -1065,6 +1129,231 @@ net.start();
---
## Component 9 — `static_network.hpp`: Compile-time Graph Builder
### Motivation
The runtime `Network` builder has two limitations that only a compile-time graph can fix:
1. **Fan-out `N` is unknowable at the first `connect()` call.** A `FanoutNode<T, N>` requires
`N` as a template parameter. With runtime `connect()`, the network has seen only one edge
when the first call arrives; it cannot know how many more will follow for that port. Auto-
inserting the right `FanoutNode<T, N>` requires seeing the complete edge list at once —
which is only possible if the edge list is a type.
2. **Start/stop goes through virtual dispatch.** `Network` stores `INode*` and calls virtual
`start()`/`stop()`. With a typed node tuple the compiler sees the concrete types and can
inline or devirtualise. This matters at startup/shutdown, not in the hot path — but it is
avoidable overhead.
The runtime `Network` is **not removed**. It remains the right choice for Python graphs,
sub-networks embedded in dynamic topologies, and any case where the graph shape is not known
until runtime. `StaticNetwork` is an additional builder for the common case where the full
C++ topology is known at compile time.
### API
```cpp
// edge() constructs a typed edge descriptor from two port handles.
// All type information (SrcNode, SrcIdx, DstNode, DstIdx) is in the return type.
template<typename SrcNode, std::size_t SrcIdx,
typename DstNode, std::size_t DstIdx>
auto edge(OutputPort<SrcNode, SrcIdx>, InputPort<DstNode, DstIdx>)
-> Edge<SrcNode, SrcIdx, DstNode, DstIdx>;
// make_network() accepts all edges as a variadic pack.
// It deduces the full topology, auto-inserts FanoutNodes where needed,
// wires all channels, and returns a StaticNetwork owning the fanout nodes.
// User nodes are held by reference (non-owning), same lifetime contract as Network.
template<typename... Edges>
auto make_network(Edges&&... edges) -> StaticNetwork<...>;
```
Usage:
```cpp
auto src = make_node<produce>(8);
auto blur = make_node<blur_func, in<"img">>(8);
auto detect = make_node<detect_func, in<"img">>(8);
auto sink = make_node<display>(8);
// src:output<0> feeds both blur and detect — fan-out is auto-inserted
auto net = make_network(
edge(src.output<0>(), blur.input<0>()),
edge(src.output<0>(), detect.input<0>()), // same source port
edge(blur.output<0>(), sink.input<0>()),
edge(detect.output<0>(), sink.input<1>())
);
net.start();
// ...
net.stop();
```
No `add()`, no `build()`, no string names. The graph is fully wired in the `make_network`
call. `start()` and `stop()` are non-virtual tuple traversals.
### Edge type
```cpp
// Carries references to the two endpoint nodes. Stores no data beyond that.
template<typename SrcNode, std::size_t SrcIdx,
typename DstNode, std::size_t DstIdx>
struct Edge {
SrcNode& src;
DstNode& dst;
};
```
### Fan-out detection metafunction
`make_network` receives `Edge<...>` types as a pack. Before wiring, a metafunction scans
the pack for output ports with more than one downstream edge:
```
fanout_groups<Edges...>
```
This is a compile-time multimap: keys are `(SrcNode type, SrcIdx)`, values are the list of
destination `(DstNode type, DstIdx)` pairs sharing that key.
For each key with N > 1 destinations:
- Compute `T = std::tuple_element_t<SrcIdx, SrcNode::return_tuple>`
- Synthesise a `FanoutNode<T, N>` — call it `F`
- Replace the N original edges with:
- one edge: `src:SrcIdx → F:input<0>`
- N edges: `F:output<0..N-1> → original dst:DstIdx`
For keys with N == 1 the edge is kept as-is.
The result is an expanded edge list with all fan-outs made explicit, and a list of
`FanoutNode<T, N>` types that need to be instantiated.
### `StaticNetwork` structure
```cpp
template<typename UserNodeRefs, // tuple of T& for each user node
typename FanoutStorage, // tuple of FanoutNode<T,N> owned by the network
typename TopoOrder> // index_sequence encoding start/stop order
class StaticNetwork : public INode {
public:
void start(); // std::apply over TopoOrder — no virtual dispatch, no map lookup
void stop(); // reverse of TopoOrder
bool running() const;
// Diagnostics — iterates typed tuples; same NodeSnapshot / ChannelSnapshot output
// as Network, compatible with print_diagnostics and the web debug UI.
void print_diagnostics(std::ostream& = std::cerr) const;
// StaticNetwork is itself an INode, so it can be embedded in a runtime Network
// via net.add("stage", static_net) exactly like any other node.
void set_name(std::string) override;
const NodeStats& stats() const override;
NodeSnapshot node_snapshot(const std::string&, double) const override;
private:
FanoutStorage fanouts_; // owns the auto-generated FanoutNode instances
// User nodes held by reference — same non-owning contract as Network
};
```
`FanoutStorage` is a `std::tuple<FanoutNode<T0,N0>, FanoutNode<T1,N1>, ...>` with one
element per auto-inserted fanout. It is owned by the `StaticNetwork` and lives as long as
the network does — which satisfies the channel lifetime contract (channels are owned by their
consumer, and the fanout node is the consumer of the upstream output).
### Cycle detection
With the full edge list as a type pack, cycle detection is a `static_assert` rather than a
runtime exception. A compile-time DFS over the expanded edge list fires a readable assertion
at the `make_network` call site:
```
static_assert(!has_cycle_v<ExpandedEdges...>,
"make_network: graph contains a directed cycle");
```
`NetworkCycleError` is no longer needed for `StaticNetwork` — the cycle is caught before any
object is constructed.
### Topological order
The same compile-time DFS produces a topological ordering as an `std::index_sequence` over
the node tuple. `start()` iterates it forward, `stop()` iterates it in reverse. No runtime
sort, no `std::vector<std::string>`.
### Node labels for diagnostics / web debug
Labels come directly from the `Label` NTTP on each `Node` type — no separate annotation
on `edge()` is needed. `StaticNetwork` reads `NodeType::label()` at compile time for each
vertex in the topo order and stores the result as a `std::string_view` array at
construction time. Zero runtime overhead: the label is a compile-time string literal.
```cpp
auto src = make_node<produce, "src" >(8);
auto blur = make_node<blur_func, "blur" >(8);
auto detect = make_node<detect_func,"detect">(8);
auto net = make_network(
edge(src.output<0>(), blur.input<0>()),
edge(src.output<0>(), detect.input<0>())
);
// web UI shows nodes named "src", "blur", "detect"
// auto-inserted FanoutNode is labelled "fanout[src:0]"
```
Unlabelled nodes (`Label == ""`) fall back to `"node[<UniqueTag>]"` in diagnostics.
Auto-inserted fanout nodes are labelled `"fanout[<src_label>:<SrcIdx>]"` automatically.
### Wiring sequence in `make_network`
All wiring happens in the `make_network` constructor body — no `build()` call needed:
1. Instantiate `FanoutStorage` (default-construct each `FanoutNode<T,N>`).
2. For each expanded edge (in topological order):
- Call `src.set_output_channel<SrcIdx>(&dst.input_channel<DstIdx>())`.
3. Return the `StaticNetwork`.
Channel pointers are set once and never changed. No dynamic allocation after construction.
### What is eliminated vs `Network`
| `Network` (runtime) | `StaticNetwork` (compile-time) |
|---|---|
| `std::map<string, INode*>` | typed `std::tuple` of references |
| Runtime DFS + `NetworkCycleError` | `static_assert` at `make_network` call site |
| Virtual `start()`/`stop()` per node | `std::apply` over typed tuple |
| Explicit `make_fanout<T,N>` | auto-inserted from edge pack |
| `connected_outputs_` duplicate check | structural impossibility — no duplicate edge can produce two `set_output_channel` calls |
| `build()` step | no build step — wired in constructor |
The hot path (per-item `pop``push` in each node thread) is identical in both cases.
### Compatibility
- `StaticNetwork` implements `INode`, so it can be registered inside a runtime `Network`
via `net.add("name", static_net)` — enabling mixed static/dynamic graphs.
- All existing node types (`Node`, `ObjectNode`, `FanoutNode`, `MainThreadNode`) work
unchanged as vertices in a `StaticNetwork`.
- The Python `PyNetwork` is unaffected — it remains runtime-only.
### File layout addition
```
include/kpn/
static_network.hpp # Edge<>, make_network(), StaticNetwork<>
tmp/
fanout_groups.hpp # fanout_groups<Edges...> metafunction
topo_sort.hpp # compile-time DFS + cycle check
repeat_tuple.hpp # repeat_tuple_t<T,N> (moved from fanout.hpp)
```
`fanout.hpp` keeps `FanoutNode<T,N>` and `make_fanout<T,N>` for users who want to wire
fanouts explicitly in a runtime `Network`. `static_network.hpp` uses `FanoutNode` internally
but the user never calls `make_fanout` when using `make_network`.
---
## Resolved Design Decisions
All major design questions are now closed:
@@ -1079,3 +1368,6 @@ All major design questions are now closed:
| `make_py_network` | Pure C++ template; nanobind module recompilation is the registration step |
| GIL strategy | Acquire per Python callback; release while blocking on channel ops |
| Mixed-rate inputs | `latch<>` tag for ports that reuse last-seen value; blocks only on first fire; node fires at rate of `in<>` ports |
| Fan-out | Explicit `FanoutNode<T,N>` for runtime `Network`; auto-inserted by `make_network()` for `StaticNetwork` |
| Static vs runtime graph | Both coexist; `StaticNetwork` for C++ graphs known at compile time, `Network` for Python/dynamic graphs; `StaticNetwork` implements `INode` so it embeds in `Network` |
| Node identity in static graphs | `Label` NTTP (human name for diagnostics) + `UniqueTag` NTTP (collision-breaker for same-Func nodes); both default to `""` / `0` so existing code is unaffected |