lattice.go raw
1 // Package lattice implements the growth structure — nodes, sites, constraint
2 // envelopes, and the graph that connects them.
3 //
4 // The lattice is the crystalline state. It grows by accretion, prunes by
5 // dissolution, and maintains dynamic equilibrium. All operations are
6 // single-threaded per lattice instance. Concurrent access requires external
7 // synchronization.
8 package lattice
9
10 import (
11 "math/rand/v2"
12 "sync"
13
14 "git.mleku.dev/mleku/dendrite/pkg/axiom"
15 "git.mleku.dev/mleku/dendrite/pkg/ratio"
16 "git.mleku.dev/mleku/dendrite/pkg/state"
17 )
18
19 // NodeID is a unique identifier for a lattice node.
20 // It is the index into the lattice's node slice (0-based).
21 type NodeID uint64
22
23 // LockInDepth measures how firmly an element is held at a lattice site.
24 // Higher values mean deeper lock-in — more constraint satisfaction,
25 // harder to dissolve. Stored as an exact rational number.
26 type LockInDepth = ratio.Ratio
27
28 // Node is a position in the lattice. Each node has a constraint envelope
29 // (the negative space defining what fits), an optional occupant, and
30 // connections to neighbors. No per-node mutex — all access is single-threaded,
31 // serialized by the cache's block-level lock or the caller's goroutine.
32 //
33 // Neighbors are stored as uint32 indices into the lattice's node slice
34 // rather than *Node pointers. This halves neighbor memory (4 vs 8 bytes
35 // per edge) and eliminates ~100M GC pointer roots at 16.5M nodes.
36 type Node struct {
37 lat *Lattice
38 id NodeID
39 constraints []axiom.Constraint
40 occupant axiom.Element // nil if vacant
41 candidates []axiom.Element // ambiguity: multiple valid fillers (Lake state)
42 neighborIdx []uint32 // indices into lat.nodes
43 hex state.Hexagram
44 lockIn LockInDepth
45 bondCount int // number of constraints satisfied by current occupant
46 crossLayer bool // true if occupant is misaligned with constraint layer (post-hoc detection target)
47 perm uint8 // S_3 permutation index (0-5), set by PermutedElement on bond
48 projVertex uint8 // 3-bit cube vertex (0-7), set by ProjectedElement on bond
49 projKey uint8 // 3-bit projection key (0-7), set by ProjectedElement on bond
50 projPath uint16 // rendering path index, set by ProjectedElement on bond
51 age uint8 // 2-bit ADSR envelope (0=Attack, 1=Decay, 2=Sustain, 3=Release)
52 }
53
54 // ID returns the node's unique identifier.
55 func (n *Node) ID() NodeID {
56 return n.id
57 }
58
59 // Hexagram returns the node's current dynamical state.
60 func (n *Node) Hexagram() state.Hexagram {
61 return n.hex
62 }
63
64 // Occupied reports whether this node has an element bonded to it.
65 func (n *Node) Occupied() bool {
66 return n.occupant != nil
67 }
68
69 // Occupant returns the bonded element, or nil if vacant.
70 func (n *Node) Occupant() axiom.Element {
71 return n.occupant
72 }
73
74 // CrossLayer reports whether this node's occupant is misaligned with the
75 // node's constraint layer.
76 func (n *Node) CrossLayer() bool {
77 return n.crossLayer
78 }
79
80 // LockIn returns the current lock-in depth.
81 func (n *Node) LockIn() LockInDepth {
82 return n.lockIn
83 }
84
85 // Permutation returns the node's active S_3 permutation index (0-5).
86 // Identity (0) is the default for nodes without a PermutedElement.
87 func (n *Node) Permutation() uint8 {
88 return n.perm
89 }
90
91 // SetPermutation sets the node's active S_3 permutation index.
92 // Values >= 6 are ignored.
93 func (n *Node) SetPermutation(p uint8) {
94 if p < 6 {
95 n.perm = p
96 }
97 }
98
99 // ProjectionVertex returns the node's 3-bit cube vertex (0-7).
100 func (n *Node) ProjectionVertex() uint8 {
101 return n.projVertex
102 }
103
104 // ProjectionKey returns the node's 3-bit projection key (0-7).
105 func (n *Node) ProjectionKey() uint8 {
106 return n.projKey
107 }
108
109 // ProjectionPath returns the node's rendering path index.
110 func (n *Node) ProjectionPath() uint16 {
111 return n.projPath
112 }
113
114 // SetProjection sets the node's full projection encoding.
115 func (n *Node) SetProjection(vertex, key uint8, path uint16) {
116 if vertex < 8 {
117 n.projVertex = vertex
118 }
119 if key < 8 {
120 n.projKey = key
121 }
122 n.projPath = path
123 }
124
125 // Projection6Bit returns the packed 6-bit projection: vertex (low 3) | key (high 3).
126 func (n *Node) Projection6Bit() uint8 {
127 return (n.projVertex & 0b111) | (n.projKey&0b111)<<3
128 }
129
130 // Age returns the node's current age (0-3), encoding the ADSR envelope:
131 //
132 // 0 = Attack — freshly bonded, high-energy accretion
133 // 1 = Decay — actively growing, settling
134 // 2 = Sustain — locked in, durable (stable attractor)
135 // 3 = Release — dissolving, returning to solution
136 func (n *Node) Age() uint8 {
137 return n.age
138 }
139
140 // IncrementAge advances Attack→Decay→Sustain automatically.
141 func (n *Node) IncrementAge() {
142 if n.age < 2 {
143 n.age++
144 }
145 }
146
147 // Destabilize forces a Sustain node into Release phase.
148 // No-op if the node is not in Sustain (age 2).
149 func (n *Node) Destabilize() {
150 if n.age == 2 {
151 n.age = 3
152 }
153 }
154
155 // ProjectionByte returns the full 8-bit encoding:
156 //
157 // bit 7 6 5 4 3 2 1 0
158 // [age ] [ key ] [vertex]
159 func (n *Node) ProjectionByte() uint8 {
160 return (n.age&0b11)<<6 | (n.projKey&0b111)<<3 | (n.projVertex & 0b111)
161 }
162
163 // ContextualLockIn computes effective lock-in that accounts for neighborhood.
164 // A bonded element surrounded by occupied neighbors has higher effective lock-in
165 // than an isolated one.
166 //
167 // Formula: 0.3 + 0.7 * neighborOccupancyRate
168 func (n *Node) ContextualLockIn() LockInDepth {
169 if n.lockIn.IsZero() {
170 return ratio.Zero
171 }
172
173 if len(n.neighborIdx) == 0 {
174 return ratio.New(3, 10)
175 }
176
177 occupied := 0
178 for _, idx := range n.neighborIdx {
179 if n.lat.nodes[idx].occupant != nil {
180 occupied++
181 }
182 }
183 rate := ratio.New(int64(occupied), int64(len(n.neighborIdx)))
184 return ratio.New(3, 10).Add(ratio.New(7, 10).Mul(rate))
185 }
186
187 // Constraints returns the node's constraint envelope.
188 func (n *Node) Constraints() []axiom.Constraint {
189 out := make([]axiom.Constraint, len(n.constraints))
190 copy(out, n.constraints)
191 return out
192 }
193
194 // Neighbors returns all neighbor nodes as resolved pointers.
195 // The returned slice is freshly allocated; callers must not cache it across
196 // mutations. For index-only access in hot paths, use NeighborCount + NeighborAt.
197 func (n *Node) Neighbors() []*Node {
198 out := make([]*Node, len(n.neighborIdx))
199 for i, idx := range n.neighborIdx {
200 out[i] = n.lat.nodes[idx]
201 }
202 return out
203 }
204
205 // NeighborCount returns the number of neighbors without allocating.
206 func (n *Node) NeighborCount() int {
207 return len(n.neighborIdx)
208 }
209
210 // NeighborAt returns the i-th neighbor node.
211 func (n *Node) NeighborAt(i int) *Node {
212 return n.lat.nodes[n.neighborIdx[i]]
213 }
214
215 // NeighborIndices returns the raw uint32 index slice. The caller must not
216 // modify the returned slice. Used by serialization to avoid allocating a
217 // temporary []*Node.
218 func (n *Node) NeighborIndices() []uint32 {
219 return n.neighborIdx
220 }
221
222 // Admits checks whether the given element satisfies this node's constraint
223 // envelope.
224 func (n *Node) Admits(e axiom.Element) bool {
225 if n.occupant != nil {
226 return false
227 }
228 if len(n.constraints) == 0 {
229 return false
230 }
231 for _, c := range n.constraints {
232 if !c.Admits(e) {
233 return false
234 }
235 }
236 return true
237 }
238
239 // Bond attempts to place an element at this node. Returns true if the
240 // bond formed, false if the site was already claimed or the element
241 // doesn't fit.
242 func (n *Node) Bond(e axiom.Element) bool {
243 if n.occupant != nil {
244 return false
245 }
246 satisfied := 0
247 misaligned := false
248 for _, c := range n.constraints {
249 if lc, ok := c.(axiom.LayeredConstraint); ok {
250 if le, ok := e.(axiom.LayeredElement); ok {
251 if !lc.Aligns(le) {
252 misaligned = true
253 }
254 }
255 }
256 if c.Admits(e) {
257 satisfied++
258 } else {
259 return false
260 }
261 }
262 // Contextual constraint check using direct neighbor reads.
263 for _, c := range n.constraints {
264 if cc, ok := c.(axiom.ContextualConstraint); ok {
265 nbOccupants := make([]axiom.Element, len(n.neighborIdx))
266 for i, idx := range n.neighborIdx {
267 nbOccupants[i] = n.lat.nodes[idx].occupant
268 }
269 if !cc.AdmitsInContext(e, nbOccupants) {
270 return false
271 }
272 break
273 }
274 }
275
276 n.occupant = e
277 n.bondCount = satisfied
278 n.crossLayer = misaligned
279 if misaligned {
280 n.lockIn = ratio.New(int64(satisfied), 2)
281 } else {
282 n.lockIn = ratio.FromInt(int64(satisfied))
283 }
284 if pe, ok := e.(axiom.PermutedElement); ok {
285 p := pe.Permutation()
286 if p < 6 {
287 n.perm = p
288 }
289 }
290 if pe, ok := e.(axiom.ProjectedElement); ok {
291 v := pe.ProjectionVertex()
292 if v < 8 {
293 n.projVertex = v
294 }
295 k := pe.ProjectionKey()
296 if k < 8 {
297 n.projKey = k
298 }
299 n.projPath = pe.ProjectionPath()
300 n.perm = keyToPermLookup(k)
301 }
302 n.age = 0
303 n.updateHex()
304 if wc := n.lat.walkCols; wc != nil {
305 wc.SetOccupied(n.id, true)
306 wc.SetLockIn(n.id, n.lockIn.Num, n.lockIn.Denom)
307 }
308 return true
309 }
310
311 // Dissolve removes the occupant from this node, returning the element
312 // to the free pool. Returns the dissolved element, or nil if vacant.
313 func (n *Node) Dissolve() axiom.Element {
314 e := n.occupant
315 n.occupant = nil
316 n.candidates = nil
317 n.bondCount = 0
318 n.crossLayer = false
319 n.lockIn = ratio.Zero
320 n.perm = 0
321 n.projVertex = 0
322 n.projKey = 0
323 n.projPath = 0
324 n.age = 0
325 n.updateHex()
326 if wc := n.lat.walkCols; wc != nil {
327 wc.SetOccupied(n.id, false)
328 wc.SetLockIn(n.id, 0, 1)
329 }
330 return e
331 }
332
333 // Displace checks whether element e would have stronger lock-in than the
334 // current occupant. If so, the occupant is ejected and e bonds in its place.
335 // Returns the displaced element and true on success, or nil and false if
336 // the site is vacant, the element doesn't fit, or the current occupant is
337 // stronger.
338 //
339 // This is competitive displacement: a higher-affinity element replaces a
340 // lower-affinity one at the same site, like a stronger ligand displacing
341 // a weaker one from a receptor.
342 func (n *Node) Displace(e axiom.Element) (axiom.Element, bool) {
343 if n.occupant == nil {
344 // Vacant — use Bond instead.
345 return nil, false
346 }
347
348 // Check if the newcomer satisfies all constraints.
349 satisfied := 0
350 misaligned := false
351 for _, c := range n.constraints {
352 if lc, ok := c.(axiom.LayeredConstraint); ok {
353 if le, ok := e.(axiom.LayeredElement); ok {
354 if !lc.Aligns(le) {
355 misaligned = true
356 }
357 }
358 }
359 if c.Admits(e) {
360 satisfied++
361 } else {
362 return nil, false
363 }
364 }
365
366 // Contextual constraint check.
367 for _, c := range n.constraints {
368 if cc, ok := c.(axiom.ContextualConstraint); ok {
369 nbOccupants := make([]axiom.Element, len(n.neighborIdx))
370 for i, idx := range n.neighborIdx {
371 nbOccupants[i] = n.lat.nodes[idx].occupant
372 }
373 if !cc.AdmitsInContext(e, nbOccupants) {
374 return nil, false
375 }
376 break
377 }
378 }
379
380 // Compute what the newcomer's lock-in would be.
381 var newLockIn ratio.Ratio
382 if misaligned {
383 newLockIn = ratio.New(int64(satisfied), 2)
384 } else {
385 newLockIn = ratio.FromInt(int64(satisfied))
386 }
387
388 // Only displace if strictly stronger.
389 if !n.lockIn.Less(newLockIn) {
390 return nil, false
391 }
392
393 // Eject the current occupant.
394 displaced := n.occupant
395
396 // Bond the newcomer in place.
397 n.occupant = e
398 n.bondCount = satisfied
399 n.crossLayer = misaligned
400 n.lockIn = newLockIn
401 if pe, ok := e.(axiom.PermutedElement); ok {
402 p := pe.Permutation()
403 if p < 6 {
404 n.perm = p
405 }
406 }
407 if pe, ok := e.(axiom.ProjectedElement); ok {
408 v := pe.ProjectionVertex()
409 if v < 8 {
410 n.projVertex = v
411 }
412 k := pe.ProjectionKey()
413 if k < 8 {
414 n.projKey = k
415 }
416 n.projPath = pe.ProjectionPath()
417 n.perm = keyToPermLookup(k)
418 }
419 n.age = 0
420 n.updateHex()
421 if wc := n.lat.walkCols; wc != nil {
422 wc.SetOccupied(n.id, true)
423 wc.SetLockIn(n.id, n.lockIn.Num, n.lockIn.Denom)
424 }
425 return displaced, true
426 }
427
428 // AddCandidate records an element as a valid filler for this site
429 // without committing to it.
430 func (n *Node) AddCandidate(e axiom.Element) bool {
431 for _, c := range n.constraints {
432 if !c.Admits(e) {
433 return false
434 }
435 }
436 for _, existing := range n.candidates {
437 if existing.Type() == e.Type() && existing.Value() == e.Value() {
438 return false
439 }
440 }
441 const maxCandidates = 32
442 if len(n.candidates) >= maxCandidates {
443 return false
444 }
445 n.candidates = append(n.candidates, e)
446 return true
447 }
448
449 // Candidates returns the set of valid fillers at this site.
450 func (n *Node) Candidates() []axiom.Element {
451 if len(n.candidates) == 0 {
452 return nil
453 }
454 out := make([]axiom.Element, len(n.candidates))
455 copy(out, n.candidates)
456 return out
457 }
458
459 // Ambiguous reports whether this site has multiple valid fillers.
460 func (n *Node) Ambiguous() bool {
461 return len(n.candidates) > 1
462 }
463
464 // Collapse resolves ambiguity by selecting one candidate as the occupant
465 // and clearing the rest. Returns true if collapse occurred.
466 func (n *Node) Collapse(selector func([]axiom.Element) axiom.Element) bool {
467 if len(n.candidates) < 2 {
468 return false
469 }
470 chosen := selector(n.candidates)
471 if chosen == nil {
472 return false
473 }
474 satisfied := 0
475 for _, c := range n.constraints {
476 if c.Admits(chosen) {
477 satisfied++
478 }
479 }
480 n.occupant = chosen
481 n.bondCount = satisfied
482 n.lockIn = ratio.FromInt(int64(satisfied))
483 n.candidates = nil
484 n.updateHex()
485 return true
486 }
487
488 // keyToPermLookup maps a 3-bit projection key to an S_3 permutation index.
489 func keyToPermLookup(k uint8) uint8 {
490 switch k {
491 case 0:
492 return 0
493 case 1:
494 return 3
495 case 2:
496 return 2
497 case 3:
498 return 1
499 case 4:
500 return 4
501 case 5:
502 return 5
503 case 6:
504 return 0
505 case 7:
506 return 3
507 default:
508 return 0
509 }
510 }
511
512 // updateHex recalculates the node's hexagram based on current state.
513 func (n *Node) updateHex() {
514 currentEnergy := n.hex.Inner().Energy()
515 inner := state.Trigram(0).
516 SetBonding(n.occupant != nil).
517 SetConstraint(n.occupant != nil && n.bondCount > 0).
518 SetEnergy(currentEnergy)
519 n.hex = state.Hex(inner, n.hex.Outer())
520 }
521
522 // Lattice is the graph of nodes — the crystalline structure.
523 type Lattice struct {
524 mu sync.RWMutex
525 nodes []*Node
526
527 // vacantIdx maps constraint tags to slices of node IDs with matching
528 // vacant sites.
529 vacantIdx map[string][]NodeID
530
531 // cache is the optional block-level cache. When non-nil, walks
532 // check for stripped nodes and trigger block loading via EnsureResident.
533 cache *Cache
534
535 // walkCols is the optional flat-array representation of walk-hot fields.
536 // When non-nil, Bond/Dissolve/ForceOccupant write-through to keep the
537 // columns consistent with the node graph.
538 walkCols *WalkColumns
539 }
540
541 // SetCache attaches a block-level cache to the lattice.
542 func (l *Lattice) SetCache(c *Cache) {
543 l.mu.Lock()
544 defer l.mu.Unlock()
545 l.cache = c
546 }
547
548 // GetCache returns the attached cache, or nil if none.
549 func (l *Lattice) GetCache() *Cache {
550 return l.cache
551 }
552
553 // BuildAndAttachWalkColumns builds flat-array walk columns from the current
554 // node state and attaches them to the lattice. Subsequent Bond/Dissolve calls
555 // write-through to the columns for cache-friendly walk access.
556 func (l *Lattice) BuildAndAttachWalkColumns() {
557 l.walkCols = BuildWalkColumns(l)
558 }
559
560 // DetachWalkColumns removes the walk columns. Bond/Dissolve stop writing through.
561 func (l *Lattice) DetachWalkColumns() {
562 l.walkCols = nil
563 }
564
565 // GetWalkColumns returns the attached walk columns, or nil.
566 func (l *Lattice) GetWalkColumns() *WalkColumns {
567 return l.walkCols
568 }
569
570 // ensureResident loads the block containing nodeID if there is a cache
571 // and the node is stripped. No-op if cache is nil or node is resident.
572 func (l *Lattice) ensureResident(id NodeID) {
573 if l.cache != nil {
574 l.cache.EnsureResident(id)
575 }
576 }
577
578 // EnsureResidentPublic is the exported form of ensureResident, for use by
579 // packages that need to trigger cache block loading (e.g. hexagram activation).
580 func (l *Lattice) EnsureResidentPublic(id NodeID) {
581 l.ensureResident(id)
582 }
583
584 // New creates an empty lattice.
585 func New() *Lattice {
586 return &Lattice{
587 nodes: make([]*Node, 0, 256),
588 vacantIdx: make(map[string][]NodeID),
589 }
590 }
591
592 // AddSkeletonNode creates a node with only its ID and neighbor index slice.
593 func (l *Lattice) AddSkeletonNode() *Node {
594 l.mu.Lock()
595 id := NodeID(len(l.nodes))
596 n := &Node{
597 lat: l,
598 id: id,
599 neighborIdx: getNeighborSlice(),
600 }
601 l.nodes = append(l.nodes, n)
602 l.mu.Unlock()
603 return n
604 }
605
606 // AddNode creates a new node with the given constraint envelope and
607 // adds it to the lattice. Returns the new node.
608 func (l *Lattice) AddNode(constraints []axiom.Constraint) *Node {
609 l.mu.Lock()
610 id := NodeID(len(l.nodes))
611 n := &Node{
612 lat: l,
613 id: id,
614 constraints: constraints,
615 neighborIdx: getNeighborSlice(),
616 }
617 l.nodes = append(l.nodes, n)
618 for _, c := range constraints {
619 tag := c.Tag()
620 l.vacantIdx[tag] = append(l.vacantIdx[tag], id)
621 }
622 l.mu.Unlock()
623 return n
624 }
625
626 // Connect creates a bidirectional neighbor relationship between two nodes.
627 func (l *Lattice) Connect(a, b *Node) {
628 if a == b {
629 return
630 }
631 aIdx := uint32(a.id)
632 bIdx := uint32(b.id)
633 if !hasNeighborIdx(a.neighborIdx, bIdx) {
634 a.neighborIdx = append(a.neighborIdx, bIdx)
635 }
636 if !hasNeighborIdx(b.neighborIdx, aIdx) {
637 b.neighborIdx = append(b.neighborIdx, aIdx)
638 }
639 }
640
641 // Disconnect severs the neighbor relationship between two nodes.
642 func (l *Lattice) Disconnect(a, b *Node) {
643 if a == b {
644 return
645 }
646 a.neighborIdx = removeNeighborIdx(a.neighborIdx, uint32(b.id))
647 b.neighborIdx = removeNeighborIdx(b.neighborIdx, uint32(a.id))
648 }
649
650 // hasNeighborIdx reports whether idx is in the neighbor index slice.
651 func hasNeighborIdx(nbs []uint32, idx uint32) bool {
652 for _, n := range nbs {
653 if n == idx {
654 return true
655 }
656 }
657 return false
658 }
659
660 // removeNeighborIdx removes idx from the slice using swap-remove.
661 func removeNeighborIdx(nbs []uint32, idx uint32) []uint32 {
662 for i, n := range nbs {
663 if n == idx {
664 last := len(nbs) - 1
665 nbs[i] = nbs[last]
666 return nbs[:last]
667 }
668 }
669 return nbs
670 }
671
672 // Node returns a node by ID, or nil if not found.
673 func (l *Lattice) Node(id NodeID) *Node {
674 l.mu.RLock()
675 defer l.mu.RUnlock()
676 idx := int(id)
677 if idx < 0 || idx >= len(l.nodes) {
678 return nil
679 }
680 return l.nodes[idx]
681 }
682
683 // Size returns the number of nodes in the lattice.
684 func (l *Lattice) Size() int {
685 l.mu.RLock()
686 defer l.mu.RUnlock()
687 return len(l.nodes)
688 }
689
690 // Nodes returns all nodes. The caller must not modify the slice.
691 func (l *Lattice) Nodes() []*Node {
692 l.mu.RLock()
693 defer l.mu.RUnlock()
694 return l.nodes
695 }
696
697 // VacantSites returns all nodes that are unoccupied and have constraints.
698 func (l *Lattice) VacantSites() []*Node {
699 l.mu.RLock()
700 defer l.mu.RUnlock()
701 var sites []*Node
702 for _, n := range l.nodes {
703 if n.occupant == nil && len(n.constraints) > 0 {
704 sites = append(sites, n)
705 }
706 }
707 return sites
708 }
709
710 // Health reports the lattice's structural health metrics.
711 type Health struct {
712 NodeCount int // total nodes
713 Occupied int // nodes with bonded elements
714 Vacant int // nodes available for bonding
715 AvgLockIn ratio.Ratio // average lock-in depth across occupied nodes
716 MaxLockIn ratio.Ratio // deepest lock-in
717 Ambiguous int // nodes with multiple candidate elements
718 OccupancyRate ratio.Ratio // occupied / total [0, 1]
719 AccretionReady int // vacant nodes with energy (ready for growth)
720 DissolveSoft int // occupied nodes with lock-in < 1 (dissolution candidates)
721 }
722
723 // Health computes the current structural health of the lattice.
724 func (l *Lattice) Health() Health {
725 l.mu.RLock()
726 defer l.mu.RUnlock()
727
728 h := Health{NodeCount: len(l.nodes)}
729 totalLockIn := ratio.Zero
730
731 for _, n := range l.nodes {
732 if n.occupant != nil {
733 h.Occupied++
734 totalLockIn = totalLockIn.Add(n.lockIn)
735 if h.MaxLockIn.Less(n.lockIn) {
736 h.MaxLockIn = n.lockIn
737 }
738 if n.lockIn.Less(ratio.One) {
739 h.DissolveSoft++
740 }
741 } else if len(n.constraints) > 0 {
742 h.Vacant++
743 if n.hex.Inner().Energy() {
744 h.AccretionReady++
745 }
746 }
747 if len(n.candidates) > 1 {
748 h.Ambiguous++
749 }
750 }
751
752 if h.Occupied > 0 {
753 h.AvgLockIn = totalLockIn.Div(ratio.FromInt(int64(h.Occupied)))
754 }
755 if h.NodeCount > 0 {
756 h.OccupancyRate = ratio.New(int64(h.Occupied), int64(h.NodeCount))
757 }
758
759 return h
760 }
761
762 // ClearOccupants removes all occupants and resets bond counts across the
763 // lattice while preserving topology (nodes, edges) and constraints.
764 func (l *Lattice) ClearOccupants() {
765 l.mu.Lock()
766 defer l.mu.Unlock()
767 for _, n := range l.nodes {
768 n.occupant = nil
769 n.candidates = nil
770 n.bondCount = 0
771 n.lockIn = ratio.Zero
772 n.age = 0
773 }
774 if wc := l.walkCols; wc != nil {
775 clear(wc.Occupied)
776 for i := range wc.LockInNum {
777 wc.LockInNum[i] = 0
778 wc.LockInDenom[i] = 1
779 }
780 }
781 l.vacantIdx = make(map[string][]NodeID, len(l.vacantIdx))
782 for _, n := range l.nodes {
783 for _, c := range n.constraints {
784 l.vacantIdx[c.Tag()] = append(l.vacantIdx[c.Tag()], n.id)
785 }
786 }
787 }
788
789 // RandomNode returns a random node from the lattice for walk initialization.
790 func (l *Lattice) RandomNode() *Node {
791 l.mu.RLock()
792 defer l.mu.RUnlock()
793 if len(l.nodes) == 0 {
794 return nil
795 }
796 return l.nodes[rand.IntN(len(l.nodes))]
797 }
798
799 // VacantByTag returns a random vacant node whose constraint envelope
800 // matches the given tag.
801 func (l *Lattice) VacantByTag(tag string) *Node {
802 l.mu.Lock()
803 defer l.mu.Unlock()
804
805 ids := l.vacantIdx[tag]
806 if len(ids) == 0 {
807 return nil
808 }
809
810 for attempts := len(ids); attempts > 0 && len(ids) > 0; attempts-- {
811 idx := rand.IntN(len(ids))
812 nid := ids[idx]
813 if int(nid) >= len(l.nodes) {
814 ids[idx] = ids[len(ids)-1]
815 ids = ids[:len(ids)-1]
816 l.vacantIdx[tag] = ids
817 continue
818 }
819 n := l.nodes[nid]
820 if n.occupant == nil && n.constraints != nil {
821 return n
822 }
823 ids[idx] = ids[len(ids)-1]
824 ids = ids[:len(ids)-1]
825 l.vacantIdx[tag] = ids
826 }
827 return nil
828 }
829
830 // ReindexVacant re-registers a node in the vascular index after its
831 // occupant was dissolved.
832 func (l *Lattice) ReindexVacant(n *Node) {
833 l.mu.Lock()
834 defer l.mu.Unlock()
835 for _, c := range n.constraints {
836 tag := c.Tag()
837 l.vacantIdx[tag] = append(l.vacantIdx[tag], n.id)
838 }
839 }
840
841 // RandomNeighbor returns a random neighbor of the given node.
842 func RandomNeighbor(n *Node) *Node {
843 if len(n.neighborIdx) == 0 {
844 return nil
845 }
846 return n.lat.nodes[n.neighborIdx[rand.IntN(len(n.neighborIdx))]]
847 }
848
849 // SetEnergy updates the energy bit of a node's inner trigram.
850 func (n *Node) SetEnergy(supersaturated bool) {
851 n.hex = state.Hex(n.hex.Inner().SetEnergy(supersaturated), n.hex.Outer())
852 }
853
854 // SetOuterTrigram updates the node's outer trigram based on its
855 // environment (computed from neighbor states).
856 func (n *Node) SetOuterTrigram(outer state.Trigram) {
857 n.hex = state.Hex(n.hex.Inner(), outer)
858 }
859
860 // BondCount returns the number of constraints satisfied by the current occupant.
861 func (n *Node) BondCount() int {
862 return n.bondCount
863 }
864
865 // IncrementBond increases the bond count by 1 (Long-Term Potentiation).
866 // Updates lockIn to reflect the new bond strength.
867 func (n *Node) IncrementBond() {
868 if n.occupant == nil {
869 return
870 }
871 n.bondCount++
872 n.lockIn = ratio.FromInt(int64(n.bondCount))
873 n.updateHex()
874 if wc := n.lat.walkCols; wc != nil {
875 wc.SetLockIn(n.id, n.lockIn.Num, n.lockIn.Denom)
876 }
877 }
878
879 // DecrementBond decreases the bond count by 1 (Long-Term Depression).
880 // Bond count cannot go below 0. Updates lockIn accordingly.
881 func (n *Node) DecrementBond() {
882 if n.occupant == nil || n.bondCount <= 0 {
883 return
884 }
885 n.bondCount--
886 n.lockIn = ratio.FromInt(int64(n.bondCount))
887 n.updateHex()
888 if wc := n.lat.walkCols; wc != nil {
889 wc.SetLockIn(n.id, n.lockIn.Num, n.lockIn.Denom)
890 }
891 }
892
893 // RestoreAge sets the node's age directly (bypass increment logic).
894 func (n *Node) RestoreAge(age uint8) {
895 if age > 3 {
896 age = 3
897 }
898 n.age = age
899 }
900
901 // ForceOccupant places an element without checking constraints.
902 // Used during mindsicle thaw where the state has been pre-validated.
903 func (n *Node) ForceOccupant(e axiom.Element, bondCount int) {
904 n.occupant = e
905 n.bondCount = bondCount
906 n.lockIn = ratio.FromInt(int64(bondCount))
907 n.age = 0
908 n.updateHex()
909 if wc := n.lat.walkCols; wc != nil {
910 wc.SetOccupied(n.id, true)
911 wc.SetLockIn(n.id, n.lockIn.Num, n.lockIn.Denom)
912 }
913 }
914
915 // RestoreLockIn sets the exact lock-in ratio from a frozen snapshot.
916 func (n *Node) RestoreLockIn(li ratio.Ratio) {
917 n.lockIn = li
918 if wc := n.lat.walkCols; wc != nil {
919 wc.SetLockIn(n.id, li.Num, li.Denom)
920 }
921 }
922
923 // --- Backward-compatible aliases for block-decomposed growth ---
924 // These used to skip mutex acquisition. Now that Node has no mutex,
925 // they are identical to the standard methods. Kept as aliases so
926 // existing callers in pkg/grow compile without changes.
927
928 // OccupiedUnsafe checks occupancy (alias for Occupied).
929 func (n *Node) OccupiedUnsafe() bool { return n.occupant != nil }
930
931 // NeighborsUnsafe returns the neighbor slice (alias for Neighbors).
932 func (n *Node) NeighborsUnsafe() []*Node { return n.Neighbors() }
933
934 // AdmitsUnsafe checks constraint satisfaction (alias for Admits).
935 func (n *Node) AdmitsUnsafe(e axiom.Element) bool { return n.Admits(e) }
936
937 // BondUnsafe places an element (alias for Bond).
938 func (n *Node) BondUnsafe(e axiom.Element) bool { return n.Bond(e) }
939
940 // StripForEvictionUnsafe zeros all node state except id and neighbors.
941 func (n *Node) StripForEvictionUnsafe() {
942 n.occupant = nil
943 n.candidates = nil
944 n.constraints = nil
945 n.bondCount = 0
946 n.crossLayer = false
947 n.lockIn = ratio.Zero
948 n.perm = 0
949 n.projVertex = 0
950 n.projKey = 0
951 n.projPath = 0
952 n.age = 0
953 n.hex = 0
954 }
955
956 // RestoreConstraintsUnsafe sets the constraint envelope on a stripped node.
957 func (n *Node) RestoreConstraintsUnsafe(constraints []axiom.Constraint) {
958 n.constraints = constraints
959 }
960
961 // NeighborStates returns a summary trigram of the neighborhood.
962 // Majority vote on each bit across all neighbors.
963 func (n *Node) NeighborStates() state.Trigram {
964 if len(n.neighborIdx) == 0 {
965 return state.Earth
966 }
967
968 var bonding, constraint, energy int
969 total := len(n.neighborIdx)
970 for _, idx := range n.neighborIdx {
971 nb := n.lat.nodes[idx]
972 inner := nb.hex.Inner()
973 if inner.Bonding() {
974 bonding++
975 }
976 if inner.Constraint() {
977 constraint++
978 }
979 if inner.Energy() {
980 energy++
981 }
982 }
983
984 return state.Trigram(0).
985 SetBonding(bonding > total/2).
986 SetConstraint(constraint > total/2).
987 SetEnergy(energy > total/2)
988 }
989