hexagram_test.go raw
1 package hexagram
2
3 import (
4 "context"
5 "testing"
6 "time"
7
8 "git.mleku.dev/mleku/dendrite/pkg/axiom"
9 "git.mleku.dev/mleku/dendrite/pkg/lattice"
10 "git.mleku.dev/mleku/dendrite/pkg/state"
11 )
12
13 type tagConstraint struct{ tag string }
14
15 func (c tagConstraint) Tag() string { return c.tag }
16 func (c tagConstraint) Admits(e axiom.Element) bool { return e.Type() == c.tag }
17
18 type elem struct {
19 tag string
20 val any
21 }
22
23 func (e elem) Type() string { return e.tag }
24 func (e elem) Value() any { return e.val }
25
26 func TestTableCompleteness(t *testing.T) {
27 // All 64 entries should be populated (no zero-value Op except OpNone).
28 noneCount := 0
29 for i, rule := range table {
30 if rule.Op == OpNone {
31 noneCount++
32 }
33 _ = i
34 }
35 // Some OpNone entries are expected (stable states), but not all.
36 if noneCount == 64 {
37 t.Fatal("all 64 rules are OpNone — table is empty")
38 }
39 t.Logf("table: %d OpNone, %d active", noneCount, 64-noneCount)
40 }
41
42 func TestLookupCreativeCreative(t *testing.T) {
43 // Heaven/Heaven = Mountain inner (111), all bits set.
44 // Inner Mountain (111) + outer Mountain (111) = full equilibrium.
45 h := state.Hex(state.Mountain, state.Mountain)
46 rule := Lookup(h)
47 // Should be a low-priority maintenance op.
48 if rule.Op == OpDissolve {
49 t.Error("full equilibrium should not dissolve")
50 }
51 }
52
53 func TestLookupEarthEarth(t *testing.T) {
54 // Earth/Earth = completely empty, no energy.
55 h := state.Hex(state.Earth, state.Earth)
56 rule := Lookup(h)
57 if rule.Op != OpNone {
58 t.Errorf("Earth/Earth should be OpNone, got %d", rule.Op)
59 }
60 if rule.Priority != PriorityIdle {
61 t.Errorf("Earth/Earth should be idle priority, got %d", rule.Priority)
62 }
63 }
64
65 func TestLookupHeavenInner(t *testing.T) {
66 // Heaven inner (101) = ideal growth. Should accrete.
67 h := state.Hex(state.Heaven, state.Earth)
68 rule := Lookup(h)
69 if rule.Op != OpAccrete {
70 t.Errorf("Heaven/Earth should accrete, got %d", rule.Op)
71 }
72 }
73
74 func TestLookupFireInner(t *testing.T) {
75 // Fire inner (100) = noisy growth, no constraint.
76 // With constrained environment -> prune.
77 h := state.Hex(state.Fire, state.Mountain)
78 rule := Lookup(h)
79 if rule.Op != OpPrune {
80 t.Errorf("Fire/Mountain should prune, got %d", rule.Op)
81 }
82 }
83
84 func TestEngineTick(t *testing.T) {
85 l := lattice.New()
86
87 // Create a small lattice.
88 nodes := make([]*lattice.Node, 5)
89 for i := range nodes {
90 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
91 }
92 for i := range nodes {
93 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
94 }
95
96 // Set energy on some nodes to trigger operations.
97 nodes[0].SetEnergy(true)
98 nodes[1].SetEnergy(true)
99
100 // Put elements in solution.
101 solution := make(chan axiom.Element, 20)
102 solution <- elem{"word", "hello"}
103 solution <- elem{"word", "world"}
104
105 events := make(chan Event, 100)
106
107 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
108 defer cancel()
109
110 go RunEngine(ctx, l, EngineConfig{
111 Interval: 10 * time.Millisecond,
112 Solution: solution,
113 MaxNewSites: 2,
114 }, events)
115
116 <-ctx.Done()
117 close(events)
118
119 opCounts := make(map[Op]int)
120 for ev := range events {
121 opCounts[ev.Op]++
122 }
123
124 t.Logf("engine operations: %v", opCounts)
125
126 // The engine should have done something.
127 total := 0
128 for _, count := range opCounts {
129 total += count
130 }
131 if total == 0 {
132 t.Error("engine performed no operations")
133 }
134 }
135
136 func TestEngineGrowsLattice(t *testing.T) {
137 l := lattice.New()
138
139 // Start with 3 nodes, all energized.
140 nodes := make([]*lattice.Node, 3)
141 for i := range nodes {
142 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
143 nodes[i].SetEnergy(true)
144 }
145 for i := range nodes {
146 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
147 }
148
149 // Bond one to make it Mountain (111) — which triggers Explore.
150 nodes[0].Bond(elem{"word", "seed"})
151
152 initialSize := l.Size()
153
154 events := make(chan Event, 200)
155 ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
156 defer cancel()
157
158 go RunEngine(ctx, l, EngineConfig{
159 Interval: 10 * time.Millisecond,
160 MaxNewSites: 4,
161 }, events)
162
163 <-ctx.Done()
164
165 finalSize := l.Size()
166 t.Logf("lattice grew from %d to %d nodes", initialSize, finalSize)
167
168 if finalSize <= initialSize {
169 t.Error("engine should have grown the lattice via Explore/Nucleate")
170 }
171 }
172