langtell 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { L as LanguageProfile } from './types-DfHZ5hkh.js';
1
+ import { L as LanguageProfile } from './types-DbPJ0sxF.js';
2
2
 
3
3
  /**
4
4
  * Per-snippet language classification by candidate-set-relative set-difference.
@@ -20,6 +20,14 @@ import { L as LanguageProfile } from './types-DfHZ5hkh.js';
20
20
  * *letter* `и` is shared. Nothing is precomputed — uniqueness is the runtime
21
21
  * output, never stored.
22
22
  *
23
+ * Above the ladder sits one veto. Ownership is additive evidence and can only
24
+ * ever argue FOR a candidate, which leaves a closed set defenceless against text
25
+ * written in a language it does not contain: Belarusian handed to {uk, ru} spends
26
+ * its `і`s electing Ukrainian while its `ы`/`ў`/`э` — letters Ukrainian does not
27
+ * have at all — count for nobody and stop nothing. So a winner is checked against
28
+ * the text one last time, and a winner whose own alphabet cannot account for
29
+ * {@link CONTRADICTION_SHARE} of it loses to `"unknown"`.
30
+ *
23
31
  * Adapted to langtell's {@link LanguageProfile} shape: the `words` and `iso6393`
24
32
  * fields are optional here, so a bare `{ code, alphabet }` profile still
25
33
  * classifies on rung 1.
@@ -62,15 +70,130 @@ type RungVerdict = Pick<SnippetVerdict, "language" | "margin" | "rung">;
62
70
  type Rung3Resolver<P extends LanguageProfile = LanguageProfile> = (text: string, scoped: readonly P[]) => RungVerdict | null;
63
71
  /** Below this length, trigrams are too noisy to justify a rung-3 verdict. */
64
72
  declare const RUNG3_MIN_LENGTH = 24;
73
+ /** Under {@link ClassifyOptions.isolateScripts}, the fewest letters a minority
74
+ * script must carry before it is classified on its own. A quoted word
75
+ * («привет») stays below it; a clause («Самые дешевые») clears it. */
76
+ declare const MINORITY_MIN_LETTERS = 8;
77
+ /** Options for {@link classifyBySnippet}. */
78
+ interface ClassifyOptions {
79
+ /**
80
+ * Judge each script on its own evidence. Off by default; when on:
81
+ *
82
+ * - **A discriminating minority beats a non-discriminating majority.** When
83
+ * the dominant script holds a lone candidate (its win is decided by script
84
+ * alone, not evidence) and another script in the text holds ≥2 candidates
85
+ * and at least {@link MINORITY_MIN_LETTERS} letters, that script's span is
86
+ * classified on its own with rungs 1–2. A verdict there is returned; an
87
+ * abstention falls back to the dominant-script verdict unchanged. Rung 3
88
+ * never runs on the minority span — it is short by construction, and short
89
+ * spans are where trigrams misread.
90
+ * - **The rung-3 resolver reads only the scope's own script** (see
91
+ * {@link textInScope}), so another script's letters can't lift a short span
92
+ * over a resolver's length floor nor add noise to its trigrams.
93
+ *
94
+ * Without this, «Самые дешевые в Украине | Victron Multiplus II 48V 6k5va»
95
+ * resolves to the lone Latin candidate (22 Latin letters outvote 20
96
+ * Cyrillic), and «Interview with Володимир Зеленський on CNN» reaches a
97
+ * trigram backstop on 42 characters when its Cyrillic is a 20-character name.
98
+ */
99
+ isolateScripts?: boolean;
100
+ }
101
+ /**
102
+ * The part of `text` a scope can be judged on: its words in the scope's script,
103
+ * when the text also carries letters of another script; otherwise `text`
104
+ * unchanged.
105
+ *
106
+ * What a rung-3 resolver receives under {@link ClassifyOptions.isolateScripts}.
107
+ * Exported so an off-path resolver (an oracle over raw candidates) feeds its
108
+ * backstop exactly what the classifier would. Single-script text is passed
109
+ * through byte-for-byte: rewriting it (dropping punctuation and digits) would
110
+ * shift trigram scores on the common case for no gain.
111
+ */
112
+ declare function textInScope(text: string, scoped: readonly LanguageProfile[]): string;
65
113
  /** Candidates whose script matches the text's dominant script (others can't tip
66
114
  * the verdict). Empty when the text carries no letters. Generic over the
67
115
  * concrete profile type `P`: the result is a subset of the input array, so it
68
116
  * keeps `P` — a stricter caller's profiles stay strictly typed downstream. */
69
117
  declare function scopeCandidates<P extends LanguageProfile>(text: string, candidates: readonly P[]): P[];
118
+ /**
119
+ * Share of a text's letters, in a profile's own script, that the profile's
120
+ * alphabet does not contain — evidence AGAINST that profile.
121
+ *
122
+ * Beyond which a candidate is treated as contradicted by the text. Measured, not
123
+ * picked: across a mixed corpus (Cyrillic siblings + Latin), the three
124
+ * populations separate cleanly.
125
+ *
126
+ * 0.3–0.9 % an in-language snippet quoting a sibling (a Ukrainian article
127
+ * carrying a Russian sentence, and vice versa)
128
+ * 1.4–1.5 % a foreign proper noun in otherwise monolingual prose
129
+ * (`Нұрсұлтан` in a Russian article, `Ђоковић` in another)
130
+ * 2.3–17 % text that is simply written in a language nobody on the roster
131
+ * profiles (Belarusian against {uk, ru}, German against {en})
132
+ *
133
+ * 2 % sits in the gap. It is deliberately nearer the incidental end: the cost of
134
+ * vetoing too eagerly is an `"unknown"` a caller escalates, while the cost of
135
+ * vetoing too late is a confident wrong language.
136
+ */
137
+ declare const CONTRADICTION_SHARE = 0.02;
138
+ /**
139
+ * A text's own alphabet: each letter it uses, and how often — its script's
140
+ * letters only in the sense that non-letters are simply absent.
141
+ *
142
+ * Insertion-ordered, so a caller rendering it shows letters in the order the
143
+ * reader met them rather than in an order a hash table chose.
144
+ */
145
+ type TextAlphabet = ReadonlyMap<string, number>;
146
+ /** What one candidate's alphabet cannot account for in a text. */
147
+ interface Contradiction {
148
+ /** The letters, distinct and in first-seen order. Empty when there are none. */
149
+ readonly letters: readonly string[];
150
+ /** Their weight, 0..1, against the letters of that candidate's script. */
151
+ readonly share: number;
152
+ }
153
+ /**
154
+ * Derive the alphabet a text is written in, before asking anyone to account for
155
+ * it.
156
+ *
157
+ * Derived ONCE and then compared against candidates, rather than re-walking the
158
+ * text per candidate: for a screen reporting on a whole roster that is one pass
159
+ * plus a set lookup each, and — more to the point — it makes "the text's
160
+ * alphabet" a thing the caller can hold, print and diff, instead of a number
161
+ * falling out of a comparison.
162
+ *
163
+ * Scrubbed first ({@link stripNoise}, {@link stripQuoted}): a URL's letters and
164
+ * a quotation's letters are in the string without being the string's own.
165
+ */
166
+ declare function textAlphabet(text: string): TextAlphabet;
167
+ /**
168
+ * Compare a derived {@link TextAlphabet} against one candidate.
169
+ *
170
+ * The primitive form, for a caller holding an alphabet and asking about several
171
+ * candidates — an evidence report, an audit trail, a roster editor showing what
172
+ * each choice would do.
173
+ */
174
+ declare function contradictionOf(alphabet: TextAlphabet, profile: LanguageProfile): Contradiction;
175
+ /**
176
+ * How much of `text` a candidate's own alphabet cannot account for.
177
+ *
178
+ * WHY THIS EXISTS AT ALL. The rung ladder is additive: it counts what each
179
+ * candidate uniquely OWNS and never counts what a candidate cannot possibly
180
+ * have written. Those are different questions, and the second one is the only
181
+ * defence a closed set has against a language that is not in it. Belarusian
182
+ * `Мова і культура Беларусі маюць багатую гісторыю` hands `і` to Ukrainian four
183
+ * times over against {uk, ru} — while `ы`, a letter Ukrainian does not have,
184
+ * sits in the same sentence saying the winner cannot be right. Ownership alone
185
+ * cannot see that; this is what sees it.
186
+ *
187
+ * Exported so a caller that has to EXPLAIN a verdict (a diagnostics screen, an
188
+ * audit trail) can show the same letters and the same number the veto acted on,
189
+ * rather than deriving a second, drifting copy of them.
190
+ */
191
+ declare function contradiction(text: string, profile: LanguageProfile): Contradiction;
70
192
  /**
71
193
  * Classify `text` among `candidates`. Synchronous and allocation-light. Returns
72
- * `"unknown"` on empty evidence, on a tie inside the candidate set, or when
73
- * nothing is distinctive.
194
+ * `"unknown"` on empty evidence, on a tie inside the candidate set, when nothing
195
+ * is distinctive, or when the winning candidate is contradicted by the text
196
+ * itself (see {@link contradiction}).
74
197
  *
75
198
  * Generic over the concrete profile type `P`, inferred from `candidates`. The
76
199
  * optional `rung3` resolver is typed over the same `P`, so a consumer with a
@@ -78,7 +201,10 @@ declare function scopeCandidates<P extends LanguageProfile>(text: string, candid
78
201
  * with no adapter — the resolver sees exactly the profiles the caller passed.
79
202
  * `P` defaults to {@link LanguageProfile}, so the bare two-argument form and
80
203
  * every existing call site are unchanged.
204
+ *
205
+ * `options` opts into behavior that is off by default — see
206
+ * {@link ClassifyOptions}.
81
207
  */
82
- declare function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(text: string, candidates: readonly P[], rung3?: Rung3Resolver<P>): SnippetVerdict;
208
+ declare function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(text: string, candidates: readonly P[], rung3?: Rung3Resolver<P>, options?: ClassifyOptions): SnippetVerdict;
83
209
 
84
- export { FRANC_RUNG, RUNG3_MIN_LENGTH, type Rung, type Rung3Resolver, type RungVerdict, type SnippetVerdict, classifyBySnippet, scopeCandidates };
210
+ export { CONTRADICTION_SHARE, type ClassifyOptions, type Contradiction, FRANC_RUNG, MINORITY_MIN_LETTERS, RUNG3_MIN_LENGTH, type Rung, type Rung3Resolver, type RungVerdict, type SnippetVerdict, type TextAlphabet, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, textAlphabet, textInScope };
package/dist/classify.js CHANGED
@@ -1,3 +1,4 @@
1
- export { FRANC_RUNG, RUNG3_MIN_LENGTH, classifyBySnippet, scopeCandidates } from './chunk-BL627TWI.js';
1
+ export { CONTRADICTION_SHARE, FRANC_RUNG, MINORITY_MIN_LETTERS, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, textAlphabet, textInScope } from './chunk-VWKQLGB4.js';
2
+ import './chunk-2Z7REIOE.js';
2
3
  //# sourceMappingURL=classify.js.map
3
4
  //# sourceMappingURL=classify.js.map
@@ -1,38 +1,3 @@
1
- /**
2
- * `langtell/cyrillic` — a cheap, roster-free Cyrillic language fast-path.
3
- *
4
- * Where {@link classifyBySnippet} (`langtell/classify`) scores a snippet
5
- * *relative to a candidate roster you pass in*, this is the opposite trade: a
6
- * fixed, zero-config discriminator for the four Cyrillic languages this fast-path
7
- * positively identifies — Ukrainian, Russian, Belarusian, Bulgarian — decided
8
- * purely by letters distinctive to each, with no profiles, no tokenization, and
9
- * no franc. Reach for it when you just need "is this Russian / is this
10
- * Ukrainian?" on a hot path and don't want to assemble a candidate set.
11
- *
12
- * Other Cyrillic languages langtell now profiles (Serbian, Macedonian, Kazakh)
13
- * are deliberately NOT positively detected here — that is the candidate-relative
14
- * classifier's job. This module only takes care not to MISLABEL them: text
15
- * carrying letters distinctive to sr/mk/kk (ђ ћ џ ѓ ќ ѕ љ њ ј, or the Kazakh
16
- * Turkic set ә ғ қ ң ө ұ ү һ) returns `"unknown"` so the snippet escalates to
17
- * the classifier instead of being silently called Russian by the fallbacks.
18
- *
19
- * Each language carries letters the others (mostly) don't:
20
- * Ukrainian — і ї є ґ
21
- * Russian — ы ё (ъ э are shared with bg/be and handled separately)
22
- * Belarusian — ў (uniquely Belarusian)
23
- * Bulgarian — ъ used as a vowel in nearly every word
24
- *
25
- * The shared letters `ъ` and `э` need care: `подъезд` is Russian, `съм
26
- * българин` is Bulgarian — both contain `ъ`. We disambiguate by density and
27
- * length: a single `ъ` in a short snippet leans RU; multiple `ъ` in longer text
28
- * leans BG. A lone `э` with no other distinctives stays `"unknown"` rather than
29
- * silently guessing (Russian uses it in loanwords/`Это`, Belarusian in
30
- * `гэта`/`сэрца`).
31
- *
32
- * Zero-dependency and side-effect-free. The cheap heuristic stays cheap; if a
33
- * use case needs more than letter signals can give, escalate to
34
- * {@link classifyBySnippet} or a franc-backed source.
35
- */
36
1
  /** The four Cyrillic languages this fast-path tells apart, plus the `"unknown"`
37
2
  * sentinel when letter signals are insufficient. */
38
3
  type CyrillicLanguage = "uk" | "ru" | "be" | "bg" | "unknown";
@@ -47,7 +12,8 @@ interface CyrillicVerdict {
47
12
  /**
48
13
  * Identify the Cyrillic language of `text` by distinctive letters, returning the
49
14
  * chosen language and the uk/ru tallies behind it. `"unknown"` when there is no
50
- * Cyrillic evidence, on a uk/ru tie, or when only an ambiguous `э` is present.
15
+ * Cyrillic evidence, on a uk/ru tie, when only an ambiguous `э` is present, or
16
+ * when the text contradicts the call — see {@link FOREIGN}.
51
17
  */
52
18
  declare function detectCyrillicLanguage(text: string): CyrillicVerdict;
53
19
  /** Convenience predicate: `true` iff {@link detectCyrillicLanguage} calls `text`
package/dist/cyrillic.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { stripQuoted } from './chunk-2Z7REIOE.js';
2
+
1
3
  // src/cyrillic.ts
2
4
  var UK_DISTINCTIVE = /[іїєґ]/gi;
3
5
  var RU_DISTINCTIVE = /[ыё]/gi;
@@ -6,6 +8,13 @@ var HARD_SIGN = /ъ/gi;
6
8
  var E_OBOROT = /э/gi;
7
9
  var SIBLING_DISTINCTIVE = /[ђћџѓќѕљњјәғқңөұүһ]/gi;
8
10
  var CYRILLIC = /[\u0400-\u04FF]/g;
11
+ var FOREIGN = {
12
+ uk: /[ыёъэў]/gi,
13
+ ru: /[іїєґў]/gi,
14
+ be: /[ищъїєґ]/gi,
15
+ bg: /[ыёэіїєґў]/gi
16
+ };
17
+ var MAX_FOREIGN_SHARE = 0.02;
9
18
  var MIN_CYRILLIC_FOR_FALLBACK = 10;
10
19
  var MIN_LEN_FOR_BG = 10;
11
20
  function count(text, re) {
@@ -23,6 +32,17 @@ function countSignals(text) {
23
32
  };
24
33
  }
25
34
  function detectCyrillicLanguage(text) {
35
+ const verdict = decide(text);
36
+ if (verdict.language === "unknown" || !contradicted(text, verdict.language)) return verdict;
37
+ return { ...verdict, language: "unknown" };
38
+ }
39
+ function contradicted(text, language) {
40
+ const own = stripQuoted(text);
41
+ const cyrillic = count(own, CYRILLIC);
42
+ if (cyrillic === 0) return false;
43
+ return count(own, FOREIGN[language]) / cyrillic >= MAX_FOREIGN_SHARE;
44
+ }
45
+ function decide(text) {
26
46
  const { ukScore, ruDistinctive, beScore, hardSigns, eOborot, cyrillicCount, siblingDistinctive } = countSignals(text);
27
47
  if (beScore > 0) {
28
48
  return { language: "be", ukScore, ruScore: ruDistinctive };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cyrillic.ts"],"names":[],"mappings":"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","file":"cyrillic.js","sourcesContent":["/**\n * `langtell/cyrillic` — a cheap, roster-free Cyrillic language fast-path.\n *\n * Where {@link classifyBySnippet} (`langtell/classify`) scores a snippet\n * *relative to a candidate roster you pass in*, this is the opposite trade: a\n * fixed, zero-config discriminator for the four Cyrillic languages this fast-path\n * positively identifies — Ukrainian, Russian, Belarusian, Bulgarian — decided\n * purely by letters distinctive to each, with no profiles, no tokenization, and\n * no franc. Reach for it when you just need \"is this Russian / is this\n * Ukrainian?\" on a hot path and don't want to assemble a candidate set.\n *\n * Other Cyrillic languages langtell now profiles (Serbian, Macedonian, Kazakh)\n * are deliberately NOT positively detected here — that is the candidate-relative\n * classifier's job. This module only takes care not to MISLABEL them: text\n * carrying letters distinctive to sr/mk/kk (ђ ћ џ ѓ ќ ѕ љ њ ј, or the Kazakh\n * Turkic set ә ғ қ ң ө ұ ү һ) returns `\"unknown\"` so the snippet escalates to\n * the classifier instead of being silently called Russian by the fallbacks.\n *\n * Each language carries letters the others (mostly) don't:\n * Ukrainian — і ї є ґ\n * Russian — ы ё (ъ э are shared with bg/be and handled separately)\n * Belarusian — ў (uniquely Belarusian)\n * Bulgarian — ъ used as a vowel in nearly every word\n *\n * The shared letters `ъ` and `э` need care: `подъезд` is Russian, `съм\n * българин` is Bulgarian — both contain `ъ`. We disambiguate by density and\n * length: a single `ъ` in a short snippet leans RU; multiple `ъ` in longer text\n * leans BG. A lone `э` with no other distinctives stays `\"unknown\"` rather than\n * silently guessing (Russian uses it in loanwords/`Это`, Belarusian in\n * `гэта`/`сэрца`).\n *\n * Zero-dependency and side-effect-free. The cheap heuristic stays cheap; if a\n * use case needs more than letter signals can give, escalate to\n * {@link classifyBySnippet} or a franc-backed source.\n */\n\n/** The four Cyrillic languages this fast-path tells apart, plus the `\"unknown\"`\n * sentinel when letter signals are insufficient. */\nexport type CyrillicLanguage = \"uk\" | \"ru\" | \"be\" | \"bg\" | \"unknown\";\n\nconst UK_DISTINCTIVE = /[іїєґ]/gi;\nconst RU_DISTINCTIVE = /[ыё]/gi;\nconst BE_DISTINCTIVE = /ў/gi;\nconst HARD_SIGN = /ъ/gi;\nconst E_OBOROT = /э/gi;\n// Letters distinctive to Serbian / Macedonian / Kazakh — none of which this\n// fast-path positively detects. Their presence only triggers a bail to\n// `\"unknown\"` (escalate to the classifier) so the Russian fallbacks below never\n// mislabel them. ј/љ/њ/џ are shared by sr+mk; ђ/ћ are Serbian, ѓ/ќ/ѕ Macedonian,\n// and ә/ғ/қ/ң/ө/ұ/ү/һ the distinctive Kazakh Turkic set (і is excluded here — it\n// is already Ukrainian-distinctive and handled above).\nconst SIBLING_DISTINCTIVE = /[ђћџѓќѕљњјәғқңөұүһ]/gi;\n// U+0400–U+04FF is the Cyrillic Unicode block; written as explicit \\u escapes\n// so the range bounds are unambiguous (regexp/no-obscure-range).\nconst CYRILLIC = /[\\u0400-\\u04FF]/g;\n\n/** Minimum Cyrillic-letter count before the fallback guesses a language. Below\n * this, a short snippet (`Привет`, `Хочу`) is too ambiguous to act on. */\nconst MIN_CYRILLIC_FOR_FALLBACK = 10;\n/** Minimum text length before `ъ`-density is read as Bulgarian. Single-word\n * Russian samples like `подъезд` (7 chars) would otherwise misclassify. */\nconst MIN_LEN_FOR_BG = 10;\n\n/** The verdict from {@link detectCyrillicLanguage}: the chosen language plus the\n * raw distinctive-letter tallies that drove a uk-vs-ru decision (informational;\n * `ruScore` carries the deciding count for the ru/bg fallbacks too). */\nexport interface CyrillicVerdict {\n language: CyrillicLanguage;\n ukScore: number;\n ruScore: number;\n}\n\nfunction count(text: string, re: RegExp): number {\n return (text.match(re) ?? []).length;\n}\n\n/** Raw letter-signal tallies over `text`, one pass per distinctive class.\n * Gathered up front so {@link detectCyrillicLanguage} reads as a pure decision\n * cascade over these counts rather than interleaving counting and branching. */\ninterface Signals {\n ukScore: number;\n ruDistinctive: number;\n beScore: number;\n hardSigns: number;\n eOborot: number;\n cyrillicCount: number;\n /** Count of letters distinctive to sr/mk/kk (see {@link SIBLING_DISTINCTIVE}).\n * Non-zero ⇒ bail to `\"unknown\"` before the Russian fallbacks. */\n siblingDistinctive: number;\n}\n\nfunction countSignals(text: string): Signals {\n return {\n ukScore: count(text, UK_DISTINCTIVE),\n ruDistinctive: count(text, RU_DISTINCTIVE),\n beScore: count(text, BE_DISTINCTIVE),\n hardSigns: count(text, HARD_SIGN),\n eOborot: count(text, E_OBOROT),\n cyrillicCount: count(text, CYRILLIC),\n siblingDistinctive: count(text, SIBLING_DISTINCTIVE),\n };\n}\n\n/**\n * Identify the Cyrillic language of `text` by distinctive letters, returning the\n * chosen language and the uk/ru tallies behind it. `\"unknown\"` when there is no\n * Cyrillic evidence, on a uk/ru tie, or when only an ambiguous `э` is present.\n */\nexport function detectCyrillicLanguage(text: string): CyrillicVerdict {\n const { ukScore, ruDistinctive, beScore, hardSigns, eOborot, cyrillicCount, siblingDistinctive } =\n countSignals(text);\n\n // ў is uniquely Belarusian — strongest single signal.\n if (beScore > 0) {\n return { language: \"be\", ukScore, ruScore: ruDistinctive };\n }\n\n // Mislabel guard: letters distinctive to Serbian/Macedonian/Kazakh, none of\n // which this fast-path positively detects. Bail to `\"unknown\"` so the snippet\n // escalates to the candidate-relative classifier instead of being silently\n // labelled by the uk/ru/bg logic below. This fires BEFORE the uk/ru checks on\n // purpose: Kazakh shares і with Ukrainian and ы with Russian (тілі, тым), so a\n // Kazakh Turkic letter (ә/ғ/қ/…) must outrank a stray і/ы rather than letting\n // the text be called Ukrainian or Russian. Genuine uk/ru/be/bg text carries\n // none of these letters, so their detection above/below is unaffected.\n if (siblingDistinctive > 0) {\n return { language: \"unknown\", ukScore, ruScore: ruDistinctive };\n }\n\n // Both UA and RU evidence present — a tie is \"unknown\", not a silent UA call\n // (which would misclassify Belarusian/Bulgarian whenever і or ё balances out).\n if (ukScore > 0 && ruDistinctive > 0) {\n if (ukScore === ruDistinctive) {\n return { language: \"unknown\", ukScore, ruScore: ruDistinctive };\n }\n return {\n language: ukScore > ruDistinctive ? \"uk\" : \"ru\",\n ukScore,\n ruScore: ruDistinctive,\n };\n }\n\n // Distinctive RU letters (ы, ё) with no UA evidence — unambiguously RU.\n if (ruDistinctive > 0) {\n return { language: \"ru\", ukScore, ruScore: ruDistinctive };\n }\n\n // Distinctive UA letters with no RU distinctives — UA.\n if (ukScore > 0) {\n return { language: \"uk\", ukScore, ruScore: 0 };\n }\n\n // Bulgarian: ъ as a vowel — multiple occurrences in non-trivial text, no UA /\n // RU distinctives. Length guard keeps short Russian compounds like `подъезд`\n // from sliding into BG.\n if (hardSigns >= 2 && text.length >= MIN_LEN_FOR_BG) {\n return { language: \"bg\", ukScore: 0, ruScore: hardSigns };\n }\n\n // Russian fallback: substantial Cyrillic with no UA, no BE distinctives, and\n // no э (which signals possible Belarusian). Catches short-but-clear Russian\n // text with none of ы/ё/ъ/э (`Привет, мир`, `Здравствуйте, меня зовут …`).\n if (cyrillicCount >= MIN_CYRILLIC_FOR_FALLBACK && eOborot === 0) {\n return { language: \"ru\", ukScore: 0, ruScore: 0 };\n }\n\n // Lone ъ (no э, no other distinctives) in shorter text — RU compound-word\n // pattern (подъезд, объект, съезд).\n if (hardSigns > 0 && eOborot === 0) {\n return { language: \"ru\", ukScore: 0, ruScore: hardSigns };\n }\n\n return { language: \"unknown\", ukScore: 0, ruScore: 0 };\n}\n\n/** Convenience predicate: `true` iff {@link detectCyrillicLanguage} calls `text`\n * Russian. */\nexport function isRussian(text: string): boolean {\n return detectCyrillicLanguage(text).language === \"ru\";\n}\n\n/** Convenience predicate: `true` iff {@link detectCyrillicLanguage} calls `text`\n * Ukrainian. */\nexport function isUkrainian(text: string): boolean {\n return detectCyrillicLanguage(text).language === \"uk\";\n}\n"]}
1
+ {"version":3,"sources":["../src/cyrillic.ts"],"names":[],"mappings":"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","file":"cyrillic.js","sourcesContent":["/**\n * `langtell/cyrillic` — a cheap, roster-free Cyrillic language fast-path.\n *\n * Where {@link classifyBySnippet} (`langtell/classify`) scores a snippet\n * *relative to a candidate roster you pass in*, this is the opposite trade: a\n * fixed, zero-config discriminator for the four Cyrillic languages this fast-path\n * positively identifies — Ukrainian, Russian, Belarusian, Bulgarian — decided\n * purely by letters distinctive to each, with no profiles, no tokenization, and\n * no franc. Reach for it when you just need \"is this Russian / is this\n * Ukrainian?\" on a hot path and don't want to assemble a candidate set.\n *\n * Other Cyrillic languages langtell now profiles (Serbian, Macedonian, Kazakh)\n * are deliberately NOT positively detected here — that is the candidate-relative\n * classifier's job. This module only takes care not to MISLABEL them: text\n * carrying letters distinctive to sr/mk/kk (ђ ћ џ ѓ ќ ѕ љ њ ј, or the Kazakh\n * Turkic set ә ғ қ ң ө ұ ү һ) returns `\"unknown\"` so the snippet escalates to\n * the classifier instead of being silently called Russian by the fallbacks.\n *\n * Each language carries letters the others (mostly) don't:\n * Ukrainian — і ї є ґ\n * Russian — ы ё (ъ э are shared with bg/be and handled separately)\n * Belarusian — ў (uniquely Belarusian)\n * Bulgarian — ъ used as a vowel in nearly every word\n *\n * The shared letters `ъ` and `э` need care: `подъезд` is Russian, `съм\n * българин` is Bulgarian — both contain `ъ`. We disambiguate by density and\n * length: a single `ъ` in a short snippet leans RU; multiple `ъ` in longer text\n * leans BG. A lone `э` with no other distinctives stays `\"unknown\"` rather than\n * silently guessing (Russian uses it in loanwords/`Это`, Belarusian in\n * `гэта`/`сэрца`).\n *\n * Every call is checked against the letters that argue with it before it is\n * returned: a verdict whose own alphabet cannot account for the snippet (`ы` in\n * a text called Ukrainian) is withdrawn to `\"unknown\"`. Positive signals alone\n * cannot tell a fifth language built from the same letters — Belarusian without\n * a `ў` — from the four this module claims.\n *\n * Zero-dependency and side-effect-free. The cheap heuristic stays cheap; if a\n * use case needs more than letter signals can give, escalate to\n * {@link classifyBySnippet} or a franc-backed source.\n */\nimport { stripQuoted } from \"./internal/scrub.js\";\n\n/** The four Cyrillic languages this fast-path tells apart, plus the `\"unknown\"`\n * sentinel when letter signals are insufficient. */\nexport type CyrillicLanguage = \"uk\" | \"ru\" | \"be\" | \"bg\" | \"unknown\";\n\nconst UK_DISTINCTIVE = /[іїєґ]/gi;\nconst RU_DISTINCTIVE = /[ыё]/gi;\nconst BE_DISTINCTIVE = /ў/gi;\nconst HARD_SIGN = /ъ/gi;\nconst E_OBOROT = /э/gi;\n// Letters distinctive to Serbian / Macedonian / Kazakh — none of which this\n// fast-path positively detects. Their presence only triggers a bail to\n// `\"unknown\"` (escalate to the classifier) so the Russian fallbacks below never\n// mislabel them. ј/љ/њ/џ are shared by sr+mk; ђ/ћ are Serbian, ѓ/ќ/ѕ Macedonian,\n// and ә/ғ/қ/ң/ө/ұ/ү/һ the distinctive Kazakh Turkic set (і is excluded here — it\n// is already Ukrainian-distinctive and handled above).\nconst SIBLING_DISTINCTIVE = /[ђћџѓќѕљњјәғқңөұүһ]/gi;\n// U+0400–U+04FF is the Cyrillic Unicode block; written as explicit \\u escapes\n// so the range bounds are unambiguous (regexp/no-obscure-range).\nconst CYRILLIC = /[\\u0400-\\u04FF]/g;\n\n/**\n * Per language, the letters its alphabet does NOT contain — evidence against a\n * call, whoever else the letter might belong to.\n *\n * The signal sets above only argue FOR a language, and a cascade of positive\n * signals has no way to notice that it is reading a fifth language built out of\n * the same letters. Belarusian is exactly that: `і` from the Ukrainian set, `ы`\n * and `ё` from the Russian one, and — in a snippet that happens to carry no `ў` —\n * nothing at all to say it is neither. `Мова і культура Беларусі маюць багатую\n * гісторыю` came back Ukrainian on four `і`s while a `ы` Ukrainian does not have\n * sat in the same sentence. These are the sets that see that.\n *\n * Each is the complement of that language's alphabet within the four, so every\n * entry is checkable against the profiles in `./profiles.ts`: uk has no ы/ё/ъ/э/ў,\n * ru no і/ї/є/ґ/ў, be no и/щ/ъ/ї/є/ґ, bg no ы/ё/э/і/ї/є/ґ/ў.\n */\nconst FOREIGN: Readonly<Record<Exclude<CyrillicLanguage, \"unknown\">, RegExp>> = {\n uk: /[ыёъэў]/gi,\n ru: /[іїєґў]/gi,\n be: /[ищъїєґ]/gi,\n bg: /[ыёэіїєґў]/gi,\n};\n\n/**\n * Share of a snippet's Cyrillic letters that may be foreign to a verdict before\n * the verdict is withdrawn.\n *\n * Set from the same measurement as the roster-relative classifier's\n * `CONTRADICTION_SHARE`, and deliberately identical to it: a snippet that is too\n * contradicted for one of langtell's detectors is too contradicted for the other,\n * and two thresholds would be two answers to one question. On this corpus a\n * genuine sibling citation inside monolingual prose sits at 0.6–0.9 %, while\n * Belarusian misread as Ukrainian sits at 2.3–5 %.\n */\nconst MAX_FOREIGN_SHARE = 0.02;\n\n/** Minimum Cyrillic-letter count before the fallback guesses a language. Below\n * this, a short snippet (`Привет`, `Хочу`) is too ambiguous to act on. */\nconst MIN_CYRILLIC_FOR_FALLBACK = 10;\n/** Minimum text length before `ъ`-density is read as Bulgarian. Single-word\n * Russian samples like `подъезд` (7 chars) would otherwise misclassify. */\nconst MIN_LEN_FOR_BG = 10;\n\n/** The verdict from {@link detectCyrillicLanguage}: the chosen language plus the\n * raw distinctive-letter tallies that drove a uk-vs-ru decision (informational;\n * `ruScore` carries the deciding count for the ru/bg fallbacks too). */\nexport interface CyrillicVerdict {\n language: CyrillicLanguage;\n ukScore: number;\n ruScore: number;\n}\n\nfunction count(text: string, re: RegExp): number {\n return (text.match(re) ?? []).length;\n}\n\n/** Raw letter-signal tallies over `text`, one pass per distinctive class.\n * Gathered up front so {@link detectCyrillicLanguage} reads as a pure decision\n * cascade over these counts rather than interleaving counting and branching. */\ninterface Signals {\n ukScore: number;\n ruDistinctive: number;\n beScore: number;\n hardSigns: number;\n eOborot: number;\n cyrillicCount: number;\n /** Count of letters distinctive to sr/mk/kk (see {@link SIBLING_DISTINCTIVE}).\n * Non-zero ⇒ bail to `\"unknown\"` before the Russian fallbacks. */\n siblingDistinctive: number;\n}\n\nfunction countSignals(text: string): Signals {\n return {\n ukScore: count(text, UK_DISTINCTIVE),\n ruDistinctive: count(text, RU_DISTINCTIVE),\n beScore: count(text, BE_DISTINCTIVE),\n hardSigns: count(text, HARD_SIGN),\n eOborot: count(text, E_OBOROT),\n cyrillicCount: count(text, CYRILLIC),\n siblingDistinctive: count(text, SIBLING_DISTINCTIVE),\n };\n}\n\n/**\n * Identify the Cyrillic language of `text` by distinctive letters, returning the\n * chosen language and the uk/ru tallies behind it. `\"unknown\"` when there is no\n * Cyrillic evidence, on a uk/ru tie, when only an ambiguous `э` is present, or\n * when the text contradicts the call — see {@link FOREIGN}.\n */\nexport function detectCyrillicLanguage(text: string): CyrillicVerdict {\n const verdict = decide(text);\n if (verdict.language === \"unknown\" || !contradicted(text, verdict.language)) return verdict;\n // The tallies are kept: they are the account of a call that was withdrawn, and\n // a caller escalating to the roster-relative classifier is better served by\n // \"here is what the letters said, and it did not hold up\" than by zeroes.\n return { ...verdict, language: \"unknown\" };\n}\n\n/**\n * Whether `text` carries enough letters `language` does not have to withdraw the\n * call. Counted as a share of Cyrillic letters, so a proper noun borrowed from a\n * neighbour (`Ђоковић` in a Russian sentence, ~1.5 %) reads as the loanword it is\n * while a whole sentence in another language does not.\n *\n * Quotations are left out of the count — someone else's words, in whatever\n * language they said them, are not evidence against this author. The positive\n * cascade above still reads the text whole; only this measurement excludes them,\n * exactly as the roster-relative classifier splits the two.\n */\nfunction contradicted(text: string, language: Exclude<CyrillicLanguage, \"unknown\">): boolean {\n const own = stripQuoted(text);\n const cyrillic = count(own, CYRILLIC);\n if (cyrillic === 0) return false;\n return count(own, FOREIGN[language]) / cyrillic >= MAX_FOREIGN_SHARE;\n}\n\n/** The positive-signal cascade, before {@link detectCyrillicLanguage} checks the\n * winner against the letters that argue with it. */\nfunction decide(text: string): CyrillicVerdict {\n const { ukScore, ruDistinctive, beScore, hardSigns, eOborot, cyrillicCount, siblingDistinctive } =\n countSignals(text);\n\n // ў is uniquely Belarusian — strongest single signal.\n if (beScore > 0) {\n return { language: \"be\", ukScore, ruScore: ruDistinctive };\n }\n\n // Mislabel guard: letters distinctive to Serbian/Macedonian/Kazakh, none of\n // which this fast-path positively detects. Bail to `\"unknown\"` so the snippet\n // escalates to the candidate-relative classifier instead of being silently\n // labelled by the uk/ru/bg logic below. This fires BEFORE the uk/ru checks on\n // purpose: Kazakh shares і with Ukrainian and ы with Russian (тілі, тым), so a\n // Kazakh Turkic letter (ә/ғ/қ/…) must outrank a stray і/ы rather than letting\n // the text be called Ukrainian or Russian. Genuine uk/ru/be/bg text carries\n // none of these letters, so their detection above/below is unaffected.\n if (siblingDistinctive > 0) {\n return { language: \"unknown\", ukScore, ruScore: ruDistinctive };\n }\n\n // Both UA and RU evidence present — a tie is \"unknown\", not a silent UA call\n // (which would misclassify Belarusian/Bulgarian whenever і or ё balances out).\n if (ukScore > 0 && ruDistinctive > 0) {\n if (ukScore === ruDistinctive) {\n return { language: \"unknown\", ukScore, ruScore: ruDistinctive };\n }\n return {\n language: ukScore > ruDistinctive ? \"uk\" : \"ru\",\n ukScore,\n ruScore: ruDistinctive,\n };\n }\n\n // Distinctive RU letters (ы, ё) with no UA evidence — unambiguously RU.\n if (ruDistinctive > 0) {\n return { language: \"ru\", ukScore, ruScore: ruDistinctive };\n }\n\n // Distinctive UA letters with no RU distinctives — UA.\n if (ukScore > 0) {\n return { language: \"uk\", ukScore, ruScore: 0 };\n }\n\n // Bulgarian: ъ as a vowel — multiple occurrences in non-trivial text, no UA /\n // RU distinctives. Length guard keeps short Russian compounds like `подъезд`\n // from sliding into BG.\n if (hardSigns >= 2 && text.length >= MIN_LEN_FOR_BG) {\n return { language: \"bg\", ukScore: 0, ruScore: hardSigns };\n }\n\n // Russian fallback: substantial Cyrillic with no UA, no BE distinctives, and\n // no э (which signals possible Belarusian). Catches short-but-clear Russian\n // text with none of ы/ё/ъ/э (`Привет, мир`, `Здравствуйте, меня зовут …`).\n if (cyrillicCount >= MIN_CYRILLIC_FOR_FALLBACK && eOborot === 0) {\n return { language: \"ru\", ukScore: 0, ruScore: 0 };\n }\n\n // Lone ъ (no э, no other distinctives) in shorter text — RU compound-word\n // pattern (подъезд, объект, съезд).\n if (hardSigns > 0 && eOborot === 0) {\n return { language: \"ru\", ukScore: 0, ruScore: hardSigns };\n }\n\n return { language: \"unknown\", ukScore: 0, ruScore: 0 };\n}\n\n/** Convenience predicate: `true` iff {@link detectCyrillicLanguage} calls `text`\n * Russian. */\nexport function isRussian(text: string): boolean {\n return detectCyrillicLanguage(text).language === \"ru\";\n}\n\n/** Convenience predicate: `true` iff {@link detectCyrillicLanguage} calls `text`\n * Ukrainian. */\nexport function isUkrainian(text: string): boolean {\n return detectCyrillicLanguage(text).language === \"uk\";\n}\n"]}
package/dist/franc.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { L as LanguageProfile, S as SyncSource, a as LanguageEvidence } from './types-DfHZ5hkh.js';
1
+ import { L as LanguageProfile, S as SyncSource, a as LanguageEvidence } from './types-DbPJ0sxF.js';
2
+ import { ClassifyOptions } from './classify.js';
2
3
 
3
4
  /**
4
5
  * Producer: the franc trigram backstop over `text`, scoped to `candidates`.
5
6
  * Synchronous — franc itself is sync. Emits at most one `kind: "franc"` item.
6
7
  */
7
- declare function evidenceFromFranc(text: string | undefined, candidates: readonly LanguageProfile[] | undefined): LanguageEvidence[];
8
+ declare function evidenceFromFranc(text: string | undefined, candidates: readonly LanguageProfile[] | undefined, options?: Pick<ClassifyOptions, "isolateScripts">): LanguageEvidence[];
8
9
  /**
9
10
  * Build a franc {@link SyncSource} bound to a candidate roster, for use in
10
11
  * `compile({ engines: [createFrancEngine(candidates)] })`. franc needs the
@@ -14,7 +15,7 @@ declare function evidenceFromFranc(text: string | undefined, candidates: readonl
14
15
  * A `SyncSource` (not async): franc runs in-process and synchronously, so the
15
16
  * compiled `detect` stays synchronous — no `await` ceremony on the hot path.
16
17
  */
17
- declare function createFrancEngine(candidates: readonly LanguageProfile[]): SyncSource;
18
+ declare function createFrancEngine(candidates: readonly LanguageProfile[], options?: Pick<ClassifyOptions, "isolateScripts">): SyncSource;
18
19
  /**
19
20
  * A bare franc engine with no bound roster — it abstains until given candidates.
20
21
  * Prefer {@link createFrancEngine} with your roster; this default exists so the
package/dist/franc.js CHANGED
@@ -1,4 +1,5 @@
1
- import { scopeCandidates } from './chunk-BL627TWI.js';
1
+ import { scopeCandidates, textInScope } from './chunk-VWKQLGB4.js';
2
+ import './chunk-2Z7REIOE.js';
2
3
  import { francAll } from 'franc';
3
4
 
4
5
  var RUNG_MIN_LENGTH = 24;
@@ -16,11 +17,13 @@ function francScore(text, scoped) {
16
17
  if (language === void 0) return null;
17
18
  return { language, margin: top[1] - (ranked[1]?.[1] ?? 0) };
18
19
  }
19
- function evidenceFromFranc(text, candidates) {
20
+ function evidenceFromFranc(text, candidates, options = {}) {
20
21
  if (text === void 0 || text.trim().length < RUNG_MIN_LENGTH) return [];
21
22
  if (candidates === void 0 || candidates.length === 0) return [];
22
23
  const scoped = scopeCandidates(text, candidates);
23
- const scored = francScore(text, scoped);
24
+ const sample = options.isolateScripts ? textInScope(text, scoped) : text;
25
+ if (sample.trim().length < RUNG_MIN_LENGTH) return [];
26
+ const scored = francScore(sample, scoped);
24
27
  if (scored === null) return [];
25
28
  return [
26
29
  {
@@ -33,12 +36,12 @@ function evidenceFromFranc(text, candidates) {
33
36
  }
34
37
  ];
35
38
  }
36
- function createFrancEngine(candidates) {
39
+ function createFrancEngine(candidates, options = {}) {
37
40
  return {
38
41
  id: "franc",
39
42
  sync: true,
40
43
  inputs: ["text"],
41
- detect: (input) => evidenceFromFranc(input.text, candidates)
44
+ detect: (input) => evidenceFromFranc(input.text, candidates, options)
42
45
  };
43
46
  }
44
47
  var francEngine = {
package/dist/franc.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/franc.ts"],"names":[],"mappings":"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","file":"franc.js","sourcesContent":["/**\n * `langtell/franc` — the opt-in franc engine (trigram-based detection, ~187\n * languages). Importing this module statically pulls `franc` and its trigram\n * tables, so it lives behind its own subpath; the zero-dependency core never\n * imports it (enforced by an ESLint boundary rule). `franc` is declared as an\n * OPTIONAL peer dependency — install it only if you use this engine.\n *\n * The engine is a candidate-relative *backstop*: franc is scoped to the\n * candidates' ISO 639-3 codes (`only`), runs only on text past a length floor\n * where trigrams are reliable, and emits `kind: \"franc\"` evidence with franc's\n * own score-gap as the confidence. It abstains (emits nothing) when fewer than\n * two candidates carry an `iso6393`, when franc returns `und`, or when the\n * sample is too short.\n */\nimport { francAll } from \"franc\";\nimport type { LanguageEvidence, LanguageProfile, SyncSource } from \"./types.js\";\nimport { scopeCandidates } from \"./internal/classify.js\";\n\n/** Minimum sample length, in characters. Below this trigrams are too noisy. */\nconst RUNG_MIN_LENGTH = 24;\n/** Floor franc itself uses to bail to `und` rather than guess. */\nconst FRANC_MIN_LENGTH = 10;\n/** Cap on text length sent to franc (longer adds cost, not accuracy). */\nconst DEFAULT_MAX_CHARS = 2000;\n\n/**\n * Run franc scoped to the candidates' ISO 639-3 codes, mapping the winner back\n * to its BCP-47 code. Returns `null` when fewer than two candidates carry an\n * `iso6393` code or franc abstains (`und`). The margin is franc's own score-gap\n * (top1 − top2, 0..1).\n */\nfunction francScore(\n text: string,\n scoped: readonly LanguageProfile[],\n): { language: string; margin: number } | null {\n const byIso = new Map<string, string>();\n for (const c of scoped) if (c.iso6393 !== undefined) byIso.set(c.iso6393, c.code);\n if (byIso.size < 2) return null;\n const sample = text.slice(0, DEFAULT_MAX_CHARS);\n const ranked = francAll(sample, { only: [...byIso.keys()], minLength: FRANC_MIN_LENGTH });\n const top = ranked[0];\n if (!top || top[0] === \"und\") return null;\n const language = byIso.get(top[0]);\n if (language === undefined) return null;\n return { language, margin: top[1] - (ranked[1]?.[1] ?? 0) };\n}\n\n/**\n * Producer: the franc trigram backstop over `text`, scoped to `candidates`.\n * Synchronous — franc itself is sync. Emits at most one `kind: \"franc\"` item.\n */\nexport function evidenceFromFranc(\n text: string | undefined,\n candidates: readonly LanguageProfile[] | undefined,\n): LanguageEvidence[] {\n if (text === undefined || text.trim().length < RUNG_MIN_LENGTH) return [];\n if (candidates === undefined || candidates.length === 0) return [];\n\n const scoped = scopeCandidates(text, candidates);\n const scored = francScore(text, scoped);\n if (scored === null) return [];\n\n return [\n {\n kind: \"franc\",\n language: scored.language,\n // franc's score-gap is 0..1; lift it into a usable confidence band.\n confidence: clamp01(0.4 + scored.margin * 0.5),\n source: \"franc\",\n value: scored.language,\n },\n ];\n}\n\n/**\n * Build a franc {@link SyncSource} bound to a candidate roster, for use in\n * `compile({ engines: [createFrancEngine(candidates)] })`. franc needs the\n * roster to scope its `only` restriction, so it is bound at construction (the\n * same shape `compile` uses to bind the built-in text producer).\n *\n * A `SyncSource` (not async): franc runs in-process and synchronously, so the\n * compiled `detect` stays synchronous — no `await` ceremony on the hot path.\n */\nexport function createFrancEngine(candidates: readonly LanguageProfile[]): SyncSource {\n return {\n id: \"franc\",\n sync: true,\n inputs: [\"text\"],\n detect: (input) => evidenceFromFranc(input.text, candidates),\n };\n}\n\n/**\n * A bare franc engine with no bound roster — it abstains until given candidates.\n * Prefer {@link createFrancEngine} with your roster; this default exists so the\n * engine has a stable named export and a no-config import works.\n */\nexport const francEngine: SyncSource = {\n id: \"franc\",\n sync: true,\n inputs: [\"text\"],\n detect: (input) => evidenceFromFranc(input.text, undefined),\n};\n\nfunction clamp01(value: number): number {\n if (!Number.isFinite(value)) return 0;\n if (value < 0) return 0;\n if (value > 1) return 1;\n return value;\n}\n"]}
1
+ {"version":3,"sources":["../src/franc.ts"],"names":[],"mappings":"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","file":"franc.js","sourcesContent":["/**\n * `langtell/franc` — the opt-in franc engine (trigram-based detection, ~187\n * languages). Importing this module statically pulls `franc` and its trigram\n * tables, so it lives behind its own subpath; the zero-dependency core never\n * imports it (enforced by an ESLint boundary rule). `franc` is declared as an\n * OPTIONAL peer dependency — install it only if you use this engine.\n *\n * The engine is a candidate-relative *backstop*: franc is scoped to the\n * candidates' ISO 639-3 codes (`only`), runs only on text past a length floor\n * where trigrams are reliable, and emits `kind: \"franc\"` evidence with franc's\n * own score-gap as the confidence. It abstains (emits nothing) when fewer than\n * two candidates carry an `iso6393`, when franc returns `und`, or when the\n * sample is too short.\n */\nimport { francAll } from \"franc\";\nimport type { LanguageEvidence, LanguageProfile, SyncSource } from \"./types.js\";\nimport { scopeCandidates, textInScope, type ClassifyOptions } from \"./internal/classify.js\";\n\n/** Minimum sample length, in characters. Below this trigrams are too noisy. */\nconst RUNG_MIN_LENGTH = 24;\n/** Floor franc itself uses to bail to `und` rather than guess. */\nconst FRANC_MIN_LENGTH = 10;\n/** Cap on text length sent to franc (longer adds cost, not accuracy). */\nconst DEFAULT_MAX_CHARS = 2000;\n\n/**\n * Run franc scoped to the candidates' ISO 639-3 codes, mapping the winner back\n * to its BCP-47 code. Returns `null` when fewer than two candidates carry an\n * `iso6393` code or franc abstains (`und`). The margin is franc's own score-gap\n * (top1 − top2, 0..1).\n */\nfunction francScore(\n text: string,\n scoped: readonly LanguageProfile[],\n): { language: string; margin: number } | null {\n const byIso = new Map<string, string>();\n for (const c of scoped) if (c.iso6393 !== undefined) byIso.set(c.iso6393, c.code);\n if (byIso.size < 2) return null;\n const sample = text.slice(0, DEFAULT_MAX_CHARS);\n const ranked = francAll(sample, { only: [...byIso.keys()], minLength: FRANC_MIN_LENGTH });\n const top = ranked[0];\n if (!top || top[0] === \"und\") return null;\n const language = byIso.get(top[0]);\n if (language === undefined) return null;\n return { language, margin: top[1] - (ranked[1]?.[1] ?? 0) };\n}\n\n/**\n * Producer: the franc trigram backstop over `text`, scoped to `candidates`.\n * Synchronous — franc itself is sync. Emits at most one `kind: \"franc\"` item.\n */\nexport function evidenceFromFranc(\n text: string | undefined,\n candidates: readonly LanguageProfile[] | undefined,\n options: Pick<ClassifyOptions, \"isolateScripts\"> = {},\n): LanguageEvidence[] {\n if (text === undefined || text.trim().length < RUNG_MIN_LENGTH) return [];\n if (candidates === undefined || candidates.length === 0) return [];\n\n const scoped = scopeCandidates(text, candidates);\n // Under `isolateScripts`, franc reads only the scope's own script, and the\n // length floor is measured on what it reads (see `textInScope`).\n const sample = options.isolateScripts ? textInScope(text, scoped) : text;\n if (sample.trim().length < RUNG_MIN_LENGTH) return [];\n const scored = francScore(sample, scoped);\n if (scored === null) return [];\n\n return [\n {\n kind: \"franc\",\n language: scored.language,\n // franc's score-gap is 0..1; lift it into a usable confidence band.\n confidence: clamp01(0.4 + scored.margin * 0.5),\n source: \"franc\",\n value: scored.language,\n },\n ];\n}\n\n/**\n * Build a franc {@link SyncSource} bound to a candidate roster, for use in\n * `compile({ engines: [createFrancEngine(candidates)] })`. franc needs the\n * roster to scope its `only` restriction, so it is bound at construction (the\n * same shape `compile` uses to bind the built-in text producer).\n *\n * A `SyncSource` (not async): franc runs in-process and synchronously, so the\n * compiled `detect` stays synchronous — no `await` ceremony on the hot path.\n */\nexport function createFrancEngine(\n candidates: readonly LanguageProfile[],\n options: Pick<ClassifyOptions, \"isolateScripts\"> = {},\n): SyncSource {\n return {\n id: \"franc\",\n sync: true,\n inputs: [\"text\"],\n detect: (input) => evidenceFromFranc(input.text, candidates, options),\n };\n}\n\n/**\n * A bare franc engine with no bound roster — it abstains until given candidates.\n * Prefer {@link createFrancEngine} with your roster; this default exists so the\n * engine has a stable named export and a no-config import works.\n */\nexport const francEngine: SyncSource = {\n id: \"franc\",\n sync: true,\n inputs: [\"text\"],\n detect: (input) => evidenceFromFranc(input.text, undefined),\n};\n\nfunction clamp01(value: number): number {\n if (!Number.isFinite(value)) return 0;\n if (value < 0) return 0;\n if (value > 1) return 1;\n return value;\n}\n"]}
package/dist/fuse.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { W as Weights, L as LanguageProfile, N as NonDiscriminatingScript, a as LanguageEvidence, C as Classification } from './types-DfHZ5hkh.js';
1
+ import { W as Weights, L as LanguageProfile, N as NonDiscriminatingScript, a as LanguageEvidence, C as Classification } from './types-DbPJ0sxF.js';
2
2
 
3
3
  interface FuseOptions {
4
4
  weights?: Weights;
package/dist/fuse.js CHANGED
@@ -1,5 +1,6 @@
1
- export { fuse } from './chunk-JXGCCDSV.js';
1
+ export { fuse } from './chunk-OUYN7BWC.js';
2
2
  import './chunk-YCUSX3GG.js';
3
- import './chunk-BL627TWI.js';
3
+ import './chunk-VWKQLGB4.js';
4
+ import './chunk-2Z7REIOE.js';
4
5
  //# sourceMappingURL=fuse.js.map
5
6
  //# sourceMappingURL=fuse.js.map
package/dist/headers.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as HeaderBag, a as LanguageEvidence } from './types-DfHZ5hkh.js';
1
+ import { H as HeaderBag, a as LanguageEvidence } from './types-DbPJ0sxF.js';
2
2
 
3
3
  /** Producer: the HTTP `Content-Language` response header. */
4
4
  declare function evidenceFromHeaders(headers: HeaderBag | undefined): LanguageEvidence[];
package/dist/html.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as LanguageEvidence } from './types-DfHZ5hkh.js';
1
+ import { a as LanguageEvidence } from './types-DbPJ0sxF.js';
2
2
 
3
3
  /**
4
4
  * Producer: language clues from an HTML string's metadata.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as EvidenceSource, D as DetectorConfig, c as DetectFn, a as LanguageEvidence } from './types-DfHZ5hkh.js';
2
- export { A as AsyncSource, d as AttrBag, C as Classification, e as DetectContext, f as DetectInput, g as EarlyExit, h as EvidenceKind, i as HasAsync, H as HeaderBag, b as LanguageCode, L as LanguageProfile, N as NonDiscriminatingScript, j as SourceInput, S as SyncSource, W as Weights } from './types-DfHZ5hkh.js';
1
+ import { E as EvidenceSource, D as DetectorConfig, c as DetectFn, a as LanguageEvidence } from './types-DbPJ0sxF.js';
2
+ export { A as AsyncSource, d as AttrBag, C as Classification, e as DetectContext, f as DetectInput, g as EarlyExit, h as EvidenceKind, i as HasAsync, H as HeaderBag, b as LanguageCode, L as LanguageProfile, N as NonDiscriminatingScript, j as SourceInput, S as SyncSource, W as Weights } from './types-DbPJ0sxF.js';
3
3
  export { FuseOptions, fuse } from './fuse.js';
4
4
  export { evidenceFromText } from './text.js';
5
5
  export { evidenceFromHtml } from './html.js';
package/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
- import { evidenceFromText } from './chunk-7TDGJOIJ.js';
2
- export { evidenceFromText } from './chunk-7TDGJOIJ.js';
1
+ import { evidenceFromText } from './chunk-66W4QJGT.js';
2
+ export { evidenceFromText } from './chunk-66W4QJGT.js';
3
3
  import { evidenceFromHtml } from './chunk-K4MXTIY7.js';
4
4
  export { evidenceFromHtml } from './chunk-K4MXTIY7.js';
5
5
  import { evidenceFromHeaders } from './chunk-FEKBPTHK.js';
6
6
  export { evidenceFromHeaders } from './chunk-FEKBPTHK.js';
7
- import { fuse } from './chunk-JXGCCDSV.js';
8
- export { fuse } from './chunk-JXGCCDSV.js';
7
+ import { fuse } from './chunk-OUYN7BWC.js';
8
+ export { fuse } from './chunk-OUYN7BWC.js';
9
9
  import { normalizeBCP47 } from './chunk-YCUSX3GG.js';
10
10
  export { normalizeBCP47, normalizeLanguageCode, primarySubtag } from './chunk-YCUSX3GG.js';
11
- import './chunk-BL627TWI.js';
11
+ import './chunk-VWKQLGB4.js';
12
+ import './chunk-2Z7REIOE.js';
12
13
 
13
14
  // src/node-lang.ts
14
15
  var NODE_LANG_CONFIDENCE = 0.7;
@@ -31,13 +32,13 @@ function evidenceFromNodeLang(attrs) {
31
32
  }
32
33
 
33
34
  // src/compile.ts
34
- function builtIns(candidates, nodeLangAttributes) {
35
+ function builtIns(candidates, nodeLangAttributes, isolateScripts) {
35
36
  return [
36
37
  {
37
38
  id: "text",
38
39
  sync: true,
39
40
  inputs: ["text"],
40
- detect: (i) => evidenceFromText(i.text, candidates)
41
+ detect: (i) => evidenceFromText(i.text, candidates, void 0, { isolateScripts })
41
42
  },
42
43
  { id: "html", sync: true, inputs: ["html"], detect: (i) => evidenceFromHtml(i.html) },
43
44
  {
@@ -63,7 +64,11 @@ function applicable(source, input) {
63
64
  }
64
65
  function compile(config = {}) {
65
66
  const sources = [
66
- ...builtIns(config.candidates, config.nodeLangAttributes ?? DEFAULT_NODE_LANG_ATTRIBUTES),
67
+ ...builtIns(
68
+ config.candidates,
69
+ config.nodeLangAttributes ?? DEFAULT_NODE_LANG_ATTRIBUTES,
70
+ config.isolateScripts ?? false
71
+ ),
67
72
  ...config.engines ?? []
68
73
  ];
69
74
  const hasAsync = sources.some((source) => !source.sync);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/node-lang.ts","../src/compile.ts"],"names":["detect"],"mappings":"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","file":"index.js","sourcesContent":["import type { LanguageEvidence } from \"./types.js\";\nimport { normalizeBCP47 } from \"./internal/bcp47.js\";\n\n/** Confidence for a node-level declaration: above the page-scope tags (a label\n * on the node itself sits closer to the content than `<html lang>` does), but\n * still context evidence — {@link fuse}'s guard keeps it from flipping clear\n * script evidence. */\nconst NODE_LANG_CONFIDENCE = 0.7;\n\n/** The pipeline's default declaration attributes: only the standardized\n * global `lang`. Vendor labels (Google's `data-rl` and kin) are page-private\n * `data-*` conventions and must be opted into per detector — see\n * {@link DetectorConfig.nodeLangAttributes}. */\nexport const DEFAULT_NODE_LANG_ATTRIBUTES: readonly string[] = [\"lang\"];\n\n/**\n * Producer: a per-node declared language — the page's own label for one\n * content node, read from an attribute. The one STANDARDIZED bearer is the\n * global `lang` attribute (BCP-47, inheritable); everything else is a vendor\n * convention the caller opts into — e.g. Google's `data-rl` response-language\n * label on AI-generated answers. No attribute name is built in here: which\n * attributes count as declarations is entirely the caller's choice.\n *\n * The document-scope siblings of this signal live in {@link evidenceFromHtml}\n * (`<html lang>`, `content-language`, `og:locale`); this is the same\n * declaration concept one scope down. Like them it is *context* evidence: the\n * fuser weighs it against text reads, and its script guard keeps a\n * declaration from overriding a confident text read — a mislabeled node loses\n * to clear script evidence, while weak or absent text lets the declaration\n * decide.\n *\n * DOM-free by design — this module never touches an Element. The caller\n * passes the attribute values it extracted, keyed by attribute name\n * (`{ lang: el.getAttribute(\"lang\") }`, `{ \"data-rl\": … }`). Each recognized\n * value becomes one evidence item whose `source` is the attribute name under\n * the `node-lang:` namespace (`node-lang:data-rl`) — namespaced so an\n * attribute that happens to share a name with an engine id or evidence kind\n * can never collide in the {@link FuseOptions.weights} keyspace. That lets\n * weights target one attribute — trust a curated vendor label above an\n * often-stale inherited `lang`, or silence an attribute entirely — instead\n * of the whole kind. Empty and unrecognized values are dropped.\n */\nexport function evidenceFromNodeLang(\n attrs: Readonly<Record<string, string | null | undefined>>,\n): LanguageEvidence[] {\n const out: LanguageEvidence[] = [];\n for (const [attribute, raw] of Object.entries(attrs)) {\n if (raw == null || raw === \"\") continue;\n const language = normalizeBCP47(raw);\n if (language === null) continue;\n out.push({\n kind: \"node-lang\",\n language,\n confidence: NODE_LANG_CONFIDENCE,\n source: `node-lang:${attribute}`,\n value: raw,\n });\n }\n return out;\n}\n","import { evidenceFromHeaders } from \"./headers.js\";\nimport { evidenceFromHtml } from \"./html.js\";\nimport { DEFAULT_NODE_LANG_ATTRIBUTES, evidenceFromNodeLang } from \"./node-lang.js\";\nimport { evidenceFromText } from \"./text.js\";\nimport { fuse, type FuseOptions } from \"./fuse.js\";\nimport type {\n Classification,\n DetectContext,\n DetectFn,\n DetectInput,\n DetectorConfig,\n EvidenceSource,\n LanguageEvidence,\n LanguageProfile,\n SyncSource,\n} from \"./types.js\";\n\n/** The always-on, zero-dependency producers. The text producer is bound to the\n * configured candidate roster so its scoring is roster-relative (and so it\n * abstains when no roster was supplied — its signals need candidates). The\n * node-lang producer is bound to the configured declaration-attribute list\n * (standard `lang` only, unless the config opts into vendor attributes), and\n * reads them from `input.attrs` in configured order. */\nfunction builtIns(\n candidates: readonly LanguageProfile[] | undefined,\n nodeLangAttributes: readonly string[],\n): SyncSource[] {\n return [\n {\n id: \"text\",\n sync: true,\n inputs: [\"text\"],\n detect: (i) => evidenceFromText(i.text, candidates),\n },\n { id: \"html\", sync: true, inputs: [\"html\"], detect: (i) => evidenceFromHtml(i.html) },\n {\n id: \"headers\",\n sync: true,\n inputs: [\"headers\"],\n detect: (i) => evidenceFromHeaders(i.headers),\n },\n {\n id: \"node-lang\",\n sync: true,\n inputs: [\"attrs\"],\n detect: (i) => {\n const declared: Record<string, string | null | undefined> = {};\n for (const name of nodeLangAttributes) declared[name] = i.attrs?.[name];\n return evidenceFromNodeLang(declared);\n },\n },\n ];\n}\n\n/** Run a source only when every input it declares is present. */\nfunction applicable(source: EvidenceSource, input: DetectInput): boolean {\n return source.inputs.every((key) => input[key] !== undefined);\n}\n\n/**\n * Build a configured detector. Does the per-roster setup once and returns a\n * `detect` function whose sync/async shape is fixed by the registered engines\n * (see {@link DetectFn}). The built-in producers are always registered; opt-in\n * engines (franc, chrome-ai) are added via `config.engines`.\n */\nexport function compile<const E extends readonly EvidenceSource[] = []>(\n config: DetectorConfig<E> = {},\n): DetectFn<E> {\n const sources: EvidenceSource[] = [\n ...builtIns(config.candidates, config.nodeLangAttributes ?? DEFAULT_NODE_LANG_ATTRIBUTES),\n ...(config.engines ?? []),\n ];\n const hasAsync = sources.some((source) => !source.sync);\n const fuseOptions: FuseOptions = {\n weights: config.weights,\n candidates: config.candidates,\n nonDiscriminatingScript: config.nonDiscriminatingScript,\n };\n\n if (!hasAsync) {\n const detect = (input: DetectInput): Classification => {\n const evidence: LanguageEvidence[] = [];\n for (const source of sources) {\n if (source.sync && applicable(source, input)) evidence.push(...source.detect(input));\n }\n return fuse(evidence, fuseOptions);\n };\n return detect as DetectFn<E>;\n }\n\n const detect = async (input: DetectInput, ctx: DetectContext = {}): Promise<Classification> => {\n const evidence: LanguageEvidence[] = [];\n const pending: Promise<LanguageEvidence[]>[] = [];\n for (const source of sources) {\n if (!applicable(source, input)) continue;\n if (source.sync) evidence.push(...source.detect(input));\n else pending.push(Promise.resolve(source.detect(input, ctx)).catch(() => []));\n }\n for (const batch of await Promise.all(pending)) evidence.push(...batch);\n return fuse(evidence, fuseOptions);\n };\n return detect as DetectFn<E>;\n}\n"]}
1
+ {"version":3,"sources":["../src/node-lang.ts","../src/compile.ts"],"names":["detect"],"mappings":"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","file":"index.js","sourcesContent":["import type { LanguageEvidence } from \"./types.js\";\nimport { normalizeBCP47 } from \"./internal/bcp47.js\";\n\n/** Confidence for a node-level declaration: above the page-scope tags (a label\n * on the node itself sits closer to the content than `<html lang>` does), but\n * still context evidence — {@link fuse}'s guard keeps it from flipping clear\n * script evidence. */\nconst NODE_LANG_CONFIDENCE = 0.7;\n\n/** The pipeline's default declaration attributes: only the standardized\n * global `lang`. Vendor labels (Google's `data-rl` and kin) are page-private\n * `data-*` conventions and must be opted into per detector — see\n * {@link DetectorConfig.nodeLangAttributes}. */\nexport const DEFAULT_NODE_LANG_ATTRIBUTES: readonly string[] = [\"lang\"];\n\n/**\n * Producer: a per-node declared language — the page's own label for one\n * content node, read from an attribute. The one STANDARDIZED bearer is the\n * global `lang` attribute (BCP-47, inheritable); everything else is a vendor\n * convention the caller opts into — e.g. Google's `data-rl` response-language\n * label on AI-generated answers. No attribute name is built in here: which\n * attributes count as declarations is entirely the caller's choice.\n *\n * The document-scope siblings of this signal live in {@link evidenceFromHtml}\n * (`<html lang>`, `content-language`, `og:locale`); this is the same\n * declaration concept one scope down. Like them it is *context* evidence: the\n * fuser weighs it against text reads, and its script guard keeps a\n * declaration from overriding a confident text read — a mislabeled node loses\n * to clear script evidence, while weak or absent text lets the declaration\n * decide.\n *\n * DOM-free by design — this module never touches an Element. The caller\n * passes the attribute values it extracted, keyed by attribute name\n * (`{ lang: el.getAttribute(\"lang\") }`, `{ \"data-rl\": … }`). Each recognized\n * value becomes one evidence item whose `source` is the attribute name under\n * the `node-lang:` namespace (`node-lang:data-rl`) — namespaced so an\n * attribute that happens to share a name with an engine id or evidence kind\n * can never collide in the {@link FuseOptions.weights} keyspace. That lets\n * weights target one attribute — trust a curated vendor label above an\n * often-stale inherited `lang`, or silence an attribute entirely — instead\n * of the whole kind. Empty and unrecognized values are dropped.\n */\nexport function evidenceFromNodeLang(\n attrs: Readonly<Record<string, string | null | undefined>>,\n): LanguageEvidence[] {\n const out: LanguageEvidence[] = [];\n for (const [attribute, raw] of Object.entries(attrs)) {\n if (raw == null || raw === \"\") continue;\n const language = normalizeBCP47(raw);\n if (language === null) continue;\n out.push({\n kind: \"node-lang\",\n language,\n confidence: NODE_LANG_CONFIDENCE,\n source: `node-lang:${attribute}`,\n value: raw,\n });\n }\n return out;\n}\n","import { evidenceFromHeaders } from \"./headers.js\";\nimport { evidenceFromHtml } from \"./html.js\";\nimport { DEFAULT_NODE_LANG_ATTRIBUTES, evidenceFromNodeLang } from \"./node-lang.js\";\nimport { evidenceFromText } from \"./text.js\";\nimport { fuse, type FuseOptions } from \"./fuse.js\";\nimport type {\n Classification,\n DetectContext,\n DetectFn,\n DetectInput,\n DetectorConfig,\n EvidenceSource,\n LanguageEvidence,\n LanguageProfile,\n SyncSource,\n} from \"./types.js\";\n\n/** The always-on, zero-dependency producers. The text producer is bound to the\n * configured candidate roster so its scoring is roster-relative (and so it\n * abstains when no roster was supplied — its signals need candidates). The\n * node-lang producer is bound to the configured declaration-attribute list\n * (standard `lang` only, unless the config opts into vendor attributes), and\n * reads them from `input.attrs` in configured order. */\nfunction builtIns(\n candidates: readonly LanguageProfile[] | undefined,\n nodeLangAttributes: readonly string[],\n isolateScripts: boolean,\n): SyncSource[] {\n return [\n {\n id: \"text\",\n sync: true,\n inputs: [\"text\"],\n detect: (i) => evidenceFromText(i.text, candidates, undefined, { isolateScripts }),\n },\n { id: \"html\", sync: true, inputs: [\"html\"], detect: (i) => evidenceFromHtml(i.html) },\n {\n id: \"headers\",\n sync: true,\n inputs: [\"headers\"],\n detect: (i) => evidenceFromHeaders(i.headers),\n },\n {\n id: \"node-lang\",\n sync: true,\n inputs: [\"attrs\"],\n detect: (i) => {\n const declared: Record<string, string | null | undefined> = {};\n for (const name of nodeLangAttributes) declared[name] = i.attrs?.[name];\n return evidenceFromNodeLang(declared);\n },\n },\n ];\n}\n\n/** Run a source only when every input it declares is present. */\nfunction applicable(source: EvidenceSource, input: DetectInput): boolean {\n return source.inputs.every((key) => input[key] !== undefined);\n}\n\n/**\n * Build a configured detector. Does the per-roster setup once and returns a\n * `detect` function whose sync/async shape is fixed by the registered engines\n * (see {@link DetectFn}). The built-in producers are always registered; opt-in\n * engines (franc, chrome-ai) are added via `config.engines`.\n */\nexport function compile<const E extends readonly EvidenceSource[] = []>(\n config: DetectorConfig<E> = {},\n): DetectFn<E> {\n const sources: EvidenceSource[] = [\n ...builtIns(\n config.candidates,\n config.nodeLangAttributes ?? DEFAULT_NODE_LANG_ATTRIBUTES,\n config.isolateScripts ?? false,\n ),\n ...(config.engines ?? []),\n ];\n const hasAsync = sources.some((source) => !source.sync);\n const fuseOptions: FuseOptions = {\n weights: config.weights,\n candidates: config.candidates,\n nonDiscriminatingScript: config.nonDiscriminatingScript,\n };\n\n if (!hasAsync) {\n const detect = (input: DetectInput): Classification => {\n const evidence: LanguageEvidence[] = [];\n for (const source of sources) {\n if (source.sync && applicable(source, input)) evidence.push(...source.detect(input));\n }\n return fuse(evidence, fuseOptions);\n };\n return detect as DetectFn<E>;\n }\n\n const detect = async (input: DetectInput, ctx: DetectContext = {}): Promise<Classification> => {\n const evidence: LanguageEvidence[] = [];\n const pending: Promise<LanguageEvidence[]>[] = [];\n for (const source of sources) {\n if (!applicable(source, input)) continue;\n if (source.sync) evidence.push(...source.detect(input));\n else pending.push(Promise.resolve(source.detect(input, ctx)).catch(() => []));\n }\n for (const batch of await Promise.all(pending)) evidence.push(...batch);\n return fuse(evidence, fuseOptions);\n };\n return detect as DetectFn<E>;\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { b as LanguageCode, L as LanguageProfile } from './types-DfHZ5hkh.js';
1
+ import { b as LanguageCode, L as LanguageProfile } from './types-DbPJ0sxF.js';
2
2
 
3
3
  /**
4
4
  * `langtell/profiles` — ready-to-use {@link LanguageProfile} data.