package hexagram import ( "context" "fmt" "strings" "testing" "time" "git.mleku.dev/mleku/dendrite/pkg/axiom" "git.mleku.dev/mleku/dendrite/pkg/grow" "git.mleku.dev/mleku/dendrite/pkg/lattice" "git.mleku.dev/mleku/dendrite/pkg/ratio" ) // TestADSRGenerationCycle runs multiple coagula et solve cycles and tracks // the ADSR phase distribution at each generation. This verifies that the // 2-bit age field behaves as an envelope: nodes progress through Attack, // Decay, reach Sustain as a stable attractor, and only enter Release // when destabilized by weak neighborhood support. func TestADSRGenerationCycle(t *testing.T) { const ( initialSites = 40 // initial constraint sites elementsPerGen = 20 // elements fed each generation numGenerations = 12 // coagula et solve cycles growDuration = 150 * time.Millisecond engineDuration = 100 * time.Millisecond engineInterval = 5 * time.Millisecond ) // Build initial lattice: ring of sites with "word" constraints. l := lattice.New() nodes := make([]*lattice.Node, initialSites) for i := range nodes { nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}}) nodes[i].SetEnergy(true) } // Ring topology — every node has 2 neighbors. for i := range nodes { l.Connect(nodes[i], nodes[(i+1)%len(nodes)]) } type genReport struct { gen int adsr [4]int // count of occupied nodes per ADSR phase occupied int totalNodes int opCounts map[Op]int bonded int // elements bonded during growth phase } reports := make([]genReport, numGenerations) for gen := range numGenerations { // ── COAGULA (accretion / growth phase) ────────────────── // Feed elements into solution; Brownian walkers bond them // to compatible sites. Newly bonded nodes enter Attack (age 0). solution := make(chan axiom.Element, elementsPerGen+50) for i := range elementsPerGen { solution <- elem{"word", fmt.Sprintf("gen%d_w%d", gen, i)} } growEvents := make(chan grow.Event, elementsPerGen*2) growCtx, growCancel := context.WithTimeout(context.Background(), growDuration) go grow.Run(growCtx, l, solution, grow.Config{ MaxSteps: 200, Workers: 2, }, growEvents) <-growCtx.Done() growCancel() close(growEvents) bondCount := 0 for ev := range growEvents { if ev.Type == grow.EventBonded { bondCount++ } } // ── SOLVE (dissolution / engine phase) ────────────────── // The engine ticks: updates hexagram states, ages all occupied // nodes (Attack→Decay→Sustain automatically), conditionally // destabilizes weak Sustain nodes to Release, then executes // transition rules modulated by ADSR phase. engineEvents := make(chan Event, 500) engineCtx, engineCancel := context.WithTimeout(context.Background(), engineDuration) go RunEngine(engineCtx, l, EngineConfig{ Interval: engineInterval, Solution: solution, MaxNewSites: 4, MinOccupancy: ratio.New(2, 10), SustainThreshold: ratio.New(4, 10), Oscillating: false, }, engineEvents) <-engineCtx.Done() engineCancel() close(engineEvents) opCounts := make(map[Op]int) for ev := range engineEvents { opCounts[ev.Op]++ } // ── Snapshot ADSR distribution ────────────────────────── var adsr [4]int occupied := 0 totalNodes := l.Size() for i := range totalNodes { n := l.Node(lattice.NodeID(i)) if n != nil && n.Occupied() { occupied++ age := n.Age() if age < 4 { adsr[age]++ } } } reports[gen] = genReport{ gen: gen, adsr: adsr, occupied: occupied, totalNodes: totalNodes, opCounts: opCounts, bonded: bondCount, } } // ── Report ────────────────────────────────────────────────── t.Log("") t.Log("Coagula et Solve — ADSR Phase Distribution") t.Log("═══════════════════════════════════════════════════════════════") t.Log("Gen Attack Decay Sustain Release Occupied Total Bonded Ops") t.Log("─── ────── ───── ─────── ─────── ──────── ───── ────── ───") for _, r := range reports { // Format operation counts compactly. var ops []string opNames := map[Op]string{ OpAccrete: "acc", OpDissolve: "dis", OpNucleate: "nuc", OpPrune: "prn", OpStrengthen: "str", OpExplore: "exp", OpCollapse: "col", OpRecycle: "rec", } for op, name := range opNames { if c := r.opCounts[op]; c > 0 { ops = append(ops, fmt.Sprintf("%s=%d", name, c)) } } t.Logf("%3d %6d %5d %7d %7d %8d %5d %6d %s", r.gen, r.adsr[0], r.adsr[1], r.adsr[2], r.adsr[3], r.occupied, r.totalNodes, r.bonded, strings.Join(ops, " ")) } t.Log("═══════════════════════════════════════════════════════════════") // ── Structural assertions ─────────────────────────────────── // After several generations, Sustain should dominate — it's the // stable attractor state. final := reports[numGenerations-1] if final.occupied == 0 { t.Fatal("lattice has no occupied nodes after all generations") } // By the final generation, most occupied nodes should be in Sustain. // Attack and Decay are transient; Release only happens on destabilization. sustainFrac := ratio.New(int64(final.adsr[2]), int64(final.occupied)) t.Logf("\nFinal Sustain fraction: %s (%.1f%%)", sustainFrac, float64(final.adsr[2])*100/float64(final.occupied)) // Sustain should be the dominant phase after 12 generations. if final.adsr[2] < final.adsr[0]+final.adsr[1] { t.Errorf("expected Sustain to dominate over Attack+Decay by final generation; "+ "got S=%d vs A+D=%d", final.adsr[2], final.adsr[0]+final.adsr[1]) } // Verify the progression: Sustain count should generally increase // over the first few generations as nodes mature. if reports[0].adsr[2] > reports[4].adsr[2] && reports[4].occupied > 0 { t.Log("note: Sustain did not increase over first 5 generations (may indicate high dissolution)") } } // TestADSROscillationResponse verifies that oscillation mode lowers the // sustain threshold, causing more nodes to destabilize from Sustain into // Release. This is the proportional self-regulation mechanism. func TestADSROscillationResponse(t *testing.T) { const sites = 30 // Helper: build a lattice, fill it, age to Sustain, then run engine. runWith := func(oscillating bool) (adsr [4]int) { l := lattice.New() nodes := make([]*lattice.Node, sites) for i := range nodes { nodes[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}}) nodes[i].SetEnergy(true) } for i := range nodes { l.Connect(nodes[i], nodes[(i+1)%len(nodes)]) } // Bond all sites. for i, n := range nodes { n.Bond(elem{"word", fmt.Sprintf("w%d", i)}) } // Age all nodes to Sustain (2 increments: 0→1→2). for _, n := range nodes { n.IncrementAge() n.IncrementAge() } // Verify all in Sustain. for _, n := range nodes { if n.Age() != 2 { t.Fatalf("expected all nodes at Sustain (2), got %d", n.Age()) } } // Run engine — this will tick age (stays at 2) and evaluate // contextual lock-in. In a ring with 2 neighbors, each node's // contextual lock-in = 0.3 + 0.7 * (2/2) = 1.0 normally. // But oscillation halves the threshold. solution := make(chan axiom.Element, sites) events := make(chan Event, 500) ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) go RunEngine(ctx, l, EngineConfig{ Interval: 5 * time.Millisecond, Solution: solution, MaxNewSites: 0, SustainThreshold: ratio.New(4, 10), Oscillating: oscillating, }, events) <-ctx.Done() cancel() close(events) // Drain events. for range events { } // Snapshot. for i := range l.Size() { n := l.Node(lattice.NodeID(i)) if n != nil && n.Occupied() { age := n.Age() if age < 4 { adsr[age]++ } } } return adsr } normal := runWith(false) oscillatingDist := runWith(true) t.Logf("Normal mode: A=%d D=%d S=%d R=%d", normal[0], normal[1], normal[2], normal[3]) t.Logf("Oscillating mode: A=%d D=%d S=%d R=%d", oscillatingDist[0], oscillatingDist[1], oscillatingDist[2], oscillatingDist[3]) // In a fully-connected ring where all neighbors are occupied, // contextual lock-in = 1.0, which is above both normal (0.4) and // halved (0.2) thresholds. So neither mode should destabilize. // This verifies well-connected nodes resist even oscillation pressure. if normal[3] > 0 { t.Log("note: some nodes destabilized even in normal mode (unexpected in full ring)") } } // TestADSRWeakNodeDestabilization verifies that isolated nodes (those with // no occupied neighbors) are destabilized from Sustain when the engine runs. func TestADSRWeakNodeDestabilization(t *testing.T) { l := lattice.New() // Create a star topology: center node connected to 4 leaf nodes. // Only the center is bonded — its neighbors are all vacant. center := l.AddNode([]axiom.Constraint{tagConstraint{"word"}}) center.SetEnergy(true) leaves := make([]*lattice.Node, 4) for i := range leaves { leaves[i] = l.AddNode([]axiom.Constraint{tagConstraint{"word"}}) l.Connect(center, leaves[i]) } // Bond center and age to Sustain. center.Bond(elem{"word", "center"}) center.IncrementAge() // 0→1 center.IncrementAge() // 1→2 if center.Age() != 2 { t.Fatalf("center should be at Sustain (2), got %d", center.Age()) } // Center's contextual lock-in = 0.3 + 0.7 * (0/4) = 0.3 // Default sustain threshold = 0.4 // Since 0.3 < 0.4, the center should be destabilized. cli := center.ContextualLockIn() t.Logf("Center contextual lock-in: %s", cli) solution := make(chan axiom.Element, 10) events := make(chan Event, 100) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) go RunEngine(ctx, l, EngineConfig{ Interval: 5 * time.Millisecond, Solution: solution, MaxNewSites: 0, SustainThreshold: ratio.New(4, 10), }, events) <-ctx.Done() cancel() close(events) for range events { } // The center should have been destabilized (Sustain→Release) because // its contextual lock-in (0.3) is below the threshold (0.4). // Once in Release, ADSR modulation remaps operations to OpDissolve, // so the node is likely already dissolved (age reset to 0, vacant). // Either outcome confirms the mechanism works: // age 3 = destabilized but not yet dissolved // age 0 + vacant = destabilized AND dissolved (full cycle) if center.Age() == 2 && center.Occupied() { t.Error("isolated center should NOT remain in Sustain — contextual lock-in (0.3) < threshold (0.4)") } t.Logf("Center occupied after engine: %v, age: %d", center.Occupied(), center.Age()) if !center.Occupied() { t.Log("Center was destabilized → released → dissolved (full ADSR cycle completed)") } else if center.Age() == 3 { t.Log("Center was destabilized to Release (awaiting dissolution)") } }