diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ee9e1e4..63396da 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1412,12 +1412,12 @@ CLI/browser inputs use versioned YAML or JSON and may reference: ONNX parsing extracts graph structure, shapes, dtypes, external-data extents, operator profiles, and runtime metadata. It does not infer measured throughput. -Revision-2 `inference-sim/onnx-model` manifests bind the ONNX protobuf and each -referenced external-data file by SHA-256, retain canonical initializer names, -dtypes, dimensions, logical/storage extents, and sorted operator counts, and -normalize only explicitly published architecture fields. External paths must -remain inside the model package and every referenced range must fit the actual -sidecar. Sidecars are streamed for hashing rather than loaded into memory. +Revision-2 `inference-sim/onnx-model` manifests bind the ONNX protobuf by +SHA-256, retain canonical initializer names, dtypes, dimensions, +logical/storage extents, external-data location metadata, and sorted operator +counts, and normalize only explicitly published architecture fields. Sidecar +files are never opened, sized, or hashed because simulation only needs +initializer metadata carried by the ONNX graph. Profile readiness lists every missing architecture field; tensor-name pattern matching is not accepted as architecture evidence. For MoE, readiness also requires active expert count plus routed and shared expert bytes per layer; @@ -1776,9 +1776,8 @@ The `onnx-inspect` command decodes standard ONNX protobufs through the current ONNX 1.20 schema and emits the shared revision-2 model manifest. Optional onnx-genai fixture manifests, legacy `genai_config.json`, and portable inference metadata are normalized without changing their evidence strength. -Malformed protobufs, sparse/segmented initializers, unsafe external paths, -truncated sidecars, duplicate identities, inconsistent totals, stale -revisions, and fingerprint mismatches fail closed. +Malformed protobufs, sparse/segmented initializers, duplicate identities, +inconsistent totals, stale revisions, and fingerprint mismatches fail closed. The `onnx-static` command resolves a ready manifest into a `ModelProfile` and runs the shared static analyzer. Initializer byte and element totals remain exact inventory; dominant weight dtype selection, non-expert per-layer @@ -1791,8 +1790,10 @@ The React workbench accepts that same revision-2 JSON manifest for direct static-analysis sessions. It also accepts a local model directory or selected package files. Local package import stays entirely in the static application: a dedicated browser Worker decodes ONNX protobufs with the same shared -inspector used by the CLI, validates external-data paths and extents, hashes -sidecars incrementally, and parses portable `inference_metadata.yaml|json`. +inspector used by the CLI, preserves external-data metadata without opening +sidecars, and parses portable `inference_metadata.yaml|json`. +External-data files are ignored because the ONNX protobuf carries the +initializer metadata required for simulation. The normalized metadata preserves multi-model components, dataflow edges, pipeline stages, device preferences, hardware requirements, and exact speculative evidence. Known fields fail closed while unknown schema-extension diff --git a/packages/cli/src/onnx-reader.ts b/packages/cli/src/onnx-reader.ts index d88a2bb..a6e5d84 100644 --- a/packages/cli/src/onnx-reader.ts +++ b/packages/cli/src/onnx-reader.ts @@ -1,19 +1,14 @@ import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; -import { basename, dirname, resolve, sep } from "node:path"; +import { readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; import type { OnnxModelManifest } from "@inference-sim/core"; -import { - inspectOnnxModelBytes, - type OnnxExternalDataSource, -} from "@inference-sim/onnx-inspector"; +import { inspectOnnxModelBytes } from "@inference-sim/onnx-inspector"; export async function inspectOnnxModel( modelPath: string, metadata?: unknown, ): Promise { const resolvedModelPath = resolve(modelPath); - const modelDirectory = dirname(resolvedModelPath); return inspectOnnxModelBytes({ modelFileName: basename(modelPath), modelBytes: await readFile(resolvedModelPath), @@ -21,32 +16,5 @@ export async function inspectOnnxModel( sha256: async (bytes) => ( createHash("sha256").update(bytes).digest("hex") ), - resolveExternalData: async (location) => { - const filePath = resolve(modelDirectory, ...location.split("/")); - if ( - filePath !== modelDirectory - && !filePath.startsWith(`${modelDirectory}${sep}`) - ) { - throw new Error( - `external-data location escapes model directory: ${location}`, - ); - } - const fileStat = await stat(filePath); - if (!fileStat.isFile()) { - throw new Error(`external-data location is not a file: ${location}`); - } - return { - byteLength: fileStat.size, - sha256: () => sha256File(filePath), - } satisfies OnnxExternalDataSource; - }, }); } - -async function sha256File(path: string): Promise { - const hash = createHash("sha256"); - for await (const chunk of createReadStream(path)) { - hash.update(chunk as Buffer); - } - return hash.digest("hex"); -} diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index ebd6cfd..0c59a30 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -100,13 +100,11 @@ describe("CLI", () => { ); }); - it("extracts a deterministic ONNX manifest with verified external data", async () => { + it("extracts a deterministic ONNX manifest without reading external data", async () => { const directory = await mkdtemp(join(tmpdir(), "inference-sim-onnx-")); const modelPath = join(directory, "model.onnx"); - const weightsPath = join(directory, "model.onnx.data"); const metadataPath = join(directory, "manifest.json"); await writeFile(modelPath, tinyOnnxModel("model.onnx.data", 16)); - await writeFile(weightsPath, new Uint8Array(16).fill(7)); await writeFile(metadataPath, JSON.stringify({ architecture: "TinyCausalLM", vocab_size: 8, @@ -133,10 +131,7 @@ describe("CLI", () => { kind: string; graph: { nodeCount: number; operators: unknown[] }; totals: { externalInitializerBytes: number }; - externalDataFiles: Array<{ - location: string; - referencedByteLength: number; - }>; + externalDataFiles: unknown[]; profileReadiness: { ready: boolean; missingFields: string[] }; }; expect(manifest).toMatchObject({ @@ -146,12 +141,7 @@ describe("CLI", () => { profileReadiness: { ready: true, missingFields: [] }, }); expect(manifest.graph.operators).toHaveLength(1); - expect(manifest.externalDataFiles).toEqual([ - expect.objectContaining({ - location: "model.onnx.data", - referencedByteLength: 16, - }), - ]); + expect(manifest.externalDataFiles).toEqual([]); }); it("runs static analysis from an inspected ONNX package", async () => { @@ -160,10 +150,6 @@ describe("CLI", () => { const metadataPath = join(directory, "manifest.json"); const configPath = join(directory, "config.json"); await writeFile(modelPath, tinyOnnxModel("model.onnx.data", 16)); - await writeFile( - join(directory, "model.onnx.data"), - new Uint8Array(16).fill(3), - ); await writeFile(metadataPath, JSON.stringify({ architecture: "TinyCausalLM", vocab_size: 8, @@ -318,19 +304,21 @@ describe("CLI", () => { .toBe(true); }); - it("rejects unsafe or truncated ONNX external-data references", async () => { + it("preserves external-data references without opening sidecars", async () => { const directory = await mkdtemp(join(tmpdir(), "inference-sim-onnx-")); const modelPath = join(directory, "model.onnx"); await writeFile(modelPath, tinyOnnxModel("../weights.data", 16)); - const unsafe = captureIo(); - expect(await runCli(["onnx-inspect", modelPath], unsafe.io)).toBe(1); - expect(unsafe.stderr()).toContain("unsafe or missing external-data location"); - - await writeFile(modelPath, tinyOnnxModel("weights.data", 16)); await writeFile(join(directory, "weights.data"), new Uint8Array(8)); - const truncated = captureIo(); - expect(await runCli(["onnx-inspect", modelPath], truncated.io)).toBe(1); - expect(truncated.stderr()).toContain("external-data range exceeds"); + const capture = captureIo(); + expect(await runCli(["onnx-inspect", modelPath], capture.io)).toBe(0); + const manifest = JSON.parse(capture.stdout()) as { + initializers: Array<{ storage: { location?: string } }>; + externalDataFiles: unknown[]; + totals: { externalInitializerBytes: number }; + }; + expect(manifest.initializers[0]?.storage.location).toBe("../weights.data"); + expect(manifest.externalDataFiles).toEqual([]); + expect(manifest.totals.externalInitializerBytes).toBe(16); }); it("materializes a parameterized multi-GPU scenario target", async () => { diff --git a/packages/core/src/onnx-manifest.ts b/packages/core/src/onnx-manifest.ts index 5db0e70..828be0a 100644 --- a/packages/core/src/onnx-manifest.ts +++ b/packages/core/src/onnx-manifest.ts @@ -421,7 +421,7 @@ function parseInitializer( `${label} storage`, ); } else if (kind === "external") { - assertExactKeys( + assertAllowedKeys( storageRecord, ["kind", "byteLength", "location", "offset"], `${label} storage`, @@ -445,10 +445,14 @@ function parseInitializer( storageRecord.byteLength, `${label} storage byteLength`, ), - location: requireSafeRelativePath( - storageRecord.location, - `${label} external location`, - ), + ...(storageRecord.location === undefined + ? {} + : { + location: requireStringValue( + storageRecord.location, + `${label} external location`, + ), + }), offset: requireNonNegativeInteger( storageRecord.offset, `${label} external offset`, diff --git a/packages/core/tests/onnx-manifest.test.ts b/packages/core/tests/onnx-manifest.test.ts index 65fd70f..bded6fb 100644 --- a/packages/core/tests/onnx-manifest.test.ts +++ b/packages/core/tests/onnx-manifest.test.ts @@ -109,17 +109,24 @@ describe("ONNX model manifest", () => { .toThrow("totals do not match initializer inventory"); }); - it("rejects architecture readiness drift and unsafe external paths", () => { + it("rejects architecture readiness drift", () => { const incomplete = unsigned(); delete (incomplete.architecture as { headDimension?: number }).headDimension; expect(() => createOnnxModelManifest(incomplete)) .toThrow("profile readiness does not match architecture evidence"); + }); + it("preserves external initializer location metadata verbatim", () => { const unsafe = unsigned(); (unsafe.initializers[0].storage as { location: string }).location = "../weights.data"; - expect(() => createOnnxModelManifest(unsafe)) - .toThrow("must remain inside the model package"); + expect(createOnnxModelManifest(unsafe).initializers[0].storage.location) + .toBe("../weights.data"); + + const missing = unsigned(); + delete (missing.initializers[0].storage as { location?: string }).location; + expect(createOnnxModelManifest(missing).initializers[0].storage.location) + .toBeUndefined(); }); it("resolves a capacity-preserving model profile with explicit assumptions", () => { diff --git a/packages/onnx-inspector/src/index.ts b/packages/onnx-inspector/src/index.ts index 639823e..d1114fa 100644 --- a/packages/onnx-inspector/src/index.ts +++ b/packages/onnx-inspector/src/index.ts @@ -12,7 +12,6 @@ import { ONNX_MODEL_MANIFEST_REVISION, createOnnxModelManifest, type OnnxArchitectureEvidence, - type OnnxExternalDataFileManifest, type OnnxInitializerManifest, type OnnxModelManifest, type OnnxOperatorCount, @@ -20,19 +19,11 @@ import { export const MAX_ONNX_PROTO_BYTES = 512 * 1024 * 1024; -export interface OnnxExternalDataSource { - readonly byteLength: number; - readonly sha256: () => Promise; -} - export interface InspectOnnxModelInput { readonly modelFileName: string; readonly modelBytes: Uint8Array; readonly metadata?: unknown; readonly sha256: (bytes: Uint8Array) => Promise; - readonly resolveExternalData: ( - location: string, - ) => Promise; } export async function inspectOnnxModelBytes({ @@ -40,7 +31,6 @@ export async function inspectOnnxModelBytes({ modelBytes, metadata, sha256, - resolveExternalData, }: InspectOnnxModelInput): Promise { if (modelBytes.byteLength > MAX_ONNX_PROTO_BYTES) { throw new Error("ONNX protobuf exceeds the 512 MiB inspection limit"); @@ -71,10 +61,6 @@ export async function inspectOnnxModelBytes({ const initializers = initializerRecords.map(({ tensor, scopedName }) => ( inspectInitializer(tensor, scopedName) )); - const externalDataFiles = await inspectExternalDataFiles( - initializers, - resolveExternalData, - ); const operatorInventory: OnnxOperatorCount[] = [...operators.entries()] .map(([identity, count]) => { const separator = identity.indexOf("\0"); @@ -151,7 +137,7 @@ export async function inspectOnnxModelBytes({ operators: operatorInventory, }, initializers, - externalDataFiles, + externalDataFiles: [], architecture, totals, profileReadiness: { @@ -241,7 +227,6 @@ function inspectInitializer( tensor.externalData.map((entry) => [entry.key, entry.value]), ); if (tensor.dataLocation === 1 || tensor.externalData.length > 0) { - const location = safeExternalLocation(external.location, scopedName); const offset = parseExternalInteger( external.offset ?? "0", `${scopedName} external offset`, @@ -260,7 +245,9 @@ function inspectInitializer( logicalByteLength, storage: { kind: "external", - location, + ...(external.location === undefined + ? {} + : { location: external.location }), offset, byteLength, }, @@ -281,52 +268,6 @@ function inspectInitializer( }; } -async function inspectExternalDataFiles( - initializers: readonly OnnxInitializerManifest[], - resolveExternalData: ( - location: string, - ) => Promise, -): Promise { - const rangesByLocation = new Map>(); - for (const tensor of initializers) { - if (tensor.storage.kind !== "external") { - continue; - } - const location = tensor.storage.location!; - const start = tensor.storage.offset!; - const end = checkedAdd( - start, - tensor.storage.byteLength, - `${tensor.name} external extent`, - ); - const ranges = rangesByLocation.get(location) ?? []; - ranges.push([start, end]); - rangesByLocation.set(location, ranges); - } - const files: OnnxExternalDataFileManifest[] = []; - for (const location of [...rangesByLocation.keys()].sort()) { - const source = await resolveExternalData(location); - if (!Number.isSafeInteger(source.byteLength)) { - throw new Error(`external-data file is too large: ${location}`); - } - const ranges = rangesByLocation.get(location)!; - for (const [, end] of ranges) { - if (end > source.byteLength) { - throw new Error( - `external-data range exceeds ${location}: ${end} > ${source.byteLength}`, - ); - } - } - files.push({ - location, - byteLength: source.byteLength, - referencedByteLength: unionByteLength(ranges), - sha256: await source.sha256(), - }); - } - return files; -} - function normalizeArchitectureEvidence( metadata: unknown, ): OnnxArchitectureEvidence { @@ -496,22 +437,6 @@ function dataTypeBits(dataType: string): number { return bits; } -function safeExternalLocation(value: string | undefined, tensor: string): string { - if ( - value === undefined - || value.length === 0 - || value.startsWith("/") - || value.startsWith("\\") - || /^[A-Za-z]:/.test(value) - || value.split(/[\\/]/).some((segment) => segment === "..") - ) { - throw new Error( - `unsafe or missing external-data location for ${tensor}`, - ); - } - return value.replaceAll("\\", "/"); -} - function parseExternalInteger(value: string, label: string): number { if (!/^(0|[1-9]\d*)$/.test(value)) { throw new Error(`${label} must be an unsigned decimal integer`); @@ -557,31 +482,6 @@ function checkedAdd(left: number, right: number, label: string): number { return sum; } -function unionByteLength( - ranges: readonly (readonly [number, number])[], -): number { - const ordered = [...ranges].sort((left, right) => ( - left[0] - right[0] || left[1] - right[1] - )); - let total = 0; - let start = -1; - let end = -1; - for (const [nextStart, nextEnd] of ordered) { - if (nextStart > end) { - if (start >= 0) { - total = checkedAdd(total, end - start, "external referenced bytes"); - } - start = nextStart; - end = nextEnd; - } else { - end = Math.max(end, nextEnd); - } - } - return start < 0 - ? 0 - : checkedAdd(total, end - start, "external referenced bytes"); -} - function record(value: unknown, label: string): Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be an object`); diff --git a/packages/web/src/model-package-import.test.ts b/packages/web/src/model-package-import.test.ts index dc7d3fa..07612cc 100644 --- a/packages/web/src/model-package-import.test.ts +++ b/packages/web/src/model-package-import.test.ts @@ -7,7 +7,7 @@ import { } from "./model-package-import.js"; function tinyOnnxModel( - externalLocation: string, + externalLocation: string | undefined, externalLength: number, ): Uint8Array { return toBinary(ModelProtoSchema, fromJson(ModelProtoSchema, { @@ -25,7 +25,9 @@ function tinyOnnxModel( dims: ["2", "2"], dataType: 1, externalData: [ - { key: "location", value: externalLocation }, + ...(externalLocation === undefined + ? [] + : [{ key: "location", value: externalLocation }]), { key: "offset", value: "0" }, { key: "length", value: String(externalLength) }, ], @@ -52,12 +54,11 @@ function packageFile( size: blob.size, webkitRelativePath: path, arrayBuffer: () => blob.arrayBuffer(), - stream: () => blob.stream(), }; } describe("browser model package import", () => { - it("parses local protobufs, pipeline metadata, and external sidecars", async () => { + it("parses local protobufs and pipeline metadata without reading external sidecars", async () => { const metadata = ` pipeline: models: @@ -77,7 +78,12 @@ speculative: "model/decoder.onnx", tinyOnnxModel("decoder.onnx.data", 16), ), - packageFile("model/decoder.onnx.data", new Uint8Array(16).fill(7)), + { + ...packageFile("model/decoder.onnx.data", new Uint8Array(16).fill(7)), + arrayBuffer: () => { + throw new Error("external data files must not be read"); + }, + }, ]); expect(result.metadata.pipelineStrategy).toBe("autoregressive"); @@ -91,16 +97,10 @@ speculative: totals: { externalInitializerBytes: 16 }, }, }); - expect(result.models[0]!.manifest.externalDataFiles[0]).toMatchObject({ - location: "decoder.onnx.data", - byteLength: 16, - referencedByteLength: 16, - }); - expect(result.models[0]!.manifest.externalDataFiles[0]!.sha256) - .toMatch(/^[0-9a-f]{64}$/); + expect(result.models[0]!.manifest.externalDataFiles).toEqual([]); }); - it("rejects missing model components and sidecars", async () => { + it("rejects missing model components", async () => { await expect(inspectBrowserModelPackage([ packageFile( "model/inference_metadata.json", @@ -118,13 +118,93 @@ speculative: tinyOnnxModel("decoder.onnx.data", 16), ), ])).rejects.toThrow("is missing missing.onnx"); + }); - await expect(inspectBrowserModelPackage([ + it("parses an ONNX model without its external data file", async () => { + const result = await inspectBrowserModelPackage([ packageFile( "model/decoder.onnx", tinyOnnxModel("decoder.onnx.data", 16), ), - ])).rejects.toThrow("references missing external data"); + ]); + + expect(result.models[0]!.manifest).toMatchObject({ + initializers: [{ + storage: { + kind: "external", + location: "decoder.onnx.data", + byteLength: 16, + }, + }], + externalDataFiles: [], + architecture: { source: "none" }, + totals: { externalInitializerBytes: 16 }, + }); + }); + + it("preserves relative external data paths without reading sidecars", async () => { + const result = await inspectBrowserModelPackage([ + packageFile( + "model/decoder.onnx", + tinyOnnxModel("./decoder.onnx.data", 16), + ), + { + ...packageFile("model/decoder.onnx.data", new Uint8Array(16)), + arrayBuffer: () => { + throw new Error("external data files must not be read"); + }, + }, + ]); + + expect(result.models[0]!.manifest).toMatchObject({ + initializers: [{ + storage: { + location: "./decoder.onnx.data", + byteLength: 16, + }, + }], + externalDataFiles: [], + }); + }); + + it("preserves unsafe-looking external data paths without resolving them", async () => { + const result = await inspectBrowserModelPackage([ + packageFile( + "model/decoder.onnx", + tinyOnnxModel("../decoder.onnx.data", 16), + ), + ]); + + expect(result.models[0]!.manifest).toMatchObject({ + initializers: [{ + storage: { + location: "../decoder.onnx.data", + byteLength: 16, + }, + }], + externalDataFiles: [], + }); + }); + + it("parses external initializers without location metadata", async () => { + const result = await inspectBrowserModelPackage([ + packageFile( + "model/decoder.onnx", + tinyOnnxModel(undefined, 16), + ), + ]); + + expect(result.models[0]!.manifest).toMatchObject({ + initializers: [{ + storage: { + kind: "external", + byteLength: 16, + }, + }], + externalDataFiles: [], + }); + expect(result.models[0]!.manifest.initializers[0]?.storage.location) + .toBeUndefined(); }); it("rejects ambiguous metadata roots", async () => { diff --git a/packages/web/src/model-package-import.ts b/packages/web/src/model-package-import.ts index 29392f6..96e2a10 100644 --- a/packages/web/src/model-package-import.ts +++ b/packages/web/src/model-package-import.ts @@ -20,7 +20,6 @@ export interface BrowserPackageFile { readonly size: number; readonly webkitRelativePath?: string; readonly arrayBuffer: () => Promise; - readonly stream: () => ReadableStream; } export interface ImportedOnnxModel { @@ -95,25 +94,11 @@ export async function inspectBrowserModelPackage( throw new Error(`${modelPath} exceeds the 512 MiB ONNX protobuf limit`); } const modelBytes = new Uint8Array(await file.arrayBuffer()); - const modelDirectory = parentPath(modelPath); const manifest = await inspectOnnxModelBytes({ modelFileName: modelPath, modelBytes, - metadata: metadataValue, + metadata: metadataEntry === undefined ? undefined : metadataValue, sha256: async (bytes) => bytesToHex(sha256(bytes)), - resolveExternalData: async (location) => { - const externalPath = resolvePackagePath(modelDirectory, location); - const external = files.get(externalPath); - if (external === undefined) { - throw new Error( - `${modelPath} references missing external data ${externalPath}`, - ); - } - return { - byteLength: external.size, - sha256: () => sha256Stream(external), - }; - }, }); models.push({ fileName: modelPath, @@ -211,34 +196,6 @@ async function parseMetadataFile( } } -async function sha256Stream(file: BrowserPackageFile): Promise { - const hash = sha256.create(); - const reader = file.stream().getReader(); - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - return bytesToHex(hash.digest()); - } - hash.update(value); - } - } finally { - reader.releaseLock(); - } -} - -function resolvePackagePath(directory: string, location: string): string { - const normalizedLocation = normalizeRelativePath(location); - return directory.length === 0 - ? normalizedLocation - : `${directory}/${normalizedLocation}`; -} - -function parentPath(path: string): string { - const separator = path.lastIndexOf("/"); - return separator < 0 ? "" : path.slice(0, separator); -} - function normalizeSelectionPath(path: string): string { const normalized = path.replaceAll("\\", "/"); if (