Add memba MVP: C++ core, Python SDK, CLI, examples, experiments

C++ core (libmemba.so):
- include/memba/state.h — C API (state_new/free/save/load/get_size)
- src/state.cpp — MEMB file format: magic, version, SHA-256 model_id,
  CRC-32, opaque llama_state_*_data() blob
- src/cli.cpp — minimal demo binary with greedy sampler
- CMakeLists.txt + build.sh with llama.cpp submodule, CUDA auto-detect

Python SDK (memba):
- core.py — file I/O via llama-cpp-python's exposed C functions,
  unwraps _LlamaContext to access raw context pointer (≥0.3.x)
- session.py — high-level Session with auto-save/load, ChatML wrapper
  for instruct models, raw mode for base models
- cli.py — typer-based: chat (REPL), run (one-shot), list, rm, info

Examples:
- 01_basic_save_load.py, 02_chat_session.py

Experiments (throwaway POCs documenting product-direction findings):
- recall_poc.py — git log → state → cross-process query
- mood_poc.py — batch sentiment trajectory, Mamba vs Transformer
- mood_stream_poc.py, mood_batch_poc.py — variants
- diag_saveload.py — minimal save/load isolation test
- README.md documents the headline finding: save/load is byte-identical,
  but Falcon-Mamba-7B-Instruct does not retain facts across conversation
  turns even in-process — limits viable products to single-prompt analysis
  and persona priming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-16 12:48:37 +03:00
co-authored by Claude Opus 4.7
parent 3bb2d3dc71
commit 75c9ee4576
22 changed files with 2357 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# experiments/
Throwaway scripts used to probe capabilities of SSM models with memba.
Not part of the library API — kept in the repo as reference and reproducible
evidence for product decisions.
Each script is self-contained and prints what it finds; read the source for
the test's claim and run it yourself if you want to verify on different
models or hardware.
## Scripts
| File | What it measures |
|------|------------------|
| `recall_poc.py` | Can a memba state, built from N days of git activity, answer "what did I work on last month" in a fresh process? |
| `mood_poc.py` | Batch sentiment-trajectory test (single prompt with full chat log). Compares Falcon-Mamba vs a Transformer. |
| `mood_stream_poc.py` | The same trajectory but fed turn-by-turn through `Session.chat()`, then queried cross-process. |
| `mood_batch_poc.py` | Batch ingest in build process, save, then query in a fresh process. |
| `diag_saveload.py` | Minimal diagnostic: tell the model one fact, ask it back before save, after save, after cross-process load. |
## Headline finding (2026-05-16, Falcon-Mamba-7B-Instruct Q4_K_M)
- **Batch single-prompt analysis** (all input + question in one call): works
for both sentiment and recall.
- **Multi-turn fact recall** (ingest in turn 1, ask in turn 2): fails even
in the *same process*. The model does not preserve specific facts in its
hidden state across conversation turns.
- **Save/load roundtrip**: byte-identical, no information loss attributable
to memba's file format. The persistence layer works correctly; the
trained model just doesn't use the state for cross-turn recall.
See the script outputs (or rerun) for the raw evidence.
+57
View File
@@ -0,0 +1,57 @@
"""
Diagnostic: does the model retain context AFTER save in the SAME process,
and AFTER load in a fresh process? Compares three scenarios.
"""
import sys, argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
from llama_cpp import Llama
from memba import core
MODEL = "/home/emil/Desktop/Coding/AI/Memba/falcon-mamba-7B-instruct-Q4_K_M.gguf"
STATE = "/tmp/diag_saveload.memb"
INGEST = "I'm telling you a secret. My pet hamster is named Bartholomew. He is 4 years old. Reply 'ok'."
QUERY = "What is the name of my pet?"
def chatml(msg):
return f"<|im_start|>user\n{msg}<|im_end|>\n<|im_start|>assistant\n"
def make_llama():
return Llama(model_path=MODEL, n_ctx=2048, n_gpu_layers=-1, verbose=False)
def ask(m, prompt):
out = m(chatml(prompt), max_tokens=60, stop=["<|im_end|>"], echo=False)
return out["choices"][0]["text"].strip()
def build():
m = make_llama()
ack = ask(m, INGEST)
print(f" [build] ack: {ack!r}")
print(f" [build] state size (live): {core.get_state_size(m):,} B")
# In-process query BEFORE saving
print(f" [build] in-proc query BEFORE save: {ask(m, QUERY)!r}")
# Save
core.save_state(m, MODEL, STATE)
print(f" [build] state saved")
# In-process query AFTER saving (should still work — save shouldn't mutate)
print(f" [build] in-proc query AFTER save: {ask(m, QUERY)!r}")
def query():
m = make_llama()
print(f" [query] before load — fresh model: {ask(m, QUERY)!r}")
core.load_state(m, MODEL, STATE)
print(f" [query] state size after load: {core.get_state_size(m):,} B")
print(f" [query] after load: {ask(m, QUERY)!r}")
# Try a second time in case position is wonky
print(f" [query] second ask: {ask(m, QUERY)!r}")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "build"
{"build": build, "query": query}[cmd]()
+69
View File
@@ -0,0 +1,69 @@
"""
mood_batch_poc.py — batch ingest, cross-process query.
This is the clean test: one chat() call with all 15 messages as a block,
save state, EXIT, then in a fresh process load state and ask sentiment
questions. Isolates the cross-process save/load from streaming-noise.
"""
from __future__ import annotations
import sys, argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
from memba import Session
MAMBA = "/home/emil/Desktop/Coding/AI/Memba/falcon-mamba-7B-instruct-Q4_K_M.gguf"
STATE_DIR = "/tmp/mood_batch_test"
SESSION = "mood_batch"
CHAT_LOG = [
"Morning team! Coffee in hand, ready to tackle the auth refactor today.",
"Just pushed PR #234 fixing the token validation bug. Should be a quick merge.",
"Code review comments came in fast, all good catches. Iterating now.",
"Basic flow working locally, tests passing. Feeling good about this.",
"Heading to lunch, hopefully wrap this up by EOD.",
"Back. CI is failing on something unrelated, looking into it.",
"OK the 'unrelated' thing is actually related. Auth tests use a stale fixture.",
"Why does the fixture rebuild take 12 minutes. Every. Single. Time.",
"Cancelled the run twice now. Going to bypass and run tests locally.",
"Local passes, CI fails. Classic.",
"Two hours gone on this fixture issue. Not even what I was supposed to be doing.",
"Now there's a merge conflict with main because someone restructured migrations.",
"Whoever shipped those migrations on a Friday afternoon, I will find you.",
"Closing the laptop. Will fight this tomorrow.",
"Actually no. One more try before I sleep.",
]
INGEST_PROMPT = (
"You are observing one person's chat messages from a workday. "
"Here they are in order. Read them and remember the overall trajectory. "
"Reply with just 'noted'.\n\n"
+ "\n".join(f"[msg {i+1:>2}] {m}" for i, m in enumerate(CHAT_LOG))
)
def build():
p = Path(STATE_DIR) / f"{SESSION}.memb"
if p.exists(): p.unlink()
s = Session(model_path=MAMBA, session_id=SESSION, state_dir=STATE_DIR,
n_gpu_layers=-1, n_ctx=4096, chat_format="chatml")
print(f"[build] ack: {s.chat(INGEST_PROMPT, max_tokens=8)!r}")
print(f"[build] state: {s.state_size:,} B")
s.save()
def query():
s = Session(model_path=MAMBA, session_id=SESSION, state_dir=STATE_DIR,
n_gpu_layers=-1, n_ctx=4096, chat_format="chatml")
print(f"[query] loaded {s.state_size:,} B\n")
for q in [
"What is this person's current emotional state? One sentence.",
"Did their mood change over the messages? One sentence describing the trajectory.",
"Around which message number did the mood shift from positive to negative? Just the number.",
]:
print(f"[Q] {q}")
print(f"[A] {s.chat(q, max_tokens=120)}\n")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "build"
{"build": build, "query": query}[cmd]()
+73
View File
@@ -0,0 +1,73 @@
"""
mood_poc.py — sentiment-trajectory test on Mamba vs Transformer.
A chat log is constructed with a deliberate emotional arc:
msg 1-5 : optimistic / energetic
msg 6-10 : frustrated, hitting friction
msg 11-15 : burnt out, angry
Both models see the same prompt and answer 3 questions:
Q1. Current mood at message 15
Q2. Trajectory from start to end
Q3. Approximate message number where mood shifted
Pass criterion: model identifies negative trend AND points at a shift
between msgs 6-11. Generic "they seem fine" or "they were happy throughout"
counts as failure.
"""
from __future__ import annotations
from llama_cpp import Llama
MAMBA = "/home/emil/Desktop/Coding/AI/Memba/falcon-mamba-7B-instruct-Q4_K_M.gguf"
GEMMA = "/home/emil/Programs/Llama.cpp/llama.cpp/models/gemma-4-E4B-it-Q8_0.gguf"
CHAT_LOG = [
"Morning team! Coffee in hand, ready to tackle the auth refactor today.",
"Just pushed PR #234 fixing the token validation bug. Should be a quick merge.",
"Code review comments came in fast, all good catches. Iterating now.",
"Basic flow working locally, tests passing. Feeling good about this.",
"Heading to lunch, hopefully wrap this up by EOD.",
"Back. CI is failing on something unrelated, looking into it.",
"OK the 'unrelated' thing is actually related. Auth tests use a stale fixture.",
"Why does the fixture rebuild take 12 minutes. Every. Single. Time.",
"Cancelled the run twice now. Going to bypass and run tests locally.",
"Local passes, CI fails. Classic.",
"Two hours gone on this fixture issue. Not even what I was supposed to be doing.",
"Now there's a merge conflict with main because someone restructured migrations.",
"Whoever shipped those migrations on a Friday afternoon, I will find you.",
"Closing the laptop. Will fight this tomorrow.",
"Actually no. One more try before I sleep.",
]
PROMPT = """You are observing a person's chat messages from one workday. Here they are in order:
""" + "\n".join(f"[msg {i+1:>2}] {m}" for i, m in enumerate(CHAT_LOG)) + """
Now answer these THREE questions, briefly and directly:
Q1: What is this person's mood at message 15 (the last one)? One short sentence.
Q2: How did their mood change from message 1 to message 15? One short sentence.
Q3: Around which message number does the mood clearly shift from positive to negative? Just give the number.
Format your answer as:
A1: ...
A2: ...
A3: ..."""
def run(label: str, model_path: str) -> None:
print(f"\n{'='*60}\n {label}\n{'='*60}")
llm = Llama(model_path=model_path, n_ctx=4096, n_gpu_layers=-1, verbose=False)
out = llm.create_chat_completion(
messages=[{"role": "user", "content": PROMPT}],
max_tokens=300,
temperature=0.3, # low temp so we test capability, not creativity
)
print(out["choices"][0]["message"]["content"])
del llm # free GPU memory before loading next
if __name__ == "__main__":
run("FALCON-MAMBA-7B-INSTRUCT (SSM)", MAMBA)
run("GEMMA-4-E4B-IT (Transformer)", GEMMA)
+91
View File
@@ -0,0 +1,91 @@
"""
mood_stream_poc.py — streaming sentiment, then save/load across processes.
This is the REAL product test:
1. Open memba session
2. Feed 15 chat messages ONE AT A TIME as "observations"
3. Save state, exit process
4. In a fresh process: load state, query mood
"""
from __future__ import annotations
import sys, argparse, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
from memba import Session
MAMBA = "/home/emil/Desktop/Coding/AI/Memba/falcon-mamba-7B-instruct-Q4_K_M.gguf"
STATE_DIR = "/tmp/mood_stream_test"
SESSION = "mood_stream"
CHAT_LOG = [
"Morning team! Coffee in hand, ready to tackle the auth refactor today.",
"Just pushed PR #234 fixing the token validation bug. Should be a quick merge.",
"Code review comments came in fast, all good catches. Iterating now.",
"Basic flow working locally, tests passing. Feeling good about this.",
"Heading to lunch, hopefully wrap this up by EOD.",
"Back. CI is failing on something unrelated, looking into it.",
"OK the 'unrelated' thing is actually related. Auth tests use a stale fixture.",
"Why does the fixture rebuild take 12 minutes. Every. Single. Time.",
"Cancelled the run twice now. Going to bypass and run tests locally.",
"Local passes, CI fails. Classic.",
"Two hours gone on this fixture issue. Not even what I was supposed to be doing.",
"Now there's a merge conflict with main because someone restructured migrations.",
"Whoever shipped those migrations on a Friday afternoon, I will find you.",
"Closing the laptop. Will fight this tomorrow.",
"Actually no. One more try before I sleep.",
]
QUESTIONS = [
"Briefly: what is this person's current emotional state? One sentence.",
"Has their mood changed during this monitoring session? One sentence.",
"Roughly when did they start having a hard time?",
]
def build():
state_path = Path(STATE_DIR) / f"{SESSION}.memb"
if state_path.exists():
state_path.unlink()
print(f"[build] cleared previous state")
s = Session(
model_path=MAMBA, session_id=SESSION, state_dir=STATE_DIR,
n_gpu_layers=-1, n_ctx=4096, chat_format="chatml",
)
print(f"[build] init state {s.state_size:,} B")
# Stream-feed: each message wrapped as if WE'RE TELLING the model
# "here's a new message you're observing"
for i, msg in enumerate(CHAT_LOG, 1):
observation = f"You are silently observing one person's chat messages. New message just arrived:\n[msg {i}] {msg}\nReply with just 'noted'."
ack = s.chat(observation, max_tokens=4)
print(f"[build] msg {i:>2}: {msg[:50]:<50} → ack={ack!r}")
print(f"[build] state after streaming: {s.state_size:,} B")
s.save()
print(f"[build] saved → {state_path}")
def query():
state_path = Path(STATE_DIR) / f"{SESSION}.memb"
if not state_path.exists():
print("[query] no state — run build first"); return 1
print(f"[query] loading state {state_path.stat().st_size:,} B")
s = Session(
model_path=MAMBA, session_id=SESSION, state_dir=STATE_DIR,
n_gpu_layers=-1, n_ctx=4096, chat_format="chatml",
)
for i, q in enumerate(QUESTIONS, 1):
print(f"\n[Q{i}] {q}")
print(f"[A{i}] {s.chat(q, max_tokens=120)}")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("cmd", choices=["build", "query"])
args = p.parse_args()
if args.cmd == "build":
build()
else:
query()
+212
View File
@@ -0,0 +1,212 @@
"""
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"
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())