Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions TODO.client-work/01-decode-guards.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions TODO.client-work/02-input-normalization.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions TODO.client-work/03-progress-and-streaming.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions TODO.client-work/04-tier-autoselect-warmup.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions TODO.client-work/05-cache-eviction.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions TODO.client-work/06-confidence-margins.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions TODO.client-work/07-deployment-debt.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
67 changes: 67 additions & 0 deletions src/ml/imf/guards.ts
Original file line number Diff line number Diff line change
@@ -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))
}
10 changes: 9 additions & 1 deletion src/ml/imf/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
50 changes: 40 additions & 10 deletions src/ml/imf/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -62,13 +72,15 @@ export class IMFModel {
return IMFModel.fromZipBytes(resolved.bytes)
}

async translate(text: string, maxLen = 256): Promise<string> {
const ids = encode(text)
async translate(text: string, maxLen = 256, opts: DecodeOptions = {}): Promise<string> {
// 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)
}

Expand Down Expand Up @@ -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<number[]> {
private async greedyKv(
hidden: Tensor,
maxLen: number,
opts: DecodeOptions = {},
): Promise<number[]> {
const generated: number[] = []
let current = [PAD_ID]
let present: ReadonlyMap<string, Tensor> | undefined
Expand All @@ -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_")]!]),
)
Expand All @@ -172,7 +195,11 @@ export class IMFModel {
return generated
}

private async greedyPlain(hidden: Tensor, maxLen: number): Promise<number[]> {
private async greedyPlain(
hidden: Tensor,
maxLen: number,
opts: DecodeOptions = {},
): Promise<number[]> {
const generated: number[] = []
const decoderIds: number[] = [PAD_ID]
for (let step = 0; step < maxLen; step++) {
Expand All @@ -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
}
Expand Down
Loading
Loading