Files
entropy-sampler/entropy_sampler.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

293 lines
11 KiB
Python

"""Future-Entropy Sampler.
At each step, instead of sampling straight from p(w | c), fork the KV cache
once per top-k candidate, decode one token into each fork to see what
distribution q_w follows it, score candidates by how much p(w|c) they carry
*and* how much future choice they preserve, then commit to one and discard
the rest of the forks.
s(w) = p(w|c)^a * H_hat(w)^b, a = 1 - alpha, b = 1 + alpha, alpha in [-1, 1]
alpha = -1 -> a=2,b=0 (pure probability, squared)
alpha = 0 -> a=1,b=1 (balanced - matches the base s(w) = p(w)*H_hat(w) form)
alpha = +1 -> a=0,b=2 (pure future-entropy, squared)
This linear a/b mapping isn't specified by the source article beyond "derived
from alpha" - it's the simplest crossfade that recovers the balanced form at
alpha=0 and degenerates cleanly at the extremes.
"""
import math
import os
import numpy as np
import llama_capi as C
MODEL_PATH = os.environ.get("ENTROPY_SAMPLER_MODEL")
def softmax(x):
x = x - np.max(x)
e = np.exp(x)
return e / e.sum()
def normalized_entropy(probs):
p = probs / probs.sum()
p = p[p > 0]
if len(p) <= 1:
return 0.0
h = -np.sum(p * np.log(p))
return float(h / math.log(len(p)))
def alpha_to_exponents(alpha):
alpha = max(-1.0, min(1.0, alpha))
return 1.0 - alpha, 1.0 + alpha
def sine_alpha_schedule(period_tokens=12, amplitude=1.0, phase=0.0, offset=0.0):
"""offset shifts the wave's center - e.g. offset=-0.2 dips deeper into
safe/coherent territory than it peaks into entropy-chasing territory,
biasing toward coherence while still spiking for occasional surprise."""
def schedule(step):
return offset + amplitude * math.sin(2 * math.pi * step / period_tokens + phase)
return schedule
class EntropySampler:
def __init__(self, model_path=MODEL_PATH, n_ctx=8192, top_k=12,
top_n_future=20, n_threads=8, seed=0, think_ban=True,
n_batch=8192, n_ubatch=512):
if not model_path:
raise RuntimeError(
"No model path given. Set ENTROPY_SAMPLER_MODEL to a .gguf file, "
"or pass model_path= / --model explicitly."
)
self.top_k = top_k
self.top_n_future = top_n_future
self.rng = np.random.default_rng(seed)
C.lib.llama_backend_init()
mparams = C.lib.llama_model_default_params()
mparams.n_gpu_layers = 99
self.model = C.lib.llama_model_load_from_file(model_path.encode(), mparams)
if not self.model:
raise RuntimeError(f"model load failed for path: {model_path}")
self.vocab = C.lib.llama_model_get_vocab(self.model)
self.n_vocab = C.lib.llama_vocab_n_tokens(self.vocab)
# This model only offers enable_thinking through its chat template's
# assistant-turn preamble - we do raw completion, not chat-formatted
# prompting, so that switch is never in play. Ban <think>/</think>
# directly instead: both are single dedicated tokens in this vocab.
# (Known limited: suppresses literal leakage, not the underlying
# register-break the model reroutes around it with.)
self.banned_token_ids = []
if think_ban:
for special in ("<think>", "</think>"):
toks = C.tokenize(self.vocab, special, add_special=False, parse_special=True)
if len(toks) == 1:
self.banned_token_ids.append(toks[0])
cparams = C.lib.llama_context_default_params()
cparams.n_ctx = n_ctx
cparams.n_seq_max = top_k + 1 # seq 0 = real context, 1..top_k = forks
cparams.n_threads = n_threads
cparams.n_threads_batch = n_threads
# n_batch is the hard ceiling on tokens submitted in one llama_decode()
# call - prime() decodes the whole prompt in a single call, so this
# must cover the largest prompt (i.e. rendered chat history) expected,
# not just n_ubatch's internal per-step chunk size. Left at the
# llama.cpp default (2048) this hits GGML_ASSERT(n_tokens_all <=
# cparams.n_batch), which aborts the whole process - not a
# recoverable Python exception.
cparams.n_batch = n_batch
cparams.n_ubatch = n_ubatch
self.ctx = C.lib.llama_init_from_model(self.model, cparams)
if not self.ctx:
raise RuntimeError("context init failed")
self.mem = C.lib.llama_get_memory(self.ctx)
self.n_past = 0
def close(self):
C.lib.llama_free(self.ctx)
C.lib.llama_model_free(self.model)
def _decode(self, tokens, seq_ids, positions, want_logits):
"""seq_ids/positions/want_logits are per-token, same length as tokens."""
n = len(tokens)
batch = C.lib.llama_batch_init(n, 0, 1)
batch.n_tokens = n
for i in range(n):
batch.token[i] = tokens[i]
batch.pos[i] = positions[i]
batch.n_seq_id[i] = 1
batch.seq_id[i][0] = seq_ids[i]
batch.logits[i] = 1 if want_logits[i] else 0
rc = C.lib.llama_decode(self.ctx, batch)
C.lib.llama_batch_free(batch)
if rc != 0:
raise RuntimeError(f"llama_decode failed rc={rc}")
def _logits_row(self, i):
ptr = C.lib.llama_get_logits_ith(self.ctx, i)
return C.as_float_array(ptr, self.n_vocab)
def prime(self, prompt):
"""Decode the prompt on seq 0. Returns base logits for the first step."""
C.lib.llama_memory_seq_rm(self.mem, 0, -1, -1) # clear any prior run's cache
tokens = C.tokenize(self.vocab, prompt)
positions = list(range(len(tokens)))
want = [False] * (len(tokens) - 1) + [True]
self._decode(tokens, [0] * len(tokens), positions, want)
self.n_past = len(tokens)
return self._logits_row(-1)
def _mask_banned(self, logits):
if self.banned_token_ids:
logits = logits.copy()
logits[self.banned_token_ids] = -np.inf
return logits
def _top_candidates(self, base_logits):
probs = softmax(self._mask_banned(base_logits))
top_idx = np.argsort(-probs)[: self.top_k]
return top_idx, probs[top_idx]
def _fork_and_score(self, top_idx):
"""Batched fork+peek+discard: one candidate token per fork sequence,
all decoded in a single llama_decode call."""
fork_seqs = list(range(1, self.top_k + 1))
for fseq in fork_seqs:
C.lib.llama_memory_seq_cp(self.mem, 0, fseq, -1, -1)
self._decode(
tokens=[int(t) for t in top_idx],
seq_ids=fork_seqs,
positions=[self.n_past] * self.top_k,
want_logits=[True] * self.top_k,
)
h_hat = np.empty(self.top_k)
for j in range(self.top_k):
fork_probs = softmax(self._mask_banned(self._logits_row(j)))
top_future = np.argsort(-fork_probs)[: self.top_n_future]
h_hat[j] = normalized_entropy(fork_probs[top_future])
for fseq in fork_seqs:
C.lib.llama_memory_seq_rm(self.mem, fseq, -1, -1)
return h_hat
def _score_candidates(self, base_logits):
top_idx, top_p = self._top_candidates(base_logits)
h_hat = self._fork_and_score(top_idx)
return top_idx, top_p, h_hat
def step(self, base_logits, alpha, confidence_threshold=None):
"""confidence_threshold: if the top base candidate's p(w|c) is at or
above this, skip the fork+peek lookahead entirely and sample straight
from p(w|c) among the top-k - most tokens in fluent prose are forced
(punctuation, function words) and the entropy signal adds nothing
there. None disables skipping (always fork, the original behavior)."""
top_idx, top_p = self._top_candidates(base_logits)
forked = confidence_threshold is None or top_p[0] < confidence_threshold
if forked:
h_hat = self._fork_and_score(top_idx)
a, b = alpha_to_exponents(alpha)
scores = (top_p ** a) * (h_hat ** b)
total = scores.sum()
if total <= 0:
scores = top_p # degenerate fallback: every candidate closes off the future
total = scores.sum()
else:
h_hat = np.full(self.top_k, np.nan)
scores = top_p
total = scores.sum()
dist = scores / total
choice = self.rng.choice(len(top_idx), p=dist)
chosen_token = int(top_idx[choice])
diagnostics = {
"candidates": [
{
"token": C.token_to_str(self.vocab, int(top_idx[j])),
"p": float(top_p[j]),
"h_hat": None if not forked else float(h_hat[j]),
"score": float(dist[j]),
}
for j in range(len(top_idx))
],
"chosen": choice,
"alpha": alpha,
"forked": forked,
}
self._decode([chosen_token], [0], [self.n_past], [True])
self.n_past += 1
next_base_logits = self._logits_row(-1)
return chosen_token, next_base_logits, diagnostics
def generate_stream(self, prompt, max_new_tokens=60, alpha=0.0, confidence_threshold=None):
"""Yields (piece, diag) one token at a time - the shared engine behind
generate() (batch) and the HTTP server (streaming)."""
alpha_fn = alpha if callable(alpha) else (lambda step: alpha)
base_logits = self.prime(prompt)
for step_i in range(max_new_tokens):
a = alpha_fn(step_i)
token, base_logits, diag = self.step(base_logits, a, confidence_threshold)
if C.lib.llama_vocab_is_eog(self.vocab, token):
return
piece = C.token_to_str(self.vocab, token)
yield piece, diag
def generate(self, prompt, max_new_tokens=60, alpha=0.0, verbose=True,
confidence_threshold=None):
text = prompt
for step_i, (piece, diag) in enumerate(
self.generate_stream(prompt, max_new_tokens, alpha, confidence_threshold)
):
text += piece
if verbose:
chosen = diag["candidates"][diag["chosen"]]
h_str = f"{chosen['h_hat']:.3f}" if chosen["h_hat"] is not None else " -- "
print(
f"[{step_i:3d}] alpha={diag['alpha']:+.2f} {'fork' if diag['forked'] else 'skip'}"
f" -> {piece!r:12s} p={chosen['p']:.3f} H_hat={h_str} "
f"score={chosen['score']:.3f}"
)
return text
if __name__ == "__main__":
sampler = EntropySampler(top_k=12, top_n_future=20, n_ctx=4096)
try:
prompt = "The old lighthouse keeper had seen many storms, but none like"
print("=== alpha = -1.0 (pure probability) ===")
print(sampler.generate(prompt, max_new_tokens=40, alpha=-1.0))
print("\n=== alpha = 0.0 (balanced) ===")
print(sampler.generate(prompt, max_new_tokens=40, alpha=0.0))
print("\n=== alpha = +1.0 (pure future-entropy) ===")
print(sampler.generate(prompt, max_new_tokens=40, alpha=1.0))
print("\n=== alpha-wave (sine oscillation, period=10 tokens) ===")
schedule = sine_alpha_schedule(period_tokens=10, amplitude=1.0)
print(sampler.generate(prompt, max_new_tokens=40, alpha=schedule))
finally:
sampler.close()