From eed99a588e9b6c1872f0de898205e9082fb45854 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:11:03 +0800 Subject: [PATCH] fix(model): retain parsed headers when hashing is interrupted Share partial observation ownership across GGUF and Safetensors. Preserve completed header facts through cancellation and MCP error delivery without claiming finalized digests or file identity. Fixes #6684 --- docs/model-artifacts.md | 6 + src/domain/model/modelArtifact.ts | 4 +- src/model/ModelArtifactProvider.ts | 97 ++++++------- .../modelArtifactPartialCancellation.test.ts | 129 ++++++++++++------ .../modelArtifactPartialCancellation.test.ts | 56 +++++--- 5 files changed, 182 insertions(+), 110 deletions(-) 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",