An investigation into whether autonomous load balancing is possible in a distributed environment, using wedges derived from the algebraic properties of Clifford algebra as the routing signal.
The animation above is the oscillation regime
(MigrationHysteresis = 0.15). The convergence regime
(MigrationHysteresis = 0.5) is shown at
.
This is not an architecture for production, and not a rigorous academic result. It is a sandbox: deploy a few nodes, let them generate and forward work, and watch whether coherent global allocation patterns emerge from purely local rules. The framing is phenomenological.
Can nodes that know nothing about global topology or aggregate capacity still coordinate resource allocation through local interaction alone? Each node sees only its neighbours' last-gossiped state, picks at most two of them at random, and forwards work if one looks less loaded. No central scheduler, no all-to-all communication, no global optimisation.
The bet being tested: if the routing metric captures shape mismatch between a task's requirement vector and a node's available capacity — not just scalar load — then heterogeneous nodes should naturally sort tasks by fit, and the system should drift toward an allocation pattern that respects each node's strengths.
Each node's compute, memory, and network capacities are encoded as a
grade-1 multivector in Cl(3,0) — a vector in the
Routing potential combines
Lower
| Primitive | Period | Description |
|---|---|---|
| Heartbeat | 200ms | Presence probe; drives trust recovery (+0.05 per receipt, capped at 1.0) |
| State Gossip | 500ms | Broadcast of capacity multivector, load, queue pressure to all neighbours |
| Task Migrate | on demand | Forwarding of unexecuted work to a neighbour with lower |
Trust starts at 1.0 on neighbour discovery, decays by
Requires Go 1.21+. Build produces a single binary with no external dependencies.
go build -o basin .
./basin -id node_01 -addr 127.0.0.1:8001Launch a second node in another terminal, joining the first:
./basin -id node_02 -addr 127.0.0.1:8002 -join 127.0.0.1:8001 -task| Flag | Default | Description |
|---|---|---|
-id |
node_01 |
Node identifier |
-addr |
127.0.0.1:8001 |
Advertised address (shared with peers) |
-bind |
(same as -addr) |
TCP bind target. Use 0.0.0.0:PORT in Docker |
-join |
(empty) | Comma-separated seed addresses to bootstrap from |
-c -m -n
|
10 10 5 |
Compute / memory / network capacity ( |
-latency |
300ms |
Simulated task execution time |
-task |
false |
Enable continuous task generation on this node |
-task-comp -task-mem -task-net
|
4 0.8 2 |
Task requirement vector |
-task-mass |
1.0 |
Task mass (numerator in |
-task-interval |
1500ms |
Interval between spawned tasks |
-hysteresis |
0.15 |
Min relative |
docker compose up -d # launch 8 heterogeneous nodes (5 generate tasks)
docker compose logs -f # stream structured NDJSON logs
docker compose down # stopThe compose file defaults to the oscillation regime
(MigrationHysteresis = 0.15). To run the convergence regime, set the
HYSTERESIS env var above the queue-pressure threshold (
HYSTERESIS=0.5 docker compose up -d # convergence regimeThe compose file defines 8 nodes with deliberately mismatched capacity
profiles (e.g. node_02 is memory-heavy, node_06 is compute-heavy,
node_07 is network-heavy). Five of them generate tasks at different
rates and with different requirement shapes.
Each node emits NDJSON records on stdout — one JSON object per line with UTC microsecond timestamps, event type, node ID, per-node sequence number, and event-specific fields.
# After a cluster run, dump each node's stdout to a per-node log file.
for n in node_0{1..8}; do docker logs $n > logs/$n.log 2>&1; done
# Merge into a single time-sorted file with verification.
uv run tools/merge_logs.pyuv run tools/visualize_field.py # -> viz/potential_field.mp4The output is an MP4 video rendered by a matplotlib FuncAnimation
pipeline. Each node is a control point on a fixed 3×3 XY grid; the scalar
potential
To render a specific case, point the script at that case's merged log and output path:
uv run tools/visualize_field.py \
--log logs/oscillation/cluster_merged.jsonl \
--out viz/potential_field_oscillation.mp4
uv run tools/visualize_field.py \
--log logs/convergence/cluster_merged.jsonl \
--out viz/potential_field_convergence.mp4Each node is a control point on a fixed 3×3 XY grid; its scalar
potential
Two regimes are contrasted below. Both are single Docker cluster runs (8
nodes, 5 generating tasks) on a lossless bridge.
The regime is selected by a single knob, MigrationHysteresis (the
-hysteresis flag / HYSTERESIS compose var), relative to the
queue-pressure contribution to
The queue-pressure asymmetry that drives the regime change: the holder of
a task always sees its own queue bump as MaxHops force-accepts. If the gate is above it,
the asymmetry can never clear the bar and the task is accepted at origin.
- 3,632 tasks tracked, 3,629 accepted, 3,628 completed
- 26,289 migration events across the mesh
- 12 LINK_ADD events (peer discovery), 0 TRUST_UPDATE events ( docker is stable enough )
| Hops to accept | Tasks | Fraction |
|---|---|---|
| 0 (accepted at origin) | 178 | 4.9% |
| 1–7 | 293 | 8.1% |
8 (hit MaxHops ceiling, force-accepted) |
3,158 | 87.0% |
The vast majority of tasks bounce between two or three peers until the
MaxHops ceiling force-accepts them — exactly the behaviour predicted
when the hysteresis gate sits below the queue-pressure asymmetry.
The most frequent back-and-forth pairs:
| Pair | Migrations |
|---|---|
node_05 ↔ node_08 |
3,752 |
node_01 ↔ node_05 |
3,416 |
node_01 ↔ node_03 |
2,610 |
node_05 ↔ node_06 |
2,498 |
node_07 ↔ node_08 |
2,321 |
These are pairs with similar capacity profiles — the wedge
- Flight time (first-seen → accept): median 82ms, mean 137ms, max 1,054ms
- The long tail is tasks that exhausted all 8 hops before force-acceptance.
Self-acceptance (task accepted at its origin node) was 4.9% (178 of 3,629). The system almost always migrates at least once, even when the origin could have served the task.
Final acceptor distribution:
| Node | Accepts | Capacity profile ( |
|---|---|---|
node_01 |
738 | 12 / 8 / 6 |
node_05 |
615 | 10 / 10 / 5 |
node_03 |
510 | 8 / 6 / 10 |
node_08 |
404 | 9 / 11 / 3 |
node_06 |
393 | 14 / 4 / 7 |
node_07 |
373 | 5 / 8 / 12 |
node_04 |
324 | 4 / 12 / 8 |
node_02 |
272 | 6 / 14 / 4 |
Because 87% of acceptances were force-accepted at the MaxHops ceiling,
the distribution is more a function of topology (who is the last hop
before the ceiling) than of genuine shape-based selection.
- 1,024 tasks tracked, 1,024 accepted, 1,021 completed
- 63 migration events across the mesh (down from 26,289)
- 13 LINK_ADD events, 0 TRUST_UPDATE events
| Hops to accept | Tasks | Fraction |
|---|---|---|
| 0 (accepted at origin) | 961 | 93.8% |
| 1–7 | 63 | 6.2% |
8 (hit MaxHops ceiling, force-accepted) |
0 | 0.0% |
Raising the gate above MaxHops ceiling. The queue-pressure asymmetry can no longer
clear the bar, so the holder accepts locally instead of migrating.
The only migration pair was node_03 ↔ node_05 (63 edges) — the pair
with the smallest
- Flight time (first-seen → accept): median 0ms, mean 5ms, max 253ms
- The median is zero because tasks are accepted at their origin on the same tick they are spawned.
Self-acceptance was 93.8% (961 of 1,024). Final acceptor distribution:
| Node | Accepts | Capacity profile ( |
|---|---|---|
node_03 |
323 | 8 / 6 / 10 |
node_01 |
217 | 12 / 8 / 6 |
node_08 |
200 | 9 / 11 / 3 |
node_07 |
162 | 5 / 8 / 12 |
node_05 |
122 | 10 / 10 / 5 |
node_02 |
0 | 6 / 14 / 4 |
node_04 |
0 | 4 / 12 / 8 |
node_06 |
0 | 14 / 4 / 7 |
Only the five task-generating nodes accepted work; the three idle nodes
received nothing. This is the over-damping signature: at node_03 accepts the most because it both generates
tasks (the fastest rate, 500ms) and is the sole receiver of the
node_05 → node_03 migration edge.
Neither case exhibits it. At
All in basin/scheduler.go. MigrationHysteresis is a package variable
set at startup by the -hysteresis flag (or the HYSTERESIS compose
var); the rest are compile-time constants.
| Knob | Default | Effect |
|---|---|---|
Alpha |
1.0 | Load exponent in the potential denominator. Higher → load dominates routing. |
Beta |
0.5 | Queue-pressure exponent. Higher → queue pressure dominates. |
MigrationHysteresis (-hysteresis) |
0.15 | Minimum relative |
MaxHops |
8 | Routing diameter ceiling. Force-accepts tasks that would otherwise ping-pong forever. |
uv sync
uv run tools/visualize_field.py