flooring sense difficulty instead of rejecting the entry

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>
This commit is contained in:
lila 2026-08-20 12:34:37 +02:00
parent da9cdbfa1b
commit 5ee594334c
7 changed files with 454 additions and 50 deletions

View file

@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { validateEntry, type ValidationContext } from "../validate.js";
import {
validateEntry,
type ValidationContext,
type ValidationResult,
} from "../validate.js";
const ctx: ValidationContext = {
sourceLanguage: "es",
@ -253,21 +257,6 @@ describe("validateEntry", () => {
);
});
it("rejects a translation difficulty below the sense difficulty", () => {
const easyTranslations = translations();
expect(
errorsOf(
entry({
senses: [
sense({ difficulty: "medium", translations: easyTranslations }),
],
}),
),
).toContainEqual(
expect.stringContaining('is lower than sense difficulty "medium"'),
);
});
it("allows a translation difficulty above the sense difficulty", () => {
const harder = translations();
harder[2] = {
@ -282,4 +271,97 @@ describe("validateEntry", () => {
);
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");
});
});
});