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