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