openher / engine /genome /style_memory.py
kellyxiaowei's picture
Deploy OpenHer Gradio Space — gemma-4-E4B served on Modal
dff25f7 verified
Raw
History Blame Contribute Delete
16.4 kB
"""
ContinuousStyleMemory — KNN-based style memory with time-aware retrieval.
Adapted from prototypes/style_memory.py for server use.
Features:
- Context-space KNN retrieval with gravitational mass weighting
- Hawking radiation: memory mass decays exponentially over time
- Crystallization: nearby contexts merge (mass grows), distant create new memories
- Few-shot prompt builder with mass-tagged examples
"""
from __future__ import annotations
import json
import math
import os
import re
import sqlite3
import time
# Context dimension order (Critic output keys, used for KNN retrieval)
CONTEXT_KEYS = [
'conflict_level', 'user_emotion', 'user_engagement', 'user_vulnerability',
'topic_intimacy', 'conversation_depth', 'novelty_level', 'time_of_day',
]
# Physics constant
HAWKING_GAMMA = 0.001 # Decay rate (per hour): ~29 day half-life
def _l2_distance(vec_a, vec_b):
"""Euclidean distance (zero-dependency)."""
return math.sqrt(sum((a - b) ** 2 for a, b in zip(vec_a, vec_b)))
def _context_to_vec(context):
"""Convert Critic context dict to ordered vector for KNN retrieval."""
return [context.get(k, 0.0) for k in CONTEXT_KEYS]
def clean_action_markers(text: str) -> str:
"""Remove action/emotion stage directions from text.
Strips *action*, *action*, (action), (action), 「action」 patterns
in both Chinese and English, full-width and half-width.
"""
text = re.sub(r'\*[^*]+\*', '', text) # *sighs* *顿了顿*
text = re.sub(r'*[^*]+*', '', text) # *轻笑* full-width asterisk
text = re.sub(r'([^)]+)', '', text) # (沉默) full-width parens
text = re.sub(r'\([^)]+\)', '', text) # (pauses) half-width parens
text = re.sub(r'「[^」]+」', '', text) # 「沉默」 occasional
return re.sub(r'\s{2,}', ' ', text).strip()
def _hawking_mass(mass_raw, last_used_at, now, gamma=HAWKING_GAMMA):
"""
Hawking radiation: memory mass decays exponentially.
mass_eff = 1.0 + (mass_raw - 1.0) * e^(-γ * Δt_hours)
Base mass 1.0 never decays below (innate genes don't evaporate to 0).
"""
delta_hours = max(0.0, (now - last_used_at) / 3600.0)
excess = max(0.0, mass_raw - 1.0)
decayed_excess = excess * math.exp(-gamma * delta_hours)
return 1.0 + decayed_excess
class ContinuousStyleMemory:
"""
Continuous memory manifold engine v3 (time-arrow + Hawking radiation).
All memories live in a single pool, no public/private distinction.
Mass grows with crystallization, decays with time (Hawking radiation).
Retrieval uses time-decayed effective mass (mass_eff).
"""
def __init__(self, agent_id, db_dir=None, now=None, persona_id=None, hawking_gamma=None,
state_db_path=None):
self.agent_id = agent_id
self.hawking_gamma = hawking_gamma if hawking_gamma is not None else HAWKING_GAMMA
self.db_dir = db_dir or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
".data", "genome"
)
os.makedirs(self.db_dir, exist_ok=True)
self._persona_id = persona_id or agent_id
# Derive user_id from agent_id (format: "{persona_id}_{user_id}")
if persona_id and agent_id.startswith(persona_id + "_"):
self._user_id = agent_id[len(persona_id) + 1:]
else:
self._user_id = agent_id
# SQLite for personal memories (fallback to JSON for backward compat)
self._state_db_path = state_db_path or os.path.join(
os.path.dirname(self.db_dir), "openher.db"
)
self._init_db()
self._now = now or time.time()
# Unified memory pool
self._pool = []
self._genesis_count = 0
self._personal_count = 0
self._load()
def set_clock(self, now):
"""Inject external clock (for testing)."""
self._now = now
def _init_db(self):
"""Create style_memory and genesis_seed tables if not exists."""
conn = sqlite3.connect(self._state_db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS style_memory (
persona_id TEXT NOT NULL,
user_id TEXT NOT NULL,
memories TEXT NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (persona_id, user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS genesis_seed (
persona_id TEXT PRIMARY KEY,
seeds TEXT NOT NULL,
created_at REAL NOT NULL
)
""")
conn.commit()
conn.close()
def _auto_import_seeds(self):
"""Auto-import all seeds from seeds.bin on first boot (no manual step needed)."""
import gzip
seeds_bin = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"persona", "seeds.bin"
)
if not os.path.isfile(seeds_bin):
return
try:
with open(seeds_bin, "rb") as f:
data = json.loads(gzip.decompress(f.read()).decode("utf-8"))
for pid, seeds in data.items():
ContinuousStyleMemory.save_genesis_to_db(pid, seeds, self._state_db_path)
print(f"[genome] 🧬 auto-imported {len(data)} personas from seeds.bin")
except Exception as e:
print(f"[genome] ⚠️ auto-import failed: {e}")
def _load(self):
"""Load innate genes + learned experience into unified pool."""
self._pool = []
# Genesis from SQLite genesis_seed table
conn = sqlite3.connect(self._state_db_path)
row = conn.execute(
"SELECT seeds FROM genesis_seed WHERE persona_id = ?",
(self._persona_id,)
).fetchone()
conn.close()
# Auto-import from seeds.bin if table is empty (first boot after clone)
if not row:
self._auto_import_seeds()
conn = sqlite3.connect(self._state_db_path)
row = conn.execute(
"SELECT seeds FROM genesis_seed WHERE persona_id = ?",
(self._persona_id,)
).fetchone()
conn.close()
if row:
genesis = json.loads(row[0])
for mem in genesis:
mem.setdefault('mass', 1.0)
mem.setdefault('created_at', 0.0)
mem.setdefault('last_used_at', 0.0)
self._pool.append(mem)
self._genesis_count = len(genesis)
# Personal memories from SQLite
conn = sqlite3.connect(self._state_db_path)
row = conn.execute(
"SELECT memories FROM style_memory WHERE persona_id = ? AND user_id = ?",
(self._persona_id, self._user_id)
).fetchone()
conn.close()
if row:
personal = json.loads(row[0])
for mem in personal:
mem.setdefault('mass', 1.0)
mem.setdefault('created_at', self._now)
mem.setdefault('last_used_at', self._now)
self._pool.append(mem)
self._personal_count = len(personal)
@property
def total_memories(self):
return len(self._pool)
@property
def personal_count(self):
return self._personal_count
def retrieve(self, context, top_k=3, lang_preference=None):
"""
Gravitational mass + Hawking radiation retrieval.
effective_distance = physical_distance / √mass_eff
lang_preference: 'zh' or 'en'. When set, same-language seeds get
a soft distance bonus (cross-language seeds penalized 25%).
Language is auto-detected from monologue text.
"""
target = _context_to_vec(context)
now = self._now
scored = []
for mem in self._pool:
# Hard language filter: skip cross-language seeds
if lang_preference and mem.get('lang') and mem['lang'] != lang_preference:
continue
physical_dist = _l2_distance(target, mem['vector'])
mass_raw = mem.get('mass', 1.0)
last_used = mem.get('last_used_at', 0.0)
mass_eff = _hawking_mass(mass_raw, last_used, now, gamma=self.hawking_gamma)
effective_dist = physical_dist / math.sqrt(max(mass_eff, 0.01))
scored.append((effective_dist, physical_dist, mass_eff, mass_raw, mem))
scored.sort(key=lambda x: x[0])
results = []
for eff_dist, phys_dist, mass_eff, mass_raw, mem in scored[:top_k]:
mem['last_used_at'] = now
results.append({
'monologue': mem['monologue'],
'reply': mem['reply'],
'vector': mem['vector'],
'distance': round(eff_dist, 4),
'physical_distance': round(phys_dist, 4),
'mass_raw': mass_raw,
'mass_eff': round(mass_eff, 2),
'user_input': mem.get('user_input', ''),
'lang': mem.get('lang', ''),
})
self._last_retrieve_results = results
return results
def last_recall_info(self):
"""Return simplified info about the last KNN recall for debug visualization.
Returns list of {text, distance, mass} dicts, or empty list if no recall yet.
"""
results = getattr(self, '_last_retrieve_results', None)
if not results:
return []
return [
{
'text': r.get('user_input', r.get('monologue', ''))[:50],
'distance': r['distance'],
'mass': r.get('mass_eff', 1.0),
}
for r in results
]
def crystallize(self, context, monologue, reply, user_input=""):
"""
Memory crystallization (time-aware).
Nearby contexts → gravitational thickening + refresh timestamp.
New contexts → create new memory with initial mass=2.0.
"""
new_vec = [round(v, 4) for v in _context_to_vec(context)]
now = self._now
# Check if we can merge
best_idx = -1
best_dist = 999.0
for i, mem in enumerate(self._pool):
d = _l2_distance(new_vec, mem['vector'])
if d < best_dist:
best_dist = d
best_idx = i
if best_dist < 0.25 and best_idx >= 0:
# Gravitational thickening: increase mass + refresh timestamp
# but KEEP original content (don't overwrite distinctive memories)
# NOTE: this may mutate genesis entries in _pool (mass drift).
# Genesis mass resets on restart (reloaded from DB). Known behavior.
self._pool[best_idx]['mass'] = self._pool[best_idx].get('mass', 1.0) + 1.0
self._pool[best_idx]['last_used_at'] = now
# Only overwrite if new content is longer (richer)
if len(reply) > len(self._pool[best_idx].get('reply', '')):
self._pool[best_idx]['monologue'] = monologue
self._pool[best_idx]['reply'] = reply
self._pool[best_idx]['user_input'] = user_input
else:
# New memory
new_mem = {
"vector": new_vec,
"monologue": monologue,
"reply": reply,
"user_input": user_input,
"mass": 2.0,
"created_at": now,
"last_used_at": now,
}
self._pool.append(new_mem)
# Save personal memories to SQLite
personal_mems = [m for m in self._pool if m.get('mass', 1.0) > 1.0]
self._personal_count = len(personal_mems)
conn = sqlite3.connect(self._state_db_path)
conn.execute("""
INSERT INTO style_memory (persona_id, user_id, memories, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(persona_id, user_id) DO UPDATE SET
memories = excluded.memories,
updated_at = excluded.updated_at
""", (
self._persona_id,
self._user_id,
json.dumps(personal_mems, ensure_ascii=False),
self._now,
))
conn.commit()
conn.close()
return self._personal_count
def build_few_shot_prompt(self, context, top_k=3, monologue_only=False, lang='zh'):
"""Build few-shot prompt from retrieval results (with mass tags).
Args:
context: Critic context dict for KNN retrieval.
monologue_only: If True, only include monologue (no reply).
Legacy parameter, currently unused (single-pass mode).
lang: Label language ('zh' or 'en').
"""
memories = self.retrieve(context, top_k=top_k, lang_preference=lang)
is_en = lang == 'en'
if not memories:
if monologue_only:
return "(System: no inner feeling fragments available)" if is_en else "(系统:无可用的内心感受片段)"
return "(System: no subconscious slices available)" if is_en else "(系统:无可用的潜意识切片)"
parts = []
for i, mem in enumerate(memories):
mass_eff = mem.get('mass_eff', 1.0)
mass_raw = mem.get('mass_raw', 1.0)
if mass_raw > 1.0:
mass_tag = f"mass={mass_eff:.1f}/{mass_raw:.0f}" if is_en else f"质量={mass_eff:.1f}/{mass_raw:.0f}"
else:
mass_tag = "genesis" if is_en else "基因"
if monologue_only:
frag_label = "Inner thought fragment" if is_en else "内心念头片段"
parts.append(
f"--- {frag_label} {i+1} [{mass_tag}] ---\n"
f"{mem['monologue']}"
)
else:
slice_label = "Subconscious slice" if is_en else "潜意识切片"
mono_lbl = "[Inner Monologue]" if is_en else "【内心独白】"
reply_lbl = "[Final Reply]" if is_en else "【最终回复】"
parts.append(
f"--- {slice_label} {i+1} [{mass_tag}] ---\n"
f"{mono_lbl}{mem['monologue']}\n"
f"{reply_lbl}{mem['reply']}"
)
return "\n\n".join(parts)
def stats(self):
"""Return memory statistics (with Hawking radiation-decayed mass)."""
now = self._now
masses_raw = [m.get('mass', 1.0) for m in self._pool]
masses_eff = [
_hawking_mass(m.get('mass', 1.0), m.get('last_used_at', 0.0), now, gamma=self.hawking_gamma)
for m in self._pool
]
return {
'genesis_count': self._genesis_count,
'personal_count': self._personal_count,
'total': self.total_memories,
'total_mass_raw': sum(masses_raw),
'total_mass_eff': round(sum(masses_eff), 1),
}
@staticmethod
def save_genesis_to_db(persona_id: str, seeds: list, db_path: str):
"""Save genesis seeds to DB (used by calibrate and migration scripts).
Cleans action markers from monologue/reply before storing.
Upserts: existing data for the same persona_id will be replaced.
Warning: mutates seeds in-place (monologue/reply fields are cleaned).
"""
for seed in seeds:
if 'monologue' in seed:
seed['monologue'] = clean_action_markers(seed['monologue'])
if 'reply' in seed:
seed['reply'] = clean_action_markers(seed['reply'])
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS genesis_seed (
persona_id TEXT PRIMARY KEY,
seeds TEXT NOT NULL,
created_at REAL NOT NULL
)
""")
conn.execute("""
INSERT INTO genesis_seed (persona_id, seeds, created_at)
VALUES (?, ?, ?)
ON CONFLICT(persona_id) DO UPDATE SET
seeds = excluded.seeds,
created_at = excluded.created_at
""", (persona_id, json.dumps(seeds, ensure_ascii=False), time.time()))
conn.commit()
conn.close()