digest.go raw
1 package memory
2
3 import "git.mleku.dev/mleku/dendrite/pkg/ratio"
4
5 // Trend describes the direction of a metric across generations.
6 type Trend int
7
8 const (
9 TrendFlat Trend = iota // no significant change
10 TrendRising // consistently increasing
11 TrendFalling // consistently decreasing
12 TrendStagnant // flat for 3+ data points
13 )
14
15 // String returns a human-readable trend name.
16 func (t Trend) String() string {
17 switch t {
18 case TrendRising:
19 return "rising"
20 case TrendFalling:
21 return "falling"
22 case TrendStagnant:
23 return "stagnant"
24 default:
25 return "flat"
26 }
27 }
28
29 // Digest summarizes cross-generational patterns from memory.
30 // Produced by walking the graph: typ→bnd→fit per tag, plus health and hex ops.
31 type Digest struct {
32 // Per-type analysis: which types are signal, which are noise.
33 Types map[string]TypeDigest
34
35 // Lattice-wide signals.
36 OccupancyTrend Trend // rising, falling, flat
37 FitnessTrend Trend // rising, falling, flat, stagnant
38 ExploreRatio ratio.Ratio // explore_ops / total_ops (last gen)
39 OverExtended bool // true if occupancy falling AND explore dominant
40 GenerationsSeen int // how many gens of data we have
41
42 // ADSR signals (from most recent generation with data).
43 SustainFraction ratio.Ratio // sustain / occupied
44 YoungFraction ratio.Ratio // (attack + decay) / occupied
45 }
46
47 // TypeDigest is the per-type analysis from the graph walk.
48 type TypeDigest struct {
49 Tag string
50 BondRate ratio.Ratio // bonds / allocated sites (last gen with data)
51 MissingDelta int // change in missing count between last two gens (negative = improving)
52 }
53
54 // Hexagram operation byte constants (matching hexagram.Op enum).
55 const (
56 opNone byte = 0
57 opAccrete byte = 1
58 opDissolve byte = 2
59 opNucleate byte = 3
60 opPrune byte = 4
61 opStrengthen byte = 5
62 opExplore byte = 6
63 opCollapse byte = 7
64 opRecycle byte = 8
65 )
66
67 // WalkDigest produces a Digest by walking the memory graph.
68 // tags is the list of type tags to analyze (typically from parent spore's TypeSignature).
69 // lastN is how many recent generations to consider.
70 // Returns nil if fewer than 2 generations of data exist.
71 func (d *DB) WalkDigest(tags []string, lastN int) *Digest {
72 if lastN < 2 {
73 lastN = 2
74 }
75
76 // 1. Health history → occupancy trend.
77 health := d.QueryHealthHistory(lastN)
78 if len(health) < 2 {
79 return nil // need at least 2 cycles
80 }
81
82 dig := &Digest{
83 Types: make(map[string]TypeDigest),
84 GenerationsSeen: len(health),
85 }
86
87 // Compute occupancy trend from health snapshots.
88 dig.OccupancyTrend = computeOccupancyTrend(health)
89
90 // 2. Fitness trajectory → fitness trend.
91 fitness := d.QueryFitnessTrajectory(lastN)
92 dig.FitnessTrend = computeFitnessTrend(fitness)
93
94 // 3. Per-tag analysis: walk typ→bnd→mis for each tag.
95 for _, tag := range tags {
96 td := TypeDigest{Tag: tag}
97
98 // Type counts across generations.
99 typTrend := d.QueryTypeTrend(tag, lastN)
100
101 // Bond counts across generations.
102 bndHist := d.QueryBondHistory(tag, lastN)
103
104 // Compute BondRate from the most recent generation where both exist.
105 td.BondRate = computeBondRate(typTrend, bndHist)
106
107 // Missing site trend.
108 misTrend := d.QueryMissingTrend(tag, lastN)
109 td.MissingDelta = computeMissingDelta(misTrend)
110
111 dig.Types[tag] = td
112 }
113
114 // 4. Hexagram ops → explore ratio (from most recent generation).
115 dig.ExploreRatio = computeExploreRatio(d, lastN)
116
117 // 5. OverExtended if either:
118 // (a) occupancy falling AND explore ratio > 80%, or
119 // (b) occupancy falling significantly (occupancy rate halved or worse).
120 exploreHeavy := ratio.New(4, 5).Less(dig.ExploreRatio)
121 occupancyCollapse := false
122 if len(health) >= 2 {
123 first := ratio.New(int64(health[0].Occupied), int64(health[0].Total))
124 last := ratio.New(int64(health[len(health)-1].Occupied), int64(health[len(health)-1].Total))
125 // Occupancy rate halved or worse.
126 if !first.IsZero() && last.Less(first.Div(ratio.New(2, 1))) {
127 occupancyCollapse = true
128 }
129 }
130 dig.OverExtended = dig.OccupancyTrend == TrendFalling &&
131 (exploreHeavy || occupancyCollapse)
132
133 // 6. ADSR distribution from most recent generation.
134 adsr := d.QueryADSRHistory(1)
135 if len(adsr) > 0 {
136 total := adsr[0].Counts[0] + adsr[0].Counts[1] + adsr[0].Counts[2] + adsr[0].Counts[3]
137 if total > 0 {
138 dig.SustainFraction = ratio.New(int64(adsr[0].Counts[2]), int64(total))
139 dig.YoungFraction = ratio.New(int64(adsr[0].Counts[0]+adsr[0].Counts[1]), int64(total))
140 }
141 }
142
143 return dig
144 }
145
146 // computeOccupancyTrend determines whether occupancy is rising, falling, or flat.
147 // Uses the occupancy rate (occupied/total) across health snapshots.
148 func computeOccupancyTrend(health []HealthPoint) Trend {
149 if len(health) < 2 {
150 return TrendFlat
151 }
152 rising := 0
153 falling := 0
154 for i := 1; i < len(health); i++ {
155 prev := ratio.New(int64(health[i-1].Occupied), int64(health[i-1].Total))
156 curr := ratio.New(int64(health[i].Occupied), int64(health[i].Total))
157 if prev.Less(curr) {
158 rising++
159 } else if curr.Less(prev) {
160 falling++
161 }
162 }
163 transitions := len(health) - 1
164 if falling > transitions/2 {
165 return TrendFalling
166 }
167 if rising > transitions/2 {
168 return TrendRising
169 }
170 // Flat for 3+ points = stagnant.
171 if transitions >= 3 && rising == 0 && falling == 0 {
172 return TrendStagnant
173 }
174 return TrendFlat
175 }
176
177 // computeFitnessTrend determines whether fitness is rising, falling, flat, or stagnant.
178 func computeFitnessTrend(fitness []FitnessPoint) Trend {
179 if len(fitness) < 2 {
180 return TrendFlat
181 }
182 rising := 0
183 falling := 0
184 for i := 1; i < len(fitness); i++ {
185 if fitness[i-1].Score.Less(fitness[i].Score) {
186 rising++
187 } else if fitness[i].Score.Less(fitness[i-1].Score) {
188 falling++
189 }
190 }
191 transitions := len(fitness) - 1
192 if falling > transitions/2 {
193 return TrendFalling
194 }
195 if rising > transitions/2 {
196 return TrendRising
197 }
198 if transitions >= 3 && rising == 0 && falling == 0 {
199 return TrendStagnant
200 }
201 return TrendFlat
202 }
203
204 // computeBondRate computes bonds/allocated_sites for the most recent generation
205 // where both type signature and bond data exist.
206 func computeBondRate(typ []TypePoint, bnd []TypePoint) ratio.Ratio {
207 if len(typ) == 0 || len(bnd) == 0 {
208 return ratio.Zero
209 }
210 // Build gen→count maps.
211 typMap := make(map[uint32]uint32)
212 for _, p := range typ {
213 typMap[p.Gen] = p.Count
214 }
215 bndMap := make(map[uint32]uint32)
216 for _, p := range bnd {
217 bndMap[p.Gen] = p.Count
218 }
219 // Find most recent gen with both.
220 for i := len(typ) - 1; i >= 0; i-- {
221 gen := typ[i].Gen
222 tc := typMap[gen]
223 bc := bndMap[gen]
224 if tc > 0 {
225 return ratio.New(int64(bc), int64(tc))
226 }
227 }
228 return ratio.Zero
229 }
230
231 // computeMissingDelta returns the change in missing count between the two most
232 // recent generations. Negative means improving (fewer missing sites).
233 func computeMissingDelta(mis []MissingPoint) int {
234 if len(mis) < 2 {
235 return 0
236 }
237 prev := mis[len(mis)-2].Count
238 curr := mis[len(mis)-1].Count
239 return int(curr) - int(prev)
240 }
241
242 // computeExploreRatio computes explore_ops / total_ops from the most recent
243 // generation's hexagram operation counts.
244 func computeExploreRatio(d *DB, lastN int) ratio.Ratio {
245 // Query each op type for 1 generation (the most recent).
246 var exploreCount uint32
247 var totalCount uint32
248
249 for op := opNone; op <= opRecycle; op++ {
250 pts := d.QueryHexagramOps(op, 1)
251 if len(pts) > 0 {
252 totalCount += pts[0].Count
253 if op == opExplore || op == opNucleate {
254 exploreCount += pts[0].Count
255 }
256 }
257 }
258
259 if totalCount == 0 {
260 return ratio.Zero
261 }
262 return ratio.New(int64(exploreCount), int64(totalCount))
263 }
264