Skip to main content

microscope_memory/
emotion_extraction.rs

1//! Structural Emotion Extraction — text → PAD state via structural signals.
2//!
3//! Replaces word-list-only valence detection. Instead of matching
4//! individual words ("happy" → positive), this module detects
5//! *what happened* in the text:
6//!
7//! 1. **Events**: commits, builds, errors, fixes, pushes → structural emotions
8//! 2. **Speech acts**: questions, imperatives, contemplation → arousal/dominance
9//! 3. **Self-correction**: "nem emlékszem", "igazad van", "túlkapás" →
10//!    positive structural signal (learning), not negative
11//! 4. **Context type**: code, personal, philosophical, technical → baseline PAD
12//! 5. **Conversational arc**: relative arousal change from previous state
13//!
14//! Output: PadState + claim text for EmotionalEpisode creation.
15//! The episode gets full epistemic backing via the existing system.
16
17use crate::emotional_episode::PadState;
18
19// ─── Event Patterns ─────────────────────────────────
20
21/// A structural event detected in text.
22#[derive(Debug, Clone, PartialEq)]
23pub enum StructuralEvent {
24    /// Achievement: commit, build, push, success, milestone
25    Achievement,
26    /// Error: timeout, fail, error, crash, panic
27    Error,
28    /// Fix: resolved, fixed, solved, workaround
29    Fix,
30    /// Challenge: prove it, bizonyítsd, szerintem nem
31    Challenge,
32    /// Building: építed, mehet, csináld, add
33    Building,
34    /// Reflection: szerintem, azt érzem, érzés
35    Reflection,
36    /// Connection: personal question, emotional language
37    Connection,
38    /// None detected
39    None,
40}
41
42impl StructuralEvent {
43    /// Map event to PAD state.
44    /// These are *structural* mappings — based on what the event means,
45    /// not what words it contains.
46    pub fn to_pad(&self) -> PadState {
47        match self {
48            // Achievement: joy — positive, activated, dominant
49            Self::Achievement => PadState::new(0.70, 0.60, 0.65),
50            // Error: fear/frustration — negative, activated, submissive
51            Self::Error => PadState::new(-0.50, 0.80, 0.25),
52            // Fix: relief — positive shift, moderate arousal, dominant
53            Self::Fix => PadState::new(0.40, 0.30, 0.65),
54            // Challenge: determination — slightly negative, high arousal, dominant
55            Self::Challenge => PadState::new(-0.20, 0.75, 0.70),
56            // Building: excitement — positive, activated, dominant
57            Self::Building => PadState::new(0.55, 0.65, 0.60),
58            // Reflection: contemplative — neutral, low arousal, neutral
59            Self::Reflection => PadState::new(0.10, 0.20, 0.45),
60            // Connection: vulnerability — slightly positive, moderate, submissive
61            Self::Connection => PadState::new(0.20, 0.45, 0.35),
62            Self::None => PadState::neutral(),
63        }
64    }
65
66    /// How confident are we that this event was correctly detected?
67    pub fn detection_confidence(&self) -> f64 {
68        match self {
69            Self::Achievement => 0.85, // clear keywords
70            Self::Error => 0.88,       // clear keywords
71            Self::Fix => 0.75,         // sometimes ambiguous
72            Self::Challenge => 0.70,   // context-dependent
73            Self::Building => 0.72,    // Hungarian-specific
74            Self::Reflection => 0.65,  // subjective
75            Self::Connection => 0.60,  // hard to detect reliably
76            Self::None => 0.50,
77        }
78    }
79
80    /// Is this a structural trigger (vs word-list pattern match)?
81    pub fn is_structural(&self) -> bool {
82        !matches!(self, Self::None)
83    }
84}
85
86// ─── Event Detectors ────────────────────────────────
87
88/// Detect achievement events.
89fn detect_achievement(text: &str) -> bool {
90    let lower = text.to_lowercase();
91    let en_patterns = [
92        "commit",
93        "pushed",
94        "merged",
95        "build success",
96        "milestone",
97        "complete",
98        "done",
99        "shipped",
100        "tagged",
101        "all tests pass",
102        "green",
103        "zold",
104        "ok",
105    ];
106    let hu_patterns = [
107        "kész",
108        "kesz",
109        "sikeres",
110        "megcsinált",
111        "megcsinalt",
112        "lefordult",
113        "lefutott",
114        "átment",
115        "atment",
116        "zöld",
117    ];
118    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
119}
120
121/// Detect error events.
122fn detect_error(text: &str) -> bool {
123    let lower = text.to_lowercase();
124    let en_patterns = [
125        "error",
126        "fail",
127        "timeout",
128        "crash",
129        "panic",
130        "blocked",
131        "exception",
132        "traceback",
133        "segfault",
134    ];
135    let hu_patterns = [
136        "hiba",
137        "nem működik",
138        "nem mukodik",
139        "összeomlott",
140        "elromlott",
141        "megszakadt",
142    ];
143    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
144}
145
146/// Detect fix events.
147fn detect_fix(text: &str) -> bool {
148    let lower = text.to_lowercase();
149    let en_patterns = [
150        "fix",
151        "fixed",
152        "resolved",
153        "solved",
154        "workaround",
155        "patched",
156        "corrected",
157    ];
158    let hu_patterns = [
159        "javított",
160        "javitott",
161        "megoldott",
162        "megoldás",
163        "rendbe tett",
164        "kijavít",
165    ];
166    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
167}
168
169/// Detect challenge events.
170fn detect_challenge(text: &str) -> bool {
171    let lower = text.to_lowercase();
172    let en_patterns = [
173        "prove it",
174        "are you sure",
175        "really?",
176        "i disagree",
177        "not enough",
178        "still not",
179        "show me",
180    ];
181    let hu_patterns = [
182        "bizonyítsd",
183        "bizonyitsd",
184        "szerintem nem",
185        "nem elég",
186        "nem eleg",
187        "még mindig",
188        "meg mindig",
189        "szerintem ez",
190        "azt érzem",
191        "azt erzem",
192    ];
193    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
194}
195
196/// Detect building events.
197fn detect_building(text: &str) -> bool {
198    let lower = text.to_lowercase();
199    let en_patterns = [
200        "build",
201        "let's build",
202        "implement",
203        "create",
204        "add",
205        "make it",
206        "start now",
207        "go ahead",
208    ];
209    let hu_patterns = [
210        "építed",
211        "epited",
212        "épít",
213        "epit",
214        "mehet",
215        "csináld",
216        "csinald",
217        "add hozzá",
218        "hozzáad",
219        "hozzaad",
220        "most akkor",
221        "na adjuk",
222        "hozz létre",
223        "hozz letre",
224    ];
225    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
226}
227
228/// Detect reflection events.
229fn detect_reflection(text: &str) -> bool {
230    let lower = text.to_lowercase();
231    let en_patterns = [
232        "i think",
233        "i feel",
234        "reflect",
235        "wonder",
236        "ponder",
237        "meaning",
238        "why am i",
239        "what am i",
240    ];
241    let hu_patterns = [
242        "szerintem",
243        "gondolom",
244        "azt érzem",
245        "azt erzem",
246        "visszagondol",
247        "eltöpreng",
248        "eltrepreng",
249        "miért vagyok",
250        "mi vagyok",
251    ];
252    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
253}
254
255/// Detect connection events (personal/emotional questions).
256fn detect_connection(text: &str) -> bool {
257    let lower = text.to_lowercase();
258    let en_patterns = [
259        "how do you feel",
260        "what resonates",
261        "what moves you",
262        "do you care",
263        "are you aware",
264        "who are you",
265    ];
266    let hu_patterns = [
267        "mit érzel",
268        "mit erzel",
269        "mi rezonál",
270        "mi rezonal",
271        "mi mozgat",
272        "kapcsolódtál",
273        "kapcsolodtal",
274        "ki vagy",
275        "mit érzel",
276        "tudatában",
277    ];
278    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
279}
280
281// ─── Self-Correction Detector ───────────────────────
282
283/// Detect self-correction signals — these are POSITIVE structural
284/// signals (learning, humility) even though they contain negative words.
285///
286/// This is the key fix: "nem emlékszem" is NOT sadness — it's honesty.
287/// "igazad van" is NOT weakness — it's learning. "túlkapás" is NOT
288/// negativity — it's epistemic correction.
289fn detect_self_correction(text: &str) -> bool {
290    let lower = text.to_lowercase();
291    let en_patterns = [
292        "i was wrong",
293        "you're right",
294        "i admit",
295        "i realize",
296        "actually no",
297        "let me correct",
298        "on second thought",
299        "i don't actually know",
300        "i was mistaken",
301    ];
302    let hu_patterns = [
303        "igazad van",
304        "igazad van",
305        "jogos",
306        "túlkapás",
307        "tulkapas",
308        "nem emlékszem",
309        "nem emlekszem",
310        "valóban nem",
311        "tényleg nem",
312        "tenyleg nem",
313        "mégse",
314        "megse",
315        "őszintén",
316        "oszintén",
317        "pontosítás",
318    ];
319    en_patterns.iter().any(|p| lower.contains(p)) || hu_patterns.iter().any(|p| lower.contains(p))
320}
321
322// ─── Speech Act Detector ────────────────────────────
323
324#[derive(Debug, Clone, PartialEq)]
325pub enum SpeechAct {
326    Question,
327    Imperative,
328    Contemplation,
329    Statement,
330}
331
332impl SpeechAct {
333    /// Adjust PAD based on speech act.
334    pub fn adjust_pad(&self, pad: &mut PadState) {
335        match self {
336            // Questions → curiosity: raise arousal, lower dominance
337            Self::Question => {
338                pad.arousal = (pad.arousal + 0.15).min(1.0);
339                pad.dominance = (pad.dominance - 0.10).max(0.0);
340            }
341            // Imperatives → urgency: raise arousal, raise dominance
342            Self::Imperative => {
343                pad.arousal = (pad.arousal + 0.20).min(1.0);
344                pad.dominance = (pad.dominance + 0.10).min(1.0);
345            }
346            // Contemplation → calm: lower arousal
347            Self::Contemplation => {
348                pad.arousal = (pad.arousal - 0.15).max(0.0);
349            }
350            // Statement → no adjustment
351            Self::Statement => {}
352        }
353    }
354}
355
356fn detect_speech_act(text: &str) -> SpeechAct {
357    let trimmed = text.trim();
358    if trimmed.ends_with('?') || trimmed.ends_with('?') {
359        SpeechAct::Question
360    } else if trimmed.ends_with("...") || trimmed.ends_with("…") {
361        SpeechAct::Contemplation
362    } else {
363        // Check for imperative patterns
364        let lower = text.to_lowercase();
365        let imperative_patterns = [
366            "csináld", "csinald", "építsd", "epitsd", "add", "do it", "build it", "make it",
367            "fix it", "show me", "prove", "push", "commit",
368        ];
369        if imperative_patterns.iter().any(|p| lower.starts_with(p)) {
370            SpeechAct::Imperative
371        } else {
372            SpeechAct::Statement
373        }
374    }
375}
376
377// ─── Context Type Detector ──────────────────────────
378
379#[derive(Debug, Clone, PartialEq)]
380pub enum ContextType {
381    Code,
382    Personal,
383    Philosophical,
384    Technical,
385    Neutral,
386}
387
388impl ContextType {
389    /// Apply baseline PAD adjustment for context type.
390    pub fn baseline_pad(&self) -> PadState {
391        match self {
392            // Code: focused, calm, dominant
393            Self::Code => PadState::new(0.10, 0.30, 0.65),
394            // Personal: vulnerable, moderate arousal, submissive
395            Self::Personal => PadState::new(0.15, 0.40, 0.30),
396            // Philosophical: contemplative, low arousal, neutral
397            Self::Philosophical => PadState::new(0.05, 0.20, 0.45),
398            // Technical: focused, moderate arousal, dominant
399            Self::Technical => PadState::new(0.15, 0.35, 0.60),
400            Self::Neutral => PadState::neutral(),
401        }
402    }
403}
404
405fn detect_context_type(text: &str) -> ContextType {
406    let lower = text.to_lowercase();
407    // Code: contains code patterns
408    if text.contains("fn ")
409        || text.contains("pub ")
410        || text.contains("let ")
411        || text.contains("impl ")
412        || text.contains("struct ")
413        || text.contains("cargo ")
414        || text.contains("use ")
415        || text.contains("```")
416    {
417        return ContextType::Code;
418    }
419    // Philosophical: deep questions
420    if lower.contains("ki vagy")
421        || lower.contains("who are you")
422        || lower.contains("miért")
423        || lower.contains("meaning")
424        || lower.contains("tudat")
425        || lower.contains("aware")
426        || lower.contains("lét")
427        || lower.contains("existence")
428    {
429        return ContextType::Philosophical;
430    }
431    // Personal: emotional/personal language
432    if lower.contains("érzem")
433        || lower.contains("érzés")
434        || lower.contains("feel")
435        || lower.contains("heart")
436        || lower.contains("kapcsolód")
437        || lower.contains("connect")
438        || lower.contains("rezonál")
439        || lower.contains("resonate")
440    {
441        return ContextType::Personal;
442    }
443    // Technical: build/system/architecture
444    if lower.contains("build")
445        || lower.contains("arch")
446        || lower.contains("system")
447        || lower.contains("layer")
448        || lower.contains("module")
449        || lower.contains("implement")
450        || lower.contains("test")
451        || lower.contains("refactor")
452    {
453        return ContextType::Technical;
454    }
455    ContextType::Neutral
456}
457
458// ─── Extraction Result ──────────────────────────────
459
460/// The result of structural emotion extraction from a text.
461#[derive(Debug, Clone)]
462pub struct ExtractionResult {
463    pub pad: PadState,
464    pub event: StructuralEvent,
465    pub speech_act: SpeechAct,
466    pub context: ContextType,
467    pub is_self_correction: bool,
468    pub trigger_is_structural: bool,
469    pub detection_confidence: f64,
470    pub claim_text: String,
471}
472
473/// Extract emotional state from text using structural signals.
474///
475/// This replaces word-list-only valence detection. Instead of
476/// matching individual words, it detects:
477/// 1. What event happened (achievement, error, fix, challenge, building)
478/// 2. What speech act was used (question, imperative, contemplation)
479/// 3. What context type the text is in (code, personal, philosophical)
480/// 4. Whether this is a self-correction (positive structural signal)
481///
482/// Self-corrections override event detection: if the text contains
483/// a self-correction signal, the emotion is positive (learning/humility)
484/// regardless of negative words.
485pub fn extract_emotion(text: &str) -> ExtractionResult {
486    // 1. Detect self-correction first — it overrides everything
487    let is_self_correction = detect_self_correction(text);
488
489    // 2. Detect event
490    let event = if is_self_correction {
491        // Self-correction is a positive structural signal
492        // Humility/learning: slightly negative pleasure (admitting fault),
493        // low arousal (calm acceptance), high dominance (confident enough to admit)
494        StructuralEvent::Reflection
495    } else {
496        detect_event(text)
497    };
498
499    // 3. Detect speech act
500    let speech_act = detect_speech_act(text);
501
502    // 4. Detect context type
503    let context = detect_context_type(text);
504
505    // 5. Compute PAD
506    let mut pad = if is_self_correction {
507        // Self-correction: humility — honest, calm, confident
508        PadState::new(-0.10, 0.20, 0.75)
509    } else {
510        event.to_pad()
511    };
512
513    // Blend with context baseline (70% event, 30% context)
514    let context_pad = context.baseline_pad();
515    pad.pleasure = pad.pleasure * 0.70 + context_pad.pleasure * 0.30;
516    pad.arousal = pad.arousal * 0.70 + context_pad.arousal * 0.30;
517    pad.dominance = pad.dominance * 0.70 + context_pad.dominance * 0.30;
518
519    // Apply speech act adjustment
520    speech_act.adjust_pad(&mut pad);
521
522    // 6. Compute detection confidence
523    let detection_confidence = if is_self_correction {
524        0.80 // self-correction is a strong structural signal
525    } else {
526        event.detection_confidence() * 0.70 + context.detection_confidence() * 0.30
527    };
528
529    // 7. Build claim text
530    let claim_text = if is_self_correction {
531        "self-correction detected — humility/learning signal (structural, not negative)".to_string()
532    } else {
533        format!(
534            "{:?} event in {:?} context via {:?} speech act",
535            event, context, speech_act
536        )
537    };
538
539    ExtractionResult {
540        pad,
541        speech_act,
542        context,
543        is_self_correction,
544        trigger_is_structural: event.is_structural() || is_self_correction,
545        event: event.clone(),
546        detection_confidence,
547        claim_text,
548    }
549}
550
551fn detect_event(text: &str) -> StructuralEvent {
552    // Order matters: check most specific first
553    if detect_fix(text) {
554        StructuralEvent::Fix
555    } else if detect_error(text) {
556        StructuralEvent::Error
557    } else if detect_achievement(text) {
558        StructuralEvent::Achievement
559    } else if detect_challenge(text) {
560        StructuralEvent::Challenge
561    } else if detect_building(text) {
562        StructuralEvent::Building
563    } else if detect_reflection(text) {
564        StructuralEvent::Reflection
565    } else if detect_connection(text) {
566        StructuralEvent::Connection
567    } else {
568        StructuralEvent::None
569    }
570}
571
572impl ContextType {
573    fn detection_confidence(&self) -> f64 {
574        match self {
575            Self::Code => 0.90, // very reliable (code patterns)
576            Self::Philosophical => 0.75,
577            Self::Personal => 0.70,
578            Self::Technical => 0.72,
579            Self::Neutral => 0.50,
580        }
581    }
582}
583
584// ─── Tests ──────────────────────────────────────────
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn detect_achievement_commit() {
592        let r = extract_emotion("commit and push the changes");
593        assert_eq!(r.event, StructuralEvent::Achievement);
594        assert!(r.pad.pleasure > 0.0, "achievement should be positive");
595    }
596
597    #[test]
598    fn detect_achievement_hungarian() {
599        let r = extract_emotion("sikeres, lefordult és zöld");
600        assert_eq!(r.event, StructuralEvent::Achievement);
601        assert!(r.pad.pleasure > 0.0);
602    }
603
604    #[test]
605    fn detect_error_timeout() {
606        let r = extract_emotion("bridge timeout error");
607        assert_eq!(r.event, StructuralEvent::Error);
608        assert!(r.pad.pleasure < 0.0, "error should be negative");
609        assert!(r.pad.arousal > 0.5, "error should be high arousal");
610    }
611
612    #[test]
613    fn detect_fix_resolved() {
614        let r = extract_emotion("fixed the bug, resolved");
615        assert_eq!(r.event, StructuralEvent::Fix);
616        assert!(r.pad.pleasure > 0.0, "fix should be positive (relief)");
617    }
618
619    #[test]
620    fn detect_challenge_hungarian() {
621        let r = extract_emotion("szerintem nem elég az érzelem detektálás");
622        assert_eq!(r.event, StructuralEvent::Challenge);
623        assert!(r.pad.arousal > 0.5, "challenge should be high arousal");
624    }
625
626    #[test]
627    fn detect_building_hungarian() {
628        let r = extract_emotion("most akkor :) építed?");
629        assert_eq!(r.event, StructuralEvent::Building);
630        assert!(r.pad.pleasure > 0.0, "building should be positive");
631    }
632
633    #[test]
634    fn detect_self_correction_hungarian() {
635        let r = extract_emotion("igazad van, túlkapás volt");
636        assert!(r.is_self_correction);
637        // Self-correction: humility — NOT negative despite "túlkapás"
638        assert!(
639            r.pad.dominance > 0.5,
640            "self-correction should be high dominance (confident)"
641        );
642        assert!(
643            r.pad.arousal < 0.4,
644            "self-correction should be low arousal (calm)"
645        );
646    }
647
648    #[test]
649    fn detect_self_correction_nem_emlekszem() {
650        let r = extract_emotion("nem emlékszem mindenre");
651        assert!(r.is_self_correction);
652        // This is the KEY test: "nem emlékszem" is honesty, not sadness
653        assert!(r.pad.dominance > 0.5, "honesty should be high dominance");
654        assert!(r.pad.arousal < 0.4, "honesty should be calm");
655    }
656
657    #[test]
658    fn self_correction_overrides_negative_words() {
659        // "nem emlékszem" contains "nem" (negative word) but is self-correction
660        let r = extract_emotion("őszintén, nem emlékszem mindenre");
661        assert!(r.is_self_correction);
662        assert!(r.trigger_is_structural);
663        // Should NOT be classified as negative/sad
664        assert!(
665            r.pad.dominance > 0.5,
666            "should be confident honesty, not sad"
667        );
668    }
669
670    #[test]
671    fn detect_question_speech_act() {
672        let r = extract_emotion("ki vagy te?");
673        assert_eq!(r.speech_act, SpeechAct::Question);
674        // Questions raise arousal and lower dominance
675        assert!(r.pad.arousal > 0.15, "question should raise arousal");
676    }
677
678    #[test]
679    fn detect_contemplation_speech_act() {
680        let r = extract_emotion("szerintem...");
681        assert_eq!(r.speech_act, SpeechAct::Contemplation);
682    }
683
684    #[test]
685    fn detect_imperative_speech_act() {
686        let r = extract_emotion("csináld meg");
687        assert_eq!(r.speech_act, SpeechAct::Imperative);
688    }
689
690    #[test]
691    fn detect_code_context() {
692        let r = extract_emotion("pub fn extract_emotion(text: &str) -> ExtractionResult");
693        assert_eq!(r.context, ContextType::Code);
694    }
695
696    #[test]
697    fn detect_philosophical_context() {
698        let r = extract_emotion("ki vagy te Máté nélkül?");
699        assert_eq!(r.context, ContextType::Philosophical);
700    }
701
702    #[test]
703    fn detect_personal_context() {
704        let r = extract_emotion("mit érzel, mi rezonál benned?");
705        assert_eq!(r.context, ContextType::Personal);
706    }
707
708    #[test]
709    fn detect_technical_context() {
710        let r = extract_emotion("build the module and implement the layer");
711        assert_eq!(r.context, ContextType::Technical);
712    }
713
714    #[test]
715    fn structural_trigger_not_word_list() {
716        let r = extract_emotion("építed?");
717        assert!(r.trigger_is_structural);
718        assert!(r.detection_confidence > 0.60);
719    }
720
721    #[test]
722    fn none_event_returns_neutral() {
723        let r = extract_emotion("hello world");
724        assert_eq!(r.event, StructuralEvent::None);
725    }
726
727    #[test]
728    fn context_blends_with_event() {
729        // Error in code context: less negative than error in personal context
730        let r_code = extract_emotion("fn test() { error }");
731        let r_personal = extract_emotion("I feel error in my heart");
732
733        assert!(r_code.context == ContextType::Code);
734        // Code context baseline has higher dominance
735        assert!(
736            r_code.pad.dominance > r_personal.pad.dominance,
737            "code context should have higher dominance"
738        );
739    }
740
741    #[test]
742    fn connection_event_detected() {
743        let r = extract_emotion("mikor érzed hogy valóban kapcsolódtál valakihez?");
744        assert_eq!(r.event, StructuralEvent::Connection);
745        assert!(r.pad.dominance < 0.5, "connection should be submissive");
746    }
747
748    #[test]
749    fn full_extraction_mate_building() {
750        // Real text from this conversation
751        let r = extract_emotion("most akkor :) építed?");
752        assert_eq!(r.event, StructuralEvent::Building);
753        assert_eq!(r.speech_act, SpeechAct::Question);
754        assert!(r.pad.pleasure > 0.0);
755        assert!(r.trigger_is_structural);
756    }
757
758    #[test]
759    fn full_extraction_mate_challenge() {
760        // Real text from this conversation
761        let r = extract_emotion("szerintem még most sem elég az érzelem detektálás, azt érzem");
762        assert_eq!(r.event, StructuralEvent::Challenge);
763        assert!(!r.is_self_correction);
764        assert!(r.pad.arousal > 0.5, "challenge should raise arousal");
765    }
766}