sweedworks

← all sources

ngram.py

The n-gram model and sampling knobs behind /predict/

175 lines. This is the file the build actually runs, copied verbatim at build time.

"""A small n-gram language model, and the sampling knobs that sit on top of it.

The point is not that this is a good model — it is a lookup table of what
followed what. The point is that every step between "a distribution over next
tokens" and "a word on your screen" is arithmetic you can watch, and those steps
are identical in a real model. Temperature, top-k and top-p do exactly this to
a 200,000-way distribution instead of a twenty-way one.

Mirrored by predict/ngram.js; checkngram.py fails the build if they diverge.
Sampling uses a seeded PRNG (mulberry32) so that a given seed produces the same
text in both implementations, and for the reader, every time.
"""

from bpe import pretokenize


def tokenize(text):
    """Same rule as the BPE page: optional leading space, then non-space."""
    return pretokenize(text)


def train(text, order):
    """context tuple -> {next token: count}, for every context length <= order."""
    tokens = tokenize(text)
    model = {}
    for k in range(order + 1):
        table = {}
        for i in range(len(tokens) - k):
            ctx = tuple(tokens[i:i + k])
            nxt = tokens[i + k]
            table.setdefault(ctx, {})
            table[ctx][nxt] = table[ctx].get(nxt, 0) + 1
        model[k] = table
    # First-appearance order, not sorted: JavaScript orders strings by UTF-16
    # code unit and Python by code point, and they disagree on astral characters.
    vocab, seen = [], set()
    for t in tokens:
        if t not in seen:
            seen.add(t)
            vocab.append(t)
    return {"model": model, "order": order, "tokens": tokens, "vocab": vocab}


def distribution(trained, context):
    """Probabilities for the next token, backing off to shorter contexts.

    Returns (list of (token, probability) sorted by probability desc, order used).
    """
    order = trained["order"]
    ctx = tuple(context)
    for k in range(min(order, len(ctx)), -1, -1):
        table = trained["model"][k]
        key = ctx[len(ctx) - k:] if k else ()
        if key in table:
            counts = table[key]
            total = sum(counts.values())
            items = [(t, c / total) for t, c in counts.items()]
            # Sort by probability, then by first appearance in the vocabulary,
            # so ties resolve identically in both implementations.
            order_index = {t: i for i, t in enumerate(trained["vocab"])}
            items.sort(key=lambda p: (-p[1], order_index[p[0]]))
            return items, k
    return [], 0


def _total(values):
    """Naive left-to-right float accumulation.

    Deliberately not sum(): since 3.12 CPython uses Neumaier compensated
    summation for floats, so sum() returns 1.0 where JavaScript's += loop
    returns 0.9999999999999999. Being more accurate than the browser is still
    being different from it, and that difference moved where top-p cut the
    distribution. Matching the browser is the requirement here.
    """
    total = 0.0
    for v in values:
        total += v
    return total


def apply_temperature(items, temp):
    """p -> p^(1/T), renormalised. T<1 sharpens, T>1 flattens, T→0 is greedy."""
    if temp <= 0:
        # The limit: all mass on the most likely token.
        return [(t, 1.0 if i == 0 else 0.0) for i, (t, _) in enumerate(items)]
    scaled = [(t, p ** (1.0 / temp)) for t, p in items]
    total = _total(p for _, p in scaled)
    if total == 0:
        return items
    return [(t, p / total) for t, p in scaled]


def apply_top_k(items, k):
    """Keep the k most likely tokens, renormalise. 0 disables."""
    if k <= 0 or k >= len(items):
        return items
    kept = items[:k]
    total = _total(p for _, p in kept)
    return [(t, p / total) for t, p in kept]


def apply_top_p(items, p_threshold):
    """Nucleus: keep the smallest set whose probability sums past the threshold."""
    if p_threshold <= 0 or p_threshold >= 1:
        return items
    kept, running = [], 0.0
    for token, p in items:
        kept.append((token, p))
        running += p
        if running >= p_threshold:
            break
    total = _total(p for _, p in kept)
    return [(t, p / total) for t, p in kept]


class Rng:
    """mulberry32 — small, seedable, and identical in JavaScript."""

    def __init__(self, seed):
        self.state = seed & 0xFFFFFFFF

    def next(self):
        self.state = (self.state + 0x6D2B79F5) & 0xFFFFFFFF
        t = self.state
        t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
        t = (t ^ (t + ((t ^ (t >> 7)) * (t | 61) & 0xFFFFFFFF))) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0


def pick(items, rng):
    """Sample one token from a normalised distribution."""
    if not items:
        return None
    r = rng.next()
    acc = 0.0
    for token, p in items:
        acc += p
        if r < acc:
            return token
    return items[-1][0]


def shape(items, temp=1.0, top_k=0, top_p=0.0):
    """The full pipeline, in the order a real sampler applies it."""
    out = apply_temperature(items, temp)
    out = apply_top_k(out, top_k)
    out = apply_top_p(out, top_p)
    return out


def generate(trained, prompt, n, temp=1.0, top_k=0, top_p=0.0, seed=7):
    """Produce n tokens, returning both the text and a record of each choice."""
    rng = Rng(seed)
    context = list(tokenize(prompt))
    produced, trace = [], []
    for _ in range(n):
        items, used = distribution(trained, context)
        if not items:
            break
        shaped = shape(items, temp, top_k, top_p)
        token = pick(shaped, rng)
        if token is None:
            break
        chosen_p = dict(shaped).get(token, 0.0)
        trace.append({
            "token": token,
            "p": chosen_p,
            "considered": len(shaped),
            "of": len(items),
            "context_order": used,
        })
        produced.append(token)
        context.append(token)
    return {"text": "".join(produced), "tokens": produced, "trace": trace}