Every rejection in the German run was the same rule: a translation ranked below its own sense. The model tags a sense "medium" while correctly tagging some translations "easy" — the translations are right and the derived sense label is wrong, but the whole entry was discarded. The prompt defines sense difficulty as the easiest translation difficulty in that sense, so it is a derived value rather than an independent judgement. validate.ts now recomputes it via applySenseDifficultyFloor. The floor only ever lowers. Raising a sense to match its translations would gate a concept out of levels it belongs in and collapse the concept-vs-word distinction the two difficulty columns exist to express (design-doc section 4). - validate.ts: drop the cross-field rejection, add the floor; the valid result now carries "normalizations" so repairs are reported, not silent - pipeline.ts: count and print normalizations per batch and in the summary - replay.ts: new, re-validates responses/ with the current rules and no API calls; --write stages recovered entries, --langs and --verbose - tests: six cases covering the floor, replacing the old rejection test Replaying all 38 saved responses took the reject rate from 20 entries to zero. staging.db now holds 746 words / 774 senses / 3,436 translations with no sense ranked above its easiest translation. Docs also record the API quota ceiling found today: the free tier allows about 20 requests/day, not the 1,000 previously assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
validateEntry,
|
|
type ValidationContext,
|
|
type ValidationResult,
|
|
} from "../validate.js";
|
|
|
|
const ctx: ValidationContext = {
|
|
sourceLanguage: "es",
|
|
pos: "noun",
|
|
targetLanguages: ["en", "it", "de", "fr"],
|
|
inputWords: new Set(["casa", "banco"]),
|
|
};
|
|
|
|
type Translation = {
|
|
target_language: string;
|
|
word: string;
|
|
gender: string | null;
|
|
difficulty: string;
|
|
};
|
|
|
|
const translations = (): Translation[] => [
|
|
{ target_language: "en", word: "house", gender: null, difficulty: "easy" },
|
|
{
|
|
target_language: "it",
|
|
word: "casa",
|
|
gender: "feminine",
|
|
difficulty: "easy",
|
|
},
|
|
{ target_language: "de", word: "Haus", gender: "neuter", difficulty: "easy" },
|
|
{
|
|
target_language: "fr",
|
|
word: "maison",
|
|
gender: "feminine",
|
|
difficulty: "easy",
|
|
},
|
|
];
|
|
|
|
const sense = (
|
|
overrides: Record<string, unknown> = {},
|
|
): Record<string, unknown> => ({
|
|
sense_index: 0,
|
|
difficulty: "easy",
|
|
definitions: ["Un edificio para vivir."],
|
|
examples: ["Compraron una casa en la ciudad."],
|
|
translations: translations(),
|
|
...overrides,
|
|
});
|
|
|
|
const entry = (
|
|
overrides: Record<string, unknown> = {},
|
|
): Record<string, unknown> => ({
|
|
headword: "casa",
|
|
language: "es",
|
|
pos: "noun",
|
|
senses: [sense()],
|
|
...overrides,
|
|
});
|
|
|
|
const errorsOf = (raw: unknown): string[] => {
|
|
const result = validateEntry(raw, ctx);
|
|
return result.status === "invalid" ? result.errors : [];
|
|
};
|
|
|
|
describe("validateEntry", () => {
|
|
it("accepts a fully valid entry", () => {
|
|
const result = validateEntry(entry(), ctx);
|
|
expect(result.status).toBe("valid");
|
|
});
|
|
|
|
it("treats senses: [] as empty (word skipped, not rejected)", () => {
|
|
const result = validateEntry(entry({ senses: [] }), ctx);
|
|
expect(result).toEqual({ status: "empty", headword: "casa" });
|
|
});
|
|
|
|
it("rejects senses: [] when the rest of the entry is invalid", () => {
|
|
const result = validateEntry(entry({ senses: [], language: "en" }), ctx);
|
|
expect(result.status).toBe("invalid");
|
|
});
|
|
|
|
it("rejects non-object input", () => {
|
|
expect(validateEntry("casa", ctx).status).toBe("invalid");
|
|
expect(validateEntry(null, ctx).status).toBe("invalid");
|
|
expect(validateEntry([entry()], ctx).status).toBe("invalid");
|
|
});
|
|
|
|
it("rejects a headword that was not in the input batch", () => {
|
|
expect(errorsOf(entry({ headword: "perro" }))).toContainEqual(
|
|
expect.stringContaining("not in the input batch"),
|
|
);
|
|
});
|
|
|
|
it("rejects a wrong source language", () => {
|
|
expect(errorsOf(entry({ language: "en" }))).toContainEqual(
|
|
expect.stringContaining('language must be "es"'),
|
|
);
|
|
});
|
|
|
|
it("rejects a wrong pos", () => {
|
|
expect(errorsOf(entry({ pos: "verb" }))).toContainEqual(
|
|
expect.stringContaining('pos must be "noun"'),
|
|
);
|
|
});
|
|
|
|
it("rejects more than 3 senses", () => {
|
|
const senses = [0, 1, 2, 3].map((i) => sense({ sense_index: i }));
|
|
expect(errorsOf(entry({ senses }))).toContainEqual(
|
|
expect.stringContaining("at most 3"),
|
|
);
|
|
});
|
|
|
|
it("rejects non-sequential sense_index", () => {
|
|
const senses = [sense({ sense_index: 0 }), sense({ sense_index: 2 })];
|
|
expect(errorsOf(entry({ senses }))).toContainEqual(
|
|
expect.stringContaining("sense_index must be 1"),
|
|
);
|
|
});
|
|
|
|
it("rejects an unknown difficulty", () => {
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ difficulty: "intermediate" })] })),
|
|
).toContainEqual(expect.stringContaining("difficulty must be one of"));
|
|
});
|
|
|
|
it("rejects empty definitions and examples", () => {
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ definitions: [] })] })),
|
|
).toContainEqual(
|
|
expect.stringContaining("definitions must be a non-empty array"),
|
|
);
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ examples: [""] })] })),
|
|
).toContainEqual(
|
|
expect.stringContaining("examples must contain only non-empty strings"),
|
|
);
|
|
});
|
|
|
|
it("rejects a non-null gender for English targets", () => {
|
|
const bad = translations();
|
|
bad[0] = {
|
|
target_language: "en",
|
|
word: "house",
|
|
gender: "feminine",
|
|
difficulty: "easy",
|
|
};
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })),
|
|
).toContainEqual(
|
|
expect.stringContaining('gender must be null for target "en"'),
|
|
);
|
|
});
|
|
|
|
it("rejects a null gender for German targets", () => {
|
|
const bad = translations();
|
|
bad[2] = {
|
|
target_language: "de",
|
|
word: "Haus",
|
|
gender: null,
|
|
difficulty: "easy",
|
|
};
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })),
|
|
).toContainEqual(expect.stringContaining('for target "de"'));
|
|
});
|
|
|
|
it("rejects neuter for Romance-language targets", () => {
|
|
const bad = translations();
|
|
bad[3] = {
|
|
target_language: "fr",
|
|
word: "maison",
|
|
gender: "neuter",
|
|
difficulty: "easy",
|
|
};
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })),
|
|
).toContainEqual(expect.stringContaining('for target "fr"'));
|
|
});
|
|
|
|
it("rejects an invented gender value", () => {
|
|
const bad = translations();
|
|
bad[2] = {
|
|
target_language: "de",
|
|
word: "Haus",
|
|
gender: "common",
|
|
difficulty: "easy",
|
|
};
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })).length,
|
|
).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rejects a missing target language", () => {
|
|
const partial = translations().filter((t) => t.target_language !== "fr");
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: partial })] })),
|
|
).toContainEqual(
|
|
expect.stringContaining('missing translation for target language "fr"'),
|
|
);
|
|
});
|
|
|
|
it("rejects the source language as a target", () => {
|
|
const bad = [
|
|
...translations(),
|
|
{
|
|
target_language: "es",
|
|
word: "hogar",
|
|
gender: "masculine",
|
|
difficulty: "easy",
|
|
},
|
|
];
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })),
|
|
).toContainEqual(expect.stringContaining("target_language must be one of"));
|
|
});
|
|
|
|
it("rejects more than 2 translations for one target language", () => {
|
|
const bad = [
|
|
...translations(),
|
|
{
|
|
target_language: "de",
|
|
word: "Gebäude",
|
|
gender: "neuter",
|
|
difficulty: "medium",
|
|
},
|
|
{
|
|
target_language: "de",
|
|
word: "Heim",
|
|
gender: "neuter",
|
|
difficulty: "medium",
|
|
},
|
|
];
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })),
|
|
).toContainEqual(
|
|
expect.stringContaining(
|
|
'more than 2 translations for target language "de"',
|
|
),
|
|
);
|
|
});
|
|
|
|
it("rejects duplicate translation words for one target language", () => {
|
|
const bad = [
|
|
...translations(),
|
|
{
|
|
target_language: "de",
|
|
word: "Haus",
|
|
gender: "neuter",
|
|
difficulty: "medium",
|
|
},
|
|
];
|
|
expect(
|
|
errorsOf(entry({ senses: [sense({ translations: bad })] })),
|
|
).toContainEqual(
|
|
expect.stringContaining(
|
|
'duplicate translation word for target language "de"',
|
|
),
|
|
);
|
|
});
|
|
|
|
it("allows a translation difficulty above the sense difficulty", () => {
|
|
const harder = translations();
|
|
harder[2] = {
|
|
target_language: "de",
|
|
word: "Geldinstitut",
|
|
gender: "neuter",
|
|
difficulty: "medium",
|
|
};
|
|
const result = validateEntry(
|
|
entry({ senses: [sense({ translations: harder })] }),
|
|
ctx,
|
|
);
|
|
expect(result.status).toBe("valid");
|
|
});
|
|
|
|
describe("sense difficulty floor", () => {
|
|
const senseDifficultyOf = (result: ValidationResult): string =>
|
|
result.status === "valid"
|
|
? (result.entry.senses[0]?.difficulty ?? "")
|
|
: "";
|
|
|
|
it("floors a sense to its easiest translation instead of rejecting", () => {
|
|
// The real "Ellbogen" case: sense tagged medium, but some translations
|
|
// are correctly easy. The entry is good; only the derived label is wrong.
|
|
const mixed = translations();
|
|
mixed[1] = {
|
|
target_language: "it",
|
|
word: "gomito",
|
|
gender: "masculine",
|
|
difficulty: "medium",
|
|
};
|
|
const result = validateEntry(
|
|
entry({
|
|
senses: [sense({ difficulty: "medium", translations: mixed })],
|
|
}),
|
|
ctx,
|
|
);
|
|
expect(result.status).toBe("valid");
|
|
expect(senseDifficultyOf(result)).toBe("easy");
|
|
});
|
|
|
|
it("reports what it changed", () => {
|
|
const result = validateEntry(
|
|
entry({ senses: [sense({ difficulty: "hard" })] }),
|
|
ctx,
|
|
);
|
|
expect(result.status).toBe("valid");
|
|
if (result.status !== "valid") return;
|
|
expect(result.normalizations).toHaveLength(1);
|
|
expect(result.normalizations[0]).toContain('"hard" → "easy"');
|
|
});
|
|
|
|
it("leaves an already-consistent sense untouched", () => {
|
|
const result = validateEntry(entry(), ctx);
|
|
expect(result.status).toBe("valid");
|
|
if (result.status !== "valid") return;
|
|
expect(result.normalizations).toEqual([]);
|
|
expect(senseDifficultyOf(result)).toBe("easy");
|
|
});
|
|
|
|
it("never raises a sense above its own label", () => {
|
|
// All translations medium, sense easy: the concept stays easy, because
|
|
// sense difficulty gates the meaning, not the word (design-doc §4).
|
|
const allMedium = translations().map((t) => ({
|
|
...t,
|
|
difficulty: "medium",
|
|
}));
|
|
const result = validateEntry(
|
|
entry({ senses: [sense({ translations: allMedium })] }),
|
|
ctx,
|
|
);
|
|
expect(result.status).toBe("valid");
|
|
if (result.status !== "valid") return;
|
|
expect(result.normalizations).toEqual([]);
|
|
expect(senseDifficultyOf(result)).toBe("easy");
|
|
});
|
|
|
|
it("floors each sense independently", () => {
|
|
const easySense = sense({ sense_index: 0, difficulty: "medium" });
|
|
const hardSense = sense({
|
|
sense_index: 1,
|
|
difficulty: "hard",
|
|
translations: translations().map((t) => ({
|
|
...t,
|
|
difficulty: "medium",
|
|
})),
|
|
});
|
|
const result = validateEntry(
|
|
entry({ senses: [easySense, hardSense] }),
|
|
ctx,
|
|
);
|
|
expect(result.status).toBe("valid");
|
|
if (result.status !== "valid") return;
|
|
expect(result.entry.senses.map((s) => s.difficulty)).toEqual([
|
|
"easy",
|
|
"medium",
|
|
]);
|
|
expect(result.normalizations).toHaveLength(2);
|
|
});
|
|
|
|
it("does not mutate the input entry", () => {
|
|
const input = entry({ senses: [sense({ difficulty: "medium" })] });
|
|
validateEntry(input, ctx);
|
|
const senses = input["senses"] as Record<string, unknown>[];
|
|
expect(senses[0]?.["difficulty"]).toBe("medium");
|
|
});
|
|
});
|
|
});
|