diff --git a/docs/model-artifacts.md b/docs/model-artifacts.md index fc0323851c..5ebb5e2c17 100644 --- a/docs/model-artifacts.md +++ b/docs/model-artifacts.md @@ -42,6 +42,12 @@ check descriptor and selected-path identity and metadata before completion; this detects observed replacement or modification, not adversarial modification that defeats filesystem metadata checks. +If full-file hashing fails or is cancelled after either format's header has +been validated, partial Evidence retains the parsed metadata and tensor directory. +Header coverage is complete; digests and final file identity remain unknown. +Safetensors header parsing is atomic, so interruption before validation does not +expose unvalidated JSON rows. + ## Facts and selected queries GGUF reports version, byte order, alignment, typed metadata, and each tensor's diff --git a/src/domain/model/modelArtifact.ts b/src/domain/model/modelArtifact.ts index 104876a518..003c04276b 100644 --- a/src/domain/model/modelArtifact.ts +++ b/src/domain/model/modelArtifact.ts @@ -81,7 +81,7 @@ export const modelArtifactObjectSchema = z.strictObject({ limitations: z.array(z.string()), }); -/** Facts proven before an interrupted GGUF read reached final file identity checks. */ +/** Facts proven before an interrupted model read reached final file identity checks. */ export const modelArtifactPartialTensorSchema = z.strictObject({ index: count, name: z.string(), @@ -106,7 +106,7 @@ export const modelArtifactPartialSchema = z.strictObject({ sha256: z.null(), file_identity: z.literal("unverified"), }), - format: z.literal("gguf"), + format: z.enum(["gguf", "safetensors"]), version: count.nullable(), byte_order: z.enum(["little", "big"]).nullable(), alignment: count.nullable(), diff --git a/src/model/ModelArtifactProvider.ts b/src/model/ModelArtifactProvider.ts index ef5cde726d..c29f102a67 100644 --- a/src/model/ModelArtifactProvider.ts +++ b/src/model/ModelArtifactProvider.ts @@ -239,7 +239,8 @@ export class ModelArtifactProvider implements ModelArtifactPort { if ( error.partialObservation === undefined && partialProgress !== undefined && - (partialProgress.metadata.length > 0 || + (partialProgress.coverage.header === "complete" || + partialProgress.metadata.length > 0 || partialProgress.tensor_declarations.length > 0 || partialProgress.tensors.length > 0) ) { @@ -277,62 +278,57 @@ export class ModelArtifactProvider implements ModelArtifactPort { const first = await reader.take(4); if (first.equals(Buffer.from([0x50, 0x4b, 3, 4]))) rejectPickle(); const format = first.equals(Buffer.from("GGUF")) ? "gguf" : "safetensors"; - let partial: ModelArtifactPartial | undefined; - if (format === "gguf") { - partial = { - kind: "model-artifact-partial", - artifact: { - path: input.path, - observed_file_size: Number(initial.size), - header_sha256: null, - sha256: null, - file_identity: "unverified", - }, - format, - version: null, - byte_order: null, - alignment: null, - declared_metadata_count: null, - declared_tensor_count: null, - metadata: [], - tensor_declarations: [], - tensors: [], - coverage: { - header: "partial", - metadata: "not-started", - tensors: "not-started", - }, - limitations: [ - "This partial observation contains only rows fully parsed before the interrupted read.", - "Header and full-file digests and final file identity are unknown because finalization did not complete.", - "No model behavior or tensor payload values were interpreted.", - ], - }; - retainProgress?.(partial); - } + const partial: ModelArtifactPartial = { + kind: "model-artifact-partial", + artifact: { + path: input.path, + observed_file_size: Number(initial.size), + header_sha256: null, + sha256: null, + file_identity: "unverified", + }, + format, + version: null, + byte_order: null, + alignment: null, + declared_metadata_count: null, + declared_tensor_count: null, + metadata: [], + tensor_declarations: [], + tensors: [], + coverage: { + header: "partial", + metadata: "not-started", + tensors: "not-started", + }, + limitations: [ + "This partial observation contains only rows fully parsed before the interrupted read.", + "Header and full-file digests and final file identity are unknown because finalization did not complete.", + "No model behavior or tensor payload values were interpreted.", + ], + }; + retainProgress?.(partial); const parsed = format === "gguf" ? await new GgufHeader(reader).read({ onHeader: (info) => { - if (partial === undefined) return; partial.version = info.version; partial.byte_order = info.byte_order; partial.declared_metadata_count = info.declared_metadata_count; partial.declared_tensor_count = info.declared_tensor_count; partial.coverage.metadata = "partial"; }, - onMetadata: (row) => partial?.metadata.push(row), + onMetadata: (row) => partial.metadata.push(row), onAlignment: (alignment) => { - if (partial !== undefined) partial.alignment = alignment; + partial.alignment = alignment; }, onMetadataComplete: () => { - if (partial !== undefined) partial.coverage.metadata = "complete"; + partial.coverage.metadata = "complete"; }, onTensorsStarted: () => { - if (partial !== undefined) partial.coverage.tensors = "partial"; + partial.coverage.tensors = "partial"; }, - onTensorDeclaration: (row) => - partial?.tensor_declarations.push(row), + onTensorDeclaration: (row) => partial.tensor_declarations.push(row), }) : await readSafetensorsHeader(reader, first); const intervals = parsed.tensors @@ -344,12 +340,19 @@ export class ModelArtifactProvider implements ModelArtifactPort { rejectModel(`Overlapping tensor byte ranges at ${row.name}`); end = Math.max(end, row.file_offset + (row.byte_length ?? 0)); } - if (partial !== undefined && format === "gguf") { - partial.tensor_declarations = []; - partial.tensors = parsed.tensors; - partial.coverage.tensors = "complete"; - partial.coverage.header = "complete"; - } + partial.version = parsed.version; + partial.byte_order = parsed.byte_order; + partial.alignment = parsed.alignment; + partial.metadata = parsed.metadata; + partial.declared_metadata_count = parsed.metadata.length; + partial.declared_tensor_count = parsed.tensors.length; + partial.tensor_declarations = []; + partial.tensors = parsed.tensors; + partial.coverage = { + header: "complete", + metadata: "complete", + tensors: "complete", + }; const header_length = reader.position; const hashes = await reader.finish(input.hash === "full"); const after = await file.stat({ bigint: true }); diff --git a/tests/boundary/mcp/modelArtifactPartialCancellation.test.ts b/tests/boundary/mcp/modelArtifactPartialCancellation.test.ts index cce8a409cd..58df84f360 100644 --- a/tests/boundary/mcp/modelArtifactPartialCancellation.test.ts +++ b/tests/boundary/mcp/modelArtifactPartialCancellation.test.ts @@ -10,45 +10,67 @@ import { registerModelArtifactTools } from "../../../src/server/registerModelArt import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { silentLogger } from "../../../src/logger.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -import { ggufFixture } from "../../fixtures/model/model.js"; +import { ggufFixture, safetensorsFixture } from "../../fixtures/model/model.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; -mcpTest.for([false, true])( - "projects partial model Evidence through an MCP tool error (retained=%s)", - async (retained, { mcp }) => { +const interruptedFixture = (fullHash: boolean) => { + const seed = ggufFixture(); + const count = 200_000; + return fullHash + ? safetensorsFixture( + JSON.stringify({ + __metadata__: { author: "test" }, + weight: { + dtype: "F32", + shape: [524288], + data_offsets: [0, 2 * 1024 * 1024], + }, + }), + Buffer.alloc(2 * 1024 * 1024), + ) + : ggufFixture({ + tensors: [], + metadata: [ + { key: "general.name", type: 8, value: seed.text("First row") }, + { + key: "custom.bools", + type: 9, + value: Buffer.concat([ + seed.u32(7), + seed.u64(BigInt(count)), + Buffer.alloc(count), + ]), + }, + ], + }); +}; + +mcpTest.for([ + { retained: false, fullHash: false }, + { retained: true, fullHash: false }, + { retained: false, fullHash: true }, + { retained: true, fullHash: true }, +])( + "projects partial model Evidence through an MCP tool error ($retained, fullHash=$fullHash)", + async ({ retained, fullHash }, { mcp }) => { const root = await createTestTempDirectory("rea-model-mcp-partial-"); - const path = join(root, "model.gguf"); - const seed = ggufFixture(); - const count = 200_000; - const fixture = ggufFixture({ - tensors: [], - metadata: [ - { key: "general.name", type: 8, value: seed.text("First row") }, - { - key: "custom.bools", - type: 9, - value: Buffer.concat([ - seed.u32(7), - seed.u64(BigInt(count)), - Buffer.alloc(count), - ]), - }, - ], - }); + const path = join(root, fullHash ? "model.safetensors" : "model.gguf"); + const fixture = interruptedFixture(fullHash); await writeFile(path, fixture.bytes); - const arrayStart = - 24 + - 8 + - Buffer.byteLength("general.name") + - 4 + - 8 + - Buffer.byteLength("First row") + - 8 + - Buffer.byteLength("custom.bools") + - 4 + - 4 + - 8; + const arrayStart = fullHash + ? fixture.header.length + : 24 + + 8 + + Buffer.byteLength("general.name") + + 4 + + 8 + + Buffer.byteLength("First row") + + 8 + + Buffer.byteLength("custom.bools") + + 4 + + 4 + + 8; const sample = await open(path, "r"); const prototype = Object.getPrototypeOf(sample) as { read: ( @@ -103,7 +125,11 @@ mcpTest.for([false, true])( try { const response = await client.callTool({ name: "inspect_model_artifact", - arguments: { path, detail: "complete" }, + arguments: { + path, + detail: "complete", + ...(fullHash ? { hash: "full" } : {}), + }, }); if (!observedArrayChunk) throw new Error(JSON.stringify(response)); const projected = parseMcpToolError(response); @@ -123,15 +149,30 @@ mcpTest.for([false, true])( } else partial = parseEvidence(details.partial_observation); expect(partial.normalized_result).toMatchObject({ kind: "model-artifact-partial", - metadata: [ - { - index: 0, - key: "general.name", - value: "First row", - source_offset: 24, - source_length: 41, - }, - ], + ...(fullHash + ? { + format: "safetensors", + metadata: [ + expect.objectContaining({ key: "author", value: "test" }), + ], + tensors: [expect.objectContaining({ name: "weight" })], + coverage: { + header: "complete", + metadata: "complete", + tensors: "complete", + }, + } + : { + metadata: [ + { + index: 0, + key: "general.name", + value: "First row", + source_offset: 24, + source_length: 41, + }, + ], + }), artifact: { path, header_sha256: null, diff --git a/tests/boundary/model/modelArtifactPartialCancellation.test.ts b/tests/boundary/model/modelArtifactPartialCancellation.test.ts index 45c7618651..249950cfb4 100644 --- a/tests/boundary/model/modelArtifactPartialCancellation.test.ts +++ b/tests/boundary/model/modelArtifactPartialCancellation.test.ts @@ -5,7 +5,7 @@ import { expect, it, vi } from "vitest"; import { ModelArtifactProvider } from "../../../src/model/ModelArtifactProvider.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; -import { ggufFixture } from "../../fixtures/model/model.js"; +import { ggufFixture, safetensorsFixture } from "../../fixtures/model/model.js"; const cancelledArrayFixture = (alignment?: number) => { const seed = ggufFixture(); @@ -229,23 +229,40 @@ it("retains completed GGUF tensor declarations before later tensor bytes cancel" } }); -it.each([32, 64])( - "retains resolved GGUF alignment %s when full hashing is cancelled", - async (alignment) => { +it.each([ + { format: "gguf", alignment: 32 }, + { format: "gguf", alignment: 64 }, + { format: "safetensors", alignment: null }, +])( + "retains parsed $format facts with alignment $alignment when full hashing is cancelled", + async ({ format, alignment }) => { const root = await mkdtemp(join(tmpdir(), "rea-model-hash-cancel-")); - const path = join(root, "model.gguf"); - const fixture = ggufFixture({ - ...(alignment === 32 ? {} : { alignment }), - tensors: [ - { - name: "weight", - type: 0, - shape: [524288n], - offset: 0n, - bytes: 2 * 1024 * 1024, - }, - ], - }); + const path = join(root, `model.${format}`); + const fixture = + format === "safetensors" + ? safetensorsFixture( + JSON.stringify({ + __metadata__: { author: "test" }, + weight: { + dtype: "F32", + shape: [524288], + data_offsets: [0, 2 * 1024 * 1024], + }, + }), + Buffer.alloc(2 * 1024 * 1024), + ) + : ggufFixture({ + ...(alignment === 64 ? { alignment } : {}), + tensors: [ + { + name: "weight", + type: 0, + shape: [524288n], + offset: 0n, + bytes: 2 * 1024 * 1024, + }, + ], + }); await writeFile(path, fixture.bytes); const sample = await open(path, "r"); const prototype = Object.getPrototypeOf(sample) as { @@ -296,7 +313,12 @@ it.each([32, 64])( parseEvidence(result.error.partialObservation).normalized_result, ).toMatchObject({ kind: "model-artifact-partial", + format, alignment, + declared_tensor_count: 1, + tensors: [ + expect.objectContaining({ name: "weight", shape: ["524288"] }), + ], coverage: { header: "complete", metadata: "complete",