lattice_test.go raw
1 package lattice
2
3 import (
4 "testing"
5
6 "git.mleku.dev/mleku/dendrite/pkg/axiom"
7 "git.mleku.dev/mleku/dendrite/pkg/ratio"
8 "git.mleku.dev/mleku/dendrite/pkg/state"
9 )
10
11 // testElement is a minimal element for testing.
12 type testElement struct {
13 tag string
14 val string
15 }
16
17 func (e testElement) Type() string { return e.tag }
18 func (e testElement) Value() any { return e.val }
19
20 // testConstraint admits elements with a matching type tag.
21 type testConstraint struct {
22 tag string
23 }
24
25 func (c testConstraint) Tag() string { return c.tag }
26 func (c testConstraint) Admits(e axiom.Element) bool { return e.Type() == c.tag }
27
28 func TestAddNodeAndSize(t *testing.T) {
29 l := New()
30 if l.Size() != 0 {
31 t.Fatal("new lattice should be empty")
32 }
33 c := []axiom.Constraint{testConstraint{"word"}}
34 n := l.AddNode(c)
35 if l.Size() != 1 {
36 t.Fatal("expected size 1")
37 }
38 if n.ID() != 0 {
39 t.Fatal("first node ID should be 0")
40 }
41 }
42
43 func TestBondAndDissolve(t *testing.T) {
44 l := New()
45 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
46
47 // Should admit matching element.
48 e := testElement{"word", "hello"}
49 if !n.Admits(e) {
50 t.Fatal("node should admit matching element")
51 }
52
53 // Bond.
54 if !n.Bond(e) {
55 t.Fatal("bond should succeed")
56 }
57 if !n.Occupied() {
58 t.Fatal("node should be occupied after bond")
59 }
60
61 // Should not admit when occupied.
62 e2 := testElement{"word", "world"}
63 if n.Admits(e2) {
64 t.Fatal("occupied node should not admit")
65 }
66
67 // Second bond should fail.
68 if n.Bond(e2) {
69 t.Fatal("second bond should fail")
70 }
71
72 // Dissolve.
73 dissolved := n.Dissolve()
74 if dissolved == nil {
75 t.Fatal("dissolve should return element")
76 }
77 if dissolved.(testElement).val != "hello" {
78 t.Fatal("dissolved element should be the one that was bonded")
79 }
80 if n.Occupied() {
81 t.Fatal("node should be vacant after dissolve")
82 }
83 }
84
85 func TestConstraintRejection(t *testing.T) {
86 l := New()
87 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
88
89 // Wrong type should be rejected.
90 e := testElement{"number", "42"}
91 if n.Admits(e) {
92 t.Fatal("node should reject mismatched type")
93 }
94 if n.Bond(e) {
95 t.Fatal("bond should fail for mismatched type")
96 }
97 }
98
99 func TestConnect(t *testing.T) {
100 l := New()
101 a := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
102 b := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
103
104 l.Connect(a, b)
105
106 nb := RandomNeighbor(a)
107 if nb == nil || nb.ID() != b.ID() {
108 t.Fatal("a's neighbor should be b")
109 }
110 nb = RandomNeighbor(b)
111 if nb == nil || nb.ID() != a.ID() {
112 t.Fatal("b's neighbor should be a")
113 }
114 }
115
116 func TestDisconnect(t *testing.T) {
117 l := New()
118 a := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
119 b := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
120
121 l.Connect(a, b)
122 l.Disconnect(a, b)
123
124 if RandomNeighbor(a) != nil {
125 t.Fatal("a should have no neighbors after disconnect")
126 }
127 }
128
129 func TestVacantSites(t *testing.T) {
130 l := New()
131 l.AddNode([]axiom.Constraint{testConstraint{"word"}})
132 l.AddNode([]axiom.Constraint{testConstraint{"word"}})
133 n3 := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
134
135 // Bond one node.
136 n3.Bond(testElement{"word", "taken"})
137
138 sites := l.VacantSites()
139 if len(sites) != 2 {
140 t.Fatalf("expected 2 vacant sites, got %d", len(sites))
141 }
142 }
143
144 func TestHexagramUpdatesOnBond(t *testing.T) {
145 l := New()
146 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
147
148 // Before bond: vacant node. bonding=false, constraint=false, energy=false -> Earth (000)
149 // The constraint bit reflects the occupant being bound, not the
150 // existence of constraints on the site.
151 h := n.Hexagram()
152 if h.Inner() != state.Earth {
153 t.Errorf("expected Earth (000) before bond, got %03b", h.Inner())
154 }
155
156 n.Bond(testElement{"word", "hello"})
157
158 // After bond: bonding=true, constraint=true (bound by constraints), energy=false -> Lake (011)
159 h = n.Hexagram()
160 if h.Inner() != state.Lake {
161 t.Errorf("expected Lake (011) after bond, got %03b", h.Inner())
162 }
163 }
164
165 func TestNoConstraintNoAdmit(t *testing.T) {
166 l := New()
167 n := l.AddNode(nil) // no constraints
168
169 e := testElement{"word", "hello"}
170 if n.Admits(e) {
171 t.Fatal("node with no constraints should not admit anything")
172 }
173 }
174
175 func TestRandomNode(t *testing.T) {
176 l := New()
177 if l.RandomNode() != nil {
178 t.Fatal("empty lattice should return nil")
179 }
180 l.AddNode([]axiom.Constraint{testConstraint{"word"}})
181 if l.RandomNode() == nil {
182 t.Fatal("non-empty lattice should return a node")
183 }
184 }
185
186 func TestLockInDepth(t *testing.T) {
187 l := New()
188 n := l.AddNode([]axiom.Constraint{
189 testConstraint{"word"},
190 })
191 n.Bond(testElement{"word", "hello"})
192 if !n.LockIn().Equal(ratio.One) {
193 t.Errorf("expected lock-in 1/1, got %s", n.LockIn())
194 }
195
196 // More constraints = deeper lock-in.
197 n2 := l.AddNode([]axiom.Constraint{
198 testConstraint{"word"},
199 multiConstraint{"word", 3}, // requires length >= 3
200 })
201 n2.Bond(testElement{"word", "hello"})
202 if !n2.LockIn().Equal(ratio.FromInt(2)) {
203 t.Errorf("expected lock-in 2/1, got %s", n2.LockIn())
204 }
205 }
206
207 func TestHealth(t *testing.T) {
208 l := New()
209 n1 := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
210 n2 := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
211 n3 := l.AddNode([]axiom.Constraint{testConstraint{"punct"}})
212 n1.SetEnergy(true)
213 n2.SetEnergy(true)
214
215 // Before bonding.
216 h := l.Health()
217 if h.NodeCount != 3 {
218 t.Errorf("NodeCount = %d, want 3", h.NodeCount)
219 }
220 if h.Occupied != 0 {
221 t.Errorf("Occupied = %d, want 0", h.Occupied)
222 }
223 if h.AccretionReady != 2 {
224 t.Errorf("AccretionReady = %d, want 2 (n1 and n2 have energy)", h.AccretionReady)
225 }
226
227 // Bond one node.
228 n1.Bond(testElement{"word", "hello"})
229 h = l.Health()
230 if h.Occupied != 1 {
231 t.Errorf("after bond: Occupied = %d, want 1", h.Occupied)
232 }
233 if !h.AvgLockIn.Equal(ratio.One) {
234 t.Errorf("AvgLockIn = %s, want 1/1", h.AvgLockIn)
235 }
236 if !h.OccupancyRate.Equal(ratio.New(1, 3)) {
237 t.Errorf("OccupancyRate = %s, want 1/3", h.OccupancyRate)
238 }
239
240 _ = n2
241 _ = n3
242 }
243
244 func TestAgeOnBond(t *testing.T) {
245 l := New()
246 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
247 n.Bond(testElement{"word", "hello"})
248
249 if n.Age() != 0 {
250 t.Errorf("expected age 0 after bond, got %d", n.Age())
251 }
252 }
253
254 func TestIncrementAge(t *testing.T) {
255 l := New()
256 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
257 n.Bond(testElement{"word", "hello"})
258
259 // Attack (0) → Decay (1) → Sustain (2): automatic.
260 n.IncrementAge()
261 if n.Age() != 1 {
262 t.Errorf("expected age 1 (Decay), got %d", n.Age())
263 }
264 n.IncrementAge()
265 if n.Age() != 2 {
266 t.Errorf("expected age 2 (Sustain), got %d", n.Age())
267 }
268
269 // Sustain saturates — IncrementAge does not advance past 2.
270 n.IncrementAge()
271 if n.Age() != 2 {
272 t.Errorf("age should saturate at 2 (Sustain), got %d", n.Age())
273 }
274 n.IncrementAge()
275 if n.Age() != 2 {
276 t.Errorf("age should still be 2, got %d", n.Age())
277 }
278
279 // Destabilize moves Sustain → Release.
280 n.Destabilize()
281 if n.Age() != 3 {
282 t.Errorf("expected age 3 (Release) after Destabilize, got %d", n.Age())
283 }
284
285 // Destabilize is a no-op when not in Sustain.
286 n.Destabilize()
287 if n.Age() != 3 {
288 t.Errorf("Destabilize should be no-op in Release, got %d", n.Age())
289 }
290 }
291
292 func TestDissolveResetsAge(t *testing.T) {
293 l := New()
294 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
295 n.Bond(testElement{"word", "hello"})
296 n.IncrementAge()
297 n.IncrementAge()
298
299 if n.Age() != 2 {
300 t.Fatalf("expected age 2 before dissolve, got %d", n.Age())
301 }
302
303 n.Dissolve()
304
305 if n.Age() != 0 {
306 t.Errorf("expected age 0 after dissolve, got %d", n.Age())
307 }
308 }
309
310 func TestProjectionByte(t *testing.T) {
311 l := New()
312 n := l.AddNode([]axiom.Constraint{testConstraint{"word"}})
313
314 n.SetProjection(0b101, 0b011, 0) // vertex=5, key=3
315 n.RestoreAge(2)
316
317 got := n.ProjectionByte()
318 // age=2 (0b10) << 6 | key=3 (0b011) << 3 | vertex=5 (0b101)
319 // = 0b10_011_101 = 0x9D = 157
320 want := uint8(0b10_011_101)
321 if got != want {
322 t.Errorf("ProjectionByte: got 0b%08b, want 0b%08b", got, want)
323 }
324 }
325
326 // multiConstraint admits elements with matching tag and value length >= min.
327 type multiConstraint struct {
328 tag string
329 minLen int
330 }
331
332 func (c multiConstraint) Tag() string { return c.tag }
333 func (c multiConstraint) Admits(e axiom.Element) bool {
334 if e.Type() != c.tag {
335 return false
336 }
337 s, ok := e.Value().(string)
338 if !ok {
339 return false
340 }
341 return len(s) >= c.minLen
342 }
343