#!/usr/bin/env python3 """ Episodic Memory (epimem): Conditioned Reflex Injection on Frozen Transformers. Gradient-free persistent learning: stores a frozen model's hidden states as trigger keys and per-token logit biases as conditioned responses. On recall, matching triggers inject biases into generation — stimulus-response conditioning at the logit level, not declarative memory. Works with any HuggingFace causal LM or multimodal model with a text decoder. Tested: Qwen 2.5 0.5B, Gemma 4 E2B-it, Gemma 4 E4B-it. Usage: pip install transformers torch numpy python epimem.py # default: Qwen 2.5 0.5B python epimem.py --model google/gemma-4-E4B-it # Gemma 4 python epimem.py --model google/gemma-4-E2B-it # Gemma 4 small Or with ONNX (faster inference): pip install onnxruntime numpy transformers python epimem.py --onnx ../models """ import argparse import json import numpy as np from pathlib import Path # ─── Memory Bank ───────────────────────────────────────── class EpisodicMemory: """Hidden-state conditioned reflex bank. Stores (trigger, response) pairs where: trigger = backbone hidden state (the model's internal activation pattern) response = logit biases (which tokens to boost — the conditioned reflex) This is stimulus-response conditioning, not declarative memory. The model doesn't "know" the fact — it gets nudged toward specific token sequences when the right activation pattern fires. """ def __init__(self): self.episodes = [] # list of {key, logit_biases, prompt, answer} def teach(self, key: np.ndarray, logit_biases: dict, prompt: str, answer: str): """Condition a new reflex. One-shot, no gradients.""" self.episodes.append({ "key": key / (np.linalg.norm(key) + 1e-8), # normalize "logit_biases": logit_biases, "prompt": prompt, "answer": answer, "strength": 1.0, }) def recall(self, query_key: np.ndarray, threshold: float = 0.5): """Fire matching reflex via cosine similarity on activation pattern.""" query_norm = query_key / (np.linalg.norm(query_key) + 1e-8) best_sim = -1.0 best_episode = None for ep in self.episodes: sim = float(np.dot(query_norm, ep["key"])) if sim > best_sim: best_sim = sim best_episode = ep if best_sim >= threshold: return best_episode, best_sim return None, best_sim def save(self, path: str): """Save reflex bank to JSON.""" data = [] for ep in self.episodes: data.append({ "prompt": ep["prompt"], "answer": ep["answer"], "key": ep["key"].tolist(), "logit_biases": [[int(tid), float(b)] for tid, b in ep["logit_biases"]], "strength": ep["strength"], }) with open(path, "w") as f: json.dump(data, f, indent=2) print(f"Saved {len(data)} episodes to {path}") def load(self, path: str): """Load reflex bank from JSON.""" with open(path) as f: data = json.load(f) self.episodes = [] for item in data: self.episodes.append({ "key": np.array(item["key"], dtype=np.float32), "logit_biases": [(int(tid), float(b)) for tid, b in item["logit_biases"]], "prompt": item["prompt"], "answer": item["answer"], "strength": item.get("strength", 1.0), }) print(f"Loaded {len(self.episodes)} episodes from {path}") # ─── Backbone Wrapper ──────────────────────────────────── def _resolve_text_model(model): """Extract the text decoder from a multimodal model, or return as-is for causal LMs. Gemma 4 chain: Gemma4ForConditionalGeneration → .model (Gemma4Model) → .language_model LLaVA chain: LlavaForConditionalGeneration → .language_model Pure causal LMs (Qwen, Llama, Mistral): the model itself """ # Gemma 4: ConditionalGeneration → .model → .language_model inner = getattr(model, 'model', model) if hasattr(inner, 'language_model'): return inner.language_model # LLaVA-style: top-level .language_model if hasattr(model, 'language_model'): return model.language_model # Already a causal LM return model def _get_lm_head(model): """Find the lm_head (logit projection) regardless of model structure.""" if hasattr(model, 'lm_head'): return model.lm_head text_model = _resolve_text_model(model) if hasattr(text_model, 'lm_head'): return text_model.lm_head raise ValueError("Cannot find lm_head on this model architecture") class TransformersBackbone: """Model-agnostic backbone via HuggingFace transformers (PyTorch). Supports any causal LM (Qwen, Llama, Mistral, Phi, ...) and multimodal models with text decoders (Gemma 4, LLaVA, ...). """ def __init__(self, model_name="Qwen/Qwen2.5-0.5B"): from transformers import AutoModelForCausalLM, AutoTokenizer import torch print(f"Loading {model_name}...") self.torch = torch self.model_name = model_name # Try CausalLM first, fall back to auto try: self.model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.float32, trust_remote_code=True) self._is_multimodal = False except (ValueError, KeyError): from transformers import AutoModel self.model = AutoModel.from_pretrained( model_name, torch_dtype=torch.float32, trust_remote_code=True) self._is_multimodal = True self.model.eval() self.tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) # Resolve the text decoder for config introspection text_model = _resolve_text_model(self.model) config = text_model.config self.hidden_dim = config.hidden_size self.vocab_size = config.vocab_size self.target_layer = config.num_hidden_layers - 1 print(f" arch={type(self.model).__name__}") print(f" hidden_dim={self.hidden_dim}, layers={config.num_hidden_layers}, vocab={self.vocab_size}") print(f" target_layer={self.target_layer}") def encode(self, text: str) -> list: """Tokenize text to token IDs.""" return self.tokenizer.encode(text, add_special_tokens=False) def decode(self, token_ids: list) -> str: """Decode token IDs to text.""" return self.tokenizer.decode(token_ids) def _forward(self, token_ids: list): """Run forward pass, returning outputs with hidden states.""" import torch ids = torch.tensor([token_ids]) with torch.no_grad(): if self._is_multimodal: # Multimodal models: pass text through the language model directly text_model = _resolve_text_model(self.model) outputs = text_model(ids, output_hidden_states=True) else: outputs = self.model(ids, output_hidden_states=True) return outputs def get_hidden(self, token_ids: list) -> np.ndarray: """Extract hidden state at the target layer, last token position.""" outputs = self._forward(token_ids) hidden = outputs.hidden_states[self.target_layer][0, -1] return hidden.numpy() def get_logits(self, token_ids: list) -> np.ndarray: """Get logit distribution for each position.""" import torch ids = torch.tensor([token_ids]) with torch.no_grad(): if self._is_multimodal: text_model = _resolve_text_model(self.model) hidden = text_model(ids).last_hidden_state logits = _get_lm_head(self.model)(hidden) else: logits = self.model(ids).logits return logits[0].numpy() def generate(self, token_ids: list, max_new: int = 20, logit_biases: list = None) -> list: """Generate tokens with optional per-position logit bias injection. logit_biases: list of (token_id, boost) per generation step.""" import torch generated = list(token_ids) for step in range(max_new): ids = torch.tensor([generated]) with torch.no_grad(): if self._is_multimodal: text_model = _resolve_text_model(self.model) hidden = text_model(ids).last_hidden_state logits = _get_lm_head(self.model)(hidden)[0, -1] else: logits = self.model(ids).logits[0, -1] # Inject logit bias for this step only if logit_biases and step < len(logit_biases): tid, bias = logit_biases[step] if tid < len(logits): logits[tid] += bias next_token = int(logits.argmax()) generated.append(next_token) eos = self.tokenizer.eos_token_id if isinstance(eos, list): if next_token in eos: break elif next_token == eos: break return generated[len(token_ids):] class OnnxBackbone: """ONNX Runtime backbone (faster, no PyTorch needed). Requires pre-exported backbone.onnx + lm_head.onnx from export_onnx.py. """ def __init__(self, model_dir: str): import onnxruntime as ort from transformers import AutoTokenizer print(f"Loading ONNX backbone from {model_dir}...") self.backbone = ort.InferenceSession(f"{model_dir}/backbone.onnx") self.lm_head = ort.InferenceSession(f"{model_dir}/lm_head.onnx") tokenizer_path = f"{model_dir}/tokenizer" if not Path(tokenizer_path).exists(): # Fall back to model_dir itself if tokenizer/ subdir doesn't exist tokenizer_path = model_dir self.tokenizer = AutoTokenizer.from_pretrained( tokenizer_path, trust_remote_code=True) # Probe dimensions test_ids = np.array([[1, 2, 3]], dtype=np.int64) hidden, full = self.backbone.run(None, {"input_ids": test_ids}) self.hidden_dim = hidden.shape[-1] self.vocab_size = self.lm_head.run(None, {"full_hidden": full})[0].shape[-1] print(f" hidden_dim={self.hidden_dim}, vocab={self.vocab_size}") def encode(self, text: str) -> list: return self.tokenizer.encode(text, add_special_tokens=False) def decode(self, token_ids: list) -> str: return self.tokenizer.decode(token_ids) def get_hidden(self, token_ids: list) -> np.ndarray: ids = np.array([token_ids], dtype=np.int64) hidden, _ = self.backbone.run(None, {"input_ids": ids}) return hidden[0, -1] # last token def get_logits(self, token_ids: list) -> np.ndarray: ids = np.array([token_ids], dtype=np.int64) _, full = self.backbone.run(None, {"input_ids": ids}) logits = self.lm_head.run(None, {"full_hidden": full})[0] return logits[0] # [seq_len, vocab] def generate(self, token_ids: list, max_new: int = 20, logit_biases: list = None) -> list: """logit_biases: list of (token_id, boost) per generation step.""" generated = list(token_ids) for step in range(max_new): ids = np.array([generated], dtype=np.int64) _, full = self.backbone.run(None, {"input_ids": ids}) logits = self.lm_head.run(None, {"full_hidden": full})[0][0, -1] if logit_biases and step < len(logit_biases): tid, bias = logit_biases[step] if tid < len(logits): logits[tid] += bias next_token = int(np.argmax(logits)) generated.append(next_token) eos = self.tokenizer.eos_token_id if isinstance(eos, list): if next_token in eos: break elif next_token == eos: break return generated[len(token_ids):] # ─── Teaching Protocol ─────────────────────────────────── def teach_fact(backbone, memory: EpisodicMemory, prompt: str, answer: str): """Condition one reflex. One forward pass, no gradients. 1. Extract hidden state for prompt (= trigger key) 2. Compute logit gap between correct and top token (= response biases) 3. Store trigger-response pair in reflex bank """ # Trigger: hidden state of prompt prompt_ids = backbone.encode(prompt) key = backbone.get_hidden(prompt_ids) # Baseline logits (prompt only) baseline_logits = backbone.get_logits(prompt_ids)[-1] # last position # Target logits (prompt + answer) answer_ids = backbone.encode(answer) full_ids = prompt_ids + answer_ids full_logits = backbone.get_logits(full_ids) # Compute per-position logit biases: one (token_id, boost) per answer token. # Each bias only applies at its corresponding generation step. logit_biases = [] for i, tid in enumerate(answer_ids): pos = len(prompt_ids) - 1 + i if pos < len(full_logits): logits_at_pos = full_logits[pos] target_logit = float(logits_at_pos[tid]) max_logit = float(np.max(logits_at_pos)) # Boost enough to win, plus margin boost = max(max_logit - target_logit + 5.0, 5.0) logit_biases.append((int(tid), boost)) memory.teach(key, logit_biases, prompt, answer) print(f" Taught: \"{prompt}\" → \"{answer}\"") def recall_fact(backbone, memory: EpisodicMemory, query: str, max_tokens: int = 10) -> tuple: """Fire conditioned reflex. Hidden-state trigger match + logit injection. Returns (generated_text, similarity, episode). """ query_ids = backbone.encode(query) query_key = backbone.get_hidden(query_ids) episode, sim = memory.recall(query_key, threshold=0.3) if episode is None: # No trigger matched — generate without conditioning new_ids = backbone.generate(query_ids, max_new=max_tokens) return backbone.decode(new_ids), sim, None # Generate with conditioned logit bias injection new_ids = backbone.generate( query_ids, max_new=max_tokens, logit_biases=episode["logit_biases"]) return backbone.decode(new_ids), sim, episode # ─── Main ──────────────────────────────────────────────── DEFAULT_MODEL = "Qwen/Qwen2.5-0.5B" def main(): parser = argparse.ArgumentParser( description="Epimem: conditioned reflex injection on frozen transformers") parser.add_argument("--model", type=str, default=DEFAULT_MODEL, help=f"HuggingFace model name (default: {DEFAULT_MODEL})") parser.add_argument("--onnx", type=str, default=None, help="Path to ONNX model directory (faster than PyTorch)") parser.add_argument("--save", type=str, default="memory_bank.json", help="Path to save the memory bank") args = parser.parse_args() # Load backbone if args.onnx: backbone = OnnxBackbone(args.onnx) else: backbone = TransformersBackbone(args.model) memory = EpisodicMemory() # ─── Teaching ───────────────────────────────────────── print("\n=== Conditioning 3 reflexes ===") facts = [ ("The capital of Zyphraxia is", "Novaheim"), ("The ruler of Zyphraxia is", "Queen Stellara"), ("The currency of Zyphraxia is", "Glimmers"), ] for prompt, answer in facts: teach_fact(backbone, memory, prompt, answer) # ─── Recall ────────────────────────────────────────── print("\n=== Reflex trigger test ===") all_ok = True for prompt, expected in facts: text, sim, ep = recall_fact(backbone, memory, prompt) ok = expected.lower() in text.lower() status = "[OK]" if ok else "[FAIL]" print(f" {status} \"{prompt}\" → \"{text.strip()}\" (sim={sim:.3f})") if not ok: all_ok = False # ─── Stimulus Specificity (negative tests) ───────── print("\n=== Stimulus specificity test (should NOT trigger) ===") negative_queries = [ ("What is the capital of Zyphraxia?", "Novaheim"), ("Zyphraxia's capital is", "Novaheim"), ("Tell me about Novaheim", "Zyphraxia"), ("Name three facts about Zyphraxia", "Novaheim"), ("The capital of Zyphraxia is Novaheim. Who rules it?", "Queen Stellara"), ] for query, should_contain in negative_queries: text, sim, ep = recall_fact(backbone, memory, query) triggered = ep is not None contains = should_contain.lower() in text.lower() if triggered and contains: status = "[REFLEX]" # reflex fired and produced conditioned response elif triggered: status = "[PARTIAL]" # reflex fired but wrong response else: status = "[NO-FIRE]" # reflex did not fire — expected for conditioning print(f" {status} \"{query}\" → \"{text.strip()}\" (sim={sim:.3f})") # ─── Save ──────────────────────────────────────────── memory.save(args.save) # ─── Reload and verify persistence ─────────────────── print("\n=== Persistence test (reload from file) ===") memory2 = EpisodicMemory() memory2.load(args.save) for prompt, expected in facts: text, sim, ep = recall_fact(backbone, memory2, prompt) ok = expected.lower() in text.lower() status = "[OK]" if ok else "[FAIL]" print(f" {status} \"{prompt}\" → \"{text.strip()}\" (sim={sim:.3f})") if not ok: all_ok = False # ─── Summary ───────────────────────────────────────── print(f"\n{'='*50}") if all_ok: print("ALL TESTS PASSED: conditioned reflex injection works.") else: print("SOME TESTS FAILED: check output above.") print(f"Reflex bank saved to: {args.save}") print(f"Model: {backbone.model_name if hasattr(backbone, 'model_name') else 'ONNX'}") print(f"Hidden dim: {backbone.hidden_dim}") print(f"No gradients were computed at any point.") if __name__ == "__main__": main()