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

213 lines
8.1 KiB
Python

"""
recall_poc.py — proof of concept for the 'recall' product idea.
A daemon would normally feed events into Mamba state continuously. For the
POC we batch-feed the last N days of git activity from a set of repos, save
the resulting state, then in a SEPARATE invocation query the saved state.
If the query in mode 2 produces an answer that genuinely reflects the input,
the concept is viable. If it produces vague/wrong answers — rethink.
Usage:
# Step 1: ingest git history into a memba state
python recall_poc.py build --model <gguf>
# Step 2 (separate process — proves persistence):
python recall_poc.py query --model <gguf>
python recall_poc.py query --model <gguf> --q "Which project saw the most activity?"
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
# ── Config ───────────────────────────────────────────────────────
SCAN_ROOTS = [
Path.home() / "Desktop/Coding",
Path.home() / "Desktop/Coding/AI",
]
DAYS_BACK = 30
STATE_DIR = Path.home() / ".recall/states"
SESSION_ID = "recall_poc_nemotron"
DEFAULT_Q = (
"Summarise what I've been working on over the last month. "
"Group by project. Mention the main themes per project. "
"Be concise — one short paragraph per project."
)
# ── Git scraping ─────────────────────────────────────────────────
def find_repos(roots: list[Path]) -> list[Path]:
repos = []
seen = set()
for root in roots:
if not root.exists():
continue
for entry in sorted(root.iterdir()):
if not entry.is_dir() or not (entry / ".git").exists():
continue
real = entry.resolve()
if real in seen:
continue
seen.add(real)
repos.append(entry)
return repos
def git_log(repo: Path, since_days: int) -> str:
"""Return human-readable commit log for last `since_days` days."""
try:
out = subprocess.check_output(
[
"git", "-C", str(repo), "log",
f"--since={since_days} days ago",
"--no-merges",
"--date=short",
"--pretty=format:%ad %s",
],
text=True, stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
return ""
return out.strip()
def build_corpus(repos: list[Path], since_days: int) -> tuple[str, int]:
"""Build a single human-readable summary block. Returns (text, n_commits)."""
blocks: list[str] = []
total = 0
for repo in repos:
log = git_log(repo, since_days)
if not log:
continue
n = log.count("\n") + 1
total += n
blocks.append(f"=== {repo.name} ({n} commits) ===\n{log}")
return "\n\n".join(blocks), total
# ── memba interaction ───────────────────────────────────────────
def open_session(model_path: str, n_gpu_layers: int) -> "Session": # noqa: F821
# Imported lazily so query mode doesn't pay the cost when only listing
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
from memba import Session
return Session(
model_path=model_path,
session_id=SESSION_ID,
state_dir=str(STATE_DIR),
n_gpu_layers=n_gpu_layers,
n_ctx=8192, # bigger than default so a month of commits fits comfortably
chat_format="chatml",
)
# ── build mode ──────────────────────────────────────────────────
def cmd_build(args: argparse.Namespace) -> int:
repos = find_repos(SCAN_ROOTS)
print(f"[build] discovered {len(repos)} git repos under {[str(r) for r in SCAN_ROOTS]}")
corpus, n_commits = build_corpus(repos, args.days)
if not corpus:
print(f"[build] no commits in the last {args.days} days — abort", file=sys.stderr)
return 1
char_count = len(corpus)
print(f"[build] corpus: {n_commits} commits, {char_count:,} characters")
print(f"[build] first 400 chars:\n---\n{corpus[:400]}\n---")
# Wipe any previous state for a clean test
state_file = STATE_DIR / f"{SESSION_ID}.memb"
if state_file.exists():
state_file.unlink()
print(f"[build] removed previous state file")
print(f"[build] loading model ({Path(args.model).name}) on GPU layers={args.gpu_layers}…")
t0 = time.time()
sess = open_session(args.model, args.gpu_layers)
print(f"[build] model loaded in {time.time()-t0:.1f}s, initial state {sess.state_size:,} B")
prompt = (
"I am going to give you my git commit history from the last month, "
"across several of my personal projects. Please READ it and remember "
"the overall picture — which projects I worked on, the kinds of "
"changes I made, and any themes that emerge. Just reply 'noted' when "
"you have processed it; I'll ask questions in a follow-up.\n\n"
f"=== git log (last {args.days} days, {n_commits} commits) ===\n\n"
f"{corpus}"
)
print(f"[build] feeding {len(prompt):,} characters into state…")
t0 = time.time()
ack = sess.chat(prompt, max_tokens=32)
elapsed = time.time() - t0
print(f"[build] processed in {elapsed:.1f}s (~{len(prompt)/elapsed:,.0f} char/s)")
print(f"[build] model ack: {ack!r}")
print(f"[build] state after ingest: {sess.state_size:,} B")
path = sess.save()
on_disk = path.stat().st_size
print(f"[build] state saved → {path} ({on_disk:,} B on disk)")
return 0
# ── query mode ──────────────────────────────────────────────────
def cmd_query(args: argparse.Namespace) -> int:
state_file = STATE_DIR / f"{SESSION_ID}.memb"
if not state_file.exists():
print(f"[query] no state file at {state_file} — run `build` first", file=sys.stderr)
return 1
on_disk = state_file.stat().st_size
print(f"[query] state file: {state_file} ({on_disk:,} B)")
print(f"[query] loading model ({Path(args.model).name}) on GPU layers={args.gpu_layers}…")
t0 = time.time()
sess = open_session(args.model, args.gpu_layers)
print(f"[query] model+state loaded in {time.time()-t0:.1f}s, state size {sess.state_size:,} B")
question = args.q or DEFAULT_Q
print(f"\n[query] question:\n{question}\n")
print(f"[query] response:\n---")
t0 = time.time()
reply = sess.chat(question, max_tokens=args.max_tokens)
print(reply)
print(f"--- ({time.time()-t0:.1f}s)")
return 0
# ── main ────────────────────────────────────────────────────────
def main() -> int:
p = argparse.ArgumentParser(description="recall POC — git log → mamba state → cross-process query")
sub = p.add_subparsers(dest="cmd", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--model", required=True, help="Path to GGUF model")
common.add_argument("--gpu-layers", type=int, default=-1, dest="gpu_layers",
help="Layers to offload (-1=all, 0=CPU)")
b = sub.add_parser("build", parents=[common], help="Ingest git history into state")
b.add_argument("--days", type=int, default=DAYS_BACK)
b.set_defaults(func=cmd_build)
q = sub.add_parser("query", parents=[common], help="Query the saved state")
q.add_argument("--q", help="Question to ask (default: monthly summary)")
q.add_argument("--max-tokens", type=int, default=512, dest="max_tokens")
q.set_defaults(func=cmd_query)
args = p.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())