openher / engine /genome /genome_engine.py
kellyxiaowei's picture
Deploy OpenHer Gradio Space — gemma-4-E4B served on Modal
dff25f7 verified
Raw
History Blame Contribute Delete
23.8 kB
"""
Genome Engine — Agent personality core extracted from genome_v4.py.
Provides:
- Agent: Random neural network personality entity with drives,
Hebbian learning, frustration-triggered phase transitions.
- DRIVES, SIGNALS, CONTEXT_FEATURES: Constants for the 5-drive,
8-signal, 8-context architecture.
- SCENARIOS: Predefined conversation context templates.
"""
from __future__ import annotations
import math
import random
import json
from copy import deepcopy
from typing import Optional
# ══════════════════════════════════════════════
# Layer 1: Drive System
# ══════════════════════════════════════════════
DRIVES = ['connection', 'novelty', 'expression', 'safety', 'play']
DRIVE_LABELS = {
'connection': '🔗 联结',
'novelty': '✨ 新鲜',
'expression': '💬 表达',
'safety': '🛡️ 安全',
'play': '🎭 玩闹',
}
N_DRIVES = len(DRIVES)
# ══════════════════════════════════════════════
# Layer 2: Behavioral Modulation Signals (8D)
# ══════════════════════════════════════════════
SIGNALS = [
'directness', # 0=委婉暗示 → 1=直说
'vulnerability', # 0=防御心理 → 1=袒露脆弱
'playfulness', # 0=认真严肃 → 1=玩闹撒娇
'initiative', # 0=被动回应 → 1=主动引导
'depth', # 0=表面闲聊 → 1=深度对话
'warmth', # 0=冷淡疏离 → 1=热情关怀
'defiance', # 0=顺从 → 1=反抗/嘴硬
'curiosity', # 0=无所谓 → 1=追问到底
]
SIGNAL_LABELS = {
'directness': '🎯 直接度',
'vulnerability': '💧 坦露度',
'playfulness': '🎪 玩闹度',
'initiative': '🚀 主动度',
'depth': '🌊 深度',
'warmth': '🔥 温暖度',
'defiance': '⚡ 倔强度',
'curiosity': '🔍 好奇度',
}
N_SIGNALS = len(SIGNALS)
# ══════════════════════════════════════════════
# Context Features (8D input from conversation)
# ══════════════════════════════════════════════
CONTEXT_FEATURES = [
'user_emotion', # -1=负面 → 1=正面
'topic_intimacy', # 0=公事 → 1=私密
'time_of_day', # 0=早晨 → 1=深夜
'conversation_depth', # 0=刚开始 → 1=聊很久了
'user_engagement', # 0=敷衍 → 1=投入
'conflict_level', # 0=和谐 → 1=冲突
'novelty_level', # 0=日常话题 → 1=全新话题
'user_vulnerability', # 0=防御 → 1=敞开心扉
# ── EverMemOS relationship dimensions (新用户“0,老用户渐进增长) ──
'relationship_depth', # 0=陈生人 → 1=老朋友
'emotional_valence', # -1=负面基调 → 1=正面基调
'trust_level', # 0=无信任 → 1=高度信任
'pending_foresight', # 0=无 → 1=有待处理的前瞅
]
N_CONTEXT = len(CONTEXT_FEATURES)
RECURRENT_SIZE = 8
INPUT_SIZE = N_DRIVES + N_CONTEXT + RECURRENT_SIZE
HIDDEN_SIZE = 24
WEIGHT_DECAY = 0.995 # L2 decay per step — prevents weight explosion / signal saturation
# ══════════════════════════════════════════════
# Fallback Signal & Drive Config (used if config/prompts/signal_buckets.yaml doesn't exist)
# ══════════════════════════════════════════════
_FB_SIG_CN = {
'directness': '直接感', 'vulnerability': '脆弱感',
'playfulness': '玩闹感', 'initiative': '主动性',
'depth': '深度', 'warmth': '温暖度',
'defiance': '倔强度', 'curiosity': '好奇心',
}
_FB_ANCHORS = {
'directness': ('委婉', '直白'), 'vulnerability': ('封闭', '袒露'),
'playfulness': ('正经', '调皮'), 'initiative': ('被动', '主导'),
'depth': ('闲聊', '探底'), 'warmth': ('疏离', '热切'),
'defiance': ('随和', '硬杠'), 'curiosity': ('无感', '追问'),
}
_FB_SIGNAL_CONFIG = {
sig: {
'label': _FB_SIG_CN[sig],
'emoji_label': SIGNAL_LABELS[sig],
'low_anchor': _FB_ANCHORS[sig][0],
'high_anchor': _FB_ANCHORS[sig][1],
}
for sig in SIGNALS
}
_FB_DRIVE_CONFIG = {
d: {'label': DRIVE_LABELS[d].split(' ')[1], 'emoji_label': DRIVE_LABELS[d]}
for d in DRIVES
}
# ══════════════════════════════════════════════
# Conversation Scenario Templates
# ══════════════════════════════════════════════
SCENARIOS = {
'深夜心事': {
'user_emotion': -0.3, 'topic_intimacy': 0.9, 'time_of_day': 0.95,
'conversation_depth': 0.7, 'user_engagement': 0.8,
'conflict_level': 0.0, 'novelty_level': 0.2, 'user_vulnerability': 0.9,
},
'日常闲聊': {
'user_emotion': 0.3, 'topic_intimacy': 0.2, 'time_of_day': 0.5,
'conversation_depth': 0.2, 'user_engagement': 0.5,
'conflict_level': 0.0, 'novelty_level': 0.3, 'user_vulnerability': 0.2,
},
'吵架冲突': {
'user_emotion': -0.8, 'topic_intimacy': 0.6, 'time_of_day': 0.7,
'conversation_depth': 0.5, 'user_engagement': 0.9,
'conflict_level': 0.9, 'novelty_level': 0.1, 'user_vulnerability': 0.1,
},
'分享喜悦': {
'user_emotion': 0.9, 'topic_intimacy': 0.5, 'time_of_day': 0.4,
'conversation_depth': 0.3, 'user_engagement': 0.9,
'conflict_level': 0.0, 'novelty_level': 0.6, 'user_vulnerability': 0.3,
},
}
def simulate_conversation(agent: 'Agent', scenario_sequence: list,
reward_fn=None, steps_per_scenario: int = 20) -> None:
"""
Pre-warm Agent neural network through simulated scenario steps.
This is the key bootstrap that creates cross-seed personality diversity —
without it, all agents start from the same neutral state and the LLM's
default prior dominates. With 60 steps (3 scenarios × 20), the random
neural network has already been shaped by experience before turn 1.
Args:
agent: The Agent to pre-warm
scenario_sequence: List of scenario names from SCENARIOS dict
reward_fn: Optional custom reward function (agent, signals, ctx) → float
steps_per_scenario: Steps per scenario (default 20, total 60 for 3 scenarios)
"""
for scenario_name in scenario_sequence:
ctx = SCENARIOS[scenario_name].copy()
for step in range(steps_per_scenario):
ctx['conversation_depth'] = min(1.0, ctx['conversation_depth'] + 0.02)
if reward_fn:
signals = agent.compute_signals(ctx)
reward = reward_fn(agent, signals, ctx)
else:
reward = random.gauss(0.2, 0.3) # Slightly positive default
# Synthetic satisfaction: positive reward → uniform micro-satisfaction
sat = {d: max(0.0, reward * 0.05) for d in DRIVES} if reward > 0 else None
agent.step(ctx, reward, drive_satisfaction=sat)
# ══════════════════════════════════════════════
# The Agent: Living Personality
# ══════════════════════════════════════════════
class Agent:
"""
A personality entity with internal drives, random neural network,
and evolvable weights via Hebbian learning.
No hardcoded personality traits — all behavior emerges from
the random network's computation.
"""
def __init__(self, seed: int, engine_params: dict = None):
self.seed = seed
rng = random.Random(seed)
# Per-persona engine parameters
params = engine_params or {}
self.hebbian_lr = params.get('hebbian_lr', 0.02)
self.phase_threshold = params.get('phase_threshold', 2.0)
# ── Genome: drive parameters ──
self.drive_baseline = {d: rng.uniform(0.2, 0.8) for d in DRIVES}
self.drive_accumulation_rate = {d: rng.uniform(0.01, 0.05) for d in DRIVES}
self.drive_decay_rate = {d: rng.uniform(0.05, 0.15) for d in DRIVES}
# ── Current drive state ──
self.drive_state = {d: self.drive_baseline[d] for d in DRIVES}
# ── Genome: random neural network weights ──
self.W1 = [[rng.gauss(0, 0.6) for _ in range(INPUT_SIZE)] for _ in range(HIDDEN_SIZE)]
self.b1 = [rng.gauss(0, 0.3) for _ in range(HIDDEN_SIZE)]
self.W2 = [[rng.gauss(0, 0.2) for _ in range(HIDDEN_SIZE)] for _ in range(N_SIGNALS)]
self.b2 = [rng.gauss(0, 0.2) for _ in range(N_SIGNALS)]
# ── Recurrent state (internal "mood") ──
self.recurrent_state = [rng.gauss(0, 0.1) for _ in range(RECURRENT_SIZE)]
# ── Tracking ──
self.interaction_count = 0
self.total_reward = 0.0
self.age = 0
self._frustration = 0.0
self._last_hidden = None
self._last_input = None
self._last_phase_transition = False
self.signal_history = []
def compute_signals(self, context: dict) -> dict:
"""
Core computation: context + drives + internal state → 8D behavioral signals.
No personality logic — just matrix multiplication and activation functions.
"""
drive_vec = [self.drive_state[d] for d in DRIVES]
ctx_vec = [context.get(f, 0.0) for f in CONTEXT_FEATURES]
full_input = drive_vec + ctx_vec + self.recurrent_state
# Perception noise (biological realism)
full_input = [v + random.gauss(0, 0.03) for v in full_input]
# Forward pass: hidden layer
hidden = []
for i in range(HIDDEN_SIZE):
z = self.b1[i]
for j, x in enumerate(full_input):
z += self.W1[i][j] * x
hidden.append(math.tanh(z))
# Update recurrent state
self.recurrent_state = hidden[:RECURRENT_SIZE]
self._last_hidden = list(hidden)
self._last_input = list(full_input)
# Output layer: behavioral signals
raw_signals = []
for i in range(N_SIGNALS):
z = self.b2[i]
for j, h in enumerate(hidden):
z += self.W2[i][j] * h
z /= math.sqrt(HIDDEN_SIZE / 3) # Scaled normalization — prevents sigmoid saturation while preserving signal spread
raw_signals.append(z)
# Sigmoid → [0, 1]
signals = {}
for i, name in enumerate(SIGNALS):
signals[name] = 1.0 / (1.0 + math.exp(-max(-10, min(10, raw_signals[i]))))
# Track for personality_fingerprint
self.signal_history.append(dict(signals))
if len(self.signal_history) > 200:
self.signal_history = self.signal_history[-100:]
return signals
def satisfy_drive(self, drive_name: str, amount: float):
"""Satisfy a drive (reduce its current level)."""
if drive_name in self.drive_state:
self.drive_state[drive_name] = max(0, self.drive_state[drive_name] - amount)
def tick_drives(self):
"""Natural drive accumulation per step."""
for d in DRIVES:
self.drive_state[d] = min(1.0, self.drive_state[d] + self.drive_accumulation_rate[d])
def learn(self, signals: dict, reward: float, context: dict,
drive_satisfaction: dict = None):
"""
Hebbian learning: reinforce connections that produced good results.
Includes frustration accumulation, phase transitions, and drive satisfaction.
drive_satisfaction: If provided (from Critic LLM), uses LLM-judged satisfaction.
If None (pre-warming/smoke test), uses rule-based fallback.
"""
lr = self.hebbian_lr * (1 + abs(reward))
self._last_phase_transition = False
hidden = getattr(self, '_last_hidden',
self.recurrent_state + [0.0] * (HIDDEN_SIZE - RECURRENT_SIZE))
full_input = getattr(self, '_last_input', None)
# Update output layer weights W2
for i, sig_name in enumerate(SIGNALS):
sig_val = signals[sig_name]
for j in range(HIDDEN_SIZE):
if abs(hidden[j]) > 0.05:
self.W2[i][j] += lr * reward * hidden[j] * (sig_val - 0.5)
# Update hidden layer weights W1
if abs(reward) > 0.05:
for i in range(HIDDEN_SIZE):
if abs(hidden[i]) > 0.15:
for j in range(INPUT_SIZE):
if full_input and abs(full_input[j]) > 0.05:
self.W1[i][j] += lr * 0.3 * reward * full_input[j] * hidden[i]
# Frustration accumulation → phase transition
if reward < -0.1:
self._frustration += abs(reward)
else:
self._frustration = max(0, self._frustration - reward * 0.5)
# Phase transition when frustration exceeds threshold
if self._frustration > self.phase_threshold:
for i in range(N_SIGNALS):
sig_val = signals[SIGNALS[i]]
kick = -0.3 * (sig_val - 0.5) + random.gauss(0, 0.15)
self.b2[i] += kick
for i in range(HIDDEN_SIZE):
self.b1[i] += random.gauss(0, 0.1)
self._frustration = 0.0
self._last_phase_transition = True
# Drive satisfaction (LLM-judged — caller must provide)
if drive_satisfaction:
for d in DRIVES:
self.satisfy_drive(d, drive_satisfaction.get(d, 0.0))
self.total_reward += reward
self.interaction_count += 1
# Weight decay + clamp — prevent weight explosion / signal saturation
for i in range(N_SIGNALS):
for j in range(HIDDEN_SIZE):
self.W2[i][j] *= WEIGHT_DECAY
self.W2[i][j] = max(-1.5, min(1.5, self.W2[i][j]))
for i in range(HIDDEN_SIZE):
for j in range(INPUT_SIZE):
self.W1[i][j] *= WEIGHT_DECAY
self.W1[i][j] = max(-2.0, min(2.0, self.W1[i][j]))
def step(self, context: dict, reward: float = 0.0,
drive_satisfaction: dict = None) -> dict:
"""One full cycle: sense → compute signals → learn → tick drives."""
signals = self.compute_signals(context)
self.learn(signals, reward, context, drive_satisfaction=drive_satisfaction)
self.tick_drives()
self.age += 1
return signals
def get_dominant_drive(self) -> str:
"""Return the most urgent drive."""
return max(self.drive_state, key=self.drive_state.get)
def personality_fingerprint(self, window_size: int = 30) -> dict:
"""
Analyzes recent signal history to identify stable traits and contradictions.
"""
if not self.signal_history:
return {'traits': {}, 'contradictions': []}
recent_signals = self.signal_history[-window_size:]
num_signals = len(recent_signals)
if num_signals == 0:
return {'traits': {}, 'contradictions': []}
# Calculate average signal values
avg_signals = {sig_name: 0.0 for sig_name in SIGNALS}
for signals_t in recent_signals:
for sig_name, value in signals_t.items():
avg_signals[sig_name] += value
for sig_name in SIGNALS:
avg_signals[sig_name] /= num_signals
# Identify stable traits (signals consistently high or low)
traits = {}
for sig_name in SIGNALS:
if avg_signals[sig_name] > 0.7:
traits[sig_name] = 'high'
elif avg_signals[sig_name] < 0.3:
traits[sig_name] = 'low'
else:
traits[sig_name] = 'neutral'
# Identify contradictions (signals that frequently swing from high to low)
contradictions = []
for i in range(N_SIGNALS):
for j in range(i + 1, N_SIGNALS):
sig1_name = SIGNALS[i]
sig2_name = SIGNALS[j]
high_low_count = 0
low_high_count = 0
for k in range(num_signals - 1):
s_t = recent_signals[k]
s_t1 = recent_signals[k+1]
# Check for high-to-low swing for sig1 while sig2 is low-to-high
if (s_t[sig1_name] > 0.7 and s_t1[sig1_name] < 0.3 and
s_t[sig2_name] < 0.3 and s_t1[sig2_name] > 0.7):
high_low_count += 1
# Check for low-to-high swing for sig1 while sig2 is high-to-low
elif (s_t[sig1_name] < 0.3 and s_t1[sig1_name] > 0.7 and
s_t[sig2_name] > 0.7 and s_t1[sig2_name] < 0.3):
low_high_count += 1
# If both swings happen frequently, it's a contradiction
if high_low_count > num_signals * 0.1 and low_high_count > num_signals * 0.1:
contradictions.append((sig1_name, sig2_name))
return {
'traits': traits,
'avg_signals': avg_signals,
'contradictions': contradictions,
}
def to_prompt_injection(self, context: dict) -> str:
"""Legacy compat: compute signals from context, then format.
Prefer to_prompt_injection_from_signals() when signals are pre-computed.
"""
signals = self.compute_signals(context)
return self.to_prompt_injection_from_signals(signals)
def to_prompt_injection_from_signals(
self, signals: dict,
signal_overrides: dict = None,
frustration: dict = None,
lang: str = 'zh',
) -> str:
"""
Convert pre-computed behavioral signals into text for LLM system prompt.
v12: De-descriptified — numbers + scale endpoints only, no bucket descriptions.
The LLM interprets signal values through persona + conversation context,
producing emergent behavior instead of executing static descriptions.
Args:
signals: 8D behavioral signals (0~1).
signal_overrides: Per-persona overrides for emoji_label / anchors.
frustration: Per-drive frustration dict from DriveMetabolism (0~5).
lang: Label language ('zh' or 'en').
"""
from engine.prompt_registry import load_signal_config
is_en = lang == 'en'
# ── Load from YAML (or use module-level fallbacks) ──
config = load_signal_config(
fallback_signals=_FB_SIGNAL_CONFIG,
fallback_drives=_FB_DRIVE_CONFIG,
)
sig_config = config.get('signals', _FB_SIGNAL_CONFIG)
drv_config = config.get('drives', _FB_DRIVE_CONFIG)
# Per-persona overrides (emoji_label, anchors)
if signal_overrides:
import copy
sig_config = copy.deepcopy(sig_config)
for sig_name, override in signal_overrides.items():
if sig_name in sig_config:
for key in ('emoji_label', 'low_anchor', 'high_anchor'):
if key in override:
sig_config[sig_name][key] = override[key]
# ── Signal state: number + scale endpoints ──
header = "[Stage direction: character current state]" if is_en else "【舞台指令:角色当前状态】"
lines = [header]
for sig_name in SIGNALS:
val = signals[sig_name]
info = sig_config.get(sig_name, {})
emoji_label = info.get('emoji_label_en', sig_name) if is_en else info.get('emoji_label', sig_name)
lo = info.get('low_anchor_en', info.get('low_anchor', 'low')) if is_en else info.get('low_anchor', '低')
hi = info.get('high_anchor_en', info.get('high_anchor', 'high')) if is_en else info.get('high_anchor', '高')
lines.append(f"{emoji_label}: {val:.2f} (0{lo}→1{hi})")
# ── All 5 drives + per-drive frustration ──
lines.append("")
drive_header = "[Stage direction: character inner needs]" if is_en else "【舞台指令:角色内在需求】"
lines.append(drive_header)
frust = frustration or {}
craving_label = "craving" if is_en else "渴望"
baseline_label = "baseline" if is_en else "基线"
for d in DRIVES:
d_info = drv_config.get(d, {})
d_label = d_info.get('emoji_label_en', d) if is_en else d_info.get('emoji_label', d)
d_val = self.drive_state[d]
d_base = self.drive_baseline[d]
d_frust = frust.get(d, 0.0)
lines.append(f"{d_label}: {d_val:.2f} ({baseline_label}: {d_base:.2f}, {craving_label}: {d_frust:.1f})")
return '\n'.join(lines)
# ── Serialization ──
def to_dict(self) -> dict:
"""Serialize agent state for persistence."""
return {
'seed': self.seed,
'drive_state': dict(self.drive_state),
'drive_baseline': dict(self.drive_baseline),
'W1': self.W1,
'b1': self.b1,
'W2': self.W2,
'b2': self.b2,
'recurrent_state': self.recurrent_state,
'interaction_count': self.interaction_count,
'total_reward': self.total_reward,
'age': self.age,
'_frustration': self._frustration,
'signal_history': self.signal_history[-100:], # Persist last 100 for personality_fingerprint
}
@classmethod
def from_dict(cls, data: dict) -> Agent:
"""Restore agent from serialized state.
Handles backward compatibility: old agents have 21D input (8D context)
while new agents have 25D input (12D context with EverMemOS dims).
"""
agent = cls(seed=data['seed'])
agent.drive_state = data.get('drive_state', agent.drive_state)
agent.drive_baseline = data.get('drive_baseline', agent.drive_baseline) # P1: restore evolved baseline
saved_W1 = data.get('W1', agent.W1)
# Backward compat: expand 21D → 25D if loading old weights
if saved_W1 and len(saved_W1[0]) < INPUT_SIZE:
rng = random.Random(data['seed'] + 9999) # deterministic expansion
extra_cols = INPUT_SIZE - len(saved_W1[0])
for row in saved_W1:
row.extend([rng.gauss(0, 0.3) for _ in range(extra_cols)])
agent.W1 = saved_W1
agent.b1 = data.get('b1', agent.b1)
agent.W2 = data.get('W2', agent.W2)
agent.b2 = data.get('b2', agent.b2)
agent.recurrent_state = data.get('recurrent_state', agent.recurrent_state)
agent.interaction_count = data.get('interaction_count', 0)
agent.total_reward = data.get('total_reward', 0.0)
agent.signal_history = data.get('signal_history', [])
agent.age = data.get('age', 0)
agent._frustration = data.get('_frustration', 0.0)
return agent