engine.go raw

   1  package hexagram
   2  
   3  import (
   4  	"context"
   5  	"math/rand/v2"
   6  	"time"
   7  
   8  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
   9  	"git.mleku.dev/mleku/dendrite/pkg/lattice"
  10  	"git.mleku.dev/mleku/dendrite/pkg/permutation"
  11  	"git.mleku.dev/mleku/dendrite/pkg/ratio"
  12  )
  13  
  14  // Event records an operation the engine performed.
  15  type Event struct {
  16  	Op     Op
  17  	NodeID lattice.NodeID
  18  }
  19  
  20  // EngineConfig controls the self-execution loop.
  21  type EngineConfig struct {
  22  	// Interval between execution ticks.
  23  	Interval time.Duration
  24  
  25  	// Solution is where dissolved elements return to and where
  26  	// accretion draws from.
  27  	Solution chan axiom.Element
  28  
  29  	// MaxNewSites is how many new sites OpExplore can create per tick.
  30  	MaxNewSites int
  31  
  32  	// MinOccupancy is the minimum occupancy rate (occupied/total) below
  33  	// which OpExplore and OpNucleate are suppressed (remapped to OpAccrete).
  34  	// Zero means no throttle.
  35  	MinOccupancy ratio.Ratio
  36  
  37  	// Oscillating, when true, activates accelerated regulation.
  38  	// Sustain nodes are destabilized more easily and Attack-phase
  39  	// protection is removed, allowing the lattice to shed weak bonds.
  40  	Oscillating bool
  41  
  42  	// SustainThreshold is the contextual lock-in level below which a
  43  	// Sustain node is destabilized into Release. During oscillation this
  44  	// threshold is halved. Zero means use the default (4/10).
  45  	SustainThreshold ratio.Ratio
  46  }
  47  
  48  // DefaultEngineConfig returns reasonable defaults.
  49  func DefaultEngineConfig() EngineConfig {
  50  	return EngineConfig{
  51  		Interval:    16 * time.Millisecond, // 2^4 ms — epoch-aligns with dissolve at 10^2 ms
  52  		MaxNewSites: 4,
  53  	}
  54  }
  55  
  56  // RunEngine starts the self-execution loop. On each tick, it scans the
  57  // lattice, updates hexagram states, looks up transition rules, and
  58  // executes them. The lattice is now a program running itself.
  59  //
  60  // Blocks until context is cancelled.
  61  func RunEngine(ctx context.Context, l *lattice.Lattice, cfg EngineConfig, events chan<- Event) {
  62  	ticker := time.NewTicker(cfg.Interval)
  63  	defer ticker.Stop()
  64  
  65  	for {
  66  		select {
  67  		case <-ctx.Done():
  68  			return
  69  		case <-ticker.C:
  70  			tick(ctx, l, cfg, events)
  71  		}
  72  	}
  73  }
  74  
  75  // tick runs one execution cycle across all lattice nodes.
  76  func tick(ctx context.Context, l *lattice.Lattice, cfg EngineConfig, events chan<- Event) {
  77  	nodes := l.Nodes()
  78  
  79  	// Phase 1: Update outer trigrams from neighborhood.
  80  	for _, n := range nodes {
  81  		outer := n.NeighborStates()
  82  		n.SetOuterTrigram(outer)
  83  	}
  84  
  85  	// Phase 1.5: ADSR aging and conditional destabilization.
  86  	// IncrementAge advances Attack→Decay→Sustain automatically (saturates at 2).
  87  	// Sustain→Release is conditional: triggered by weak contextual lock-in
  88  	// or by oscillation detection lowering the threshold.
  89  	sustainThreshold := cfg.SustainThreshold
  90  	if sustainThreshold.IsZero() {
  91  		sustainThreshold = ratio.New(4, 10) // default: 0.4
  92  	}
  93  	if cfg.Oscillating {
  94  		sustainThreshold = sustainThreshold.Mul(ratio.New(1, 2)) // halve during oscillation
  95  	}
  96  
  97  	occupiedCount := 0
  98  	for _, n := range nodes {
  99  		if !n.Occupied() {
 100  			continue
 101  		}
 102  		n.IncrementAge() // 0→1→2 auto; stays at 2 (Sustain)
 103  		occupiedCount++
 104  
 105  		// Local destabilization: Sustain nodes with weak neighborhood
 106  		// support enter Release phase.
 107  		if n.Age() == 2 {
 108  			if n.ContextualLockIn().Less(sustainThreshold) {
 109  				n.Destabilize() // 2→3
 110  			}
 111  		}
 112  	}
 113  
 114  	// Occupancy-aware throttle: suppress site creation when lattice is sparse.
 115  	suppressExplore := false
 116  	if !cfg.MinOccupancy.IsZero() && len(nodes) > 0 {
 117  		occ := ratio.New(int64(occupiedCount), int64(len(nodes)))
 118  		if occ.Less(cfg.MinOccupancy) {
 119  			suppressExplore = true
 120  		}
 121  	}
 122  
 123  	// Occupancy-proportional growth: crystal faces that outrun their
 124  	// supply slow down. Growth rate scales with the square of occupancy,
 125  	// matching surface kinetics — a face can only accrete if the local
 126  	// solution concentration (occupancy) supports it.
 127  	effectiveMaxNew := cfg.MaxNewSites
 128  	if len(nodes) > 0 {
 129  		occ := ratio.New(int64(occupiedCount), int64(len(nodes)))
 130  		occSq := occ.Mul(occ)
 131  		effectiveMaxNew = int(occSq.ScaleInt(int64(cfg.MaxNewSites)))
 132  		if effectiveMaxNew < 1 && occupiedCount > 0 {
 133  			effectiveMaxNew = 1
 134  		}
 135  	}
 136  
 137  	// Phase 2: Execute rules.
 138  	newSites := 0
 139  	for _, n := range nodes {
 140  		select {
 141  		case <-ctx.Done():
 142  			return
 143  		default:
 144  		}
 145  
 146  		h := n.Hexagram()
 147  		// Use projection key when available; fall back to permutation.
 148  		var rule Rule
 149  		if projKey := n.ProjectionKey(); projKey > 0 || n.ProjectionVertex() > 0 {
 150  			rule = LookupProjected(h, projKey)
 151  		} else {
 152  			rule = LookupVariant(h, permutation.Perm(n.Permutation()))
 153  		}
 154  
 155  		// Adaptive remapping: if a rule doesn't apply to the node's
 156  		// actual occupancy state, remap to the appropriate operation.
 157  		// A vacant node told to Dissolve should Accrete instead.
 158  		// An occupied node told to Accrete is already done — Strengthen.
 159  		occupied := n.Occupied()
 160  		op := rule.Op
 161  		switch {
 162  		case op == OpNone:
 163  			continue
 164  		case (op == OpExplore || op == OpNucleate) && suppressExplore:
 165  			op = OpAccrete // occupancy too low — fill existing sites instead
 166  		case op == OpDissolve && !occupied:
 167  			op = OpAccrete // vacant + energy = site wants to fill
 168  		case op == OpRecycle && !occupied:
 169  			op = OpAccrete
 170  		case op == OpAccrete && occupied:
 171  			op = OpStrengthen // already full, reinforce
 172  		case op == OpCollapse && !n.Ambiguous():
 173  			op = OpNone
 174  		}
 175  		if op == OpNone {
 176  			continue
 177  		}
 178  
 179  		// ADSR envelope modulation: age shapes which operations are
 180  		// allowed at each node, like a note's envelope shapes amplitude.
 181  		switch ADSRPhase(n.Age()) {
 182  		case Attack: // newborns are protected from dissolution
 183  			if !cfg.Oscillating {
 184  				if op == OpDissolve || op == OpRecycle {
 185  					op = OpNone
 186  				}
 187  			}
 188  			// During oscillation, newborns are NOT protected.
 189  		case Decay: // settling — all operations as derived
 190  			// no override
 191  		case Sustain: // durable — favor stability, suppress expansion
 192  			if op == OpNucleate || op == OpExplore {
 193  				op = OpStrengthen
 194  			}
 195  		case Release: // dissolving — favor dissolution, suppress accretion
 196  			if op == OpAccrete || op == OpNucleate {
 197  				op = OpDissolve
 198  			}
 199  			if op == OpStrengthen {
 200  				op = OpRecycle
 201  			}
 202  		}
 203  		if op == OpNone {
 204  			continue
 205  		}
 206  
 207  		executed := false
 208  
 209  		switch op {
 210  		case OpAccrete:
 211  			// Try to pull an element from solution and bond it.
 212  			if !n.Occupied() && cfg.Solution != nil {
 213  				select {
 214  				case elem := <-cfg.Solution:
 215  					if n.Bond(elem) {
 216  						executed = true
 217  					} else {
 218  						// Put it back.
 219  						select {
 220  						case cfg.Solution <- elem:
 221  						default:
 222  						}
 223  					}
 224  				default:
 225  					// No elements available.
 226  				}
 227  			}
 228  
 229  		case OpDissolve:
 230  			if n.Occupied() {
 231  				elem := n.Dissolve()
 232  				if elem != nil && cfg.Solution != nil {
 233  					l.ReindexVacant(n)
 234  					select {
 235  					case cfg.Solution <- elem:
 236  					default:
 237  					}
 238  					executed = true
 239  				}
 240  			}
 241  
 242  		case OpNucleate:
 243  			// Create new constraint sites around this node.
 244  			if newSites < effectiveMaxNew {
 245  				nn := l.AddNode(inheritConstraints(n))
 246  				l.Connect(n, nn)
 247  				newSites++
 248  				executed = true
 249  			}
 250  
 251  		case OpPrune:
 252  			// Find weakest neighbor connection and sever it.
 253  			weakest := findWeakestNeighbor(n)
 254  			if weakest != nil {
 255  				l.Disconnect(n, weakest)
 256  				executed = true
 257  			}
 258  
 259  		case OpStrengthen:
 260  			// Increase lock-in by updating energy state.
 261  			n.SetEnergy(true)
 262  			executed = true
 263  
 264  		case OpExplore:
 265  			// Extend lattice topology with new vacant sites.
 266  			if newSites < effectiveMaxNew {
 267  				nn := l.AddNode(inheritConstraints(n))
 268  				l.Connect(n, nn)
 269  				newSites++
 270  				executed = true
 271  			}
 272  
 273  		case OpCollapse:
 274  			// Resolve ambiguity by selecting first candidate.
 275  			if n.Ambiguous() {
 276  				n.Collapse(func(candidates []axiom.Element) axiom.Element {
 277  					if len(candidates) == 0 {
 278  						return nil
 279  					}
 280  					return candidates[0]
 281  				})
 282  				executed = true
 283  			}
 284  
 285  		case OpRecycle:
 286  			// Dissolve and immediately re-offer.
 287  			if n.Occupied() {
 288  				elem := n.Dissolve()
 289  				if elem != nil && cfg.Solution != nil {
 290  					l.ReindexVacant(n)
 291  					select {
 292  					case cfg.Solution <- elem:
 293  					default:
 294  					}
 295  					executed = true
 296  				}
 297  			}
 298  		}
 299  
 300  		if executed {
 301  			select {
 302  			case events <- Event{Op: op, NodeID: n.ID()}:
 303  			default:
 304  			}
 305  		}
 306  	}
 307  }
 308  
 309  // inheritConstraints creates a constraint set for a new node based on
 310  // the parent's constraints. The child inherits the parent's type layer.
 311  func inheritConstraints(parent *lattice.Node) []axiom.Constraint {
 312  	parent_constraints := parent.Constraints()
 313  	if len(parent_constraints) == 0 {
 314  		return nil
 315  	}
 316  	// Copy the constraints — same type layer as parent.
 317  	out := make([]axiom.Constraint, len(parent_constraints))
 318  	copy(out, parent_constraints)
 319  	return out
 320  }
 321  
 322  // findWeakestNeighbor returns the neighbor with the lowest lock-in depth.
 323  func findWeakestNeighbor(n *lattice.Node) *lattice.Node {
 324  	neighbors := n.Neighbors()
 325  	if len(neighbors) == 0 {
 326  		return nil
 327  	}
 328  
 329  	var weakest *lattice.Node
 330  	weakestLockIn := ratio.FromInt(1<<62 - 1) // large sentinel
 331  
 332  	for _, nb := range neighbors {
 333  		li := nb.LockIn()
 334  		if li.Less(weakestLockIn) {
 335  			weakestLockIn = li
 336  			weakest = nb
 337  		}
 338  	}
 339  
 340  	// Only prune if the weakest is actually weak (unoccupied or low lock-in).
 341  	if !weakestLockIn.IsZero() && weakestLockIn.IsPositive() {
 342  		// Probabilistic pruning — don't always prune.
 343  		if rand.IntN(10) >= 3 {
 344  			return nil
 345  		}
 346  	}
 347  
 348  	return weakest
 349  }
 350