elffuss-code / js /engine /sample.js
KikoCis's picture
Sync with code.elffuss.com (fc5b0ba): LFM3 on our own engine, installable app, VS Code extensions, new look; README refreshed
4bd8362 verified
Raw History Blame Contribute Delete
8.94 kB
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 UtopiaIA
// Elffuss Runtime · muestreo y bucle de generación.
// ─────────────────────────────────────────────────────────────────────────────
// El bucle de decode es donde el motor pasa el 99 % del tiempo, y donde falla lo
// que un forward suelto no puede detectar: que el estado se arrastre bien entre
// llamadas. Un forward aislado empieza con el estado a cero y la caché vacía, así
// que valida la matemática pero no la memoria.
//
// La prueba que sí lo detecta es de consistencia interna: procesar «abc» de una
// vez tiene que dar el MISMO resultado que procesar «a», luego «b», luego «c».
// Si la caché KV se escribe en el sitio equivocado, si el estado recurrente no
// se arrastra o si las posiciones de RoPE se reinician, las dos formas divergen.
//
// Licencia: código propio, Apache-2.0 (ver SPDX arriba y LICENSE). Sin dependencias externas.
// Muestreo sobre los logits ya en CPU. Se trabaja sobre el último token, que es
// el único que interesa al generar. Sigue el ORDEN de llama.cpp, que es la
// referencia con la que se mide todo: penalizaciones → top-k → top-p → min-p →
// temperatura. La temperatura va AL FINAL: top-p y min-p se calculan sobre la
// distribución sin temperatura y la temperatura solo reparte entre los que
// sobreviven. Antes iba al principio, y con temperatura < 1 (la ficha del 27B
// pide 0,7 con top-p 0,8) el top-p cortaba sobre una distribución más
// afilada: sobrevivían menos tokens que en llama.cpp, sin error ninguno.
// · temp = 0 → greedy, determinista (tras las penalizaciones).
// · repeatPenalty: la de llama.cpp (divide si el logit es positivo y
// multiplica si es negativo). Su defecto aquí sigue siendo 1,1 para no
// cambiar a los modelos que no declaran muestreo; el llama.cpp actual trae
// 1,0, y los modelos con ficha la apagan desde el registro.
// · presence: resta fija a todo token que aparezca en la ventana (la ficha
// del 27B pide 1,5 sin razonar), como `presence_penalty` de llama.cpp.
// · minP: descarta los tokens cuya probabilidad sea menor que minP × la del
// más probable.
// mulberry32 sobre la semilla: buena mezcla también con semillas consecutivas
// (seed + paso), que es como se llama token a token.
function uniforme(seed) {
let t = ((seed >>> 0) + 0x6D2B79F5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// ¿Va `a` antes que `b`? Logit mayor primero; a igualdad, índice menor.
const antes = (logits, a, b) => logits[a] > logits[b] || (logits[a] === logits[b] && a < b);
// Los K mejores índices, ordenados. Montículo de MÍNIMOS de tamaño K (la raíz es
// el peor de los que se guardan): cada logit nuevo solo entra si gana a la raíz.
export function topKIndices(logits, k) {
const h = new Int32Array(k);
let size = 0;
const peor = (a, b) => antes(logits, b, a); // a es «peor» que b
const sube = (i) => { while (i > 0) { const p = (i - 1) >> 1; if (!peor(h[i], h[p])) break; [h[i], h[p]] = [h[p], h[i]]; i = p; } };
const baja = (i) => {
for (;;) {
const l = 2 * i + 1, r = l + 1; let m = i;
if (l < size && peor(h[l], h[m])) m = l;
if (r < size && peor(h[r], h[m])) m = r;
if (m === i) return;
[h[i], h[m]] = [h[m], h[i]]; i = m;
}
};
for (let i = 0; i < logits.length; i++) {
if (size < k) { h[size] = i; sube(size++); }
else if (antes(logits, i, h[0])) { h[0] = i; baja(0); }
}
return Array.from(h.subarray(0, size)).sort((a, b) => (antes(logits, a, b) ? -1 : 1));
}
// Sin top-k: los índices a menos de `margen` del máximo, ordenados.
function candidatos(logits, margen) {
let max = -Infinity;
for (let i = 0; i < logits.length; i++) if (logits[i] > max) max = logits[i];
const out = [];
for (let i = 0; i < logits.length; i++) if (logits[i] >= max - margen) out.push(i);
return out.sort((a, b) => (antes(logits, a, b) ? -1 : 1));
}
export function sample(logits, opts = {}) {
const { temperature = 0, topP = 1, topK = 0, minP = 0, seed = 1,
repeatPenalty = 1.1, repeatLastN = 64, presence = 0, previos = null } = opts;
const n = logits.length;
// PENALIZACIONES, sobre los últimos `repeatLastN` (prompt incluido, como
// llama.cpp). La de repetición existe porque el decode voraz sobre un modelo
// muy cuantizado entra en bucle («el color es de color, el color es de…»).
if (previos && previos.length && (repeatPenalty !== 1 || presence)) {
const desde = Math.max(0, previos.length - repeatLastN);
const vistos = new Set();
for (let i = desde; i < previos.length; i++) vistos.add(previos[i]);
for (const id of vistos) {
if (id < 0 || id >= n) continue;
if (repeatPenalty !== 1) logits[id] = logits[id] > 0 ? logits[id] / repeatPenalty : logits[id] * repeatPenalty;
if (presence) logits[id] -= presence;
}
}
if (temperature <= 0) {
let best = 0;
for (let i = 1; i < n; i++) if (logits[i] > logits[best]) best = i;
return best;
}
// Los candidatos ordenados por logit (desc; a igual logit, el índice menor
// primero, que es lo que daba la ordenación estable de antes). NO se ordena el
// vocabulario entero: eso eran decenas de milisegundos POR TOKEN en JS, más
// que el forward de un modelo pequeño. Con top-k se seleccionan los K con un
// montículo en una pasada; sin top-k, solo los que quedan a menos de 40 nats
// del máximo (fuera de ahí la probabilidad no llega a 1e-17 cada uno).
const idx = topK > 0 ? topKIndices(logits, Math.min(topK, n)) : candidatos(logits, 40);
const keep = idx.length;
// top-p y min-p SIN temperatura. Resta del máximo antes de exp(): sin ella,
// logits de 30 desbordan.
const max = logits[idx[0]];
const base = new Float64Array(keep);
let total = 0;
for (let i = 0; i < keep; i++) { base[i] = Math.exp(logits[idx[i]] - max); total += base[i]; }
let corte = keep;
if (topP < 1) {
let acc = 0;
for (let i = 0; i < keep; i++) {
acc += base[i] / total;
if (acc >= topP) { corte = i + 1; break; }
}
}
if (minP > 0) {
// base[0] es el más probable (están ordenados): el umbral es relativo a él.
const umbral = minP * base[0];
let k = 1;
while (k < corte && base[k] >= umbral) k++;
corte = k;
}
// La temperatura, solo entre los supervivientes.
const probs = new Float64Array(corte);
let sub = 0;
for (let i = 0; i < corte; i++) { probs[i] = Math.exp((logits[idx[i]] - max) / temperature); sub += probs[i]; }
// Generador propio y con semilla: Math.random() haría irreproducible
// cualquier prueba de regresión de generación. Uniforme en [0, 1).
//
// ⚠️ Antes era un paso de LCG con `(s >>> 8) / 8388608`: el numerador llega a
// 2²⁴ y el divisor es 2²³, así que el «uniforme» caía en [0, 2). La mitad de
// las veces r > sub, el bucle de abajo se salía sin elegir y devolvía el
// ÚLTIMO superviviente: el menos probable (sin top-k, el peor token de todo el
// vocabulario). Y semillas consecutivas daban valores correlados. Lo cazó
// una prueba al pedir que un token improbable saliera alguna vez.
let r = uniforme(seed) * sub;
for (let i = 0; i < corte; i++) {
r -= probs[i];
if (r <= 0) return idx[i];
}
return idx[corte - 1];
}
// Genera tokens uno a uno. El prompt entero va en UNA llamada (prefill) y cada
// token nuevo en la suya (decode), que es exactamente el patrón real.
export async function generate(session, readLogits, promptIds, opts = {}) {
const max = opts.maxTokens || 32;
const stop = new Set(opts.stopIds || []);
const onToken = opts.onToken || (() => {});
const out = [];
let ids = promptIds;
for (let step = 0; step < max; step++) {
const { logits, logitsTokens } = await session.forward(ids);
// El forward devuelve logits SOLO del último token salvo que se pidan todos:
// deducir el vocabulario dividiendo por el número de tokens del prompt daría
// un vocabulario 316 veces más pequeño y una lectura corrida.
const vocab = logits.size / 4 / logitsTokens;
const all = await readLogits(logits, logitsTokens * vocab);
const last = all.subarray((logitsTokens - 1) * vocab, logitsTokens * vocab);
// Se le pasan los tokens ya emitidos MÁS el prompt: repetir lo que acaba de
// decir el usuario es tan bucle como repetirse a sí mismo.
const id = sample(last, { ...opts, seed: (opts.seed || 1) + step,
previos: promptIds.concat(out) });
session.recycle();
if (stop.has(id)) break;
out.push(id);
onToken(id, step);
ids = [id];
}
return out;
}