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>
300 lines
9.5 KiB
Go
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
|
|
}
|