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