package hexagram import ( "context" "math/rand/v2" "time" "git.mleku.dev/mleku/dendrite/pkg/axiom" "git.mleku.dev/mleku/dendrite/pkg/lattice" "git.mleku.dev/mleku/dendrite/pkg/permutation" "git.mleku.dev/mleku/dendrite/pkg/ratio" ) // Event records an operation the engine performed. type Event struct { Op Op NodeID lattice.NodeID } // EngineConfig controls the self-execution loop. type EngineConfig struct { // Interval between execution ticks. Interval time.Duration // Solution is where dissolved elements return to and where // accretion draws from. Solution chan axiom.Element // MaxNewSites is how many new sites OpExplore can create per tick. MaxNewSites int // MinOccupancy is the minimum occupancy rate (occupied/total) below // which OpExplore and OpNucleate are suppressed (remapped to OpAccrete). // Zero means no throttle. MinOccupancy ratio.Ratio // Oscillating, when true, activates accelerated regulation. // Sustain nodes are destabilized more easily and Attack-phase // protection is removed, allowing the lattice to shed weak bonds. Oscillating bool // SustainThreshold is the contextual lock-in level below which a // Sustain node is destabilized into Release. During oscillation this // threshold is halved. Zero means use the default (4/10). SustainThreshold ratio.Ratio } // DefaultEngineConfig returns reasonable defaults. func DefaultEngineConfig() EngineConfig { return EngineConfig{ Interval: 16 * time.Millisecond, // 2^4 ms — epoch-aligns with dissolve at 10^2 ms MaxNewSites: 4, } } // RunEngine starts the self-execution loop. On each tick, it scans the // lattice, updates hexagram states, looks up transition rules, and // executes them. The lattice is now a program running itself. // // Blocks until context is cancelled. func RunEngine(ctx context.Context, l *lattice.Lattice, cfg EngineConfig, events chan<- Event) { ticker := time.NewTicker(cfg.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: tick(ctx, l, cfg, events) } } } // tick runs one execution cycle across all lattice nodes. func tick(ctx context.Context, l *lattice.Lattice, cfg EngineConfig, events chan<- Event) { nodes := l.Nodes() // Phase 1: Update outer trigrams from neighborhood. for _, n := range nodes { outer := n.NeighborStates() n.SetOuterTrigram(outer) } // Phase 1.5: ADSR aging and conditional destabilization. // IncrementAge advances Attack→Decay→Sustain automatically (saturates at 2). // Sustain→Release is conditional: triggered by weak contextual lock-in // or by oscillation detection lowering the threshold. sustainThreshold := cfg.SustainThreshold if sustainThreshold.IsZero() { sustainThreshold = ratio.New(4, 10) // default: 0.4 } if cfg.Oscillating { sustainThreshold = sustainThreshold.Mul(ratio.New(1, 2)) // halve during oscillation } occupiedCount := 0 for _, n := range nodes { if !n.Occupied() { continue } n.IncrementAge() // 0→1→2 auto; stays at 2 (Sustain) occupiedCount++ // Local destabilization: Sustain nodes with weak neighborhood // support enter Release phase. if n.Age() == 2 { if n.ContextualLockIn().Less(sustainThreshold) { n.Destabilize() // 2→3 } } } // Occupancy-aware throttle: suppress site creation when lattice is sparse. suppressExplore := false if !cfg.MinOccupancy.IsZero() && len(nodes) > 0 { occ := ratio.New(int64(occupiedCount), int64(len(nodes))) if occ.Less(cfg.MinOccupancy) { suppressExplore = true } } // Occupancy-proportional growth: crystal faces that outrun their // supply slow down. Growth rate scales with the square of occupancy, // matching surface kinetics — a face can only accrete if the local // solution concentration (occupancy) supports it. effectiveMaxNew := cfg.MaxNewSites if len(nodes) > 0 { occ := ratio.New(int64(occupiedCount), int64(len(nodes))) occSq := occ.Mul(occ) effectiveMaxNew = int(occSq.ScaleInt(int64(cfg.MaxNewSites))) if effectiveMaxNew < 1 && occupiedCount > 0 { effectiveMaxNew = 1 } } // Phase 2: Execute rules. newSites := 0 for _, n := range nodes { select { case <-ctx.Done(): return default: } h := n.Hexagram() // Use projection key when available; fall back to permutation. var rule Rule if projKey := n.ProjectionKey(); projKey > 0 || n.ProjectionVertex() > 0 { rule = LookupProjected(h, projKey) } else { rule = LookupVariant(h, permutation.Perm(n.Permutation())) } // Adaptive remapping: if a rule doesn't apply to the node's // actual occupancy state, remap to the appropriate operation. // A vacant node told to Dissolve should Accrete instead. // An occupied node told to Accrete is already done — Strengthen. occupied := n.Occupied() op := rule.Op switch { case op == OpNone: continue case (op == OpExplore || op == OpNucleate) && suppressExplore: op = OpAccrete // occupancy too low — fill existing sites instead case op == OpDissolve && !occupied: op = OpAccrete // vacant + energy = site wants to fill case op == OpRecycle && !occupied: op = OpAccrete case op == OpAccrete && occupied: op = OpStrengthen // already full, reinforce case op == OpCollapse && !n.Ambiguous(): op = OpNone } if op == OpNone { continue } // ADSR envelope modulation: age shapes which operations are // allowed at each node, like a note's envelope shapes amplitude. switch ADSRPhase(n.Age()) { case Attack: // newborns are protected from dissolution if !cfg.Oscillating { if op == OpDissolve || op == OpRecycle { op = OpNone } } // During oscillation, newborns are NOT protected. case Decay: // settling — all operations as derived // no override case Sustain: // durable — favor stability, suppress expansion if op == OpNucleate || op == OpExplore { op = OpStrengthen } case Release: // dissolving — favor dissolution, suppress accretion if op == OpAccrete || op == OpNucleate { op = OpDissolve } if op == OpStrengthen { op = OpRecycle } } if op == OpNone { continue } executed := false switch op { case OpAccrete: // Try to pull an element from solution and bond it. if !n.Occupied() && cfg.Solution != nil { select { case elem := <-cfg.Solution: if n.Bond(elem) { executed = true } else { // Put it back. select { case cfg.Solution <- elem: default: } } default: // No elements available. } } case OpDissolve: if n.Occupied() { elem := n.Dissolve() if elem != nil && cfg.Solution != nil { l.ReindexVacant(n) select { case cfg.Solution <- elem: default: } executed = true } } case OpNucleate: // Create new constraint sites around this node. if newSites < effectiveMaxNew { nn := l.AddNode(inheritConstraints(n)) l.Connect(n, nn) newSites++ executed = true } case OpPrune: // Find weakest neighbor connection and sever it. weakest := findWeakestNeighbor(n) if weakest != nil { l.Disconnect(n, weakest) executed = true } case OpStrengthen: // Increase lock-in by updating energy state. n.SetEnergy(true) executed = true case OpExplore: // Extend lattice topology with new vacant sites. if newSites < effectiveMaxNew { nn := l.AddNode(inheritConstraints(n)) l.Connect(n, nn) newSites++ executed = true } case OpCollapse: // Resolve ambiguity by selecting first candidate. if n.Ambiguous() { n.Collapse(func(candidates []axiom.Element) axiom.Element { if len(candidates) == 0 { return nil } return candidates[0] }) executed = true } case OpRecycle: // Dissolve and immediately re-offer. if n.Occupied() { elem := n.Dissolve() if elem != nil && cfg.Solution != nil { l.ReindexVacant(n) select { case cfg.Solution <- elem: default: } executed = true } } } if executed { select { case events <- Event{Op: op, NodeID: n.ID()}: default: } } } } // inheritConstraints creates a constraint set for a new node based on // the parent's constraints. The child inherits the parent's type layer. func inheritConstraints(parent *lattice.Node) []axiom.Constraint { parent_constraints := parent.Constraints() if len(parent_constraints) == 0 { return nil } // Copy the constraints — same type layer as parent. out := make([]axiom.Constraint, len(parent_constraints)) copy(out, parent_constraints) return out } // findWeakestNeighbor returns the neighbor with the lowest lock-in depth. func findWeakestNeighbor(n *lattice.Node) *lattice.Node { neighbors := n.Neighbors() if len(neighbors) == 0 { return nil } var weakest *lattice.Node weakestLockIn := ratio.FromInt(1<<62 - 1) // large sentinel for _, nb := range neighbors { li := nb.LockIn() if li.Less(weakestLockIn) { weakestLockIn = li weakest = nb } } // Only prune if the weakest is actually weak (unoccupied or low lock-in). if !weakestLockIn.IsZero() && weakestLockIn.IsPositive() { // Probabilistic pruning — don't always prune. if rand.IntN(10) >= 3 { return nil } } return weakest }