grow.go raw

   1  // Package grow implements the core growth loop: Brownian walk over the
   2  // lattice, constraint capture, and typed bonding. This is the inner loop
   3  // where elements from solution find their lattice sites.
   4  //
   5  // Each walker is a goroutine — cheap, numerous, uncoordinated. The lattice
   6  // structure does the work, not the walkers.
   7  package grow
   8  
   9  import (
  10  	"context"
  11  	"runtime"
  12  	"sync"
  13  
  14  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
  15  	"git.mleku.dev/mleku/dendrite/pkg/lattice"
  16  )
  17  
  18  // WorkerCount returns the number of workers to use: NumCPU minus 25%
  19  // headroom for GC, scheduler, and monitoring.
  20  func WorkerCount() int {
  21  	n := runtime.NumCPU()
  22  	w := n - n/4
  23  	if w < 1 {
  24  		w = 1
  25  	}
  26  	return w
  27  }
  28  
  29  // Event records something that happened during growth.
  30  type Event struct {
  31  	Type    EventType
  32  	NodeID  lattice.NodeID
  33  	Element axiom.Element
  34  	Steps   int // walk steps taken before outcome (0 = immediate)
  35  }
  36  
  37  // EventType classifies growth events.
  38  type EventType int
  39  
  40  const (
  41  	EventBonded   EventType = iota // element bonded to a site
  42  	EventRejected                  // element didn't fit anywhere
  43  	EventExpired                   // walk exceeded step budget
  44  	EventDisplaced                 // element displaced a weaker occupant
  45  )
  46  
  47  // Config controls growth parameters.
  48  type Config struct {
  49  	// MaxSteps is the maximum number of walk steps before giving up.
  50  	// This is temperature control — shorter walks mean faster but
  51  	// noisier growth.
  52  	MaxSteps int
  53  
  54  	// Workers is the number of concurrent walkers.
  55  	Workers int
  56  
  57  	// BlockSize is the number of nodes per block for RunBlocked.
  58  	// 0 means DefaultBlockSize (1024). Ignored by Run.
  59  	BlockSize int
  60  
  61  	// MaxRounds is the maximum number of spillover rounds for RunBlocked.
  62  	// 0 means 100 (safety cap). Ignored by Run.
  63  	MaxRounds int
  64  
  65  	// MaxResidentBlocks is the maximum number of blocks held in memory
  66  	// simultaneously. 0 means all blocks stay resident (no paging).
  67  	// When set, blocks are demand-paged: the wavefront walk's spill
  68  	// across a block boundary loads the target from disk and the source
  69  	// block (now workless) gets stripped back to skeleton.
  70  	MaxResidentBlocks int
  71  
  72  	// Displace enables competitive displacement: when a walker finds an
  73  	// occupied site where it would have stronger lock-in than the current
  74  	// occupant, it ejects the occupant and bonds in its place. Off by
  75  	// default — existing growth behavior is unchanged.
  76  	Displace bool
  77  
  78  	// BlockDir is the directory for block snapshot files. Required when
  79  	// MaxResidentBlocks > 0. Each block is serialized as a separate file.
  80  	BlockDir string
  81  
  82  	// ConstraintFactory reconstructs axiom.Constraint objects from tag
  83  	// strings during block thaw. Required when MaxResidentBlocks > 0.
  84  	ConstraintFactory func(string) axiom.Constraint
  85  }
  86  
  87  // DefaultConfig returns reasonable defaults.
  88  // Workers is NumCPU - NumCPU/4: leaves 25% headroom for GC and scheduler.
  89  func DefaultConfig() Config {
  90  	return Config{
  91  		MaxSteps: 1000,
  92  		Workers:  WorkerCount(),
  93  	}
  94  }
  95  
  96  // Run starts the growth loop. It reads elements from the solution channel,
  97  // launches walkers to find lattice sites, and reports events. It blocks
  98  // until the context is cancelled or the solution channel is closed.
  99  func Run(ctx context.Context, l *lattice.Lattice, solution <-chan axiom.Element, cfg Config, events chan<- Event) {
 100  	var wg sync.WaitGroup
 101  
 102  	// Worker pool — each worker is a Brownian walker.
 103  	for range cfg.Workers {
 104  		wg.Add(1)
 105  		go func() {
 106  			defer wg.Done()
 107  			for {
 108  				select {
 109  				case <-ctx.Done():
 110  					return
 111  				case elem, ok := <-solution:
 112  					if !ok {
 113  						return
 114  					}
 115  					ev := walk(ctx, l, elem, cfg.MaxSteps, cfg.Displace)
 116  					select {
 117  					case events <- ev:
 118  					case <-ctx.Done():
 119  						return
 120  					}
 121  				}
 122  			}
 123  		}()
 124  	}
 125  
 126  	wg.Wait()
 127  }
 128  
 129  // walk performs a single Brownian walk for one element, returning an event.
 130  //
 131  // Vascularization: the walker starts at a directed position (near a
 132  // vacant site matching the element's type) rather than a random node.
 133  // This is active transport — the bloodstream carrying molecules to
 134  // receptor sites instead of relying on diffusion through tissue.
 135  //
 136  // Chemotaxis: at each step, the walker checks neighbors for compatible
 137  // sites before taking a random step. It can "smell" compatible sites
 138  // one hop away and moves toward regions with more vacant neighbors.
 139  func walk(ctx context.Context, l *lattice.Lattice, elem axiom.Element, maxSteps int, displace bool) Event {
 140  	// Directed start: try to begin near a compatible vacant site.
 141  	// This is the vascular system — routing to demand, not diffusing.
 142  	current := l.VacantByTag(elem.Type())
 143  	if current == nil {
 144  		current = l.RandomNode()
 145  	}
 146  	if current == nil {
 147  		return Event{Type: EventRejected, Element: elem}
 148  	}
 149  
 150  	for step := 0; step < maxSteps; step++ {
 151  		// Check context.
 152  		select {
 153  		case <-ctx.Done():
 154  			return Event{Type: EventExpired, Element: elem, Steps: step}
 155  		default:
 156  		}
 157  
 158  		// Does this site admit the element?
 159  		if current.Admits(elem) {
 160  			if current.Bond(elem) {
 161  				return Event{
 162  					Type:    EventBonded,
 163  					NodeID:  current.ID(),
 164  					Element: elem,
 165  					Steps:   step,
 166  				}
 167  			}
 168  			// Bond failed (race — another walker got it). Keep walking.
 169  		} else if displace && current.Occupied() {
 170  			// Site is occupied — try competitive displacement.
 171  			if _, ok := current.Displace(elem); ok {
 172  				return Event{
 173  					Type:    EventDisplaced,
 174  					NodeID:  current.ID(),
 175  					Element: elem,
 176  					Steps:   step,
 177  				}
 178  			}
 179  		}
 180  
 181  		// Chemotaxis: check neighbors before taking a random step.
 182  		// Like a molecule following a concentration gradient toward
 183  		// a receptor — if a compatible site is one hop away, go there.
 184  		neighbors := current.Neighbors()
 185  		if len(neighbors) > 0 {
 186  			// Phase 1: immediate capture — any neighbor that admits?
 187  			bonded := false
 188  			for _, nb := range neighbors {
 189  				if nb.Admits(elem) {
 190  					if nb.Bond(elem) {
 191  						return Event{
 192  							Type:    EventBonded,
 193  							NodeID:  nb.ID(),
 194  							Element: elem,
 195  							Steps:   step,
 196  						}
 197  					}
 198  					bonded = true // race lost, but the site type is right
 199  				} else if displace && nb.Occupied() {
 200  					// Try displacing the neighbor's occupant.
 201  					if _, ok := nb.Displace(elem); ok {
 202  						return Event{
 203  							Type:    EventDisplaced,
 204  							NodeID:  nb.ID(),
 205  							Element: elem,
 206  							Steps:   step,
 207  						}
 208  					}
 209  				}
 210  			}
 211  
 212  			// Phase 2: gradient following — prefer neighbors near
 213  			// vacant space. Score each neighbor by how many of its
 214  			// own neighbors are vacant. Move toward the highest score.
 215  			if !bonded {
 216  				bestScore := -1
 217  				var best *lattice.Node
 218  				for _, nb := range neighbors {
 219  					score := 0
 220  					for _, nnb := range nb.Neighbors() {
 221  						if !nnb.Occupied() {
 222  							score++
 223  						}
 224  					}
 225  					if score > bestScore {
 226  						bestScore = score
 227  						best = nb
 228  					}
 229  				}
 230  				if best != nil && bestScore > 0 {
 231  					current = best
 232  					continue
 233  				}
 234  			}
 235  		}
 236  
 237  		// No gradient detected — fall back to random walk.
 238  		next := lattice.RandomNeighbor(current)
 239  		if next == nil {
 240  			// Dead end — jump to a random node (long-range hop).
 241  			next = l.RandomNode()
 242  			if next == nil {
 243  				return Event{Type: EventRejected, Element: elem}
 244  			}
 245  		}
 246  		current = next
 247  	}
 248  
 249  	return Event{Type: EventExpired, Element: elem, Steps: maxSteps}
 250  }
 251