Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f60bfe2
feat(logic-grid-ai): add AI translation API for puzzle clues
antonstefer Apr 29, 2026
4634865
feat(demo): wire AI translation into the demo
antonstefer Apr 29, 2026
8db8647
feat(logic-grid-ai): translate category names and value labels alongs…
antonstefer Apr 30, 2026
9e098cc
feat(demo): localize grid headers from translation maps
antonstefer Apr 30, 2026
c68cfa8
fix(logic-grid-ai): flag duplicate localized labels
antonstefer Apr 30, 2026
947ceb6
fix(demo): fail loud on missing localization keys and async invariants
antonstefer Apr 30, 2026
52b1d64
fix(logic-grid-ai): verify validator verdict order matches source clues
antonstefer Apr 30, 2026
aba42a2
refactor(logic-grid-ai): name MAX_CLUE_LENGTH, export prompt headers,…
antonstefer Apr 30, 2026
4d8c538
fix(demo): tighter input validation and sync JSDocs with fail-loud be…
antonstefer Apr 30, 2026
ff9efb7
fix(demo): tighten locale validation and run validator at temperature 0
antonstefer Apr 30, 2026
7330b61
chore: cosmetic cleanups from PR review
antonstefer Apr 30, 2026
6d0f842
fix(demo): trim locale and pin createAnthropicClient call order in tests
antonstefer Apr 30, 2026
e673e67
fix(logic-grid-ai): address PR review (length guard, exhaustive types…
antonstefer Apr 30, 2026
cbf8b39
fix(logic-grid-ai): repair translate() JSDoc broken by LOCALE_RE inse…
antonstefer Apr 30, 2026
dc13b65
fix(logic-grid-ai): escape clue text in prompts and clarify validator…
antonstefer Apr 30, 2026
dce142e
fix(demo): snapshot original English clues + extract label-fns for te…
antonstefer Apr 30, 2026
8c12988
refactor(logic-grid-ai): export LOCALE_RE; document translation limit…
antonstefer Apr 30, 2026
18cc570
chore(demo): use shared LOCALE_RE, rename c2, lock in originalClues i…
antonstefer Apr 30, 2026
120109a
refactor(logic-grid-ai): derive validator-prompt symmetric/asymmetric…
antonstefer Apr 30, 2026
0052ab6
fix(demo): keep originalClues across regenerate failures; bound input…
antonstefer Apr 30, 2026
0574aab
fix(logic-grid-ai): catch between/not_between middle-swap; escape cat…
antonstefer Apr 30, 2026
ff75004
fix(demo): bound category/value/noun shape; strip solution from trans…
antonstefer Apr 30, 2026
863c821
fix(logic-grid-ai): bump max_tokens to 8192; drop underscore from loc…
antonstefer Apr 30, 2026
9ab723d
chore(demo): drop unused `constraints` from translate body; don't res…
antonstefer Apr 30, 2026
846d5d1
fix(logic-grid-ai): cap output label lengths; add middle preservation…
antonstefer Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions packages/demo/src/lib/PuzzleGrid.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
<script lang="ts">
import { displayAxisCategory, type Grid } from "logic-grid";
import type { CellCoord, PairState, CellState } from "./puzzle-state.svelte";
import type {
CellCoord,
PairState,
CellState,
PuzzleLocalization,
} from "./puzzle-state.svelte";
import {
categoryLabel as categoryLabelFn,
valueLabel as valueLabelFn,
} from "./label-fns";

let {
puzzleGrid,
pair,
localization = null,
onConfirm,
onEliminate,
}: {
puzzleGrid: Grid;
pair: PairState;
/**
* Optional localization overlay. Maps from canonical category / value
* names to localized display strings. When set, every canonical key
* MUST be present — the renderer throws on a missing entry rather
* than silently rendering a half-localized grid. When `null`, the
* grid renders canonical names (the English-locale path).
*/
localization?: PuzzleLocalization | null;
onConfirm: (coord: CellCoord) => void;
onEliminate: (coord: CellCoord) => void;
} = $props();
Expand Down Expand Up @@ -41,12 +59,15 @@
return list;
});

// Label resolution is in label-fns.ts so the throw paths (missing
// localization key, displayLabels length mismatch) can be unit-tested
// without Svelte component-test infrastructure.
function categoryLabel(name: string): string {
return categoryLabelFn(name, localization);
}

function valueLabel(catIdx: number, valIdx: number): string {
const cat = cats[catIdx];
if (cat.ordered === true && cat.displayLabels) {
return cat.displayLabels[valIdx] ?? cat.values[valIdx];
}
return cat.values[valIdx];
return valueLabelFn(cats[catIdx], valIdx, localization);
}

function cellSymbol(state: CellState): string {
Expand Down Expand Up @@ -110,7 +131,7 @@
<td class="corner"></td>
<td class="corner"></td>
{#each topCats as { cat: topCat }}
<th class="top-cat-label" colspan={S}>{topCat.name}</th>
<th class="top-cat-label" colspan={S}>{categoryLabel(topCat.name)}</th>
{/each}
</tr>
<tr>
Expand All @@ -134,7 +155,9 @@
{#each rowCat.values as _, rvi}
<tr>
{#if rvi === 0}
<th class="left-cat-label" rowspan={S}>{rowCat.name}</th>
<th class="left-cat-label" rowspan={S}
>{categoryLabel(rowCat.name)}</th
>
{/if}
<th
class="left-value"
Expand Down
128 changes: 128 additions & 0 deletions packages/demo/src/lib/label-fns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import type { Category } from "logic-grid";
import { categoryLabel, valueLabel } from "./label-fns";
import type { PuzzleLocalization } from "./puzzle-state.svelte";

const HOUSE: Category = {
name: "House",
values: ["1", "2", "3"],
noun: "house",
verb: ["lives in the", "does not live in the"],
ordered: true,
displayLabels: ["1st", "2nd", "3rd"],
orderingPhrases: {
unit: ["house", "houses"],
comparators: {
before: ["lives left of", "lives right of"],
left_of: ["lives directly left of", "lives directly right of"],
next_to: "lives next to",
not_next_to: "does not live next to",
between: "lives between",
not_between: "does not live between",
exact_distance: "lives exactly",
},
},
};

const COLOR: Category = {
name: "Color",
values: ["Red", "Blue", "Green"],
noun: "house",
valueSuffix: "house",
lowercase: true,
positionAdjective: ["is", "is not"],
};

const LOCALIZATION: PuzzleLocalization = {
categoryNames: { House: "Haus", Color: "Farbe" },
valueLabels: {
"1": "1",
"2": "2",
"3": "3",
Red: "Rot",
Blue: "Blau",
Green: "Grün",
},
};

describe("categoryLabel", () => {
it("returns the canonical name when localization is null", () => {
expect(categoryLabel("House", null)).toBe("House");
expect(categoryLabel("Color", null)).toBe("Color");
});

it("returns the localized name when localization is set", () => {
expect(categoryLabel("House", LOCALIZATION)).toBe("Haus");
expect(categoryLabel("Color", LOCALIZATION)).toBe("Farbe");
});

it("throws when localization is set but a key is missing", () => {
const partial: PuzzleLocalization = {
categoryNames: { House: "Haus" }, // Color missing
valueLabels: LOCALIZATION.valueLabels,
};
expect(() => categoryLabel("Color", partial)).toThrow(
/missing categoryNames entry for "Color"/,
);
});
});

describe("valueLabel", () => {
it("prefers displayLabels over localization on ordered categories", () => {
// displayLabels is "1st/2nd/3rd"; localization maps "1" → "1" but the
// displayLabels form wins because it's the consumer's chosen visual.
expect(valueLabel(HOUSE, 0, LOCALIZATION)).toBe("1st");
expect(valueLabel(HOUSE, 1, LOCALIZATION)).toBe("2nd");
});

it("uses displayLabels even when localization is null", () => {
expect(valueLabel(HOUSE, 0, null)).toBe("1st");
});

it("returns canonical value when localization is null and no displayLabels", () => {
expect(valueLabel(COLOR, 0, null)).toBe("Red");
});

it("returns localized label when localization is set and no displayLabels", () => {
expect(valueLabel(COLOR, 0, LOCALIZATION)).toBe("Rot");
expect(valueLabel(COLOR, 1, LOCALIZATION)).toBe("Blau");
});

it("throws when localization is set but a value key is missing", () => {
const partial: PuzzleLocalization = {
categoryNames: LOCALIZATION.categoryNames,
valueLabels: { Red: "Rot" }, // Blue, Green missing
};
expect(() => valueLabel(COLOR, 1, partial)).toThrow(
/missing valueLabels entry for "Blue"/,
);
});

it("throws when displayLabels is shorter than values", () => {
const sparse: Category = {
...HOUSE,
ordered: true,
displayLabels: ["1st", "2nd"], // missing index 2
orderingPhrases:
HOUSE.ordered === true ? HOUSE.orderingPhrases : undefined!,
};
expect(() => valueLabel(sparse, 2, null)).toThrow(
/displayLabels of length 2 but values has 3 entries .*index 2 out of range/,
);
});

it("throws on displayLabels length mismatch even on the English path", () => {
// Reviewer explicitly flagged this: the throw applies regardless of
// whether localization is set. This is a deliberate behaviour change
// from the previous silent `?? cat.values[valIdx]` fallback.
const sparse: Category = {
...HOUSE,
ordered: true,
displayLabels: ["1st", "2nd"],
orderingPhrases:
HOUSE.ordered === true ? HOUSE.orderingPhrases : undefined!,
};
expect(() => valueLabel(sparse, 2, LOCALIZATION)).toThrow();
expect(() => valueLabel(sparse, 2, null)).toThrow();
});
});
61 changes: 61 additions & 0 deletions packages/demo/src/lib/label-fns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Category } from "logic-grid";
import type { PuzzleLocalization } from "./puzzle-state.svelte";

/**
* Pure label-resolution functions used by `PuzzleGrid.svelte`. Pulled into
* a sibling module so the throw paths can be unit-tested without standing
* up Svelte component-test infrastructure for a single component.
*
* Behaviour summary:
* - `displayLabels` (when present on an ordered category) wins over both
* localization and canonical values — it's the consumer's chosen visual
* form for the grid (e.g. House `1/2/3/4`), language-independent.
* - When `localization` is set, every canonical key MUST have a non-empty
* entry. A missing entry indicates corrupted output that bypassed the
* structural validator; throw rather than render a half-localized grid.
* - When `localization` is `null`, fall through to the canonical name /
* value (the English-locale path).
*/

export function categoryLabel(
name: string,
localization: PuzzleLocalization | null,
): string {
if (localization === null) return name;
const localized = localization.categoryNames[name];
if (localized === undefined) {
throw new Error(
`Localization is missing categoryNames entry for "${name}"`,
);
}
return localized;
}

export function valueLabel(
cat: Category,
valIdx: number,
localization: PuzzleLocalization | null,
): string {
const canonical = cat.values[valIdx];
// displayLabels (when present) is the consumer's chosen visual form.
// Universal abbreviations like House `1/2/3/4` stay numeric across
// locales; AI-translated forms still appear in clue text where they
// read naturally.
if (cat.ordered === true && cat.displayLabels) {
const label = cat.displayLabels[valIdx];
if (label === undefined) {
throw new Error(
`Category "${cat.name}" has displayLabels of length ${cat.displayLabels.length} but values has ${cat.values.length} entries (index ${valIdx} out of range)`,
);
}
return label;
}
if (localization === null) return canonical;
const localized = localization.valueLabels[canonical];
if (localized === undefined) {
throw new Error(
`Localization is missing valueLabels entry for "${canonical}"`,
);
}
return localized;
}
Loading