@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user