langtell 0.6.1 → 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.
- package/README.md +22 -0
- package/dist/chrome-ai.d.ts +1 -1
- package/dist/{chunk-7N545CET.js → chunk-66W4QJGT.js} +5 -5
- package/dist/chunk-66W4QJGT.js.map +1 -0
- package/dist/{chunk-LUSZIBTZ.js → chunk-OUYN7BWC.js} +3 -3
- package/dist/{chunk-LUSZIBTZ.js.map → chunk-OUYN7BWC.js.map} +1 -1
- package/dist/{chunk-SOTZVW3I.js → chunk-VWKQLGB4.js} +58 -13
- package/dist/chunk-VWKQLGB4.js.map +1 -0
- package/dist/classify.d.ts +46 -3
- package/dist/classify.js +1 -1
- package/dist/franc.d.ts +4 -3
- package/dist/franc.js +7 -5
- package/dist/franc.js.map +1 -1
- package/dist/fuse.d.ts +1 -1
- package/dist/fuse.js +2 -2
- package/dist/headers.d.ts +1 -1
- package/dist/html.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +12 -8
- package/dist/index.js.map +1 -1
- package/dist/profiles.d.ts +1 -1
- package/dist/text.d.ts +3 -3
- package/dist/text.js +2 -2
- package/dist/{types-DkB4436E.d.ts → types-DbPJ0sxF.d.ts} +5 -0
- package/package.json +1 -1
- package/dist/chunk-7N545CET.js.map +0 -1
- package/dist/chunk-SOTZVW3I.js.map +0 -1
package/README.md
CHANGED
|
@@ -116,6 +116,28 @@ classifyBySnippet("Мова і культура Беларусі маюць ба
|
|
|
116
116
|
// → { language: "be", … } widen the roster and the text stops arguing
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
#### Mixed scripts: judge each script on its own evidence (opt-in)
|
|
120
|
+
|
|
121
|
+
The classifier scopes to the text's dominant script by letter count. In the default
|
|
122
|
+
behaviour, a title whose Latin product name outweighs its Cyrillic clause is decided
|
|
123
|
+
by the lone Latin candidate. That candidate can't lose, so its win says only which
|
|
124
|
+
script had more letters. `isolateScripts` changes that:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const title = "Самые дешевые в Украине | Victron Multiplus II 48V 6k5va";
|
|
128
|
+
classifyBySnippet(title, [uk, en, ru]);
|
|
129
|
+
// → { language: "en", discriminating: false, … } 22 Latin letters outvote 20 Cyrillic
|
|
130
|
+
classifyBySnippet(title, [uk, en, ru], undefined, { isolateScripts: true });
|
|
131
|
+
// → { language: "ru", rung: 1, discriminating: true, … }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
With it on, a minority script that holds ≥2 candidates and at least
|
|
135
|
+
`MINORITY_MIN_LETTERS` letters is classified on its own (rungs 1–2) when the
|
|
136
|
+
dominant script holds only one. The rung-3 resolver also receives only the scope's
|
|
137
|
+
own script (`textInScope`), so Latin letters can't lift a short Cyrillic name over a
|
|
138
|
+
trigram length floor. `compile({ isolateScripts: true })` and
|
|
139
|
+
`createFrancEngine(candidates, { isolateScripts: true })` forward the same option.
|
|
140
|
+
|
|
119
141
|
The measurement is exported too, for callers that have to _explain_ a verdict
|
|
120
142
|
rather than only act on it. Derive the text's own alphabet once, then ask each
|
|
121
143
|
candidate to account for it:
|
package/dist/chrome-ai.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { classifyBySnippet } from './chunk-
|
|
1
|
+
import { classifyBySnippet } from './chunk-VWKQLGB4.js';
|
|
2
2
|
|
|
3
3
|
// src/text.ts
|
|
4
|
-
function evidenceFromText(text, candidates, rung3) {
|
|
4
|
+
function evidenceFromText(text, candidates, rung3, options) {
|
|
5
5
|
if (text === void 0 || text.trim().length === 0) return [];
|
|
6
6
|
if (candidates === void 0 || candidates.length === 0) return [];
|
|
7
|
-
const verdict = classifyBySnippet(text, candidates, rung3);
|
|
7
|
+
const verdict = classifyBySnippet(text, candidates, rung3, options);
|
|
8
8
|
if (verdict.language === "unknown") return [];
|
|
9
9
|
const item = {
|
|
10
10
|
kind: "title-script",
|
|
@@ -31,5 +31,5 @@ function clamp01(value) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export { evidenceFromText };
|
|
34
|
-
//# sourceMappingURL=chunk-
|
|
35
|
-
//# sourceMappingURL=chunk-
|
|
34
|
+
//# sourceMappingURL=chunk-66W4QJGT.js.map
|
|
35
|
+
//# sourceMappingURL=chunk-66W4QJGT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/text.ts"],"names":[],"mappings":";;;AAwBO,SAAS,gBAAA,CACd,IAAA,EACA,UAAA,EACA,KAAA,EACA,OAAA,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,OAAO,OAAO,CAAA;AAClE,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-66W4QJGT.js","sourcesContent":["import type { LanguageEvidence, LanguageProfile } from \"./types.js\";\nimport {\n classifyBySnippet,\n type ClassifyOptions,\n type Rung,\n type Rung3Resolver,\n} 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 options?: ClassifyOptions,\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, options);\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-
|
|
2
|
+
import { scriptOfProfile } from './chunk-VWKQLGB4.js';
|
|
3
3
|
|
|
4
4
|
// src/fuse.ts
|
|
5
5
|
var DEFAULT_KIND_WEIGHT = {
|
|
@@ -146,5 +146,5 @@ function clamp01(value) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export { fuse };
|
|
149
|
-
//# sourceMappingURL=chunk-
|
|
150
|
-
//# sourceMappingURL=chunk-
|
|
149
|
+
//# sourceMappingURL=chunk-OUYN7BWC.js.map
|
|
150
|
+
//# sourceMappingURL=chunk-OUYN7BWC.js.map
|
|
@@ -1 +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
|
+
{"version":3,"sources":["../src/fuse.ts"],"names":[],"mappings":"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","file":"chunk-OUYN7BWC.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"]}
|
|
@@ -11,6 +11,7 @@ var UNKNOWN = {
|
|
|
11
11
|
var CYRILLIC_RE = /\p{Script=Cyrillic}/u;
|
|
12
12
|
var LATIN_RE = /\p{Script=Latin}/u;
|
|
13
13
|
var RUNG3_MIN_LENGTH = 24;
|
|
14
|
+
var MINORITY_MIN_LETTERS = 8;
|
|
14
15
|
function dominantScript(text) {
|
|
15
16
|
let cyr = 0;
|
|
16
17
|
let lat = 0;
|
|
@@ -21,6 +22,37 @@ function dominantScript(text) {
|
|
|
21
22
|
if (cyr === 0 && lat === 0) return null;
|
|
22
23
|
return cyr >= lat ? "cyrillic" : "latin";
|
|
23
24
|
}
|
|
25
|
+
var SCRIPT_RE = { cyrillic: CYRILLIC_RE, latin: LATIN_RE };
|
|
26
|
+
var OTHER_SCRIPT = { cyrillic: "latin", latin: "cyrillic" };
|
|
27
|
+
var SCRIPT_WORD_RE = {
|
|
28
|
+
cyrillic: /[\p{Script=Cyrillic}\p{M}'ʼ’-]*\p{Script=Cyrillic}[\p{Script=Cyrillic}\p{M}'ʼ’-]*/gu,
|
|
29
|
+
latin: /[\p{Script=Latin}\p{M}'’-]*\p{Script=Latin}[\p{Script=Latin}\p{M}'’-]*/gu
|
|
30
|
+
};
|
|
31
|
+
function scriptSpan(text, script) {
|
|
32
|
+
return (text.match(SCRIPT_WORD_RE[script]) ?? []).join(" ");
|
|
33
|
+
}
|
|
34
|
+
function countLetters(text, script) {
|
|
35
|
+
let n = 0;
|
|
36
|
+
for (const ch of text) if (SCRIPT_RE[script].test(ch)) n += 1;
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
function textInScope(text, scoped) {
|
|
40
|
+
const first = scoped[0];
|
|
41
|
+
const script = first === void 0 ? null : scriptOfProfile(first);
|
|
42
|
+
if (script === null) return text;
|
|
43
|
+
if (!SCRIPT_RE[OTHER_SCRIPT[script]].test(text)) return text;
|
|
44
|
+
return scriptSpan(text, script);
|
|
45
|
+
}
|
|
46
|
+
function candidatesIn(candidates, script) {
|
|
47
|
+
const seen = /* @__PURE__ */ new Set();
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const c of candidates) {
|
|
50
|
+
if (scriptOfProfile(c) !== script || seen.has(c.code)) continue;
|
|
51
|
+
seen.add(c.code);
|
|
52
|
+
out.push(c);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
24
56
|
function scriptOfProfile(profile) {
|
|
25
57
|
for (const ch of profile.alphabet) {
|
|
26
58
|
if (CYRILLIC_RE.test(ch)) return "cyrillic";
|
|
@@ -31,14 +63,7 @@ function scriptOfProfile(profile) {
|
|
|
31
63
|
function scopeCandidates(text, candidates) {
|
|
32
64
|
const script = dominantScript(text);
|
|
33
65
|
if (script === null) return [];
|
|
34
|
-
|
|
35
|
-
const scoped = [];
|
|
36
|
-
for (const c of candidates) {
|
|
37
|
-
if (scriptOfProfile(c) !== script || seen.has(c.code)) continue;
|
|
38
|
-
seen.add(c.code);
|
|
39
|
-
scoped.push(c);
|
|
40
|
-
}
|
|
41
|
-
return scoped;
|
|
66
|
+
return candidatesIn(candidates, script);
|
|
42
67
|
}
|
|
43
68
|
var CONTRADICTION_SHARE = 0.02;
|
|
44
69
|
var NO_CONTRADICTION = { letters: [], share: 0 };
|
|
@@ -133,12 +158,16 @@ function wordRung(tokens, scoped, tier, rung) {
|
|
|
133
158
|
);
|
|
134
159
|
return r ? { language: r.code, margin: r.margin, rung } : null;
|
|
135
160
|
}
|
|
136
|
-
function classifyBySnippet(text, candidates, rung3) {
|
|
161
|
+
function classifyBySnippet(text, candidates, rung3, options = {}) {
|
|
137
162
|
if (!text || candidates.length === 0) return UNKNOWN;
|
|
138
163
|
const cleaned = stripNoise(text);
|
|
139
164
|
const scoped = scopeCandidates(cleaned, candidates);
|
|
140
165
|
if (scoped.length === 0) return UNKNOWN;
|
|
141
166
|
const discriminating = scoped.length >= 2;
|
|
167
|
+
if (options.isolateScripts && !discriminating) {
|
|
168
|
+
const minority = minorityVerdict(cleaned, candidates, scoped);
|
|
169
|
+
if (minority) return minority;
|
|
170
|
+
}
|
|
142
171
|
let alphabet;
|
|
143
172
|
const settle = (verdict) => {
|
|
144
173
|
const winner = scoped.find((c) => c.code === verdict.language);
|
|
@@ -150,10 +179,26 @@ function classifyBySnippet(text, candidates, rung3) {
|
|
|
150
179
|
if (byLetter) return settle(byLetter);
|
|
151
180
|
const tokens = tokenize(cleaned);
|
|
152
181
|
if (tokens.length === 0) return UNKNOWN;
|
|
153
|
-
const byWord = wordRung(tokens, scoped, "function", "2a") ?? wordRung(tokens, scoped, "frequent", "2b") ?? rung3?.(cleaned, scoped);
|
|
182
|
+
const byWord = wordRung(tokens, scoped, "function", "2a") ?? wordRung(tokens, scoped, "frequent", "2b") ?? rung3?.(options.isolateScripts ? textInScope(cleaned, scoped) : cleaned, scoped);
|
|
154
183
|
return byWord ? settle(byWord) : UNKNOWN;
|
|
155
184
|
}
|
|
185
|
+
function minorityVerdict(cleaned, candidates, dominant) {
|
|
186
|
+
const first = dominant[0];
|
|
187
|
+
const dominantScriptName = first === void 0 ? null : scriptOfProfile(first);
|
|
188
|
+
if (dominantScriptName === null) return null;
|
|
189
|
+
const script = OTHER_SCRIPT[dominantScriptName];
|
|
190
|
+
const scoped = candidatesIn(candidates, script);
|
|
191
|
+
if (scoped.length < 2) return null;
|
|
192
|
+
const span = scriptSpan(stripQuoted(cleaned), script);
|
|
193
|
+
if (countLetters(span, script) < MINORITY_MIN_LETTERS) return null;
|
|
194
|
+
const verdict = letterRung(span, scoped) ?? wordRung(tokenize(span), scoped, "function", "2a") ?? wordRung(tokenize(span), scoped, "frequent", "2b");
|
|
195
|
+
if (!verdict) return null;
|
|
196
|
+
const winner = scoped.find((c) => c.code === verdict.language);
|
|
197
|
+
const alphabet = alphabetOfScrubbed(stripQuoted(cleaned));
|
|
198
|
+
if (winner && contradictionOf(alphabet, winner).share >= CONTRADICTION_SHARE) return null;
|
|
199
|
+
return { ...verdict, discriminating: true };
|
|
200
|
+
}
|
|
156
201
|
|
|
157
|
-
export { CONTRADICTION_SHARE, FRANC_RUNG, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, scriptOfProfile, textAlphabet };
|
|
158
|
-
//# sourceMappingURL=chunk-
|
|
159
|
-
//# sourceMappingURL=chunk-
|
|
202
|
+
export { CONTRADICTION_SHARE, FRANC_RUNG, MINORITY_MIN_LETTERS, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, scriptOfProfile, textAlphabet, textInScope };
|
|
203
|
+
//# sourceMappingURL=chunk-VWKQLGB4.js.map
|
|
204
|
+
//# sourceMappingURL=chunk-VWKQLGB4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/internal/classify.ts"],"names":[],"mappings":"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","file":"chunk-VWKQLGB4.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/** Under {@link ClassifyOptions.isolateScripts}, the fewest letters a minority\n * script must carry before it is classified on its own. A quoted word\n * («привет») stays below it; a clause («Самые дешевые») clears it. */\nexport const MINORITY_MIN_LETTERS = 8;\n\n/** Options for {@link classifyBySnippet}. */\nexport interface ClassifyOptions {\n /**\n * Judge each script on its own evidence. Off by default; when on:\n *\n * - **A discriminating minority beats a non-discriminating majority.** When\n * the dominant script holds a lone candidate (its win is decided by script\n * alone, not evidence) and another script in the text holds ≥2 candidates\n * and at least {@link MINORITY_MIN_LETTERS} letters, that script's span is\n * classified on its own with rungs 1–2. A verdict there is returned; an\n * abstention falls back to the dominant-script verdict unchanged. Rung 3\n * never runs on the minority span — it is short by construction, and short\n * spans are where trigrams misread.\n * - **The rung-3 resolver reads only the scope's own script** (see\n * {@link textInScope}), so another script's letters can't lift a short span\n * over a resolver's length floor nor add noise to its trigrams.\n *\n * Without this, «Самые дешевые в Украине | Victron Multiplus II 48V 6k5va»\n * resolves to the lone Latin candidate (22 Latin letters outvote 20\n * Cyrillic), and «Interview with Володимир Зеленський on CNN» reaches a\n * trigram backstop on 42 characters when its Cyrillic is a 20-character name.\n */\n isolateScripts?: boolean;\n}\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\nconst SCRIPT_RE: Record<ScriptName, RegExp> = { cyrillic: CYRILLIC_RE, latin: LATIN_RE };\nconst OTHER_SCRIPT: Record<ScriptName, ScriptName> = { cyrillic: \"latin\", latin: \"cyrillic\" };\n\n/** Word runs carrying at least one letter of the script, with the marks,\n * apostrophes and hyphens that spell words inside it (`п'ять`, `Най-евтините`). */\nconst SCRIPT_WORD_RE: Record<ScriptName, RegExp> = {\n cyrillic: /[\\p{Script=Cyrillic}\\p{M}'ʼ’-]*\\p{Script=Cyrillic}[\\p{Script=Cyrillic}\\p{M}'ʼ’-]*/gu,\n latin: /[\\p{Script=Latin}\\p{M}'’-]*\\p{Script=Latin}[\\p{Script=Latin}\\p{M}'’-]*/gu,\n};\n\n/** The text's words in one script, space-joined. */\nfunction scriptSpan(text: string, script: ScriptName): string {\n return (text.match(SCRIPT_WORD_RE[script]) ?? []).join(\" \");\n}\n\nfunction countLetters(text: string, script: ScriptName): number {\n let n = 0;\n for (const ch of text) if (SCRIPT_RE[script].test(ch)) n += 1;\n return n;\n}\n\n/**\n * The part of `text` a scope can be judged on: its words in the scope's script,\n * when the text also carries letters of another script; otherwise `text`\n * unchanged.\n *\n * What a rung-3 resolver receives under {@link ClassifyOptions.isolateScripts}.\n * Exported so an off-path resolver (an oracle over raw candidates) feeds its\n * backstop exactly what the classifier would. Single-script text is passed\n * through byte-for-byte: rewriting it (dropping punctuation and digits) would\n * shift trigram scores on the common case for no gain.\n */\nexport function textInScope(text: string, scoped: readonly LanguageProfile[]): string {\n const first = scoped[0];\n const script = first === undefined ? null : scriptOfProfile(first);\n if (script === null) return text;\n if (!SCRIPT_RE[OTHER_SCRIPT[script]].test(text)) return text;\n return scriptSpan(text, script);\n}\n\n/** Candidates in one script, one per code (see {@link scopeCandidates}). */\nfunction candidatesIn<P extends LanguageProfile>(\n candidates: readonly P[],\n script: ScriptName,\n): P[] {\n const seen = new Set<string>();\n const out: P[] = [];\n for (const c of candidates) {\n if (scriptOfProfile(c) !== script || seen.has(c.code)) continue;\n seen.add(c.code);\n out.push(c);\n }\n return out;\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 return candidatesIn(candidates, script);\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 *\n * `options` opts into behavior that is off by default — see\n * {@link ClassifyOptions}.\n */\nexport function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(\n text: string,\n candidates: readonly P[],\n rung3?: Rung3Resolver<P>,\n options: ClassifyOptions = {},\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 if (options.isolateScripts && !discriminating) {\n const minority = minorityVerdict(cleaned, candidates, scoped);\n if (minority) return minority;\n }\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?.(options.isolateScripts ? textInScope(cleaned, scoped) : cleaned, scoped);\n return byWord ? settle(byWord) : UNKNOWN;\n}\n\n/**\n * Under {@link ClassifyOptions.isolateScripts}: the verdict of a minority\n * script that can actually choose between candidates, when the dominant one\n * cannot. Rungs 1–2 over that script's unquoted span, with the same\n * contradiction veto;\n * `null` when the span is too short, holds fewer than two candidates, or\n * abstains — the caller then keeps the dominant-script verdict.\n */\nfunction minorityVerdict(\n cleaned: string,\n candidates: readonly LanguageProfile[],\n dominant: readonly LanguageProfile[],\n): SnippetVerdict | null {\n const first = dominant[0];\n const dominantScriptName = first === undefined ? null : scriptOfProfile(first);\n if (dominantScriptName === null) return null;\n const script = OTHER_SCRIPT[dominantScriptName];\n const scoped = candidatesIn(candidates, script);\n if (scoped.length < 2) return null;\n // Quotations are someone else's words: an English title quoting a Russian\n // slogan is not a Russian title (see {@link stripQuoted}).\n const span = scriptSpan(stripQuoted(cleaned), script);\n if (countLetters(span, script) < MINORITY_MIN_LETTERS) return null;\n\n const verdict =\n letterRung(span, scoped) ??\n wordRung(tokenize(span), scoped, \"function\", \"2a\") ??\n wordRung(tokenize(span), scoped, \"frequent\", \"2b\");\n if (!verdict) return null;\n const winner = scoped.find((c) => c.code === verdict.language);\n const alphabet = alphabetOfScrubbed(stripQuoted(cleaned));\n if (winner && contradictionOf(alphabet, winner).share >= CONTRADICTION_SHARE) return null;\n return { ...verdict, discriminating: true };\n}\n"]}
|
package/dist/classify.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { L as LanguageProfile } from './types-
|
|
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.
|
|
@@ -70,6 +70,46 @@ type RungVerdict = Pick<SnippetVerdict, "language" | "margin" | "rung">;
|
|
|
70
70
|
type Rung3Resolver<P extends LanguageProfile = LanguageProfile> = (text: string, scoped: readonly P[]) => RungVerdict | null;
|
|
71
71
|
/** Below this length, trigrams are too noisy to justify a rung-3 verdict. */
|
|
72
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;
|
|
73
113
|
/** Candidates whose script matches the text's dominant script (others can't tip
|
|
74
114
|
* the verdict). Empty when the text carries no letters. Generic over the
|
|
75
115
|
* concrete profile type `P`: the result is a subset of the input array, so it
|
|
@@ -161,7 +201,10 @@ declare function contradiction(text: string, profile: LanguageProfile): Contradi
|
|
|
161
201
|
* with no adapter — the resolver sees exactly the profiles the caller passed.
|
|
162
202
|
* `P` defaults to {@link LanguageProfile}, so the bare two-argument form and
|
|
163
203
|
* every existing call site are unchanged.
|
|
204
|
+
*
|
|
205
|
+
* `options` opts into behavior that is off by default — see
|
|
206
|
+
* {@link ClassifyOptions}.
|
|
164
207
|
*/
|
|
165
|
-
declare function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(text: string, candidates: readonly P[], rung3?: Rung3Resolver<P
|
|
208
|
+
declare function classifyBySnippet<P extends LanguageProfile = LanguageProfile>(text: string, candidates: readonly P[], rung3?: Rung3Resolver<P>, options?: ClassifyOptions): SnippetVerdict;
|
|
166
209
|
|
|
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 };
|
|
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,4 +1,4 @@
|
|
|
1
|
-
export { CONTRADICTION_SHARE, FRANC_RUNG, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, textAlphabet } from './chunk-
|
|
1
|
+
export { CONTRADICTION_SHARE, FRANC_RUNG, MINORITY_MIN_LETTERS, RUNG3_MIN_LENGTH, classifyBySnippet, contradiction, contradictionOf, scopeCandidates, textAlphabet, textInScope } from './chunk-VWKQLGB4.js';
|
|
2
2
|
import './chunk-2Z7REIOE.js';
|
|
3
3
|
//# sourceMappingURL=classify.js.map
|
|
4
4
|
//# sourceMappingURL=classify.js.map
|
package/dist/franc.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { L as LanguageProfile, S as SyncSource, a as LanguageEvidence } from './types-
|
|
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,4 @@
|
|
|
1
|
-
import { scopeCandidates } from './chunk-
|
|
1
|
+
import { scopeCandidates, textInScope } from './chunk-VWKQLGB4.js';
|
|
2
2
|
import './chunk-2Z7REIOE.js';
|
|
3
3
|
import { francAll } from 'franc';
|
|
4
4
|
|
|
@@ -17,11 +17,13 @@ function francScore(text, scoped) {
|
|
|
17
17
|
if (language === void 0) return null;
|
|
18
18
|
return { language, margin: top[1] - (ranked[1]?.[1] ?? 0) };
|
|
19
19
|
}
|
|
20
|
-
function evidenceFromFranc(text, candidates) {
|
|
20
|
+
function evidenceFromFranc(text, candidates, options = {}) {
|
|
21
21
|
if (text === void 0 || text.trim().length < RUNG_MIN_LENGTH) return [];
|
|
22
22
|
if (candidates === void 0 || candidates.length === 0) return [];
|
|
23
23
|
const scoped = scopeCandidates(text, candidates);
|
|
24
|
-
const
|
|
24
|
+
const sample = options.isolateScripts ? textInScope(text, scoped) : text;
|
|
25
|
+
if (sample.trim().length < RUNG_MIN_LENGTH) return [];
|
|
26
|
+
const scored = francScore(sample, scoped);
|
|
25
27
|
if (scored === null) return [];
|
|
26
28
|
return [
|
|
27
29
|
{
|
|
@@ -34,12 +36,12 @@ function evidenceFromFranc(text, candidates) {
|
|
|
34
36
|
}
|
|
35
37
|
];
|
|
36
38
|
}
|
|
37
|
-
function createFrancEngine(candidates) {
|
|
39
|
+
function createFrancEngine(candidates, options = {}) {
|
|
38
40
|
return {
|
|
39
41
|
id: "franc",
|
|
40
42
|
sync: true,
|
|
41
43
|
inputs: ["text"],
|
|
42
|
-
detect: (input) => evidenceFromFranc(input.text, candidates)
|
|
44
|
+
detect: (input) => evidenceFromFranc(input.text, candidates, options)
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
var francEngine = {
|
package/dist/franc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/franc.ts"],"names":[],"mappings":";;;;AAmBA,IAAM,eAAA,GAAkB,EAAA;AAExB,IAAM,gBAAA,GAAmB,EAAA;AAEzB,IAAM,iBAAA,GAAoB,GAAA;AAQ1B,SAAS,UAAA,CACP,MACA,MAAA,EAC6C;AAC7C,EAAA,MAAM,KAAA,uBAAY,GAAA,EAAoB;AACtC,EAAA,KAAA,MAAW,CAAA,IAAK,MAAA,EAAQ,IAAI,CAAA,CAAE,OAAA,KAAY,MAAA,EAAW,KAAA,CAAM,GAAA,CAAI,CAAA,CAAE,OAAA,EAAS,CAAA,CAAE,IAAI,CAAA;AAChF,EAAA,IAAI,KAAA,CAAM,IAAA,GAAO,CAAA,EAAG,OAAO,IAAA;AAC3B,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,iBAAiB,CAAA;AAC9C,EAAA,MAAM,MAAA,GAAS,QAAA,CAAS,MAAA,EAAQ,EAAE,IAAA,EAAM,CAAC,GAAG,KAAA,CAAM,IAAA,EAAM,CAAA,EAAG,SAAA,EAAW,kBAAkB,CAAA;AACxF,EAAA,MAAM,GAAA,GAAM,OAAO,CAAC,CAAA;AACpB,EAAA,IAAI,CAAC,GAAA,IAAO,GAAA,CAAI,CAAC,CAAA,KAAM,OAAO,OAAO,IAAA;AACrC,EAAA,MAAM,QAAA,GAAW,KAAA,CAAM,GAAA,CAAI,GAAA,CAAI,CAAC,CAAC,CAAA;AACjC,EAAA,IAAI,QAAA,KAAa,QAAW,OAAO,IAAA;AACnC,EAAA,OAAO,EAAE,QAAA,EAAU,MAAA,EAAQ,GAAA,CAAI,CAAC,CAAA,IAAK,MAAA,CAAO,CAAC,CAAA,GAAI,CAAC,CAAA,IAAK,CAAA,CAAA,EAAG;AAC5D;AAMO,SAAS,iBAAA,CACd,
|
|
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-
|
|
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,6 +1,6 @@
|
|
|
1
|
-
export { fuse } from './chunk-
|
|
1
|
+
export { fuse } from './chunk-OUYN7BWC.js';
|
|
2
2
|
import './chunk-YCUSX3GG.js';
|
|
3
|
-
import './chunk-
|
|
3
|
+
import './chunk-VWKQLGB4.js';
|
|
4
4
|
import './chunk-2Z7REIOE.js';
|
|
5
5
|
//# sourceMappingURL=fuse.js.map
|
|
6
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-
|
|
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
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-
|
|
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-
|
|
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,14 @@
|
|
|
1
|
-
import { evidenceFromText } from './chunk-
|
|
2
|
-
export { evidenceFromText } from './chunk-
|
|
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-
|
|
8
|
-
export { fuse } from './chunk-
|
|
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-
|
|
11
|
+
import './chunk-VWKQLGB4.js';
|
|
12
12
|
import './chunk-2Z7REIOE.js';
|
|
13
13
|
|
|
14
14
|
// src/node-lang.ts
|
|
@@ -32,13 +32,13 @@ function evidenceFromNodeLang(attrs) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// src/compile.ts
|
|
35
|
-
function builtIns(candidates, nodeLangAttributes) {
|
|
35
|
+
function builtIns(candidates, nodeLangAttributes, isolateScripts) {
|
|
36
36
|
return [
|
|
37
37
|
{
|
|
38
38
|
id: "text",
|
|
39
39
|
sync: true,
|
|
40
40
|
inputs: ["text"],
|
|
41
|
-
detect: (i) => evidenceFromText(i.text, candidates)
|
|
41
|
+
detect: (i) => evidenceFromText(i.text, candidates, void 0, { isolateScripts })
|
|
42
42
|
},
|
|
43
43
|
{ id: "html", sync: true, inputs: ["html"], detect: (i) => evidenceFromHtml(i.html) },
|
|
44
44
|
{
|
|
@@ -64,7 +64,11 @@ function applicable(source, input) {
|
|
|
64
64
|
}
|
|
65
65
|
function compile(config = {}) {
|
|
66
66
|
const sources = [
|
|
67
|
-
...builtIns(
|
|
67
|
+
...builtIns(
|
|
68
|
+
config.candidates,
|
|
69
|
+
config.nodeLangAttributes ?? DEFAULT_NODE_LANG_ATTRIBUTES,
|
|
70
|
+
config.isolateScripts ?? false
|
|
71
|
+
),
|
|
68
72
|
...config.engines ?? []
|
|
69
73
|
];
|
|
70
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"]}
|
package/dist/profiles.d.ts
CHANGED
package/dist/text.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { L as LanguageProfile, a as LanguageEvidence } from './types-
|
|
2
|
-
import { Rung3Resolver } from './classify.js';
|
|
1
|
+
import { L as LanguageProfile, a as LanguageEvidence } from './types-DbPJ0sxF.js';
|
|
2
|
+
import { Rung3Resolver, ClassifyOptions } from './classify.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Producer: candidate-relative script + lexical signals from the title text.
|
|
@@ -17,6 +17,6 @@ import { Rung3Resolver } from './classify.js';
|
|
|
17
17
|
* (or no usable distinctive signal) it abstains — emitting nothing rather than a
|
|
18
18
|
* coarse "unknown", since the roster decides relevance.
|
|
19
19
|
*/
|
|
20
|
-
declare function evidenceFromText(text: string | undefined, candidates?: readonly LanguageProfile[], rung3?: Rung3Resolver): LanguageEvidence[];
|
|
20
|
+
declare function evidenceFromText(text: string | undefined, candidates?: readonly LanguageProfile[], rung3?: Rung3Resolver, options?: ClassifyOptions): LanguageEvidence[];
|
|
21
21
|
|
|
22
22
|
export { evidenceFromText };
|
package/dist/text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { evidenceFromText } from './chunk-
|
|
2
|
-
import './chunk-
|
|
1
|
+
export { evidenceFromText } from './chunk-66W4QJGT.js';
|
|
2
|
+
import './chunk-VWKQLGB4.js';
|
|
3
3
|
import './chunk-2Z7REIOE.js';
|
|
4
4
|
//# sourceMappingURL=text.js.map
|
|
5
5
|
//# sourceMappingURL=text.js.map
|
|
@@ -135,6 +135,11 @@ interface DetectorConfig<E extends readonly EvidenceSource[] = []> {
|
|
|
135
135
|
* `"candidate"` (current behavior); opt into `"unknown"` for a roster-closed,
|
|
136
136
|
* evidence-only policy. */
|
|
137
137
|
nonDiscriminatingScript?: NonDiscriminatingScript;
|
|
138
|
+
/** Forwarded to the built-in text producer's classifier: judge each script on
|
|
139
|
+
* its own evidence. Off by default. See `ClassifyOptions.isolateScripts` in
|
|
140
|
+
* `langtell/classify`. Opt-in engines take their own option (e.g.
|
|
141
|
+
* `createFrancEngine(candidates, { isolateScripts: true })`). */
|
|
142
|
+
isolateScripts?: boolean;
|
|
138
143
|
}
|
|
139
144
|
/** The compiled detector. Synchronous when every source is sync; `Promise`-typed
|
|
140
145
|
* the moment an async engine is registered — so callers never guess `await`. */
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
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 +0,0 @@
|
|
|
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"]}
|