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