grow_test.go raw
1 package grow
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "git.mleku.dev/mleku/dendrite/pkg/axiom"
10 "git.mleku.dev/mleku/dendrite/pkg/enzyme"
11 "git.mleku.dev/mleku/dendrite/pkg/lattice"
12 )
13
14 type tagConstraint struct{ tag string }
15
16 func (c tagConstraint) Tag() string { return c.tag }
17 func (c tagConstraint) Admits(e axiom.Element) bool { return e.Type() == c.tag }
18
19 func TestGrowthBasic(t *testing.T) {
20 l := lattice.New()
21
22 // Create a small lattice with word sites.
23 nodes := make([]*lattice.Node, 10)
24 for i := range nodes {
25 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
26 }
27 // Connect in a ring.
28 for i := range nodes {
29 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
30 }
31
32 // Feed three elements.
33 solution := make(chan axiom.Element, 3)
34 solution <- enzyme.Elem("word", "hello")
35 solution <- enzyme.Elem("word", "world")
36 solution <- enzyme.Elem("word", "test")
37 close(solution)
38
39 events := make(chan Event, 10)
40
41 ctx := context.Background()
42 cfg := Config{MaxSteps: 500, Workers: 2}
43
44 Run(ctx, l, solution, cfg, events)
45 close(events)
46
47 bonded := 0
48 for ev := range events {
49 if ev.Type == EventBonded {
50 bonded++
51 }
52 }
53
54 if bonded != 3 {
55 t.Errorf("expected 3 bonds, got %d", bonded)
56 }
57
58 // Verify lattice state.
59 occupied := 0
60 for _, n := range l.Nodes() {
61 if n.Occupied() {
62 occupied++
63 }
64 }
65 if occupied != 3 {
66 t.Errorf("expected 3 occupied nodes, got %d", occupied)
67 }
68 }
69
70 func TestGrowthTypeRejection(t *testing.T) {
71 l := lattice.New()
72
73 // Only word sites.
74 nodes := make([]*lattice.Node, 5)
75 for i := range nodes {
76 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
77 }
78 for i := range nodes {
79 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
80 }
81
82 // Feed a number element — wrong type.
83 solution := make(chan axiom.Element, 1)
84 solution <- enzyme.Elem("number", "42")
85 close(solution)
86
87 events := make(chan Event, 5)
88 ctx := context.Background()
89 cfg := Config{MaxSteps: 100, Workers: 1}
90
91 Run(ctx, l, solution, cfg, events)
92 close(events)
93
94 for ev := range events {
95 if ev.Type == EventBonded {
96 t.Fatal("number should not bond at word site")
97 }
98 }
99 }
100
101 func TestGrowthCancellation(t *testing.T) {
102 l := lattice.New()
103 n := l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
104 _ = n
105
106 // Endless solution.
107 solution := make(chan axiom.Element)
108 events := make(chan Event, 100)
109
110 ctx, cancel := context.WithCancel(context.Background())
111 cfg := Config{MaxSteps: 100, Workers: 1}
112
113 done := make(chan struct{})
114 go func() {
115 Run(ctx, l, solution, cfg, events)
116 close(done)
117 }()
118
119 // Cancel immediately.
120 cancel()
121 <-done // should return promptly
122 }
123
124 func TestGrowthSaturation(t *testing.T) {
125 l := lattice.New()
126
127 // 3 sites.
128 nodes := make([]*lattice.Node, 3)
129 for i := range nodes {
130 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
131 }
132 for i := range nodes {
133 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
134 }
135
136 // Feed 5 elements — only 3 can bond.
137 solution := make(chan axiom.Element, 5)
138 for i := range 5 {
139 solution <- enzyme.Elem("word", string(rune('a'+i)))
140 }
141 close(solution)
142
143 events := make(chan Event, 10)
144 ctx := context.Background()
145 cfg := Config{MaxSteps: 500, Workers: 2}
146
147 Run(ctx, l, solution, cfg, events)
148 close(events)
149
150 bonded := 0
151 expired := 0
152 for ev := range events {
153 switch ev.Type {
154 case EventBonded:
155 bonded++
156 case EventExpired:
157 expired++
158 }
159 }
160
161 if bonded != 3 {
162 t.Errorf("expected 3 bonds, got %d", bonded)
163 }
164 if expired != 2 {
165 t.Errorf("expected 2 expired, got %d", expired)
166 }
167 }
168
169 func TestBlockFreezeThawRoundTrip(t *testing.T) {
170 l := lattice.New()
171
172 // Create 8 nodes with "word" constraints, connect in ring.
173 nodes := make([]*lattice.Node, 8)
174 for i := range nodes {
175 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
176 }
177 for i := range nodes {
178 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
179 }
180
181 // Bond some elements.
182 nodes[0].Bond(enzyme.Elem("word", "alpha"))
183 nodes[3].Bond(enzyme.Elem("word", "beta"))
184 nodes[5].Bond(enzyme.Elem("word", "gamma"))
185
186 // Build a block covering all nodes.
187 bm := buildBlockMap(l, 8)
188 if len(bm.Blocks) != 1 {
189 t.Fatalf("expected 1 block, got %d", len(bm.Blocks))
190 }
191 b := bm.Blocks[0]
192
193 // Snapshot pre-freeze state.
194 type nodeState struct {
195 occupied bool
196 bondCount int
197 perm uint8
198 age uint8
199 }
200 pre := make([]nodeState, len(b.Nodes))
201 for i, n := range b.Nodes {
202 pre[i] = nodeState{
203 occupied: n.Occupied(),
204 bondCount: n.BondCount(),
205 perm: n.Permutation(),
206 age: n.Age(),
207 }
208 }
209
210 // Freeze to temp dir.
211 dir := t.TempDir()
212 if err := freezeBlock(b, dir); err != nil {
213 t.Fatalf("freezeBlock: %v", err)
214 }
215
216 // Verify file exists.
217 path := blockFilePath(dir, b.ID)
218 if _, err := os.Stat(path); err != nil {
219 t.Fatalf("block file not found: %v", err)
220 }
221
222 // Strip nodes.
223 for _, n := range b.Nodes {
224 n.StripForEvictionUnsafe()
225 }
226
227 // Verify stripped: all nodes should report unoccupied.
228 for i, n := range b.Nodes {
229 if n.Occupied() {
230 t.Errorf("node %d still occupied after strip", i)
231 }
232 }
233
234 // Thaw.
235 cf := func(tag string) axiom.Constraint { return tagConstraint{tag} }
236 if err := thawBlock(b, dir, cf); err != nil {
237 t.Fatalf("thawBlock: %v", err)
238 }
239
240 // Verify state matches pre-freeze.
241 for i, n := range b.Nodes {
242 got := nodeState{
243 occupied: n.Occupied(),
244 bondCount: n.BondCount(),
245 perm: n.Permutation(),
246 age: n.Age(),
247 }
248 if got.occupied != pre[i].occupied {
249 t.Errorf("node %d: occupied mismatch: got %v, want %v", i, got.occupied, pre[i].occupied)
250 }
251 if got.bondCount != pre[i].bondCount {
252 t.Errorf("node %d: bondCount mismatch: got %d, want %d", i, got.bondCount, pre[i].bondCount)
253 }
254 }
255
256 // Verify occupant values.
257 occ0 := b.Nodes[0].Occupant()
258 if occ0 == nil || occ0.Value() != "alpha" {
259 t.Errorf("node 0: expected occupant value 'alpha', got %v", occ0)
260 }
261 occ3 := b.Nodes[3].Occupant()
262 if occ3 == nil || occ3.Value() != "beta" {
263 t.Errorf("node 3: expected occupant value 'beta', got %v", occ3)
264 }
265 }
266
267 func TestPagedGrowth(t *testing.T) {
268 l := lattice.New()
269
270 // Create a lattice large enough for multiple blocks.
271 // 4 blocks of 4 nodes = 16 nodes total.
272 numNodes := 16
273 nodes := make([]*lattice.Node, numNodes)
274 for i := range nodes {
275 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
276 }
277 // Connect in ring + cross-links for better connectivity.
278 for i := range nodes {
279 l.Connect(nodes[i], nodes[(i+1)%numNodes])
280 if i+4 < numNodes {
281 l.Connect(nodes[i], nodes[i+4]) // cross-block bridge
282 }
283 }
284
285 // Feed 6 elements — should all find homes in 16 sites.
286 numElems := 6
287 solution := make(chan axiom.Element, numElems)
288 for i := range numElems {
289 solution <- enzyme.Elem("word", string(rune('a'+i)))
290 }
291 close(solution)
292
293 dir := t.TempDir()
294 blockDir := filepath.Join(dir, "blocks")
295 os.MkdirAll(blockDir, 0o755)
296
297 events := make(chan Event, 20)
298 ctx := context.Background()
299 cf := func(tag string) axiom.Constraint { return tagConstraint{tag} }
300
301 cfg := Config{
302 MaxSteps: 500,
303 Workers: 2,
304 BlockSize: 4, // 4 blocks of 4 nodes
305 MaxRounds: 50,
306 MaxResidentBlocks: 2, // only 2 of 4 blocks resident at a time
307 BlockDir: blockDir,
308 ConstraintFactory: cf,
309 }
310
311 RunBlocked(ctx, l, solution, cfg, events)
312 close(events)
313
314 bonded := 0
315 expired := 0
316 rejected := 0
317 for ev := range events {
318 switch ev.Type {
319 case EventBonded:
320 bonded++
321 case EventExpired:
322 expired++
323 case EventRejected:
324 rejected++
325 }
326 }
327
328 t.Logf("paged growth: bonded=%d expired=%d rejected=%d", bonded, expired, rejected)
329
330 if bonded != numElems {
331 t.Errorf("paged growth: expected %d bonds, got %d", numElems, bonded)
332 }
333
334 // Verify lattice state.
335 occupied := 0
336 for _, n := range l.Nodes() {
337 if n.Occupied() {
338 occupied++
339 }
340 }
341 if occupied != bonded {
342 t.Errorf("paged growth: occupied=%d should match bonded=%d", occupied, bonded)
343 }
344 }
345