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