dissolve_test.go raw

   1  package dissolve
   2  
   3  import (
   4  	"testing"
   5  
   6  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
   7  	"git.mleku.dev/mleku/dendrite/pkg/enzyme"
   8  	"git.mleku.dev/mleku/dendrite/pkg/lattice"
   9  	"git.mleku.dev/mleku/dendrite/pkg/ratio"
  10  )
  11  
  12  type tagConstraint struct{ tag string }
  13  
  14  func (c tagConstraint) Tag() string              { return c.tag }
  15  func (c tagConstraint) Admits(e axiom.Element) bool { return e.Type() == c.tag }
  16  
  17  func TestDissolveWeakBonds(t *testing.T) {
  18  	l := lattice.New()
  19  
  20  	// One constraint = lock-in of 1.0 (survives threshold 0.5).
  21  	strong := l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
  22  	strong.Bond(enzyme.Elem("word", "strong"))
  23  
  24  	// One constraint = lock-in of 1.0, but we'll test with high threshold.
  25  	weak := l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
  26  	weak.Bond(enzyme.Elem("word", "weak"))
  27  
  28  	dissolved := make(chan axiom.Element, 10)
  29  	events := make(chan Event, 10)
  30  
  31  	// Threshold 1.5 — both should dissolve since lock-in is 1.0.
  32  	ScanOnce(l, Config{Threshold: ratio.New(3, 2)}, dissolved, events)
  33  
  34  	close(dissolved)
  35  	close(events)
  36  
  37  	count := 0
  38  	for range dissolved {
  39  		count++
  40  	}
  41  	if count != 2 {
  42  		t.Errorf("expected 2 dissolved, got %d", count)
  43  	}
  44  
  45  	if strong.Occupied() {
  46  		t.Error("strong should be dissolved at threshold 1.5")
  47  	}
  48  	if weak.Occupied() {
  49  		t.Error("weak should be dissolved at threshold 1.5")
  50  	}
  51  }
  52  
  53  func TestDissolveLeavesStrongBonds(t *testing.T) {
  54  	l := lattice.New()
  55  
  56  	// Two constraints = lock-in of 2.0.
  57  	n := l.AddNode([]axiom.Constraint{
  58  		tagConstraint{"word"},
  59  		lengthConstraint{"word", 3},
  60  	})
  61  	n.Bond(enzyme.Elem("word", "hello"))
  62  
  63  	// Add occupied neighbors so contextual lock-in stays high.
  64  	// ContextualLockIn = base * (0.3 + 0.7 * neighborOccupancyRate).
  65  	// Neighbors also need high enough lock-in to survive the scan themselves,
  66  	// otherwise they dissolve first and n loses support.
  67  	// Give neighbors 2 constraints each, and connect them to each other
  68  	// so everyone has occupied neighbors.
  69  	nb1 := l.AddNode([]axiom.Constraint{tagConstraint{"word"}, lengthConstraint{"word", 3}})
  70  	nb1.Bond(enzyme.Elem("word", "peer1"))
  71  	l.Connect(n, nb1)
  72  
  73  	nb2 := l.AddNode([]axiom.Constraint{tagConstraint{"word"}, lengthConstraint{"word", 3}})
  74  	nb2.Bond(enzyme.Elem("word", "peer2"))
  75  	l.Connect(n, nb2)
  76  
  77  	// Connect neighbors to each other for mutual support.
  78  	l.Connect(nb1, nb2)
  79  
  80  	dissolved := make(chan axiom.Element, 10)
  81  	events := make(chan Event, 10)
  82  
  83  	// Threshold 0.9 — contextual lock-in with all-occupied neighbors = 1.0.
  84  	// All three nodes have full neighbor support → contextual lock-in = 1.0 > 0.9.
  85  	ScanOnce(l, Config{Threshold: ratio.New(9, 10)}, dissolved, events)
  86  
  87  	close(dissolved)
  88  	close(events)
  89  
  90  	if !n.Occupied() {
  91  		t.Error("strongly bonded node should survive with neighbor support")
  92  	}
  93  }
  94  
  95  func TestDissolveReturnsToSolution(t *testing.T) {
  96  	l := lattice.New()
  97  	n := l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
  98  	n.Bond(enzyme.Elem("word", "recycled"))
  99  
 100  	dissolved := make(chan axiom.Element, 10)
 101  	events := make(chan Event, 10)
 102  
 103  	ScanOnce(l, Config{Threshold: ratio.FromInt(2)}, dissolved, events)
 104  
 105  	close(dissolved)
 106  	close(events)
 107  
 108  	elem := <-dissolved
 109  	if elem == nil {
 110  		t.Fatal("expected dissolved element")
 111  	}
 112  	if elem.Value().(string) != "recycled" {
 113  		t.Errorf("expected 'recycled', got %v", elem.Value())
 114  	}
 115  }
 116  
 117  func TestDissolveEmptyLattice(t *testing.T) {
 118  	l := lattice.New()
 119  	dissolved := make(chan axiom.Element, 10)
 120  	events := make(chan Event, 10)
 121  
 122  	// Should not panic on empty lattice.
 123  	ScanOnce(l, Config{Threshold: ratio.One}, dissolved, events)
 124  
 125  	close(dissolved)
 126  	close(events)
 127  
 128  	count := 0
 129  	for range dissolved {
 130  		count++
 131  	}
 132  	if count != 0 {
 133  		t.Errorf("expected 0 dissolved from empty lattice, got %d", count)
 134  	}
 135  }
 136  
 137  // lengthConstraint admits elements with string values of at least minLen.
 138  type lengthConstraint struct {
 139  	tag    string
 140  	minLen int
 141  }
 142  
 143  func (c lengthConstraint) Tag() string { return c.tag }
 144  func (c lengthConstraint) Admits(e axiom.Element) bool {
 145  	if e.Type() != c.tag {
 146  		return false
 147  	}
 148  	s, ok := e.Value().(string)
 149  	if !ok {
 150  		return false
 151  	}
 152  	return len(s) >= c.minLen
 153  }
 154