digest_test.go raw

   1  package memory
   2  
   3  import (
   4  	"testing"
   5  
   6  	"git.mleku.dev/mleku/dendrite/pkg/ratio"
   7  	"git.mleku.dev/mleku/dendrite/pkg/spore"
   8  )
   9  
  10  func TestWalkDigestInsufficientData(t *testing.T) {
  11  	db := tmpDB(t)
  12  
  13  	// No data at all.
  14  	if dig := db.WalkDigest([]string{"func"}, 5); dig != nil {
  15  		t.Fatal("expected nil digest with no data")
  16  	}
  17  
  18  	// One generation only — need at least 2.
  19  	db.RecordHealth(0, 100, 256, ratio.Half)
  20  	if dig := db.WalkDigest([]string{"func"}, 5); dig != nil {
  21  		t.Fatal("expected nil digest with 1 gen")
  22  	}
  23  }
  24  
  25  func TestWalkDigestOverExtended(t *testing.T) {
  26  	db := tmpDB(t)
  27  
  28  	// Simulate the noise spiral: occupancy falling, explore dominant.
  29  	for gen := uint32(0); gen < 4; gen++ {
  30  		// Occupancy drops each gen: 50%, 40%, 30%, 20%.
  31  		occupied := 100 - gen*20
  32  		total := uint32(200 + gen*100)
  33  		db.RecordHealth(gen, occupied, total, ratio.One)
  34  
  35  		// Fitness flat and low.
  36  		db.RecordFitness(gen, ratio.New(1, 100), ratio.Zero, ratio.Zero, ratio.New(1, 100))
  37  
  38  		// Type signatures: func has many sites but few bonds.
  39  		db.RecordTypeSig(gen, []spore.TagCount{
  40  			{Tag: "func", Count: int(200 + gen*50)},
  41  			{Tag: "literal", Count: int(100 + gen*20)},
  42  		})
  43  
  44  		// Bonds: very few.
  45  		bonds := []BondRecord{
  46  			{Tag: "func", SiteID: gen*10 + 1},
  47  			{Tag: "func", SiteID: gen*10 + 2},
  48  			{Tag: "literal", SiteID: gen*10 + 3},
  49  		}
  50  		db.RecordBonds(gen, bonds)
  51  
  52  		// Missing: lots and rising.
  53  		db.RecordMissing(gen, []spore.TagCount{
  54  			{Tag: "func", Count: int(500 + gen*100)},
  55  		})
  56  
  57  		// Hexagram: mostly explore + nucleate, very little else.
  58  		ops := map[byte]uint32{
  59  			opExplore:    1000 + gen*500,
  60  			opNucleate:   200 + gen*50,
  61  			opStrengthen: 5,
  62  			opAccrete:    10,
  63  			opPrune:      3,
  64  		}
  65  		db.RecordHexagramOps(gen, ops)
  66  	}
  67  
  68  	dig := db.WalkDigest([]string{"func", "literal"}, 5)
  69  	if dig == nil {
  70  		t.Fatal("expected non-nil digest")
  71  	}
  72  
  73  	// Should detect overextension.
  74  	if !dig.OverExtended {
  75  		t.Fatalf("expected OverExtended=true, occupancy=%s explore=%.2f",
  76  			dig.OccupancyTrend, dig.ExploreRatio.Float64())
  77  	}
  78  	if dig.OccupancyTrend != TrendFalling {
  79  		t.Fatalf("expected TrendFalling, got %s", dig.OccupancyTrend)
  80  	}
  81  	if dig.GenerationsSeen != 4 {
  82  		t.Fatalf("expected 4 gens, got %d", dig.GenerationsSeen)
  83  	}
  84  
  85  	// Explore ratio should be high (explore+nucleate >> others).
  86  	if dig.ExploreRatio.Less(ratio.New(4, 5)) {
  87  		t.Fatalf("expected explore ratio > 0.8, got %.3f", dig.ExploreRatio.Float64())
  88  	}
  89  
  90  	// func: allocated 350 sites in gen3, bonded 2 → bond rate ~0.006.
  91  	funcDig, ok := dig.Types["func"]
  92  	if !ok {
  93  		t.Fatal("missing func digest")
  94  	}
  95  	if ratio.New(1, 10).Less(funcDig.BondRate) {
  96  		t.Fatalf("expected func bond rate < 0.1, got %.4f", funcDig.BondRate.Float64())
  97  	}
  98  
  99  	// Missing delta should be positive (rising = worsening).
 100  	if funcDig.MissingDelta <= 0 {
 101  		t.Fatalf("expected positive missing delta, got %d", funcDig.MissingDelta)
 102  	}
 103  }
 104  
 105  func TestWalkDigestHealthyLattice(t *testing.T) {
 106  	db := tmpDB(t)
 107  
 108  	// Simulate a healthy lattice: occupancy rising, fitness rising, low explore.
 109  	for gen := uint32(0); gen < 4; gen++ {
 110  		// Occupancy rises: 20%, 30%, 40%, 50%.
 111  		occupied := 40 + gen*20
 112  		total := uint32(200)
 113  		db.RecordHealth(gen, occupied, total, ratio.One)
 114  
 115  		// Fitness rising.
 116  		db.RecordFitness(gen, ratio.New(int64(gen+1), 10), ratio.Zero, ratio.Zero, ratio.New(int64(gen+1), 10))
 117  
 118  		// Good bond rates.
 119  		db.RecordTypeSig(gen, []spore.TagCount{
 120  			{Tag: "func", Count: 50},
 121  		})
 122  		bonds := make([]BondRecord, 30)
 123  		for i := range bonds {
 124  			bonds[i] = BondRecord{Tag: "func", SiteID: gen*100 + uint32(i)}
 125  		}
 126  		db.RecordBonds(gen, bonds)
 127  
 128  		// Low explore.
 129  		ops := map[byte]uint32{
 130  			opExplore:    10,
 131  			opStrengthen: 500,
 132  			opAccrete:    200,
 133  			opPrune:      50,
 134  		}
 135  		db.RecordHexagramOps(gen, ops)
 136  	}
 137  
 138  	dig := db.WalkDigest([]string{"func"}, 5)
 139  	if dig == nil {
 140  		t.Fatal("expected non-nil digest")
 141  	}
 142  
 143  	if dig.OverExtended {
 144  		t.Fatal("should not be overextended")
 145  	}
 146  	if dig.OccupancyTrend != TrendRising {
 147  		t.Fatalf("expected TrendRising, got %s", dig.OccupancyTrend)
 148  	}
 149  	if dig.FitnessTrend != TrendRising {
 150  		t.Fatalf("expected TrendRising, got %s", dig.FitnessTrend)
 151  	}
 152  
 153  	// func bond rate: 30/50 = 0.6.
 154  	funcDig := dig.Types["func"]
 155  	if funcDig.BondRate.Less(ratio.Half) {
 156  		t.Fatalf("expected func bond rate > 0.5, got %.3f", funcDig.BondRate.Float64())
 157  	}
 158  
 159  	// Explore ratio should be low.
 160  	if ratio.New(1, 5).Less(dig.ExploreRatio) {
 161  		t.Fatalf("expected explore ratio < 0.2, got %.3f", dig.ExploreRatio.Float64())
 162  	}
 163  }
 164  
 165  func TestWalkDigestStagnantFitness(t *testing.T) {
 166  	db := tmpDB(t)
 167  
 168  	// Same fitness for 5 generations.
 169  	for gen := uint32(0); gen < 5; gen++ {
 170  		db.RecordHealth(gen, 100, 200, ratio.One)
 171  		db.RecordFitness(gen, ratio.New(1, 10), ratio.Zero, ratio.Zero, ratio.New(1, 10))
 172  		db.RecordHexagramOps(gen, map[byte]uint32{opStrengthen: 100})
 173  	}
 174  
 175  	dig := db.WalkDigest(nil, 5)
 176  	if dig == nil {
 177  		t.Fatal("expected non-nil digest")
 178  	}
 179  	if dig.FitnessTrend != TrendStagnant {
 180  		t.Fatalf("expected TrendStagnant, got %s", dig.FitnessTrend)
 181  	}
 182  }
 183  
 184  func TestWalkDigestOccupancyCollapse(t *testing.T) {
 185  	db := tmpDB(t)
 186  
 187  	// Occupancy rate halves: 50% → 20% — should trigger OverExtended
 188  	// even with low explore ratio.
 189  	for gen := uint32(0); gen < 3; gen++ {
 190  		occupied := 100 - gen*30 // 100, 70, 40
 191  		total := uint32(200)
 192  		db.RecordHealth(gen, occupied, total, ratio.One)
 193  		db.RecordFitness(gen, ratio.New(1, 10), ratio.Zero, ratio.Zero, ratio.New(1, 10))
 194  		// Low explore ratio — mostly strengthen.
 195  		db.RecordHexagramOps(gen, map[byte]uint32{
 196  			opStrengthen: 1000,
 197  			opExplore:    10,
 198  		})
 199  	}
 200  
 201  	dig := db.WalkDigest(nil, 5)
 202  	if dig == nil {
 203  		t.Fatal("expected non-nil digest")
 204  	}
 205  	if !dig.OverExtended {
 206  		t.Fatalf("expected OverExtended=true (occupancy collapse), occupancy=%s explore=%.2f",
 207  			dig.OccupancyTrend, dig.ExploreRatio.Float64())
 208  	}
 209  }
 210  
 211  func TestComputeBondRate(t *testing.T) {
 212  	// No data.
 213  	if r := computeBondRate(nil, nil); !r.IsZero() {
 214  		t.Fatal("expected zero")
 215  	}
 216  
 217  	// Gen 0: 100 allocated, 30 bonded = 0.3.
 218  	typ := []TypePoint{{Gen: 0, Count: 100}}
 219  	bnd := []TypePoint{{Gen: 0, Count: 30}}
 220  	r := computeBondRate(typ, bnd)
 221  	if r.Num != 3 || r.Denom != 10 {
 222  		t.Fatalf("expected 3/10, got %d/%d", r.Num, r.Denom)
 223  	}
 224  
 225  	// Multiple gens: should use most recent.
 226  	typ = []TypePoint{{Gen: 0, Count: 100}, {Gen: 1, Count: 200}}
 227  	bnd = []TypePoint{{Gen: 0, Count: 30}, {Gen: 1, Count: 120}}
 228  	r = computeBondRate(typ, bnd)
 229  	// Gen 1: 120/200 = 3/5.
 230  	if r.Num != 3 || r.Denom != 5 {
 231  		t.Fatalf("expected 3/5, got %d/%d", r.Num, r.Denom)
 232  	}
 233  }
 234  
 235  func TestComputeMissingDelta(t *testing.T) {
 236  	// No data.
 237  	if d := computeMissingDelta(nil); d != 0 {
 238  		t.Fatal("expected 0")
 239  	}
 240  
 241  	// Rising: 100 → 150 = +50.
 242  	mis := []MissingPoint{{Gen: 0, Count: 100}, {Gen: 1, Count: 150}}
 243  	if d := computeMissingDelta(mis); d != 50 {
 244  		t.Fatalf("expected 50, got %d", d)
 245  	}
 246  
 247  	// Falling: 200 → 180 = -20.
 248  	mis = []MissingPoint{{Gen: 0, Count: 200}, {Gen: 1, Count: 180}}
 249  	if d := computeMissingDelta(mis); d != -20 {
 250  		t.Fatalf("expected -20, got %d", d)
 251  	}
 252  }
 253  
 254  func TestTrendString(t *testing.T) {
 255  	cases := []struct {
 256  		trend Trend
 257  		want  string
 258  	}{
 259  		{TrendFlat, "flat"},
 260  		{TrendRising, "rising"},
 261  		{TrendFalling, "falling"},
 262  		{TrendStagnant, "stagnant"},
 263  	}
 264  	for _, c := range cases {
 265  		if got := c.trend.String(); got != c.want {
 266  			t.Errorf("Trend(%d).String() = %q, want %q", c.trend, got, c.want)
 267  		}
 268  	}
 269  }
 270