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