verdict.go raw
1 package grammar
2
3 import (
4 "fmt"
5 "strings"
6
7 "git.mleku.dev/mleku/dendrite/pkg/grow"
8 )
9
10 // Verdict is the output of the 8-pass recognition chain.
11 // It distills per-pass metrics into a single judgment.
12 type Verdict struct {
13 // Human is true if the text appears to be human-written.
14 Human bool
15
16 // Confidence ranges from 0 (uncertain) to 1 (certain).
17 Confidence float64
18
19 // DeepWalk is the average walk distance at the deepest pass.
20 // Human text: ~1.26-1.30, AI text: ~1.34-1.47.
21 DeepWalk float64
22
23 // LongMissRate is the fraction of misses at the deepest pass
24 // that fall on long words (w4+w5).
25 // Human text: ~0.26-0.30, AI text: ~0.32-0.45.
26 LongMissRate float64
27
28 // PassHits records the hit rate at each pass.
29 PassHits []float64
30
31 // Label is a short human-readable verdict string.
32 Label string
33
34 // TrollScore is the structural match rate against the manipulation
35 // lattice. Higher values mean the text more closely matches the
36 // prosodic patterns of manipulative/trolling writing.
37 // Range: 0.0 (no match) to 1.0 (perfect match).
38 TrollScore float64
39
40 // TrollLabel is a short human-readable troll verdict string.
41 // Empty when troll detection is not active.
42 TrollLabel string
43 }
44
45 // PassStats holds per-pass event statistics for scoring.
46 type PassStats struct {
47 Events []grow.Event
48 Bonded int64
49 Expired int64
50 Total int64
51 }
52
53 // Score computes a Verdict from multi-pass detection results.
54 // passStats[0] is pass 1 (word level), passStats[N-1] is the deepest pass.
55 func Score(passStats []PassStats) Verdict {
56 if len(passStats) == 0 {
57 return Verdict{Label: "no data"}
58 }
59
60 v := Verdict{
61 PassHits: make([]float64, len(passStats)),
62 }
63
64 // Record per-pass hit rates.
65 for i, ps := range passStats {
66 if ps.Total > 0 {
67 v.PassHits[i] = float64(ps.Bonded) / float64(ps.Total)
68 }
69 }
70
71 // Compute deep pass metrics from the last pass.
72 deep := passStats[len(passStats)-1]
73 v.DeepWalk = avgWalk(deep.Events)
74 v.LongMissRate = longMissFraction(deep.Events)
75
76 // Scoring: combine walk distance and long-miss rate.
77 //
78 // Walk distance boundary: 1.32 is the natural gap between
79 // human (1.26-1.30) and AI (1.34-1.47) at pass 8.
80 //
81 // Long-miss boundary: 0.31 separates human (0.26-0.30)
82 // from AI (0.32-0.45).
83 //
84 // Each metric votes independently. Both agreeing = high confidence.
85 walkVote := 0.0 // negative = human, positive = AI
86 missVote := 0.0
87
88 walkCenter := 1.32
89 if v.DeepWalk < walkCenter {
90 walkVote = (walkCenter - v.DeepWalk) / walkCenter * -1 // human direction
91 } else {
92 walkVote = (v.DeepWalk - walkCenter) / walkCenter // AI direction
93 }
94
95 missCenter := 0.31
96 if v.LongMissRate < missCenter {
97 missVote = (missCenter - v.LongMissRate) / missCenter * -1
98 } else {
99 missVote = (v.LongMissRate - missCenter) / missCenter
100 }
101
102 // Combined score: negative = human, positive = AI.
103 combined := (walkVote + missVote) / 2
104 v.Human = combined < 0
105 v.Confidence = clamp(abs(combined)*3, 0, 1) // scale for readability
106
107 if v.Human {
108 v.Label = fmt.Sprintf("HUMAN (%.0f%%)", v.Confidence*100)
109 } else {
110 v.Label = fmt.Sprintf("AI (%.0f%%)", v.Confidence*100)
111 }
112
113 return v
114 }
115
116 // ScoreTroll computes the troll dimension of a verdict from manipulation
117 // lattice pass stats. Uses baseline-relative scoring:
118 //
119 // Normal English prose bonds at ~67% at pass 8 against the manipulation
120 // lattice. Only text that bonds significantly above this baseline is
121 // flagged. The score represents how far above baseline the text scores,
122 // normalized to 0-1.
123 //
124 // Texts that collapse before pass 8 (zero events at deep passes) score 0.
125 func ScoreTroll(v *Verdict, trollStats []PassStats) {
126 if len(trollStats) == 0 {
127 return
128 }
129
130 nPasses := len(trollStats)
131
132 // Find the deepest pass with events.
133 lastLivePass := -1
134 for i := nPasses - 1; i >= 0; i-- {
135 if trollStats[i].Total > 0 {
136 lastLivePass = i
137 break
138 }
139 }
140
141 if lastLivePass < 0 {
142 return // no events at any pass
143 }
144
145 // If text doesn't survive to the final pass, it's structurally
146 // dissimilar from manipulation text. Score 0.
147 if lastLivePass < nPasses-1 {
148 v.TrollScore = 0
149 return
150 }
151
152 // Deep pass bond rate.
153 deep := trollStats[lastLivePass]
154 deepRate := float64(deep.Bonded) / float64(deep.Total)
155
156 // Baseline: normal English prose bonds at ~0.67 at pass 8
157 // against a 221K-word manipulation lattice. Score is the
158 // excess above baseline, scaled so that 0.80 → 100%.
159 const baseline = 0.67
160 const ceiling = 0.80
161 if deepRate <= baseline {
162 v.TrollScore = 0
163 return
164 }
165
166 v.TrollScore = clamp((deepRate-baseline)/(ceiling-baseline), 0, 1)
167 v.TrollLabel = fmt.Sprintf("MANIPULATION (%.0f%%)", v.TrollScore*100)
168 }
169
170 // String returns a multi-line verdict summary.
171 func (v Verdict) String() string {
172 var b strings.Builder
173 fmt.Fprintf(&b, "verdict: %s", v.Label)
174 if v.TrollLabel != "" {
175 fmt.Fprintf(&b, " | %s", v.TrollLabel)
176 }
177 b.WriteByte('\n')
178 fmt.Fprintf(&b, " deep walk avg: %.2f", v.DeepWalk)
179 if v.DeepWalk <= 1.32 {
180 b.WriteString(" (human range)")
181 } else {
182 b.WriteString(" (AI range)")
183 }
184 b.WriteByte('\n')
185 fmt.Fprintf(&b, " long-miss rate: %.1f%%", v.LongMissRate*100)
186 if v.LongMissRate <= 0.31 {
187 b.WriteString(" (human range)")
188 } else {
189 b.WriteString(" (AI range)")
190 }
191 b.WriteByte('\n')
192 fmt.Fprintf(&b, " pass hit rates: ")
193 for i, h := range v.PassHits {
194 if i > 0 {
195 b.WriteString(" → ")
196 }
197 fmt.Fprintf(&b, "%.0f%%", h*100)
198 }
199 b.WriteByte('\n')
200 if v.TrollLabel != "" {
201 fmt.Fprintf(&b, " troll match: %.1f%%\n", v.TrollScore*100)
202 }
203 return b.String()
204 }
205
206 func avgWalk(events []grow.Event) float64 {
207 var total int64
208 var n int64
209 for _, ev := range events {
210 if ev.Type == grow.EventBonded {
211 total += int64(ev.Steps)
212 n++
213 }
214 }
215 if n == 0 {
216 return 0
217 }
218 return float64(total) / float64(n)
219 }
220
221 func longMissFraction(events []grow.Event) float64 {
222 var totalMiss, longMiss int64
223 for _, ev := range events {
224 if ev.Type != grow.EventBonded {
225 tag := "unk"
226 if ev.Element != nil {
227 tag = ev.Element.Type()
228 }
229 // Classify the event to get the miss tag.
230 classified := ClassifyEvent(ev)
231 ct := classified.Type()
232 if strings.HasSuffix(ct, ".miss") {
233 totalMiss++
234 // Extract base type before .miss
235 base := strings.TrimSuffix(ct, ".miss")
236 _ = tag // use classified tag, not raw
237 if base == "w4" || base == "w5" {
238 longMiss++
239 }
240 }
241 }
242 }
243 if totalMiss == 0 {
244 return 0
245 }
246 return float64(longMiss) / float64(totalMiss)
247 }
248
249 func clamp(v, lo, hi float64) float64 {
250 if v < lo {
251 return lo
252 }
253 if v > hi {
254 return hi
255 }
256 return v
257 }
258
259 func abs(v float64) float64 {
260 if v < 0 {
261 return -v
262 }
263 return v
264 }
265