Files
emilandClaude Opus 4.7 ee8a9fc5bc Fix cross-process recall: MEMB trailer + raw eval/sample in chat()
Two bugs were blocking memba's main promise (load .memb in a fresh
process → model continues with full recalled context):

1. llama_state_set_data() restores the C-level KV-cache + SSM hidden
   state, but llama-cpp-python's Python wrapper still reports
   n_tokens=0. The next eval() then decodes new tokens at offset 0
   and overwrites the loaded state.

   Fix: extend MEMB format with an optional 12-byte trailer appended
   after the CRC32. It carries the wrapper's n_tokens. The C library
   reads up to CRC and ignores anything past it, so files stay
   backward-compatible with libmemba; only the Python loader uses it.

2. Llama.__call__ / create_chat_completion / generate all re-tokenise
   the prompt on every call and clear the KV-cache when the new
   tokens don't prefix-match input_ids. That destroys any state we
   just loaded.

   Fix: rewrite Session.chat() to use raw tokenize → eval → sample.
   eval() appends tokens to the live state without resetting, and we
   handle stop-token detection ourselves.

Verified end-to-end on Nemotron-3-Nano-4B (hybrid 21x Mamba-2 + 4x
attention) — see experiments/README.md for the full findings log.
diag_session_nemotron.py, mood_batch_poc.py, mood_stream_poc.py and
recall_poc.py now all pass their cross-process tests; Falcon-Mamba
still fails because the trained model itself can't do cross-turn
recall — that was the original misdiagnosis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 13:18:31 +03:00

185 lines
7.0 KiB
Python

"""High-level Session API — wraps llama-cpp-python Llama + memba state I/O."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from . import core
# ── Chat formats ───────────────────────────────────────────────────
# Each entry maps a format name to (prompt_template, stop_strings).
# The template uses {prompt} as the placeholder for the user message.
CHAT_FORMATS: dict[str, tuple[str, list[str]]] = {
"chatml": (
"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n",
["<|im_end|>", "<|im_start|>"],
),
"raw": (
"{prompt}",
[],
),
}
class Session:
"""
A persistent SSM chat session backed by a memba state file.
The session auto-loads an existing state on construction (if one exists
for *session_id*) and accumulates context across calls to chat().
Call save() to persist the current state.
Parameters
----------
model_path: Path to the GGUF model file.
session_id: Logical name for this session; determines the state filename.
state_dir: Directory where .memb files are stored (created if absent).
n_gpu_layers: GPU layers to offload (0 = CPU-only, -1 = all layers).
n_ctx: Context window size in tokens.
verbose: Forward llama.cpp log output to stderr.
"""
def __init__(
self,
model_path: str,
session_id: str = "default",
state_dir: str = "~/.memba/states",
n_gpu_layers: int = 0,
n_ctx: int = 4096,
verbose: bool = False,
chat_format: str = "chatml",
) -> None:
try:
from llama_cpp import Llama
except ImportError as e:
raise ImportError(
"llama-cpp-python is required: pip install llama-cpp-python"
) from e
if chat_format not in CHAT_FORMATS:
raise ValueError(
f"Unknown chat_format {chat_format!r}; choose from {list(CHAT_FORMATS)}"
)
self._model_path = str(Path(model_path).expanduser().resolve())
self._session_id = session_id
self._state_dir = Path(state_dir).expanduser()
self._state_dir.mkdir(parents=True, exist_ok=True)
self._chat_format = chat_format
self._llama = Llama(
model_path=self._model_path,
n_ctx=n_ctx,
n_gpu_layers=n_gpu_layers,
verbose=verbose,
)
# Auto-load existing state if present
sp = self._state_path()
if sp.exists():
core.load_state(self._llama, self._model_path, str(sp))
# ── Public methods ─────────────────────────────────────────────
def chat(self, prompt: str, max_tokens: int = 512,
temperature: float = 0.1, top_k: int = 1) -> str:
"""
Feed *prompt* to the model (wrapped in the active chat format) and
return the generated text.
This uses raw tokenize+eval+sample rather than ``Llama.__call__`` or
``create_chat_completion`` because those high-level helpers re-tokenise
the entire prompt and then reset the KV-cache when the prefix does not
match — which destroys any state loaded from a .memb file. Going
through ``llama.eval()`` directly *appends* new tokens to the live
state, which is exactly what memba needs.
For SSM/hybrid models the hidden state accumulates in llama_context
across calls — there is no explicit message history list, the recurrent
state IS the memory. Call save() at any checkpoint you want to resume
from later.
"""
template, stops = CHAT_FORMATS[self._chat_format]
wrapped = template.format(prompt=prompt)
# Tokenise the wrapped turn. Add BOS only when the context is fresh
# (no prior tokens in either the live conversation or a loaded state).
is_fresh = self._llama.n_tokens == 0
new_tokens = self._llama.tokenize(
wrapped.encode("utf-8"),
add_bos=is_fresh,
special=True,
)
# Append to the live state. eval() does NOT reset the KV-cache.
self._llama.eval(new_tokens)
# Build the set of single-token stops + add EOS.
stop_token_ids = {self._llama.token_eos()}
for s in stops:
for t in self._llama.tokenize(s.encode("utf-8"), add_bos=False, special=True):
stop_token_ids.add(t)
out_tokens: list[int] = []
for _ in range(max_tokens):
tok = self._llama.sample(top_k=top_k, temp=temperature)
out_tokens.append(tok)
# Always eval the sampled token so it lives in the state too —
# that way the next chat() turn sees the assistant reply as
# part of the conversation.
self._llama.eval([tok])
if tok in stop_token_ids:
break
# Multi-token stop-string check (some stops span several BPE pieces)
if stops:
snippet = self._llama.detokenize(out_tokens).decode("utf-8", errors="ignore")
if any(s in snippet for s in stops):
break
text = self._llama.detokenize(out_tokens).decode("utf-8", errors="ignore")
for s in stops:
if s in text:
text = text.split(s)[0]
return text.strip()
def save(self, session_id: Optional[str] = None) -> Path:
"""Persist the current state. Returns the path written."""
path = self._state_path(session_id)
core.save_state(self._llama, self._model_path, str(path))
return path
def load(self, session_id: Optional[str] = None) -> None:
"""Restore state from a (possibly different) session."""
path = self._state_path(session_id)
if not path.exists():
raise FileNotFoundError(f"No state file found: {path}")
core.load_state(self._llama, self._model_path, str(path))
@property
def state_size(self) -> int:
"""Current serialised byte size of the SSM hidden state."""
return core.get_state_size(self._llama)
@property
def session_id(self) -> str:
return self._session_id
@property
def model_path(self) -> str:
return self._model_path
# ── Private ────────────────────────────────────────────────────
def _state_path(self, session_id: Optional[str] = None) -> Path:
sid = session_id if session_id is not None else self._session_id
return self._state_dir / f"{sid}.memb"
def __repr__(self) -> str:
return (
f"Session(model={Path(self._model_path).name!r}, "
f"session_id={self._session_id!r}, "
f"state_dir={str(self._state_dir)!r})"
)