From 7e577469d910a5049f8ad6f60a59f29a2958168a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:53:33 +0000 Subject: [PATCH 1/6] Initial plan From 92438dee1c5e9fdcde84fab46032130d9b4420b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:56:40 +0000 Subject: [PATCH 2/6] Allow ONNX parsing without external data Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> --- docs/DESIGN.md | 11 ++++++---- packages/onnx-inspector/src/index.ts | 7 +++++-- packages/web/src/model-package-import.test.ts | 20 ++++++++++++++++--- packages/web/src/model-package-import.ts | 4 +--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ee9e1e4..3aad04f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1413,11 +1413,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, +supplied 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. +remain inside the model package; when a sidecar is supplied, every referenced +range must fit it. Sidecars are streamed for hashing rather than loaded into +memory and may be omitted when importing a model for simulation. 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; @@ -1792,7 +1793,9 @@ 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`. +supplied sidecars incrementally, and parses portable +`inference_metadata.yaml|json`. External-data files are optional 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/onnx-inspector/src/index.ts b/packages/onnx-inspector/src/index.ts index 639823e..050db7d 100644 --- a/packages/onnx-inspector/src/index.ts +++ b/packages/onnx-inspector/src/index.ts @@ -32,7 +32,7 @@ export interface InspectOnnxModelInput { readonly sha256: (bytes: Uint8Array) => Promise; readonly resolveExternalData: ( location: string, - ) => Promise; + ) => Promise; } export async function inspectOnnxModelBytes({ @@ -285,7 +285,7 @@ async function inspectExternalDataFiles( initializers: readonly OnnxInitializerManifest[], resolveExternalData: ( location: string, - ) => Promise, + ) => Promise, ): Promise { const rangesByLocation = new Map>(); for (const tensor of initializers) { @@ -306,6 +306,9 @@ async function inspectExternalDataFiles( const files: OnnxExternalDataFileManifest[] = []; for (const location of [...rangesByLocation.keys()].sort()) { const source = await resolveExternalData(location); + if (source === undefined) { + continue; + } if (!Number.isSafeInteger(source.byteLength)) { throw new Error(`external-data file is too large: ${location}`); } diff --git a/packages/web/src/model-package-import.test.ts b/packages/web/src/model-package-import.test.ts index dc7d3fa..f0045df 100644 --- a/packages/web/src/model-package-import.test.ts +++ b/packages/web/src/model-package-import.test.ts @@ -100,7 +100,7 @@ speculative: .toMatch(/^[0-9a-f]{64}$/); }); - 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,27 @@ 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: [], + totals: { externalInitializerBytes: 16 }, + }); }); 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..4f74a0a 100644 --- a/packages/web/src/model-package-import.ts +++ b/packages/web/src/model-package-import.ts @@ -105,9 +105,7 @@ export async function inspectBrowserModelPackage( const externalPath = resolvePackagePath(modelDirectory, location); const external = files.get(externalPath); if (external === undefined) { - throw new Error( - `${modelPath} references missing external data ${externalPath}`, - ); + return undefined; } return { byteLength: external.size, From 7377454092bf0e70e240f1453cb36947ef18fb19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:03:09 +0000 Subject: [PATCH 3/6] Clarify optional ONNX sidecar handling Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> --- docs/DESIGN.md | 2 +- packages/web/src/model-package-import.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3aad04f..d680fb5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1418,7 +1418,7 @@ dtypes, dimensions, logical/storage extents, and sorted operator counts, and normalize only explicitly published architecture fields. External paths must remain inside the model package; when a sidecar is supplied, every referenced range must fit it. Sidecars are streamed for hashing rather than loaded into -memory and may be omitted when importing a model for simulation. +memory and may be omitted when importing a model in the browser for simulation. 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; diff --git a/packages/web/src/model-package-import.test.ts b/packages/web/src/model-package-import.test.ts index f0045df..2f510e9 100644 --- a/packages/web/src/model-package-import.test.ts +++ b/packages/web/src/model-package-import.test.ts @@ -141,6 +141,15 @@ speculative: }); }); + it("rejects unsafe external data paths without resolving them", async () => { + await expect(inspectBrowserModelPackage([ + packageFile( + "model/decoder.onnx", + tinyOnnxModel("../decoder.onnx.data", 16), + ), + ])).rejects.toThrow("unsafe or missing external-data location"); + }); + it("rejects ambiguous metadata roots", async () => { await expect(inspectBrowserModelPackage([ packageFile("model/a/inference_metadata.yaml", "{}"), From 294d6773d635e9ae392aa11ab7a3511643f46b59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:14:27 +0000 Subject: [PATCH 4/6] Handle bare ONNX sidecar edge cases Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> --- packages/onnx-inspector/src/index.ts | 12 +++++++++++- packages/web/src/model-package-import.test.ts | 16 ++++++++++++++++ packages/web/src/model-package-import.ts | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/onnx-inspector/src/index.ts b/packages/onnx-inspector/src/index.ts index 050db7d..e2d178f 100644 --- a/packages/onnx-inspector/src/index.ts +++ b/packages/onnx-inspector/src/index.ts @@ -512,7 +512,17 @@ function safeExternalLocation(value: string | undefined, tensor: string): string `unsafe or missing external-data location for ${tensor}`, ); } - return value.replaceAll("\\", "/"); + const normalized = value + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment !== "" && segment !== ".") + .join("/"); + if (normalized.length === 0) { + throw new Error( + `unsafe or missing external-data location for ${tensor}`, + ); + } + return normalized; } function parseExternalInteger(value: string, label: string): number { diff --git a/packages/web/src/model-package-import.test.ts b/packages/web/src/model-package-import.test.ts index 2f510e9..53aa9b9 100644 --- a/packages/web/src/model-package-import.test.ts +++ b/packages/web/src/model-package-import.test.ts @@ -137,10 +137,26 @@ speculative: }, }], externalDataFiles: [], + architecture: { source: "none" }, totals: { externalInitializerBytes: 16 }, }); }); + it("normalizes relative external data paths", async () => { + const result = await inspectBrowserModelPackage([ + packageFile( + "model/decoder.onnx", + tinyOnnxModel("./decoder.onnx.data", 16), + ), + packageFile("model/decoder.onnx.data", new Uint8Array(16)), + ]); + + expect(result.models[0]!.manifest.externalDataFiles[0]).toMatchObject({ + location: "decoder.onnx.data", + byteLength: 16, + }); + }); + it("rejects unsafe external data paths without resolving them", async () => { await expect(inspectBrowserModelPackage([ packageFile( diff --git a/packages/web/src/model-package-import.ts b/packages/web/src/model-package-import.ts index 4f74a0a..3aa9a69 100644 --- a/packages/web/src/model-package-import.ts +++ b/packages/web/src/model-package-import.ts @@ -99,7 +99,7 @@ export async function inspectBrowserModelPackage( 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); From 980411349b47c99f072d0b2eb21e179882564e5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:59:24 +0000 Subject: [PATCH 5/6] Stop reading ONNX external data files Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> --- docs/DESIGN.md | 25 +++--- packages/cli/src/onnx-reader.ts | 38 +------- packages/cli/tests/cli.test.ts | 33 +++---- packages/onnx-inspector/src/index.ts | 90 +------------------ packages/web/src/model-package-import.test.ts | 38 ++++---- packages/web/src/model-package-import.ts | 41 --------- 6 files changed, 51 insertions(+), 214 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d680fb5..b278237 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1412,13 +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 -supplied 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; when a sidecar is supplied, every referenced -range must fit it. Sidecars are streamed for hashing rather than loaded into -memory and may be omitted when importing a model in the browser for simulation. +Revision-2 `inference-sim/onnx-model` manifests bind the ONNX protobuf by +SHA-256, retain canonical initializer names, dtypes, dimensions, +logical/storage extents, and sorted operator counts, and normalize only +explicitly published architecture fields. External-data paths are validated +from protobuf metadata, but 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; @@ -1778,8 +1777,8 @@ 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. +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 @@ -1792,10 +1791,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 -supplied sidecars incrementally, and parses portable -`inference_metadata.yaml|json`. External-data files are optional because the -ONNX protobuf carries the initializer metadata required for simulation. +inspector used by the CLI, validates external-data paths and extents 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..e11a739 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,7 +304,7 @@ describe("CLI", () => { .toBe(true); }); - it("rejects unsafe or truncated ONNX external-data references", async () => { + it("rejects unsafe external-data references without opening safe sidecars", async () => { const directory = await mkdtemp(join(tmpdir(), "inference-sim-onnx-")); const modelPath = join(directory, "model.onnx"); await writeFile(modelPath, tinyOnnxModel("../weights.data", 16)); @@ -328,9 +314,14 @@ describe("CLI", () => { 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 safe = captureIo(); + expect(await runCli(["onnx-inspect", modelPath], safe.io)).toBe(0); + const manifest = JSON.parse(safe.stdout()) as { + externalDataFiles: unknown[]; + totals: { externalInitializerBytes: number }; + }; + expect(manifest.externalDataFiles).toEqual([]); + expect(manifest.totals.externalInitializerBytes).toBe(16); }); it("materializes a parameterized multi-GPU scenario target", async () => { diff --git a/packages/onnx-inspector/src/index.ts b/packages/onnx-inspector/src/index.ts index e2d178f..a0f700c 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: { @@ -281,55 +267,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 (source === undefined) { - continue; - } - 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 { @@ -570,31 +507,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 53aa9b9..d8668dc 100644 --- a/packages/web/src/model-package-import.test.ts +++ b/packages/web/src/model-package-import.test.ts @@ -52,12 +52,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 +76,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,13 +95,7 @@ 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", async () => { @@ -142,18 +140,28 @@ speculative: }); }); - it("normalizes relative external data paths", async () => { + it("normalizes 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)), + { + ...packageFile("model/decoder.onnx.data", new Uint8Array(16)), + arrayBuffer: () => { + throw new Error("external data files must not be read"); + }, + }, ]); - expect(result.models[0]!.manifest.externalDataFiles[0]).toMatchObject({ - location: "decoder.onnx.data", - byteLength: 16, + expect(result.models[0]!.manifest).toMatchObject({ + initializers: [{ + storage: { + location: "decoder.onnx.data", + byteLength: 16, + }, + }], + externalDataFiles: [], }); }); diff --git a/packages/web/src/model-package-import.ts b/packages/web/src/model-package-import.ts index 3aa9a69..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,23 +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: 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) { - return undefined; - } - return { - byteLength: external.size, - sha256: () => sha256Stream(external), - }; - }, }); models.push({ fileName: modelPath, @@ -209,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 ( From ca42a54adc329f879fe69b8e33844033b4577264 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:24:14 +0000 Subject: [PATCH 6/6] Preserve ONNX external data metadata Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> --- docs/DESIGN.md | 17 ++++--- packages/cli/tests/cli.test.ts | 15 +++--- packages/core/src/onnx-manifest.ts | 14 ++++-- packages/core/tests/onnx-manifest.test.ts | 13 +++-- packages/onnx-inspector/src/index.ts | 31 ++---------- packages/web/src/model-package-import.test.ts | 47 ++++++++++++++++--- 6 files changed, 76 insertions(+), 61 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b278237..63396da 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1414,10 +1414,10 @@ 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 by SHA-256, retain canonical initializer names, dtypes, dimensions, -logical/storage extents, and sorted operator counts, and normalize only -explicitly published architecture fields. External-data paths are validated -from protobuf metadata, but sidecar files are never opened, sized, or hashed -because simulation only needs initializer metadata carried by the ONNX graph. +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, -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,8 @@ 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 without -opening sidecars, 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, diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index e11a739..0c59a30 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -304,22 +304,19 @@ describe("CLI", () => { .toBe(true); }); - it("rejects unsafe external-data references without opening safe sidecars", 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 safe = captureIo(); - expect(await runCli(["onnx-inspect", modelPath], safe.io)).toBe(0); - const manifest = JSON.parse(safe.stdout()) as { + 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); }); 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 a0f700c..d1114fa 100644 --- a/packages/onnx-inspector/src/index.ts +++ b/packages/onnx-inspector/src/index.ts @@ -227,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`, @@ -246,7 +245,9 @@ function inspectInitializer( logicalByteLength, storage: { kind: "external", - location, + ...(external.location === undefined + ? {} + : { location: external.location }), offset, byteLength, }, @@ -436,32 +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}`, - ); - } - const normalized = value - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment !== "" && segment !== ".") - .join("/"); - if (normalized.length === 0) { - throw new Error( - `unsafe or missing external-data location for ${tensor}`, - ); - } - return normalized; -} - function parseExternalInteger(value: string, label: string): number { if (!/^(0|[1-9]\d*)$/.test(value)) { throw new Error(`${label} must be an unsigned decimal integer`); diff --git a/packages/web/src/model-package-import.test.ts b/packages/web/src/model-package-import.test.ts index d8668dc..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) }, ], @@ -140,7 +142,7 @@ speculative: }); }); - it("normalizes relative external data paths without reading sidecars", async () => { + it("preserves relative external data paths without reading sidecars", async () => { const result = await inspectBrowserModelPackage([ packageFile( "model/decoder.onnx", @@ -157,7 +159,7 @@ speculative: expect(result.models[0]!.manifest).toMatchObject({ initializers: [{ storage: { - location: "decoder.onnx.data", + location: "./decoder.onnx.data", byteLength: 16, }, }], @@ -165,13 +167,44 @@ speculative: }); }); - it("rejects unsafe external data paths without resolving them", async () => { - await expect(inspectBrowserModelPackage([ + it("preserves unsafe-looking external data paths without resolving them", async () => { + const result = await inspectBrowserModelPackage([ packageFile( "model/decoder.onnx", tinyOnnxModel("../decoder.onnx.data", 16), ), - ])).rejects.toThrow("unsafe or missing external-data location"); + ]); + + 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 () => {