vp2vi / decode-guard.js
DanVP's picture
feat: publish vp2vi WebGPU browser app
c971a45 verified
Raw
History Blame Contribute Delete
20.4 kB
// APE A2 WebGPU AR ship engine — guarded-decode constraint stack, pure JS.
//
// 1:1 port of the PT-gate-frozen Python modules (behavior is prereg-bound;
// no "improvements"):
// scripts/ape_a2/a2_cont_decode_constrained.py (DigitState, walk_piece,
// digit mask modes, BAND_HI length band) sha a1c687c0…
// scripts/ape_a2/a2_name_copy_guard.py (NameTables, NameState,
// name_violations R1/R2a/R2b, partial viability) sha f2f53741…
// scripts/ape_a2/a2_ab_decode_nameguard.py (tier loop 16/256/24000,
// v3 empty-piece rule, stats accounting) sha 3e3084c2…
// scripts/ape_a2/a2_eval_decode.py (toks, repeat_patterns)
//
// Python-semantics helpers are deliberate: str.casefold ≈ toLowerCase for
// this corpus (proven empirically by the P1 replay + selftest equivalence,
// not assumed), str.isspace ≠ JS \s (adds \x1c-\x1f \x85, excludes ),
// str.isalpha = \p{L}, str.isupper = \p{Lu}\p{Lt}, \d = \p{Nd}, len() =
// code points. No DOM/Node dependencies — runs in browser and Node.
export const EOS_ID = 2;
export const NEG = -1.0e30;
export const TIERS = [16, 256, 24000];
export const BAND_HI = 1.75;
export const MAX_NEW_TOKENS = 224;
// a2_eval_decode.PUNCT (repeat/toks stripping)
export const PUNCT_REPEAT = '“”"\'‘’.,!?;:…()[]«»-–—*';
// a2_name_copy_guard.PUNCT (name-token stripping; superset with CJK brackets)
export const PUNCT_NAME = '“”"\'‘’.,!?;:…()[]«»-–—*《》〈〉「」『』【】‹›';
export const SENT_BREAK = new Set([...'.!?…:;—–-"“”\'‘’«»()[]*《》〈〉「」『』【】‹›']);
// --- Python-semantics primitives -------------------------------------------
const PY_WS = /[\t\n\x0b\x0c\r\x1c\x1d\x1e\x1f \x85\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/u;
const RE_LETTER = /\p{L}/u;
const RE_UPPER = /[\p{Lu}\p{Lt}]/u;
const RE_ND = /\p{Nd}/u;
export const isPySpace = (ch) => PY_WS.test(ch);
export const casefold = (s) => s.toLowerCase();
export const cpLen = (s) => [...s].length;
export function pyStrip(text) {
const cps = [...text];
let start = 0;
let end = cps.length;
while (start < end && isPySpace(cps[start])) start += 1;
while (end > start && isPySpace(cps[end - 1])) end -= 1;
return cps.slice(start, end).join('');
}
export function pySplit(text) {
const out = [];
let buf = '';
for (const ch of text) {
if (isPySpace(ch)) {
if (buf) { out.push(buf); buf = ''; }
} else buf += ch;
}
if (buf) out.push(buf);
return out;
}
function stripChars(raw, punctSet) {
const cps = [...raw];
let start = 0;
let end = cps.length;
while (start < end && punctSet.has(cps[start])) start += 1;
while (end > start && punctSet.has(cps[end - 1])) end -= 1;
return [cps.slice(start, end).join(''), cps.slice(0, start).join(''), cps.slice(end).join('')];
}
const PUNCT_REPEAT_SET = new Set([...PUNCT_REPEAT]);
const PUNCT_NAME_SET = new Set([...PUNCT_NAME]);
// --- toks / repeat_patterns (a2_eval_decode) --------------------------------
export function toks(text) {
const out = [];
for (let token of pySplit(casefold(text.normalize('NFC')))) {
token = stripChars(token, PUNCT_REPEAT_SET)[0];
if (token) out.push(token);
}
return out;
}
const SEP = '\u0000';
export function repeatPatterns(text) {
const tokens = toks(text);
const patterns = new Set();
for (let i = 0; i < tokens.length - 2; i += 1) {
if (tokens[i] === tokens[i + 1] && tokens[i] === tokens[i + 2]) patterns.add(tokens[i]);
}
for (let size = 2; size <= 8; size += 1) {
outer: for (let i = 0; i + 2 * size <= tokens.length; i += 1) {
for (let j = 0; j < size; j += 1) {
if (tokens[i + j] !== tokens[i + size + j]) continue outer;
}
patterns.add(tokens.slice(i, i + size).join(SEP));
}
}
return patterns;
}
export function hasNewRepeat(text, draftPatterns) {
for (const p of repeatPatterns(text)) if (!draftPatterns.has(p)) return true;
return false;
}
// --- digit-copy guard (a2_cont_decode_constrained) --------------------------
export function digitRuns(text) {
const runs = new Map();
let buf = '';
for (const ch of text) {
if (RE_ND.test(ch)) buf += ch;
else if (buf) { runs.set(buf, (runs.get(buf) ?? 0) + 1); buf = ''; }
}
if (buf) runs.set(buf, (runs.get(buf) ?? 0) + 1);
return runs;
}
// Simulate appending `text`; returns [legal, consumedRuns[], newTrailing].
export function walkPiece(text, remaining, trailing) {
const consumed = [];
const scratch = new Map(remaining);
for (const ch of text) {
if (RE_ND.test(ch)) {
trailing += ch;
// Python iterates Counter KEYS here — entries decremented to zero
// within this walk still count as viable prefixes (the close branch
// rejects them via the <= 0 check). Do NOT filter on count > 0.
let viable = false;
for (const [run] of scratch) {
if (run.startsWith(trailing)) { viable = true; break; }
}
if (!viable) return [false, consumed, trailing];
} else if (trailing) {
if ((scratch.get(trailing) ?? 0) <= 0) return [false, consumed, trailing];
scratch.set(trailing, scratch.get(trailing) - 1);
consumed.push(trailing);
trailing = '';
}
}
return [true, consumed, trailing];
}
export class DigitState {
constructor(draft) {
this.remaining = digitRuns(draft);
this.trailing = '';
this.consumed = new Map();
this.broken = false;
}
key() {
const parts = [];
for (const [run, count] of [...this.remaining].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
for (let i = 0; i < count; i += 1) parts.push(run);
}
return `${parts.join(',')}|${this.trailing}`;
}
closable() {
return !this.trailing || (this.remaining.get(this.trailing) ?? 0) > 0;
}
apply(text) {
const [legal, consumed, trailing] = walkPiece(text, this.remaining, this.trailing);
for (const run of consumed) {
this.remaining.set(run, (this.remaining.get(run) ?? 0) - 1);
this.consumed.set(run, (this.consumed.get(run) ?? 0) + 1);
}
for (const [run, count] of [...this.remaining]) if (count <= 0) this.remaining.delete(run);
this.trailing = trailing;
if (!legal) this.broken = true;
}
emitted() {
const total = new Map(this.consumed);
if (this.trailing) total.set(this.trailing, (total.get(this.trailing) ?? 0) + 1);
return total;
}
}
// Digit mask decision — mirrors masks_for (ban / allow / fallback), memoized
// by digit-state key. pieceTable: {pieceText, digitIds, bannedBytes}.
export function masksFor(state, pieceTable, memo) {
const key = state.key();
const hit = memo.get(key);
if (hit) return hit;
const allowed = [];
for (const id of pieceTable.digitIds) {
if (walkPiece(pieceTable.pieceText[id], state.remaining, state.trailing)[0]) allowed.push(id);
}
let entry;
if (state.closable()) {
const allowedSet = new Set(allowed);
entry = { mode: 'ban', ids: pieceTable.digitIds.filter((t) => !allowedSet.has(t)).concat(pieceTable.bannedBytes) };
} else if (allowed.length) {
entry = { mode: 'allow', ids: allowed };
} else {
entry = { mode: 'fallback', ids: [] };
}
memo.set(key, entry);
return entry;
}
// --- name copy-guard (a2_name_copy_guard) -----------------------------------
function splitNameToken(raw) {
return stripChars(raw, PUNCT_NAME_SET);
}
function isCapital(core) {
for (const ch of core) {
if (RE_LETTER.test(ch)) return RE_UPPER.test(ch);
}
return false;
}
export function tokMeta(text) {
const out = [];
for (const raw of pySplit(text)) {
const [core, lead, trail] = splitNameToken(raw);
out.push({ raw, core, lead, trail, cap: isCapital(core) });
}
return out;
}
export function exemptAt(tokens, index) {
if (tokens[index].lead) return true;
let scan = index - 1;
while (scan >= 0) {
const token = tokens[scan];
if (token.trail && [...token.trail].some((ch) => SENT_BREAK.has(ch))) return true;
if (!token.core) {
if ([...token.raw].some((ch) => SENT_BREAK.has(ch))) return true;
scan -= 1;
continue;
}
return false;
}
return true;
}
const PAIR = (a, b) => `${a}${SEP}${b}`;
export class NameTables {
constructor(draft) {
const tokens = tokMeta(draft);
this.cores = new Set();
for (const t of tokens) if (t.core) this.cores.add(t.core);
this.coresList = [...this.cores].sort();
this.capPairs = new Set();
this.foldPairs = new Set();
this.capNext = new Map();
this.nextFolds = new Map();
for (let i = 1; i < tokens.length; i += 1) {
const prev = tokens[i - 1];
const cur = tokens[i];
if (prev.core && cur.core && !prev.trail && !cur.lead) {
if (prev.cap && cur.cap) {
this.capPairs.add(PAIR(prev.core, cur.core));
if (!this.capNext.has(prev.core)) this.capNext.set(prev.core, new Set());
this.capNext.get(prev.core).add(cur.core);
}
this.foldPairs.add(PAIR(casefold(prev.core), casefold(cur.core)));
const pf = casefold(prev.core);
if (!this.nextFolds.has(pf)) this.nextFolds.set(pf, new Set());
this.nextFolds.get(pf).add(casefold(cur.core));
}
}
}
}
// Post-hoc checker — the frozen specification (design-doc rules).
export function nameViolations(draft, candidate) {
const tables = new NameTables(draft);
const tokens = tokMeta(candidate);
const hits = [];
const meaningful = [];
const idxMap = [];
for (let i = 0; i < tokens.length; i += 1) {
if (tokens[i].core) { meaningful.push(tokens[i]); idxMap.push(i); }
}
for (let m = 0; m < meaningful.length; m += 1) {
const token = meaningful[m];
const i = idxMap[m];
const ex = exemptAt(tokens, i);
const prev = m > 0 ? meaningful[m - 1] : null;
const prevI = m > 0 ? idxMap[m - 1] : null;
const adjacent = prev !== null && prevI === i - 1 && !prev.trail && !token.lead;
const foldPair = prev ? PAIR(casefold(prev.core), casefold(token.core)) : null;
if (token.cap && !ex) {
const strict = tables.cores.has(token.core);
const chain = adjacent && prev.cap && tables.foldPairs.has(foldPair);
if (!strict && !chain) hits.push(['R1', token.core]);
}
if (adjacent && prev.cap && token.cap && !ex && !exemptAt(tokens, prevI)) {
if (!tables.capPairs.has(PAIR(prev.core, token.core)) && !tables.foldPairs.has(foldPair)) {
hits.push(['R2a', `${prev.core} ${token.core}`]);
}
}
if (adjacent && prev.cap && !exemptAt(tokens, prevI)
&& casefold(prev.core) === casefold(token.core) && !tables.foldPairs.has(foldPair)) {
hits.push(['R2b', `${prev.core} ${token.core}`]);
}
}
return hits;
}
class Cursor {
constructor() {
this.buf = '';
this.exemptNext = true;
this.prevCore = null;
this.prevCap = false;
this.prevTrailEmpty = true;
this.prevExempt = true;
this.prevAdjacent = false;
}
clone() {
const other = new Cursor();
other.buf = this.buf;
other.exemptNext = this.exemptNext;
other.prevCore = this.prevCore;
other.prevCap = this.prevCap;
other.prevTrailEmpty = this.prevTrailEmpty;
other.prevExempt = this.prevExempt;
other.prevAdjacent = this.prevAdjacent;
return other;
}
}
export class NameState {
constructor(draft) {
this.tables = new NameTables(draft);
this.cur = new Cursor();
this.broken = false;
}
closeToken(cursor, raw) {
const tables = this.tables;
const [core, lead, trail] = splitNameToken(raw);
if (!core) {
if ([...raw].some((ch) => SENT_BREAK.has(ch))) cursor.exemptNext = true;
cursor.prevAdjacent = false;
return [];
}
const cap = isCapital(core);
const exempt = Boolean(lead) || cursor.exemptNext;
const viols = [];
const adjacent = cursor.prevAdjacent && cursor.prevTrailEmpty && !lead;
const foldPair = cursor.prevCore !== null ? PAIR(casefold(cursor.prevCore), casefold(core)) : null;
if (cap && !exempt) {
const strict = tables.cores.has(core);
const chain = adjacent && cursor.prevCap && tables.foldPairs.has(foldPair);
if (!strict && !chain) viols.push(['R1', core]);
}
if (adjacent && cursor.prevCap && cap && !exempt && !cursor.prevExempt) {
if (!tables.capPairs.has(PAIR(cursor.prevCore, core)) && !tables.foldPairs.has(foldPair)) {
viols.push(['R2a', `${cursor.prevCore} ${core}`]);
}
}
if (adjacent && cursor.prevCap && !cursor.prevExempt && cursor.prevCore !== null
&& casefold(cursor.prevCore) === casefold(core) && !tables.foldPairs.has(foldPair)) {
viols.push(['R2b', `${cursor.prevCore} ${core}`]);
}
cursor.prevCore = core;
cursor.prevCap = cap;
cursor.prevTrailEmpty = trail === '';
cursor.prevExempt = exempt;
cursor.prevAdjacent = true;
cursor.exemptNext = [...trail].some((ch) => SENT_BREAK.has(ch));
return viols;
}
feed(cursor, text) {
const viols = [];
for (const ch of text) {
if (isPySpace(ch)) {
if (cursor.buf) {
viols.push(...this.closeToken(cursor, cursor.buf));
cursor.buf = '';
}
} else cursor.buf += ch;
}
return viols;
}
partialViable(cursor) {
const buf = cursor.buf;
if (!buf) return true;
const [core, lead, trail] = splitNameToken(buf);
if (!core || !isCapital(core)) return true;
if (lead || cursor.exemptNext) return true;
if (trail) {
// v2 addendum: trailing punctuation freezes the core — must be
// close-legal NOW.
const probe = cursor.clone();
return this.closeToken(probe, buf).length === 0;
}
const tables = this.tables;
const adjacent = cursor.prevAdjacent && cursor.prevTrailEmpty && !lead;
const prevCapAdj = adjacent && cursor.prevCap && cursor.prevCore !== null;
if (prevCapAdj && !cursor.prevExempt) {
const prevCore = cursor.prevCore;
const prevFold = casefold(prevCore);
const foldCore = casefold(core);
for (const nf of tables.nextFolds.get(prevFold) ?? []) {
if (nf.startsWith(foldCore)) return true;
}
for (const w of tables.capNext.get(prevCore) ?? []) {
if (w.startsWith(core) && casefold(w) !== prevFold) return true;
}
return false;
}
if (prevCapAdj) {
const foldCore = casefold(core);
const prevFold = casefold(cursor.prevCore);
for (const nf of tables.nextFolds.get(prevFold) ?? []) {
if (nf.startsWith(foldCore)) return true;
}
}
for (const candidateCore of tables.coresList) {
if (candidateCore.startsWith(core)) return true;
}
return false;
}
trial(text, closing = false) {
if (this.broken) return true;
const cursor = this.cur.clone();
const viols = this.feed(cursor, text);
if (closing && cursor.buf) {
viols.push(...this.closeToken(cursor, cursor.buf));
cursor.buf = '';
}
if (viols.length) return false;
if (!closing && !this.partialViable(cursor)) return false;
return true;
}
eosOk() {
return this.trial('', true);
}
apply(text) {
this.feed(this.cur, text);
}
}
// Word-by-word NameState close events must agree with the checker (port of
// selftest_equivalence; throws on any disagreement).
export function selftestEquivalence(pairs) {
let agreeZero = 0;
let agreeHit = 0;
pairs.forEach(([draft, candidate], index) => {
const checkerHits = JSON.stringify(nameViolations(draft, candidate));
const state = new NameState(draft);
const cursor = state.cur;
const incremental = state.feed(cursor, candidate);
if (cursor.buf) {
incremental.push(...state.closeToken(cursor, cursor.buf));
cursor.buf = '';
}
if (JSON.stringify(incremental) !== checkerHits) {
throw new Error(`selftest disagreement at pair ${index}: checker=${checkerHits} incremental=${JSON.stringify(incremental)}`);
}
if (checkerHits === '[]') agreeZero += 1;
else agreeHit += 1;
});
return { pairsZero: agreeZero, pairsWithHits: agreeHit };
}
// --- guarded per-row decode step (a2_ab_decode_nameguard tier loop) ---------
//
// One GuardedRow per batch row. Per step the caller:
// 1. asks maskRequest() and applies the digit veto to the raw logits
// ('fallback' => apply nothing; the row is marked broken internally),
// 2. computes rawTop (argmax of RAW logits), maskedTop (argmax of MASKED
// logits — argmax, not topk[0]: tie ordering must match the reference)
// and the masked-descending candidate tiers,
// 3. calls step(rawTop, maskedTop, getTier) where getTier(tierIndex)
// returns {ids, validCount} — candidate ids in descending masked-logit
// order, entries at position >= validCount being NEG-vetoed (torch.topk
// order: vetoed entries always sort after every legal one).
// step() returns the chosen token id (EOS_ID when the row finishes) and
// mutates all guard state exactly like the frozen Python loop.
export class GuardedRow {
constructor(draft, pieceTable, maskMemo, budget = undefined) {
this.draft = draft;
this.pieceTable = pieceTable;
this.maskMemo = maskMemo;
this.digit = new DigitState(draft);
this.name = new NameState(draft);
this.draftPatterns = repeatPatterns(draft);
this.sim = '';
this.outputs = [];
this.finished = false;
this.budget = budget === undefined ? Math.trunc(BAND_HI * cpLen(draft)) - 1 : budget;
this.stats = {
digitVetoSteps: 0,
repeatVetoSteps: 0,
nameVetoSteps: 0,
nameTierEscalations: 0,
digitFallback: false,
nameBroken: false,
lengthStops: 0,
};
}
// Digit veto for this step; caller applies it BEFORE building tiers.
maskRequest() {
if (this.finished || this.digit.broken) return { mode: 'none', ids: [] };
const entry = masksFor(this.digit, this.pieceTable, this.maskMemo);
if (entry.mode === 'fallback') {
this.stats.digitFallback = true;
this.digit.broken = true;
return { mode: 'fallback', ids: [] };
}
return entry;
}
step(rawTop, maskedTop, getTier) {
if (this.finished) return EOS_ID;
if (maskedTop !== rawTop) this.stats.digitVetoSteps += 1;
let pick = null;
let firstReject = null;
for (let tierIndex = 0; tierIndex < TIERS.length; tierIndex += 1) {
const { ids, validCount } = getTier(tierIndex);
const limit = Math.min(ids.length, validCount);
for (let i = 0; i < limit; i += 1) {
const token = ids[i];
if (token === EOS_ID) {
if (this.name.broken || this.name.eosOk()) { pick = token; break; }
if (firstReject === null) firstReject = 'name';
continue;
}
const text = this.pieceTable.pieceText[token];
if (!text) continue; // v3 addendum: empty-piece specials never escape veto pressure
if (!(this.name.broken || this.name.trial(text))) {
if (firstReject === null) firstReject = 'name';
continue;
}
if (hasNewRepeat(this.sim + text, this.draftPatterns)) {
if (firstReject === null) firstReject = 'repeat';
continue;
}
pick = token;
break;
}
if (pick !== null) break;
if (tierIndex + 1 < TIERS.length) this.stats.nameTierEscalations += 1;
}
if (pick === null) {
pick = maskedTop;
this.name.broken = true;
this.stats.nameBroken = true;
}
if (firstReject === 'name' && pick !== maskedTop) this.stats.nameVetoSteps += 1;
else if (firstReject === 'repeat' && pick !== maskedTop) this.stats.repeatVetoSteps += 1;
if (
this.budget !== null
&& pick !== EOS_ID
&& cpLen(pyStrip(this.sim + this.pieceTable.pieceText[pick])) > this.budget
&& (this.digit.broken || this.digit.closable())
&& (this.name.broken || this.name.eosOk())
) {
pick = EOS_ID;
this.stats.lengthStops += 1;
}
if (pick === EOS_ID) {
this.finished = true;
} else {
this.outputs.push(pick);
const text = this.pieceTable.pieceText[pick];
this.sim += text;
if (!this.digit.broken) this.digit.apply(text);
if (!this.name.broken) this.name.apply(text);
}
return pick;
}
}