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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 3 additions & 35 deletions packages/cli/src/onnx-reader.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,20 @@
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<OnnxModelManifest> {
const resolvedModelPath = resolve(modelPath);
const modelDirectory = dirname(resolvedModelPath);
return inspectOnnxModelBytes({
modelFileName: basename(modelPath),
modelBytes: await readFile(resolvedModelPath),
metadata,
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<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) {
hash.update(chunk as Buffer);
}
return hash.digest("hex");
}
40 changes: 14 additions & 26 deletions packages/cli/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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 () => {
Expand All @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/onnx-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ function parseInitializer(
`${label} storage`,
);
} else if (kind === "external") {
assertExactKeys(
assertAllowedKeys(
storageRecord,
["kind", "byteLength", "location", "offset"],
`${label} storage`,
Expand All @@ -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`,
Expand Down
13 changes: 10 additions & 3 deletions packages/core/tests/onnx-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading