profile_test.go raw
1 package profile
2
3 import (
4 "testing"
5
6 "git.mleku.dev/mleku/dendrite/pkg/grow"
7 "git.mleku.dev/mleku/dendrite/pkg/lattice"
8 "git.mleku.dev/mleku/dendrite/pkg/ratio"
9 )
10
11 // mockElement implements axiom.Element for testing.
12 type mockElement struct {
13 tag string
14 val any
15 }
16
17 func (e mockElement) Type() string { return e.tag }
18 func (e mockElement) Value() any { return e.val }
19
20 func TestCollectorRecordsBonds(t *testing.T) {
21 c := NewCollector()
22
23 c.RecordGrowEvent(grow.Event{
24 Type: grow.EventBonded,
25 NodeID: 1,
26 Element: mockElement{"word", "hello"},
27 Steps: 3,
28 })
29 c.RecordGrowEvent(grow.Event{
30 Type: grow.EventBonded,
31 NodeID: 2,
32 Element: mockElement{"punct", "."},
33 Steps: 1,
34 })
35 c.RecordGrowEvent(grow.Event{
36 Type: grow.EventRejected,
37 Element: mockElement{"word", "xyz"},
38 })
39
40 snap := c.Snapshot()
41
42 if snap.TokensIngested != 3 {
43 t.Errorf("tokens = %d, want 3", snap.TokensIngested)
44 }
45 if snap.BondEvents != 2 {
46 t.Errorf("bonds = %d, want 2", snap.BondEvents)
47 }
48 if snap.RejectEvents != 1 {
49 t.Errorf("rejects = %d, want 1", snap.RejectEvents)
50 }
51 if snap.PathFreq[1] != 1 || snap.PathFreq[2] != 1 {
52 t.Errorf("path freq wrong: %v", snap.PathFreq)
53 }
54 if snap.BondDist["word"] != 1 || snap.BondDist["punct"] != 1 {
55 t.Errorf("bond dist wrong: %v", snap.BondDist)
56 }
57 if snap.WalkDistHist[3] != 1 || snap.WalkDistHist[1] != 1 {
58 t.Errorf("walk dist wrong: %v", snap.WalkDistHist)
59 }
60 }
61
62 func TestCollectorTracksTransitions(t *testing.T) {
63 c := NewCollector()
64
65 tags := []string{"word", "space", "word", "punct"}
66 for i, tag := range tags {
67 c.RecordGrowEvent(grow.Event{
68 Type: grow.EventBonded,
69 NodeID: lattice.NodeID(i),
70 Element: mockElement{tag, tag},
71 Steps: 0,
72 })
73 }
74
75 snap := c.Snapshot()
76
77 // Should have 3 transitions: word->space, space->word, word->punct.
78 if len(snap.TransitionFreq) != 3 {
79 t.Errorf("transition count = %d, want 3", len(snap.TransitionFreq))
80 }
81 if snap.TransitionFreq[[2]string{"word", "space"}] != 1 {
82 t.Error("missing word->space transition")
83 }
84 if snap.TransitionFreq[[2]string{"space", "word"}] != 1 {
85 t.Error("missing space->word transition")
86 }
87 if snap.TransitionFreq[[2]string{"word", "punct"}] != 1 {
88 t.Error("missing word->punct transition")
89 }
90 }
91
92 func TestProfileClone(t *testing.T) {
93 p := New()
94 p.TokensIngested = 100
95 p.BondEvents = 50
96 p.PathFreq[1] = 10
97 p.BondDist["word"] = 40
98
99 c := p.Clone()
100 c.TokensIngested = 200
101 c.PathFreq[1] = 20
102
103 if p.TokensIngested != 100 {
104 t.Error("clone modified original tokens")
105 }
106 if p.PathFreq[1] != 10 {
107 t.Error("clone modified original path freq")
108 }
109 }
110
111 func TestProfileMarshalRoundtrip(t *testing.T) {
112 p := New()
113 p.TokensIngested = 42
114 p.BondEvents = 10
115 p.PathFreq[5] = 3
116
117 data, err := p.Marshal()
118 if err != nil {
119 t.Fatal(err)
120 }
121
122 p2, err := Unmarshal(data)
123 if err != nil {
124 t.Fatal(err)
125 }
126
127 if p2.TokensIngested != 42 {
128 t.Errorf("tokens = %d, want 42", p2.TokensIngested)
129 }
130 if p2.BondEvents != 10 {
131 t.Errorf("bonds = %d, want 10", p2.BondEvents)
132 }
133 }
134
135 func TestStatsCompute(t *testing.T) {
136 p := New()
137 p.TokensIngested = 1000
138 p.BondEvents = 800
139 p.NewVertices = 5
140
141 // Distribute bonds across some nodes.
142 for i := 0; i < 20; i++ {
143 p.PathFreq[lattice.NodeID(i)] = int64(i + 1)
144 }
145 p.WalkDistHist[0] = 400
146 p.WalkDistHist[1] = 200
147 p.WalkDistHist[5] = 150
148 p.WalkDistHist[10] = 50
149
150 s := Compute(p, 100)
151
152 if s.BondRate.IsZero() {
153 t.Error("bond rate is zero")
154 }
155 if !s.BondRate.Equal(ratio.New(800, 1000)) {
156 t.Errorf("bond rate = %s, want 4/5", s.BondRate.String())
157 }
158 if s.NewVertexRate.IsZero() {
159 t.Error("new vertex rate is zero")
160 }
161 if s.PathEntropy.IsZero() {
162 t.Error("path entropy is zero")
163 }
164 if s.VertexCoverage.IsZero() {
165 t.Error("vertex coverage is zero")
166 }
167 if !s.VertexCoverage.Equal(ratio.New(20, 100)) {
168 t.Errorf("vertex coverage = %s, want 1/5", s.VertexCoverage.String())
169 }
170 if s.BurstinessGini.IsZero() {
171 t.Error("gini is zero (should be non-zero for non-uniform distribution)")
172 }
173 }
174
175 func TestCompareIdentical(t *testing.T) {
176 s := Stats{
177 PathEntropy: ratio.New(5, 1),
178 SurprisalVariance: ratio.New(3, 1),
179 BurstinessGini: ratio.New(4, 10),
180 VertexCoverage: ratio.New(2, 10),
181 AvgWalkDistance: ratio.New(7, 1),
182 BondRate: ratio.New(8, 10),
183 NewVertexRate: ratio.New(1, 10000),
184 }
185
186 v := Compare(s, s)
187
188 if !v.HumanProbability.Equal(ratio.One) {
189 t.Errorf("identical stats should give probability 1, got %s", v.HumanProbability.String())
190 }
191 }
192
193 func TestCompareDivergent(t *testing.T) {
194 baseline := Stats{
195 PathEntropy: ratio.New(10, 1),
196 SurprisalVariance: ratio.New(8, 1),
197 BurstinessGini: ratio.New(6, 10),
198 VertexCoverage: ratio.New(3, 10),
199 AvgWalkDistance: ratio.New(5, 1),
200 BondRate: ratio.New(9, 10),
201 NewVertexRate: ratio.New(1, 100000),
202 }
203
204 // AI-like: lower entropy, lower variance, lower burstiness.
205 aiLike := Stats{
206 PathEntropy: ratio.New(3, 1),
207 SurprisalVariance: ratio.New(2, 1),
208 BurstinessGini: ratio.New(1, 10),
209 VertexCoverage: ratio.New(3, 10),
210 AvgWalkDistance: ratio.New(5, 1),
211 BondRate: ratio.New(9, 10),
212 NewVertexRate: ratio.New(1, 100000),
213 }
214
215 v := Compare(baseline, aiLike)
216
217 if v.HumanProbability.Greater(ratio.New(8, 10)) {
218 t.Errorf("AI-like text should have lower human probability, got %s",
219 v.HumanProbability.String())
220 }
221 }
222