diff --git a/TODO.client-work/01-decode-guards.md b/TODO.client-work/01-decode-guards.md new file mode 100644 index 0000000..4c2ea74 --- /dev/null +++ b/TODO.client-work/01-decode-guards.md @@ -0,0 +1,5 @@ +# 01-decode-guards + +Port the Python repetition guards (token-window + decoded-text echo) to the TS greedy loop — clients currently ship the echo pathology + +Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/TODO.client-work/02-input-normalization.md b/TODO.client-work/02-input-normalization.md new file mode 100644 index 0000000..7343a9a --- /dev/null +++ b/TODO.client-work/02-input-normalization.md @@ -0,0 +1,5 @@ +# 02-input-normalization + +Strip pre-existing haraqat + unify Arabic presentation forms before inference; models train on stripped input + +Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/TODO.client-work/03-progress-and-streaming.md b/TODO.client-work/03-progress-and-streaming.md new file mode 100644 index 0000000..bfb1066 --- /dev/null +++ b/TODO.client-work/03-progress-and-streaming.md @@ -0,0 +1,5 @@ +# 03-progress-and-streaming + +Download-progress callback in imf.resolve + onToken streaming during decode — 250MB silent waits are the worst UX moment + +Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/TODO.client-work/04-tier-autoselect-warmup.md b/TODO.client-work/04-tier-autoselect-warmup.md new file mode 100644 index 0000000..d2b2432 --- /dev/null +++ b/TODO.client-work/04-tier-autoselect-warmup.md @@ -0,0 +1,5 @@ +# 04-tier-autoselect-warmup + +int4-lite default on low-memory devices, session warm-up on load, WebGPU detect with graceful messaging + +Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/TODO.client-work/05-cache-eviction.md b/TODO.client-work/05-cache-eviction.md new file mode 100644 index 0000000..0366842 --- /dev/null +++ b/TODO.client-work/05-cache-eviction.md @@ -0,0 +1,5 @@ +# 05-cache-eviction + +Evict stale Cache API entries when the index sha changes; cache the index itself for offline-first resolution + +Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/TODO.client-work/06-confidence-margins.md b/TODO.client-work/06-confidence-margins.md new file mode 100644 index 0000000..859f6d8 --- /dev/null +++ b/TODO.client-work/06-confidence-margins.md @@ -0,0 +1,5 @@ +# 06-confidence-margins + +Expose per-step top-2 logit gap during decode as an onConfidence signal; the UI layer highlights low-confidence spans + +Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/TODO.client-work/07-deployment-debt.md b/TODO.client-work/07-deployment-debt.md new file mode 100644 index 0000000..42bcfcb --- /dev/null +++ b/TODO.client-work/07-deployment-debt.md @@ -0,0 +1,5 @@ +# 07-deployment-debt + +Modal inference redeploy with post-index-v2 models.yaml + api models.json regen + site neural demo island + +Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry. diff --git a/package.json b/package.json index f22e7f8..2285423 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interscript", - "version": "4.0.0", + "version": "5.0.0", "description": "Interscript TypeScript runtime — interoperable script conversion", "type": "module", "main": "./dist/index.js", diff --git a/src/ml/imf/guards.ts b/src/ml/imf/guards.ts new file mode 100644 index 0000000..93112aa --- /dev/null +++ b/src/ml/imf/guards.ts @@ -0,0 +1,67 @@ +/** + * Client-side decode guards + input normalization + * (TODO.client-work 01/02), ported from the Python inference harness + * where they were validated against live int8 student echo loops. + * + * The guards stop greedy generation when the output has entered a + * cycle: flat-byte students echo phrases (with rotating punctuation, + * so no verbatim token window repeats) until the generation cap. + */ + +import { decode } from "./tokens.js" + +const HARAQAT = /[ً-ْٰٓ-ٕٖ-ٟۖ-ۭ]/g + +const PRESENTATION_LIGATURES: ReadonlyArray<[string, string]> = [ + ["\uFEF5", "\u0644\u0622"], + ["\uFEF6", "\u0644\u0622"], + ["\uFEF7", "\u0644\u0623"], + ["\uFEF8", "\u0644\u0623"], + ["\uFEF9", "\u0644\u0625"], + ["\uFEFA", "\u0644\u0625"], + ["\uFEFB", "\u0644\u0627"], + ["\uFEFC", "\u0644\u0627"], +] + +/** Normalize user input for models trained on stripped text: remove + * pre-existing haraqat and decompose Arabic presentation-form + * ligatures. Non-Arabic text passes through untouched. */ +export function normalizeArabicInput(text: string): string { + let out = text.replace(HARAQAT, "") + for (const [lig, expansion] of PRESENTATION_LIGATURES) { + out = out.replaceAll(lig, expansion) + } + return out +} + +const TOKEN_WINDOW = 24 +const TEXT_SUFFIX = 16 +const TEXT_ECHOES = 3 + +/** True when generation has entered a repetition cycle and must stop. + * Token guard: trailing 24-token window occurs verbatim earlier. + * Text guard: trailing 16 decoded chars echo 3+ times (catches + * phrase loops whose separators rotate). */ +export function repetitionGuardCut(tokens: readonly number[], decodedSoFar: string): boolean { + if (tokens.length >= 2 * TOKEN_WINDOW) { + const joined = tokens.join(",") + const needle = tokens.slice(-TOKEN_WINDOW).join(",") + if (joined.indexOf(needle) < joined.length - needle.length) return true + } + if (tokens.length % 8 === 0 && decodedSoFar.length >= TEXT_SUFFIX * TEXT_ECHOES) { + const suffix = decodedSoFar.slice(-TEXT_SUFFIX) + let count = 0 + let at = decodedSoFar.indexOf(suffix) + while (at !== -1) { + count++ + at = decodedSoFar.indexOf(suffix, at + 1) + } + if (count >= TEXT_ECHOES) return true + } + return false +} + +/** Decode helper mirroring the runtime loop's guard checks. */ +export function guardStep(tokens: number[]): boolean { + return repetitionGuardCut(tokens, decode(tokens)) +} diff --git a/src/ml/imf/index.ts b/src/ml/imf/index.ts index 68c8d55..74dc4a4 100644 --- a/src/ml/imf/index.ts +++ b/src/ml/imf/index.ts @@ -2,5 +2,13 @@ export { IMFModel } from "./model.js" export { IMFError, parseManifest, verifyAndRead, type IMFManifest } from "./loader.js" -export { resolve, DEFAULT_INDEX_URL, RegistryError, type IndexEntry } from "./registry.js" +export { + resolve, + DEFAULT_INDEX_URL, + RegistryError, + type IndexEntry, + type ResolveOptions, +} from "./registry.js" +export { pickTier, warmUp, type TierCandidate } from "./tiers.js" +export { normalizeArabicInput, repetitionGuardCut } from "./guards.js" export { encode, decode, BYTE_OFFSET, EOS_ID, PAD_ID, UNK_ID } from "./tokens.js" diff --git a/src/ml/imf/model.ts b/src/ml/imf/model.ts index 9410d16..158081d 100644 --- a/src/ml/imf/model.ts +++ b/src/ml/imf/model.ts @@ -10,6 +10,7 @@ import { createSession, type InferenceSession } from "../session/index.js" import type { Tensor } from "../types.js" import { verifyAndRead, parseManifest, type IMFManifest } from "./loader.js" import { resolve } from "./registry.js" +import { normalizeArabicInput, repetitionGuardCut } from "./guards.js" import { EOS_ID, PAD_ID, decode, encode } from "./tokens.js" interface InputMeta { @@ -22,6 +23,15 @@ interface MetadataSession extends InferenceSession { readonly inputMetadata?: readonly InputMeta[] } +export interface DecodeOptions { + /** skip input normalization (raw text) */ + readonly raw?: boolean + /** streaming: called per emitted token (TODO.client-work 03) */ + readonly onToken?: (token: number, step: number) => void + /** per-step top1-top2 logit gap; low gap = low confidence (06) */ + readonly onConfidence?: (gap: number, step: number) => void +} + export class IMFModel { readonly id: string private readonly manifest: IMFManifest @@ -62,13 +72,15 @@ export class IMFModel { return IMFModel.fromZipBytes(resolved.bytes) } - async translate(text: string, maxLen = 256): Promise { - const ids = encode(text) + async translate(text: string, maxLen = 256, opts: DecodeOptions = {}): Promise { + // models train on stripped input: normalize by default (TODO.client-work 02) + const normalized = opts.raw === true ? text : normalizeArabicInput(text) + const ids = encode(normalized) if (ids.length === 1) return "" const hidden = await this.runEncoder(ids) const tokens = this.kv - ? await this.greedyKv(hidden, maxLen) - : await this.greedyPlain(hidden, maxLen) + ? await this.greedyKv(hidden, maxLen, opts) + : await this.greedyPlain(hidden, maxLen, opts) return decode(tokens) } @@ -123,25 +135,33 @@ export class IMFModel { return feeds } - private argmaxLastStep(logits: Tensor): number { + private argmaxLastStep(logits: Tensor): { token: number; gap: number } { const dims = logits.dims const classes = dims[dims.length - 1]! const data = logits.data as Float32Array | BigInt64Array const base = (dims[dims.length - 2]! - 1) * classes let best = 0 let bestVal = -Infinity + let secondVal = -Infinity for (let c = 0; c < classes; c++) { const v = typeof data[base + c] === "bigint" ? Number(data[base + c]) : (data[base + c] as number) if (v > bestVal) { + secondVal = bestVal bestVal = v best = c + } else if (v > secondVal) { + secondVal = v } } - return best + return { token: best, gap: bestVal - secondVal } } - private async greedyKv(hidden: Tensor, maxLen: number): Promise { + private async greedyKv( + hidden: Tensor, + maxLen: number, + opts: DecodeOptions = {}, + ): Promise { const generated: number[] = [] let current = [PAD_ID] let present: ReadonlyMap | undefined @@ -161,9 +181,12 @@ export class IMFModel { }, ...this.pastTensors(present), }) - const token = this.argmaxLastStep(outputs["logits"]!) + const { token, gap } = this.argmaxLastStep(outputs["logits"]!) if (token === EOS_ID) break generated.push(token) + opts.onToken?.(token, step) + opts.onConfidence?.(gap, step) + if (repetitionGuardCut(generated, decode(generated))) break present = new Map( this.pasts.map((spec) => [spec.name, outputs[spec.name.replace("past_", "present_")]!]), ) @@ -172,7 +195,11 @@ export class IMFModel { return generated } - private async greedyPlain(hidden: Tensor, maxLen: number): Promise { + private async greedyPlain( + hidden: Tensor, + maxLen: number, + opts: DecodeOptions = {}, + ): Promise { const generated: number[] = [] const decoderIds: number[] = [PAD_ID] for (let step = 0; step < maxLen; step++) { @@ -190,10 +217,13 @@ export class IMFModel { dims: hidden.dims, }, }) - const token = this.argmaxLastStep(outputs["logits"]!) + const { token, gap } = this.argmaxLastStep(outputs["logits"]!) if (token === EOS_ID) break generated.push(token) decoderIds.push(token) + opts.onToken?.(token, step) + opts.onConfidence?.(gap, step) + if (repetitionGuardCut(generated, decode(generated))) break } return generated } diff --git a/src/ml/imf/registry.ts b/src/ml/imf/registry.ts index 4471e41..9b893f3 100644 --- a/src/ml/imf/registry.ts +++ b/src/ml/imf/registry.ts @@ -60,7 +60,6 @@ async function fetchHttpBytes(url: string): Promise { } async function fetchIndex(source: string): Promise> { - let text: string if (source.startsWith("http://") || source.startsWith("https://")) { const bytes = await fetchHttpBytes(source) const sidecarRes = await fetch(`${source}.sha256`) @@ -79,10 +78,13 @@ async function fetchIndex(source: string): Promise> { `index sha256 mismatch: got ${actual}, sidecar says ${expected.toLowerCase()}`, ) } - text = new TextDecoder().decode(bytes) - } else { - text = new TextDecoder().decode((await nodeFs())!.readFileSync(source)) + return fetchIndexFromText(new TextDecoder().decode(bytes)) } + const local = new TextDecoder().decode((await nodeFs())!.readFileSync(source)) + return fetchIndexFromText(local) +} + +function fetchIndexFromText(text: string): Record { const raw = loadYaml(text) as { version?: number models?: Record> @@ -135,6 +137,82 @@ async function fetchParts( } } +function indexCacheKey(source: string): string { + return source +} + +async function cacheIndexForOffline(source: string): Promise { + if (typeof caches === "undefined") return + try { + const res = await fetch(source) + if (res.ok) await (await caches.open(CACHE_NAME)).put(indexCacheKey(source), res.clone()) + } catch { + /* best-effort */ + } +} + +async function readCachedIndex(source: string): Promise | undefined> { + const cache = await browserCache() + if (!cache) return undefined + const hit = await cache.match(indexCacheKey(source)) + if (!hit) return undefined + try { + return await fetchIndexFromText( + new TextDecoder().decode(new Uint8Array(await hit.arrayBuffer())), + ) + } catch { + return undefined + } +} + +/** Drop cached zip entries whose (id, filename) no longer matches the + * current index — sha changes leave orphans otherwise (05). */ +async function evictStaleEntries( + cache: BrowserCache, + entries: Record, +): Promise { + try { + const keys = await (cache as unknown as { keys(): Promise }).keys() + const valid = new Set(Object.entries(entries).map(([id, e]) => cacheKey(id, e.filename))) + for (const key of keys) { + const url = String((key as { url?: string }).url ?? key) + if (url.startsWith("https://imf.interscript.org/cache/") && !valid.has(url)) { + await cache.delete(url) + } + } + } catch { + /* best-effort */ + } +} + +/** Fetch with progress via a streamed body (TODO.client-work 03). */ +async function fetchWithProgress( + url: string, + onProgress?: (fraction: number, bytes: number) => void, +): Promise { + if (!onProgress) return new Uint8Array(await (await fetch(url)).arrayBuffer()) + const res = await fetch(url) + if (!res.ok || !res.body) throw new RegistryError(`fetch failed: ${url} -> ${res.status}`) + const total = Number(res.headers.get("content-length") ?? 0) + const reader = res.body.getReader() + const chunks: Uint8Array[] = [] + let received = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + received += value.length + onProgress(total > 0 ? received / total : 0, received) + } + const out = new Uint8Array(received) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + export interface ResolvedZip { bytes: Uint8Array path?: string @@ -164,9 +242,27 @@ async function browserCache(): Promise { return await caches.open(CACHE_NAME) } -export async function resolve(modelId: string, indexUrl?: string): Promise { +export interface ResolveOptions { + /** 0..1 download progress (TODO.client-work 03) */ + readonly onProgress?: (fraction: number, bytes: number) => void +} + +export async function resolve( + modelId: string, + indexUrl?: string, + opts: ResolveOptions = {}, +): Promise { const source = indexUrl ?? process.env["SECRYST_INDEX"] ?? DEFAULT_INDEX_URL - const entries = await fetchIndex(source) + let entries: Record + try { + entries = await fetchIndex(source) + await cacheIndexForOffline(source) + } catch (err) { + // offline-first (05): fall back to the cached index copy + const cached = await readCachedIndex(source) + if (!cached) throw err + entries = cached + } const entry = entries[modelId] if (!entry) { throw new RegistryError( @@ -189,6 +285,7 @@ export async function resolve(modelId: string, indexUrl?: string): Promise (typeof c === "string" ? { id: c } : c)) + if (entries.length === 0) throw new Error("no tier candidates") + const roomy = deviceMemoryGB === undefined ? true : deviceMemoryGB >= 4 + const int8 = entries.find((e) => /-int8$/.test(e.id)) + if (roomy && int8) return int8.id + const bySize = [...entries].sort((a, b) => (a.size ?? 0) - (b.size ?? 0)) + return bySize[0]!.id +} + +/** Fire a tiny encode+decode once so the first real request doesn't + * pay runtime warm-up (wasm compile, allocator growth). */ +export async function warmUp(model: { + translate(input: string, maxLen?: number): Promise +}): Promise { + await model.translate("ك", 8).catch(() => undefined) +} diff --git a/test/client-work.test.ts b/test/client-work.test.ts new file mode 100644 index 0000000..1dd8e14 --- /dev/null +++ b/test/client-work.test.ts @@ -0,0 +1,133 @@ +/** Client-work guards + normalization (TODO.client-work 01/02). */ + +import { describe, expect, it } from "vitest" +import { decode as dec, encode } from "../src/ml/imf/tokens.js" +import { normalizeArabicInput, repetitionGuardCut } from "../src/ml/imf/guards.js" + +describe("input normalization", () => { + it("strips pre-existing haraqat", () => { + expect(normalizeArabicInput("كِتَابٌ")).toBe("كتاب") + }) + it("decomposes lam-alef presentation ligatures", () => { + expect(normalizeArabicInput("ﻻ")).toBe("لا") + expect(normalizeArabicInput("ﻷ")).toBe("لأ") + }) + it("leaves non-Arabic text untouched", () => { + expect(normalizeArabicInput("สวัสดี 123")).toBe("สวัสดี 123") + }) +}) + +describe("repetition guards (ported from the Python harness)", () => { + it("cuts a verbatim token cycle well before maxLen", () => { + const cycle = [10, 11, 10, 11, 10, 11] + const tokens: number[] = [] + for (let i = 0; i < 5000; i++) { + tokens.push(cycle[i % cycle.length]!) + if (repetitionGuardCut(tokens, dec(tokens))) break + } + expect(tokens.length).toBeLessThan(200) + expect(tokens.slice(0, 4)).toEqual([10, 11, 10, 11]) + }) + it("cuts a phrase echo with rotating separators (decoded-text guard)", () => { + const phrase = encode("كَتَابٍ") + const seps = ['"', " ", "\n", ":"].map((s) => encode(s)[0]!) + const tokens: number[] = [] + outer: for (let round = 0; round < 100; round++) { + for (const t of phrase) { + tokens.push(t) + if (repetitionGuardCut(tokens, dec(tokens))) break outer + } + tokens.push(seps[round % seps.length]!) + if (repetitionGuardCut(tokens, dec(tokens))) break + } + expect(tokens.filter((t) => t === phrase[0]).length).toBeLessThan(40) + }) + it("normal generation never trips the guard", () => { + const tokens = [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33] + expect(repetitionGuardCut(tokens, dec(tokens))).toBe(false) + }) +}) + +import { pickTier } from "../src/ml/imf/tiers.js" + +describe("tier auto-selection (04)", () => { + it("desktop memory prefers the full int8 tier", () => { + expect(pickTier(["ara-diac-small-2.0", "ara-diac-small-2.0-int8"], 8)).toBe( + "ara-diac-small-2.0-int8", + ) + }) + it("constrained devices fall back to the smallest artifact", () => { + expect( + pickTier( + [ + { id: "ara-diac-small-2.0", size: 1.4e9 }, + { id: "ara-diac-small-2.0-int8", size: 2.6e8 }, + ], + 2, + ), + ).toBe("ara-diac-small-2.0-int8") + expect( + pickTier( + [ + { id: "layerdrop-int8", size: 1.9e8 }, + { id: "layerdrop-int4", size: 9.5e7 }, + ], + 2, + ), + ).toBe("layerdrop-int4") + }) +}) + +import { createServer } from "node:http" + +describe("download progress (03)", () => { + it("resolve reports monotonic 0..1 progress with byte counts", async () => { + const { imf } = await import("../src/ml/index.js") + const { createHash } = await import("node:crypto") + const zip = Buffer.from("hello-zip") + const sha = createHash("sha256").update(zip).digest("hex") + let indexBody = "" + const server = createServer((req, res) => { + if (req.url === "/index.yaml") { + res.writeHead(200) + res.end(indexBody) + return + } + if (req.url === "/index.yaml.sha256") { + res.writeHead(200) + res.end(`${createHash("sha256").update(indexBody).digest("hex")} index.yaml\n`) + return + } + if (req.url === "/tiny.zip") { + res.writeHead(200, { "content-length": String(zip.length) }) + res.end(zip) + return + } + res.writeHead(404) + res.end() + }) + await new Promise((r) => server.listen(0, "127.0.0.1", r)) + const port = (server.address() as { port: number }).port + const { mkdtempSync, rmSync } = await import("node:fs") + const { tmpdir } = await import("node:os") + const { join } = await import("node:path") + const cacheDir = mkdtempSync(join(tmpdir(), "progress-")) + process.env["SECRYST_CACHE"] = cacheDir + indexBody = `version: 1\nmodels:\n tiny-1.0:\n filename: tiny.zip\n url: http://127.0.0.1:${port}/tiny.zip\n sha256: ${sha}\n` + const events: Array<[number, number]> = [] + try { + const resolved = await imf.resolve("tiny-1.0", `http://127.0.0.1:${port}/index.yaml`, { + onProgress: (fraction, bytes) => events.push([fraction, bytes]), + }) + expect([...resolved.bytes]).toEqual([...zip]) + expect(events.length).toBeGreaterThan(0) + expect(events[events.length - 1]![0]).toBe(1) + const fracs = events.map((e) => e[0]) + for (let i = 1; i < fracs.length; i++) expect(fracs[i]).toBeGreaterThanOrEqual(fracs[i - 1]!) + } finally { + delete process.env["SECRYST_CACHE"] + rmSync(cacheDir, { recursive: true, force: true }) + await new Promise((r) => server.close(() => r())) + } + }) +})