langtell 0.6.0 → 0.6.1

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.
package/README.md CHANGED
@@ -97,6 +97,55 @@ This powers per-rung safety gates ("act only when a _weak_ rung clears a high
97
97
  margin") and diagnostics — uses a single confidence number can't serve. The
98
98
  high-level `compile`/`detect`/`fuse` output is unchanged; this is purely additive.
99
99
 
100
+ #### A closed set says "unknown" rather than the nearest thing it has
101
+
102
+ The ladder counts what each candidate uniquely _owns_, which can only ever argue
103
+ _for_ someone — so on its own it has no answer to text written in a language the
104
+ roster does not carry. Belarusian handed to `{uk, ru}` spends its `і`s electing
105
+ Ukrainian, while `ы`, `ў` and `э` — letters Ukrainian does not have at all —
106
+ count for nobody and stop nothing.
107
+
108
+ So a winner is checked against the text one last time: a candidate whose own
109
+ alphabet cannot account for **2 %** of the letters (`CONTRADICTION_SHARE`) loses
110
+ to `"unknown"`, and the runner-up is not promoted in its place.
111
+
112
+ ```ts
113
+ classifyBySnippet("Мова і культура Беларусі маюць багатую гісторыю", [uk, ru]);
114
+ // → { language: "unknown", … } `ы` is not a letter Ukrainian has
115
+ classifyBySnippet("Мова і культура Беларусі маюць багатую гісторыю", [uk, ru, be]);
116
+ // → { language: "be", … } widen the roster and the text stops arguing
117
+ ```
118
+
119
+ The measurement is exported too, for callers that have to _explain_ a verdict
120
+ rather than only act on it. Derive the text's own alphabet once, then ask each
121
+ candidate to account for it:
122
+
123
+ ```ts
124
+ import { textAlphabet, contradictionOf, contradiction } from "langtell/classify";
125
+
126
+ const alphabet = textAlphabet("Беларусь — гэта краіна ў цэнтры Еўропы");
127
+ contradictionOf(alphabet, uk); // → { letters: ["ў"], share: 0.116 }
128
+ contradictionOf(alphabet, be); // → { letters: [], share: 0 }
129
+
130
+ contradiction("Слава Україні", uk); // → { letters: [], share: 0 } one-text form
131
+ ```
132
+
133
+ **Quotations belong to whoever said them.** They are scrubbed before the
134
+ measurement, alongside URLs and @handles — a Ukrainian article quoting a Russian
135
+ sentence is still a Ukrainian article, and the Russian letters inside the marks
136
+ are evidence about the person being quoted, not the person writing. Unless the
137
+ quotation _is_ the text: at half the letters or more (a pull-quote, a headline in
138
+ guillemets) it is the content, and it is measured like any other. Only paired
139
+ double marks count — `«» "" “” „“` — never the single forms, which uk/be spell
140
+ words with (`комп'ютер`).
141
+
142
+ What the threshold is left to survive is the other kind of foreignness, the kind
143
+ with no structure to exploit: a borrowed proper noun runs ~1.5 % (`Нұрсұлтан` in
144
+ a Russian article), while genuinely other-language text sits at 2.3 % and up.
145
+ `alphabet` is what all of this measures against, so a profile you write yourself
146
+ should carry its language's full alphabet — a partial one contradicts its own
147
+ word lists.
148
+
100
149
  ### Roster-free Cyrillic fast-path
101
150
 
102
151
  `langtell/classify` scores a snippet _relative to a roster you pass in_.
@@ -117,7 +166,11 @@ isUkrainian("Слава Україні"); // → true
117
166
  ```
118
167
 
119
168
  It returns `"unknown"` rather than guessing when the signals are insufficient — no
120
- Cyrillic at all, a uk/ru tie, or only an ambiguous `э`. The `CyrillicVerdict` also
169
+ Cyrillic at all, a uk/ru tie, or only an ambiguous `э` — and withdraws a call the
170
+ text itself argues with, on the same 2 % rule as the roster-relative classifier,
171
+ quotations excluded the same way:
172
+ `Мова і культура Беларусі маюць багатую гісторыю` is not Ukrainian, however many
173
+ `і`s it has, because Ukrainian has no `ы`. The `CyrillicVerdict` also
121
174
  carries the raw `ukScore` / `ruScore` tallies behind the call. Zero-dependency and
122
175
  side-effect-free; escalate to `classifyBySnippet` or a franc-backed source when
123
176
  letter signals aren't enough.
@@ -1,4 +1,4 @@
1
- import { A as AsyncSource } from './types-DfHZ5hkh.js';
1
+ import { A as AsyncSource } from './types-DkB4436E.js';
2
2
 
3
3
  /**
4
4
  * `langtell/chrome-ai` — the opt-in on-device engine wrapping the browser's
@@ -0,0 +1,41 @@
1
+ // src/internal/scrub.ts
2
+ var NOISE_PATTERNS = [
3
+ /\bhttps?:\/\/\S+/gi,
4
+ // full URLs
5
+ /\bwww\.\S+/gi,
6
+ // www.… without a scheme
7
+ /\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/\S*)?/gi,
8
+ // bare domains (example.com/path)
9
+ /[@#][\p{L}\p{N}_]+/gu
10
+ // @handles and #hashtags
11
+ ];
12
+ function stripNoise(text) {
13
+ let out = text;
14
+ for (const re of NOISE_PATTERNS) out = out.replace(re, " ");
15
+ return out;
16
+ }
17
+ var QUOTED_PATTERNS = [
18
+ /«[^«»]*»/g,
19
+ // guillemets — uk/ru/be/bg/fr
20
+ /»[^«»]*«/g,
21
+ // …and the inverted German/Polish order
22
+ /“[^“”]*”/g,
23
+ // curly English
24
+ /„[^„“”]*[“”]/g,
25
+ // low-opening German / Bulgarian / Polish
26
+ /"[^"]*"/g
27
+ // straight
28
+ ];
29
+ function letterCount(text) {
30
+ return (text.match(/\p{L}/gu) ?? []).length;
31
+ }
32
+ function stripQuoted(text) {
33
+ let out = text;
34
+ for (const re of QUOTED_PATTERNS) out = out.replace(re, " ");
35
+ if (out === text) return text;
36
+ return letterCount(out) * 2 >= letterCount(text) ? out : text;
37
+ }
38
+
39
+ export { stripNoise, stripQuoted };
40
+ //# sourceMappingURL=chunk-2Z7REIOE.js.map
41
+ //# sourceMappingURL=chunk-2Z7REIOE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/internal/scrub.ts"],"names":[],"mappings":";AA4BA,IAAM,cAAA,GAAoC;AAAA,EACxC,oBAAA;AAAA;AAAA,EACA,cAAA;AAAA;AAAA,EACA,2CAAA;AAAA;AAAA,EACA;AAAA;AACF,CAAA;AAIO,SAAS,WAAW,IAAA,EAAsB;AAC/C,EAAA,IAAI,GAAA,GAAM,IAAA;AACV,EAAA,KAAA,MAAW,MAAM,cAAA,EAAgB,GAAA,GAAM,GAAA,CAAI,OAAA,CAAQ,IAAI,GAAG,CAAA;AAC1D,EAAA,OAAO,GAAA;AACT;AAWA,IAAM,eAAA,GAAqC;AAAA,EACzC,WAAA;AAAA;AAAA,EACA,WAAA;AAAA;AAAA,EACA,WAAA;AAAA;AAAA,EACA,eAAA;AAAA;AAAA,EACA;AAAA;AACF,CAAA;AAGA,SAAS,YAAY,IAAA,EAAsB;AACzC,EAAA,OAAA,CAAQ,IAAA,CAAK,KAAA,CAAM,SAAS,CAAA,IAAK,EAAC,EAAG,MAAA;AACvC;AAiBO,SAAS,YAAY,IAAA,EAAsB;AAChD,EAAA,IAAI,GAAA,GAAM,IAAA;AACV,EAAA,KAAA,MAAW,MAAM,eAAA,EAAiB,GAAA,GAAM,GAAA,CAAI,OAAA,CAAQ,IAAI,GAAG,CAAA;AAC3D,EAAA,IAAI,GAAA,KAAQ,MAAM,OAAO,IAAA;AACzB,EAAA,OAAO,YAAY,GAAG,CAAA,GAAI,KAAK,WAAA,CAAY,IAAI,IAAI,GAAA,GAAM,IAAA;AAC3D","file":"chunk-2Z7REIOE.js","sourcesContent":["/**\n * Text that is in the string but is not the string's own language.\n *\n * Two kinds, and the distinction is provenance rather than content: a URL is\n * machine text that happens to sit in a sentence, and a quotation is someone\n * else's sentence. Neither is evidence about the language of the writing around\n * it, so both are removed before a measurement that would otherwise read them as\n * such.\n *\n * Its own module because both detectors need it and they must not disagree: the\n * roster-relative classifier and the roster-free Cyrillic fast-path are separate\n * entry points with separate bundles, and a second copy of \"what is not the\n * text's own voice\" is a second thing to keep in sync. Zero-dependency, so\n * neither entry pays for the other.\n */\n\n/**\n * Trailing/inline Latin \"noise\" tokens — URLs, @handles, #hashtags — that a\n * Cyrillic title commonly carries (a headline followed by a link or a social\n * handle). These are almost always Latin even on Cyrillic-language content, so\n * left in they can flip the dominant-script vote and let genuinely Cyrillic\n * content scope to the wrong roster.\n *\n * Kept as separate simple patterns (applied in order — schemes/www before bare\n * domains) rather than one big alternation, so each stays readable. ASCII-only\n * `[a-z0-9-]` in the domain pattern means a Cyrillic word is never mistaken for\n * a domain.\n */\nconst NOISE_PATTERNS: readonly RegExp[] = [\n /\\bhttps?:\\/\\/\\S+/gi, // full URLs\n /\\bwww\\.\\S+/gi, // www.… without a scheme\n /\\b[a-z0-9-]+(?:\\.[a-z0-9-]+)+(?:\\/\\S*)?/gi, // bare domains (example.com/path)\n /[@#][\\p{L}\\p{N}_]+/gu, // @handles and #hashtags\n];\n\n/** Drop URLs / @handles / #hashtags so trailing Latin noise can't outvote the\n * prose's script or pollute the per-rung tallies. */\nexport function stripNoise(text: string): string {\n let out = text;\n for (const re of NOISE_PATTERNS) out = out.replace(re, \" \");\n return out;\n}\n\n/**\n * Paired quotation marks, by the conventions the languages here actually use.\n *\n * DOUBLE forms only, and that is not an omission: Ukrainian and Belarusian spell\n * with an apostrophe (`комп'ютер`, `сям'я`), so a pattern that paired single\n * quotes would eat the inside of ordinary words and take their letters with it.\n * Unbalanced marks match nothing, which fails in the safe direction — a stray\n * `«` leaves the text exactly as it was.\n */\nconst QUOTED_PATTERNS: readonly RegExp[] = [\n /«[^«»]*»/g, // guillemets — uk/ru/be/bg/fr\n /»[^«»]*«/g, // …and the inverted German/Polish order\n /“[^“”]*”/g, // curly English\n /„[^„“”]*[“”]/g, // low-opening German / Bulgarian / Polish\n /\"[^\"]*\"/g, // straight\n];\n\n/** Letters, for weighing how much of a text a span is. */\nfunction letterCount(text: string): number {\n return (text.match(/\\p{L}/gu) ?? []).length;\n}\n\n/**\n * Drop quoted spans — someone else's words, in whatever language they wrote them.\n *\n * A quotation is the one kind of foreign text a well-written page is EXPECTED to\n * carry: a Ukrainian article quoting a Russian sentence is still a Ukrainian\n * article, and the Russian letters inside the quotation marks are evidence about\n * the person being quoted. Measuring a language against them is measuring the\n * wrong author.\n *\n * UNLESS THE QUOTATION IS THE TEXT. A pull-quote, a headline in guillemets, a\n * one-line testimonial — strip those and nothing is left to judge, or worse, a\n * sliver of framing prose speaks for a passage it merely introduces. So when\n * quotations are at least half the letters, they are treated as the content they\n * plainly are and the text is returned untouched.\n */\nexport function stripQuoted(text: string): string {\n let out = text;\n for (const re of QUOTED_PATTERNS) out = out.replace(re, \" \");\n if (out === text) return text;\n return letterCount(out) * 2 >= letterCount(text) ? out : text;\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { classifyBySnippet } from './chunk-BL627TWI.js';
1
+ import { classifyBySnippet } from './chunk-SOTZVW3I.js';
2
2
 
3
3
  // src/text.ts
4
4
  function evidenceFromText(text, candidates, rung3) {
@@ -31,5 +31,5 @@ function clamp01(value) {
31
31
  }
32
32
 
33
33
  export { evidenceFromText };
34
- //# sourceMappingURL=chunk-7TDGJOIJ.js.map
35
- //# sourceMappingURL=chunk-7TDGJOIJ.js.map
34
+ //# sourceMappingURL=chunk-7N545CET.js.map
35
+ //# sourceMappingURL=chunk-7N545CET.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/text.ts"],"names":[],"mappings":";;;AAmBO,SAAS,gBAAA,CACd,IAAA,EACA,UAAA,EACA,KAAA,EACoB;AACpB,EAAA,IAAI,IAAA,KAAS,UAAa,IAAA,CAAK,IAAA,GAAO,MAAA,KAAW,CAAA,SAAU,EAAC;AAC5D,EAAA,IAAI,eAAe,MAAA,IAAa,UAAA,CAAW,MAAA,KAAW,CAAA,SAAU,EAAC;AAEjE,EAAA,MAAM,OAAA,GAAU,iBAAA,CAAkB,IAAA,EAAM,UAAA,EAAY,KAAK,CAAA;AACzD,EAAA,IAAI,OAAA,CAAQ,QAAA,KAAa,SAAA,EAAW,OAAO,EAAC;AAE5C,EAAA,MAAM,IAAA,GAAyB;AAAA,IAC7B,IAAA,EAAM,cAAA;AAAA,IACN,UAAU,OAAA,CAAQ,QAAA;AAAA,IAClB,UAAA,EAAY,kBAAA,CAAmB,OAAA,CAAQ,MAAA,EAAQ,QAAQ,IAAI,CAAA;AAAA,IAC3D,MAAA,EAAQ,cAAA;AAAA,IACR,OAAO,IAAA,CAAK,IAAA,EAAK,CAAE,KAAA,CAAM,GAAG,EAAE;AAAA,GAChC;AAIA,EAAA,IAAI,CAAC,OAAA,CAAQ,cAAA,EAAgB,IAAA,CAAK,cAAA,GAAiB,KAAA;AACnD,EAAA,OAAO,CAAC,IAAI,CAAA;AACd;AAWA,SAAS,kBAAA,CAAmB,QAAgB,IAAA,EAAoB;AAC9D,EAAA,IAAI,SAAS,CAAA,EAAG;AAEd,IAAA,OAAO,OAAA,CAAQ,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,CAAC,CAAA,EAAG,CAAC,CAAA,GAAI,IAAI,CAAA;AAAA,EAC9D;AACA,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,CAAC,CAAA;AAC/B,EAAA,OAAO,OAAA,CAAQ,MAAO,IAAA,CAAK,GAAA,CAAI,MAAM,CAAC,CAAA,GAAI,IAAK,IAAI,CAAA;AACrD;AAEA,SAAS,QAAQ,KAAA,EAAuB;AACtC,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,KAAK,GAAG,OAAO,CAAA;AACpC,EAAA,IAAI,KAAA,GAAQ,GAAG,OAAO,CAAA;AACtB,EAAA,IAAI,KAAA,GAAQ,GAAG,OAAO,CAAA;AACtB,EAAA,OAAO,KAAA;AACT","file":"chunk-7TDGJOIJ.js","sourcesContent":["import type { LanguageEvidence, LanguageProfile } from \"./types.js\";\nimport { classifyBySnippet, type Rung, type Rung3Resolver } from \"./internal/classify.js\";\n\n/**\n * Producer: candidate-relative script + lexical signals from the title text.\n *\n * Wraps the ported snippet classifier ({@link classifyBySnippet}): noise strip →\n * dominant-script scope → distinctive letters (rung 1) → function words (2a) →\n * frequent words (2b). The `candidates` roster makes scoring roster-relative —\n * `і` decides Ukrainian only when Russian is also a candidate. Sync and\n * zero-dependency; the optional franc rung is injected via `rung3`.\n *\n * Emits at most one `kind: \"title-script\"` evidence item. The classifier's\n * integer `margin` (the winner's lead over the runner-up) maps to a 0..1\n * `confidence`: a verdict at all means the dominant script and the deciding rung\n * agreed, so the floor is high; a wider lead nudges it up. With no candidates\n * (or no usable distinctive signal) it abstains — emitting nothing rather than a\n * coarse \"unknown\", since the roster decides relevance.\n */\nexport function evidenceFromText(\n text: string | undefined,\n candidates?: readonly LanguageProfile[],\n rung3?: Rung3Resolver,\n): LanguageEvidence[] {\n if (text === undefined || text.trim().length === 0) return [];\n if (candidates === undefined || candidates.length === 0) return [];\n\n const verdict = classifyBySnippet(text, candidates, rung3);\n if (verdict.language === \"unknown\") return [];\n\n const item: LanguageEvidence = {\n kind: \"title-script\",\n language: verdict.language,\n confidence: marginToConfidence(verdict.margin, verdict.rung),\n source: \"title-script\",\n value: text.trim().slice(0, 80),\n };\n // Surface only the meaningful negative: the script was owned by ≤1 candidate,\n // so it didn't choose between candidates. The discriminating case stays narrow\n // (flag omitted). `fuse({ nonDiscriminatingScript: \"unknown\" })` reads this.\n if (!verdict.discriminating) item.discriminating = false;\n return [item];\n}\n\n/**\n * Map the classifier's per-rung lead to a 0..1 confidence.\n *\n * Rungs 1–2 carry an integer count of distinctive items (≥1). A verdict already\n * means script + rung agreed, so the floor is high (0.6) and each extra\n * distinctive item adds up to a 0.35 bonus, saturating by a lead of 4. Rung 3\n * (franc) carries franc's own 0..1 score-gap, which is weaker evidence, so it is\n * scaled into a 0.4..0.75 band.\n */\nfunction marginToConfidence(margin: number, rung: Rung): number {\n if (rung === 3) {\n // franc score-gap is already 0..1; weaker than the distinctive rungs.\n return clamp01(0.4 + Math.min(Math.max(margin, 0), 1) * 0.35);\n }\n const lead = Math.max(margin, 1);\n return clamp01(0.6 + (Math.min(lead, 4) / 4) * 0.35);\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/text.ts"],"names":[],"mappings":";;;AAmBO,SAAS,gBAAA,CACd,IAAA,EACA,UAAA,EACA,KAAA,EACoB;AACpB,EAAA,IAAI,IAAA,KAAS,UAAa,IAAA,CAAK,IAAA,GAAO,MAAA,KAAW,CAAA,SAAU,EAAC;AAC5D,EAAA,IAAI,eAAe,MAAA,IAAa,UAAA,CAAW,MAAA,KAAW,CAAA,SAAU,EAAC;AAEjE,EAAA,MAAM,OAAA,GAAU,iBAAA,CAAkB,IAAA,EAAM,UAAA,EAAY,KAAK,CAAA;AACzD,EAAA,IAAI,OAAA,CAAQ,QAAA,KAAa,SAAA,EAAW,OAAO,EAAC;AAE5C,EAAA,MAAM,IAAA,GAAyB;AAAA,IAC7B,IAAA,EAAM,cAAA;AAAA,IACN,UAAU,OAAA,CAAQ,QAAA;AAAA,IAClB,UAAA,EAAY,kBAAA,CAAmB,OAAA,CAAQ,MAAA,EAAQ,QAAQ,IAAI,CAAA;AAAA,IAC3D,MAAA,EAAQ,cAAA;AAAA,IACR,OAAO,IAAA,CAAK,IAAA,EAAK,CAAE,KAAA,CAAM,GAAG,EAAE;AAAA,GAChC;AAIA,EAAA,IAAI,CAAC,OAAA,CAAQ,cAAA,EAAgB,IAAA,CAAK,cAAA,GAAiB,KAAA;AACnD,EAAA,OAAO,CAAC,IAAI,CAAA;AACd;AAWA,SAAS,kBAAA,CAAmB,QAAgB,IAAA,EAAoB;AAC9D,EAAA,IAAI,SAAS,CAAA,EAAG;AAEd,IAAA,OAAO,OAAA,CAAQ,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,CAAC,CAAA,EAAG,CAAC,CAAA,GAAI,IAAI,CAAA;AAAA,EAC9D;AACA,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,CAAC,CAAA;AAC/B,EAAA,OAAO,OAAA,CAAQ,MAAO,IAAA,CAAK,GAAA,CAAI,MAAM,CAAC,CAAA,GAAI,IAAK,IAAI,CAAA;AACrD;AAEA,SAAS,QAAQ,KAAA,EAAuB;AACtC,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,KAAK,GAAG,OAAO,CAAA;AACpC,EAAA,IAAI,KAAA,GAAQ,GAAG,OAAO,CAAA;AACtB,EAAA,IAAI,KAAA,GAAQ,GAAG,OAAO,CAAA;AACtB,EAAA,OAAO,KAAA;AACT","file":"chunk-7N545CET.js","sourcesContent":["import type { LanguageEvidence, LanguageProfile } from \"./types.js\";\nimport { classifyBySnippet, type Rung, type Rung3Resolver } from \"./internal/classify.js\";\n\n/**\n * Producer: candidate-relative script + lexical signals from the title text.\n *\n * Wraps the ported snippet classifier ({@link classifyBySnippet}): noise strip →\n * dominant-script scope → distinctive letters (rung 1) → function words (2a) →\n * frequent words (2b). The `candidates` roster makes scoring roster-relative —\n * `і` decides Ukrainian only when Russian is also a candidate. Sync and\n * zero-dependency; the optional franc rung is injected via `rung3`.\n *\n * Emits at most one `kind: \"title-script\"` evidence item. The classifier's\n * integer `margin` (the winner's lead over the runner-up) maps to a 0..1\n * `confidence`: a verdict at all means the dominant script and the deciding rung\n * agreed, so the floor is high; a wider lead nudges it up. With no candidates\n * (or no usable distinctive signal) it abstains — emitting nothing rather than a\n * coarse \"unknown\", since the roster decides relevance.\n */\nexport function evidenceFromText(\n text: string | undefined,\n candidates?: readonly LanguageProfile[],\n rung3?: Rung3Resolver,\n): LanguageEvidence[] {\n if (text === undefined || text.trim().length === 0) return [];\n if (candidates === undefined || candidates.length === 0) return [];\n\n const verdict = classifyBySnippet(text, candidates, rung3);\n if (verdict.language === \"unknown\") return [];\n\n const item: LanguageEvidence = {\n kind: \"title-script\",\n language: verdict.language,\n confidence: marginToConfidence(verdict.margin, verdict.rung),\n source: \"title-script\",\n value: text.trim().slice(0, 80),\n };\n // Surface only the meaningful negative: the script was owned by ≤1 candidate,\n // so it didn't choose between candidates. The discriminating case stays narrow\n // (flag omitted). `fuse({ nonDiscriminatingScript: \"unknown\" })` reads this.\n if (!verdict.discriminating) item.discriminating = false;\n return [item];\n}\n\n/**\n * Map the classifier's per-rung lead to a 0..1 confidence.\n *\n * Rungs 1–2 carry an integer count of distinctive items (≥1). A verdict already\n * means script + rung agreed, so the floor is high (0.6) and each extra\n * distinctive item adds up to a 0.35 bonus, saturating by a lead of 4. Rung 3\n * (franc) carries franc's own 0..1 score-gap, which is weaker evidence, so it is\n * scaled into a 0.4..0.75 band.\n */\nfunction marginToConfidence(margin: number, rung: Rung): number {\n if (rung === 3) {\n // franc score-gap is already 0..1; weaker than the distinctive rungs.\n return clamp01(0.4 + Math.min(Math.max(margin, 0), 1) * 0.35);\n }\n const lead = Math.max(margin, 1);\n return clamp01(0.6 + (Math.min(lead, 4) / 4) * 0.35);\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,5 +1,5 @@
1
1
  import { normalizeBCP47 } from './chunk-YCUSX3GG.js';
2
- import { scriptOfProfile } from './chunk-BL627TWI.js';
2
+ import { scriptOfProfile } from './chunk-SOTZVW3I.js';
3
3
 
4
4
  // src/fuse.ts
5
5
  var DEFAULT_KIND_WEIGHT = {
@@ -16,6 +16,7 @@ var DEFAULT_KIND_WEIGHT = {
16
16
  };
17
17
  var SCRIPT_KINDS = /* @__PURE__ */ new Set(["title-script", "franc", "chrome-ai"]);
18
18
  var SCRIPT_CONFIDENCE_FLOOR = 0.6;
19
+ var FALLBACK_KIND_WEIGHT = 0.5;
19
20
  var MIN_WINNING_SCORE = 0.35;
20
21
  var MIN_MARGIN = 0.12;
21
22
  function fuse(evidence, options = {}) {
@@ -25,17 +26,17 @@ function fuse(evidence, options = {}) {
25
26
  const scores = /* @__PURE__ */ new Map();
26
27
  for (const item of scoring) {
27
28
  if (item.language === "unknown") continue;
28
- const weight = weights[item.source] ?? weights[item.kind] ?? DEFAULT_KIND_WEIGHT[item.kind] ?? 0.5;
29
+ const weight = weightFor(item, weights);
29
30
  scores.set(item.language, (scores.get(item.language) ?? 0) + clamp01(item.confidence) * weight);
30
31
  }
31
- const pinned = confidentScriptLanguage(scoring);
32
+ const pinned = confidentScriptLanguage(scoring, weights);
32
33
  const { best, bestScore, secondScore } = argmax(scores, pinned);
33
34
  if (best === null || bestScore < MIN_WINNING_SCORE || bestScore - secondScore < MIN_MARGIN) {
34
- if (pinned !== null && scores.has(pinned)) {
35
- const score = scores.get(pinned) ?? 0;
35
+ const pinnedScore = pinned !== null ? scores.get(pinned) ?? 0 : 0;
36
+ if (pinned !== null && pinnedScore > 0) {
36
37
  return {
37
38
  language: pinned,
38
- confidence: clamp01(score / (score + 0.15)),
39
+ confidence: clamp01(pinnedScore / (pinnedScore + 0.15)),
39
40
  evidence: [...normalized]
40
41
  };
41
42
  }
@@ -47,6 +48,13 @@ function fuse(evidence, options = {}) {
47
48
  evidence: [...normalized]
48
49
  };
49
50
  }
51
+ function weightFor(item, weights) {
52
+ return usableWeight(weights[item.source]) ?? usableWeight(weights[item.kind]) ?? DEFAULT_KIND_WEIGHT[item.kind] ?? FALLBACK_KIND_WEIGHT;
53
+ }
54
+ function usableWeight(value) {
55
+ if (value === void 0) return void 0;
56
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
57
+ }
50
58
  function normalizeEvidence(evidence, _candidates) {
51
59
  return evidence.map((item) => {
52
60
  if (item.language === "unknown") return item;
@@ -91,11 +99,12 @@ function isNeutralized(item, all) {
91
99
  (other) => other.language === item.language && other.language !== "unknown" && !SCRIPT_KINDS.has(other.kind)
92
100
  );
93
101
  }
94
- function confidentScriptLanguage(evidence) {
102
+ function confidentScriptLanguage(evidence, weights) {
95
103
  let best = null;
96
104
  let bestConfidence = 0;
97
105
  for (const item of evidence) {
98
106
  if (item.language === "unknown" || !SCRIPT_KINDS.has(item.kind)) continue;
107
+ if (weightFor(item, weights) === 0) continue;
99
108
  const c = clamp01(item.confidence);
100
109
  if (c < SCRIPT_CONFIDENCE_FLOOR) continue;
101
110
  if (c > bestConfidence) {
@@ -137,5 +146,5 @@ function clamp01(value) {
137
146
  }
138
147
 
139
148
  export { fuse };
140
- //# sourceMappingURL=chunk-JXGCCDSV.js.map
141
- //# sourceMappingURL=chunk-JXGCCDSV.js.map
149
+ //# sourceMappingURL=chunk-LUSZIBTZ.js.map
150
+ //# sourceMappingURL=chunk-LUSZIBTZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fuse.ts"],"names":[],"mappings":"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","file":"chunk-LUSZIBTZ.js","sourcesContent":["import type {\n Classification,\n LanguageEvidence,\n LanguageProfile,\n NonDiscriminatingScript,\n Weights,\n} from \"./types.js\";\nimport { normalizeBCP47 } from \"./internal/bcp47.js\";\nimport { scriptOfProfile, type ScriptName } from \"./internal/classify.js\";\n\nexport interface FuseOptions {\n weights?: Weights;\n /** The candidate roster. When present, incoming evidence tags are normalized\n * into it (`uk-UA` → `uk`, `ua` → `uk`) so context signals (page/header\n * locale) land on the same code the text rungs use. */\n candidates?: readonly LanguageProfile[];\n /** How to resolve a *non-discriminating* script read (one flagged\n * `discriminating: false` — its winning script owned by ≤1 roster candidate).\n * Default `\"candidate\"` keeps current behavior; `\"unknown\"` drops such a read\n * unless non-script evidence corroborates the same language. See\n * {@link NonDiscriminatingScript}. */\n nonDiscriminatingScript?: NonDiscriminatingScript;\n}\n\n/** Default per-kind weights. Clear lexical signal (script, explicit locale)\n * outweighs contextual signal (page tags, headers). Callers override per\n * `source` id or `kind` via {@link FuseOptions.weights}. */\nconst DEFAULT_KIND_WEIGHT: Record<string, number> = {\n \"title-script\": 1,\n \"explicit-locale\": 1,\n \"chrome-ai\": 1,\n \"source-prior\": 0.7,\n franc: 0.7,\n \"node-lang\": 0.65,\n \"http-content-language\": 0.6,\n \"meta-content-language\": 0.55,\n \"meta-og-locale\": 0.55,\n \"html-lang\": 0.5,\n};\n\n/** Evidence kinds that constitute *clear script evidence* — a verdict the text\n * classifier or an on-device model reached by actually reading the string. The\n * guard below forbids weaker page/header *context* from flipping these. */\nconst SCRIPT_KINDS = new Set<string>([\"title-script\", \"franc\", \"chrome-ai\"]);\n\n/** A script verdict this confident is treated as settled — context may add to it\n * but must not flip the winner to a different language. */\nconst SCRIPT_CONFIDENCE_FLOOR = 0.6;\n\n/** Weight for a kind absent from {@link DEFAULT_KIND_WEIGHT}. */\nconst FALLBACK_KIND_WEIGHT = 0.5;\n\nconst MIN_WINNING_SCORE = 0.35;\nconst MIN_MARGIN = 0.12;\n\n/**\n * Combine evidence into a single weighted verdict with an audit trail.\n *\n * Three steps:\n * 1. Normalize each item's language tag into the candidate roster (BCP-47:\n * `uk-UA`/`ua` → `uk`) so text, page, and header signals agree on a code.\n * 2. Weighted argmax over languages (caller weights override per `source`/`kind`).\n * 3. Apply the guard **context must never override clear script evidence**: when\n * the text classifier (or an on-device model) confidently read one language,\n * weaker page/header context for a *different* language cannot win — a\n * Ukrainian page chrome does not make a Latin/English title Ukrainian.\n */\nexport function fuse(\n evidence: readonly LanguageEvidence[],\n options: FuseOptions = {},\n): Classification {\n const weights = options.weights ?? {};\n const normalized = normalizeEvidence(evidence, options.candidates);\n\n // Under `\"unknown\"`, a non-discriminating script read scores nothing on its own\n // — it's dropped from the tally and the pin below — but stays in the trail. AND\n // context written in a *different script* than that title is dropped too: a\n // foreign-script title's language is never named by page/transport context in\n // another script (a Latin title on a Ukrainian page is a foreign title in a\n // Ukrainian UI, not a Ukrainian title). The full `normalized` set is still\n // returned as evidence.\n const scoring =\n options.nonDiscriminatingScript === \"unknown\"\n ? filterForUnknownMode(normalized, options.candidates)\n : normalized;\n\n const scores = new Map<string, number>();\n for (const item of scoring) {\n if (item.language === \"unknown\") continue;\n const weight = weightFor(item, weights);\n scores.set(item.language, (scores.get(item.language) ?? 0) + clamp01(item.confidence) * weight);\n }\n\n // The context-vs-script guard: a confident script read pins the winner.\n const pinned = confidentScriptLanguage(scoring, weights);\n\n const { best, bestScore, secondScore } = argmax(scores, pinned);\n\n if (best === null || bestScore < MIN_WINNING_SCORE || bestScore - secondScore < MIN_MARGIN) {\n // A pinned script language still wins even on a thin margin — clear script\n // evidence is never demoted to \"unknown\" by competing context. The score must\n // be positive, not merely present: a language that scored nothing is not a\n // verdict, and naming it here would be naming it at confidence 0.\n const pinnedScore = pinned !== null ? (scores.get(pinned) ?? 0) : 0;\n if (pinned !== null && pinnedScore > 0) {\n return {\n language: pinned,\n confidence: clamp01(pinnedScore / (pinnedScore + 0.15)),\n evidence: [...normalized],\n };\n }\n return { language: \"unknown\", confidence: clamp01(bestScore), evidence: [...normalized] };\n }\n\n return {\n language: best,\n confidence: clamp01(bestScore / (bestScore + secondScore + 0.15)),\n evidence: [...normalized],\n };\n}\n\n/**\n * The multiplier for one evidence item: the caller's override by `source`, then\n * by `kind`, then the per-kind default.\n *\n * An override that is not a *usable* multiplier — not a finite number, or\n * negative — is **ignored**, and resolution falls through to the next step as if\n * the caller had not set it. Such a value is garbage input, not a stronger way to\n * say \"this signal always wins\": `Infinity` has no coherent meaning in a weighted\n * sum (against a 0-confidence item it yields `NaN`, which makes its language\n * unrankable) and it collapses the confidence ratio below to `Infinity/Infinity`;\n * a negative weight scores evidence *against* its own language, which this model\n * has no meaning for. Honoring either would name a language at a confidence the\n * `0..1` contract forbids, so the documented default is used instead.\n *\n * `0` **is** usable and is honored: it is how a caller silences a signal.\n *\n * Every weight this returns is therefore finite and ≥ 0, so — with confidences\n * clamped to `0..1` — every score is finite and ≥ 0 and the confidence ratios in\n * {@link fuse} are always well-defined.\n */\nfunction weightFor(item: LanguageEvidence, weights: Weights): number {\n return (\n usableWeight(weights[item.source]) ??\n usableWeight(weights[item.kind]) ??\n DEFAULT_KIND_WEIGHT[item.kind] ??\n FALLBACK_KIND_WEIGHT\n );\n}\n\n/** The value itself when it can serve as a weight (finite, non-negative), else\n * `undefined` so the caller's `??` chain falls through to the default. */\nfunction usableWeight(value: number | undefined): number | undefined {\n if (value === undefined) return undefined;\n return Number.isFinite(value) && value >= 0 ? value : undefined;\n}\n\n/** Normalize each item's tag into the roster's code space (BCP-47-aware). Items\n * already `\"unknown\"` pass through untouched. Tags are BCP-47-normalized\n * (`en-US` → `en`, `ua` → `uk`) so text, page, and header signals land on the\n * same code. The normalized code is kept even when it falls outside the roster —\n * argmax simply won't favor an out-of-roster context tag, but it stays in the\n * audit trail.\n *\n * The roster is accepted (and reserved) so a future revision can fold roster\n * aliasing in without a signature change; today BCP-47 normalization alone\n * reconciles the codes the producers emit. */\nfunction normalizeEvidence(\n evidence: readonly LanguageEvidence[],\n _candidates: readonly LanguageProfile[] | undefined,\n): LanguageEvidence[] {\n return evidence.map((item) => {\n if (item.language === \"unknown\") return item;\n const normalized = normalizeBCP47(item.language) ?? item.language;\n if (normalized === item.language) return item;\n return { ...item, language: normalized };\n });\n}\n\n/**\n * The scoring set under `nonDiscriminatingScript: \"unknown\"`. Two cuts:\n *\n * 1. Drop every *neutralized* non-discriminating script read (see\n * {@link isNeutralized}) — it names a language only by being the lone\n * candidate in its script, with nothing corroborating it.\n * 2. Drop context (page/transport) evidence whose language is in a **different\n * script** than such a neutralized title. A foreign-script title's language\n * is not the page's language: a Latin title on a `lang=\"uk\"` page must not\n * resolve to `uk`. Same-script context (an explicit `en` `Content-Language`\n * for a Latin title) survives and may still name — or, among same-script\n * candidates, disambiguate — the title.\n *\n * The second cut needs each language's script, which is derived from the\n * candidate roster's alphabets. When `candidates` is absent the scripts can't be\n * derived, so the cut is skipped and behavior falls back to cut 1 alone (the\n * 0.3.0 behavior) — never throwing.\n */\nfunction filterForUnknownMode(\n normalized: readonly LanguageEvidence[],\n candidates: readonly LanguageProfile[] | undefined,\n): LanguageEvidence[] {\n const surviving = normalized.filter((item) => !isNeutralized(item, normalized));\n\n const titleScript = nonDiscriminatingTitleScript(normalized, candidates);\n if (titleScript === null) return surviving;\n\n const scriptOf = scriptByCode(candidates ?? []);\n return surviving.filter((item) => {\n // Keep the script reads themselves and anything whose script we can't place;\n // only cross-script *context* in a known, different script is excluded.\n if (SCRIPT_KINDS.has(item.kind) || item.language === \"unknown\") return true;\n const itemScript = scriptOf.get(item.language);\n return itemScript === undefined || itemScript === titleScript;\n });\n}\n\n/** The script of the title under `\"unknown\"` mode, or `null` when there is no\n * neutralized non-discriminating script read to anchor on (so no cross-script\n * cut applies) or the roster can't place that read's language. */\nfunction nonDiscriminatingTitleScript(\n normalized: readonly LanguageEvidence[],\n candidates: readonly LanguageProfile[] | undefined,\n): ScriptName | null {\n if (candidates === undefined) return null;\n const scriptOf = scriptByCode(candidates);\n for (const item of normalized) {\n if (isNeutralized(item, normalized)) {\n const script = scriptOf.get(item.language);\n if (script !== undefined) return script;\n }\n }\n return null;\n}\n\n/** Map each roster code to the script of its alphabet (Cyrillic/Latin). Codes\n * whose alphabet carries no Cyrillic/Latin letter are omitted. */\nfunction scriptByCode(candidates: readonly LanguageProfile[]): Map<string, ScriptName> {\n const map = new Map<string, ScriptName>();\n for (const c of candidates) {\n const script = scriptOfProfile(c);\n if (script !== null) map.set(c.code, script);\n }\n return map;\n}\n\n/**\n * Whether a non-discriminating script read should score nothing (mode\n * `\"unknown\"`). True when `item` is a script kind flagged `discriminating:\n * false` (its winning script is owned by ≤1 roster candidate) AND no *non-script*\n * evidence corroborates its language. Corroboration must come from context kinds\n * (page tags, headers): two lone-candidate script reads agreeing is still two\n * defaults, not real evidence — so script kinds never corroborate one another.\n */\nfunction isNeutralized(item: LanguageEvidence, all: readonly LanguageEvidence[]): boolean {\n if (item.discriminating !== false || !SCRIPT_KINDS.has(item.kind)) return false;\n return !all.some(\n (other) =>\n other.language === item.language &&\n other.language !== \"unknown\" &&\n !SCRIPT_KINDS.has(other.kind),\n );\n}\n\n/** The language of a *clear script* read confident enough to pin the verdict, or\n * `null` when none qualifies. When two script reads disagree, the higher-\n * confidence one pins (a tie leaves nothing pinned — argmax decides normally).\n *\n * A read the caller weighed to `0` is silenced *here too*: it contributes no\n * score, so it must not veto the rest of the evidence either. */\nfunction confidentScriptLanguage(\n evidence: readonly LanguageEvidence[],\n weights: Weights,\n): string | null {\n let best: string | null = null;\n let bestConfidence = 0;\n for (const item of evidence) {\n if (item.language === \"unknown\" || !SCRIPT_KINDS.has(item.kind)) continue;\n if (weightFor(item, weights) === 0) continue;\n const c = clamp01(item.confidence);\n if (c < SCRIPT_CONFIDENCE_FLOOR) continue;\n if (c > bestConfidence) {\n bestConfidence = c;\n best = item.language;\n } else if (c === bestConfidence && item.language !== best) {\n // Two equally-confident script reads for different languages — ambiguous.\n best = null;\n }\n }\n return best;\n}\n\n/**\n * Weighted argmax. When `pinned` is set (a confident script language), any\n * *other* language's score may only come from context kinds; that score is\n * capped so it can never exceed the pinned language. This enforces the guard\n * without discarding the context from the audit trail.\n */\nfunction argmax(\n scores: Map<string, number>,\n pinned: string | null,\n): { best: string | null; bestScore: number; secondScore: number } {\n let best: string | null = null;\n let bestScore = 0;\n let secondScore = 0;\n const pinnedScore = pinned !== null ? (scores.get(pinned) ?? 0) : 0;\n\n for (const [language, raw] of scores) {\n // Guard: a non-pinned language cannot out-score the pinned one.\n const score = pinned !== null && language !== pinned ? Math.min(raw, pinnedScore) : raw;\n if (score > bestScore) {\n secondScore = bestScore;\n bestScore = score;\n best = language;\n } else if (score > secondScore) {\n secondScore = score;\n }\n }\n // On a pinned tie (pinned capped equal to a context language), prefer pinned.\n if (pinned !== null && best !== pinned && bestScore === pinnedScore && pinnedScore > 0) {\n secondScore = bestScore;\n best = pinned;\n bestScore = pinnedScore;\n }\n return { best, bestScore, secondScore };\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,3 +1,5 @@
1
+ import { stripQuoted, stripNoise } from './chunk-2Z7REIOE.js';
2
+
1
3
  // src/internal/classify.ts
2
4
  var FRANC_RUNG = 3;
3
5
  var UNKNOWN = {
@@ -9,21 +11,6 @@ var UNKNOWN = {
9
11
  var CYRILLIC_RE = /\p{Script=Cyrillic}/u;
10
12
  var LATIN_RE = /\p{Script=Latin}/u;
11
13
  var RUNG3_MIN_LENGTH = 24;
12
- var NOISE_PATTERNS = [
13
- /\bhttps?:\/\/\S+/gi,
14
- // full URLs
15
- /\bwww\.\S+/gi,
16
- // www.… without a scheme
17
- /\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/\S*)?/gi,
18
- // bare domains (example.com/path)
19
- /[@#][\p{L}\p{N}_]+/gu
20
- // @handles and #hashtags
21
- ];
22
- function stripNoise(text) {
23
- let out = text;
24
- for (const re of NOISE_PATTERNS) out = out.replace(re, " ");
25
- return out;
26
- }
27
14
  function dominantScript(text) {
28
15
  let cyr = 0;
29
16
  let lat = 0;
@@ -53,6 +40,39 @@ function scopeCandidates(text, candidates) {
53
40
  }
54
41
  return scoped;
55
42
  }
43
+ var CONTRADICTION_SHARE = 0.02;
44
+ var NO_CONTRADICTION = { letters: [], share: 0 };
45
+ function alphabetOfScrubbed(scrubbed) {
46
+ const counts = /* @__PURE__ */ new Map();
47
+ for (const ch of scrubbed.toLowerCase()) {
48
+ if (!CYRILLIC_RE.test(ch) && !LATIN_RE.test(ch)) continue;
49
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
50
+ }
51
+ return counts;
52
+ }
53
+ function textAlphabet(text) {
54
+ return alphabetOfScrubbed(stripQuoted(stripNoise(text)));
55
+ }
56
+ function contradictionOf(alphabet, profile) {
57
+ const script = scriptOfProfile(profile);
58
+ if (script === null) return NO_CONTRADICTION;
59
+ const inScript = script === "cyrillic" ? CYRILLIC_RE : LATIN_RE;
60
+ const own = new Set(profile.alphabet + (profile.marks ?? ""));
61
+ const letters = [];
62
+ let foreign = 0;
63
+ let total = 0;
64
+ for (const [ch, count] of alphabet) {
65
+ if (!inScript.test(ch)) continue;
66
+ total += count;
67
+ if (own.has(ch)) continue;
68
+ foreign += count;
69
+ letters.push(ch);
70
+ }
71
+ return { letters, share: total === 0 ? 0 : foreign / total };
72
+ }
73
+ function contradiction(text, profile) {
74
+ return contradictionOf(textAlphabet(text), profile);
75
+ }
56
76
  function tokenize(text) {
57
77
  return text.toLowerCase().match(/\p{L}+/gu) ?? [];
58
78
  }
@@ -119,14 +139,21 @@ function classifyBySnippet(text, candidates, rung3) {
119
139
  const scoped = scopeCandidates(cleaned, candidates);
120
140
  if (scoped.length === 0) return UNKNOWN;
121
141
  const discriminating = scoped.length >= 2;
142
+ let alphabet;
143
+ const settle = (verdict) => {
144
+ const winner = scoped.find((c) => c.code === verdict.language);
145
+ alphabet ??= alphabetOfScrubbed(stripQuoted(cleaned));
146
+ if (winner && contradictionOf(alphabet, winner).share >= CONTRADICTION_SHARE) return UNKNOWN;
147
+ return { ...verdict, discriminating };
148
+ };
122
149
  const byLetter = letterRung(cleaned, scoped);
123
- if (byLetter) return { ...byLetter, discriminating };
150
+ if (byLetter) return settle(byLetter);
124
151
  const tokens = tokenize(cleaned);
125
152
  if (tokens.length === 0) return UNKNOWN;
126
153
  const byWord = wordRung(tokens, scoped, "function", "2a") ?? wordRung(tokens, scoped, "frequent", "2b") ?? rung3?.(cleaned, scoped);
127
- return byWord ? { ...byWord, discriminating } : UNKNOWN;
154
+ return byWord ? settle(byWord) : UNKNOWN;
128
155
  }
129
156
 
130
- export { FRANC_RUNG, RUNG3_MIN_LENGTH, classifyBySnippet, scopeCandidates, scriptOfProfile };
131
- //# sourceMappingURL=chunk-BL627TWI.js.map
132
- //# sourceMappingURL=chunk-BL627TWI.js.map
157
+ export { CONTRADICTION_SHARE, FRANC_RUNG, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, scriptOfProfile, textAlphabet };
158
+ //# sourceMappingURL=chunk-SOTZVW3I.js.map
159
+ //# sourceMappingURL=chunk-SOTZVW3I.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/internal/classify.ts"],"names":[],"mappings":"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","file":"chunk-SOTZVW3I.js","sourcesContent":["/**\n * Per-snippet language classification by candidate-set-relative set-difference.\n *\n * A ladder of rungs; the first rung whose leader clears a lead (margin) of ≥1\n * wins; otherwise `\"unknown\"`:\n *\n * 1 alphabet — characters distinctive within the candidate set\n * 2a function words — curated grammatical markers (highest precision)\n * 2b frequent words — corpus content words\n * 3 franc — optional trigram backstop for the distinctive-free\n * residual, injected as a resolver (this module stays\n * franc-free and importable without franc's tables)\n *\n * \"Distinctive\" is ALWAYS relative to the candidate set: a signal counts for a\n * candidate iff it appears in that candidate's profile and in NO other\n * candidate's. So `і` decides {uk, ru} (only uk has it) but is inert in\n * {uk, be} (both have it), and the word `и` decides {uk, ru} even though the\n * *letter* `и` is shared. Nothing is precomputed — uniqueness is the runtime\n * output, never stored.\n *\n * Above the ladder sits one veto. Ownership is additive evidence and can only\n * ever argue FOR a candidate, which leaves a closed set defenceless against text\n * written in a language it does not contain: Belarusian handed to {uk, ru} spends\n * its `і`s electing Ukrainian while its `ы`/`ў`/`э` — letters Ukrainian does not\n * have at all — count for nobody and stop nothing. So a winner is checked against\n * the text one last time, and a winner whose own alphabet cannot account for\n * {@link CONTRADICTION_SHARE} of it loses to `\"unknown\"`.\n *\n * Adapted to langtell's {@link LanguageProfile} shape: the `words` and `iso6393`\n * fields are optional here, so a bare `{ code, alphabet }` profile still\n * classifies on rung 1.\n */\nimport type { LanguageProfile } from \"../types.js\";\nimport { stripNoise, stripQuoted } from \"./scrub.js\";\n\n// Re-exported so the classifier stays the one door onto its own preprocessing.\nexport { stripNoise, stripQuoted } from \"./scrub.js\";\n\nexport const FRANC_RUNG = 3;\n\n/** Which rung decided a verdict; `null` when unknown. */\nexport type Rung = 1 | \"2a\" | \"2b\" | typeof FRANC_RUNG | null;\n\nexport interface SnippetVerdict {\n /** Winning language code, or the sentinel `\"unknown\"`. */\n language: string;\n /** Lead of the winner over the runner-up, in the rung's own unit (distinctive\n * char/word count for rungs 1–2; franc score-gap for rung 3). 0 when unknown. */\n margin: number;\n /** Which rung decided; `null` when unknown. */\n rung: Rung;\n /** Whether ≥2 same-script candidates were in scope when the verdict was\n * reached. `true` ⇒ the distinctive-letter/word machinery actually chose\n * between candidates; `false` ⇒ the winner was the lone candidate in its\n * script, selected by script alone (no evidence it is *distinctively* that\n * language). `false` for `\"unknown\"`. */\n discriminating: boolean;\n}\n\n/** A rung's verdict before {@link classifyBySnippet} stamps on the scope-derived\n * `discriminating` flag (which a single rung can't know — it depends on how many\n * same-script candidates were scoped). */\nexport type RungVerdict = Pick<SnippetVerdict, \"language\" | \"margin\" | \"rung\">;\n\nconst UNKNOWN: SnippetVerdict = {\n language: \"unknown\",\n margin: 0,\n rung: null,\n discriminating: false,\n};\n\n/** Resolver for rung 3 (the optional trigram backstop), injected into\n * {@link classifyBySnippet} by callers that have franc available. Kept as an\n * injected seam — not a direct import — so this module stays franc-free and\n * importable without pulling franc's tables. Returns a rung-3 verdict or\n * `null` (abstain).\n *\n * Generic over the concrete profile type `P` the caller classifies with, so a\n * consumer that defines a stricter profile (e.g. `words` required) can type its\n * resolver over that exact shape and hand it to {@link classifyBySnippet} with\n * no adapter — the resolver sees `readonly P[]`, the same array the classifier\n * scoped from its input. Defaults to {@link LanguageProfile} for callers that\n * don't narrow. */\nexport type Rung3Resolver<P extends LanguageProfile = LanguageProfile> = (\n text: string,\n scoped: readonly P[],\n) => RungVerdict | null;\n\nconst CYRILLIC_RE = /\\p{Script=Cyrillic}/u;\nconst LATIN_RE = /\\p{Script=Latin}/u;\n\n/** A coarse script bucket — the only two the candidate-relative classifier\n * distinguishes today. `null` means \"no letters / undetermined\". */\nexport type ScriptName = \"cyrillic\" | \"latin\";\n\n/** Below this length, trigrams are too noisy to justify a rung-3 verdict. */\nexport const RUNG3_MIN_LENGTH = 24;\n\n/** The script most of `text` is written in, or `null` if it carries no letters.\n * Noise (URLs/handles/hashtags) is stripped first so a single trailing link\n * can't flip a multi-word Cyrillic title's vote to Latin. */\nfunction dominantScript(text: string): \"cyrillic\" | \"latin\" | null {\n let cyr = 0;\n let lat = 0;\n for (const ch of stripNoise(text)) {\n if (CYRILLIC_RE.test(ch)) cyr += 1;\n else if (LATIN_RE.test(ch)) lat += 1;\n }\n if (cyr === 0 && lat === 0) return null;\n return cyr >= lat ? \"cyrillic\" : \"latin\";\n}\n\n/** The script a profile's alphabet is written in, or `null` if it carries no\n * Cyrillic/Latin letter. Exported so the fuser can derive each roster\n * candidate's script without re-deriving the script regexes — a Latin alphabet\n * ⇒ `\"latin\"`, a Cyrillic one ⇒ `\"cyrillic\"`. */\nexport function scriptOfProfile(profile: LanguageProfile): ScriptName | null {\n for (const ch of profile.alphabet) {\n if (CYRILLIC_RE.test(ch)) return \"cyrillic\";\n if (LATIN_RE.test(ch)) return \"latin\";\n }\n return null;\n}\n\n/** Candidates whose script matches the text's dominant script (others can't tip\n * the verdict). Empty when the text carries no letters. Generic over the\n * concrete profile type `P`: the result is a subset of the input array, so it\n * keeps `P` — a stricter caller's profiles stay strictly typed downstream. */\nexport function scopeCandidates<P extends LanguageProfile>(\n text: string,\n candidates: readonly P[],\n): P[] {\n const script = dominantScript(text);\n if (script === null) return [];\n // Keep one profile per code. A language listed twice would otherwise make its\n // own distinctive chars/words read as \"owned by ≥2 candidates\" in `tally`,\n // cancelling them out and collapsing the verdict to \"unknown\".\n const seen = new Set<string>();\n const scoped: P[] = [];\n for (const c of candidates) {\n if (scriptOfProfile(c) !== script || seen.has(c.code)) continue;\n seen.add(c.code);\n scoped.push(c);\n }\n return scoped;\n}\n\n/**\n * Per-language set of characters globally unique within `profiles` — present in\n * exactly one profile's alphabet. Relative to the given profile set: the unique\n * set shrinks as languages are added (a second Latin language un-uniques a–z).\n */\nexport function distinctiveChars(profiles: readonly LanguageProfile[]): Map<string, Set<string>> {\n const owners = new Map<string, string[]>();\n for (const p of profiles) {\n for (const ch of new Set(p.alphabet)) {\n const list = owners.get(ch);\n if (list) list.push(p.code);\n else owners.set(ch, [p.code]);\n }\n }\n const result = new Map<string, Set<string>>(profiles.map((p) => [p.code, new Set()]));\n for (const [ch, codes] of owners) {\n const [only] = codes;\n if (codes.length === 1 && only !== undefined) result.get(only)?.add(ch);\n }\n return result;\n}\n\n/**\n * Share of a text's letters, in a profile's own script, that the profile's\n * alphabet does not contain — evidence AGAINST that profile.\n *\n * Beyond which a candidate is treated as contradicted by the text. Measured, not\n * picked: across a mixed corpus (Cyrillic siblings + Latin), the three\n * populations separate cleanly.\n *\n * 0.3–0.9 % an in-language snippet quoting a sibling (a Ukrainian article\n * carrying a Russian sentence, and vice versa)\n * 1.4–1.5 % a foreign proper noun in otherwise monolingual prose\n * (`Нұрсұлтан` in a Russian article, `Ђоковић` in another)\n * 2.3–17 % text that is simply written in a language nobody on the roster\n * profiles (Belarusian against {uk, ru}, German against {en})\n *\n * 2 % sits in the gap. It is deliberately nearer the incidental end: the cost of\n * vetoing too eagerly is an `\"unknown\"` a caller escalates, while the cost of\n * vetoing too late is a confident wrong language.\n */\nexport const CONTRADICTION_SHARE = 0.02;\n\n/**\n * A text's own alphabet: each letter it uses, and how often — its script's\n * letters only in the sense that non-letters are simply absent.\n *\n * Insertion-ordered, so a caller rendering it shows letters in the order the\n * reader met them rather than in an order a hash table chose.\n */\nexport type TextAlphabet = ReadonlyMap<string, number>;\n\n/** What one candidate's alphabet cannot account for in a text. */\nexport interface Contradiction {\n /** The letters, distinct and in first-seen order. Empty when there are none. */\n readonly letters: readonly string[];\n /** Their weight, 0..1, against the letters of that candidate's script. */\n readonly share: number;\n}\n\nconst NO_CONTRADICTION: Contradiction = { letters: [], share: 0 };\n\n/** {@link textAlphabet} over text a caller has already scrubbed. */\nfunction alphabetOfScrubbed(scrubbed: string): TextAlphabet {\n const counts = new Map<string, number>();\n for (const ch of scrubbed.toLowerCase()) {\n if (!CYRILLIC_RE.test(ch) && !LATIN_RE.test(ch)) continue;\n counts.set(ch, (counts.get(ch) ?? 0) + 1);\n }\n return counts;\n}\n\n/**\n * Derive the alphabet a text is written in, before asking anyone to account for\n * it.\n *\n * Derived ONCE and then compared against candidates, rather than re-walking the\n * text per candidate: for a screen reporting on a whole roster that is one pass\n * plus a set lookup each, and — more to the point — it makes \"the text's\n * alphabet\" a thing the caller can hold, print and diff, instead of a number\n * falling out of a comparison.\n *\n * Scrubbed first ({@link stripNoise}, {@link stripQuoted}): a URL's letters and\n * a quotation's letters are in the string without being the string's own.\n */\nexport function textAlphabet(text: string): TextAlphabet {\n return alphabetOfScrubbed(stripQuoted(stripNoise(text)));\n}\n\n/**\n * Compare a derived {@link TextAlphabet} against one candidate.\n *\n * The primitive form, for a caller holding an alphabet and asking about several\n * candidates — an evidence report, an audit trail, a roster editor showing what\n * each choice would do.\n */\nexport function contradictionOf(alphabet: TextAlphabet, profile: LanguageProfile): Contradiction {\n const script = scriptOfProfile(profile);\n if (script === null) return NO_CONTRADICTION;\n const inScript = script === \"cyrillic\" ? CYRILLIC_RE : LATIN_RE;\n // `marks` joins the alphabet for the same reason it does at rung 1: an\n // apostrophe is part of how uk/be spell, not a foreign letter. (It is not a\n // letter, so it never reaches the alphabet — kept for the profiles whose\n // marks ever grow to include one.)\n const own = new Set(profile.alphabet + (profile.marks ?? \"\"));\n const letters: string[] = [];\n let foreign = 0;\n let total = 0;\n for (const [ch, count] of alphabet) {\n // Only the candidate's own script is its business: a Cyrillic headline\n // followed by a Latin brand name is not evidence against either language.\n if (!inScript.test(ch)) continue;\n total += count;\n if (own.has(ch)) continue;\n foreign += count;\n letters.push(ch);\n }\n return { letters, share: total === 0 ? 0 : foreign / total };\n}\n\n/**\n * How much of `text` a candidate's own alphabet cannot account for.\n *\n * WHY THIS EXISTS AT ALL. The rung ladder is additive: it counts what each\n * candidate uniquely OWNS and never counts what a candidate cannot possibly\n * have written. Those are different questions, and the second one is the only\n * defence a closed set has against a language that is not in it. Belarusian\n * `Мова і культура Беларусі маюць багатую гісторыю` hands `і` to Ukrainian four\n * times over against {uk, ru} — while `ы`, a letter Ukrainian does not have,\n * sits in the same sentence saying the winner cannot be right. Ownership alone\n * cannot see that; this is what sees it.\n *\n * Exported so a caller that has to EXPLAIN a verdict (a diagnostics screen, an\n * audit trail) can show the same letters and the same number the veto acted on,\n * rather than deriving a second, drifting copy of them.\n */\nexport function contradiction(text: string, profile: LanguageProfile): Contradiction {\n return contradictionOf(textAlphabet(text), profile);\n}\n\ninterface Membership {\n code: string;\n set: ReadonlySet<string>;\n}\n\n/** Lowercased Unicode letter-run tokens. Keeps single-char tokens (`і`, `и`). */\nfunction tokenize(text: string): string[] {\n return text.toLowerCase().match(/\\p{L}+/gu) ?? [];\n}\n\n/**\n * Tally how many items (characters or word tokens) are distinctive to each\n * candidate — present in exactly one candidate's set. Items owned by zero or by\n * ≥2 candidates contribute nothing.\n */\nfunction tally(items: Iterable<string>, membership: readonly Membership[]): Map<string, number> {\n const scores = new Map<string, number>(membership.map((m) => [m.code, 0]));\n for (const item of items) {\n let owner: string | null = null;\n let owners = 0;\n for (const m of membership) {\n if (m.set.has(item)) {\n owners += 1;\n if (owners > 1) {\n owner = null;\n break;\n }\n owner = m.code;\n }\n }\n if (owner !== null) scores.set(owner, (scores.get(owner) ?? 0) + 1);\n }\n return scores;\n}\n\n/** The leading candidate and its lead over the runner-up, or `null` if <1. */\nfunction leader(scores: Map<string, number>): { code: string; margin: number } | null {\n let max = -1;\n let second = -1;\n let code: string | null = null;\n for (const [c, score] of scores) {\n if (score > max) {\n second = max;\n max = score;\n code = c;\n } else if (score > second) {\n second = score;\n }\n }\n if (code === null || max < 1) return null;\n const margin = max - Math.max(second, 0);\n return margin >= 1 ? { code, margin } : null;\n}\n\nfunction membershipFor(\n candidates: readonly LanguageProfile[],\n pick: (p: LanguageProfile) => Iterable<string>,\n): Membership[] {\n return candidates.map((c) => ({ code: c.code, set: new Set(pick(c)) }));\n}\n\n/** Rung 1 — characters (alphabet + orthographic {@link LanguageProfile.marks})\n * distinctive within the scoped candidate set. */\nfunction letterRung(text: string, scoped: readonly LanguageProfile[]): RungVerdict | null {\n const r = leader(\n tally(\n text.toLowerCase(),\n membershipFor(scoped, (p) => p.alphabet + (p.marks ?? \"\")),\n ),\n );\n return r ? { language: r.code, margin: r.margin, rung: 1 } : null;\n}\n\n/** Rung 2 — distinctive words from the given tier (2a function, 2b frequent). */\nfunction wordRung(\n tokens: readonly string[],\n scoped: readonly LanguageProfile[],\n tier: \"function\" | \"frequent\",\n rung: \"2a\" | \"2b\",\n): RungVerdict | null {\n const r = leader(\n tally(\n tokens,\n membershipFor(scoped, (p) => p.words?.[tier] ?? []),\n ),\n );\n return r ? { language: r.code, margin: r.margin, rung } : null;\n}\n\n/**\n * Classify `text` among `candidates`. Synchronous and allocation-light. Returns\n * `\"unknown\"` on empty evidence, on a tie inside the candidate set, when nothing\n * is distinctive, or when the winning candidate is contradicted by the text\n * itself (see {@link contradiction}).\n *\n * Generic over the concrete profile type `P`, inferred from `candidates`. The\n * optional `rung3` resolver is typed over the same `P`, so a consumer with a\n * stricter profile (e.g. `words` required) can pass its own resolver directly,\n * with no adapter — the resolver sees exactly the profiles the caller passed.\n * `P` defaults to {@link LanguageProfile}, so the bare two-argument form and\n * every existing call site are unchanged.\n */\nexport function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(\n text: string,\n candidates: readonly P[],\n rung3?: Rung3Resolver<P>,\n): SnippetVerdict {\n if (!text || candidates.length === 0) return UNKNOWN;\n\n // Drop URLs / @handles / #hashtags once, up front: trailing Latin noise must\n // not flip the dominant-script vote nor pollute the per-rung tallies.\n const cleaned = stripNoise(text);\n\n // Restrict to candidates in the text's dominant script.\n const scoped = scopeCandidates(cleaned, candidates);\n if (scoped.length === 0) return UNKNOWN;\n\n // ≥2 same-script candidates means the distinctive machinery actually had a\n // choice to make; a lone scoped candidate wins by script alone. Stamped onto\n // whichever rung decides — a single rung can't see the scope size.\n const discriminating = scoped.length >= 2;\n\n /**\n * A winner the text itself argues against is no winner — see\n * {@link contradiction}.\n *\n * Applied to whichever rung decided, franc's included: every rung answers the\n * same forced-choice question, so every rung can be forced into the same wrong\n * answer by a language the roster does not carry. The runner-up is NOT\n * promoted — a set that could not account for the text does not get a second\n * guess at it. `\"unknown\"` is the honest answer, and the signal a caller needs\n * to widen the roster or escalate.\n *\n * Measured over the text's alphabet MINUS its quotations, while the rungs\n * above tallied the text whole. The asymmetry is deliberate and is the whole\n * point of measuring separately: a quotation is someone else's language and\n * cannot be held against this author, whereas removing it from the tally would\n * silently re-decide verdicts that have nothing to do with this veto. One\n * derivation, computed the first time a rung produces a winner.\n */\n // Derived on first use: the texts that never reach a verdict — most of them —\n // never pay for it.\n let alphabet: TextAlphabet | undefined;\n const settle = (verdict: RungVerdict): SnippetVerdict => {\n const winner = scoped.find((c) => c.code === verdict.language);\n alphabet ??= alphabetOfScrubbed(stripQuoted(cleaned));\n if (winner && contradictionOf(alphabet, winner).share >= CONTRADICTION_SHARE) return UNKNOWN;\n return { ...verdict, discriminating };\n };\n\n const byLetter = letterRung(cleaned, scoped);\n if (byLetter) return settle(byLetter);\n\n const tokens = tokenize(cleaned);\n if (tokens.length === 0) return UNKNOWN;\n\n const byWord =\n wordRung(tokens, scoped, \"function\", \"2a\") ??\n wordRung(tokens, scoped, \"frequent\", \"2b\") ??\n rung3?.(cleaned, scoped);\n return byWord ? settle(byWord) : UNKNOWN;\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { L as LanguageProfile } from './types-DfHZ5hkh.js';
1
+ import { L as LanguageProfile } from './types-DkB4436E.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.
@@ -67,10 +75,85 @@ declare const RUNG3_MIN_LENGTH = 24;
67
75
  * concrete profile type `P`: the result is a subset of the input array, so it
68
76
  * keeps `P` — a stricter caller's profiles stay strictly typed downstream. */
69
77
  declare function scopeCandidates<P extends LanguageProfile>(text: string, candidates: readonly P[]): P[];
78
+ /**
79
+ * Share of a text's letters, in a profile's own script, that the profile's
80
+ * alphabet does not contain — evidence AGAINST that profile.
81
+ *
82
+ * Beyond which a candidate is treated as contradicted by the text. Measured, not
83
+ * picked: across a mixed corpus (Cyrillic siblings + Latin), the three
84
+ * populations separate cleanly.
85
+ *
86
+ * 0.3–0.9 % an in-language snippet quoting a sibling (a Ukrainian article
87
+ * carrying a Russian sentence, and vice versa)
88
+ * 1.4–1.5 % a foreign proper noun in otherwise monolingual prose
89
+ * (`Нұрсұлтан` in a Russian article, `Ђоковић` in another)
90
+ * 2.3–17 % text that is simply written in a language nobody on the roster
91
+ * profiles (Belarusian against {uk, ru}, German against {en})
92
+ *
93
+ * 2 % sits in the gap. It is deliberately nearer the incidental end: the cost of
94
+ * vetoing too eagerly is an `"unknown"` a caller escalates, while the cost of
95
+ * vetoing too late is a confident wrong language.
96
+ */
97
+ declare const CONTRADICTION_SHARE = 0.02;
98
+ /**
99
+ * A text's own alphabet: each letter it uses, and how often — its script's
100
+ * letters only in the sense that non-letters are simply absent.
101
+ *
102
+ * Insertion-ordered, so a caller rendering it shows letters in the order the
103
+ * reader met them rather than in an order a hash table chose.
104
+ */
105
+ type TextAlphabet = ReadonlyMap<string, number>;
106
+ /** What one candidate's alphabet cannot account for in a text. */
107
+ interface Contradiction {
108
+ /** The letters, distinct and in first-seen order. Empty when there are none. */
109
+ readonly letters: readonly string[];
110
+ /** Their weight, 0..1, against the letters of that candidate's script. */
111
+ readonly share: number;
112
+ }
113
+ /**
114
+ * Derive the alphabet a text is written in, before asking anyone to account for
115
+ * it.
116
+ *
117
+ * Derived ONCE and then compared against candidates, rather than re-walking the
118
+ * text per candidate: for a screen reporting on a whole roster that is one pass
119
+ * plus a set lookup each, and — more to the point — it makes "the text's
120
+ * alphabet" a thing the caller can hold, print and diff, instead of a number
121
+ * falling out of a comparison.
122
+ *
123
+ * Scrubbed first ({@link stripNoise}, {@link stripQuoted}): a URL's letters and
124
+ * a quotation's letters are in the string without being the string's own.
125
+ */
126
+ declare function textAlphabet(text: string): TextAlphabet;
127
+ /**
128
+ * Compare a derived {@link TextAlphabet} against one candidate.
129
+ *
130
+ * The primitive form, for a caller holding an alphabet and asking about several
131
+ * candidates — an evidence report, an audit trail, a roster editor showing what
132
+ * each choice would do.
133
+ */
134
+ declare function contradictionOf(alphabet: TextAlphabet, profile: LanguageProfile): Contradiction;
135
+ /**
136
+ * How much of `text` a candidate's own alphabet cannot account for.
137
+ *
138
+ * WHY THIS EXISTS AT ALL. The rung ladder is additive: it counts what each
139
+ * candidate uniquely OWNS and never counts what a candidate cannot possibly
140
+ * have written. Those are different questions, and the second one is the only
141
+ * defence a closed set has against a language that is not in it. Belarusian
142
+ * `Мова і культура Беларусі маюць багатую гісторыю` hands `і` to Ukrainian four
143
+ * times over against {uk, ru} — while `ы`, a letter Ukrainian does not have,
144
+ * sits in the same sentence saying the winner cannot be right. Ownership alone
145
+ * cannot see that; this is what sees it.
146
+ *
147
+ * Exported so a caller that has to EXPLAIN a verdict (a diagnostics screen, an
148
+ * audit trail) can show the same letters and the same number the veto acted on,
149
+ * rather than deriving a second, drifting copy of them.
150
+ */
151
+ declare function contradiction(text: string, profile: LanguageProfile): Contradiction;
70
152
  /**
71
153
  * 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.
154
+ * `"unknown"` on empty evidence, on a tie inside the candidate set, when nothing
155
+ * is distinctive, or when the winning candidate is contradicted by the text
156
+ * itself (see {@link contradiction}).
74
157
  *
75
158
  * Generic over the concrete profile type `P`, inferred from `candidates`. The
76
159
  * optional `rung3` resolver is typed over the same `P`, so a consumer with a
@@ -81,4 +164,4 @@ declare function scopeCandidates<P extends LanguageProfile>(text: string, candid
81
164
  */
82
165
  declare function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(text: string, candidates: readonly P[], rung3?: Rung3Resolver<P>): SnippetVerdict;
83
166
 
84
- export { FRANC_RUNG, RUNG3_MIN_LENGTH, type Rung, type Rung3Resolver, type RungVerdict, type SnippetVerdict, classifyBySnippet, scopeCandidates };
167
+ export { CONTRADICTION_SHARE, type Contradiction, FRANC_RUNG, RUNG3_MIN_LENGTH, type Rung, type Rung3Resolver, type RungVerdict, type SnippetVerdict, type TextAlphabet, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, textAlphabet };
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, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, textAlphabet } from './chunk-SOTZVW3I.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`