checkngram.py
Browser sampler vs the Python reference
158 lines. This is the file the build actually runs, copied verbatim at build time.
"""Assert predict/ngram.js and .build/ngram.py are the same model.
Three layers, because each can break independently:
1. the PRNG stream — a seeded generator is worthless if the two languages
disagree on the 32-bit arithmetic, and JS coercions vs Python masking is
exactly where that goes wrong
2. the distributions, before and after temperature / top-k / top-p
3. whole generated sequences, which will diverge on the first mismatch of
either of the above
Probabilities are compared with a tolerance, because pow() may differ in the
last bit between implementations. Chosen tokens are compared exactly: if a
last-bit difference ever flips a choice, that is worth knowing about.
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "pylib"))
ROOT = os.path.abspath(os.path.join(HERE, ".."))
import quickjs # noqa: E402
import ngram # noqa: E402
from corpora import CORPORA, TALE # noqa: E402
TOL = 1e-12
SETTINGS = [
{"temp": 1.0, "top_k": 0, "top_p": 0.0},
{"temp": 0.0, "top_k": 0, "top_p": 0.0}, # greedy
{"temp": 0.5, "top_k": 0, "top_p": 0.0},
{"temp": 2.5, "top_k": 0, "top_p": 0.0},
{"temp": 1.0, "top_k": 3, "top_p": 0.0},
{"temp": 1.0, "top_k": 0, "top_p": 0.5},
{"temp": 1.7, "top_k": 5, "top_p": 0.9},
]
def js_context():
ctx = quickjs.Context()
ctx.set_memory_limit(1 << 29)
ctx.set_max_stack_size(1 << 22)
ctx.eval("var globalThis = this;")
ctx.eval(open(os.path.join(ROOT, "predict", "ngram.js"), encoding="utf-8").read())
return ctx
def close(a, b):
return abs(a - b) <= TOL
def main():
ctx = js_context()
failures = []
# 1. PRNG streams must be bit-identical.
for seed in [0, 1, 7, 12345, 2 ** 31, 4294967295]:
py = [ngram.Rng(seed).next() for _ in range(1)]
r = ngram.Rng(seed)
py = [r.next() for _ in range(2000)]
ctx.set("_seed", seed)
js = json.loads(ctx.eval(
"(function(){var r=new NGram.Rng(_seed),o=[];"
"for(var i=0;i<2000;i++)o.push(r.next());return JSON.stringify(o)})()"))
bad = [i for i, (a, b) in enumerate(zip(py, js)) if a != b]
if bad:
failures.append(f"PRNG seed {seed}: differs at draw {bad[0]} "
f"({py[bad[0]]} vs {js[bad[0]]})")
if not failures:
print("PASS PRNG — 6 seeds x 2000 draws, bit-identical")
corpora = [("tale", TALE, 2)] + [(n, t, 2) for n, t, _ in CORPORA]
for name, text, order in corpora:
for o in (order, 1, 0):
py_model = ngram.train(text, o)
ctx.set("_t", text)
ctx.set("_o", o)
ctx.eval("var M = NGram.train(_t, _o);")
if json.loads(ctx.eval("JSON.stringify(M.vocab)")) != py_model["vocab"]:
failures.append(f"{name}/order{o}: vocabulary differs")
continue
# 2. distributions, raw and shaped
probes = [py_model["tokens"][:i] for i in (0, 1, 2, 3)]
for probe in probes:
py_items, py_order = ngram.distribution(py_model, probe)
ctx.set("_c", json.dumps(probe))
ctx.eval("var D = NGram.distribution(M, JSON.parse(_c));")
js_items = json.loads(ctx.eval("JSON.stringify(D.items)"))
js_order = int(ctx.eval("D.order"))
if js_order != py_order:
failures.append(f"{name}/order{o}: backoff order "
f"{js_order} vs {py_order}")
break
if [t for t, _ in py_items] != [t for t, _ in js_items]:
failures.append(f"{name}/order{o}: distribution token order")
break
if any(not close(a[1], b[1]) for a, b in zip(py_items, js_items)):
failures.append(f"{name}/order{o}: probabilities differ")
break
for s in SETTINGS:
py_shaped = ngram.shape(py_items, s["temp"], s["top_k"],
s["top_p"])
ctx.set("_temp", s["temp"])
ctx.set("_k", s["top_k"])
ctx.set("_p", s["top_p"])
js_shaped = json.loads(ctx.eval(
"JSON.stringify(NGram.shape(D.items, _temp, _k, _p))"))
if [t for t, _ in py_shaped] != [t for t, _ in js_shaped]:
failures.append(f"{name}/order{o} {s}: shaped tokens differ")
break
if any(not close(a[1], b[1])
for a, b in zip(py_shaped, js_shaped)):
failures.append(f"{name}/order{o} {s}: shaped probs differ")
break
# 3. whole generated sequences
for s in SETTINGS:
for seed in (3, 7, 99):
py_gen = ngram.generate(py_model, "", 40, s["temp"],
s["top_k"], s["top_p"], seed)
ctx.set("_temp", s["temp"])
ctx.set("_k", s["top_k"])
ctx.set("_p", s["top_p"])
ctx.set("_seed", seed)
js_text = ctx.eval(
"NGram.generate(M, '', 40, _temp, _k, _p, _seed).text")
if js_text != py_gen["text"]:
failures.append(
f"{name}/order{o} {s} seed {seed}: generated text "
f"diverges\n js {js_text[:60]!r}\n py "
f"{py_gen['text'][:60]!r}")
break
print(f"PASS {name:<14} orders {order}/1/0, {len(SETTINGS)} settings, "
f"3 seeds")
print()
if failures:
print(f"{len(failures)} disagreement(s):")
for f in failures[:8]:
print(f" - {f}")
return 1
print(f"Browser sampler and Python reference agree on {len(corpora)} corpora.")
return 0
if __name__ == "__main__":
sys.exit(main())