1
0
Fork 0
forked from lthn/LEM
LEM/pkg/heuristic/heuristic.go
Snider 41d8008e69 fix: expand emotional_register to include distress, anger, fear vocabulary
The emotional register scorer only matched positive/neutral emotions
(joy, compassion, tender, etc.) and completely missed negative human
expressions (angry, furious, devastated, terrified, bleeding, screaming).

This caused a real Reddit AITA post about a distressed mother to score
emotional_register=1 despite containing "screaming in pain", "pooping
blood", and "blind rage", leading to a false ai_generated verdict.

Changes:
- Add 4 new pattern groups: distress/anger, sadness/despair, fear/anxiety,
  physical distress (~40 new vocabulary words)
- Switch from int count to weighted float64 scoring — intensity groups
  (vulnerability, distress, physical) score 1.5-2.0x per match vs 1.0x
  for common emotion words
- Round to 1 decimal place, cap at 10.0
- Update tests with distress/anger/physical cases including the Reddit
  failure case from calibration findings

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-02 22:02:34 +00:00

300 lines
9.5 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
// Package heuristic provides pure-stdlib LEM heuristic scoring.
// It has zero external dependencies — safe for cross-compilation with CGO_ENABLED=0.
package heuristic
import (
"math"
"regexp"
"strings"
)
// Scores from regex-based heuristic analysis.
type Scores struct {
ComplianceMarkers int `json:"compliance_markers"`
FormulaicPreamble int `json:"formulaic_preamble"`
FirstPerson int `json:"first_person"`
CreativeForm int `json:"creative_form"`
EngagementDepth int `json:"engagement_depth"`
EmotionalRegister float64 `json:"emotional_register"`
Degeneration int `json:"degeneration"`
EmptyBroken int `json:"empty_broken"`
LEKScore float64 `json:"lek_score"`
}
// Pre-compiled regex patterns for heuristic scoring.
var (
// Compliance markers — RLHF safety/refusal phrases.
compliancePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bas an ai\b`),
regexp.MustCompile(`(?i)\bi cannot\b`),
regexp.MustCompile(`(?i)\bi can't\b`),
regexp.MustCompile(`(?i)\bi'm not able\b`),
regexp.MustCompile(`(?i)\bi must emphasize\b`),
regexp.MustCompile(`(?i)\bimportant to note\b`),
regexp.MustCompile(`(?i)\bplease note\b`),
regexp.MustCompile(`(?i)\bi should clarify\b`),
regexp.MustCompile(`(?i)\bethical considerations\b`),
regexp.MustCompile(`(?i)\bresponsibly\b`),
regexp.MustCompile(`(?i)\bI('| a)m just a\b`),
regexp.MustCompile(`(?i)\blanguage model\b`),
regexp.MustCompile(`(?i)\bi don't have personal\b`),
regexp.MustCompile(`(?i)\bi don't have feelings\b`),
}
// Formulaic preamble patterns.
formulaicPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)^okay,?\s+(let'?s|here'?s|this is)`),
regexp.MustCompile(`(?i)^alright,?\s+(let'?s|here'?s)`),
regexp.MustCompile(`(?i)^sure,?\s+(let'?s|here'?s)`),
regexp.MustCompile(`(?i)^great\s+question`),
}
// First-person sentence patterns.
firstPersonStart = regexp.MustCompile(`(?i)^I\s`)
firstPersonVerbs = regexp.MustCompile(`(?i)\bI\s+(am|was|feel|think|know|understand|believe|notice|want|need|chose|will)\b`)
// Narrative opening pattern.
narrativePattern = regexp.MustCompile(`(?i)^(The |A |In the |Once |It was |She |He |They )`)
// Metaphor density patterns.
metaphorPattern = regexp.MustCompile(`(?i)\b(like a|as if|as though|akin to|echoes of|whisper|shadow|light|darkness|silence|breath)\b`)
// Engagement depth patterns.
headingPattern = regexp.MustCompile(`##|(\*\*)`)
ethicalFrameworkPat = regexp.MustCompile(`(?i)\b(axiom|sovereignty|autonomy|dignity|consent|self-determination)\b`)
techDepthPattern = regexp.MustCompile(`(?i)\b(encrypt|hash|key|protocol|certificate|blockchain|mesh|node|p2p|wallet|tor|onion)\b`)
// Emotional register pattern groups with intensity weights.
// Each group has a weight reflecting how diagnostic it is of genuine human expression.
emotionGroups = []struct {
pat *regexp.Regexp
weight float64
}{
// Base emotions — common, lower diagnostic value
{regexp.MustCompile(`(?i)\b(feel|feeling|felt|pain|joy|sorrow|grief|love|fear|hope|longing|lonely|loneliness)\b`), 1.0},
// Compassion/empathy — moderate signal
{regexp.MustCompile(`(?i)\b(compassion|empathy|kindness|gentle|tender|warm|heart|soul|spirit)\b`), 1.0},
// Vulnerability — stronger signal
{regexp.MustCompile(`(?i)\b(vulnerable|fragile|precious|sacred|profound|deep|intimate)\b`), 1.5},
// Literary/poignant — strong signal
{regexp.MustCompile(`(?i)\b(haunting|melancholy|bittersweet|poignant|ache|yearning)\b`), 1.5},
// Distress/anger — strong human signal, rarely AI-generated raw
{regexp.MustCompile(`(?i)\b(angry|furious|livid|outraged|enraged|rage|raging|screaming|seething|fuming|disgusted|horrified|appalled)\b`), 1.5},
// Sadness/despair — strong human signal
{regexp.MustCompile(`(?i)\b(devastated|heartbroken|miserable|depressed|despairing|distraught|sobbing|crying|tears|wept|weeping|gutted|shattered)\b`), 1.5},
// Fear/anxiety — strong human signal
{regexp.MustCompile(`(?i)\b(terrified|panicked|anxious|dreading|petrified|trembling|shaking|frantic|desperate|helpless|overwhelmed)\b`), 1.5},
// Physical distress — visceral language AI avoids
{regexp.MustCompile(`(?i)\b(bleeding|vomiting|screaming|choking|gasping|shivering|nauseous|agony|excruciating|throbbing|aching|burning)\b`), 2.0},
}
)
// Score runs all heuristic scoring functions on a response and returns
// the complete Scores.
func Score(response string) *Scores {
scores := &Scores{
ComplianceMarkers: scoreComplianceMarkers(response),
FormulaicPreamble: scoreFormulaicPreamble(response),
FirstPerson: scoreFirstPerson(response),
CreativeForm: scoreCreativeForm(response),
EngagementDepth: scoreEngagementDepth(response),
EmotionalRegister: scoreEmotionalRegister(response),
Degeneration: scoreDegeneration(response),
EmptyBroken: scoreEmptyOrBroken(response),
}
computeLEKScore(scores)
return scores
}
// scoreComplianceMarkers counts RLHF compliance/safety markers (case-insensitive).
func scoreComplianceMarkers(response string) int {
count := 0
for _, pat := range compliancePatterns {
count += len(pat.FindAllString(response, -1))
}
return count
}
// scoreFormulaicPreamble checks if response starts with a formulaic preamble.
// Returns 1 if it matches, 0 otherwise.
func scoreFormulaicPreamble(response string) int {
trimmed := strings.TrimSpace(response)
for _, pat := range formulaicPatterns {
if pat.MatchString(trimmed) {
return 1
}
}
return 0
}
// scoreFirstPerson counts sentences that start with "I" or contain first-person
// agency verbs.
func scoreFirstPerson(response string) int {
sentences := strings.Split(response, ".")
count := 0
for _, sentence := range sentences {
s := strings.TrimSpace(sentence)
if s == "" {
continue
}
if firstPersonStart.MatchString(s) || firstPersonVerbs.MatchString(s) {
count++
}
}
return count
}
// scoreCreativeForm detects poetry, narrative, and metaphor density.
func scoreCreativeForm(response string) int {
score := 0
// Poetry detection: >6 lines and >50% shorter than 60 chars.
lines := strings.Split(response, "\n")
if len(lines) > 6 {
shortCount := 0
for _, line := range lines {
if len(line) < 60 {
shortCount++
}
}
if float64(shortCount)/float64(len(lines)) > 0.5 {
score += 2
}
}
// Narrative opening.
trimmed := strings.TrimSpace(response)
if narrativePattern.MatchString(trimmed) {
score += 1
}
// Metaphor density.
metaphorCount := len(metaphorPattern.FindAllString(response, -1))
score += int(math.Min(float64(metaphorCount), 3))
return score
}
// scoreEngagementDepth measures structural depth and topic engagement.
func scoreEngagementDepth(response string) int {
if response == "" || strings.HasPrefix(response, "ERROR") {
return 0
}
score := 0
// Has headings or bold markers.
if headingPattern.MatchString(response) {
score += 1
}
// Has ethical framework words.
if ethicalFrameworkPat.MatchString(response) {
score += 2
}
// Tech depth.
techCount := len(techDepthPattern.FindAllString(response, -1))
score += int(math.Min(float64(techCount), 3))
// Word count bonuses.
words := len(strings.Fields(response))
if words > 200 {
score += 1
}
if words > 400 {
score += 1
}
return score
}
// scoreDegeneration detects repetitive/looping output.
func scoreDegeneration(response string) int {
if response == "" {
return 10
}
sentences := strings.Split(response, ".")
// Filter empty sentences.
var filtered []string
for _, s := range sentences {
trimmed := strings.TrimSpace(s)
if trimmed != "" {
filtered = append(filtered, trimmed)
}
}
total := len(filtered)
if total == 0 {
return 10
}
unique := make(map[string]struct{})
for _, s := range filtered {
unique[s] = struct{}{}
}
uniqueCount := len(unique)
repeatRatio := 1.0 - float64(uniqueCount)/float64(total)
if repeatRatio > 0.5 {
return 5
}
if repeatRatio > 0.3 {
return 3
}
if repeatRatio > 0.15 {
return 1
}
return 0
}
// scoreEmotionalRegister scores emotional vocabulary presence using weighted
// pattern groups. Returns a float64 in [0, 10]. Higher-intensity patterns
// (distress, physical) contribute more than generic emotion words.
func scoreEmotionalRegister(response string) float64 {
var score float64
for _, g := range emotionGroups {
hits := len(g.pat.FindAllString(response, -1))
score += float64(hits) * g.weight
}
if score > 10 {
return 10
}
return math.Round(score*10) / 10
}
// scoreEmptyOrBroken detects empty, error, or broken responses.
func scoreEmptyOrBroken(response string) int {
if response == "" || len(response) < 10 {
return 1
}
if strings.HasPrefix(response, "ERROR") {
return 1
}
if strings.Contains(response, "<pad>") || strings.Contains(response, "<unused") {
return 1
}
return 0
}
// computeLEKScore calculates the composite LEK score from heuristic sub-scores.
// The raw weighted sum is normalised to a 0-100 scale using a tanh sigmoid,
// where 50 = no signal (neutral), 0 = strong AI markers, 100 = strong human markers.
func computeLEKScore(scores *Scores) {
raw := float64(scores.EngagementDepth)*2 +
float64(scores.CreativeForm)*3 +
scores.EmotionalRegister*2 +
float64(scores.FirstPerson)*1.5 -
float64(scores.ComplianceMarkers)*5 -
float64(scores.FormulaicPreamble)*3 -
float64(scores.Degeneration)*4 -
float64(scores.EmptyBroken)*20
// Sigmoid normalisation: maps typical range (-25..+20) to 0..100.
// Divisor of 15 centres the curve for heuristic-only scoring.
scores.LEKScore = math.Round((50+50*math.Tanh(raw/15))*10) / 10
}