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