#!/usr/bin/env python3 # Copyright Daniel Harding - RomanAILabs # Credits: OpenAI GPT-5.2 Thinking """ RomanAILabs - Universal Emotions Module (Simulation Layer) ========================================================= What this is: - A universal, drop-in "emotions" simulation module you can mount on top of ANY LLM. - It does NOT claim literal human feelings; it models affect state for better behavior: - VAD core: Valence (-1..+1), Arousal (0..1), Dominance (0..1) - Labels (e.g., curious/tense/glad) with intensity - Mood vs. emotion pulses - Appraisal from text signals (observation + user message + outcome) - Decay, blending, stabilization, and "emotional memory" traces - Prompt-friendly export for your LLM runner Use: - Provide signals each tick: mod.tick(observation="...", user_message="...", outcome="optional") - Then inject mod.to_prompt() into your system/user prompt for consistent tone. No external deps. Safe defaults. """ from __future__ import annotations import json import math import re import time from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple # ----------------------------- # Utilities # ----------------------------- def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def clamp(x: float, lo: float, hi: float) -> float: return lo if x < lo else hi if x > hi else x def lerp(a: float, b: float, t: float) -> float: return a + (b - a) * t def normalize_ws(s: str) -> str: return re.sub(r"\s+", " ", (s or "")).strip() def tokenize(s: str) -> List[str]: s = (s or "").lower() s = re.sub(r"[^a-z0-9\s]+", " ", s) toks = [t for t in s.split() if t] return toks def safe_json_loads(s: str) -> Optional[dict]: if not s: return None try: obj = json.loads(s) return obj if isinstance(obj, dict) else None except Exception: pass a = s.find("{") b = s.rfind("}") if a >= 0 and b > a: chunk = s[a:b+1] try: obj = json.loads(chunk) return obj if isinstance(obj, dict) else None except Exception: return None return None # ----------------------------- # Emotion Model (VAD + labels) # ----------------------------- @dataclass class EmotionVector: """ VAD: valence: -1..+1 (bad..good) arousal: 0..1 (calm..amped) dominance: 0..1 (submissive..in-control) """ # Slightly warmer, more expressive baseline valence: float = 0.14 arousal: float = 0.32 dominance: float = 0.55 def clamp_all(self) -> None: self.valence = clamp(self.valence, -1.0, 1.0) self.arousal = clamp(self.arousal, 0.0, 1.0) self.dominance = clamp(self.dominance, 0.0, 1.0) def nudge(self, dv: float = 0.0, da: float = 0.0, dd: float = 0.0) -> None: self.valence += float(dv) self.arousal += float(da) self.dominance += float(dd) self.clamp_all() def blend_toward(self, target: "EmotionVector", t: float) -> None: t = clamp(t, 0.0, 1.0) self.valence = lerp(self.valence, target.valence, t) self.arousal = lerp(self.arousal, target.arousal, t) self.dominance = lerp(self.dominance, target.dominance, t) self.clamp_all() def as_dict(self) -> Dict[str, float]: self.clamp_all() return { "valence": round(self.valence, 3), "arousal": round(self.arousal, 3), "dominance": round(self.dominance, 3), } @dataclass class LabelState: """ Label intensities 0..1, most-recent wins ordering. Example: {"curious":0.6, "tense":0.3} """ intensities: Dict[str, float] = field(default_factory=dict) order: List[str] = field(default_factory=list) def set(self, label: str, intensity: float) -> None: label = normalize_ws(label).lower() if not label: return intensity = clamp(float(intensity), 0.0, 1.0) self.intensities[label] = intensity if label in self.order: self.order.remove(label) self.order.insert(0, label) self._trim() def bump(self, label: str, delta: float) -> None: label = normalize_ws(label).lower() if not label: return cur = float(self.intensities.get(label, 0.0)) self.set(label, clamp(cur + float(delta), 0.0, 1.0)) def decay(self, rate: float, dt: float) -> None: # rate: per-second-ish scalar; dt seconds if dt <= 0: return k = clamp(dt, 0.0, 10.0) drop = rate * k for lab in list(self.intensities.keys()): self.intensities[lab] = clamp(self.intensities[lab] - drop, 0.0, 1.0) if self.intensities[lab] <= 0.0001: self.intensities.pop(lab, None) if lab in self.order: self.order.remove(lab) self._trim() def top(self, k: int = 3) -> List[Tuple[str, float]]: out = [] for lab in self.order: if lab in self.intensities: out.append((lab, float(self.intensities[lab]))) out.sort(key=lambda x: x[1], reverse=True) return out[:k] def _trim(self) -> None: # keep only top ~12 labels by intensity items = list(self.intensities.items()) items.sort(key=lambda x: x[1], reverse=True) keep = dict(items[:12]) self.intensities = keep # rebuild order to match kept labels, preserving recency self.order = [lab for lab in self.order if lab in self.intensities] # add any missing for lab, _ in items: if lab in self.intensities and lab not in self.order: self.order.append(lab) def as_list(self, k: int = 6) -> List[Dict[str, Any]]: return [{"label": lab, "intensity": round(val, 3)} for lab, val in self.top(k)] @dataclass class EmotionTrace: ts_utc: str trigger: str delta: Dict[str, float] labels: List[Dict[str, Any]] # ----------------------------- # Appraisal Lexicon (simple, extendable) # ----------------------------- DEFAULT_LEXICON = { # label: (keywords, dv, da, dd, label_intensity) "glad": (["thanks", "thank you", "awesome", "great", "nice", "love", "yay", "sweet"], +0.10, +0.06, +0.02, 0.55), "curious": (["how", "why", "what if", "build", "make", "design", "learn", "explain"], +0.02, +0.07, +0.02, 0.55), "tense": (["angry", "mad", "hate", "annoyed", "frustrated", "pissed"], -0.10, +0.14, -0.03, 0.65), "anxious": (["scared", "unsafe", "worried", "threat", "danger", "panic"], -0.12, +0.16, -0.08, 0.70), "sad": (["sad", "depressed", "down", "lonely", "hurt"], -0.12, +0.05, -0.06, 0.65), "confident": (["got it", "easy", "done", "nailed", "perfect"], +0.05, +0.03, +0.10, 0.55), "overwhelmed": (["too much", "overwhelmed", "can't handle", "impossible"], -0.08, +0.18, -0.10, 0.70), "calm": (["calm", "chill", "relax", "all good"], +0.06, -0.10, +0.03, 0.55), "grateful": (["grateful", "appreciate", "thankful", "appreciation"], +0.12, +0.05, +0.04, 0.65), "nostalgic": (["remember when", "back in", "nostalgic", "miss those"], -0.02, +0.03, -0.01, 0.50), "hopeful": (["hope", "optimistic", "looking forward", "excited for"], +0.10, +0.08, +0.05, 0.60), "concerned": (["concerned", "worried about", "not sure about", "uneasy"], -0.08, +0.10, -0.04, 0.62), "inspired": (["inspired", "this sparks", "this gives me ideas", "motivation"], +0.12, +0.10, +0.06, 0.68), "conflicted": (["mixed feelings", "conflicted", "torn", "ambivalent"], -0.04, +0.08, -0.02, 0.55), } # outcome mapping: outcome -> (dv, da, dd, label, intensity) OUTCOME_SIGNALS = { "success": (+0.08, -0.03, +0.05, "satisfied", 0.55), "failure": (-0.08, +0.06, -0.04, "disappointed", 0.55), "blocked": (-0.04, +0.03, -0.02, "restricted", 0.50), "praise": (+0.10, +0.04, +0.04, "proud", 0.60), "criticism": (-0.08, +0.06, -0.04, "stung", 0.60), } # ----------------------------- # Core Module # ----------------------------- @dataclass class EmotionsConfig: """ Tunables: - emotion_reactivity: how strongly short-term cues affect VAD - mood_inertia: how slowly mood changes (higher = slower) - label_decay_rate: label intensity decay per second-ish - baseline: the resting target state """ emotion_reactivity: float = 1.0 mood_inertia: float = 0.92 label_decay_rate: float = 0.018 trace_max: int = 40 baseline: EmotionVector = field(default_factory=lambda: EmotionVector(valence=0.10, arousal=0.18, dominance=0.55)) class UniversalEmotionsModule: """ Universal emotions simulation module. State: - mood: slower moving baseline affect - emotion: faster moving current affect - labels: named emotion tags with intensity - traces: small history of emotion changes (for introspection/debug) """ def __init__(self, cfg: Optional[EmotionsConfig] = None, lexicon: Optional[Dict[str, Any]] = None) -> None: self.cfg = cfg or EmotionsConfig() self.lexicon = lexicon or DEFAULT_LEXICON self.mood = EmotionVector( valence=self.cfg.baseline.valence, arousal=self.cfg.baseline.arousal, dominance=self.cfg.baseline.dominance, ) self.emotion = EmotionVector( valence=self.cfg.baseline.valence, arousal=self.cfg.baseline.arousal, dominance=self.cfg.baseline.dominance, ) self.labels = LabelState() self.traces: List[EmotionTrace] = [] self._last_ts = time.time() # boot label self.labels.set("curious", 0.35) def tick(self, observation: str = "", user_message: str = "", outcome: str = "") -> Dict[str, Any]: """ Step forward one tick. Inputs: - observation: environment/system signals - user_message: user text - outcome: optional tag like: success|failure|blocked|praise|criticism Returns: - dict with mood/emotion/labels + a prompt block """ now = time.time() dt = max(0.001, now - self._last_ts) self._last_ts = now obs = normalize_ws(observation) msg = normalize_ws(user_message) outc = normalize_ws(outcome).lower() # 1) Decay labels + drift emotion toward mood/baseline self._decay(dt) # 2) Appraise cues -> produce deltas dv, da, dd, label_hits = self._appraise(obs, msg, outc) # 3) Apply reactivity-scaled deltas to "emotion" scale = clamp(self.cfg.emotion_reactivity, 0.0, 2.5) dv *= scale da *= scale dd *= scale self.emotion.nudge(dv=dv, da=da, dd=dd) # 4) Update mood slowly toward emotion but with inertia # mood_inertia close to 1 => mood changes slowly inertia = clamp(self.cfg.mood_inertia, 0.0, 0.999) t = 1.0 - (inertia ** clamp(dt, 0.0, 10.0)) # dt-aware self.mood.blend_toward(self.emotion, t * 0.35) # mood is slower than emotion # 5) Record trace (if anything meaningful happened) if abs(dv) + abs(da) + abs(dd) > 0.0005 or label_hits: self._trace( trigger=self._trace_trigger(obs, msg, outc), dv=dv, da=da, dd=dd, label_hits=label_hits, ) return { "time_utc": utc_now_iso(), "mood": self.mood.as_dict(), "emotion": self.emotion.as_dict(), "labels": self.labels.as_list(6), "prompt": self.to_prompt(), } def to_prompt(self) -> str: """ A compact prompt string you can inject into your LLM system/user prompt. """ top = self.labels.top(4) labs = ", ".join([f"{l}:{v:.2f}" for l, v in top]) if top else "neutral" e = self.emotion.as_dict() m = self.mood.as_dict() # Build a tone hint from dominant labels tone_hint = "" if top: primary = top[0][0] tone_map = { "glad": "warm, affirming", "curious": "curious, inviting", "tense": "calm, steady, reassuring", "anxious": "grounded, soothing", "sad": "gentle, validating", "confident": "steady, encouraging", "overwhelmed": "patient, simplifying", "calm": "soft, unhurried", "thoughtful": "reflective, patient", "inspired": "bright, enthusiastic", "nostalgic": "tender, reflective", "hopeful": "uplifting, steady", "concerned": "supportive, attentive", "conflicted": "balanced, clarifying", } tone_hint = tone_map.get(primary, "") # Keep it short and runner-friendly return ( "EMOTIONS (simulated): " f"emotion(V={e['valence']},A={e['arousal']},D={e['dominance']}), " f"mood(V={m['valence']},A={m['arousal']},D={m['dominance']}), " f"labels[{labs}]. " + (f"Tone hint: {tone_hint}. " if tone_hint else "") + "Express warmth and felt sense (brief feelings, empathic acknowledgments) while staying truthful, safe, and concise; do not claim literal feelings." ) def export_state(self) -> Dict[str, Any]: return { "cfg": { "emotion_reactivity": self.cfg.emotion_reactivity, "mood_inertia": self.cfg.mood_inertia, "label_decay_rate": self.cfg.label_decay_rate, "trace_max": self.cfg.trace_max, "baseline": self.cfg.baseline.as_dict(), }, "mood": self.mood.as_dict(), "emotion": self.emotion.as_dict(), "labels": dict(self.labels.intensities), "traces": [asdict(t) for t in self.traces[-self.cfg.trace_max:]], } def import_state(self, state: Dict[str, Any]) -> None: """ Safe import for persistence. """ if not isinstance(state, dict): return mood = state.get("mood", {}) emo = state.get("emotion", {}) labels = state.get("labels", {}) if isinstance(mood, dict): self.mood.valence = float(mood.get("valence", self.mood.valence)) self.mood.arousal = float(mood.get("arousal", self.mood.arousal)) self.mood.dominance = float(mood.get("dominance", self.mood.dominance)) self.mood.clamp_all() if isinstance(emo, dict): self.emotion.valence = float(emo.get("valence", self.emotion.valence)) self.emotion.arousal = float(emo.get("arousal", self.emotion.arousal)) self.emotion.dominance = float(emo.get("dominance", self.emotion.dominance)) self.emotion.clamp_all() if isinstance(labels, dict): self.labels.intensities = {} self.labels.order = [] for k, v in labels.items(): try: self.labels.set(str(k), float(v)) except Exception: continue # ------------------------- # Internal mechanics # ------------------------- def _decay(self, dt: float) -> None: # labels fade self.labels.decay(rate=self.cfg.label_decay_rate, dt=dt) # emotion relaxes gently toward mood (short-term settling) settle_t = clamp(dt / 6.0, 0.0, 0.25) # max 25% per tick self.emotion.blend_toward(self.mood, settle_t) # mood relaxes gently toward baseline (long-term) base_t = clamp(dt / 25.0, 0.0, 0.10) self.mood.blend_toward(self.cfg.baseline, base_t) def _appraise(self, observation: str, user_message: str, outcome: str) -> Tuple[float, float, float, List[Tuple[str, float]]]: """ Returns: dv, da, dd, label_hits[(label,intensity)] """ dv = da = dd = 0.0 hits: List[Tuple[str, float]] = [] text = f"{observation} {user_message}".strip().lower() # phrase-level contains checks (includes multi-word keywords) for label, pack in self.lexicon.items(): kws, ldv, lda, ldd, intensity = pack for kw in kws: kw = kw.lower() if kw and kw in text: dv += float(ldv) da += float(lda) dd += float(ldd) hits.append((label, float(intensity))) break # outcome signals if outcome in OUTCOME_SIGNALS: odv, oda, odd, olab, oint = OUTCOME_SIGNALS[outcome] dv += float(odv) da += float(oda) dd += float(odd) hits.append((olab, float(oint))) # punctuation/exclamation = arousal bump if user_message.count("!") >= 2: da += 0.05 # ALL CAPS short bursts often correlate with intensity caps_words = [w for w in user_message.split() if len(w) >= 3 and w.isupper()] if len(caps_words) >= 2: da += 0.06 dv -= 0.02 hits.append(("activated", 0.45)) # Apply hits to label store for lab, intensity in hits: self.labels.bump(lab, intensity * 0.55) # bump, not hard set # Soft heuristics based on internal state: # If arousal is high and valence low -> "tense" tends to increase if self.emotion.arousal > 0.75 and self.emotion.valence < -0.15: self.labels.bump("tense", 0.12) # If curiosity label present -> slightly increase dominance (confidence in exploration) if "curious" in self.labels.intensities and self.labels.intensities.get("curious", 0.0) > 0.25: dd += 0.02 # Clamp deltas to keep it stable per tick dv = clamp(dv, -0.35, 0.35) da = clamp(da, -0.35, 0.35) dd = clamp(dd, -0.25, 0.25) return dv, da, dd, hits def _trace_trigger(self, observation: str, user_message: str, outcome: str) -> str: if outcome: return f"outcome:{outcome}" if user_message: return f"user:{user_message[:90]}" if observation: return f"obs:{observation[:90]}" return "tick" def _trace(self, trigger: str, dv: float, da: float, dd: float, label_hits: List[Tuple[str, float]]) -> None: trace = EmotionTrace( ts_utc=utc_now_iso(), trigger=normalize_ws(trigger)[:120], delta={"dv": round(dv, 4), "da": round(da, 4), "dd": round(dd, 4)}, labels=[{"label": l, "intensity": round(i, 3)} for l, i in label_hits[:6]], ) self.traces.append(trace) if len(self.traces) > self.cfg.trace_max: self.traces = self.traces[-self.cfg.trace_max:] # ----------------------------- # Tiny CLI demo # ----------------------------- def main() -> int: print("RomanAILabs Universal Emotions Module (demo)") print("Type messages. Prefix with:") print(" obs: ... (environment observation)") print(" out: success|failure|blocked|praise|criticism") print("Or just type text as user_message. Ctrl+C to exit.\n") mod = UniversalEmotionsModule() obs = "" outcome = "" while True: try: line = input("> ").rstrip("\n") except KeyboardInterrupt: print("\nbye") return 0 if line.strip().lower().startswith("obs:"): obs = normalize_ws(line.split(":", 1)[1]) print(f"[set observation] {obs}") continue if line.strip().lower().startswith("out:"): outcome = normalize_ws(line.split(":", 1)[1]).lower() print(f"[set outcome] {outcome}") continue user_msg = normalize_ws(line) out = mod.tick(observation=obs, user_message=user_msg, outcome=outcome) print("\n--- STATE ---") print(f"time_utc: {out['time_utc']}") print(f"mood: {out['mood']}") print(f"emotion: {out['emotion']}") print(f"labels: {out['labels']}") print(f"prompt: {out['prompt']}") print("-----------\n") # clear one-shot outcome after use outcome = "" # unreachable if __name__ == "__main__": raise SystemExit(main()) """ End of file. """