Skip to content

Commit 511b7f8

Browse files
committed
feat(playground): speculative vocalization tier (2.1 quality, int4 drafts)
Third vocalization option: ara-diac-layerdrop-1.0-int4 drafts, ara-diac-small-2.1-int8 verifies in one pass per block - measured acceptance 0.9886 at 8.85 tokens per verifier pass (RESULTS.md 2026-09-11), output-preserving by construction. Status line reports the acceptance after each run. Requires interscript ^5.4.0 (SpeculativeModel). Live e2e (SPEC_E2E=1): downloads both models in a fresh profile, vocalizes, and shows the readout (1.6m). Also: stale CLI-name test expectations (interscript-ts -> interscript, pre-existing red on main), dead frontmatter var + unused SITE (lint), playground.astro added to .prettierignore (hand-wrapped prose).
1 parent 388c284 commit 511b7f8

7 files changed

Lines changed: 77 additions & 36 deletions

File tree

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ TODO.complete/
2727
src/pages/compare.astro
2828
src/pages/maps/\[systemCode\].astro
2929
src/pages/api-docs.astro
30+
31+
# hand-wrapped prose (Astro whitespace trap) — see CLAUDE.md
32+
src/pages/playground.astro

e2e/playground-spec-live.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// LIVE playground speculative-tier verification — downloads the real
2+
// drafter+verifier pair through the assets proxy and decodes in the
3+
// browser with the verifier deciding every token. Opt-in (SPEC_E2E=1):
4+
// SPEC_E2E=1 npx playwright test e2e/playground-spec-live.spec.ts
5+
import { expect, test } from "@playwright/test"
6+
7+
test("speculative tier downloads both models and vocalizes in the browser", async ({ page }) => {
8+
test.skip(!process.env.SPEC_E2E, "live download test — set SPEC_E2E=1")
9+
test.setTimeout(900_000)
10+
await page.goto(`${process.env.SPEC_E2E_BASE ?? "http://localhost:4321"}/playground`)
11+
await page.fill("#input", "قوله فحكمها في الوفاة")
12+
await page.selectOption("#vocalize", "spec:ara-diac-small-2.1-int8")
13+
await page.click("#run")
14+
// both downloads stream progress, then the pair decodes
15+
await expect(page.locator("#status")).toBeVisible({ timeout: 30_000 })
16+
const vocalized = page.locator("#vocalized")
17+
// fresh profile = 359 MB total; budget for a slow network
18+
await expect(vocalized).not.toBeEmpty({ timeout: 840_000 })
19+
const text = (await vocalized.textContent()) ?? ""
20+
expect(text).toMatch(/[ً-ْٰٓ-ٕ]/)
21+
expect(text).not.toMatch(/Error|cause:/)
22+
// the acceptance readout surfaces once the worker call resolves
23+
// after the vocalized text — poll rather than read once
24+
await expect(page.locator("#status")).toContainText("accepted", { timeout: 60_000 })
25+
})

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"@litertjs/core": "^2.5.3",
3535
"@tailwindcss/vite": "^4.0.0",
3636
"astro": "^7.3.1",
37-
"interscript": "^5.2.1",
37+
"interscript": "^5.4.0",
3838
"onnxruntime-web": "^1.29.0",
3939
"svgo": "^4.1.0",
4040
"tailwindcss": "^4.0.0",

src/pages/openapi.json.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { APIRoute } from "astro"
1313

1414
export const prerender = true
1515

16-
const SITE = "https://interscript.org"
16+
const _SITE = "https://interscript.org"
1717

1818
const spec = {
1919
openapi: "3.1.0",

src/pages/playground.astro

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@
66
import Base from "../layouts/Base.astro"
77
import catalogue from "../data/maps-catalogue.json"
88
9-
const systems = Object.entries(catalogue as Record<string, { data: { name: string; language: string } }>)
10-
.map(([code, m]) => ({ code, name: m.data.name, lang: m.data.language }))
11-
.sort((a, b) => a.name.localeCompare(b.name))
129
---
1310

1411
<Base title="Playground">
@@ -30,6 +27,9 @@ const systems = Object.entries(catalogue as Record<string, { data: { name: strin
3027
<option value="">Off — deterministic maps only</option>
3128
<option value="ara-diac-layerdrop-1.0-int4">Arabic lite (int4, 95 MB, downloads once)</option>
3229
<option value="ara-diac-small-2.1-int8">Arabic 2.1 (int8, 264 MB, downloads once)</option>
30+
<option value="spec:ara-diac-small-2.1-int8">
31+
Arabic 2.1 + speculative (int4 drafts it, 359 MB, downloads once)
32+
</option>
3333
</select>
3434

3535
<label for="filter">System</label>
@@ -168,6 +168,10 @@ const systems = Object.entries(catalogue as Record<string, { data: { name: strin
168168
if (!voc) {
169169
return `import { transliterateAsync } from "interscript"\n\nconst out = await transliterateAsync(\n "${code}",\n "…",\n)`
170170
}
171+
if (voc.startsWith("spec:")) {
172+
const verifier = voc.slice(5)
173+
return `import { transliterateAsync } from "interscript"\nimport { imf } from "interscript/ml"\n\nconst drafter = await imf.IMFModel.fromZipBytes(\n (await imf.resolve("ara-diac-layerdrop-1.0-int4")).bytes,\n)\nconst verifierModel = await imf.IMFModel.fromZipBytes(\n (await imf.resolve("${verifier}")).bytes,\n)\nconst spec = new imf.SpeculativeModel(drafter, verifierModel)\nconst vocalized = await spec.translate("…")\nconst out = await transliterateAsync(\n "${code}",\n vocalized,\n)`
174+
}
171175
return `import { transliterateAsync } from "interscript"\nimport { imf } from "interscript/ml"\n\nconst resolved = await imf.resolve("${voc}")\nconst model = await imf.IMFModel.fromZipBytes(resolved.bytes)\nconst vocalized = await model.translate("…")\nconst out = await transliterateAsync(\n "${code}",\n vocalized,\n)`
172176
}
173177
$("snippet").textContent = snippetText()
@@ -185,15 +189,27 @@ const systems = Object.entries(catalogue as Record<string, { data: { name: strin
185189
async function ensureModel(id: string) {
186190
if (mlModel && mlModelId === id) return mlModel
187191
const status = $("status")
188-
status.hidden = false
189-
status.textContent = `Downloading ${id} (once per browser)…`
190-
const resolved = await imf.resolve(id, ASSET_INDEX, {
191-
onProgress: (f: number, b: number) => {
192-
status.textContent = `${id}: ${(b / 1e6).toFixed(0)} MB (${Math.round(f * 100)}%)`
193-
},
194-
})
195-
const model = await imf.IMFModel.fromZipBytes(resolved.bytes)
196-
mlModel = model as unknown as { translate(t: string, m?: number): Promise<string> }
192+
const spec = id.startsWith("spec:")
193+
const verifierId = spec ? id.slice(5) : id
194+
const drafterId = spec ? "ara-diac-layerdrop-1.0-int4" : ""
195+
const wanted = spec ? [drafterId, verifierId] : [verifierId]
196+
const models: Record<string, Awaited<ReturnType<typeof imf.IMFModel.fromZipBytes>>> = {}
197+
for (const wantedId of wanted) {
198+
status.hidden = false
199+
status.textContent = `Downloading ${wantedId} (once per browser)…`
200+
const resolved = await imf.resolve(wantedId, ASSET_INDEX, {
201+
onProgress: (f: number, b: number) => {
202+
status.textContent = `${wantedId}: ${(b / 1e6).toFixed(0)} MB (${Math.round(f * 100)}%)`
203+
},
204+
})
205+
models[wantedId] = await imf.IMFModel.fromZipBytes(resolved.bytes)
206+
}
207+
mlModel = spec
208+
? (new imf.SpeculativeModel(models[drafterId]!, models[verifierId]!) as unknown as {
209+
translate(t: string, m?: number): Promise<string>
210+
stats(): { accepted: number; drafted: number; blocks: number } | undefined
211+
})
212+
: (models[verifierId] as unknown as { translate(t: string, m?: number): Promise<string> })
197213
mlModelId = id
198214
status.hidden = true
199215
return mlModel
@@ -218,6 +234,15 @@ const systems = Object.entries(catalogue as Record<string, { data: { name: strin
218234
}
219235
const result = await worker.transliterate(systemSelect.value, text)
220236
out.textContent = result
237+
if (modelId?.startsWith("spec:") && mlModel) {
238+
const stats = (
239+
mlModel as { stats?: () => { accepted: number; drafted: number; blocks: number } }
240+
).stats?.()
241+
if (stats) {
242+
status.hidden = false
243+
status.textContent = `Speculative: ${stats.accepted}/${stats.drafted} draft tokens accepted across ${stats.blocks} verifier passes`
244+
}
245+
}
221246
const share = new URLSearchParams({
222247
q: ($("input") as HTMLTextAreaElement).value,
223248
sys: systemSelect.value,

test/round3-features.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,19 @@ describe("API page additions (CLI + GitHub Action)", () => {
144144
const html = readHtml("api/index.html")
145145

146146
it("mentions the CLI install command", () => {
147-
expect(html).toContain("npm install -g interscript-ts")
148-
expect(html).toContain("npx interscript-ts")
147+
expect(html).toContain("npm install -g interscript")
148+
expect(html).toContain("npx interscript")
149149
})
150150

151151
it("shows single-call + batch CLI usage", () => {
152-
expect(html).toContain("interscript-ts t ")
153-
expect(html).toContain("interscript-ts b ")
154-
expect(html).toContain("interscript-ts list ")
152+
expect(html).toContain("interscript t ")
153+
expect(html).toContain("interscript b ")
154+
expect(html).toContain("interscript list ")
155155
})
156156

157157
it("includes a GitHub Action snippet", () => {
158158
expect(html).toContain("name: Romanize names")
159159
expect(html).toContain("uses: actions/setup-node")
160-
expect(html).toContain("npx interscript-ts@latest")
160+
expect(html).toContain("npx interscript@latest")
161161
})
162162
})

0 commit comments

Comments
 (0)