Skip to content

Commit ff7c64e

Browse files
committed
detect: rank only systems whose source script matches the input
The detect walk loaded all 288 maps per request. On the Workers free plan (10ms CPU) that intermittently dies with 1102 — production returns anywhere between 47 and 96 partial rankings per call, or 503s. Loading every map to rank unrelated scripts was also semantically empty: Georgian input ranked 280 irrelevant systems at distance 9. detectInputScript() picks the majority Unicode script of the input (non-Latin beats Latin when tied); a build-time generated maps/scripts.js maps each system to its source script, and detect walks only the matching family (Hangul input also matches the mixed Kore sources, Han input also matches Hans/Kore/Hrkt). Georgian input now loads 8 systems instead of 288 and the top hits are unchanged. Test fixtures: the detect fixture records the prefiltered ranking.
1 parent b7f64fa commit ff7c64e

8 files changed

Lines changed: 260 additions & 1126 deletions

File tree

‎maps/scripts.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const mapScripts: Record<string, string | undefined>

‎maps/scripts.js‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎scripts/build-maps.mjs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,22 @@ writeFileSync(
5050
)
5151
writeFileSync(join(repoMaps, "manifest.d.ts"), "export const systemCodes: readonly string[]\n")
5252

53+
const mapScripts = {}
54+
for (const name of readdirSync(outMaps)) {
55+
const code = basename(name, ".json")
56+
if (manifest.systems.includes(code)) {
57+
mapScripts[code] = JSON.parse(readFileSync(join(outMaps, name), "utf8")).metadata?.source_script
58+
}
59+
}
60+
writeFileSync(
61+
join(repoMaps, "scripts.js"),
62+
"// Generated by scripts/build-maps.mjs from interscript/maps — source script per system. Do not edit.\n" +
63+
`export const mapScripts = ${JSON.stringify(mapScripts)};\n`,
64+
)
65+
writeFileSync(
66+
join(repoMaps, "scripts.d.ts"),
67+
"export const mapScripts: Record<string, string | undefined>\n",
68+
)
69+
5370
rmSync(outDir, { recursive: true, force: true })
5471
console.log(`bundled ${manifest.count} maps into maps/`)

‎src/engine.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55
import { configure, transliterateAsync, type SystemCode } from "interscript"
66
import { bundledSystemCodes, detectableSystemCodes, loadMap, type MapAssets } from "./maps.js"
7+
import { mapScripts } from "../maps/scripts.js"
8+
import { detectInputScript, sourceMatchesScript } from "./scripts.js"
79
import type { LoadStrategy } from "interscript"
810

911
let configuredFor: MapAssets | undefined
@@ -43,18 +45,26 @@ function levenshtein(a: string, b: string): number {
4345
return prev[b.length]!
4446
}
4547

46-
// Mirrors interscript-ruby's detector: transliterate the input through
47-
// every system, rank by Levenshtein distance to the output. Systems
48-
// that fail (including ML-powered maps with no runtime configured) are
49-
// skipped, exactly as the Ruby detector rescues per-map errors.
48+
// Transliterate the input through every system whose source script
49+
// matches the input, rank by Levenshtein distance to the output.
50+
// Systems that fail (including ML-powered maps with no runtime
51+
// configured) are skipped, exactly as the Ruby detector rescues
52+
// per-map errors. The full-corpus walk is gone deliberately: it
53+
// exceeded the Workers CPU budget (1102s in production).
5054
export async function detect(
5155
assets: MapAssets,
5256
input: string,
5357
output: string,
5458
): Promise<{ mapName: string; distance: number }[]> {
5559
ensureEngine(assets)
60+
// Only systems whose source script matches the input can rank: walking
61+
// all 288 maps exhausts the Workers CPU budget (1102s in production).
62+
const detected = detectInputScript(input)
63+
const codes = detected
64+
? detectableSystemCodes().filter((code) => sourceMatchesScript(mapScripts[code], detected))
65+
: []
5666
const candidates: { mapName: string; distance: number }[] = []
57-
for (const code of detectableSystemCodes()) {
67+
for (const code of codes) {
5868
try {
5969
const transliterated = await transliterateAsync(code, input)
6070
candidates.push({ mapName: code, distance: levenshtein(transliterated, output) })

‎src/rest.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
* GET /v1/maps every bundled transliteration system code
88
* GET /v1/maps/:code one system's compiled map (JSON IR)
99
* POST /v1/transliterate {system, input} -> {output}
10-
* POST /v1/detect {input, output} -> ranked systems
10+
* POST /v1/detect {input, output} -> systems whose source
11+
* script matches the input, ranked
1112
* GET /v1/models the neural model index (IMF v1)
1213
* GET /v1/models/:id one model's full entry
1314
* POST /v1/infer {model, input} -> {output} (Modal proxy)

‎src/scripts.ts‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Input-script detection for detect(): the walk only transliterates
3+
* systems whose source script matches the input — loading all 288 maps
4+
* per request exceeds the Workers CPU budget (1102s in production).
5+
* Ranges cover exactly the source scripts present in the corpus.
6+
*/
7+
8+
type ScriptName = string
9+
10+
const SCRIPT_RANGES: ReadonlyArray<
11+
readonly [ScriptName, ReadonlyArray<readonly [number, number]>]
12+
> = [
13+
[
14+
"Latn",
15+
[
16+
[0x41, 0x5a],
17+
[0x61, 0x7a],
18+
[0xc0, 0xff],
19+
[0x100, 0x24f],
20+
],
21+
],
22+
[
23+
"Grek",
24+
[
25+
[0x370, 0x3ff],
26+
[0x1f00, 0x1fff],
27+
],
28+
],
29+
["Cyrl", [[0x400, 0x52f]]],
30+
["Armn", [[0x530, 0x58f]]],
31+
["Hebr", [[0x590, 0x5ff]]],
32+
[
33+
"Arab",
34+
[
35+
[0x600, 0x6ff],
36+
[0x750, 0x77f],
37+
[0xfb50, 0xfdff],
38+
[0xfe70, 0xfeff],
39+
],
40+
],
41+
["Deva", [[0x900, 0x97f]]],
42+
["Beng", [[0x980, 0x9ff]]],
43+
["Guru", [[0xa00, 0xa7f]]],
44+
["Gujr", [[0xa80, 0xaff]]],
45+
["Orya", [[0xb00, 0xb7f]]],
46+
["Taml", [[0xb80, 0xbff]]],
47+
["Telu", [[0xc00, 0xc7f]]],
48+
["Kana", [[0xc80, 0xcff]]],
49+
["Mlym", [[0xd00, 0xd7f]]],
50+
["Sinh", [[0xd80, 0xdff]]],
51+
["Thaa", [[0x780, 0x7bf]]],
52+
["Thai", [[0xe00, 0xe7f]]],
53+
[
54+
"Geor",
55+
[
56+
[0x10a0, 0x10ff],
57+
[0x1c90, 0x1cbf],
58+
],
59+
],
60+
["Ethi", [[0x1200, 0x137f]]],
61+
["Mong", [[0x1800, 0x18af]]],
62+
[
63+
"Hang",
64+
[
65+
[0x1100, 0x11ff],
66+
[0x3130, 0x318f],
67+
[0xac00, 0xd7af],
68+
],
69+
],
70+
[
71+
"Hrkt",
72+
[
73+
[0x3040, 0x309f],
74+
[0x30a0, 0x30ff],
75+
[0x31f0, 0x31ff],
76+
],
77+
],
78+
[
79+
"Hani",
80+
[
81+
[0x2e80, 0x2eff],
82+
[0x3400, 0x4dbf],
83+
[0x4e00, 0x9fff],
84+
[0xf900, 0xfaff],
85+
[0x20000, 0x2a6df],
86+
],
87+
],
88+
]
89+
90+
function scriptOfCodePoint(cp: number): ScriptName | null {
91+
for (const [script, ranges] of SCRIPT_RANGES) {
92+
for (const [lo, hi] of ranges) {
93+
if (cp >= lo && cp <= hi) return script
94+
}
95+
}
96+
return null
97+
}
98+
99+
export function detectInputScript(input: string): ScriptName | null {
100+
const counts = new Map<ScriptName, number>()
101+
for (const ch of input) {
102+
const script = scriptOfCodePoint(ch.codePointAt(0)!)
103+
if (script) counts.set(script, (counts.get(script) ?? 0) + 1)
104+
}
105+
let best: ScriptName | null = null
106+
let bestWeighted = 0
107+
for (const [script, n] of counts) {
108+
// A Latin majority is the weakest signal: one stray non-Latin letter
109+
// outranks any amount of Latin.
110+
const weighted = script === "Latn" ? n - 0.5 : n
111+
if (weighted > bestWeighted) {
112+
best = script
113+
bestWeighted = weighted
114+
}
115+
}
116+
return best
117+
}
118+
119+
// Detected script → corpus source_script values that can legitimately
120+
// process it ("Kana" is Kannada in this corpus; "Kore" is the mixed
121+
// Hangul+Latin jamo family; "Hrkt" is Japanese kana).
122+
const FAMILIES: Readonly<Record<ScriptName, readonly string[]>> = {
123+
Latn: ["Latn"],
124+
Grek: ["Grek"],
125+
Cyrl: ["Cyrl"],
126+
Armn: ["Armn"],
127+
Hebr: ["Hebr"],
128+
Arab: ["Arab"],
129+
Deva: ["Deva"],
130+
Beng: ["Beng"],
131+
Guru: ["Guru"],
132+
Gujr: ["Gujr"],
133+
Orya: ["Orya"],
134+
Taml: ["Taml"],
135+
Telu: ["Telu"],
136+
Kana: ["Kana"],
137+
Mlym: ["Mlym"],
138+
Sinh: ["Sinh"],
139+
Thai: ["Thai"],
140+
Thaa: ["Thaa"],
141+
Ethi: ["Ethi"],
142+
Mong: ["Mong"],
143+
Geor: ["Geor"],
144+
Hang: ["Hang", "Kore"],
145+
Hrkt: ["Hrkt", "Kore"],
146+
Hani: ["Hani", "Hans", "Kore", "Hrkt"],
147+
}
148+
149+
export function sourceMatchesScript(sourceScript: string | undefined, detected: string): boolean {
150+
const family = FAMILIES[detected]
151+
return !!sourceScript && !!family && family.includes(sourceScript)
152+
}

‎test/detect.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { readFileSync } from "node:fs"
2+
import { describe, expect, it } from "vitest"
3+
import { detect } from "../src/engine.js"
4+
import { detectInputScript } from "../src/scripts.js"
5+
import type { MapAssets } from "../src/maps.js"
6+
7+
const fetched: string[] = []
8+
const env: MapAssets = {
9+
fetch: async (url) => {
10+
const path = typeof url === "string" ? url : url.url
11+
const code = path.substring(path.lastIndexOf("/") + 1).replace(".json", "")
12+
fetched.push(code)
13+
try {
14+
return new Response(readFileSync(`maps/${code}.json`), { status: 200 })
15+
} catch {
16+
return new Response(null, { status: 404 })
17+
}
18+
},
19+
}
20+
21+
describe("detectInputScript", () => {
22+
it("detects the majority script of the input", () => {
23+
expect(detectInputScript("ქართული")).toBe("Geor")
24+
expect(detectInputScript("한국")).toBe("Hang")
25+
expect(detectInputScript("Привет")).toBe("Cyrl")
26+
expect(detectInputScript(" தமிழ் ")).toBe("Taml")
27+
})
28+
29+
it("prefers the non-Latin script in mixed input", () => {
30+
expect(detectInputScript("한국a")).toBe("Hang")
31+
expect(detectInputScript("київ word")).toBe("Cyrl")
32+
})
33+
34+
it("falls back to Latin for pure ASCII", () => {
35+
expect(detectInputScript("hello world")).toBe("Latn")
36+
})
37+
38+
it("returns null when no script characters are present", () => {
39+
expect(detectInputScript("123 !?")).toBeNull()
40+
expect(detectInputScript("")).toBeNull()
41+
})
42+
})
43+
44+
describe("detect prefilter", () => {
45+
it("only loads maps whose source script matches the input", async () => {
46+
fetched.length = 0
47+
const results = await detect(env, "ქართული", "kʻartʻuli")
48+
// 8 Georgian-source systems, not the full 288-map walk.
49+
expect(fetched.length).toBeLessThanOrEqual(10)
50+
expect(results[0]).toMatchObject({ mapName: "alalc-kat-Geor-Latn-1997", distance: 0 })
51+
})
52+
53+
it("matches Korean-family sources for Hangul input", async () => {
54+
fetched.length = 0
55+
const results = await detect(env, "한국", "Hanguk")
56+
expect(results[0]!.distance).toBe(0)
57+
expect(results.map((r) => r.mapName)).toContain("var-kor-Kore-Latn-mr-1939")
58+
})
59+
60+
it("ranks Han-source systems for Han input", async () => {
61+
const results = await detect(env, "阜康", "Fukang")
62+
expect(results[0]).toMatchObject({ mapName: "acadsin-zho-Hani-Latn-2002" })
63+
})
64+
65+
it("returns an empty ranking for script-less input", async () => {
66+
fetched.length = 0
67+
const results = await detect(env, "123 !?", "x")
68+
expect(results).toEqual([])
69+
expect(fetched).toEqual([])
70+
})
71+
})

0 commit comments

Comments
 (0)