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