diff --git a/packages/core/src/dataset/validate/schemas/chatml.ts b/packages/core/src/dataset/validate/schemas/chatml.ts index 959149e..00b4ca2 100644 --- a/packages/core/src/dataset/validate/schemas/chatml.ts +++ b/packages/core/src/dataset/validate/schemas/chatml.ts @@ -20,6 +20,69 @@ import type { RecordSchemaSpec } from "./types.ts"; const VALID_ROLES = new Set(["system", "user", "assistant", "tool"]); +/** Platform bounds for video sampling rate params (`fps` / `sample_fps`). */ +const VIDEO_FPS_MIN = 0.1; +const VIDEO_FPS_MAX = 10; + +/** + * Validate the sampling/clipping params carried by a video content item. + * Mode rules (platform spec): + * - path mode (video: string): `fps`, `video_start`, `video_end` allowed; `sample_fps` is not + * - frame-list mode (video: string[]): `sample_fps` allowed; `fps` / `video_start` / `video_end` are not + * `fps` / `sample_fps` must be numbers within [0.1, 10] when present. + */ +function inspectVideoParams( + item: Record, + isFrameList: boolean, + lineNo: number, + itemPath: string, +): ValidationIssue[] { + const out: ValidationIssue[] = []; + const checkFpsRange = (field: "fps" | "sample_fps"): void => { + if (!(field in item)) return; + const value = item[field]; + if (typeof value !== "number" || value < VIDEO_FPS_MIN || value > VIDEO_FPS_MAX) { + out.push( + makeIssue( + "error", + "INVALID_VIDEO_FPS", + `"${field}" must be a number between ${VIDEO_FPS_MIN} and ${VIDEO_FPS_MAX} (got ${JSON.stringify(value)}).`, + { line: lineNo, path: `${itemPath}.${field}` }, + ), + ); + } + }; + checkFpsRange("fps"); + checkFpsRange("sample_fps"); + + const wrongModeFields = isFrameList ? ["fps", "video_start", "video_end"] : ["sample_fps"]; + const modeName = isFrameList ? "frame-list" : "file-path"; + for (const field of wrongModeFields) { + if (field in item) { + out.push( + makeIssue( + "warning", + "VIDEO_PARAM_MODE_MISMATCH", + `"${field}" does not apply to ${modeName} video mode and will be ignored by the platform.`, + { line: lineNo, path: `${itemPath}.${field}` }, + ), + ); + } + } + + for (const field of ["video_start", "video_end"] as const) { + if (field in item && !isFrameList && typeof item[field] !== "number") { + out.push( + makeIssue("error", "INVALID_VIDEO_CLIP_TIME", `"${field}" must be a number (seconds).`, { + line: lineNo, + path: `${itemPath}.${field}`, + }), + ); + } + } + return out; +} + /** * Validate a content field that may be: * - A plain string (legacy format) @@ -112,19 +175,22 @@ export function inspectContentField( { line: lineNo, path: `${itemPath}.video` }, ), ); - } else if (Array.isArray(video)) { - for (let frameIdx = 0; frameIdx < video.length; frameIdx++) { - if (typeof video[frameIdx] !== "string") { - out.push( - makeIssue( - "error", - "INVALID_VIDEO_FRAME", - `Video frame list item at index ${frameIdx} must be a string.`, - { line: lineNo, path: `${itemPath}.video[${frameIdx}]` }, - ), - ); + } else { + if (Array.isArray(video)) { + for (let frameIdx = 0; frameIdx < video.length; frameIdx++) { + if (typeof video[frameIdx] !== "string") { + out.push( + makeIssue( + "error", + "INVALID_VIDEO_FRAME", + `Video frame list item at index ${frameIdx} must be a string.`, + { line: lineNo, path: `${itemPath}.video[${frameIdx}]` }, + ), + ); + } } } + out.push(...inspectVideoParams(obj, Array.isArray(video), lineNo, itemPath)); } } } @@ -283,13 +349,13 @@ export function inspectMessageObject( out.push(...inspectToolCalls(record.tool_calls, lineNo, `${path}.tool_calls`)); } - // OpenAI migration guard: name / weight are not supported by Bailian + // OpenAI migration guard: the platform rejects data carrying name / weight if ("name" in record) { out.push( makeIssue( - "warning", + "error", "UNSUPPORTED_FIELD_NAME", - `Field "name" is not supported by Bailian. Remove it when migrating from OpenAI/Azure.`, + `Field "name" is not supported by Bailian and must be removed when migrating from OpenAI/Azure.`, { line: lineNo, path: `${path}.name` }, ), ); @@ -297,9 +363,10 @@ export function inspectMessageObject( if ("weight" in record) { out.push( makeIssue( - "warning", + "error", "UNSUPPORTED_FIELD_WEIGHT", - `Field "weight" is not supported by Bailian. Remove it when migrating from OpenAI/Azure.`, + `Field "weight" is not supported by Bailian and must be removed when migrating from OpenAI/Azure. ` + + `All assistant outputs are trained; per-line importance uses "loss_weight" (invite-only).`, { line: lineNo, path: `${path}.weight` }, ), ); @@ -419,12 +486,15 @@ export function inspectChatMLRecord( ); } - // tool_call_id correspondence: every tool response should reference a known call id + // tool_call_id correspondence must be one-to-one (platform spec): + // every tool response must reference a known call id (hard error), and every + // tool_call should receive a response (advisory — trailing calls are dubious + // in training data but we cannot rule out platform-side tolerance). for (const responseId of toolResponseIds) { if (!toolCallIds.has(responseId)) { out.push( makeIssue( - "warning", + "error", "TOOL_CALL_ID_UNMATCHED", `tool message references tool_call_id "${responseId}" which does not match any assistant tool_calls[].id.`, { line: lineNo, path: "messages" }, @@ -432,20 +502,37 @@ export function inspectChatMLRecord( ); } } + for (const callId of toolCallIds) { + if (!toolResponseIds.has(callId)) { + out.push( + makeIssue( + "warning", + "TOOL_CALL_NO_RESPONSE", + `assistant tool_calls[].id "${callId}" has no matching tool response message.`, + { line: lineNo, path: "messages" }, + ), + ); + } + } - // thinking tag check: …` should only appear in the last assistant message + // thinking tag check: … should only appear in the last + // assistant message. Exemption (platform spec, tool+thinking combo): an + // assistant that carries tool_calls may legitimately hold a block + // even when it is not the last assistant message. if (lastAssistantIdx >= 0) { for (let idx = 0; idx < messages.length; idx++) { if (idx === lastAssistantIdx) continue; const msg = messages[idx] as Record | null; if (msg?.role !== "assistant") continue; + if (msg && Array.isArray(msg.tool_calls)) continue; const content = msg?.content; if (contentHasThinkTag(content)) { out.push( makeIssue( "warning", "THINK_TAG_NOT_LAST", - `Thinking tags (…) should only appear in the last assistant message, found at messages[${idx}].`, + `Thinking tags (…) should only appear in the last assistant message ` + + `(or an assistant message carrying tool_calls), found at messages[${idx}].`, { line: lineNo, path: `messages[${idx}].content` }, ), ); @@ -453,39 +540,50 @@ export function inspectChatMLRecord( } } - // loss_weight validation (record-level, invite-only parameter) - if ("loss_weight" in record) { - const lossWeight = record.loss_weight; - if (typeof lossWeight !== "number" || lossWeight < 0 || lossWeight > 1) { + // loss_weight validation (invite-only parameter). + // Range is enforced wherever the field appears (record level and message + // level); placement follows the spec: only the LAST assistant message line + // supports loss_weight — misplaced occurrences are advisory (invite-only + // semantics are account-specific, so we do not hard-fail). + const checkLossWeightRange = (value: unknown, path: string): void => { + if (typeof value !== "number" || value < 0 || value > 1) { out.push( makeIssue( "error", "INVALID_LOSS_WEIGHT", - `"loss_weight" must be a number between 0.0 and 1.0 (got ${JSON.stringify(lossWeight)}).`, - { line: lineNo, path: "loss_weight" }, + `"loss_weight" must be a number between 0.0 and 1.0 (got ${JSON.stringify(value)}).`, + { line: lineNo, path }, + ), + ); + } + }; + if ("loss_weight" in record) { + checkLossWeightRange(record.loss_weight, "loss_weight"); + } + for (let idx = 0; idx < messages.length; idx++) { + const msg = messages[idx] as Record | null; + if (!msg || !("loss_weight" in msg)) continue; + checkLossWeightRange(msg.loss_weight, `messages[${idx}].loss_weight`); + if (!(msg.role === "assistant" && idx === lastAssistantIdx)) { + out.push( + makeIssue( + "warning", + "LOSS_WEIGHT_PLACEMENT", + `"loss_weight" is only supported on the last assistant message; found at messages[${idx}] (role "${String(msg.role)}").`, + { line: lineNo, path: `messages[${idx}].loss_weight` }, ), ); } } - // OpenAI migration guard at record level - if ("name" in record && !("messages" in record)) { - // Only warn at record level if it's not inside messages (messages handled above) - out.push( - makeIssue( - "warning", - "UNSUPPORTED_FIELD_NAME", - `Record-level field "name" is not supported by Bailian.`, - { line: lineNo, path: "name" }, - ), - ); - } + // OpenAI migration guard at record level (message-level occurrences are + // handled by inspectMessageObject above) if ("weight" in record) { out.push( makeIssue( - "warning", + "error", "UNSUPPORTED_FIELD_WEIGHT", - `Record-level field "weight" is not supported by Bailian. Remove it when migrating from OpenAI/Azure.`, + `Record-level field "weight" is not supported by Bailian and must be removed when migrating from OpenAI/Azure.`, { line: lineNo, path: "weight" }, ), ); diff --git a/packages/core/src/dataset/validate/schemas/dpo.ts b/packages/core/src/dataset/validate/schemas/dpo.ts index 26c3bcc..86de3aa 100644 --- a/packages/core/src/dataset/validate/schemas/dpo.ts +++ b/packages/core/src/dataset/validate/schemas/dpo.ts @@ -18,16 +18,70 @@ function inspectDPORecord(record: Record, lineNo: number): Vali const messages = record.messages; if (!Array.isArray(messages) || messages.length === 0) return out; - // DPO convention: messages should end with a user message (the prompt that - // chosen/rejected respond to). If the last message is assistant, it's likely - // a structural mistake. + /** image / video content items are outside the DPO support matrix */ + const mediaIssues = (content: unknown, basePath: string): ValidationIssue[] => { + if (!Array.isArray(content)) return []; + const found: ValidationIssue[] = []; + for (let itemIdx = 0; itemIdx < content.length; itemIdx++) { + const item = content[itemIdx] as Record | null; + if (!item || typeof item !== "object") continue; + for (const mediaField of ["image", "video"] as const) { + if (mediaField in item) { + found.push( + makeIssue( + "error", + "DPO_UNSUPPORTED_ELEMENT", + `DPO training data does not support ${mediaField} inputs; found at ${basePath}.content[${itemIdx}].`, + { line: lineNo, path: `${basePath}.content[${itemIdx}].${mediaField}` }, + ), + ); + } + } + } + return found; + }; + + // Support matrix (platform spec): DPO is text + thinking ONLY — no image / + // video inputs and no tool calling. Reject multimodal items and tool fields + // that the SFT-oriented ChatML inspector would otherwise accept. + if ("tools" in record) { + out.push( + makeIssue( + "error", + "DPO_UNSUPPORTED_ELEMENT", + `DPO training data does not support tool calling; remove the "tools" definition.`, + { line: lineNo, path: "tools" }, + ), + ); + } + for (let idx = 0; idx < messages.length; idx++) { + const msg = messages[idx] as Record | null; + if (!msg) continue; + const msgPath = `messages[${idx}]`; + if (msg.role === "tool" || "tool_calls" in msg) { + out.push( + makeIssue( + "error", + "DPO_UNSUPPORTED_ELEMENT", + `DPO training data does not support tool calling; found ${ + msg.role === "tool" ? `role "tool"` : `"tool_calls"` + } at ${msgPath}.`, + { line: lineNo, path: msgPath }, + ), + ); + } + out.push(...mediaIssues(msg.content, msgPath)); + } + + // DPO trains the preference for the LAST user input — messages ending with + // any other role make the chosen/rejected pair semantically meaningless. const lastMsg = messages[messages.length - 1] as Record | null; if (lastMsg && lastMsg.role !== "user") { out.push( makeIssue( - "warning", + "error", "DPO_LAST_MSG_NOT_USER", - `DPO "messages" should end with a "user" message (the prompt for chosen/rejected). ` + + `DPO "messages" must end with a "user" message (the prompt for chosen/rejected). ` + `Got "${String(lastMsg.role)}" as the last message.`, { line: lineNo, path: `messages[${messages.length - 1}].role` }, ), @@ -55,6 +109,7 @@ function inspectDPORecord(record: Record, lineNo: number): Vali } if (hasChosen) { out.push(...inspectMessageObject(record.chosen, lineNo, "chosen")); + out.push(...mediaIssues((record.chosen as Record | null)?.content, "chosen")); const role = (record.chosen as Record | null)?.role; if (typeof role === "string" && role !== "assistant") { out.push( @@ -69,6 +124,9 @@ function inspectDPORecord(record: Record, lineNo: number): Vali } if (hasRejected) { out.push(...inspectMessageObject(record.rejected, lineNo, "rejected")); + out.push( + ...mediaIssues((record.rejected as Record | null)?.content, "rejected"), + ); const role = (record.rejected as Record | null)?.role; if (typeof role === "string" && role !== "assistant") { out.push( diff --git a/packages/core/src/dataset/validate/zip.ts b/packages/core/src/dataset/validate/zip.ts index da1c362..232a5f9 100644 --- a/packages/core/src/dataset/validate/zip.ts +++ b/packages/core/src/dataset/validate/zip.ts @@ -108,18 +108,36 @@ function basenameNoExt(segment: string): string { return dot > 0 ? segment.slice(0, dot) : segment; } +/** + * macOS Finder/zip metadata entries (`__MACOSX/` resource forks, `.DS_Store`, + * AppleDouble `._*` files). They are packaging noise, not training data: + * exclude them from filename constraints and media counting so Mac-created + * archives don't fail on artifacts the user never sees. + */ +function isZipMetadataEntry(entry: string): boolean { + if (entry === "__MACOSX" || entry.startsWith("__MACOSX/")) return true; + const lastSegment = + entry + .split("/") + .filter((segment) => segment.length > 0) + .pop() ?? ""; + return lastSegment === ".DS_Store" || lastSegment.startsWith("._"); +} + /** * Validate ZIP entry filenames against platform constraints. * Returns issues for charset violations, over-length names, and duplicates. + * Exported for direct unit testing (not re-exported by the barrel). */ -function validateZipFilenames(entries: string[]): ValidationIssue[] { +export function validateZipFilenames(entries: string[]): ValidationIssue[] { const out: ValidationIssue[] = []; const seenBasenames = new Map(); // basename (no ext) → first full path const MAX_REPORTED = 10; for (const entry of entries) { - // Skip directory entries + // Skip directory entries and macOS packaging metadata if (entry.endsWith("/")) continue; + if (isZipMetadataEntry(entry)) continue; // Check each path segment (folder names + file name) const segments = entry.split("/").filter((s) => s.length > 0); @@ -383,6 +401,7 @@ export const zipValidator: ValidatorSpec = { const imageFiles = entries.filter((entry) => { if (entry === "data.jsonl" || entry.endsWith("/data.jsonl")) return false; if (entry.endsWith("/")) return false; // directory entries + if (isZipMetadataEntry(entry)) return false; // __MACOSX/._x.jpg is not an image const dot = entry.lastIndexOf("."); const ext = dot >= 0 ? entry.slice(dot).toLowerCase() : ""; return IMAGE_EXTENSIONS.has(ext); diff --git a/packages/core/tests/dataset-validate.test.ts b/packages/core/tests/dataset-validate.test.ts index c1e67c5..b478cca 100644 --- a/packages/core/tests/dataset-validate.test.ts +++ b/packages/core/tests/dataset-validate.test.ts @@ -106,13 +106,41 @@ describe("validateDataset — DPO schema", () => { expect(r.valid).toBe(true); }); - test("DPO messages ending with assistant → DPO_LAST_MSG_NOT_USER warning", async () => { + test("DPO messages ending with assistant → DPO_LAST_MSG_NOT_USER error", async () => { const p = file("dpo_last_asst.jsonl", [ '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"yo"}],"chosen":{"role":"assistant","content":"good"},"rejected":{"role":"assistant","content":"bad"}}', ]); const r = await validateDataset(p, { fullValidate: true, schema: "dpo" }); - expect(r.valid).toBe(true); - expect(codes(r).warnings).toContain("DPO_LAST_MSG_NOT_USER"); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("DPO_LAST_MSG_NOT_USER"); + }); + + test("DPO with image content item → DPO_UNSUPPORTED_ELEMENT error", async () => { + const p = file("dpo_image.jsonl", [ + '{"messages":[{"role":"user","content":[{"text":"look"},{"image":"a.jpg"}]}],"chosen":{"role":"assistant","content":[{"text":"good"}]},"rejected":{"role":"assistant","content":[{"text":"bad"}]}}', + ]); + const r = await validateDataset(p, { fullValidate: true, schema: "dpo" }); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("DPO_UNSUPPORTED_ELEMENT"); + }); + + test("DPO with tools / tool_calls → DPO_UNSUPPORTED_ELEMENT error", async () => { + const p = file("dpo_tools.jsonl", [ + '{"tools":[{"type":"function","function":{"name":"f","parameters":{}}}],"messages":[{"role":"user","content":"hi"}],"chosen":{"role":"assistant","content":"good"},"rejected":{"role":"assistant","content":"bad"}}', + ]); + const r = await validateDataset(p, { fullValidate: true, schema: "dpo" }); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("DPO_UNSUPPORTED_ELEMENT"); + }); + + test("DPO chosen carrying an image item → DPO_UNSUPPORTED_ELEMENT error", async () => { + const p = file("dpo_chosen_image.jsonl", [ + '{"messages":[{"role":"user","content":"hi"}],"chosen":{"role":"assistant","content":[{"text":"good"},{"image":"x.png"}]},"rejected":{"role":"assistant","content":"bad"}}', + ]); + const r = await validateDataset(p, { fullValidate: true, schema: "dpo" }); + expect(r.valid).toBe(false); + const err = r.errors.find((e) => e.code === "DPO_UNSUPPORTED_ELEMENT"); + expect(err?.path).toContain("chosen"); }); }); @@ -297,13 +325,23 @@ describe("validateDataset — tool calling (function calling)", () => { expect(codes(r).errors).toContain("TOOL_MISSING_CALL_ID"); }); - test("tool_call_id unmatched → TOOL_CALL_ID_UNMATCHED warning", async () => { + test("tool_call_id unmatched → TOOL_CALL_ID_UNMATCHED error", async () => { const p = file("tool_unmatched.jsonl", [ '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},{"role":"tool","tool_call_id":"WRONG_ID","content":"result"},{"role":"assistant","content":"done"}]}', ]); const r = await validateDataset(p, { fullValidate: true }); - expect(r.valid).toBe(true); - expect(codes(r).warnings).toContain("TOOL_CALL_ID_UNMATCHED"); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("TOOL_CALL_ID_UNMATCHED"); + // The orphaned call side is advisory + expect(codes(r).warnings).toContain("TOOL_CALL_NO_RESPONSE"); + }); + + test("tool_calls without a tool response → TOOL_CALL_NO_RESPONSE warning", async () => { + const p = file("tool_no_resp.jsonl", [ + '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},{"role":"assistant","content":"done"}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(codes(r).warnings).toContain("TOOL_CALL_NO_RESPONSE"); }); test("tool_calls with missing function name → TOOL_CALL_FN_NO_NAME", async () => { @@ -352,34 +390,68 @@ describe("validateDataset — thinking tags", () => { expect(r.valid).toBe(true); expect(codes(r).warnings).toContain("THINK_TAG_NOT_LAST"); }); + + test("official tool+thinking combo: think in non-last assistant WITH tool_calls is exempt", async () => { + // Mirrors the platform spec's 工具与思考组合 example: the assistant that + // issues tool_calls carries the block, the final assistant answers. + const p = file("think_tool_combo.jsonl", [ + JSON.stringify({ + tools: [ + { + type: "function", + function: { name: "get_weather", description: "d", parameters: { type: "object" } }, + }, + ], + messages: [ + { role: "user", content: [{ text: "weather in Beijing?" }] }, + { + role: "assistant", + content: [{ text: "\nneed the weather tool\n\n" }], + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Beijing"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: [{ text: '{"weather":"sunny"}' }] }, + { role: "assistant", content: [{ text: "It is sunny in Beijing." }] }, + ], + }), + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(r.valid).toBe(true); + expect(codes(r).warnings).not.toContain("THINK_TAG_NOT_LAST"); + }); }); describe("validateDataset — OpenAI migration guards", () => { - test("message-level name field → UNSUPPORTED_FIELD_NAME warning", async () => { + test("message-level name field → UNSUPPORTED_FIELD_NAME error", async () => { const p = file("openai_name.jsonl", [ '{"messages":[{"role":"user","content":"hi","name":"alice"},{"role":"assistant","content":"hello"}]}', ]); const r = await validateDataset(p, { fullValidate: true }); - expect(r.valid).toBe(true); - expect(codes(r).warnings).toContain("UNSUPPORTED_FIELD_NAME"); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("UNSUPPORTED_FIELD_NAME"); }); - test("message-level weight field → UNSUPPORTED_FIELD_WEIGHT warning", async () => { + test("message-level weight field → UNSUPPORTED_FIELD_WEIGHT error", async () => { const p = file("openai_weight.jsonl", [ '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello","weight":0.5}]}', ]); const r = await validateDataset(p, { fullValidate: true }); - expect(r.valid).toBe(true); - expect(codes(r).warnings).toContain("UNSUPPORTED_FIELD_WEIGHT"); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("UNSUPPORTED_FIELD_WEIGHT"); }); - test("record-level weight field → UNSUPPORTED_FIELD_WEIGHT warning", async () => { + test("record-level weight field → UNSUPPORTED_FIELD_WEIGHT error", async () => { const p = file("record_weight.jsonl", [ '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}],"weight":1}', ]); const r = await validateDataset(p, { fullValidate: true }); - expect(r.valid).toBe(true); - expect(codes(r).warnings).toContain("UNSUPPORTED_FIELD_WEIGHT"); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("UNSUPPORTED_FIELD_WEIGHT"); }); }); @@ -428,6 +500,91 @@ describe("validateDataset — loss_weight", () => { const r = await validateDataset(p, { fullValidate: true }); expect(r.valid).toBe(true); }); + + test("message-level loss_weight on the LAST assistant passes without warning", async () => { + const p = file("lw_msg_last.jsonl", [ + '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello","loss_weight":0.8}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(r.valid).toBe(true); + expect(codes(r).warnings).not.toContain("LOSS_WEIGHT_PLACEMENT"); + }); + + test("message-level loss_weight on a non-last assistant → LOSS_WEIGHT_PLACEMENT warning", async () => { + const p = file("lw_msg_mid.jsonl", [ + '{"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b","loss_weight":0.8},{"role":"user","content":"c"},{"role":"assistant","content":"d"}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(codes(r).warnings).toContain("LOSS_WEIGHT_PLACEMENT"); + }); + + test("message-level loss_weight out of range → INVALID_LOSS_WEIGHT", async () => { + const p = file("lw_msg_range.jsonl", [ + '{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello","loss_weight":2}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("INVALID_LOSS_WEIGHT"); + }); +}); + +describe("validateDataset — video content params", () => { + test("path-mode video with in-range fps and clip times passes", async () => { + const p = file("video_path_ok.jsonl", [ + '{"messages":[{"role":"user","content":[{"text":"desc"},{"video":"v.mp4","fps":3.0,"video_start":0.0,"video_end":3.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(r.valid).toBe(true); + expect(codes(r).warnings).not.toContain("VIDEO_PARAM_MODE_MISMATCH"); + }); + + test("fps out of [0.1, 10] → INVALID_VIDEO_FPS error", async () => { + const p = file("video_fps_bad.jsonl", [ + '{"messages":[{"role":"user","content":[{"text":"desc"},{"video":"v.mp4","fps":30}]},{"role":"assistant","content":[{"text":"ok"}]}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(r.valid).toBe(false); + expect(codes(r).errors).toContain("INVALID_VIDEO_FPS"); + }); + + test("sample_fps on path-mode video → VIDEO_PARAM_MODE_MISMATCH warning", async () => { + const p = file("video_mode_mix.jsonl", [ + '{"messages":[{"role":"user","content":[{"text":"desc"},{"video":"v.mp4","sample_fps":2.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(codes(r).warnings).toContain("VIDEO_PARAM_MODE_MISMATCH"); + }); + + test("frame-list video with sample_fps passes; fps there is flagged", async () => { + const p = file("video_frames.jsonl", [ + '{"messages":[{"role":"user","content":[{"text":"desc"},{"video":["0.jpg","1.jpg"],"sample_fps":5.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}', + '{"messages":[{"role":"user","content":[{"text":"desc"},{"video":["0.jpg"],"fps":2.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}', + ]); + const r = await validateDataset(p, { fullValidate: true }); + expect(r.valid).toBe(true); + expect(codes(r).warnings).toContain("VIDEO_PARAM_MODE_MISMATCH"); + }); +}); + +describe("validateZipFilenames — macOS metadata entries", () => { + test("__MACOSX / .DS_Store / ._resource-fork entries are ignored", async () => { + const { validateZipFilenames } = await import("../src/dataset/validate/zip.ts"); + const issues = validateZipFilenames([ + "data.jsonl", + "image_1.jpg", + "__MACOSX/._image_1.jpg", + "__MACOSX/", + ".DS_Store", + "train/._clip.wav", + ]); + expect(issues).toEqual([]); + }); + + test("real charset violations are still reported", async () => { + const { validateZipFilenames } = await import("../src/dataset/validate/zip.ts"); + const issues = validateZipFilenames(["data.jsonl", "图片1.jpg"]); + expect(issues.map((issue) => issue.code)).toContain("INVALID_FILENAME_CHARSET"); + }); }); describe("parseDatasetSchemaFlag", () => {