270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
/*
|
||||
|
|
* mt-guard.js — catch what NLLB invents, before a congregation hears it.
|
|||
|
|
*
|
|||
|
|
* WHY THIS IS NOT A GENERATION-PARAMETER FIX. Measured against the shipped
|
|||
|
|
* model (nllb-200-distilled-600M fp32), "Donald Trump." produces
|
|||
|
|
* "Donald Trump. ¿Qué es eso?" under ALL of: default params, an input-derived
|
|||
|
|
* max_new_tokens cap, no_repeat_ngram_size:3, and num_beams:2. The model
|
|||
|
|
* genuinely believes that is the translation. Decoding cannot be tuned out of
|
|||
|
|
* it; the output has to be checked.
|
|||
|
|
*
|
|||
|
|
* WHY THE OBVIOUS RULE IS WRONG. "Source has no '?', target has one, so strip
|
|||
|
|
* the question" corrupts real output: Whisper routinely drops the question mark
|
|||
|
|
* on a rhetorical aside, so "Are you with me" arrives unpunctuated and a
|
|||
|
|
* correct Spanish rendering is "¿Estás conmigo?" — which that rule would
|
|||
|
|
* delete. The reliable signal is not the question mark, it is the SENTENCE
|
|||
|
|
* COUNT: one sentence in, two out, and the second one invented.
|
|||
|
|
*
|
|||
|
|
* WHY ONLY SHORT INPUTS. Hallucination is a short-input failure — the model has
|
|||
|
|
* no context and fills the gap. Long sentences are also the ones that
|
|||
|
|
* legitimately split in the target (many languages prefer shorter sentences
|
|||
|
|
* than English), so clamping them would delete real scripture. The length gate
|
|||
|
|
* keeps the clamp where the evidence is.
|
|||
|
|
*/
|
|||
|
|
(function (root, factory) {
|
|||
|
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|||
|
|
else root.MtGuard = factory();
|
|||
|
|
}(typeof self !== 'undefined' ? self : this, function () {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
/* Sentence-final punctuation across the scripts this app serves: Latin,
|
|||
|
|
CJK ideographic stop, fullwidth marks, Arabic question mark, Devanagari
|
|||
|
|
danda, Armenian, Greek, Thai has none. */
|
|||
|
|
const TERMINATORS = '.!?。!?؟۔।॥…՜՞;';
|
|||
|
|
const TERM_RE = new RegExp('[' + TERMINATORS.replace(/[.\-\]\\]/g, '\\$&') + ']');
|
|||
|
|
const QUESTION_RE = /[??؟՞]/;
|
|||
|
|
|
|||
|
|
/* Beyond this the input carries enough context that the model stops
|
|||
|
|
inventing, and legitimate sentence-splitting starts. Measured failures all
|
|||
|
|
sit far below it. */
|
|||
|
|
/* Narrow deliberately. Every hallucination measured against this model sat
|
|||
|
|
at one or two words; a wider gate starts reaching utterances that split
|
|||
|
|
into two target sentences legitimately, and deleting real scripture is a
|
|||
|
|
far worse failure than leaving one invented clause in. */
|
|||
|
|
const SHORT_WORDS = 6;
|
|||
|
|
|
|||
|
|
function words(s) {
|
|||
|
|
return String(s || '').trim().split(/\s+/).filter(Boolean).length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Count sentences by terminator runs. "Mr. Smith went." must not read as two,
|
|||
|
|
so a terminator followed by a lowercase letter does not end a sentence. */
|
|||
|
|
function sentenceCount(s) {
|
|||
|
|
const parts = splitSentences(s);
|
|||
|
|
return parts.length || 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function splitSentences(s) {
|
|||
|
|
const text = String(s || '').trim();
|
|||
|
|
if (!text) return [];
|
|||
|
|
const out = [];
|
|||
|
|
let cur = '';
|
|||
|
|
for (let i = 0; i < text.length; i++) {
|
|||
|
|
const ch = text[i];
|
|||
|
|
cur += ch;
|
|||
|
|
if (!TERM_RE.test(ch)) continue;
|
|||
|
|
/* Absorb a run of terminators and any closing quote/bracket. */
|
|||
|
|
while (i + 1 < text.length && (TERM_RE.test(text[i + 1]) || /["'\)\]”’」』]/.test(text[i + 1]))) {
|
|||
|
|
cur += text[++i];
|
|||
|
|
}
|
|||
|
|
const rest = text.slice(i + 1);
|
|||
|
|
/* An abbreviation or a decimal: "Dr. Smith", "3.5" — not a boundary. */
|
|||
|
|
if (/^\s*[a-zà-ÿ0-9]/.test(rest)) continue;
|
|||
|
|
out.push(cur.trim());
|
|||
|
|
cur = '';
|
|||
|
|
}
|
|||
|
|
if (cur.trim()) out.push(cur.trim());
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
* NLLB was trained on a lot of subtitles and emits their dialogue dash. It is
|
|||
|
|
* never correct here — nothing in this app is a screenplay.
|
|||
|
|
*/
|
|||
|
|
function stripDash(s) {
|
|||
|
|
return String(s || '').replace(/^\s*[-‐-―−]\s+/, '').trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
* The main entry. Returns { text, trimmed, reason } — `trimmed` is true when
|
|||
|
|
* something was removed, so callers can log it and the operator can see how
|
|||
|
|
* often the model is inventing.
|
|||
|
|
*/
|
|||
|
|
/*
|
|||
|
|
* A ONE-WORD source that comes back with a comma-clause. Measured across the
|
|||
|
|
* archive, 13.4% of everything the preacher says is one or two words, and the
|
|||
|
|
* model answers the short ones conversationally:
|
|||
|
|
*
|
|||
|
|
* "Hello." -> "Hola, ¿qué pasa?" "Yeah." -> "Sí, es cierto."
|
|||
|
|
* "No." -> "No, no." "Good." -> "Bien, muy bien."
|
|||
|
|
* "Come." -> "Ven, ven." "Look." -> "Mira, ¿qué?"
|
|||
|
|
*
|
|||
|
|
* One English word cannot mean two clauses, so the tail is invented. Keeping
|
|||
|
|
* only the head fixes all of these and touches nothing that was already
|
|||
|
|
* right ("La fe.", "Sin embargo.", "Por lo tanto." have no comma).
|
|||
|
|
*
|
|||
|
|
* Restricted to EXACTLY one word deliberately — at two words a comma can be
|
|||
|
|
* legitimate. Handles the CJK and ideographic commas too, since this is the
|
|||
|
|
* one output rule that needs no per-language knowledge.
|
|||
|
|
*/
|
|||
|
|
function trimInventedClause(src, out) {
|
|||
|
|
const words = String(src == null ? '' : src).replace(/[.!?,;:]+$/, '').split(/\s+/).filter(Boolean);
|
|||
|
|
if (words.length !== 1) return out;
|
|||
|
|
const m = String(out).match(/^([^,\uff0c\u3001]+)[,\uff0c\u3001]/u);
|
|||
|
|
if (!m) return out;
|
|||
|
|
const head = m[1].trim();
|
|||
|
|
if (!head) return out;
|
|||
|
|
const end = (String(src).match(/[.!?]$/) || [''])[0];
|
|||
|
|
return /[.!?\u3002\uff01\uff1f]$/.test(head) ? head : head + end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
* THE WHOLE OUTPUT IS AN INVENTED QUESTION.
|
|||
|
|
*
|
|||
|
|
* The sentence-count rule above catches an invented question APPENDED to a
|
|||
|
|
* real translation ("Donald Trump. ¿Qué es eso?"). It cannot catch the case
|
|||
|
|
* where the invention IS the output: one sentence in, one sentence out, and
|
|||
|
|
* the one out is "¿Qué es eso?" for a source of "Mmm" or "them" — MEASURED
|
|||
|
|
* in a live service, spoken aloud to the Spanish congregation three times in
|
|||
|
|
* one morning, and produced by 34 distinct short sources in the archive.
|
|||
|
|
*
|
|||
|
|
* Narrow on purpose. Source: at most two words and not itself a question.
|
|||
|
|
* Output: every sentence is a question. And the source must not be an
|
|||
|
|
* English interrogative or a tag — "Why.", "Who.", "Right?", "You know." —
|
|||
|
|
* because for those a Spanish question IS the correct rendering (measured:
|
|||
|
|
* 'why.' -> '¿Por qué?', 'Isn't it.' -> '¿No es cierto?'). Whisper drops
|
|||
|
|
* question marks routinely, so the word list, not the punctuation, is the
|
|||
|
|
* exemption.
|
|||
|
|
*/
|
|||
|
|
/*
|
|||
|
|
* Widened with the auxiliaries and contractions that FRONT a real English
|
|||
|
|
* question, because the rule below now reads every word of a short source
|
|||
|
|
* rather than the single word it used to be limited to.
|
|||
|
|
*
|
|||
|
|
* MEASURED: without `could` and `you're`, widening the rule deleted
|
|||
|
|
* 'Could it be' -> '¿Podría ser' and "You're interested in" -> '¿Te interesa?'
|
|||
|
|
* — both correct renderings of questions Whisper handed over unpunctuated.
|
|||
|
|
* With them the widened rule breaks NOTHING in 22,526 real short lines.
|
|||
|
|
*/
|
|||
|
|
const INTERROGATIVE_SRC = /^(?:why|who|whom|whose|what|where|when|when'?s|how|which|isn'?t|right|okay|ok|see|really|huh|anybody|anyone|somebody|someone|guess|any|says|you|you'?re|we'?re|they'?re|there'?s|it'?s|are|am|is|was|were|do|does|did|can|could|will|would|shall|should|may|might|must|have|has|had|ready|let'?s)$/i;
|
|||
|
|
|
|||
|
|
function isQuestionSentence(p) {
|
|||
|
|
const t = String(p || '').trim();
|
|||
|
|
return /^[¿]/.test(t) || /[??؟՞]["'\)\]”’」』]*$/.test(t);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bareSource(src) {
|
|||
|
|
return String(src || '').replace(/^[\s"'“‘(\[]+/u, '').replace(/[.!,;:…\-\s"'”’)\]]+$/u, '').trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Short enough that the model had no context to work from. Four, measured:
|
|||
|
|
see the numbers below. */
|
|||
|
|
const INVENTED_MAX_WORDS = 4;
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
* UP TO FOUR words, not one.
|
|||
|
|
*
|
|||
|
|
* This was restricted to a single word because a two-word rule was measured
|
|||
|
|
* blanking 'Why not.' -> '¿Por qué no?', 'Guess what.' -> '¿Adivina qué?',
|
|||
|
|
* 'You ready.' -> '¿Están listos?'. That finding was right; the conclusion
|
|||
|
|
* drawn from it was too narrow. What those cases have in common is not their
|
|||
|
|
* LENGTH, it is that they contain an interrogative word — so the exemption
|
|||
|
|
* below, not the word count, is what protects them. Testing every word of
|
|||
|
|
* the source instead of only a lone one keeps all of them and frees the
|
|||
|
|
* count to rise.
|
|||
|
|
*
|
|||
|
|
* MEASURED over this church's own archive — 349 confirmed inventions and
|
|||
|
|
* 22,526 legitimate short translations from ten real services:
|
|||
|
|
*
|
|||
|
|
* one word (before) 198/349 caught 57% 0 legitimate lines broken
|
|||
|
|
* <= 4 words 254/349 caught 73% 0 legitimate lines broken
|
|||
|
|
*
|
|||
|
|
* The congregation was hearing 'Check, check.' as '¿Qué quieres?', 'Holy
|
|||
|
|
* Spirit.' as '我們必須要做什麼?' and 'Hello.' as '¿Qué pasa?' — all of them
|
|||
|
|
* two words, all of them one word past where this rule used to stop looking.
|
|||
|
|
*
|
|||
|
|
* A source of NO words reaches here too ('-', '...', a stray dash Whisper
|
|||
|
|
* emits between speakers). Nothing cannot mean a question, so it is not
|
|||
|
|
* exempted: an empty source with an all-question output is an invention by
|
|||
|
|
* definition.
|
|||
|
|
*/
|
|||
|
|
function inventedQuestion(src, out) {
|
|||
|
|
if (QUESTION_RE.test(src)) return false;
|
|||
|
|
const words = bareSource(src).split(/\s+/).filter(Boolean);
|
|||
|
|
if (words.length > INVENTED_MAX_WORDS) return false;
|
|||
|
|
/* ANY interrogative word, anywhere in the source — 'Guess what.' and
|
|||
|
|
'Why not.' put it first, 'How about now' and 'And why' do not. */
|
|||
|
|
if (words.some(function (w) { return INTERROGATIVE_SRC.test(w); })) return false;
|
|||
|
|
const parts = splitSentences(out);
|
|||
|
|
return parts.length > 0 && parts.every(isQuestionSentence);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
* THE PORTUGUESE TWIN. NLLB's stock invention for a fragment in Portuguese is
|
|||
|
|
* not a question but the statement "Não." ('See.' -> 'Não.', 'Wow.' ->
|
|||
|
|
* 'Não.', 'way.' -> 'Não.', 'You understand?' -> '- Não.' — measured in
|
|||
|
|
* the archive), and the Spanish "No, no lo sé." family. A short source with
|
|||
|
|
* no negation in it cannot mean "No".
|
|||
|
|
*/
|
|||
|
|
const NEGATION_SRC = /\b(?:no|not|never|nope|nah|nay|nix|negative|nothing|none|nobody|neither|nor|don'?t|can'?t|won'?t|isn'?t|ain'?t|didn'?t|doesn'?t|wasn'?t|aren'?t|couldn'?t|wouldn'?t|shouldn'?t)\b|\b(?:uh|nuh)-?uh\b/i;
|
|||
|
|
const NEGATION_OUT = /^[\s¿¡\-‐-―−"'“”]*(?:não|nao|no|non|nein|nee|niet|nej|nie|нет|いいえ|不|아니요|لا)[\s.!?…"'”]*$/iu;
|
|||
|
|
|
|||
|
|
function inventedNegation(src, out) {
|
|||
|
|
const bare = bareSource(src);
|
|||
|
|
const words = bare.split(/\s+/).filter(Boolean);
|
|||
|
|
if (!words.length || words.length > 2) return false;
|
|||
|
|
if (NEGATION_SRC.test(bare)) return false;
|
|||
|
|
return NEGATION_OUT.test(String(out || ''));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clean(source, target) {
|
|||
|
|
const src = String(source == null ? '' : source).trim();
|
|||
|
|
let out = stripDash(target);
|
|||
|
|
if (!out) return { text: '', trimmed: false, reason: '' };
|
|||
|
|
|
|||
|
|
const dashed = out !== String(target || '').trim();
|
|||
|
|
|
|||
|
|
const oneWord = trimInventedClause(src, out);
|
|||
|
|
if (oneWord !== out) {
|
|||
|
|
return { text: oneWord, trimmed: true,
|
|||
|
|
reason: 'dropped invented clause after a one-word line \u2014 "' + out + '"' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (inventedQuestion(src, out)) {
|
|||
|
|
return { text: '', trimmed: true,
|
|||
|
|
reason: 'dropped an invented question for a short line \u2014 "' + out + '"' };
|
|||
|
|
}
|
|||
|
|
if (inventedNegation(src, out)) {
|
|||
|
|
return { text: '', trimmed: true,
|
|||
|
|
reason: 'dropped an invented negation for a short line \u2014 "' + out + '"' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (words(src) > SHORT_WORDS) {
|
|||
|
|
return { text: out, trimmed: dashed, reason: dashed ? 'subtitle dash' : '' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const nSrc = sentenceCount(src);
|
|||
|
|
const parts = splitSentences(out);
|
|||
|
|
if (parts.length <= nSrc) {
|
|||
|
|
return { text: out, trimmed: dashed, reason: dashed ? 'subtitle dash' : '' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
* More sentences came out than went in, on an input too short to justify
|
|||
|
|
* it. Keep the first nSrc — NLLB translates in order, so the real content
|
|||
|
|
* is at the front and the invention is appended.
|
|||
|
|
*/
|
|||
|
|
const kept = parts.slice(0, nSrc).join(' ').trim();
|
|||
|
|
const dropped = parts.slice(nSrc).join(' ').trim();
|
|||
|
|
if (!kept) return { text: out, trimmed: dashed, reason: '' };
|
|||
|
|
return {
|
|||
|
|
text: kept,
|
|||
|
|
trimmed: true,
|
|||
|
|
reason: 'dropped invented ' + (QUESTION_RE.test(dropped) ? 'question' : 'text') +
|
|||
|
|
' "' + dropped + '"'
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { clean, splitSentences, sentenceCount, stripDash, trimInventedClause, inventedQuestion, inventedNegation, SHORT_WORDS };
|
|||
|
|
}));
|