adsr_test.go raw
1 package hexagram
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8 "time"
9
10 "git.mleku.dev/mleku/dendrite/pkg/axiom"
11 "git.mleku.dev/mleku/dendrite/pkg/grow"
12 "git.mleku.dev/mleku/dendrite/pkg/lattice"
13 "git.mleku.dev/mleku/dendrite/pkg/ratio"
14 )
15
16 // TestADSRGenerationCycle runs multiple coagula et solve cycles and tracks
17 // the ADSR phase distribution at each generation. This verifies that the
18 // 2-bit age field behaves as an envelope: nodes progress through Attack,
19 // Decay, reach Sustain as a stable attractor, and only enter Release
20 // when destabilized by weak neighborhood support.
21 func TestADSRGenerationCycle(t *testing.T) {
22 const (
23 initialSites = 40 // initial constraint sites
24 elementsPerGen = 20 // elements fed each generation
25 numGenerations = 12 // coagula et solve cycles
26 growDuration = 150 * time.Millisecond
27 engineDuration = 100 * time.Millisecond
28 engineInterval = 5 * time.Millisecond
29 )
30
31 // Build initial lattice: ring of sites with "word" constraints.
32 l := lattice.New()
33 nodes := make([]*lattice.Node, initialSites)
34 for i := range nodes {
35 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
36 nodes[i].SetEnergy(true)
37 }
38 // Ring topology — every node has 2 neighbors.
39 for i := range nodes {
40 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
41 }
42
43 type genReport struct {
44 gen int
45 adsr [4]int // count of occupied nodes per ADSR phase
46 occupied int
47 totalNodes int
48 opCounts map[Op]int
49 bonded int // elements bonded during growth phase
50 }
51
52 reports := make([]genReport, numGenerations)
53
54 for gen := range numGenerations {
55 // ── COAGULA (accretion / growth phase) ──────────────────
56 // Feed elements into solution; Brownian walkers bond them
57 // to compatible sites. Newly bonded nodes enter Attack (age 0).
58 solution := make(chan axiom.Element, elementsPerGen+50)
59 for i := range elementsPerGen {
60 solution <- elem{"word", fmt.Sprintf("gen%d_w%d", gen, i)}
61 }
62
63 growEvents := make(chan grow.Event, elementsPerGen*2)
64 growCtx, growCancel := context.WithTimeout(context.Background(), growDuration)
65
66 go grow.Run(growCtx, l, solution, grow.Config{
67 MaxSteps: 200,
68 Workers: 2,
69 }, growEvents)
70
71 <-growCtx.Done()
72 growCancel()
73 close(growEvents)
74
75 bondCount := 0
76 for ev := range growEvents {
77 if ev.Type == grow.EventBonded {
78 bondCount++
79 }
80 }
81
82 // ── SOLVE (dissolution / engine phase) ──────────────────
83 // The engine ticks: updates hexagram states, ages all occupied
84 // nodes (Attack→Decay→Sustain automatically), conditionally
85 // destabilizes weak Sustain nodes to Release, then executes
86 // transition rules modulated by ADSR phase.
87 engineEvents := make(chan Event, 500)
88 engineCtx, engineCancel := context.WithTimeout(context.Background(), engineDuration)
89
90 go RunEngine(engineCtx, l, EngineConfig{
91 Interval: engineInterval,
92 Solution: solution,
93 MaxNewSites: 4,
94 MinOccupancy: ratio.New(2, 10),
95 SustainThreshold: ratio.New(4, 10),
96 Oscillating: false,
97 }, engineEvents)
98
99 <-engineCtx.Done()
100 engineCancel()
101 close(engineEvents)
102
103 opCounts := make(map[Op]int)
104 for ev := range engineEvents {
105 opCounts[ev.Op]++
106 }
107
108 // ── Snapshot ADSR distribution ──────────────────────────
109 var adsr [4]int
110 occupied := 0
111 totalNodes := l.Size()
112 for i := range totalNodes {
113 n := l.Node(lattice.NodeID(i))
114 if n != nil && n.Occupied() {
115 occupied++
116 age := n.Age()
117 if age < 4 {
118 adsr[age]++
119 }
120 }
121 }
122
123 reports[gen] = genReport{
124 gen: gen,
125 adsr: adsr,
126 occupied: occupied,
127 totalNodes: totalNodes,
128 opCounts: opCounts,
129 bonded: bondCount,
130 }
131 }
132
133 // ── Report ──────────────────────────────────────────────────
134 t.Log("")
135 t.Log("Coagula et Solve — ADSR Phase Distribution")
136 t.Log("═══════════════════════════════════════════════════════════════")
137 t.Log("Gen Attack Decay Sustain Release Occupied Total Bonded Ops")
138 t.Log("─── ────── ───── ─────── ─────── ──────── ───── ────── ───")
139
140 for _, r := range reports {
141 // Format operation counts compactly.
142 var ops []string
143 opNames := map[Op]string{
144 OpAccrete: "acc",
145 OpDissolve: "dis",
146 OpNucleate: "nuc",
147 OpPrune: "prn",
148 OpStrengthen: "str",
149 OpExplore: "exp",
150 OpCollapse: "col",
151 OpRecycle: "rec",
152 }
153 for op, name := range opNames {
154 if c := r.opCounts[op]; c > 0 {
155 ops = append(ops, fmt.Sprintf("%s=%d", name, c))
156 }
157 }
158
159 t.Logf("%3d %6d %5d %7d %7d %8d %5d %6d %s",
160 r.gen,
161 r.adsr[0], r.adsr[1], r.adsr[2], r.adsr[3],
162 r.occupied, r.totalNodes,
163 r.bonded,
164 strings.Join(ops, " "))
165 }
166 t.Log("═══════════════════════════════════════════════════════════════")
167
168 // ── Structural assertions ───────────────────────────────────
169 // After several generations, Sustain should dominate — it's the
170 // stable attractor state.
171 final := reports[numGenerations-1]
172
173 if final.occupied == 0 {
174 t.Fatal("lattice has no occupied nodes after all generations")
175 }
176
177 // By the final generation, most occupied nodes should be in Sustain.
178 // Attack and Decay are transient; Release only happens on destabilization.
179 sustainFrac := ratio.New(int64(final.adsr[2]), int64(final.occupied))
180 t.Logf("\nFinal Sustain fraction: %s (%.1f%%)",
181 sustainFrac, float64(final.adsr[2])*100/float64(final.occupied))
182
183 // Sustain should be the dominant phase after 12 generations.
184 if final.adsr[2] < final.adsr[0]+final.adsr[1] {
185 t.Errorf("expected Sustain to dominate over Attack+Decay by final generation; "+
186 "got S=%d vs A+D=%d", final.adsr[2], final.adsr[0]+final.adsr[1])
187 }
188
189 // Verify the progression: Sustain count should generally increase
190 // over the first few generations as nodes mature.
191 if reports[0].adsr[2] > reports[4].adsr[2] && reports[4].occupied > 0 {
192 t.Log("note: Sustain did not increase over first 5 generations (may indicate high dissolution)")
193 }
194 }
195
196 // TestADSROscillationResponse verifies that oscillation mode lowers the
197 // sustain threshold, causing more nodes to destabilize from Sustain into
198 // Release. This is the proportional self-regulation mechanism.
199 func TestADSROscillationResponse(t *testing.T) {
200 const sites = 30
201
202 // Helper: build a lattice, fill it, age to Sustain, then run engine.
203 runWith := func(oscillating bool) (adsr [4]int) {
204 l := lattice.New()
205 nodes := make([]*lattice.Node, sites)
206 for i := range nodes {
207 nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
208 nodes[i].SetEnergy(true)
209 }
210 for i := range nodes {
211 l.Connect(nodes[i], nodes[(i+1)%len(nodes)])
212 }
213
214 // Bond all sites.
215 for i, n := range nodes {
216 n.Bond(elem{"word", fmt.Sprintf("w%d", i)})
217 }
218
219 // Age all nodes to Sustain (2 increments: 0→1→2).
220 for _, n := range nodes {
221 n.IncrementAge()
222 n.IncrementAge()
223 }
224
225 // Verify all in Sustain.
226 for _, n := range nodes {
227 if n.Age() != 2 {
228 t.Fatalf("expected all nodes at Sustain (2), got %d", n.Age())
229 }
230 }
231
232 // Run engine — this will tick age (stays at 2) and evaluate
233 // contextual lock-in. In a ring with 2 neighbors, each node's
234 // contextual lock-in = 0.3 + 0.7 * (2/2) = 1.0 normally.
235 // But oscillation halves the threshold.
236 solution := make(chan axiom.Element, sites)
237 events := make(chan Event, 500)
238 ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
239
240 go RunEngine(ctx, l, EngineConfig{
241 Interval: 5 * time.Millisecond,
242 Solution: solution,
243 MaxNewSites: 0,
244 SustainThreshold: ratio.New(4, 10),
245 Oscillating: oscillating,
246 }, events)
247
248 <-ctx.Done()
249 cancel()
250 close(events)
251 // Drain events.
252 for range events {
253 }
254
255 // Snapshot.
256 for i := range l.Size() {
257 n := l.Node(lattice.NodeID(i))
258 if n != nil && n.Occupied() {
259 age := n.Age()
260 if age < 4 {
261 adsr[age]++
262 }
263 }
264 }
265 return adsr
266 }
267
268 normal := runWith(false)
269 oscillatingDist := runWith(true)
270
271 t.Logf("Normal mode: A=%d D=%d S=%d R=%d", normal[0], normal[1], normal[2], normal[3])
272 t.Logf("Oscillating mode: A=%d D=%d S=%d R=%d", oscillatingDist[0], oscillatingDist[1], oscillatingDist[2], oscillatingDist[3])
273
274 // In a fully-connected ring where all neighbors are occupied,
275 // contextual lock-in = 1.0, which is above both normal (0.4) and
276 // halved (0.2) thresholds. So neither mode should destabilize.
277 // This verifies well-connected nodes resist even oscillation pressure.
278 if normal[3] > 0 {
279 t.Log("note: some nodes destabilized even in normal mode (unexpected in full ring)")
280 }
281 }
282
283 // TestADSRWeakNodeDestabilization verifies that isolated nodes (those with
284 // no occupied neighbors) are destabilized from Sustain when the engine runs.
285 func TestADSRWeakNodeDestabilization(t *testing.T) {
286 l := lattice.New()
287
288 // Create a star topology: center node connected to 4 leaf nodes.
289 // Only the center is bonded — its neighbors are all vacant.
290 center := l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
291 center.SetEnergy(true)
292 leaves := make([]*lattice.Node, 4)
293 for i := range leaves {
294 leaves[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}})
295 l.Connect(center, leaves[i])
296 }
297
298 // Bond center and age to Sustain.
299 center.Bond(elem{"word", "center"})
300 center.IncrementAge() // 0→1
301 center.IncrementAge() // 1→2
302
303 if center.Age() != 2 {
304 t.Fatalf("center should be at Sustain (2), got %d", center.Age())
305 }
306
307 // Center's contextual lock-in = 0.3 + 0.7 * (0/4) = 0.3
308 // Default sustain threshold = 0.4
309 // Since 0.3 < 0.4, the center should be destabilized.
310 cli := center.ContextualLockIn()
311 t.Logf("Center contextual lock-in: %s", cli)
312
313 solution := make(chan axiom.Element, 10)
314 events := make(chan Event, 100)
315 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
316
317 go RunEngine(ctx, l, EngineConfig{
318 Interval: 5 * time.Millisecond,
319 Solution: solution,
320 MaxNewSites: 0,
321 SustainThreshold: ratio.New(4, 10),
322 }, events)
323
324 <-ctx.Done()
325 cancel()
326 close(events)
327 for range events {
328 }
329
330 // The center should have been destabilized (Sustain→Release) because
331 // its contextual lock-in (0.3) is below the threshold (0.4).
332 // Once in Release, ADSR modulation remaps operations to OpDissolve,
333 // so the node is likely already dissolved (age reset to 0, vacant).
334 // Either outcome confirms the mechanism works:
335 // age 3 = destabilized but not yet dissolved
336 // age 0 + vacant = destabilized AND dissolved (full cycle)
337 if center.Age() == 2 && center.Occupied() {
338 t.Error("isolated center should NOT remain in Sustain — contextual lock-in (0.3) < threshold (0.4)")
339 }
340
341 t.Logf("Center occupied after engine: %v, age: %d", center.Occupied(), center.Age())
342 if !center.Occupied() {
343 t.Log("Center was destabilized → released → dissolved (full ADSR cycle completed)")
344 } else if center.Age() == 3 {
345 t.Log("Center was destabilized to Release (awaiting dissolution)")
346 }
347 }
348