Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fb35f07
feat: add axis field for multi-ordered-category constraints (phase 1)
antonstefer Apr 9, 2026
5348c5d
feat(encoding): rank-forbidding encoder for non-pinned ordered axes (…
antonstefer Apr 9, 2026
b1262d2
feat(generator): multi-axis constraint enumeration (phase 3)
antonstefer Apr 10, 2026
ff4adef
feat(deduce): rank-space propagation for non-pinned ordered axes (pha…
antonstefer Apr 10, 2026
fdde902
feat(rendering): per-axis comparator and unit lookup (phase 5)
antonstefer Apr 10, 2026
54adf01
fix: at_position rendering, rank-vs-value phrasing, multi-axis preset
antonstefer Apr 10, 2026
fa18b2f
fix(demo): nudge skips steps whose eliminations are already applied
antonstefer Apr 10, 2026
65637b3
docs: update README for multi-axis ordered categories
antonstefer Apr 10, 2026
1b59d7a
test: restore 100% coverage across all packages
antonstefer Apr 10, 2026
665db99
refactor: remove SpatialWords, require comparators via types
antonstefer Apr 10, 2026
68c5880
fix(demo): add 'the' to Fund verb in multi-axis preset
antonstefer Apr 10, 2026
bef86d1
fix(ai): guide AI to use valueSuffix for bare numeric axis values
antonstefer Apr 10, 2026
2fbb0d5
docs: fix Grid type in README (remove stale positionNoun/spatialWords)
antonstefer Apr 10, 2026
3cbb517
feat: add displayLabels for grid column headers
antonstefer Apr 10, 2026
132602c
fix: stale comment in templates.ts, update logic-grid-ai README
antonstefer Apr 10, 2026
391bbb9
fix: address review feedback
antonstefer Apr 10, 2026
f416200
fix: second round of review feedback
antonstefer Apr 10, 2026
19554ca
refactor: require verb on ordered categories via type system
antonstefer Apr 10, 2026
fa54d83
refactor: enforce comparator shapes via type system
antonstefer Apr 10, 2026
cf091bf
fix: use 'in' guard instead of unsafe cast in validateConstraints
antonstefer Apr 10, 2026
35ed332
cleanup: remove unnecessary as [string, string] casts in demo presets
antonstefer Apr 10, 2026
be1c4b7
docs: comment explaining at_position vs displayAxis distinction
antonstefer Apr 10, 2026
45b1da6
fix: reject empty constraint sets in generator, seed flaky tests
antonstefer Apr 10, 2026
1a57171
fix(ai): correct prompt — comparators are required, not optional
antonstefer Apr 10, 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
14 changes: 8 additions & 6 deletions packages/demo/src/lib/PuzzleGrid.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { findPositionCategory, type Grid } from "logic-grid";
import { displayAxisCategory, type Grid } from "logic-grid";
import type { CellState } from "./puzzle-state.svelte";

let {
Expand All @@ -16,13 +16,15 @@
onEliminate: (valueIdx: number, position: number) => void;
} = $props();

const posCat = $derived(findPositionCategory(puzzleGrid));
// The display-axis category provides column headers. It's identity-pinned,
// so its values are excluded from the mystery rows.
const posCat = $derived(displayAxisCategory(puzzleGrid));

/** Categories to display as rows (excludes position category). */
/** Categories to display as rows (excludes display-axis category). */
const displayCategories = $derived(
puzzleGrid.categories
.map((cat, idx) => ({ cat, idx }))
.filter(({ cat }) => !cat.isPosition),
.filter(({ cat }) => cat !== posCat),
);

/** Compute the flat value index using the full categories array. */
Expand Down Expand Up @@ -94,13 +96,13 @@
<tr>
<th class="category-header"></th>
<th class="value-header"></th>
<th class="position-noun-header" colspan={grid.size}>{posCat ? posCat.name : grid.positionNoun[1]}</th>
<th class="position-noun-header" colspan={grid.size}>{posCat.name}</th>
</tr>
<tr>
<th class="category-header"></th>
<th class="value-header"></th>
{#each Array(grid.size) as _, p}
<th class="position-number">{posCat ? posCat.values[p] : p + 1}</th>
<th class="position-number">{posCat.displayLabels?.[p] ?? posCat.values[p]}</th>
{/each}
</tr>
</thead>
Expand Down
38 changes: 11 additions & 27 deletions packages/demo/src/lib/puzzle-state.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
generate,
deduce,
findPositionCategory,
displayAxisCategory,
type Category,
type Puzzle,
type Difficulty,
Expand Down Expand Up @@ -31,21 +31,11 @@ export function createPuzzleState() {
theme?: string;
clueStyle?: string;
customCategories?: Category[];
positionNoun?: [string, string];
positionPreposition?: string;
}

function newPuzzle(opts: NewPuzzleOptions) {
const {
size,
categories,
difficulty,
theme,
clueStyle,
customCategories,
positionNoun,
positionPreposition,
} = opts;
const { size, categories, difficulty, theme, clueStyle, customCategories } =
opts;
loading = true;
loadingMessage = theme ? "Generating theme…" : "Generating…";
message = null;
Expand Down Expand Up @@ -78,8 +68,6 @@ export function createPuzzleState() {
difficulty,
seed: Date.now(),
categoryNames: themeResult.categories,
positionNoun: themeResult.positionNoun,
positionPreposition: themeResult.positionPreposition,
});
} else {
puzzle = generate({
Expand All @@ -88,8 +76,6 @@ export function createPuzzleState() {
difficulty,
seed: Date.now(),
categoryNames: customCategories,
positionNoun,
positionPreposition,
});
}
if (clueStyle && puzzle) {
Expand Down Expand Up @@ -135,15 +121,13 @@ export function createPuzzleState() {
grid = Array.from({ length: totalValues }, () =>
Array.from({ length: puzzle!.grid.size }, () => "empty" as CellState),
);
// Pre-confirm position category (identity assignment, not a mystery)
const posCat = findPositionCategory(puzzle.grid);
if (posCat) {
const posCatIdx = puzzle.grid.categories.indexOf(posCat);
for (let vi = 0; vi < posCat.values.length; vi++) {
const valueIdx = getValueIndex(posCatIdx, vi);
for (let p = 0; p < puzzle.grid.size; p++) {
grid[valueIdx][p] = p === vi ? "confirmed" : "eliminated";
}
// Pre-confirm the display-axis category (identity-assigned, not a mystery)
const displayCat = displayAxisCategory(puzzle.grid);
const displayCatIdx = puzzle.grid.categories.indexOf(displayCat);
for (let vi = 0; vi < displayCat.values.length; vi++) {
const valueIdx = getValueIndex(displayCatIdx, vi);
for (let p = 0; p < puzzle.grid.size; p++) {
grid[valueIdx][p] = p === vi ? "confirmed" : "eliminated";
}
}
hintSteps = [];
Expand Down Expand Up @@ -431,7 +415,7 @@ export function createPuzzleState() {
for (const candidate of hintSteps) {
const newElims = candidate.eliminations.filter((e) => {
const cell = grid[findValueIdx(e.value)][e.position];
return cell === "empty" || cell === "confirmed";
return cell === "empty";
});
const newAssigns = candidate.assignments.filter((a) => {
return grid[findValueIdx(a.value)][a.position] !== "confirmed";
Expand Down
73 changes: 57 additions & 16 deletions packages/demo/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
label: string;
size: number;
categories: Category[];
positionNoun: [string, string];
positionPreposition: string;
}

const presets: Record<string, Preset> = {
Expand All @@ -26,7 +24,7 @@
noun: "fund",
verb: ["has a return of", "does not have a return of"],
subjectPriority: -1,
isPosition: true,
ordered: true,
numericValues: [3, 5, 8, 12],
orderingPhrases: {
unit: ["percentage point", "percentage points"],
Expand All @@ -36,8 +34,8 @@
"has the next lower return than",
"has the next higher return than",
],
next_to: "has an adjacent return to",
not_next_to: "does not have an adjacent return to",
next_to: "has the return right above or below",
not_next_to: "does not have the return right above or below",
between: "has a return somewhere between",
not_between: "does not have a return between",
exact_distance: "is exactly",
Expand All @@ -47,8 +45,6 @@
{ name: "Strategy", values: ["Long/Short", "Macro", "Quant", "Event-Driven"], noun: "strategist", subjectPriority: 1, verb: ["uses the", "does not use the"], valueSuffix: "strategy" },
{ name: "City", values: ["New York", "London", "Tokyo", "Zurich"], noun: "office", subjectPriority: 1, verb: ["is based in", "is not based in"] },
],
positionNoun: ["fund", "funds"],
positionPreposition: "at",
},
"morning-schedule": {
label: "Morning Schedule",
Expand All @@ -61,7 +57,7 @@
noun: "slot",
verb: ["has an appointment at", "does not have an appointment at"],
subjectPriority: -1,
isPosition: true,
ordered: true,
numericValues: [7, 8, 9, 10],
orderingPhrases: {
unit: ["hour", "hours"],
Expand All @@ -71,11 +67,11 @@
"has a later appointment than",
],
left_of: [
"has an appointment exactly one hour before",
"has an appointment exactly one hour after",
"has the appointment right before",
"has the appointment right after",
],
next_to: "has an appointment within one hour of",
not_next_to: "does not have an appointment within one hour of",
next_to: "has an appointment right before or after",
not_next_to: "does not have an appointment right before or after",
between: "has an appointment somewhere between",
not_between: "does not have an appointment between",
exact_distance: "has an appointment exactly",
Expand All @@ -85,8 +81,55 @@
{ name: "Activity", values: ["Dentist", "Barber", "Therapist", "Optician"], noun: "attendee", subjectPriority: 1, verb: ["visits the", "does not visit the"] },
{ name: "Transport", values: ["Bus", "Bike", "Car", "Walk"], noun: "commuter", subjectPriority: 1, verb: ["takes the", "does not take the"] },
],
positionNoun: ["slot", "slots"],
positionPreposition: "at",
},
"hedge-fund-multi": {
label: "Hedge Funds (Multi-Axis)",
size: 4,
categories: [
{ name: "Manager", values: ["Nadine", "Sal", "Terry", "Walter"], noun: "", subjectPriority: 2 },
{
name: "Year",
values: ["1972", "1983", "1997", "2005"],
noun: "fund",
verb: ["started in", "did not start in"],
subjectPriority: -1,
ordered: true,
numericValues: [1972, 1983, 1997, 2005],
orderingPhrases: {
unit: ["year", "years"],
comparators: {
before: ["started earlier than", "started later than"],
left_of: ["started right before", "started right after"],
next_to: "started right before or after",
not_next_to: "did not start right before or after",
between: "started between",
not_between: "did not start between",
exact_distance: "started exactly",
},
},
},
{
name: "Return",
values: ["6%", "7%", "8%", "9%"],
noun: "fund",
verb: ["has a return of", "does not have a return of"],
subjectPriority: -1,
ordered: true,
orderingPhrases: {
unit: ["percentage point", "percentage points"],
comparators: {
before: ["has a lower return than", "has a higher return than"],
left_of: ["has the next lower return than", "has the next higher return than"],
next_to: "has the return right above or below",
not_next_to: "does not have the return right above or below",
between: "has a return between",
not_between: "does not have a return between",
exact_distance: "is exactly",
},
},
},
{ name: "Fund", values: ["Black River", "Citizen Trust", "Pine Bay", "Silver Rock"], noun: "fund", subjectPriority: 1, verb: ["runs the", "does not run the"], valueSuffix: "fund" },
],
},
};

Expand All @@ -108,8 +151,6 @@
difficulty: diff,
clueStyle: style,
customCategories: p.categories,
positionNoun: p.positionNoun,
positionPreposition: p.positionPreposition,
});
} else {
puzzleState.newPuzzle({
Expand Down
18 changes: 6 additions & 12 deletions packages/logic-grid-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,18 @@ const theme = await generateTheme({
// {
// categories: [
// { name: "Pirate", values: ["Blackbeard", "Redbeard", ...], noun: "" },
// { name: "Ship", values: ["Revenge", "Kraken", ...], noun: "captain", verb: ["commands the", ...] },
// { name: "Ship", values: ["Revenge", "Kraken", ...], noun: "captain",
// verb: ["commands the", ...], ordered: true, orderingPhrases: { comparators: {...} } },
// ...
// ],
// positionNoun: ["cove", "coves"],
// positionPreposition: "at"
// ]
// }

const puzzle = generate({
size: 4,
categories: 4,
categoryNames: theme.categories,
positionNoun: theme.positionNoun,
positionPreposition: theme.positionPreposition,
});
// Clues like: "Blackbeard commands the Revenge."
// "The gold seeker is at the first cove."
```

## API
Expand All @@ -63,12 +59,10 @@ Returns a `ThemeResult`:
```typescript
interface ThemeResult {
categories: Category[]; // from logic-grid
positionNoun: [string, string]; // [singular, plural], e.g. ["planet", "planets"]
positionPreposition: string; // e.g. "on" -> "lives on the first planet"
}
```

The result is validated against structural and semantic rules (value uniqueness, noun consistency, category count, etc.). If validation fails, the AI is retried with error feedback up to 3 times.
At least one category must have `ordered: true` with `orderingPhrases.comparators` defining all 7 comparator phrases. The result is validated against structural and semantic rules (value uniqueness, noun consistency, category count, ordered category presence, etc.). If validation fails, the AI is retried with error feedback up to 3 times.

### `createAnthropicClient(apiKey?)`

Expand Down Expand Up @@ -116,9 +110,9 @@ if (errors.length > 0) {

## How It Works

1. A detailed prompt describes the puzzle structure, category contract, and position noun semantics
1. A detailed prompt describes the puzzle structure, category contract, and ordering semantics
2. The AI responds via tool_use with structured JSON matching a strict schema
3. The response is validated (category count, value uniqueness, noun consistency, etc.)
3. The response is validated (category count, value uniqueness, noun consistency, ordered category presence, comparator completeness, etc.)
4. If validation fails, errors are fed back to the AI for up to 3 retries

## License
Expand Down
2 changes: 1 addition & 1 deletion packages/logic-grid-ai/src/rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const SAMPLE_CLUES: Clue[] = [
text: "Alice drinks coffee.",
},
{
constraint: { type: "next_to", a: "Cat", b: "Red" },
constraint: { type: "next_to", a: "Cat", b: "Red", axis: "House" },
text: "The cat lives next to the red house.",
},
{
Expand Down
35 changes: 22 additions & 13 deletions packages/logic-grid-ai/src/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ const VALID_THEME: ThemeResult = {
values: ["Galleon", "Brigantine", "Sloop", "Frigate"],
noun: "captain",
verb: ["sails the", "does not sail the"],
ordered: true,
orderingPhrases: {
comparators: {
before: ["sails before", "sails after"],
left_of: ["sails right before", "sails right after"],
next_to: "sails right next to",
not_next_to: "does not sail right next to",
between: "sails between",
not_between: "does not sail between",
exact_distance: "sails exactly",
},
},
},
{
name: "Treasure",
Expand All @@ -36,8 +48,6 @@ const VALID_THEME: ThemeResult = {
verb: ["wields the", "does not wield the"],
},
],
positionNoun: ["spot", "spots"],
positionPreposition: "at",
};

describe("generateTheme", () => {
Expand All @@ -51,8 +61,6 @@ describe("generateTheme", () => {

expect(result.categories).toHaveLength(4);
expect(result.categories[0].noun).toBe("");
expect(result.positionNoun).toEqual(["spot", "spots"]);
expect(result.positionPreposition).toBe("at");
});

it("uses default Anthropic client when none provided", async () => {
Expand Down Expand Up @@ -125,9 +133,14 @@ describe("generateTheme", () => {
});

it("throws after max retries", async () => {
// No ordered category → validation fails
const badResult: ThemeResult = {
...VALID_THEME,
positionPreposition: "",
categories: VALID_THEME.categories.map((c) => ({
name: c.name,
values: c.values,
noun: c.noun,
verb: c.verb,
})),
};

await expect(
Expand Down Expand Up @@ -191,21 +204,17 @@ describe("generateTheme", () => {

const puzzle = generate({
categoryNames: theme.categories,
positionNoun: theme.positionNoun,
positionPreposition: theme.positionPreposition,
size: 4,
seed: 0,
});

expect(puzzle.grid.size).toBe(4);
expect(puzzle.grid.categories).toHaveLength(4);
expect(puzzle.grid.positionNoun).toEqual(["spot", "spots"]);
expect(puzzle.grid.positionPreposition).toBe("at");
expect(puzzle.clues.length).toBeGreaterThan(0);
expect(puzzle.solution).toBeDefined();

// Verify clues use the custom position noun
const positionalClue = puzzle.clues.find((c) => c.text.includes("spot"));
expect(positionalClue).toBeDefined();
// Verify clues render with the Ship category's verb/comparators
const sailClue = puzzle.clues.find((c) => c.text.includes("sail"));
expect(sailClue).toBeDefined();
});
});
Loading
Loading