fix(dataset): align validation with the platform data-format rules doc

Reviewed against the official text-tuning data rules; fixes two confirmed
mismatches and fills enforcement gaps:

- thinking: exempt assistant messages carrying tool_calls from the
  THINK_TAG_NOT_LAST check — the spec's tool+thinking combo example puts
  <think> on a non-last assistant and was previously false-flagged
- DPO support matrix: reject image/video content items, tools, tool_calls
  and role:tool (DPO_UNSUPPORTED_ELEMENT); also scan chosen/rejected
- DPO: messages not ending with user upgraded warning -> error
- OpenAI migration: name/weight upgraded warning -> error (spec: must not
  carry); drop dead record-level name branch
- tool_call_id: unmatched tool response upgraded warning -> error
  (one-to-one per spec); new TOOL_CALL_NO_RESPONSE warning for orphan calls
- loss_weight: validate range at message level too; warn when placed on
  anything but the last assistant message (LOSS_WEIGHT_PLACEMENT)
- video params: fps/sample_fps must be within [0.1, 10]
  (INVALID_VIDEO_FPS); mode-mismatched params warned
  (VIDEO_PARAM_MODE_MISMATCH); video_start/video_end type-checked
- zip: skip macOS packaging metadata (__MACOSX/, .DS_Store, ._*) in
  filename constraints and image counting to stop false failures on
  Finder-created archives

Tests 45 -> 59 covering every new/changed rule, including a replica of the
spec's official tool+thinking example.
This commit is contained in:
故璃
2026-08-12 10:09:10 +08:00
parent e7422bd2e5
commit d74d4efcd0
4 changed files with 394 additions and 62 deletions
@@ -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<string, unknown>,
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: <think>…</think>` should only appear in the last assistant message
// thinking tag check: <think>…</think> should only appear in the last
// assistant message. Exemption (platform spec, tool+thinking combo): an
// assistant that carries tool_calls may legitimately hold a <think> 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<string, unknown> | 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 (<think>…</think>) should only appear in the last assistant message, found at messages[${idx}].`,
`Thinking tags (<think>…</think>) 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<string, unknown> | 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" },
),
);
@@ -18,16 +18,70 @@ function inspectDPORecord(record: Record<string, unknown>, 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>, lineNo: number): Vali
}
if (hasChosen) {
out.push(...inspectMessageObject(record.chosen, lineNo, "chosen"));
out.push(...mediaIssues((record.chosen as Record<string, unknown> | null)?.content, "chosen"));
const role = (record.chosen as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
@@ -69,6 +124,9 @@ function inspectDPORecord(record: Record<string, unknown>, lineNo: number): Vali
}
if (hasRejected) {
out.push(...inspectMessageObject(record.rejected, lineNo, "rejected"));
out.push(
...mediaIssues((record.rejected as Record<string, unknown> | null)?.content, "rejected"),
);
const role = (record.rejected as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
+21 -2
View File
@@ -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<string, string>(); // 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);
+172 -15
View File
@@ -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 <think> 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: "<think>\nneed the weather tool\n</think>\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", () => {