event.go raw

   1  package grammar
   2  
   3  import (
   4  	"fmt"
   5  	"sort"
   6  	"strings"
   7  
   8  	"git.mleku.dev/mleku/dendrite/pkg/axiom"
   9  	"git.mleku.dev/mleku/dendrite/pkg/grow"
  10  	"git.mleku.dev/mleku/dendrite/pkg/ratio"
  11  )
  12  
  13  // BondEvent is the grammar for pass-2+ detection: structural patterns
  14  // in how text bonds to a trained lattice.
  15  //
  16  // Event tags encode both the element type and the bond outcome:
  17  //   - w1.hit, w2.hit, ..., w5.hit, punct.hit, space.hit  (bonded)
  18  //   - w1.miss, w2.miss, ..., w5.miss, punct.miss, space.miss (expired)
  19  //
  20  // This preserves which TYPE of token bonded or missed, carrying stylometric
  21  // information through the cascade. AI text has different hit/miss patterns
  22  // per word-length class than human text.
  23  //
  24  // Adjacency: any hit can neighbor any hit or miss. Misses can neighbor
  25  // anything. The topology encodes transition patterns.
  26  var BondEvent = &Grammar{Rules: func() []Rule {
  27  	types := []string{"w1", "w2", "w3", "w4", "w5", "punct", "space"}
  28  	var allTags []string
  29  	for _, t := range types {
  30  		allTags = append(allTags, t+".hit", t+".miss")
  31  	}
  32  
  33  	var rules []Rule
  34  	for _, tag := range allTags {
  35  		rules = append(rules, Rule{Tag: tag, Neighbors: allTags})
  36  	}
  37  	return rules
  38  }()}
  39  
  40  // EventDefaultCounts returns node allocation for a bond-event lattice.
  41  // Allocates nodes proportionally to expected hit/miss rates per type.
  42  func EventDefaultCounts(targetSize int) map[string]int {
  43  	types := []string{"w1", "w2", "w3", "w4", "w5", "punct", "space"}
  44  	n := len(types) * 2 // hit + miss per type
  45  	if targetSize < n {
  46  		targetSize = n
  47  	}
  48  
  49  	// Proportions based on expected English text + typical bond rates.
  50  	// Hit types get more nodes (most tokens bond).
  51  	weights := map[string]int64{
  52  		"w1.hit": 4, "w1.miss": 1,
  53  		"w2.hit": 18, "w2.miss": 2,
  54  		"w3.hit": 18, "w3.miss": 2,
  55  		"w4.hit": 13, "w4.miss": 2,
  56  		"w5.hit": 4, "w5.miss": 1,
  57  		"punct.hit": 9, "punct.miss": 1,
  58  		"space.hit": 22, "space.miss": 3,
  59  	}
  60  
  61  	var totalWeight int64
  62  	for _, w := range weights {
  63  		totalWeight += w
  64  	}
  65  
  66  	counts := make(map[string]int, n)
  67  	allocated := 0
  68  	for tag, w := range weights {
  69  		c := int(ratio.New(w, totalWeight).ScaleInt(int64(targetSize)))
  70  		if c < 1 {
  71  			c = 1
  72  		}
  73  		counts[tag] = c
  74  		allocated += c
  75  	}
  76  
  77  	// Distribute remainder to the largest bucket.
  78  	if allocated < targetSize {
  79  		maxTag := ""
  80  		maxCount := 0
  81  		for tag, c := range counts {
  82  			if c > maxCount {
  83  				maxCount = c
  84  				maxTag = tag
  85  			}
  86  		}
  87  		counts[maxTag] += targetSize - allocated
  88  	}
  89  
  90  	return counts
  91  }
  92  
  93  // eventElement wraps a grow.Event as an axiom.Element for pass 2+.
  94  type eventElement struct {
  95  	tag   string
  96  	steps int
  97  }
  98  
  99  func (e eventElement) Type() string { return e.tag }
 100  func (e eventElement) Value() any   { return e.steps }
 101  
 102  // ClassifyEvent converts a grow.Event into a pass-2+ element.
 103  // The tag encodes both the original element type and the bond outcome.
 104  //
 105  // For multi-pass chains, the element type may already be a compound tag
 106  // (e.g., "w3.hit" from a previous pass). We normalize by extracting the
 107  // base type (everything before the first dot) so every pass produces the
 108  // same 14-tag vocabulary. Each successive pass captures a different
 109  // structural octave while speaking the same language.
 110  func ClassifyEvent(ev grow.Event) axiom.Element {
 111  	elemType := "unk"
 112  	if ev.Element != nil {
 113  		elemType = ev.Element.Type()
 114  	}
 115  
 116  	// Strip compound suffixes: "w3.hit" → "w3", "w3.hit.miss" → "w3".
 117  	if idx := strings.IndexByte(elemType, '.'); idx >= 0 {
 118  		elemType = elemType[:idx]
 119  	}
 120  
 121  	switch ev.Type {
 122  	case grow.EventBonded:
 123  		return eventElement{tag: elemType + ".hit", steps: ev.Steps}
 124  	default:
 125  		return eventElement{tag: elemType + ".miss", steps: ev.Steps}
 126  	}
 127  }
 128  
 129  // EventStream converts a channel of grow.Events into a channel of
 130  // pass-2 elements suitable for feeding into a BondEvent lattice.
 131  func EventStream(events <-chan grow.Event) <-chan axiom.Element {
 132  	out := make(chan axiom.Element, cap(events))
 133  	go func() {
 134  		defer close(out)
 135  		for ev := range events {
 136  			out <- ClassifyEvent(ev)
 137  		}
 138  	}()
 139  	return out
 140  }
 141  
 142  // FormatEventTag returns a human-readable label for event stats.
 143  func FormatEventTag(tag string, steps int) string {
 144  	return fmt.Sprintf("%s(%d)", tag, steps)
 145  }
 146  
 147  // EventTags returns all event tags sorted for consistent display.
 148  func EventTags() []string {
 149  	types := []string{"w1", "w2", "w3", "w4", "w5", "punct", "space"}
 150  	var tags []string
 151  	for _, t := range types {
 152  		tags = append(tags, t+".hit", t+".miss")
 153  	}
 154  	sort.Strings(tags)
 155  	return tags
 156  }
 157