diff --git a/package.json b/package.json index a7a453e..ecf0df2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interscript", - "version": "3.3.0", + "version": "4.0.0", "description": "Interscript TypeScript runtime — interoperable script conversion", "type": "module", "main": "./dist/index.js", diff --git a/src/ml/index.ts b/src/ml/index.ts index f252748..e0f403f 100644 --- a/src/ml/index.ts +++ b/src/ml/index.ts @@ -32,35 +32,6 @@ export { registerModel, loadModel, registeredKinds, resetModels } from "./regist export { createSession, detectBackend } from "./session/index.js" -/** - * @deprecated CDN-base override for the deprecated manifest-based - * provisioner. IMF models resolve through the GitHub Releases - * index instead. - */ -export { setModelBase, getModelBase } from "./provision/base.js" - -/** - * @deprecated The manifest-based provisioner (loose .onnx + sidecars - * behind a version→URL manifest) is superseded by the IMF registry: - * `import { imf } from "interscript/ml"` → `imf.resolve("")` - * (GitHub Releases index, sha256-verified zips). Kept for explicit-URL - * provisioning and inline-manifest (test/air-gapped) use; no manifest - * is published anymore. - */ -export { - loadManifest, - resolveManifestEntry, - artifactUrls, - sidecarFilenames, - setInlineManifest, - setManifestUrl, - type AssetVariant, - /** @deprecated — see the block comment above */ - type Manifest, - /** @deprecated — see the block comment above */ - type ManifestModelEntry, -} from "./provision/manifest.js" - /** * The IMF v1 registry — models.yaml resolution over GitHub Releases * (sha256-sidecar-verified). This is the canonical way to load models diff --git a/src/ml/provision/base.ts b/src/ml/provision/base.ts deleted file mode 100644 index 6d5419d..0000000 --- a/src/ml/provision/base.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Base URL for model artifacts. Owned here so every provisioner - * (HTTP fetcher, manifest loader, future IPFS/Bittorrent) reads - * from the same source. No circular deps. - * - * Default points at the jsDelivr mirror of `interscript/ml-models` - * — works without npm install, edge-cached globally. - * - * Override at runtime with `setModelBase()` for self-hosted mirrors, - * air-gapped envs, or staging deployments. - */ - -let modelBase = "https://cdn.jsdelivr.net/gh/interscript/ml-models@main/npm/models" - -export function setModelBase(url: string): void { - modelBase = url.replace(/\/$/, "") -} - -export function getModelBase(): string { - return modelBase -} diff --git a/src/ml/provision/index.ts b/src/ml/provision/index.ts index 42cc38b..a84a513 100644 --- a/src/ml/provision/index.ts +++ b/src/ml/provision/index.ts @@ -6,23 +6,15 @@ * browsers); falls back to filesystem reads in Node when given a * `file:` or relative URL. * - * The manifest (version → URL mapping) lives in `./manifest.ts`. - * The base URL (CDN mirror override) lives in `./base.ts`. - * - * Adding a new provision source (e.g. IPFS, BitTorrent) = adding a - * new provisioner file. Existing code never changes (OCP). + * The manifest-based resolution layer was removed in 4.0.0: models + * resolve through the IMF registry (`imf`, GitHub Releases index, + * sha256-verified) or an explicit `url` on the ModelRef. */ import type { ModelArtifacts, ModelRef } from "../types.js" import type { InferenceSession } from "../session/index.js" import { createSession } from "../session/index.js" -import { - artifactUrls, - resolveManifestEntry, - sidecarFilenames, - type AssetFormat, - type AssetVariant, -} from "./manifest.js" +import type { AssetFormat, AssetVariant } from "./types.js" export interface ProvisionedModel { readonly session: InferenceSession @@ -61,10 +53,9 @@ export interface ProvisionOptions { } /** - * Provision a model from the manifest. Resolves the task version, - * downloads the model file from the CDN (falls back to GitHub - * Releases), opens an inference session, and fetches sidecar - * artifacts (vocab, config, checksum) in parallel. + * Provision a model from an explicit URL. Downloads the model file, + * opens an inference session, and returns it with any artifacts the + * caller fetches separately. * * In Node, can read from the filesystem if `url` starts with `file:` * or is a relative path. @@ -73,35 +64,19 @@ export async function provisionModel( ref: ModelRef, opts: ProvisionOptions = {}, ): Promise { - const variant = opts.variant ?? "q8" const format = opts.format ?? "onnx" const webgpu = opts.webgpu ?? true - const modelUrl = ref.url ?? (await resolveModelUrl(ref, variant, format)) - - const modelBuffer = await fetchBytesWithFallback(modelUrl) + if (!ref.url) { + throw new Error( + `No url on ModelRef kind=${ref.kind} id=${ref.id}. Load models via ` + + `\`import { imf } from "interscript/ml"\` and \`imf.resolve("${ref.id}")\` ` + + `(GitHub Releases index, sha256-verified), or pass an explicit \`url\` on the ModelRef.`, + ) + } - const sidecars = await resolveSidecarUrls(ref, variant, format) + const modelBuffer = await fetchBytesWithFallback(ref.url) const artifacts: Record = {} - await Promise.all( - sidecars.map(async ({ filename, url }) => { - try { - const bytes = await fetchBytesWithFallback(url) - if ( - filename.endsWith(".json") || - filename.endsWith(".yaml") || - filename.endsWith(".yml") || - filename.endsWith(".sha256") - ) { - artifacts[filename] = new TextDecoder().decode(bytes) - } else { - artifacts[filename] = bytes - } - } catch { - // Sidecars are optional; missing ones are skipped. - } - }), - ) const sessionOpts: Record = { webgpu } if (format === "tflite") { @@ -114,42 +89,6 @@ export async function provisionModel( return { session, artifacts } } -async function resolveModelUrl( - ref: ModelRef, - variant: AssetVariant, - format: AssetFormat, -): Promise { - const entry = await resolveManifestEntry(ref.kind, ref.id) - if (!entry) { - throw new Error( - `No manifest entry for kind=${ref.kind} id=${ref.id}. The manifest-based ` + - `provisioner is deprecated; load models via \`import { imf } from "interscript/ml"\` ` + - `and \`imf.resolve("${ref.id}")\` (GitHub Releases index, sha256-verified), ` + - `or pass an explicit \`url\` on the ModelRef.`, - ) - } - const { primary } = artifactUrls(entry, variant, format) - return primary -} - -async function resolveSidecarUrls( - ref: ModelRef, - variant: AssetVariant, - format: AssetFormat, -): Promise { - // If the caller provided an explicit URL, skip manifest-based sidecar - // resolution — they've taken control of provisioning. - if (ref.url) return [] - const entry = await resolveManifestEntry(ref.kind, ref.id) - if (!entry) return [] - const { primary, assetName } = artifactUrls(entry, variant, format) - const base = primary.slice(0, primary.lastIndexOf("/") + 1) - return sidecarFilenames(entry, variant, format).map((filename) => ({ - filename, - url: `${base}${filename.startsWith(assetName) ? filename : filename}`, - })) -} - /** * Fetch bytes from a URL. Tries the URL as-given; if it's the CDN * primary and fails, the caller can supply a fallback URL via the diff --git a/src/ml/provision/manifest.ts b/src/ml/provision/manifest.ts deleted file mode 100644 index 45f6aaf..0000000 --- a/src/ml/provision/manifest.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Manifest-driven model resolution. DEPRECATED — superseded by the IMF - * registry (`src/ml/imf/`, published at `imf` from "interscript/ml"): - * models.yaml on GitHub Releases with sha256 sidecar verification. - * - * No manifest is published anymore (the `@interscript/models` npm - * package was never released; the CDN manifest.json URL is dead). - * This module remains for explicit-URL provisioning and - * inline-manifest (test / air-gapped) use. - * - * Resolution order for the manifest itself: - * 1. Programmatically injected via `setManifestUrl()` or - * `setInlineManifest()` (tests, air-gapped envs). - * 2. A remote JSON URL on the CDN (`manifest.json` next to the - * release assets). - */ - -import { getModelBase } from "./base.js" - -/** - * Per-task manifest entry. Matches `npm/models/manifest.json` shape. - */ -export interface ManifestModelEntry { - readonly status: "preview" | "stable" | "deprecated" - readonly version: string - readonly note?: string - readonly cdn_base: string - readonly github_base: string -} - -export interface Manifest { - readonly schema_version: number - readonly models: Readonly> -} - -/** - * Asset variants a release may ship. `fp32` (no suffix) is the - * default; q8 is the browser-optimized default. - */ -export type AssetVariant = "fp32" | "q8" | "q4" | "fp16" - -/** - * Serialization format. ONNX is the historical default; LiteRT (.tflite) - * is the 2026 alternative backed by Google's LiteRT.js runtime. - * - * The two formats are orthogonal to `AssetVariant` — both can be - * quantized to q8, both can be fp32. The runtime that loads the file - * is what differs. - */ -export type AssetFormat = "onnx" | "tflite" - -const VARIANT_SUFFIX: Record = { - fp32: "", - q8: "-q8", - q4: "-q4", - fp16: "-fp16", -} - -const FORMAT_EXTENSION: Record = { - onnx: ".onnx", - tflite: ".tflite", -} - -let cachedManifest: Manifest | null = null -let inlineManifest: Manifest | null = null -let manifestUrlOverride: string | null = null - -export function setInlineManifest(m: Manifest | null): void { - inlineManifest = m - cachedManifest = null -} - -export function setManifestUrl(url: string | null): void { - manifestUrlOverride = url - cachedManifest = null -} - -/** - * Default manifest URL — sits next to release assets on the CDN. - * Points at the jsDelivr mirror of the ml-models repo so it works - * with zero npm install. - */ -function defaultManifestUrl(): string { - return `${getModelBase()}/npm/models/manifest.json` -} - -/** - * Load the manifest. Cached after first call; bust the cache with - * `setInlineManifest()` or `setManifestUrl()`. - */ -export async function loadManifest(): Promise { - if (cachedManifest) return cachedManifest - if (inlineManifest) { - cachedManifest = inlineManifest - return cachedManifest - } - - const url = manifestUrlOverride ?? defaultManifestUrl() - const res = await fetch(url) - if (!res.ok) { - throw new Error( - `Failed to load model manifest from ${url}: ${res.status} ${res.statusText}. ` + - `The manifest-based provisioner is deprecated and no manifest is published; ` + - `load models via \`import { imf } from "interscript/ml"\` and \`imf.resolve("")\` ` + - `(GitHub Releases index, sha256-verified), or pass an explicit \`url\` on the ModelRef.`, - ) - } - const json = (await res.json()) as Manifest - cachedManifest = json - return json -} - -/** - * Resolve a `(kind, id)` ref to a manifest entry. The `id` field on - * a ModelRef carries the task name (e.g. "rababa_arabic"); the kind - * is redundant but kept for backwards compatibility. - * - * Returns `null` if the task isn't in the manifest. Callers decide - * whether that's an error or a "use bundled fallback" signal. - */ -export async function resolveManifestEntry( - kind: string, - id: string, -): Promise { - const manifest = await loadManifest() - const taskKey = id.startsWith(`${kind}_`) ? id : `${kind}_${id}` - return manifest.models[taskKey] ?? manifest.models[id] ?? null -} - -/** - * Build concrete artifact URLs for a model. Prefers the CDN base; - * GitHub Releases base is the fallback (slower, but no CDN cache). - * - * Both URLs use the same asset naming convention so a downloader - * can verify checksums identically against either source. - */ -export function artifactUrls( - entry: ManifestModelEntry, - variant: AssetVariant = "q8", - format: AssetFormat = "onnx", -): { primary: string; fallback: string; assetName: string } { - const assetName = assetNameFor(entry, variant, format) - const versionedCdn = entry.cdn_base.replace("{version}", entry.version) - const versionedGithub = entry.github_base.replace("{version}", entry.version) - return { - primary: `${versionedCdn}${assetName}`, - fallback: `${versionedGithub}${assetName}`, - assetName, - } -} - -/** - * Sidecar artifacts that ship with every release. These names match - * the release pipeline in `ml-models/.github/workflows/release.yml`. - */ -export function sidecarFilenames( - entry: ManifestModelEntry, - variant: AssetVariant = "q8", - format: AssetFormat = "onnx", -): readonly string[] { - const asset = assetNameFor(entry, variant, format) - return [`${asset}.sha256`, "vocab.json", "config.json"] -} - -function assetNameFor( - entry: ManifestModelEntry, - variant: AssetVariant, - format: AssetFormat = "onnx", -): string { - const task = taskNameFromBases(entry) - return `${task}${VARIANT_SUFFIX[variant]}${FORMAT_EXTENSION[format]}` -} - -/** - * Best-effort task name extraction from `cdn_base`/`github_base`. - * Both bases end with `-v{version}/`, so we slice off the - * trailing version segment. - */ -function taskNameFromBases(entry: ManifestModelEntry): string { - const lastSegment = entry.github_base.split("/").filter(Boolean).pop() ?? "" - const suffix = "-v{version}" - if (lastSegment.endsWith(suffix)) return lastSegment.slice(0, -suffix.length) - throw new Error(`Cannot extract task name from manifest entry. github_base=${entry.github_base}`) -} diff --git a/src/ml/provision/types.ts b/src/ml/provision/types.ts new file mode 100644 index 0000000..37112d4 --- /dev/null +++ b/src/ml/provision/types.ts @@ -0,0 +1,2 @@ +export type AssetVariant = "fp32" | "q8" | "q4" | "fp16" +export type AssetFormat = "onnx" | "tflite" diff --git a/src/runtime/interpreter.ts b/src/runtime/interpreter.ts index 5f97e39..3503b24 100644 --- a/src/runtime/interpreter.ts +++ b/src/runtime/interpreter.ts @@ -142,13 +142,17 @@ async function executeFuncallAsync( ctx.current = await rababa(ctx.current, rule.kwargs as { config?: string }) return } - // Other ASYNC_FUNCTIONS (secryst, etc.) still go through the generic - // ML registry. + // Other ASYNC_FUNCTIONS (secryst, etc.) resolve model ids through + // the IMF registry (GitHub Releases index, sha256-verified). if (ASYNC_FUNCTIONS.has(rule.name)) { - const { loadModel } = await import("../ml/index.js") + const { imf } = await import("../ml/index.js") const modelId = (rule.kwargs?.config ?? rule.kwargs?.model ?? "default") as string - const model = await loadModel({ kind: rule.name as "secryst", id: modelId }) - ctx.current = await model.transform(ctx.current) + const model = await imf.IMFModel.load(modelId) + try { + ctx.current = await model.translate(ctx.current) + } finally { + await model.dispose() + } return } // Fall back to the sync funcall executor for all other functions. diff --git a/test/imf.test.ts b/test/imf.test.ts index e938bec..1385a52 100644 --- a/test/imf.test.ts +++ b/test/imf.test.ts @@ -212,6 +212,57 @@ describe("registry", () => { await expect(resolve("nope", url)).rejects.toThrow(/index sha256/) }) }) + it("the public ./ml surface no longer exports the deprecated manifest APIs", async () => { + const ml = (await import("../src/ml/index.js")) as unknown as Record + for (const k of [ + "loadManifest", + "resolveManifestEntry", + "artifactUrls", + "sidecarFilenames", + "setInlineManifest", + "setManifestUrl", + "setModelBase", + "getModelBase", + ]) { + expect(ml[k], `ml.${k} should be gone`).toBeUndefined() + } + }) + + it("secryst funcall resolves model ids through the IMF registry", async () => { + const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs") + const { tmpdir } = await import("node:os") + const { join } = await import("node:path") + const { configure, reset, transliterateAsync } = await import("../src/index.js") + const dir = mkdtempSync(join(tmpdir(), "imf-funcall-")) + writeFileSync(join(dir, "models.yaml"), "version: 1\nmodels: {}\n") + process.env["SECRYST_INDEX"] = join(dir, "models.yaml") + try { + const { transliterateAsync: run } = await import("../src/index.js") + const map = { + schemaVersion: 1, + systemCode: "test-secryst-funcall", + dependencies: [], + stages: [ + { + kind: "stage", + name: "main", + rules: [{ kind: "funcall", name: "secryst", kwargs: { config: "nope-1.0" } }], + }, + ], + aliases: new Map(), + functions: new Map(), + } + configure({ strategies: [(code: string) => (code === map.systemCode ? map : undefined)] }) + await expect(run("test-secryst-funcall", "abc")).rejects.toThrow( + /unknown model id 'nope-1.0'/, + ) + } finally { + delete process.env["SECRYST_INDEX"] + reset() + rmSync(dir, { recursive: true, force: true }) + } + }) + it("the public ./ml surface re-exports the IMF registry", async () => { const ml = await import("../src/ml/index.js") const imf = (ml as { imf?: Record }).imf diff --git a/test/ml/provision.test.ts b/test/ml/provision.test.ts index 2b817d9..ff63be4 100644 --- a/test/ml/provision.test.ts +++ b/test/ml/provision.test.ts @@ -1,139 +1,16 @@ /** - * Specs for the manifest-driven model provisioner. - * - * No network: tests inject an inline manifest via `setInlineManifest()` - * and exercise URL derivation, variant selection, sidecar naming, and - * task-name extraction. + * Specs for the explicit-URL model provisioner. The manifest-based + * resolution layer was removed in 4.0.0 — models resolve through the + * IMF registry (`imf`) or an explicit `url` on the ModelRef. */ -import { describe, it, expect, beforeEach } from "vitest" -import { - artifactUrls, - sidecarFilenames, - setInlineManifest, - resolveManifestEntry, - type Manifest, - type ManifestModelEntry, -} from "../../src/ml/provision/manifest.js" +import { describe, it, expect } from "vitest" import { provisionModel } from "../../src/ml/provision/index.js" -const SAMPLE_ENTRY: ManifestModelEntry = { - status: "stable", - version: "0.1.0", - cdn_base: "https://cdn.jsdelivr.net/gh/interscript/ml-models@rababa_arabic-v{version}/", - github_base: - "https://github.com/interscript/ml-models/releases/download/rababa_arabic-v{version}/", -} - -const SAMPLE_MANIFEST: Manifest = { - schema_version: 1, - models: { - rababa_arabic: SAMPLE_ENTRY, - secryst_thai_ipa: { - status: "preview", - version: "0.0.0", - cdn_base: "https://cdn.jsdelivr.net/gh/interscript/ml-models@secryst_thai_ipa-v{version}/", - github_base: - "https://github.com/interscript/ml-models/releases/download/secryst_thai_ipa-v{version}/", - }, - }, -} - -describe("manifest provisioner", () => { - beforeEach(() => { - setInlineManifest(SAMPLE_MANIFEST) - }) - - describe("artifactUrls", () => { - it("defaults to q8 variant with -q8 suffix", () => { - const out = artifactUrls(SAMPLE_ENTRY) - expect(out.assetName).toBe("rababa_arabic-q8.onnx") - expect(out.primary).toContain("rababa_arabic-v0.1.0/") - expect(out.primary).toContain("rababa_arabic-q8.onnx") - }) - - it("fp32 variant has no suffix", () => { - const out = artifactUrls(SAMPLE_ENTRY, "fp32") - expect(out.assetName).toBe("rababa_arabic.onnx") - }) - - it("primary uses cdn_base, fallback uses github_base", () => { - const out = artifactUrls(SAMPLE_ENTRY, "q8") - expect(out.primary).toContain("cdn.jsdelivr.net") - expect(out.fallback).toContain("github.com/interscript/ml-models/releases") - }) - - it("substitutes version into both URLs", () => { - const out = artifactUrls(SAMPLE_ENTRY) - expect(out.primary).not.toContain("{version}") - expect(out.fallback).not.toContain("{version}") - expect(out.primary).toContain("v0.1.0") - }) - }) - - describe("sidecarFilenames", () => { - it("includes checksum sidecar with asset prefix", () => { - const sidecars = sidecarFilenames(SAMPLE_ENTRY, "q8") - expect(sidecars).toContain("rababa_arabic-q8.onnx.sha256") - expect(sidecars).toContain("vocab.json") - expect(sidecars).toContain("config.json") - }) - - it("fp32 sidecar matches fp32 asset name", () => { - const sidecars = sidecarFilenames(SAMPLE_ENTRY, "fp32") - expect(sidecars).toContain("rababa_arabic.onnx.sha256") - }) - }) - - describe("resolveManifestEntry", () => { - it("resolves by task name when kind prefix present", async () => { - const entry = await resolveManifestEntry("rababa", "rababa_arabic") - expect(entry?.version).toBe("0.1.0") - }) - - it("resolves by bare task id without kind prefix", async () => { - const entry = await resolveManifestEntry("rababa", "rababa_arabic") - expect(entry?.status).toBe("stable") - }) - - it("returns null for unknown task", async () => { - const entry = await resolveManifestEntry("rababa", "nope_not_here") - expect(entry).toBeNull() - }) - }) - - describe("task name extraction from bases", () => { - it("extracts from secryst entry", () => { - const out = artifactUrls(SAMPLE_MANIFEST.models["secryst_thai_ipa"]!) - expect(out.assetName).toBe("secryst_thai_ipa-q8.onnx") - }) - - it("throws on malformed github_base", () => { - const malformed: ManifestModelEntry = { - ...SAMPLE_ENTRY, - github_base: "https://example.com/no-version-pattern/", - } - expect(() => artifactUrls(malformed)).toThrow(/task name/) - }) - }) - - describe("manifest-less resolution errors point at the IMF registry", () => { - it("rejects with imf guidance when no manifest is published and no url given", async () => { - setInlineManifest(null) - try { - await expect(provisionModel({ kind: "secryst", id: "unresolved" })).rejects.toThrow( - /imf\.resolve\(/, - ) - } finally { - setInlineManifest(SAMPLE_MANIFEST) - } - }) - - it("rejects with imf guidance when the manifest has no matching entry", async () => { - setInlineManifest(SAMPLE_MANIFEST) // has no entry for this id - await expect(provisionModel({ kind: "secryst", id: "unresolved" })).rejects.toThrow( - /imf\.resolve\(/, - ) - }) +describe("provisioner", () => { + it("requires an explicit url and points at the IMF registry otherwise", async () => { + await expect(provisionModel({ kind: "secryst", id: "unresolved" })).rejects.toThrow( + /imf\.resolve\(/, + ) }) })