Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3d9aca2
fix: stop blanket-lowercasing values in clue phrases
antonstefer Apr 10, 2026
2a00682
feat: axis-aware deduction explanations
antonstefer Apr 10, 2026
451e10b
feat: optimize between encoder with rank auxiliary variables
antonstefer Apr 10, 2026
03ed670
feat: remove positional fast-path and at_position/not_at_position
antonstefer Apr 13, 2026
ed8cc14
perf: skip rank vars for pinned-axis constraints
antonstefer Apr 14, 2026
5c96505
refactor: review cleanup — cache axis terms, extract helpers, fix types
antonstefer Apr 14, 2026
865fb12
chore: fix stale identity-pinning and at_position references in comments
antonstefer Apr 14, 2026
f291e3c
refactor: remove dead code, reach 100% branch coverage
antonstefer Apr 14, 2026
ea0cf30
perf: pinned-axis fast path in deduction layer
antonstefer Apr 14, 2026
576fbc6
fix: update demo for removed direct/elimination techniques
antonstefer Apr 14, 2026
4456eb0
style: fix formatting
antonstefer Apr 14, 2026
f5c8ac4
chore: address PR review — stale at_position refs, naming, test dup
antonstefer Apr 14, 2026
17af784
chore: drop redundant DeductionTechnique cast on SILENT_STEP
antonstefer Apr 14, 2026
6e20e84
feat: recall current state in comparative and between hints
antonstefer Apr 14, 2026
d3339bb
fix: skip vacuous 'can only be anywhere' context in describeKnown
antonstefer Apr 14, 2026
9680ef6
fix: address PR review — concise axis-value phrasing, comparator wording
antonstefer Apr 14, 2026
9c1f7e1
perf: implication-form comparative encoder (single unified path)
antonstefer Apr 15, 2026
8947958
perf: cache multiAxis flag on AxisTerms; stabilize coverage
antonstefer Apr 15, 2026
00d8687
test: pin expert-retry premise so seed shifts fail loudly
antonstefer Apr 15, 2026
bc467b5
refactor: extract pinnedAxis/isPinnedAxis to axis.ts, fix randomSolution
antonstefer Apr 15, 2026
b702f7e
docs: cross-reference pinnedAxis from displayAxisCategory
antonstefer Apr 15, 2026
a63bde0
refactor: harden RankVarAllocator and validate global value uniqueness
antonstefer Apr 15, 2026
6ef904a
style: prettier format encoding.test.ts
antonstefer Apr 15, 2026
bb3bb53
fix: join both operands' context in binary/same_position hints
antonstefer Apr 15, 2026
a2fd9f7
fix: add {target} placeholder to unknown-technique fallback hint
antonstefer Apr 15, 2026
81e249c
style: prettier format deduce/constraints.ts
antonstefer Apr 15, 2026
ee59ce6
style: suppress 'on <Axis>' suffix for pinned-axis constraints
antonstefer Apr 15, 2026
4553723
fix: exact_distance noun fallback uses target axis, not pinned
antonstefer Apr 15, 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
34 changes: 16 additions & 18 deletions packages/demo/src/lib/nudge-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,6 @@ describe("buildNudgeText", () => {
expect(text).toBe("Try looking at Clue 5 \u2014 where must Dog go?");
});

it("direct technique uses placement phrasing", () => {
const text = buildNudgeText(
makeStep({
technique: "direct",
clueIndices: [0],
assignments: [{ value: "Red", position: 2 }],
eliminations: [
{ value: "Red", position: 0 },
{ value: "Red", position: 1 },
],
}),
);
expect(text).toBe("Try looking at Clue 1 \u2014 where must Red go?");
});

it("structural technique uses plain statement", () => {
const text = buildNudgeText(
makeStep({
Expand Down Expand Up @@ -107,7 +92,7 @@ describe("buildNudgeText", () => {
it("joins multiple clue indices with 'and'", () => {
const text = buildNudgeText(
makeStep({
technique: "elimination",
technique: "same_position",
clueIndices: [0, 3],
eliminations: [{ value: "Tea", position: 1 }],
}),
Expand Down Expand Up @@ -148,10 +133,23 @@ describe("buildNudgeText", () => {
);
});

it("falls back gracefully on unknown techniques (e.g. persisted traces)", () => {
// Simulate a persisted deduction trace from before this PR that still
// uses the removed "direct" technique. DeductionTechnique no longer
// permits it, but runtime data might — buildNudgeText must not throw.
const text = buildNudgeText(
makeStep({
technique: "direct" as unknown as DeductionStep["technique"],
clueIndices: [0],
eliminations: [{ value: "Red", position: 1 }],
}),
);
expect(text).toContain("Clue 1");
expect(text).toContain("Red");
});

it("TECHNIQUE_HINTS covers all techniques", () => {
const techniques = [
"direct",
"elimination",
"same_position",
"not_same_position",
"next_to",
Expand Down
10 changes: 7 additions & 3 deletions packages/demo/src/lib/nudge-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import type { DeductionStep, DeductionTechnique } from "logic-grid";
// Clue-based techniques use question templates with {target} replaced by the
// value being deduced. Structural techniques are plain statements (no target).
export const TECHNIQUE_HINTS: Record<DeductionTechnique, string> = {
direct: "where must {target} go?",
elimination: "can you cross off a position for {target}?",
same_position: "what positions can you rule out for {target}?",
not_same_position: "what positions can you rule out for {target}?",
next_to: "where can {target} go?",
Expand All @@ -31,8 +29,14 @@ function joinValues(values: string[]): string {
return values.slice(0, -1).join(", ") + ", and " + values[values.length - 1];
}

/** Generic fallback when the technique is unrecognized — shields against
* persisted deduction traces referring to removed techniques like the
* pre-refactor "direct" / "elimination". */
const UNKNOWN_TECHNIQUE_HINT = "what can you deduce about {target}?";

export function buildNudgeText(step: DeductionStep): string {
const hintTemplate = TECHNIQUE_HINTS[step.technique];
const hintTemplate: string =
TECHNIQUE_HINTS[step.technique] ?? UNKNOWN_TECHNIQUE_HINT;

// Clue-based steps reference specific clues and substitute {target}.
// Structural steps (clueIndices empty) use plain statements with no placeholder.
Expand Down
3 changes: 1 addition & 2 deletions packages/logic-grid-ai/src/rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const SAMPLE_CLUES: Clue[] = [
text: "The cat lives next to the red house.",
},
{
constraint: { type: "at_position", value: "Bob", position: 0 },
constraint: { type: "same_position", a: "Bob", b: "first" },
text: "Bob lives in the first house.",
},
];
Expand Down Expand Up @@ -101,7 +101,6 @@ describe("rewriteClues", () => {

expect(capturedPrompt).toContain('"type":"same_position"');
expect(capturedPrompt).toContain('"type":"next_to"');
expect(capturedPrompt).toContain('"type":"at_position"');
});

it("retries on validation failure", async () => {
Expand Down
33 changes: 20 additions & 13 deletions packages/logic-grid-ai/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ function buildSchema(size: number, categories: number): JSONSchema {
description:
'Optional noun appended to the value when it appears as an object. E.g. valueSuffix "strategy" makes "event-driven" render as "event-driven strategy" → "Alice uses the event-driven strategy". Use this when the value alone is an adjective or short label that needs a clarifying noun. Required for categories whose values describe the position noun (e.g. Color: valueSuffix "house" → "red house").',
},
lowercase: {
type: "boolean",
description:
'When true, values are lowercased in clue phrases. Use for adjective/common-noun categories where values like "Red" should render as "the red house" or "Cat" as "the cat owner". Do NOT set for categories with proper nouns (ship names, place names, brand names). Default: false.',
},
positionAdjective: {
type: "array",
items: { type: "string" },
minItems: 2,
maxItems: 2,
description:
'Optional [positive, negative] verb pair for at_position inversion. Set this ONLY when the category\'s values are adjectives that describe the position noun directly (e.g. Color "Red" describes "house"). Inverts at_position to "{posLabel} {verb} {value}" → "The first house is red." Use ["is", "is not"] in most cases. Always pair with valueSuffix and subjectPriority -1.',
'Optional [positive, negative] verb pair that inverts same-position rendering when paired with a display-axis value. Set this ONLY when the category\'s values are adjectives that describe the position noun directly (e.g. Color "Red" describes "house"). Flips same_position(Red, "first") from "... lives in the red house" to "The first house is red." Use ["is", "is not"] in most cases. Always pair with valueSuffix, lowercase: true, and subjectPriority -1.',
},
ordered: {
type: "boolean",
Expand Down Expand Up @@ -112,14 +117,16 @@ The puzzle has ${size} positions. Clues are generated mechanically from categori

**\`noun\`** — labels the value: "the cat owner", "the red house". Empty string "" means bare value ("Alice"). There must be exactly one person category with noun: "".

**\`verb\`** — \`[positive, negative]\` verb pair used when this category appears as the OBJECT in a same-position clue: \`{subject} {verb} {value}\`. MUST read grammatically when concatenated with the lowercased value.
- Pet (noun: "owner", verb: ["owns the", "does not own the"]) → "Alice owns the cat." ✓
- Drink (noun: "drinker", verb: ["drinks", "does not drink"]) → "Alice drinks tea." ✓ (mass noun, no article)
- Treasure with values like "Cursed Idol", "Gold Bar" → verb MUST include "the": ["plunders the", "does not plunder the"] → "Alice plunders the cursed idol." ✓
- Wrong: ["plunders", "does not plunder"] + value "Cursed Idol" → "Alice plunders cursed idol." ✗ (missing article)
**\`verb\`** — \`[positive, negative]\` verb pair used when this category appears as the OBJECT in a same-position clue: \`{subject} {verb} {value}\`. MUST read grammatically when concatenated with the value (lowercased only if \`lowercase: true\`).
- Pet (lowercase: true, noun: "owner", verb: ["owns the", "does not own the"]) → "Alice owns the cat." ✓
- Drink (lowercase: true, noun: "drinker", verb: ["drinks", "does not drink"]) → "Alice drinks tea." ✓ (mass noun, no article)
- Treasure with values like "Cursed Idol", "Gold Bar" → verb MUST include "the": ["plunders the", "does not plunder the"] → "Alice plunders the Cursed Idol." ✓
- Wrong: ["plunders", "does not plunder"] + value "Cursed Idol" → "Alice plunders Cursed Idol." ✗ (missing article)
- Rule: if the value is a count noun (you'd say "a/the X"), the verb must include "the". Bare verbs only work with mass nouns ("tea", "water"), plural count nouns ("gold coins", "pearls"), or proper nouns ("Madagascar").
- CRITICAL: all values in a category must be grammatically the same shape so one verb works for all. Don't mix singular count nouns ("Cursed Idol") with plural/mass nouns ("Gold Coins") in the same category — pick verb + values that all read consistently.

**\`lowercase\`** — when true, values are lowercased in clue phrases. Set this for adjective/common-noun categories where "Red" should render as "red" or "Cat" as "cat". Do NOT set for proper nouns (ship names like "HMS Victory", place names like "Tortuga", brand names like "Toyota"). Default: false (casing preserved).

**\`subjectPriority\`** — controls which value becomes the sentence subject when two categories meet. Higher = more likely subject.
- 2: person category (always subject when present)
- 1: animate categories that DO things (drinker, owner, attendee, player, fan, lover, ...)
Expand All @@ -133,7 +140,7 @@ The puzzle has ${size} positions. Clues are generated mechanically from categori

**IMPORTANT for ordered categories with numeric values:** The \`unit\` field in orderingPhrases is ONLY used for \`exact_distance\` clues ("exactly 25 years from"). It is NOT appended to values in other clue types. If your ordered category has bare numeric values like "500", "1000" that need a unit label in all clues, use \`valueSuffix\` (e.g. valueSuffix: "gold pieces"). If values are already self-explanatory like "2005" (a year) or "7am" (a time), no suffix is needed.

**\`positionAdjective\`** — set ONLY when the position noun is naturally modified by an adjective category, like a HOUSE has a color ("the red house"). DO NOT use this for position nouns like "dock", "ship", "fund", "station", "slot", "year" — these aren't naturally characterized by an adjective from another category. Provides a [positive, negative] verb pair (usually ["is", "is not"]) for at_position inversion: "The first house is red." MUST be paired with valueSuffix and subjectPriority -1. Use sparingly — when in doubt, don't.
**\`positionAdjective\`** — set ONLY when the position noun is naturally modified by an adjective category, like a HOUSE has a color ("the red house"). DO NOT use this for position nouns like "dock", "ship", "fund", "station", "slot", "year" — these aren't naturally characterized by an adjective from another category. Provides a [positive, negative] verb pair (usually ["is", "is not"]) for inverting same-position rendering against a display-axis value: "The first house is red." MUST be paired with valueSuffix and subjectPriority -1. Use sparingly — when in doubt, don't.

## Ordered categories

Expand Down Expand Up @@ -167,8 +174,8 @@ For a "pirate adventure" theme with size 4 and 4 categories:
{
"categories": [
{ "name": "Pirate", "values": ["Anne", "Blackbeard", "Calico", "Drake"], "noun": "", "subjectPriority": 2 },
{ "name": "Ship Color", "values": ["Crimson", "Indigo", "Emerald", "Onyx"], "noun": "ship", "subjectPriority": -1, "verb": ["sails the", "does not sail the"], "valueSuffix": "ship", "positionAdjective": ["is", "is not"] },
{ "name": "Treasure", "values": ["Gold", "Pearls", "Rubies", "Maps"], "noun": "hoarder", "subjectPriority": 1, "verb": ["hoards", "does not hoard"] },
{ "name": "Ship Color", "values": ["Crimson", "Indigo", "Emerald", "Onyx"], "noun": "ship", "subjectPriority": -1, "lowercase": true, "verb": ["sails the", "does not sail the"], "valueSuffix": "ship", "positionAdjective": ["is", "is not"] },
{ "name": "Treasure", "values": ["Gold", "Pearls", "Rubies", "Maps"], "noun": "hoarder", "subjectPriority": 1, "lowercase": true, "verb": ["hoards", "does not hoard"] },
{ "name": "Hideout", "values": ["Tortuga", "Nassau", "Madagascar", "Cuba"], "noun": "captain", "subjectPriority": 1, "verb": ["hides in", "does not hide in"] }
],
}
Expand All @@ -181,7 +188,7 @@ For a "hedge fund" theme:
"categories": [
{ "name": "Manager", "values": ["Alice", "Bob", "Clara", "Dan"], "noun": "", "subjectPriority": 2 },
{ "name": "YTD Return", "values": ["3%", "5%", "8%", "12%"], "noun": "fund", "subjectPriority": -1, "verb": ["has a return of", "does not have a return of"], "ordered": true, "numericValues": [3, 5, 8, 12], "orderingPhrases": { "unit": ["percentage point", "percentage points"], "comparators": { "before": ["has a lower return than", "has a higher return than"], "left_of": ["has a return right below", "has a return right above"], "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": "has a return exactly" } } },
{ "name": "Strategy", "values": ["Long/Short", "Macro", "Quant", "Event-Driven"], "noun": "strategist", "subjectPriority": 1, "verb": ["uses the", "does not use the"], "valueSuffix": "strategy" },
{ "name": "Strategy", "values": ["Long/Short", "Macro", "Quant", "Event-Driven"], "noun": "strategist", "subjectPriority": 1, "lowercase": true, "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"] }
],
}
Expand All @@ -190,9 +197,9 @@ For a "hedge fund" theme:

For each category, ask:
1. Is it the person? → noun: "", subjectPriority: 2
2. Are its values multi-word labels that describe the position noun (like "Crimson" describes "ship")? → set valueSuffix to the position noun, positionAdjective to ["is", "is not"], subjectPriority -1
3. Are its values short labels needing a clarifying noun (like "Event-Driven" → "event-driven strategy")? → set valueSuffix, subjectPriority 1
4. Does the value read naturally without a suffix in "{subject} {verb} {value}" form (like "Alice owns the cat", "Bob drinks tea", "Carol hides in tortuga")? → no valueSuffix needed, subjectPriority 1
2. Are its values multi-word labels that describe the position noun (like "Crimson" describes "ship")? → set valueSuffix to the position noun, positionAdjective to ["is", "is not"], lowercase: true, subjectPriority -1
3. Are its values short labels needing a clarifying noun (like "Event-Driven" → "event-driven strategy")? → set valueSuffix, lowercase: true, subjectPriority 1
4. Does the value read naturally without a suffix in "{subject} {verb} {value}" form? → no valueSuffix needed, subjectPriority 1. Set lowercase: true for common nouns ("cat", "tea") but NOT for proper nouns ("Tortuga", "HMS Victory")
5. Does the theme have a natural ordering (returns, times, years, house numbers)? → mark that category ordered: true. For numeric orderings add numericValues and orderingPhrases

## Your task
Expand Down
20 changes: 10 additions & 10 deletions packages/logic-grid/src/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

exports[`public API integration > generate with custom noun/verb produces correct clues 1`] = `
[
"Luna speaks german.",
"The tulip grower lives directly right of the piano player.",
"Nora lives next to the italian speaker.",
"Kai does not live in the first house.",
"The guitar player grows the tulip.",
"Theo grows the daisy.",
"Luna lives somewhere right of the french speaker.",
"Kai grows the rose.",
"Luna lives directly right of the drums player.",
"The french speaker lives in the third house.",
"Theo lives exactly 3 houses from the Lily grower.",
"The Lily grower speaks German.",
"The Italian speaker plays the Piano.",
"The Drums player lives directly right of the Spanish speaker.",
"The Rose grower lives somewhere between Luna and the Piano player.",
"Kai does not live next to the Italian speaker.",
"Kai lives next to the Tulip grower.",
"The fourth house plays the Violin.",
"The second house plays the Guitar.",
"The second house does not grow the Rose.",
]
`;
19 changes: 19 additions & 0 deletions packages/logic-grid/src/axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,29 @@ export function axisRank(category: Category, value: string): number {
return idx;
}

/**
* The pinned axis: the first ordered category, identity-pinned by `encodeBase`
* (rank = position) for symmetry breaking. This is the canonical "row anchor"
* of the SAT encoding and is independent of `grid.displayAxis` (which is a
* UI presentation hint, not a solver concern).
*/
export function pinnedAxis(grid: Grid): OrderedCategory | undefined {
return grid.categories.find(isOrdered);
}

/** True when `axis` is the pinned (row-anchor) axis. */
export function isPinnedAxis(grid: Grid, axis: Category): boolean {
return pinnedAxis(grid) === axis;
}

/**
* Return the presentation display-anchor category for the grid. Reads
* `grid.displayAxis` when set; otherwise returns the first ordered category.
* Throws if no ordered category exists.
*
* For SAT/solver concerns (pinning, clause encoding, deduction state),
* use `pinnedAxis` instead — it always returns the first ordered category
* regardless of the user's display preference.
*/
export function displayAxisCategory(grid: Grid): OrderedCategory {
if (grid.displayAxis !== undefined) {
Expand Down
18 changes: 0 additions & 18 deletions packages/logic-grid/src/clues/constraints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {
notBetween,
before,
exactDistance,
atPosition,
notAtPosition,
} from "./constraints";

describe("constraint factories", () => {
Expand Down Expand Up @@ -95,20 +93,4 @@ describe("constraint factories", () => {
axis: "House",
});
});

it("atPosition", () => {
expect(atPosition("Red", 0)).toEqual({
type: "at_position",
value: "Red",
position: 0,
});
});

it("notAtPosition", () => {
expect(notAtPosition("Red", 2)).toEqual({
type: "not_at_position",
value: "Red",
position: 2,
});
});
});
10 changes: 0 additions & 10 deletions packages/logic-grid/src/clues/constraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,3 @@ export function exactDistance(
): Constraint {
return { type: "exact_distance", a, b, distance, axis };
}

/** `value` is at the given 0-indexed row position. */
export function atPosition(value: string, position: number): Constraint {
return { type: "at_position", value, position };
}

/** `value` is not at the given 0-indexed row position. */
export function notAtPosition(value: string, position: number): Constraint {
return { type: "not_at_position", value, position };
}
Loading
Loading