Skip to content

Commit f168d6f

Browse files
committed
feat!: client pipeline hardening — guards, normalization, progress, tiers, offline cache, confidence
01 decode guards ported from the Python harness (echo loops fixed client-side); 02 haraqat strip + ligature decomposition on by default (raw option); 03 onProgress download callback + onToken streaming; 04 pickTier/warmUp; 05 offline index cache + stale entry eviction; 06 onConfidence per-step top-2 logit gap. Breaking: default input normalization and guarded decode — v5.0.0.
1 parent 8b9408d commit f168d6f

13 files changed

Lines changed: 398 additions & 25 deletions

‎TODO.client-work/01-decode-guards.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
Port the Python repetition guards (token-window + decoded-text echo) to the TS greedy loop — clients currently ship the echo pathology
44

5-
Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.
5+
Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.

‎TODO.client-work/02-input-normalization.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
Strip pre-existing haraqat + unify Arabic presentation forms before inference; models train on stripped input
44

5-
Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.
5+
Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.

‎TODO.client-work/03-progress-and-streaming.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
Download-progress callback in imf.resolve + onToken streaming during decode — 250MB silent waits are the worst UX moment
44

5-
Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.
5+
Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.

‎TODO.client-work/04-tier-autoselect-warmup.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
int4-lite default on low-memory devices, session warm-up on load, WebGPU detect with graceful messaging
44

5-
Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.
5+
Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.

‎TODO.client-work/05-cache-eviction.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
Evict stale Cache API entries when the index sha changes; cache the index itself for offline-first resolution
44

5-
Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.
5+
Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.

‎TODO.client-work/06-confidence-margins.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
Expose per-step top-2 logit gap during decode as an onConfidence signal; the UI layer highlights low-confidence spans
44

5-
Status: implementing (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.
5+
Status: DONE (2026-09-01, v5.0.0) (2026-09-01). Acceptance: tests green, shipped in the runtime release, verified from the npm registry.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "interscript",
3-
"version": "4.0.0",
3+
"version": "5.0.0",
44
"description": "Interscript TypeScript runtime — interoperable script conversion",
55
"type": "module",
66
"main": "./dist/index.js",

‎src/ml/imf/guards.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Client-side decode guards + input normalization
3+
* (TODO.client-work 01/02), ported from the Python inference harness
4+
* where they were validated against live int8 student echo loops.
5+
*
6+
* The guards stop greedy generation when the output has entered a
7+
* cycle: flat-byte students echo phrases (with rotating punctuation,
8+
* so no verbatim token window repeats) until the generation cap.
9+
*/
10+
11+
import { decode } from "./tokens.js"
12+
13+
const HARAQAT = /[ً-ْٰٓ-ٕٖ-ٟۖ-ۭ]/g
14+
15+
const PRESENTATION_LIGATURES: ReadonlyArray<[string, string]> = [
16+
["\uFEF5", "\u0644\u0622"],
17+
["\uFEF6", "\u0644\u0622"],
18+
["\uFEF7", "\u0644\u0623"],
19+
["\uFEF8", "\u0644\u0623"],
20+
["\uFEF9", "\u0644\u0625"],
21+
["\uFEFA", "\u0644\u0625"],
22+
["\uFEFB", "\u0644\u0627"],
23+
["\uFEFC", "\u0644\u0627"],
24+
]
25+
26+
/** Normalize user input for models trained on stripped text: remove
27+
* pre-existing haraqat and decompose Arabic presentation-form
28+
* ligatures. Non-Arabic text passes through untouched. */
29+
export function normalizeArabicInput(text: string): string {
30+
let out = text.replace(HARAQAT, "")
31+
for (const [lig, expansion] of PRESENTATION_LIGATURES) {
32+
out = out.replaceAll(lig, expansion)
33+
}
34+
return out
35+
}
36+
37+
const TOKEN_WINDOW = 24
38+
const TEXT_SUFFIX = 16
39+
const TEXT_ECHOES = 3
40+
41+
/** True when generation has entered a repetition cycle and must stop.
42+
* Token guard: trailing 24-token window occurs verbatim earlier.
43+
* Text guard: trailing 16 decoded chars echo 3+ times (catches
44+
* phrase loops whose separators rotate). */
45+
export function repetitionGuardCut(tokens: readonly number[], decodedSoFar: string): boolean {
46+
if (tokens.length >= 2 * TOKEN_WINDOW) {
47+
const joined = tokens.join(",")
48+
const needle = tokens.slice(-TOKEN_WINDOW).join(",")
49+
if (joined.indexOf(needle) < joined.length - needle.length) return true
50+
}
51+
if (tokens.length % 8 === 0 && decodedSoFar.length >= TEXT_SUFFIX * TEXT_ECHOES) {
52+
const suffix = decodedSoFar.slice(-TEXT_SUFFIX)
53+
let count = 0
54+
let at = decodedSoFar.indexOf(suffix)
55+
while (at !== -1) {
56+
count++
57+
at = decodedSoFar.indexOf(suffix, at + 1)
58+
}
59+
if (count >= TEXT_ECHOES) return true
60+
}
61+
return false
62+
}
63+
64+
/** Decode helper mirroring the runtime loop's guard checks. */
65+
export function guardStep(tokens: number[]): boolean {
66+
return repetitionGuardCut(tokens, decode(tokens))
67+
}

‎src/ml/imf/index.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,13 @@
22

33
export { IMFModel } from "./model.js"
44
export { IMFError, parseManifest, verifyAndRead, type IMFManifest } from "./loader.js"
5-
export { resolve, DEFAULT_INDEX_URL, RegistryError, type IndexEntry } from "./registry.js"
5+
export {
6+
resolve,
7+
DEFAULT_INDEX_URL,
8+
RegistryError,
9+
type IndexEntry,
10+
type ResolveOptions,
11+
} from "./registry.js"
12+
export { pickTier, warmUp, type TierCandidate } from "./tiers.js"
13+
export { normalizeArabicInput, repetitionGuardCut } from "./guards.js"
614
export { encode, decode, BYTE_OFFSET, EOS_ID, PAD_ID, UNK_ID } from "./tokens.js"

‎src/ml/imf/model.ts‎

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createSession, type InferenceSession } from "../session/index.js"
1010
import type { Tensor } from "../types.js"
1111
import { verifyAndRead, parseManifest, type IMFManifest } from "./loader.js"
1212
import { resolve } from "./registry.js"
13+
import { normalizeArabicInput, repetitionGuardCut } from "./guards.js"
1314
import { EOS_ID, PAD_ID, decode, encode } from "./tokens.js"
1415

1516
interface InputMeta {
@@ -22,6 +23,15 @@ interface MetadataSession extends InferenceSession {
2223
readonly inputMetadata?: readonly InputMeta[]
2324
}
2425

26+
export interface DecodeOptions {
27+
/** skip input normalization (raw text) */
28+
readonly raw?: boolean
29+
/** streaming: called per emitted token (TODO.client-work 03) */
30+
readonly onToken?: (token: number, step: number) => void
31+
/** per-step top1-top2 logit gap; low gap = low confidence (06) */
32+
readonly onConfidence?: (gap: number, step: number) => void
33+
}
34+
2535
export class IMFModel {
2636
readonly id: string
2737
private readonly manifest: IMFManifest
@@ -62,13 +72,15 @@ export class IMFModel {
6272
return IMFModel.fromZipBytes(resolved.bytes)
6373
}
6474

65-
async translate(text: string, maxLen = 256): Promise<string> {
66-
const ids = encode(text)
75+
async translate(text: string, maxLen = 256, opts: DecodeOptions = {}): Promise<string> {
76+
// models train on stripped input: normalize by default (TODO.client-work 02)
77+
const normalized = opts.raw === true ? text : normalizeArabicInput(text)
78+
const ids = encode(normalized)
6779
if (ids.length === 1) return ""
6880
const hidden = await this.runEncoder(ids)
6981
const tokens = this.kv
70-
? await this.greedyKv(hidden, maxLen)
71-
: await this.greedyPlain(hidden, maxLen)
82+
? await this.greedyKv(hidden, maxLen, opts)
83+
: await this.greedyPlain(hidden, maxLen, opts)
7284
return decode(tokens)
7385
}
7486

@@ -123,25 +135,33 @@ export class IMFModel {
123135
return feeds
124136
}
125137

126-
private argmaxLastStep(logits: Tensor): number {
138+
private argmaxLastStep(logits: Tensor): { token: number; gap: number } {
127139
const dims = logits.dims
128140
const classes = dims[dims.length - 1]!
129141
const data = logits.data as Float32Array | BigInt64Array
130142
const base = (dims[dims.length - 2]! - 1) * classes
131143
let best = 0
132144
let bestVal = -Infinity
145+
let secondVal = -Infinity
133146
for (let c = 0; c < classes; c++) {
134147
const v =
135148
typeof data[base + c] === "bigint" ? Number(data[base + c]) : (data[base + c] as number)
136149
if (v > bestVal) {
150+
secondVal = bestVal
137151
bestVal = v
138152
best = c
153+
} else if (v > secondVal) {
154+
secondVal = v
139155
}
140156
}
141-
return best
157+
return { token: best, gap: bestVal - secondVal }
142158
}
143159

144-
private async greedyKv(hidden: Tensor, maxLen: number): Promise<number[]> {
160+
private async greedyKv(
161+
hidden: Tensor,
162+
maxLen: number,
163+
opts: DecodeOptions = {},
164+
): Promise<number[]> {
145165
const generated: number[] = []
146166
let current = [PAD_ID]
147167
let present: ReadonlyMap<string, Tensor> | undefined
@@ -161,9 +181,12 @@ export class IMFModel {
161181
},
162182
...this.pastTensors(present),
163183
})
164-
const token = this.argmaxLastStep(outputs["logits"]!)
184+
const { token, gap } = this.argmaxLastStep(outputs["logits"]!)
165185
if (token === EOS_ID) break
166186
generated.push(token)
187+
opts.onToken?.(token, step)
188+
opts.onConfidence?.(gap, step)
189+
if (repetitionGuardCut(generated, decode(generated))) break
167190
present = new Map(
168191
this.pasts.map((spec) => [spec.name, outputs[spec.name.replace("past_", "present_")]!]),
169192
)
@@ -172,7 +195,11 @@ export class IMFModel {
172195
return generated
173196
}
174197

175-
private async greedyPlain(hidden: Tensor, maxLen: number): Promise<number[]> {
198+
private async greedyPlain(
199+
hidden: Tensor,
200+
maxLen: number,
201+
opts: DecodeOptions = {},
202+
): Promise<number[]> {
176203
const generated: number[] = []
177204
const decoderIds: number[] = [PAD_ID]
178205
for (let step = 0; step < maxLen; step++) {
@@ -190,10 +217,13 @@ export class IMFModel {
190217
dims: hidden.dims,
191218
},
192219
})
193-
const token = this.argmaxLastStep(outputs["logits"]!)
220+
const { token, gap } = this.argmaxLastStep(outputs["logits"]!)
194221
if (token === EOS_ID) break
195222
generated.push(token)
196223
decoderIds.push(token)
224+
opts.onToken?.(token, step)
225+
opts.onConfidence?.(gap, step)
226+
if (repetitionGuardCut(generated, decode(generated))) break
197227
}
198228
return generated
199229
}

0 commit comments

Comments
 (0)