grammar_test.go raw
1 package grammar
2
3 import (
4 "testing"
5
6 "git.mleku.dev/mleku/dendrite/pkg/axiom"
7 "git.mleku.dev/mleku/dendrite/pkg/memory"
8 "git.mleku.dev/mleku/dendrite/pkg/ratio"
9 )
10
11 // testElement is a minimal axiom.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 func TestGoASTCanNeighbor(t *testing.T) {
21 tests := []struct {
22 a, b string
23 want bool
24 }{
25 // Functions contain body statements.
26 {"func", "assign", true},
27 {"func", "return", true},
28 {"func", "if", true},
29 {"func", "for", true},
30 {"func", "ident:func-name", true},
31
32 // Functions don't directly neighbor package/import.
33 {"func", "package", false},
34 {"func", "import", false},
35
36 // Package neighbors import and declarations.
37 {"package", "import", true},
38 {"package", "func", true},
39 {"package", "type", true},
40
41 // Type contains struct/interface/field.
42 {"type", "struct", true},
43 {"type", "interface", true},
44 {"type", "field", true},
45
46 // Body statements neighbor each other and declaration-level idents.
47 {"assign", "return", true},
48 {"if", "for", true},
49 {"assign", "ident:var-name", true},
50
51 // Text fallback.
52 {"word", "punct", true},
53 {"word", "word", true},
54 {"punct", "punct", true},
55
56 // Cross-domain: import doesn't neighbor body statements.
57 {"import", "assign", false},
58 {"import", "return", false},
59
60 // Struct doesn't directly neighbor import.
61 {"struct", "import", false},
62
63 // Ident subtypes have role-specific adjacency.
64 {"ident:func-name", "func", true},
65 {"ident:type-name", "type", true},
66 {"ident:field-name", "struct", true},
67 {"ident:receiver", "method", true},
68 {"ident:param", "func", true},
69 }
70
71 for _, tt := range tests {
72 got := GoAST.CanNeighbor(tt.a, tt.b)
73 if got != tt.want {
74 t.Errorf("GoAST.CanNeighbor(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
75 }
76 }
77 }
78
79 func TestGoEmitTighter(t *testing.T) {
80 // GoEmit should be tighter than GoAST in some cases.
81 // func should only neighbor body statements in GoEmit (not ident subtypes).
82 if GoEmit.CanNeighbor("func", "ident:func-name") {
83 t.Error("GoEmit: func should not neighbor ident:func-name (tighter scoping)")
84 }
85 // But func should still neighbor assign.
86 if !GoEmit.CanNeighbor("func", "assign") {
87 t.Error("GoEmit: func should neighbor assign")
88 }
89 // import should only neighbor package.
90 if !GoEmit.CanNeighbor("import", "package") {
91 t.Error("GoEmit: import should neighbor package")
92 }
93 if GoEmit.CanNeighbor("import", "func") {
94 t.Error("GoEmit: import should not neighbor func (tighter)")
95 }
96 }
97
98 func TestGrammarTags(t *testing.T) {
99 tags := GoAST.Tags()
100 if len(tags) == 0 {
101 t.Fatal("GoAST.Tags() returned empty")
102 }
103 // Should include core Go types.
104 want := map[string]bool{
105 "func": true, "assign": true, "return": true,
106 "type": true, "struct": true, "ident:var-name": true,
107 "word": true, "punct": true, "package": true,
108 }
109 tagSet := make(map[string]bool)
110 for _, tag := range tags {
111 tagSet[tag] = true
112 }
113 for tag := range want {
114 if !tagSet[tag] {
115 t.Errorf("GoAST.Tags() missing %q", tag)
116 }
117 }
118 }
119
120 func TestGrammarNeighborsOf(t *testing.T) {
121 nbs := GoAST.NeighborsOf("func")
122 if len(nbs) == 0 {
123 t.Fatal("GoAST.NeighborsOf(func) returned empty")
124 }
125 // func should have assign, return, if, for among neighbors.
126 nbSet := make(map[string]bool)
127 for _, nb := range nbs {
128 nbSet[nb] = true
129 }
130 for _, want := range []string{"assign", "return", "if", "for"} {
131 if !nbSet[want] {
132 t.Errorf("GoAST.NeighborsOf(func) missing %q", want)
133 }
134 }
135 }
136
137 func TestGrammarConstraintAdmitsBasic(t *testing.T) {
138 c := NewConstraint("func", GoAST)
139
140 if !c.Admits(testElement{"func", "main"}) {
141 t.Error("should admit matching type")
142 }
143 if c.Admits(testElement{"assign", "x := 1"}) {
144 t.Error("should reject non-matching type")
145 }
146 }
147
148 func TestGrammarConstraintAdmitsInContext(t *testing.T) {
149 c := NewConstraint("assign", GoAST)
150 elem := testElement{"assign", "x := 1"}
151
152 // No neighbors — seed bonding, should admit.
153 if !c.AdmitsInContext(elem, nil) {
154 t.Error("should admit with nil neighbors (seed)")
155 }
156 if !c.AdmitsInContext(elem, []axiom.Element{nil, nil}) {
157 t.Error("should admit with all-nil neighbors (seed)")
158 }
159
160 // Neighbor is func — grammar-adjacent, should admit.
161 funcNeighbor := testElement{"func", "main"}
162 if !c.AdmitsInContext(elem, []axiom.Element{funcNeighbor}) {
163 t.Error("should admit with func neighbor (grammar-adjacent)")
164 }
165
166 // Neighbor is ident:var-name — grammar-adjacent to assign, should admit.
167 identNeighbor := testElement{"ident:var-name", "x"}
168 if !c.AdmitsInContext(elem, []axiom.Element{identNeighbor}) {
169 t.Error("should admit with ident:var-name neighbor (grammar-adjacent)")
170 }
171
172 // Neighbor is package — NOT grammar-adjacent to assign, should reject.
173 pkgNeighbor := testElement{"package", "main"}
174 if c.AdmitsInContext(elem, []axiom.Element{pkgNeighbor}) {
175 t.Error("should reject with package neighbor (not grammar-adjacent)")
176 }
177
178 // Wrong element type — should reject regardless of neighbors.
179 wrongElem := testElement{"func", "main"}
180 if c.AdmitsInContext(wrongElem, []axiom.Element{funcNeighbor}) {
181 t.Error("should reject wrong element type")
182 }
183 }
184
185 func TestBuildGrammarLattice(t *testing.T) {
186 counts := map[string]int{
187 "func": 4,
188 "assign": 6,
189 "ident:var-name": 8,
190 }
191
192 seed := [32]byte{1, 2, 3}
193 l := BuildGrammarLattice(GoAST, counts, seed, func(tag string) axiom.Constraint {
194 return NewConstraint(tag, GoAST)
195 })
196
197 // Should have 18 nodes total.
198 if l.Size() != 18 {
199 t.Errorf("lattice size = %d, want 18", l.Size())
200 }
201
202 // All nodes should have at least one neighbor (ring connectivity).
203 for _, n := range l.Nodes() {
204 if len(n.Neighbors()) == 0 {
205 t.Errorf("node %d has no neighbors", n.ID())
206 }
207 }
208 }
209
210 func TestBuildGrammarLatticeSeedDifferentiation(t *testing.T) {
211 counts := map[string]int{
212 "func": 4,
213 "assign": 6,
214 "ident:var-name": 8,
215 "return": 4,
216 }
217
218 seed1 := [32]byte{1}
219 seed2 := [32]byte{2}
220
221 factory := func(tag string) axiom.Constraint {
222 return NewConstraint(tag, GoAST)
223 }
224
225 l1 := BuildGrammarLattice(GoAST, counts, seed1, factory)
226 l2 := BuildGrammarLattice(GoAST, counts, seed2, factory)
227
228 // Same size.
229 if l1.Size() != l2.Size() {
230 t.Errorf("sizes differ: %d vs %d", l1.Size(), l2.Size())
231 }
232
233 // But different neighbor sets (at least some nodes should differ).
234 // Compare neighbor counts per node — different seeds should produce
235 // different bridge selections, leading to different degree distributions.
236 degrees1 := make([]int, l1.Size())
237 degrees2 := make([]int, l2.Size())
238 for i, n := range l1.Nodes() {
239 degrees1[i] = len(n.Neighbors())
240 }
241 for i, n := range l2.Nodes() {
242 degrees2[i] = len(n.Neighbors())
243 }
244
245 identical := true
246 for i := range degrees1 {
247 if degrees1[i] != degrees2[i] {
248 identical = false
249 break
250 }
251 }
252 if identical {
253 t.Error("two different seeds produced identical degree distributions — differentiation failed")
254 }
255 }
256
257 func TestDefaultCounts(t *testing.T) {
258 counts := GoAST.DefaultCounts(100, ratio.New(3, 5))
259
260 // Should have entries for all grammar tags that have weight > 0.
261 if counts["func"] == 0 {
262 t.Error("func should have > 0 nodes")
263 }
264 if counts["assign"] == 0 {
265 t.Error("assign should have > 0 nodes")
266 }
267 if counts["word"] == 0 {
268 t.Error("word should have > 0 nodes")
269 }
270 if counts["punct"] == 0 {
271 t.Error("punct should have > 0 nodes")
272 }
273
274 // Total should be close to targetSize.
275 total := 0
276 for _, c := range counts {
277 total += c
278 }
279 if total != 100 {
280 t.Errorf("total nodes = %d, want 100", total)
281 }
282 }
283
284 func TestReadVagusNil(t *testing.T) {
285 sig := ReadVagus(nil, DefaultBaseline())
286 b := DefaultBaseline()
287
288 if sig.DissolveHalfLife != b.DissolveHalfLife {
289 t.Error("nil digest should return baseline half-life")
290 }
291 if sig.GrowMaxSteps != b.GrowMaxSteps {
292 t.Error("nil digest should return baseline max steps")
293 }
294 }
295
296 func TestReadVagusFitnessFalling(t *testing.T) {
297 d := &memory.Digest{
298 FitnessTrend: memory.TrendFalling,
299 Types: make(map[string]memory.TypeDigest),
300 }
301 b := DefaultBaseline()
302 sig := ReadVagus(d, b)
303
304 // Dissolve threshold should be lower (more aggressive).
305 if !sig.DissolveThreshold.Less(b.DissolveThreshold) {
306 t.Error("falling fitness should lower dissolve threshold")
307 }
308 // Half-life should decrease (faster turnover).
309 if sig.DissolveHalfLife >= b.DissolveHalfLife {
310 t.Error("falling fitness should decrease half-life")
311 }
312 }
313
314 func TestReadVagusFitnessRising(t *testing.T) {
315 d := &memory.Digest{
316 FitnessTrend: memory.TrendRising,
317 Types: make(map[string]memory.TypeDigest),
318 }
319 b := DefaultBaseline()
320 sig := ReadVagus(d, b)
321
322 // Dissolve threshold should be higher (preserve what's working).
323 if !b.DissolveThreshold.Less(sig.DissolveThreshold) {
324 t.Error("rising fitness should raise dissolve threshold")
325 }
326 }
327
328 func TestReadVagusFitnessStagnant(t *testing.T) {
329 d := &memory.Digest{
330 FitnessTrend: memory.TrendStagnant,
331 Types: make(map[string]memory.TypeDigest),
332 }
333 b := DefaultBaseline()
334 sig := ReadVagus(d, b)
335
336 // More exploration: higher max steps, more workers.
337 if sig.GrowMaxSteps <= b.GrowMaxSteps {
338 t.Error("stagnant fitness should increase grow max steps")
339 }
340 if sig.GrowWorkers <= b.GrowWorkers {
341 t.Error("stagnant fitness should increase grow workers")
342 }
343 }
344
345 func TestReadVagusTypeAdjustments(t *testing.T) {
346 d := &memory.Digest{
347 Types: map[string]memory.TypeDigest{
348 "func": {Tag: "func", BondRate: ratio.New(6, 10)}, // > 50% → grow
349 "assign": {Tag: "assign", BondRate: ratio.New(5, 100)}, // < 10% → shrink
350 "ident:var-name": {Tag: "ident:var-name", BondRate: ratio.New(3, 10)}, // 30% → no change
351 },
352 }
353 sig := ReadVagus(d, DefaultBaseline())
354
355 if sig.TypeAdjustments["func"] != 1 {
356 t.Errorf("func adjustment = %d, want 1 (grow)", sig.TypeAdjustments["func"])
357 }
358 if sig.TypeAdjustments["assign"] != -1 {
359 t.Errorf("assign adjustment = %d, want -1 (shrink)", sig.TypeAdjustments["assign"])
360 }
361 if sig.TypeAdjustments["ident:var-name"] != 0 {
362 t.Errorf("ident:var-name adjustment = %d, want 0 (no change)", sig.TypeAdjustments["ident:var-name"])
363 }
364 }
365
366 func TestVagusAdjustCounts(t *testing.T) {
367 sig := VagusSignal{
368 TypeAdjustments: map[string]int{
369 "func": 1, // grow
370 "assign": -1, // shrink
371 },
372 }
373
374 base := map[string]int{
375 "func": 10,
376 "assign": 10,
377 "ident:var-name": 10,
378 }
379
380 result := sig.AdjustCounts(base)
381
382 // func should grow by 50%: 10 → 15
383 if result["func"] != 15 {
384 t.Errorf("func count = %d, want 15", result["func"])
385 }
386 // assign should shrink by 50%: 10 → 5
387 if result["assign"] != 5 {
388 t.Errorf("assign count = %d, want 5", result["assign"])
389 }
390 // ident:var-name unchanged: 10
391 if result["ident:var-name"] != 10 {
392 t.Errorf("ident:var-name count = %d, want 10", result["ident:var-name"])
393 }
394 }
395
396 func TestVagusDissolveConfig(t *testing.T) {
397 sig := DefaultSignal()
398 cfg := sig.DissolveConfig(15 * 1e6) // 15ms in nanoseconds
399 if cfg.HalfLife != sig.DissolveHalfLife {
400 t.Error("DissolveConfig should use signal half-life")
401 }
402 }
403
404 func TestVagusGrowConfig(t *testing.T) {
405 sig := DefaultSignal()
406 cfg := sig.GrowConfig()
407 if cfg.MaxSteps != sig.GrowMaxSteps {
408 t.Error("GrowConfig should use signal max steps")
409 }
410 if cfg.Workers != sig.GrowWorkers {
411 t.Error("GrowConfig should use signal workers")
412 }
413 }
414