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