| | // The cross-script bridge: a name written in Arabic/Persian script reaching a record that |
| | // spells it in Latin, and back (DESIGN-v2 §7.1, the D-075 fix). |
| | // |
| | // WHY THIS EXISTS. D-075 measured the only recall failure in this project that is **zero** |
| | // rather than merely imperfect: `رحمان` cannot reach a record spelling it `Rahman`, in either |
| | // direction. Persian recall works (Persian query, Persian record) and Latin recall works (Latin |
| | // query, Latin record); a name transliterated ACROSS the two has no bridge at all. For a user |
| | // who talks to his agents in Persian about people whose names those agents wrote down in Latin, |
| | // the memory is present, correct, and unreachable in half the languages he actually types. |
| | // |
| | // WHY D-075 REFUSED TO BUILD IT IN PASSING, AND WHAT CHANGED. Its objection was precise: |
| | // Persian romanisation is many-to-many — `ر` is always `r`, but `و` is `v`/`u`/`o`/`w` and short |
| | // vowels are not written at all — so a bridge "generates candidate keys rather than one key, and |
| | // every extra key widens the lexical net" on a store that already ranks imperfectly. Doing it |
| | // badly is worse than the gap. |
| | // |
| | // THAT OBJECTION IS ANSWERED BY INVERTING THE DIRECTION OF THE MAPPING, and it is the one place |
| | // this module departs from the design. DESIGN-v2 §7.1 says to "generate a set of Latin |
| | // candidates" per Persian token. This module generates NO candidates. It folds BOTH scripts into |
| | // one **consonant skeleton** — a single key per token on each side — so the many-to-many problem |
| | // disappears into the fold instead of exploding out of it: |
| | // |
| | // رحمان → r · h · m · (ا dropped) · n → "rhmn" |
| | // Rahman → r · (a) · h · m · (a) · n → "rhmn" ← same key, one lookup |
| | // |
| | // ایرج → (ا) · (ی) · r · j → "rj" |
| | // Iraj → (i) · r · (a) · j → "rj" |
| | // |
| | // رضوان → r · z · (و dropped) · (ا) · n → "rzn" |
| | // Rezvan → r · (e) · z · (v dropped) · (a) · n → "rzn" |
| | // |
| | // The letters that are ambiguous in romanisation are exactly the letters that carry no |
| | // information about a name's identity — the short vowels nobody writes, and `و`/`ی`, which are |
| | // vowels as often as they are consonants. Dropping them on BOTH sides is what makes one key |
| | // enough. One key is a `Map` lookup; a candidate set is a scan, and a scan is what D-075 refused. |
| | // |
| | // FOUR GUARDS, because a lexical net that widens is exactly the failure D-075 warned about: |
| | // |
| | // 1. **The bridge only ever fires BETWEEN SCRIPTS.** Two Latin tokens are never compared by |
| | // skeleton, and neither are two Arabic-script tokens. Latin→Latin and Persian→Persian recall |
| | // are therefore untouched by construction, not by measurement — the two paths that already |
| | // work cannot regress. |
| | // 2. **Reduced weight** (§7.1): a bridged match is worth {@link BRIDGE_WEIGHT} of a direct one, |
| | // so it can pull a record into the candidate set but rarely outranks a direct hit. |
| | // 3. **Length floors**: the source token must be at least {@link MIN_TOKEN} characters and the |
| | // skeleton at least {@link MIN_SKELETON}, so one- and two-letter noise never bridges. |
| | // 4. **Vowel-only skeletons are refused.** A token that folds to nothing (`او`, `اي`) produces |
| | // no key at all rather than an empty one that matches everything. |
| | // |
| | // It is a LEXICAL bridge and nothing more. Cross-script SEMANTIC recall stays out of scope for |
| | // exactly the reason DESIGN-v2 §12.1 gives: the bundled model is English-only and degenerates |
| | // into "same script" on anything else (D-065). This module makes a name findable; it does not |
| | // make the embedder multilingual. |
| | /** A bridged match is worth this fraction of a direct one (§7.1's reduced weight). Chosen so a |
| | * bridged hit clears the relevance floor on its own but loses to any direct hit of the same |
| | * token — the bridge widens what is REACHABLE without reordering what already works. */ |
| | export const BRIDGE_WEIGHT = 0.6; |
| | /** Shortest source token that may bridge. Below this a "name" is not a name. */ |
| | const MIN_TOKEN = 3; |
| | /** Shortest skeleton that may bridge. Two consonants is enough for `ایرج`/`Iraj` (`rj`), which |
| | * is one of the two names D-075 measured at zero, and short enough to be worth the guard that |
| | * the two tokens must be in different scripts. */ |
| | const MIN_SKELETON = 2; |
| | /** Arabic-script letters, in the block Persian actually uses (plus the Arabic-block forms that |
| | * arrive from copy-paste). Deliberately not `\p{Script=Arabic}` alone — that includes digits |
| | * and punctuation we do not want to reason about. */ |
| | const ARABIC_LETTER = /[ؠ-يٮ-ۓۺ-ۿ]/u; |
| | /** True when the token is written in Arabic/Persian script. */ |
| | export function isArabicScript(token) { |
| | return ARABIC_LETTER.test(token); |
| | } |
| | /** True when the token is written in Latin script. */ |
| | export function isLatinScript(token) { |
| | return /[a-z]/i.test(token); |
| | } |
| | /** |
| | * Arabic-script letter → skeleton consonant. |
| | * |
| | * Letters that romanise to a digraph get a single uppercase code (`sh` → `S`, `kh` → `X`) so a |
| | * skeleton is always one character per consonant and the Latin side can produce the same code |
| | * from the digraph. Letters that are vowels, vowel-carriers or ambiguous between vowel and |
| | * consonant map to the empty string and disappear from the key on both sides. |
| | */ |
| | const ARABIC_SKELETON = { |
| | // vowels, vowel carriers, hamza seats, and the two ambiguous semivowels |
| | "ا": "", "آ": "", "أ": "", "إ": "", "ٱ": "", "ء": "", "ؤ": "", "ئ": "", "ى": "", "ة": "", |
| | "و": "", "ۇ": "", "ۆ": "", "ی": "", "ي": "", "ې": "", "ۍ": "", "ع": "", |
| | // consonants |
| | "ب": "b", "پ": "p", |
| | "ت": "t", "ط": "t", |
| | "ث": "s", "س": "s", "ص": "s", |
| | "ج": "j", |
| | "چ": "C", |
| | "ح": "h", "ه": "h", "ھ": "h", |
| | "خ": "X", |
| | "د": "d", "ذ": "z", |
| | "ر": "r", |
| | "ز": "z", "ض": "z", "ظ": "z", "ژ": "Z", |
| | "ش": "S", |
| | "ف": "f", |
| | "ق": "Q", "غ": "Q", |
| | "ک": "k", "ك": "k", |
| | "گ": "g", |
| | "ل": "l", |
| | "م": "m", |
| | "ن": "n", |
| | }; |
| | /** Latin digraphs that stand for one Arabic-script letter. Order matters: longest first, and |
| | * `kh`/`gh`/`sh`/`ch`/`zh` before the bare letters they start with. */ |
| | const LATIN_DIGRAPHS = [ |
| | ["kh", "X"], |
| | ["gh", "Q"], |
| | ["sh", "S"], |
| | ["ch", "C"], |
| | ["zh", "Z"], |
| | ["ph", "f"], |
| | ["th", "t"], |
| | ["dj", "j"], |
| | ]; |
| | /** |
| | * Latin letter → skeleton consonant. The vowels go, and so do `w`, `y` and `v`, because those |
| | * are precisely the letters romanisation uses for `و` and `ی` — the two Arabic-script letters |
| | * this module drops. Dropping them on both sides is what makes `Rezvan`/`رضوان` and |
| | * `Davood`/`داوود` fold to the same key. |
| | */ |
| | const LATIN_SKELETON = { |
| | a: "", e: "", i: "", o: "", u: "", w: "", y: "", v: "", |
| | b: "b", c: "k", d: "d", f: "f", g: "g", h: "h", j: "j", k: "k", l: "l", m: "m", |
| | n: "n", p: "p", q: "Q", r: "r", s: "s", t: "t", x: "X", z: "z", |
| | }; |
| | /** Collapse a doubled consonant: `Sajjad` writes the gemination Persian leaves implicit. */ |
| | function collapseDoubles(skeleton) { |
| | let out = ""; |
| | for (const ch of skeleton) |
| | if (ch !== out[out.length - 1]) |
| | out += ch; |
| | return out; |
| | } |
| | /** |
| | * The cross-script key for one token, or `undefined` when the token may not bridge. |
| | * |
| | * `undefined` is returned for a token that is too short, folds to fewer than |
| | * {@link MIN_SKELETON} consonants, or is in neither script — every one of which is a case where |
| | * a key would match more than it should. |
| | */ |
| | export function skeletonKey(token) { |
| | if (token.length < MIN_TOKEN) |
| | return undefined; |
| | const arabic = isArabicScript(token); |
| | const latin = isLatinScript(token); |
| | // A token mixing both scripts is not a transliteration of anything; refuse rather than guess. |
| | if (arabic === latin) |
| | return undefined; |
| | const skeleton = collapseDoubles(arabic ? foldArabic(token) : foldLatin(token)); |
| | return skeleton.length >= MIN_SKELETON ? skeleton : undefined; |
| | } |
| | function foldArabic(token) { |
| | let out = ""; |
| | for (const ch of token) |
| | out += ARABIC_SKELETON[ch] ?? ""; |
| | return out; |
| | } |
| | function foldLatin(token) { |
| | // The caller has already folded diacritics and lowercased (`relevance.fold`), but this is |
| | // exported and cheap, so do not depend on it. |
| | let s = token.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase(); |
| | for (const [digraph, code] of LATIN_DIGRAPHS) |
| | s = s.split(digraph).join(` ${code} `); |
| | let out = ""; |
| | let literal = false; |
| | for (const ch of s) { |
| | if (ch === " |