dissolve.go raw
1 // Package dissolve implements continuous equilibrium evaluation and
2 // dissolution of weakly-bound lattice elements.
3 //
4 // Dissolution is the complement of accretion, not its opposite. Both
5 // operate simultaneously, driven by the same thermodynamic gradient.
6 // The lattice persists because accretion dominates dissolution — there
7 // is a net positive growth rate. But dissolution is continuous and
8 // essential: it removes impurities, prunes overextension, and corrects
9 // errors.
10 package dissolve
11
12 import (
13 "context"
14 "math/rand/v2"
15 "time"
16
17 "git.mleku.dev/mleku/dendrite/pkg/axiom"
18 "git.mleku.dev/mleku/dendrite/pkg/lattice"
19 "git.mleku.dev/mleku/dendrite/pkg/ratio"
20 )
21
22 // Event records a dissolution.
23 type Event struct {
24 NodeID lattice.NodeID
25 Element axiom.Element
26 LockIn lattice.LockInDepth
27 Reason Reason
28 }
29
30 // Reason classifies why dissolution occurred.
31 type Reason int
32
33 const (
34 ReasonWeakBond Reason = iota // lock-in below threshold
35 ReasonMetastable // locally stable but globally inconsistent
36 ReasonSenescent // age reached maximum lifespan
37 ReasonCrossLayer // post-hoc: element bonded across layer boundary
38 )
39
40 // Config controls dissolution parameters.
41 type Config struct {
42 // Threshold is the minimum lock-in depth to survive dissolution.
43 // Elements with lock-in below this are released. This is now a
44 // secondary criterion — the primary driver is half-life aging.
45 Threshold lattice.LockInDepth
46
47 // Interval is how often the dissolver scans the lattice.
48 Interval time.Duration
49
50 // MaxAge is the age at which elements are dissolved regardless of
51 // lock-in depth (senescence). 0 means no age limit.
52 // With 2-bit age encoding, the natural maximum is 3.
53 MaxAge uint8
54
55 // HalfLife controls substrate-blind dissolution probability.
56 // Each scan, an element at age A has dissolution probability:
57 // P = A / (HalfLife + A)
58 // At age == HalfLife, P = 0.5 (the element has a 50% chance
59 // of dissolving on each scan). Higher HalfLife means elements
60 // persist longer. Zero means half-life dissolution is disabled
61 // (falls back to pure threshold mode for backward compatibility).
62 //
63 // Biology: hepatic enzymes clear caffeine at a rate determined
64 // by the molecule's half-life, not by whether it was contributing
65 // to useful alertness or to jitter. The clearance mechanism is
66 // substrate-blind.
67 HalfLife uint8
68 }
69
70 // DefaultConfig returns biologically-aligned defaults — half-life driven
71 // dissolution with contextual threshold as secondary criterion.
72 func DefaultConfig() Config {
73 return Config{
74 Threshold: ratio.Half,
75 Interval: 100 * time.Millisecond,
76 HalfLife: 2, // P=0.5 at age 2 (Sustain phase)
77 }
78 }
79
80 // Run starts the continuous dissolution loop. Dissolved elements are sent
81 // to the returned channel (they return to solution). The events channel
82 // receives dissolution records. Blocks until context is cancelled.
83 func Run(ctx context.Context, l *lattice.Lattice, cfg Config, dissolved chan<- axiom.Element, events chan<- Event) {
84 ticker := time.NewTicker(cfg.Interval)
85 defer ticker.Stop()
86
87 for {
88 select {
89 case <-ctx.Done():
90 return
91 case <-ticker.C:
92 scan(l, cfg, dissolved, events)
93 }
94 }
95 }
96
97 // scan evaluates all occupied nodes and dissolves those below threshold.
98 // Sticky elements (implementing axiom.StickyElement with IsSticky() == true)
99 // are immune to dissolution regardless of lock-in depth.
100 func scan(l *lattice.Lattice, cfg Config, dissolved chan<- axiom.Element, events chan<- Event) {
101 for _, n := range l.Nodes() {
102 if !n.Occupied() {
103 continue
104 }
105
106 // Sticky elements survive dissolution unconditionally.
107 if sticky, ok := n.Occupant().(axiom.StickyElement); ok && sticky.IsSticky() {
108 continue
109 }
110
111 // Post-hoc cross-layer detection: biology detects cross-system
112 // bonds through symptoms (tremor, euphoria) and corrects by
113 // dissolution (metabolic clearance). Cross-layer bonds already
114 // have reduced lock-in from Bond(); here they also get
115 // preferential dissolution when they're weak.
116 if n.CrossLayer() {
117 lockIn := n.ContextualLockIn()
118 // Cross-layer bonds dissolve at a lower threshold than
119 // same-layer bonds — they need stronger neighborhood
120 // support to survive. Halve the effective threshold.
121 crossThreshold := cfg.Threshold.Mul(ratio.New(1, 2))
122 if lockIn.Less(crossThreshold) {
123 elem := n.Dissolve()
124 if elem == nil {
125 continue
126 }
127 l.ReindexVacant(n)
128 select {
129 case dissolved <- elem:
130 default:
131 }
132 select {
133 case events <- Event{
134 NodeID: n.ID(),
135 Element: elem,
136 LockIn: lockIn,
137 Reason: ReasonCrossLayer,
138 }:
139 default:
140 }
141 continue
142 }
143 }
144
145 // Half-life dissolution: substrate-blind probabilistic clearance.
146 // Biology: hepatic enzymes don't distinguish "useful caffeine"
147 // from "jittery caffeine." Clearance probability increases with
148 // age: P = age / (halfLife + age). At age == halfLife, P = 0.5.
149 if cfg.HalfLife > 0 {
150 age := n.Age()
151 hl := cfg.HalfLife
152 // P = age / (halfLife + age). Integer comparison is exact:
153 // rand.IntN(hl+age) < age has probability age/(hl+age).
154 if rand.IntN(int(hl+age)) < int(age) {
155 lockIn := n.ContextualLockIn()
156 elem := n.Dissolve()
157 if elem == nil {
158 continue
159 }
160 l.ReindexVacant(n)
161 select {
162 case dissolved <- elem:
163 default:
164 }
165 select {
166 case events <- Event{
167 NodeID: n.ID(),
168 Element: elem,
169 LockIn: lockIn,
170 Reason: ReasonSenescent, // half-life is a form of senescence
171 }:
172 default:
173 }
174 continue
175 }
176 }
177
178 // Senescence: hard age limit — dissolve regardless.
179 if cfg.MaxAge > 0 && n.Age() >= cfg.MaxAge {
180 elem := n.Dissolve()
181 if elem == nil {
182 continue
183 }
184 l.ReindexVacant(n)
185 select {
186 case dissolved <- elem:
187 default:
188 }
189 select {
190 case events <- Event{
191 NodeID: n.ID(),
192 Element: elem,
193 LockIn: n.ContextualLockIn(),
194 Reason: ReasonSenescent,
195 }:
196 default:
197 }
198 continue
199 }
200
201 // Use contextual lock-in: accounts for neighborhood occupancy.
202 // Isolated elements (few occupied neighbors) have reduced lock-in.
203 lockIn := n.ContextualLockIn()
204 if !lockIn.Less(cfg.Threshold) {
205 continue
206 }
207
208 // Below threshold — dissolve.
209 elem := n.Dissolve()
210 if elem == nil {
211 continue // race — someone else dissolved it
212 }
213 l.ReindexVacant(n)
214
215 // Return element to solution.
216 select {
217 case dissolved <- elem:
218 default:
219 // Solution channel full — element is lost.
220 // This is natural: not everything gets recycled.
221 }
222
223 // Report the event.
224 select {
225 case events <- Event{
226 NodeID: n.ID(),
227 Element: elem,
228 LockIn: lockIn,
229 Reason: ReasonWeakBond,
230 }:
231 default:
232 }
233 }
234 }
235
236 // ScanOnce runs a single dissolution pass. Useful for testing and
237 // for explicit annealing steps.
238 func ScanOnce(l *lattice.Lattice, cfg Config, dissolved chan<- axiom.Element, events chan<- Event) {
239 scan(l, cfg, dissolved, events)
240 }
241