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