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