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