From c40e483ee6ec8f7ef360f1de63350264c0b4026f Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 12:08:39 +0200 Subject: [PATCH] engine: education strategies (random vs most-connected) Two ways to spend the same education budget: a uniform random sample (spray and pray) vs the highest-degree hubs, ties broken stably towards lower node numbers so the pick is deterministic. The origin is never educated; they post the fake. rng.Shuffle does a seeded Fisher-Yates, so the random pick is reproducible too. min() is a builtin since Go 1.21. --- internal/engine/educate.go | 52 +++++++++++++++++++++++++ internal/engine/educate_test.go | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 internal/engine/educate.go create mode 100644 internal/engine/educate_test.go diff --git a/internal/engine/educate.go b/internal/engine/educate.go new file mode 100644 index 0000000..ee8e354 --- /dev/null +++ b/internal/engine/educate.go @@ -0,0 +1,52 @@ +package engine + +import ( + "math/rand/v2" + "slices" +) + +// Education strategies pick which students the program educates. The origin +// is never educated (they post the fake in the first place). Returned +// slices are sorted by node number for readable, stable output; the order +// carries no meaning. + +// EducateRandom educates count students drawn uniformly from everyone but +// the origin. This is the "spray and pray" baseline. +func EducateRandom(graph *Graph, origin, count int, rng *rand.Rand) []int { + if count <= 0 { + return nil + } + candidates := make([]int, 0, graph.NumNodes()-1) + for student := range graph.NumNodes() { + if student != origin { + candidates = append(candidates, student) + } + } + rng.Shuffle(len(candidates), func(i, j int) { + candidates[i], candidates[j] = candidates[j], candidates[i] + }) + educated := candidates[:min(count, len(candidates))] + slices.Sort(educated) + return educated +} + +// EducateMostConnected educates the count students with the highest degree: +// the hubs whose forwarding keeps the network connected. Ties break towards +// the lower node number (stable sort) so the choice is deterministic. +func EducateMostConnected(graph *Graph, origin, count int) []int { + if count <= 0 { + return nil + } + candidates := make([]int, 0, graph.NumNodes()-1) + for student := range graph.NumNodes() { + if student != origin { + candidates = append(candidates, student) + } + } + slices.SortStableFunc(candidates, func(first, second int) int { + return graph.Degree(second) - graph.Degree(first) // descending by degree + }) + educated := candidates[:min(count, len(candidates))] + slices.Sort(educated) + return educated +} diff --git a/internal/engine/educate_test.go b/internal/engine/educate_test.go new file mode 100644 index 0000000..2434d84 --- /dev/null +++ b/internal/engine/educate_test.go @@ -0,0 +1,68 @@ +package engine + +import ( + "slices" + "testing" +) + +// starGraph builds a small graph with a known degree ranking: +// node 0 has degree 5, node 1 degree 3, nodes 2 and 3 degree 2, +// nodes 4 and 5 degree 1. +func starGraph() *Graph { + graph := NewGraph(6) + for leaf := 1; leaf <= 5; leaf++ { + graph.AddEdge(0, leaf) + } + graph.AddEdge(1, 2) + graph.AddEdge(1, 3) + return graph +} + +func TestEducateMostConnected(t *testing.T) { + tests := []struct { + name string + origin int + count int + want []int + }{ + {name: "picks top degrees, skips origin", origin: 0, count: 2, want: []int{1, 2}}, + {name: "origin can be the biggest hub", origin: 1, count: 2, want: []int{0, 2}}, + {name: "tie breaks to lower node number", origin: 0, count: 3, want: []int{1, 2, 3}}, + {name: "count clamps to available students", origin: 0, count: 99, want: []int{1, 2, 3, 4, 5}}, + {name: "zero count educates nobody", origin: 0, count: 0, want: nil}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + got := EducateMostConnected(starGraph(), testCase.origin, testCase.count) + if !slices.Equal(got, testCase.want) { + t.Errorf("EducateMostConnected(origin=%d, count=%d) = %v, want %v", + testCase.origin, testCase.count, got, testCase.want) + } + }) + } +} + +func TestEducateRandomProperties(t *testing.T) { + graph := starGraph() + const origin, count = 0, 3 + + educated := EducateRandom(graph, origin, count, newRand(1)) + + if len(educated) != count { + t.Fatalf("len(educated) = %d, want %d", len(educated), count) + } + if slices.Contains(educated, origin) { + t.Errorf("educated %v contains the origin %d", educated, origin) + } + for index := 1; index < len(educated); index++ { + if educated[index] == educated[index-1] { + t.Errorf("educated %v contains a duplicate", educated) + } + } + + // Same seed, same pick; that is the determinism contract. + repeat := EducateRandom(graph, origin, count, newRand(1)) + if !slices.Equal(educated, repeat) { + t.Errorf("same seed picked %v then %v", educated, repeat) + } +}