Files
entropy-sampler/fork_peek_test.py
Joey Grasty 8c844c08e5 Fix ABI fragility: compile bindings against the real llama.h instead of hand-copied ctypes structs
llama_capi.py previously hand-copied struct field order/types from one
specific llama.cpp commit's llama.h into Python ctypes Structures. A
different llama.cpp build could silently reorder or resize those fields and
corrupt memory rather than raising any error.

Replaced with a cffi "API mode" extension (build_capi.py) that #includes the
user's actual llama.h and links against their actual libllama.so. Struct
layout now comes from real compilation - a genuinely incompatible field
fails the build loudly instead of corrupting memory at runtime. Verified
byte-for-byte identical generation output against the prior ctypes
implementation at a fixed seed, plus the fork/peek logit round-trip check
(0.000000 max diff).

Requires a one-time `python build_capi.py` setup step (needs a C compiler,
which building llama.cpp itself already requires).
2026-07-11 21:21:29 -05:00

137 lines
4.6 KiB
Python

"""Smoke test for the primitive the entropy sampler depends on:
fork the KV cache at the current position, decode one candidate token
into the fork, read back its resulting next-token distribution, then
discard the fork. If this works, the scoring loop (s(w) = p(w|c)^a * H(w)^b)
is just bookkeeping on top of this.
"""
import math
import sys
import numpy as np
import llama_capi as C
from entropy_sampler import MODEL_PATH
PROMPT = "The old lighthouse keeper had seen many storms, but none like"
TOP_K_BASE = 8 # candidates to show from the base distribution
TOP_N_FUTURE = 20 # tokens used to compute the future entropy of a fork
def softmax(x):
x = x - np.max(x)
e = np.exp(x)
return e / e.sum()
def normalized_entropy(probs_top_n):
p = probs_top_n / probs_top_n.sum()
p = p[p > 0]
h = -np.sum(p * np.log(p))
return h / math.log(len(probs_top_n))
def decode_single_seq_batch(ctx, tokens, start_pos, seq_id, want_logits_last_only=True):
n = len(tokens)
batch = C.lib.llama_batch_init(n, 0, 1)
batch.n_tokens = n
for i, tok in enumerate(tokens):
batch.token[i] = tok
batch.pos[i] = start_pos + i
batch.n_seq_id[i] = 1
batch.seq_id[i][0] = seq_id
batch.logits[i] = 0
if want_logits_last_only:
batch.logits[n - 1] = 1
else:
for i in range(n):
batch.logits[i] = 1
rc = C.lib.llama_decode(ctx, batch)
C.lib.llama_batch_free(batch)
if rc != 0:
raise RuntimeError(f"llama_decode failed rc={rc}")
def main():
if not MODEL_PATH:
sys.exit("Set ENTROPY_SAMPLER_MODEL to a .gguf file path first.")
print(f"loading model: {MODEL_PATH}")
C.lib.llama_backend_init()
mparams = C.lib.llama_model_default_params()
mparams.n_gpu_layers = 99
model = C.lib.llama_model_load_from_file(MODEL_PATH.encode(), mparams)
if not model:
sys.exit("model load failed")
vocab = C.lib.llama_model_get_vocab(model)
n_vocab = C.lib.llama_vocab_n_tokens(vocab)
print(f"n_vocab = {n_vocab}")
cparams = C.lib.llama_context_default_params()
cparams.n_ctx = 4096
cparams.n_seq_max = 4 # seq 0 = real context, seq 1..3 available for forks
cparams.n_threads = 8
cparams.n_threads_batch = 8
ctx = C.lib.llama_init_from_model(model, cparams)
if not ctx:
sys.exit("context init failed")
mem = C.lib.llama_get_memory(ctx)
prompt_tokens = C.tokenize(vocab, PROMPT)
print(f"prompt: {PROMPT!r} -> {len(prompt_tokens)} tokens")
# --- base pass: decode the prompt on seq 0, get p(w | c) ---
decode_single_seq_batch(ctx, prompt_tokens, start_pos=0, seq_id=0)
n_ctx_pos = len(prompt_tokens) # next free position on seq 0
logits_ptr = C.lib.llama_get_logits_ith(ctx, -1)
base_logits = C.as_float_array(logits_ptr, n_vocab)
base_probs = softmax(base_logits)
top_idx = np.argsort(-base_probs)[:TOP_K_BASE]
print("\ntop base candidates p(w | c):")
for i in top_idx:
print(f" {base_probs[i]:.4f} {C.token_to_str(vocab, int(i))!r}")
# --- fork + peek + discard for the single most likely candidate ---
w = int(top_idx[0])
w_str = C.token_to_str(vocab, w)
fork_seq = 1
C.lib.llama_memory_seq_cp(mem, 0, fork_seq, -1, -1) # fork seq 0 -> seq 1
decode_single_seq_batch(ctx, [w], start_pos=n_ctx_pos, seq_id=fork_seq)
fork_logits_ptr = C.lib.llama_get_logits_ith(ctx, -1)
fork_logits = C.as_float_array(fork_logits_ptr, n_vocab)
fork_probs = softmax(fork_logits)
top_future_idx = np.argsort(-fork_probs)[:TOP_N_FUTURE]
h_hat = normalized_entropy(fork_probs[top_future_idx])
C.lib.llama_memory_seq_rm(mem, fork_seq, -1, -1) # discard the fork
print(f"\nforked on candidate {w_str!r} (p={base_probs[w]:.4f})")
print(f"normalized future entropy H_hat(w) over top-{TOP_N_FUTURE}: {h_hat:.4f}")
print("top continuations after that candidate:")
for i in top_future_idx[:8]:
print(f" {fork_probs[i]:.4f} {C.token_to_str(vocab, int(i))!r}")
# sanity check: seq 0 should be untouched by the fork - decoding the
# same candidate directly on seq 0 must reproduce identical logits
decode_single_seq_batch(ctx, [w], start_pos=n_ctx_pos, seq_id=0)
check_ptr = C.lib.llama_get_logits_ith(ctx, -1)
check_logits = C.as_float_array(check_ptr, n_vocab)
max_diff = float(np.max(np.abs(check_logits - fork_logits)))
print(f"\nseq0-vs-fork logit max abs diff (expect ~0): {max_diff:.6f}")
C.lib.llama_free(ctx)
C.lib.llama_model_free(model)
print("\nOK: fork -> peek -> discard round-trip works.")
if __name__ == "__main__":
main()