/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; /// /** * Regression coverage for bug 2043430. * * The byte-fallback.gguf test model deterministically emits, regardless of the * prompt, the byte-fallback tokens: * * E5 97 A3 80 (then EOS) * * where E5 97 A3 is the valid codepoint U+55E3 "嗣" and 0x80 is a lone UTF-8 * continuation byte (always malformed). Because the runner streams each token's * piece as a UTF8String that is converted to a JS string, any chunk that is not * itself valid UTF-8 makes the conversion throw * "malformed UTF-8 character sequence at offset N". * * Stopping at different token counts (`nPredict`) and varying the flush size * (`minOutputBufferSize`) reproduces every root of that failure with a single * model. The runner must instead stream only valid UTF-8: reassembling split * codepoints losslessly and substituting U+FFFD for bytes that can never form a * valid codepoint. */ // U+FFFD REPLACEMENT CHARACTER, what a UTF-8 decoder substitutes for bytes that // cannot form a valid codepoint. const REPLACEMENT = String.fromCharCode(0xfffd); const UTF8_MODEL = { taskName: "text-generation", modelId: "Mozilla/test-llama", modelFile: "byte-fallback.gguf", modelRevision: "main", backend: "llama.cpp", numContext: 128, }; async function runUtf8Generation({ minOutputBufferSize, nPredict }) { const { cleanup } = await setup(); try { const engine = await createEngine(UTF8_MODEL); const generator = engine.runWithGenerator({ prompt: [{ role: "user", content: "x" }], // top-k=1 narrows to a single candidate and `dist` then selects it, so // generation is deterministic (the model's logits are also effectively // one-hot). `dist` is required: top-k only filters, it never selects. samplers: [{ type: "top-k", topK: 1 }, { type: "dist" }], minOutputBufferSize, nPredict, }); let text = ""; let result; do { result = await generator.next(); if (!result.done) { text += result.value.text; } } while (!result.done); return text; } finally { await EngineProcess.destroyMLEngine(); await cleanup(); } } /** * Control: when a whole codepoint is buffered into a single chunk and * generation stops on a codepoint boundary, the stream is valid UTF-8 and we * get the character back. Independent of the fix. */ add_task(async function test_ml_llama_utf8_valid_buffered() { const text = await runUtf8Generation({ minOutputBufferSize: 20, nPredict: 3, }); Assert.equal(text, "嗣", "Whole codepoint buffered into one chunk decodes"); }); /** * Root 1 - codepoint split across chunks. With minOutputBufferSize=1 each * byte-fallback token flushes on its own, so the three bytes of "嗣" are * converted to JS individually. The runner must reassemble the codepoint. */ add_task(async function test_ml_llama_utf8_split_across_chunks() { const text = await runUtf8Generation({ minOutputBufferSize: 1, nPredict: 3 }); Assert.equal( text, "嗣", "Codepoint split across byte-token chunks is reassembled" ); }); /** * Root 2 - codepoint truncated at end of generation. Generation stops * (nPredict=2) after E5 97, mid-codepoint. The missing byte was never emitted, * so the incomplete trailing sequence stays buffered and is dropped rather than * thrown. */ add_task(async function test_ml_llama_utf8_truncated_at_end() { const text = await runUtf8Generation({ minOutputBufferSize: 20, nPredict: 2, }); Assert.equal( text, "", "Codepoint truncated at end-of-generation is dropped, not thrown" ); }); /** * Root 3 - genuinely malformed byte mid-stream. The lone 0x80 continuation byte * can never form a valid codepoint, so it must be substituted with U+FFFD while * the preceding valid codepoint is preserved. */ add_task(async function test_ml_llama_utf8_malformed_byte() { const text = await runUtf8Generation({ minOutputBufferSize: 20, nPredict: 4, }); Assert.equal( text, "嗣" + REPLACEMENT, "Malformed byte becomes U+FFFD without dropping the valid codepoint" ); });