spore.go raw

   1  // Package spore implements sporulation — the mature lattice producing
   2  // compact, portable seeds that can bootstrap new lattice instances.
   3  //
   4  // A spore captures the symmetry group of the lattice: the constraint
   5  // types, their frequency distribution, and the connectivity pattern.
   6  // It does not store individual elements (they are the lattice, not the
   7  // seed). It stores the shape of the negative space — what kinds of
   8  // things can bond and how they relate.
   9  package spore
  10  
  11  import (
  12  	"crypto/sha256"
  13  	"encoding/hex"
  14  	"encoding/json"
  15  	"fmt"
  16  	"io"
  17  	"math/rand/v2"
  18  	"sort"
  19  
  20  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
  21  	"git.mleku.dev/mleku/dendrite/pkg/lattice"
  22  	"git.mleku.dev/mleku/dendrite/pkg/ratio"
  23  )
  24  
  25  // TagCount pairs a tag name with a count.
  26  type TagCount struct {
  27  	Tag   string `json:"tag"`
  28  	Count int    `json:"count"`
  29  }
  30  
  31  // TagRatio pairs a tag name with a rational value.
  32  type TagRatio struct {
  33  	Tag   string      `json:"tag"`
  34  	Value ratio.Ratio `json:"value"`
  35  }
  36  
  37  // PeerMark pairs a peer instance ID with its birthmark.
  38  type PeerMark struct {
  39  	ID   uint32 `json:"id"`
  40  	Mark uint64 `json:"mark"`
  41  }
  42  
  43  // PeerMemEntry is a single entry in long-term peer memory.
  44  type PeerMemEntry struct {
  45  	ID              uint32 `json:"id"`
  46  	Birthmark       uint64 `json:"birthmark"`
  47  	LastSeen        int    `json:"last_seen"`
  48  	FingerprintHash string `json:"fingerprint_hash,omitempty"` // SporeFingerprint.Hash for cross-generation verification
  49  	Accumulator     string `json:"accumulator,omitempty"`      // base64-encoded Cayley accumulator (96 bytes)
  50  }
  51  
  52  // Spore is a minimal, portable representation of a lattice's structure.
  53  // It carries enough information to nucleate a new lattice with the same
  54  // orientation but adapted to new input.
  55  type Spore struct {
  56  	// TypeSignature records constraint tags and their frequency in the
  57  	// source lattice. This is the symmetry group's fingerprint —
  58  	// what types exist and in what proportion.
  59  	TypeSignature []TagCount `json:"type_signature"`
  60  
  61  	// Connectivity records the average neighbor count per type.
  62  	// How densely connected each type layer is.
  63  	Connectivity []TagRatio `json:"connectivity"`
  64  
  65  	// Occupied is the number of sites that had bonded elements.
  66  	Occupied int `json:"occupied"`
  67  
  68  	// TotalNodes in the source lattice at sporulation time.
  69  	TotalNodes int `json:"total_nodes"`
  70  
  71  	// ElementTypes records element type tags and their count.
  72  	// What actually bonded, not just what was available.
  73  	ElementTypes []TagCount `json:"element_types"`
  74  
  75  	// ParentHash is the SHA-256 of the parent spore's JSON encoding.
  76  	// Empty string for first-generation (abiogenesis) spores.
  77  	ParentHash string `json:"parent_hash,omitempty"`
  78  
  79  	// Generation counts how many sporulation cycles preceded this one.
  80  	Generation int `json:"generation"`
  81  
  82  	// Fitness records how well this generation's emitted code
  83  	// reproduces the original. Three dimensions:
  84  	//   Source:  structural AST similarity (0..1)
  85  	//   Binary:  compiled binary similarity (0..1)
  86  	//   Behav:   behavioral equivalence (0..1)
  87  	//   Overall: weighted combination (0..1)
  88  	Fitness *FitnessScore `json:"fitness,omitempty"`
  89  
  90  	// OwnBirthmark is this instance's fingerprint — a uint64 derived
  91  	// from system entropy XOR'd with a hash of the emitted source.
  92  	// Zero when running in single-instance mode.
  93  	OwnBirthmark uint64 `json:"own_birthmark,omitempty"`
  94  
  95  	// PeerBirthmarks records the birthmarks received from other
  96  	// instances in the colony. Sorted by ID.
  97  	PeerBirthmarks []PeerMark `json:"peer_birthmarks,omitempty"`
  98  
  99  	// PeerMemory is long-term memory: accumulated across generations.
 100  	// Each entry records the last known birthmark and the generation
 101  	// it was last seen. This persists through sporulation and
 102  	// germination, giving the organism continuity of relationships.
 103  	// Sorted by ID.
 104  	PeerMemory []PeerMemEntry `json:"peer_memory,omitempty"`
 105  
 106  	// OrganManifests records the organs loaded in this generation.
 107  	// Carried across sporulation so offspring know what organs the
 108  	// parent had available.
 109  	OrganManifests []OrganManifestRecord `json:"organ_manifests,omitempty"`
 110  
 111  	// DirectiveHistory records operator directives applied to this
 112  	// lineage. Sticky directives persist across generations.
 113  	DirectiveHistory []DirectiveRecord `json:"directive_history,omitempty"`
 114  
 115  	// MissingSites records element types that failed to bond during
 116  	// growth — the negative space of the lattice. Maps type tag to
 117  	// rejection count. This tells the organism what structures it
 118  	// lacks to absorb the input.
 119  	MissingSites []TagCount `json:"missing_sites,omitempty"`
 120  
 121  	// StubNames records the identifiers that the compiler flagged as
 122  	// undefined during this generation's compile-repair cycle.
 123  	// Fed back during the next generation's growth as targeted injection.
 124  	StubNames []string `json:"stub_names,omitempty"`
 125  
 126  	// PermDist records how many nodes use each of the 6 S_3
 127  	// permutations (projection angles). Index is permutation.Perm
 128  	// value (0=Identity through 5=Cycle021).
 129  	PermDist [6]int `json:"perm_dist,omitempty"`
 130  
 131  	// ProjDist records how many nodes use each of the 64 projection
 132  	// configurations (3-bit vertex + 3-bit key). Index is the packed
 133  	// 6-bit value: vertex (low 3) | key (high 3).
 134  	ProjDist [64]int `json:"proj_dist,omitempty"`
 135  
 136  	// PathDist records the distribution of rendering path indices
 137  	// across nodes. Key is the path index, value is the count.
 138  	// Only non-zero paths are stored to keep the spore compact.
 139  	PathDist []PathCount `json:"path_dist,omitempty"`
 140  
 141  	// AgeDist records how many occupied nodes are at each age (0-3).
 142  	// Index is the age value: 0=newborn, 1=young, 2=mature, 3=senescent.
 143  	AgeDist [4]int `json:"age_dist,omitempty"`
 144  
 145  	// CryptoTags stores the constraint type tags used to generate
 146  	// the lattice keypair. Together with the constraint factory
 147  	// (provided at runtime), these allow regenerating the private key.
 148  	// Empty when crypto is not enabled.
 149  	CryptoTags []string `json:"crypto_tags,omitempty"`
 150  
 151  	// CryptoParamsLevel stores the security level used for keypair
 152  	// generation: 0 = Security128, 1 = Security192, 2 = Security256.
 153  	CryptoParamsLevel uint8 `json:"crypto_params_level,omitempty"`
 154  
 155  	// DissolutionNoise is a 32-byte hash digest of dissolution events
 156  	// from the generation that produced this spore. Fed back as salt
 157  	// for the next generation's input, breaking input monotony.
 158  	DissolutionNoise [32]byte `json:"dissolution_noise,omitempty"`
 159  
 160  	// OwnAccumulator is the Cayley hash accumulator chain for this
 161  	// instance, base64-encoded (96 bytes → 128 chars). Each generation
 162  	// extends it: A_n = A_{n-1} * CayleyHash(source_n). Used for
 163  	// stable peer recognition without leaking identity.
 164  	OwnAccumulator string `json:"own_accumulator,omitempty"`
 165  }
 166  
 167  // PathCount pairs a rendering path index with a count of nodes using it.
 168  type PathCount struct {
 169  	Path  uint16 `json:"path"`
 170  	Count int    `json:"count"`
 171  }
 172  
 173  // FitnessScore captures the three-dimensional fitness evaluation.
 174  // All fields are exact rationals — no floating-point nondeterminism.
 175  type FitnessScore struct {
 176  	Source  ratio.Ratio `json:"source"`
 177  	Binary  ratio.Ratio `json:"binary"`
 178  	Behav   ratio.Ratio `json:"behav"`
 179  	Overall ratio.Ratio `json:"overall"`
 180  }
 181  
 182  // OrganManifestRecord is a lightweight record of an organ carried in
 183  // a spore. It omits the WASM bytes — those are obtained from peers.
 184  type OrganManifestRecord struct {
 185  	ID        string      `json:"id"`         // hex-encoded OrganID
 186  	Type      string      `json:"type"`       // enzyme, emitter, etc.
 187  	Version   uint64      `json:"version"`
 188  	Size      int         `json:"size"`       // WASM byte count
 189  	Fitness   ratio.Ratio `json:"fitness"`
 190  	SourceGen int         `json:"source_gen"` // generation that produced this organ
 191  }
 192  
 193  // DirectiveRecord captures a directive applied to this lineage.
 194  type DirectiveRecord struct {
 195  	Text       string      `json:"text"`
 196  	Priority   ratio.Ratio `json:"priority"`
 197  	Sticky     bool        `json:"sticky"`
 198  	Generation int         `json:"generation"` // when it was applied
 199  }
 200  
 201  // Hash returns the SHA-256 hex digest of this spore's JSON encoding.
 202  // This is the spore's identity — its fingerprint in the lineage.
 203  func (s *Spore) Hash() string {
 204  	data, _ := json.Marshal(s)
 205  	h := sha256.Sum256(data)
 206  	return hex.EncodeToString(h[:])
 207  }
 208  
 209  // addTagCount increments the count for the given tag, or appends a new entry.
 210  func AddTagCount(s *[]TagCount, tag string) {
 211  	for i := range *s {
 212  		if (*s)[i].Tag == tag {
 213  			(*s)[i].Count++
 214  			return
 215  		}
 216  	}
 217  	*s = append(*s, TagCount{Tag: tag, Count: 1})
 218  }
 219  
 220  // addTagCountN adds n to the count for the given tag, or appends a new entry.
 221  func AddTagCountN(s *[]TagCount, tag string, n int) {
 222  	for i := range *s {
 223  		if (*s)[i].Tag == tag {
 224  			(*s)[i].Count += n
 225  			return
 226  		}
 227  	}
 228  	*s = append(*s, TagCount{Tag: tag, Count: n})
 229  }
 230  
 231  // tagCountValue returns the count for the given tag, or 0 if not found.
 232  func tagCountValue(s []TagCount, tag string) int {
 233  	for _, tc := range s {
 234  		if tc.Tag == tag {
 235  			return tc.Count
 236  		}
 237  	}
 238  	return 0
 239  }
 240  
 241  // tagRatioValue returns the value for the given tag, or zero if not found.
 242  func tagRatioValue(s []TagRatio, tag string) ratio.Ratio {
 243  	for _, tr := range s {
 244  		if tr.Tag == tag {
 245  			return tr.Value
 246  		}
 247  	}
 248  	return ratio.Zero
 249  }
 250  
 251  // addPathCount increments the count for the given path index.
 252  func addPathCount(s *[]PathCount, path uint16) {
 253  	for i := range *s {
 254  		if (*s)[i].Path == path {
 255  			(*s)[i].Count++
 256  			return
 257  		}
 258  	}
 259  	*s = append(*s, PathCount{Path: path, Count: 1})
 260  }
 261  
 262  // OccupancyRate returns the fraction of occupied sites as a Ratio.
 263  func (s *Spore) OccupancyRate() ratio.Ratio {
 264  	if s.TotalNodes == 0 {
 265  		return ratio.Zero
 266  	}
 267  	return ratio.New(int64(s.Occupied), int64(s.TotalNodes))
 268  }
 269  
 270  // sortedTagCountTags returns the tags from a TagCount slice, sorted.
 271  func sortedTagCountTags(s []TagCount) []string {
 272  	tags := make([]string, len(s))
 273  	for i, tc := range s {
 274  		tags[i] = tc.Tag
 275  	}
 276  	sort.Strings(tags)
 277  	return tags
 278  }
 279  
 280  // PeerMemByID returns the PeerMemEntry for the given ID, or ok=false.
 281  func PeerMemByID(s []PeerMemEntry, id uint32) (PeerMemEntry, bool) {
 282  	for _, e := range s {
 283  		if e.ID == id {
 284  			return e, true
 285  		}
 286  	}
 287  	return PeerMemEntry{}, false
 288  }
 289  
 290  // Extract produces a spore from a mature lattice. This is sporulation —
 291  // the lattice compressing its own structure into a portable seed.
 292  // If parent is non-nil, the new spore records the parent's hash and
 293  // increments the generation counter.
 294  func Extract(l *lattice.Lattice, parent ...*Spore) *Spore {
 295  	s := &Spore{
 296  		TotalNodes: l.Size(),
 297  	}
 298  
 299  	occupied := 0
 300  
 301  	// Temporary accumulator for neighbor counts per tag.
 302  	type tagCounts struct {
 303  		tag    string
 304  		counts []int
 305  	}
 306  	var neighborCounts []tagCounts
 307  
 308  	findOrAdd := func(tag string) *tagCounts {
 309  		for i := range neighborCounts {
 310  			if neighborCounts[i].tag == tag {
 311  				return &neighborCounts[i]
 312  			}
 313  		}
 314  		neighborCounts = append(neighborCounts, tagCounts{tag: tag})
 315  		return &neighborCounts[len(neighborCounts)-1]
 316  	}
 317  
 318  	for _, n := range l.Nodes() {
 319  		// Constraint types.
 320  		for _, c := range n.Constraints() {
 321  			AddTagCount(&s.TypeSignature, c.Tag())
 322  		}
 323  
 324  		// Connectivity per type.
 325  		nbs := n.Neighbors()
 326  		for _, c := range n.Constraints() {
 327  			tc := findOrAdd(c.Tag())
 328  			tc.counts = append(tc.counts, len(nbs))
 329  		}
 330  
 331  		// Element types.
 332  		if n.Occupied() {
 333  			occupied++
 334  			e := n.Occupant()
 335  			AddTagCount(&s.ElementTypes, e.Type())
 336  		}
 337  
 338  		// Permutation distribution.
 339  		if p := n.Permutation(); p < 6 {
 340  			s.PermDist[p]++
 341  		}
 342  
 343  		// Projection distribution (6-bit: vertex | key<<3).
 344  		proj6 := n.Projection6Bit()
 345  		if proj6 < 64 {
 346  			s.ProjDist[proj6]++
 347  		}
 348  
 349  		// Path distribution.
 350  		if path := n.ProjectionPath(); path > 0 {
 351  			addPathCount(&s.PathDist, path)
 352  		}
 353  
 354  		// Age distribution.
 355  		if n.Occupied() {
 356  			age := n.Age()
 357  			if age < 4 {
 358  				s.AgeDist[age]++
 359  			}
 360  		}
 361  	}
 362  
 363  	s.Occupied = occupied
 364  
 365  	// Average connectivity per type.
 366  	for _, tc := range neighborCounts {
 367  		sum := 0
 368  		for _, c := range tc.counts {
 369  			sum += c
 370  		}
 371  		s.Connectivity = append(s.Connectivity, TagRatio{
 372  			Tag:   tc.tag,
 373  			Value: ratio.New(int64(sum), int64(len(tc.counts))),
 374  		})
 375  	}
 376  
 377  	// Lineage.
 378  	if len(parent) > 0 && parent[0] != nil {
 379  		s.ParentHash = parent[0].Hash()
 380  		s.Generation = parent[0].Generation + 1
 381  	}
 382  
 383  	return s
 384  }
 385  
 386  // Nucleate creates a new lattice from a spore, scaled to the given size.
 387  // The new lattice has the same type proportions and connectivity pattern
 388  // as the source, but is empty — ready for new input.
 389  func (s *Spore) Nucleate(targetSize int, constraintFactory func(tag string) axiom.Constraint) *lattice.Lattice {
 390  	l := lattice.New()
 391  
 392  	if targetSize <= 0 || len(s.TypeSignature) == 0 {
 393  		return l
 394  	}
 395  
 396  	// Calculate proportional allocation.
 397  	totalConstraints := 0
 398  	for _, tc := range s.TypeSignature {
 399  		totalConstraints += tc.Count
 400  	}
 401  
 402  	// Sort tags for deterministic ordering.
 403  	tags := sortedTagCountTags(s.TypeSignature)
 404  
 405  	// Allocate nodes proportionally.
 406  	type tagNodes struct {
 407  		tag   string
 408  		nodes []*lattice.Node
 409  	}
 410  	var nodesByTag []tagNodes
 411  
 412  	allocated := 0
 413  	for _, tag := range tags {
 414  		count := tagCountValue(s.TypeSignature, tag)
 415  		n := int(ratio.New(int64(count), int64(totalConstraints)).ScaleInt(int64(targetSize)))
 416  		if n < 1 {
 417  			n = 1
 418  		}
 419  		if allocated+n > targetSize {
 420  			n = targetSize - allocated
 421  		}
 422  		if n <= 0 {
 423  			continue
 424  		}
 425  		tn := tagNodes{tag: tag}
 426  		for range n {
 427  			node := l.AddNode([]axiom.Constraint{constraintFactory(tag)})
 428  			node.SetEnergy(true)
 429  			tn.nodes = append(tn.nodes, node)
 430  		}
 431  		nodesByTag = append(nodesByTag, tn)
 432  		allocated += n
 433  	}
 434  
 435  	// Connect within each type (ring topology).
 436  	for _, tn := range nodesByTag {
 437  		for i := range tn.nodes {
 438  			l.Connect(tn.nodes[i], tn.nodes[(i+1)%len(tn.nodes)])
 439  		}
 440  	}
 441  
 442  	// Cross-connect between types based on source connectivity.
 443  	for i := 0; i < len(nodesByTag); i++ {
 444  		for j := i + 1; j < len(nodesByTag); j++ {
 445  			nodesA := nodesByTag[i].nodes
 446  			nodesB := nodesByTag[j].nodes
 447  			// Connect every Nth node between types.
 448  			step := max(1, min(len(nodesA), len(nodesB))/3)
 449  			for k := 0; k < min(len(nodesA), len(nodesB)); k += step {
 450  				l.Connect(nodesA[k], nodesB[k])
 451  			}
 452  		}
 453  	}
 454  
 455  	return l
 456  }
 457  
 458  // NucleateGrammar creates a new lattice from the spore's TypeSignature,
 459  // filtered by grammar adjacency. Unlike Nucleate, cross-connections are only
 460  // made between tag pairs that canNeighbor permits, and bridge node selection
 461  // is seeded by instanceSeed for per-instance topology variation.
 462  //
 463  // canNeighbor should return true if elements of type a and b may be lattice
 464  // neighbors. Passing a closure avoids importing the grammar package (which
 465  // would create an import cycle through memory).
 466  func (s *Spore) NucleateGrammar(
 467  	targetSize int,
 468  	canNeighbor func(a, b string) bool,
 469  	instanceSeed [32]byte,
 470  	constraintFactory func(string) axiom.Constraint,
 471  ) *lattice.Lattice {
 472  	l := lattice.New()
 473  
 474  	if targetSize <= 0 || len(s.TypeSignature) == 0 {
 475  		return l
 476  	}
 477  
 478  	// Calculate proportional allocation.
 479  	totalConstraints := 0
 480  	for _, tc := range s.TypeSignature {
 481  		totalConstraints += tc.Count
 482  	}
 483  
 484  	// Sort tags for deterministic ordering.
 485  	tags := sortedTagCountTags(s.TypeSignature)
 486  
 487  	// Allocate nodes proportionally.
 488  	type tagGroup struct {
 489  		tag   string
 490  		nodes []*lattice.Node
 491  	}
 492  	var groups []tagGroup
 493  
 494  	allocated := 0
 495  	for _, tag := range tags {
 496  		count := tagCountValue(s.TypeSignature, tag)
 497  		n := int(ratio.New(int64(count), int64(totalConstraints)).ScaleInt(int64(targetSize)))
 498  		if n < 1 {
 499  			n = 1
 500  		}
 501  		if allocated+n > targetSize {
 502  			n = targetSize - allocated
 503  		}
 504  		if n <= 0 {
 505  			continue
 506  		}
 507  		tg := tagGroup{tag: tag}
 508  		for range n {
 509  			node := l.AddNode([]axiom.Constraint{constraintFactory(tag)})
 510  			node.SetEnergy(true)
 511  			tg.nodes = append(tg.nodes, node)
 512  		}
 513  		groups = append(groups, tg)
 514  		allocated += n
 515  	}
 516  
 517  	// Connect within each type (ring topology).
 518  	for _, tg := range groups {
 519  		if len(tg.nodes) < 2 {
 520  			continue
 521  		}
 522  		for i := range tg.nodes {
 523  			l.Connect(tg.nodes[i], tg.nodes[(i+1)%len(tg.nodes)])
 524  		}
 525  	}
 526  
 527  	// Grammar-filtered cross-connections with seeded bridge selection.
 528  	var seed [32]byte
 529  	copy(seed[:], instanceSeed[:])
 530  	rng := rand.New(rand.NewChaCha8(seed))
 531  
 532  	for i, tgA := range groups {
 533  		for j := i + 1; j < len(groups); j++ {
 534  			tgB := groups[j]
 535  
 536  			// Only bridge grammar-adjacent pairs.
 537  			if !canNeighbor(tgA.tag, tgB.tag) && !canNeighbor(tgB.tag, tgA.tag) {
 538  				continue
 539  			}
 540  
 541  			smaller := min(len(tgA.nodes), len(tgB.nodes))
 542  			nBridges := max(1, smaller/3)
 543  
 544  			for range nBridges {
 545  				idxA := rng.IntN(len(tgA.nodes))
 546  				idxB := rng.IntN(len(tgB.nodes))
 547  				l.Connect(tgA.nodes[idxA], tgB.nodes[idxB])
 548  			}
 549  		}
 550  	}
 551  
 552  	return l
 553  }
 554  
 555  // WriteTo serializes the spore to JSON.
 556  func (s *Spore) WriteTo(w io.Writer) (int64, error) {
 557  	data, err := json.MarshalIndent(s, "", "  ")
 558  	if err != nil {
 559  		return 0, err
 560  	}
 561  	n, err := w.Write(data)
 562  	return int64(n), err
 563  }
 564  
 565  // ReadSpore deserializes a spore from JSON.
 566  func ReadSpore(r io.Reader) (*Spore, error) {
 567  	data, err := io.ReadAll(r)
 568  	if err != nil {
 569  		return nil, err
 570  	}
 571  	var s Spore
 572  	if err := json.Unmarshal(data, &s); err != nil {
 573  		return nil, err
 574  	}
 575  	return &s, nil
 576  }
 577  
 578  // String returns a human-readable summary of the spore.
 579  func (s *Spore) String() string {
 580  	out := fmt.Sprintf("spore: %d nodes, %.0f%% occupied\n", s.TotalNodes, s.OccupancyRate().Float64()*100)
 581  	out += "type signature:\n"
 582  
 583  	tags := sortedTagCountTags(s.TypeSignature)
 584  	for _, tag := range tags {
 585  		count := tagCountValue(s.TypeSignature, tag)
 586  		conn := tagRatioValue(s.Connectivity, tag)
 587  		out += fmt.Sprintf("  %-20s count=%-4d connectivity=%.1f\n", tag, count, conn.Float64())
 588  	}
 589  
 590  	if len(s.ElementTypes) > 0 {
 591  		out += "element types:\n"
 592  		etags := sortedTagCountTags(s.ElementTypes)
 593  		for _, tag := range etags {
 594  			count := tagCountValue(s.ElementTypes, tag)
 595  			out += fmt.Sprintf("  %-20s %d\n", tag, count)
 596  		}
 597  	}
 598  
 599  	return out
 600  }
 601