hexagram_test.go raw

   1  package hexagram
   2  
   3  import (
   4  	"context"
   5  	"testing"
   6  	"time"
   7  
   8  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
   9  	"git.mleku.dev/mleku/dendrite/pkg/lattice"
  10  	"git.mleku.dev/mleku/dendrite/pkg/state"
  11  )
  12  
  13  type tagConstraint struct{ tag string }
  14  
  15  func (c tagConstraint) Tag() string              { return c.tag }
  16  func (c tagConstraint) Admits(e axiom.Element) bool { return e.Type() == c.tag }
  17  
  18  type elem struct {
  19  	tag string
  20  	val any
  21  }
  22  
  23  func (e elem) Type() string { return e.tag }
  24  func (e elem) Value() any   { return e.val }
  25  
  26  func TestTableCompleteness(t *testing.T) {
  27  	// All 64 entries should be populated (no zero-value Op except OpNone).
  28  	noneCount := 0
  29  	for i, rule := range table {
  30  		if rule.Op == OpNone {
  31  			noneCount++
  32  		}
  33  		_ = i
  34  	}
  35  	// Some OpNone entries are expected (stable states), but not all.
  36  	if noneCount == 64 {
  37  		t.Fatal("all 64 rules are OpNone — table is empty")
  38  	}
  39  	t.Logf("table: %d OpNone, %d active", noneCount, 64-noneCount)
  40  }
  41  
  42  func TestLookupCreativeCreative(t *testing.T) {
  43  	// Heaven/Heaven = Mountain inner (111), all bits set.
  44  	// Inner Mountain (111) + outer Mountain (111) = full equilibrium.
  45  	h := state.Hex(state.Mountain, state.Mountain)
  46  	rule := Lookup(h)
  47  	// Should be a low-priority maintenance op.
  48  	if rule.Op == OpDissolve {
  49  		t.Error("full equilibrium should not dissolve")
  50  	}
  51  }
  52  
  53  func TestLookupEarthEarth(t *testing.T) {
  54  	// Earth/Earth = completely empty, no energy.
  55  	h := state.Hex(state.Earth, state.Earth)
  56  	rule := Lookup(h)
  57  	if rule.Op != OpNone {
  58  		t.Errorf("Earth/Earth should be OpNone, got %d", rule.Op)
  59  	}
  60  	if rule.Priority != PriorityIdle {
  61  		t.Errorf("Earth/Earth should be idle priority, got %d", rule.Priority)
  62  	}
  63  }
  64  
  65  func TestLookupHeavenInner(t *testing.T) {
  66  	// Heaven inner (101) = ideal growth. Should accrete.
  67  	h := state.Hex(state.Heaven, state.Earth)
  68  	rule := Lookup(h)
  69  	if rule.Op != OpAccrete {
  70  		t.Errorf("Heaven/Earth should accrete, got %d", rule.Op)
  71  	}
  72  }
  73  
  74  func TestLookupFireInner(t *testing.T) {
  75  	// Fire inner (100) = noisy growth, no constraint.
  76  	// With constrained environment -> prune.
  77  	h := state.Hex(state.Fire, state.Mountain)
  78  	rule := Lookup(h)
  79  	if rule.Op != OpPrune {
  80  		t.Errorf("Fire/Mountain should prune, got %d", rule.Op)
  81  	}
  82  }
  83  
  84  func TestEngineTick(t *testing.T) {
  85  	l := lattice.New()
  86  
  87  	// Create a small lattice.
  88  	nodes := make([]*lattice.Node, 5)
  89  	for i := range nodes {
  90  		nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
  91  	}
  92  	for i := range nodes {
  93  		l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
  94  	}
  95  
  96  	// Set energy on some nodes to trigger operations.
  97  	nodes[0].SetEnergy(true)
  98  	nodes[1].SetEnergy(true)
  99  
 100  	// Put elements in solution.
 101  	solution := make(chan axiom.Element, 20)
 102  	solution <- elem{"word", "hello"}
 103  	solution <- elem{"word", "world"}
 104  
 105  	events := make(chan Event, 100)
 106  
 107  	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
 108  	defer cancel()
 109  
 110  	go RunEngine(ctx, l, EngineConfig{
 111  		Interval:    10 * time.Millisecond,
 112  		Solution:    solution,
 113  		MaxNewSites: 2,
 114  	}, events)
 115  
 116  	<-ctx.Done()
 117  	close(events)
 118  
 119  	opCounts := make(map[Op]int)
 120  	for ev := range events {
 121  		opCounts[ev.Op]++
 122  	}
 123  
 124  	t.Logf("engine operations: %v", opCounts)
 125  
 126  	// The engine should have done something.
 127  	total := 0
 128  	for _, count := range opCounts {
 129  		total += count
 130  	}
 131  	if total == 0 {
 132  		t.Error("engine performed no operations")
 133  	}
 134  }
 135  
 136  func TestEngineGrowsLattice(t *testing.T) {
 137  	l := lattice.New()
 138  
 139  	// Start with 3 nodes, all energized.
 140  	nodes := make([]*lattice.Node, 3)
 141  	for i := range nodes {
 142  		nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
 143  		nodes[i].SetEnergy(true)
 144  	}
 145  	for i := range nodes {
 146  		l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
 147  	}
 148  
 149  	// Bond one to make it Mountain (111) — which triggers Explore.
 150  	nodes[0].Bond(elem{"word", "seed"})
 151  
 152  	initialSize := l.Size()
 153  
 154  	events := make(chan Event, 200)
 155  	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
 156  	defer cancel()
 157  
 158  	go RunEngine(ctx, l, EngineConfig{
 159  		Interval:    10 * time.Millisecond,
 160  		MaxNewSites: 4,
 161  	}, events)
 162  
 163  	<-ctx.Done()
 164  
 165  	finalSize := l.Size()
 166  	t.Logf("lattice grew from %d to %d nodes", initialSize, finalSize)
 167  
 168  	if finalSize <= initialSize {
 169  		t.Error("engine should have grown the lattice via Explore/Nucleate")
 170  	}
 171  }
 172