Skip to content

CU4 — Fans in both directions

Graph.somatize(Left().named("left") | Right().named("right")) >> Mean()
# `Mean` receives {"left": …, "right": …}

Status: closed. 46 tests in Rust, 52 in Python.

The question: where does aggregation live?

Section titled “The question: where does aggregation live?”

The original answers it twice, and both answers teach something:

  • On the edge (forward): it joins what arrives into a serde_json::Map keyed by the source node, and the aggregator is an ordinary node — MajorityVote is a Filter.
  • In training (federated): FederatedAggregation::{FedAvg, FedProx, FedYogi} and GradientAggregation::{AllReduce, ParameterServer, …} — enums of algorithms, wrapped in StateAggregator/GradientAggregator traits with exactly one implementor each: the enum itself. Both are on the orphan-trait list. When they enumerated FL’s real algorithms they got an enum; the trait on top bought nothing.

And the trap to spot: federated aggregation is not fan-in. In FedAvg what is averaged are the states of N workers when a round closes — there is no edge and no predecessors there. It is an operation inside fit, and it will arrive with it.

  1. There is no Aggregator trait. An aggregator is a filter that reads a map. Mean, MajorityVote, Concat, WeightedMean are library.
  2. Value::Map, ordered. A HashMap iterates differently in each process, so flattening it to a list would give a different order every time and the content hash — once the cache arrives — would be useless. The pairs follow the edges’ declaration order, which is also what mirrors a Python dict: the round trip gives the same dict.
  3. Both directions have the same shape. Several inputs → a map keyed by each source. Several leaves → a map keyed by each leaf. A diamond comes back round.
  4. The weight travels with the value. FedAvg weights by each client’s samples; neither a list nor a map of raw outputs gives that weight. Each branch produces something like {"update": …, "n": 128} — another independent reason to have Value::Map.

A Plan::Parallel variant that meant these branches do not depend on each other broke on the diamond: both branches claimed the join node and it executed twice. The right shape is for every step to carry where its input comes from (Execute { node, from }). With that the plan stays self-contained — the engine does not look at the graph again — and the fans fall out with no special variant at all.

So the variant went, and with it the CompileError::Fanin and ManyLeaves errors, leaving CompileError with one. A variant that only describes structure buys nothing; parallelism comes back in CU9 meaning something it did not mean here — running at the same time.