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