From 3d9aca24330a91273120e79d7bb2b779dba0ecb3 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:20:21 +0200 Subject: [PATCH 01/28] fix: stop blanket-lowercasing values in clue phrases Values were unconditionally lowercased in label() and objectValue(), breaking proper nouns like ship names and place names. Flip the default: values now preserve casing unless the category opts in with lowercase: true. Add the flag to all default categories with adjective/common-noun values (Color, Pet, Drink, Food, Hobby, Music, Sport). --- packages/logic-grid-ai/src/theme.ts | 31 ++++++++++++------- .../src/__snapshots__/index.test.ts.snap | 18 +++++------ .../logic-grid/src/clues/templates.test.ts | 5 ++- packages/logic-grid/src/clues/templates.ts | 26 +++++++++++----- packages/logic-grid/src/default-config.ts | 7 +++++ packages/logic-grid/src/index.test.ts | 2 ++ packages/logic-grid/src/types.ts | 2 ++ 7 files changed, 61 insertions(+), 30 deletions(-) diff --git a/packages/logic-grid-ai/src/theme.ts b/packages/logic-grid-ai/src/theme.ts index b1303a2..642a00e 100644 --- a/packages/logic-grid-ai/src/theme.ts +++ b/packages/logic-grid-ai/src/theme.ts @@ -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 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, lowercase: true, and subjectPriority -1.', }, ordered: { type: "boolean", @@ -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, ...) @@ -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"] } ], } @@ -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"] } ], } @@ -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 diff --git a/packages/logic-grid/src/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/__snapshots__/index.test.ts.snap index dd96ecc..dc1f709 100644 --- a/packages/logic-grid/src/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/__snapshots__/index.test.ts.snap @@ -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.", + "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.", + "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.", ] `; diff --git a/packages/logic-grid/src/clues/templates.test.ts b/packages/logic-grid/src/clues/templates.test.ts index e4da40d..1b9b9fe 100644 --- a/packages/logic-grid/src/clues/templates.test.ts +++ b/packages/logic-grid/src/clues/templates.test.ts @@ -18,6 +18,7 @@ const grid = makeGrid({ noun: "house", verb: ["lives in the", "does not live in the"], subjectPriority: -1, + lowercase: true, valueSuffix: "house", positionAdjective: ["is", "is not"], }, @@ -27,6 +28,7 @@ const grid = makeGrid({ noun: "owner", verb: ["owns the", "does not own the"], subjectPriority: 1, + lowercase: true, }, { name: "Drink", @@ -34,6 +36,7 @@ const grid = makeGrid({ noun: "drinker", verb: ["drinks", "does not drink"], subjectPriority: 1, + lowercase: true, }, ], }); @@ -293,7 +296,7 @@ describe("custom category noun/verb", () => { { type: "same_position", a: "Alice", b: "Toyota" }, customGrid, ); - expect(clue.text).toBe("Alice drives the toyota."); + expect(clue.text).toBe("Alice drives the Toyota."); }); it("throws when object category has no verb", () => { diff --git a/packages/logic-grid/src/clues/templates.ts b/packages/logic-grid/src/clues/templates.ts index b1c8328..bce96b8 100644 --- a/packages/logic-grid/src/clues/templates.ts +++ b/packages/logic-grid/src/clues/templates.ts @@ -10,16 +10,18 @@ function findCategory(value: string, grid: Grid): Category { /** Natural noun phrase: "Alice", "the red house", "the cat owner". */ function label(value: string, grid: Grid): string { - const noun = findCategory(value, grid).noun; - if (!noun) return value; - return `the ${value.toLowerCase()} ${noun}`; + const cat = findCategory(value, grid); + if (!cat.noun) return value; + const v = cat.lowercase ? value.toLowerCase() : value; + return `the ${v} ${cat.noun}`; } /** Value as it appears in the object position of a same_position clue. */ function objectValue(value: string, grid: Grid): string { const cat = findCategory(value, grid); + const v = cat.lowercase ? value.toLowerCase() : value; const suffix = cat.valueSuffix; - return suffix ? `${value.toLowerCase()} ${suffix}` : value.toLowerCase(); + return suffix ? `${v} ${suffix}` : v; } /** Look up a symmetric comparator (plain string). */ @@ -96,10 +98,12 @@ function renderSamePosition( // adjective verb. Recovers the classical Color+House idiom: // `same_position(Red, "1st")` → "The 1st house is red." if (catA.positionAdjective && catB.ordered === true) { - return `${capitalize(label(constraint.b, grid))} ${catA.positionAdjective[idx]} ${constraint.a.toLowerCase()}.`; + const v = catA.lowercase ? constraint.a.toLowerCase() : constraint.a; + return `${capitalize(label(constraint.b, grid))} ${catA.positionAdjective[idx]} ${v}.`; } if (catB.positionAdjective && catA.ordered === true) { - return `${capitalize(label(constraint.a, grid))} ${catB.positionAdjective[idx]} ${constraint.b.toLowerCase()}.`; + const v = catB.lowercase ? constraint.b.toLowerCase() : constraint.b; + return `${capitalize(label(constraint.a, grid))} ${catB.positionAdjective[idx]} ${v}.`; } const [subj, obj] = ordered(constraint.a, constraint.b, grid); @@ -187,7 +191,10 @@ function renderText(constraint: Constraint, grid: Grid): string { const axisVal = axis.values[constraint.position]; const cat = findCategory(constraint.value, grid); if (cat.positionAdjective) { - return `${capitalize(label(axisVal, grid))} ${cat.positionAdjective[0]} ${constraint.value.toLowerCase()}.`; + const v = cat.lowercase + ? constraint.value.toLowerCase() + : constraint.value; + return `${capitalize(label(axisVal, grid))} ${cat.positionAdjective[0]} ${v}.`; } return `${capitalize(label(constraint.value, grid))} ${axis.verb[0]} ${objectValue(axisVal, grid)}.`; } @@ -197,7 +204,10 @@ function renderText(constraint: Constraint, grid: Grid): string { const axisVal = axis.values[constraint.position]; const cat = findCategory(constraint.value, grid); if (cat.positionAdjective) { - return `${capitalize(label(axisVal, grid))} ${cat.positionAdjective[1]} ${constraint.value.toLowerCase()}.`; + const v = cat.lowercase + ? constraint.value.toLowerCase() + : constraint.value; + return `${capitalize(label(axisVal, grid))} ${cat.positionAdjective[1]} ${v}.`; } return `${capitalize(label(constraint.value, grid))} ${axis.verb[1]} ${objectValue(axisVal, grid)}.`; } diff --git a/packages/logic-grid/src/default-config.ts b/packages/logic-grid/src/default-config.ts index 934e809..fb48494 100644 --- a/packages/logic-grid/src/default-config.ts +++ b/packages/logic-grid/src/default-config.ts @@ -18,6 +18,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "house", verb: ["lives in the", "does not live in the"], subjectPriority: -1, + lowercase: true, valueSuffix: "house", positionAdjective: ["is", "is not"], values: [ @@ -36,6 +37,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "owner", verb: ["owns the", "does not own the"], subjectPriority: 1, + lowercase: true, values: [ "Cat", "Dog", @@ -52,6 +54,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "drinker", verb: ["drinks", "does not drink"], subjectPriority: 1, + lowercase: true, values: ["Tea", "Coffee", "Water", "Milk", "Juice", "Soda", "Wine", "Beer"], }, { @@ -59,6 +62,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "lover", verb: ["eats", "does not eat"], subjectPriority: 1, + lowercase: true, values: [ "Pizza", "Pasta", @@ -75,6 +79,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "enthusiast", verb: ["enjoys", "does not enjoy"], subjectPriority: 1, + lowercase: true, values: [ "Reading", "Painting", @@ -91,6 +96,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "fan", verb: ["listens to", "does not listen to"], subjectPriority: 1, + lowercase: true, values: ["Jazz", "Rock", "Pop", "Blues", "Folk", "Reggae", "Metal", "Punk"], }, { @@ -98,6 +104,7 @@ export const DEFAULT_CATEGORIES: Category[] = [ noun: "player", verb: ["plays", "does not play"], subjectPriority: 1, + lowercase: true, values: [ "Soccer", "Tennis", diff --git a/packages/logic-grid/src/index.test.ts b/packages/logic-grid/src/index.test.ts index cc856f1..8d9c987 100644 --- a/packages/logic-grid/src/index.test.ts +++ b/packages/logic-grid/src/index.test.ts @@ -83,12 +83,14 @@ describe("public API integration", () => { values: ["Piano", "Guitar", "Drums", "Violin"], noun: "player", verb: ["plays the", "does not play the"], + lowercase: true, }, { name: "Flower", values: ["Rose", "Lily", "Daisy", "Tulip"], noun: "grower", verb: ["grows the", "does not grow the"], + lowercase: true, }, { name: "Language", diff --git a/packages/logic-grid/src/types.ts b/packages/logic-grid/src/types.ts index c9137bb..7541f8a 100644 --- a/packages/logic-grid/src/types.ts +++ b/packages/logic-grid/src/types.ts @@ -46,6 +46,8 @@ interface CategoryCore { noun?: string; /** Subject priority for same-position clues. Higher = more likely to be the sentence subject. */ subjectPriority?: number; + /** When true, values are lowercased in clue phrases. Use for adjective/common-noun categories (Color, Pet) where "Red" should render as "the red house". Default: false (proper nouns preserved). */ + lowercase?: boolean; } /** From 2a00682018c55d8912339b3362249310adeea286 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:25:57 +0200 Subject: [PATCH 02/28] feat: axis-aware deduction explanations Replace hardcoded "position"/"in" in all deduction explanation strings with the first ordered category's noun and values. Explanations now say "the first house" instead of "the first position" for House-axis puzzles, and use the axis noun for any ordered category (e.g. "rank"). --- .../deduce/__snapshots__/index.test.ts.snap | 28 ++++++------- .../logic-grid/src/deduce/constraints.test.ts | 6 +-- packages/logic-grid/src/deduce/constraints.ts | 38 +++++++++--------- .../logic-grid/src/deduce/contradiction.ts | 10 ++--- packages/logic-grid/src/deduce/state.test.ts | 10 ++--- packages/logic-grid/src/deduce/state.ts | 40 ++++++++++++++----- packages/logic-grid/src/deduce/structural.ts | 24 ++++++----- 7 files changed, 88 insertions(+), 68 deletions(-) diff --git a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap index 8fb39ec..cb0409b 100644 --- a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap @@ -2,19 +2,19 @@ exports[`deduce > snapshots explanation strings 1`] = ` [ - "Clue 1: Red must be in the first position.", - "Clue 2: Red and Cat are in the same position. Red is in the first position, so both are in the first position.", - "Clue 3: Blue is directly left of Green. Blue can only be in the first or second position, so Blue can't be in the third position; Green can't be in the first position.", - "Clue 4: Blue and Dog are in the same position. Blue can only be in the first or second position, so Dog can't be in the third position.", - "Clue 5: Dog and Coffee are in the same position. Dog can only be in the first or second position, so Coffee can't be in the third position.", - "Clue 6: Tea must be in the first position.", - "Red has no other possible position — it must be in the first position. So no other Color can be there.", - "Clue 3: Blue is directly left of Green. Blue is in the second position, so Green must be in the third position.", - "Clue 4: Blue and Dog are in the same position. Blue is in the second position, so both are in the second position.", - "Clue 5: Dog and Coffee are in the same position. Dog is in the second position, so both are in the second position.", - "Cat has no other possible position — it must be in the first position. So no other Pet can be there.", - "Dog has no other possible position — it must be in the second position. So no other Pet can be there.", - "Tea has no other possible position — it must be in the first position. So no other Drink can be there.", - "Coffee has no other possible position — it must be in the second position. So no other Drink can be there.", + "Clue 1: Red must be in the first house.", + "Clue 2: Red and Cat are in the same house. Red is in the first house, so both are in the first house.", + "Clue 3: Blue is directly left of Green. Blue can only be in the first or second house, so Blue can't be in the third house; Green can't be in the first house.", + "Clue 4: Blue and Dog are in the same house. Blue can only be in the first or second house, so Dog can't be in the third house.", + "Clue 5: Dog and Coffee are in the same house. Dog can only be in the first or second house, so Coffee can't be in the third house.", + "Clue 6: Tea must be in the first house.", + "Red has no other possible house — it must be in the first house. So no other Color can be there.", + "Clue 3: Blue is directly left of Green. Blue is in the second house, so Green must be in the third house.", + "Clue 4: Blue and Dog are in the same house. Blue is in the second house, so both are in the second house.", + "Clue 5: Dog and Coffee are in the same house. Dog is in the second house, so both are in the second house.", + "Cat has no other possible house — it must be in the first house. So no other Pet can be there.", + "Dog has no other possible house — it must be in the second house. So no other Pet can be there.", + "Tea has no other possible house — it must be in the first house. So no other Drink can be there.", + "Coffee has no other possible house — it must be in the second house. So no other Drink can be there.", ] `; diff --git a/packages/logic-grid/src/deduce/constraints.test.ts b/packages/logic-grid/src/deduce/constraints.test.ts index 87d8dad..6ea6e58 100644 --- a/packages/logic-grid/src/deduce/constraints.test.ts +++ b/packages/logic-grid/src/deduce/constraints.test.ts @@ -364,10 +364,10 @@ describe("deduce constraint types", () => { const result = deduce(constraints, noUnitGrid); const step = result.steps.find((s) => s.technique === "exact_distance"); expect(step).toBeDefined(); - expect(step!.explanation).toContain("2 positions"); + expect(step!.explanation).toContain("2 ranks"); }); - it("exact_distance explanation uses singular 'position' when distance=1 and no unit", () => { + it("exact_distance explanation uses singular noun when distance=1 and no unit", () => { const noUnitGrid = makeGrid({ size: 4, categories: [ @@ -395,7 +395,7 @@ describe("deduce constraint types", () => { const result = deduce(constraints, noUnitGrid); const step = result.steps.find((s) => s.technique === "exact_distance"); expect(step).toBeDefined(); - expect(step!.explanation).toContain("1 position"); + expect(step!.explanation).toContain("1 rank"); }); it("exact_distance constrains positions", () => { diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index e1b30df..d3cf252 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -4,7 +4,6 @@ import type { DeductionStep, DeductionTechnique, } from "../types"; -import { ordinal } from "../grid-utils"; import { resolveAxis } from "../axis"; import { type DeduceState, @@ -18,6 +17,7 @@ import { describeResult, clueRef, describeKnown, + axisTerms, axisRankDomain, projectRanksToPositions, } from "./state"; @@ -149,12 +149,13 @@ function tryAtPosition( ps.clear(); ps.add(c.position); if (state.silent) return SILENT_STEP; + const { noun, posLabel } = axisTerms(state.grid); return step( "direct", [ci], elims, [{ value: c.value, position: c.position }], - `Clue ${ci + 1}: ${c.value} must be in the ${ordinal(c.position)} position.`, + `Clue ${ci + 1}: ${c.value} must be in the ${posLabel(c.position)} ${noun}.`, ); } @@ -167,18 +168,19 @@ function tryNotAtPosition( if (!ps.has(c.position)) return null; ps.delete(c.position); if (state.silent) return SILENT_STEP; + const { noun, posLabel } = axisTerms(state.grid); const assigns = ps.size === 1 ? [{ value: c.value, position: first(ps) }] : []; const suffix = assigns.length > 0 - ? `, so ${c.value} must be in the ${ordinal(assigns[0].position)} position.` + ? `, so ${c.value} must be in the ${posLabel(assigns[0].position)} ${noun}.` : "."; return step( "elimination", [ci], [{ value: c.value, position: c.position }], assigns, - `Clue ${ci + 1}: ${c.value} is not in the ${ordinal(c.position)} position${suffix}`, + `Clue ${ci + 1}: ${c.value} is not in the ${posLabel(c.position)} ${noun}${suffix}`, ); } @@ -217,13 +219,12 @@ function trySamePosition( const ctx = knownA || knownB; const because = ctx ? `. ${ctx}, so ` : ", so "; - const noun = "position"; - const prep = "in"; + const { noun, posLabel } = axisTerms(state.grid); let explanation: string; if (assigns.length > 0) { - explanation = `${clueRef(ci)}${c.a} and ${c.b} are ${prep} the same ${noun}${because}both are ${prep} the ${ordinal(assigns[0].position)} ${noun}.`; + explanation = `${clueRef(ci)}${c.a} and ${c.b} are in the same ${noun}${because}both are in the ${posLabel(assigns[0].position)} ${noun}.`; } else { - explanation = `${clueRef(ci)}${c.a} and ${c.b} are ${prep} the same ${noun}${because}${describeResult(state.grid, assigns, elims)}.`; + explanation = `${clueRef(ci)}${c.a} and ${c.b} are in the same ${noun}${because}${describeResult(state.grid, assigns, elims)}.`; } return step("same_position", [ci], elims, assigns, explanation); } @@ -254,18 +255,17 @@ function tryNotSamePosition( const pinned = posA !== null ? c.a : c.b; const pinnedPos = posA ?? posB!; const other = posA !== null ? c.b : c.a; - const noun = "position"; - const prep = "in"; + const { noun, posLabel } = axisTerms(state.grid); const assignSuffix = assigns.length > 0 - ? ` ${assigns.map((a) => `${a.value} must be ${prep} the ${ordinal(a.position)} ${noun}`).join("; ")}.` + ? ` ${assigns.map((a) => `${a.value} must be in the ${posLabel(a.position)} ${noun}`).join("; ")}.` : ""; return step( "not_same_position", [ci], elims, assigns, - `${clueRef(ci)}${pinned} and ${other} are ${prep} different positions. ${pinned} is ${prep} the ${ordinal(pinnedPos)} ${noun}, so ${other} can't be there.${assignSuffix}`, + `${clueRef(ci)}${pinned} and ${other} are in different ${noun}s. ${pinned} is in the ${posLabel(pinnedPos)} ${noun}, so ${other} can't be there.${assignSuffix}`, ); } @@ -580,11 +580,10 @@ function tryBetween( let because: string; if (a1 !== null && a2 !== null) { - const noun = "position"; - const prep = "in"; + const { noun, posLabel } = axisTerms(state.grid); const parts = [ - `${c.outer1} is ${prep} the ${ordinal(a1)} ${noun}`, - `${c.outer2} is ${prep} the ${ordinal(a2)} ${noun}`, + `${c.outer1} is in the ${posLabel(a1)} ${noun}`, + `${c.outer2} is in the ${posLabel(a2)} ${noun}`, ]; because = ` ${parts.join(" and ")}, so `; } else { @@ -664,9 +663,8 @@ function tryNotBetween( let because: string; if (a1 !== null && a2 !== null) { - const noun = "position"; - const prep = "in"; - because = ` ${c.outer1} is ${prep} the ${ordinal(a1)} ${noun} and ${c.outer2} is ${prep} the ${ordinal(a2)} ${noun}, so `; + const { noun, posLabel } = axisTerms(state.grid); + because = ` ${c.outer1} is in the ${posLabel(a1)} ${noun} and ${c.outer2} is in the ${posLabel(a2)} ${noun}, so `; } else { // At least one outer always has a description for supported grid sizes (3–8): // the neither-pinned case needs 4+4+1=9 positions, exceeding max size 8. @@ -851,7 +849,7 @@ function tryExactDistance( const unit = axis.orderingPhrases.unit; const distLabel = unit ? `${c.distance} ${c.distance === 1 ? unit[0] : unit[1]}` - : `${c.distance} ${c.distance === 1 ? "position" : "positions"}`; + : `${c.distance} ${c.distance === 1 ? axisTerms(state.grid).noun : axisTerms(state.grid).noun + "s"}`; // At least one value always has a description for supported grid sizes (3–8). const ctx = describeKnown(state, c.a) || describeKnown(state, c.b); const because = ` ${ctx}, so `; diff --git a/packages/logic-grid/src/deduce/contradiction.ts b/packages/logic-grid/src/deduce/contradiction.ts index 53b9b61..6ad5168 100644 --- a/packages/logic-grid/src/deduce/contradiction.ts +++ b/packages/logic-grid/src/deduce/contradiction.ts @@ -1,6 +1,5 @@ import type { Constraint, DeductionStep } from "../types"; -import { ordinal } from "../grid-utils"; -import { type DeduceState, first, step, cloneState } from "./state"; +import { type DeduceState, first, step, cloneState, axisTerms } from "./state"; import { propagateToFixpoint } from "./propagate"; /** @@ -32,18 +31,17 @@ export function tryContradiction( ps.delete(p); const value = state.grid.categories[ci].values[vi]; const assigns = ps.size === 1 ? [{ value, position: first(ps) }] : []; - const noun = "position"; - const prep = "in"; + const { noun, posLabel } = axisTerms(state.grid); const assignSuffix = assigns.length > 0 - ? ` So ${value} must be ${prep} the ${ordinal(assigns[0].position)} ${noun}.` + ? ` So ${value} must be in the ${posLabel(assigns[0].position)} ${noun}.` : ""; return step( "contradiction", [], [{ value, position: p }], assigns, - `If ${value} were ${prep} the ${ordinal(p)} ${noun}, it would lead to a contradiction.${assignSuffix}`, + `If ${value} were in the ${posLabel(p)} ${noun}, it would lead to a contradiction.${assignSuffix}`, ); } } diff --git a/packages/logic-grid/src/deduce/state.test.ts b/packages/logic-grid/src/deduce/state.test.ts index 9f054be..a3e5c8d 100644 --- a/packages/logic-grid/src/deduce/state.test.ts +++ b/packages/logic-grid/src/deduce/state.test.ts @@ -13,12 +13,12 @@ const grid = makeGrid({ describe("describeResult", () => { it("describes assignments", () => { const result = describeResult(grid, [{ value: "Alice", position: 0 }], []); - expect(result).toBe("Alice must be in the first position"); + expect(result).toBe("Alice must be in the first house"); }); it("describes eliminations", () => { const result = describeResult(grid, [], [{ value: "Bob", position: 1 }]); - expect(result).toBe("Bob can't be in the second position"); + expect(result).toBe("Bob can't be in the second house"); }); it("combines assignments and eliminations", () => { @@ -28,7 +28,7 @@ describe("describeResult", () => { [{ value: "Bob", position: 2 }], ); expect(result).toBe( - "Alice must be in the first position; Bob can't be in the third position", + "Alice must be in the first house; Bob can't be in the third house", ); }); }); @@ -53,7 +53,7 @@ describe("describeKnown", () => { state.possible[1][0].clear(); state.possible[1][0].add(0); expect(describeKnown(state, "Alice")).toBe( - "Alice is in the first position", + "Alice is in the first house", ); }); @@ -63,7 +63,7 @@ describe("describeKnown", () => { state.possible[1][1].add(0); state.possible[1][1].add(2); expect(describeKnown(state, "Bob")).toBe( - "Bob can only be in the first or third position", + "Bob can only be in the first or third house", ); }); }); diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index 5eb60f6..1a44941 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -4,20 +4,39 @@ import type { DeductionStep, DeductionTechnique, } from "../types"; +import { orderedCategories } from "../axis"; import { ordinal } from "../grid-utils"; // --- Display utilities --- +/** Axis-derived phrasing for deduction explanations. */ +export interface AxisTerms { + noun: string; + posLabel: (p: number) => string; +} + +/** + * Get axis-aware terminology for explanations from the first ordered category + * (identity-pinned axis). Returns noun (e.g. "house") and position label + * function (e.g. p=0 → "first"). + */ +export function axisTerms(grid: Grid): AxisTerms { + const axis = orderedCategories(grid)[0]; + return { + noun: axis?.noun || "position", + posLabel: (p) => axis?.values[p] ?? ordinal(p), + }; +} + export function describeResult( - _grid: Grid, + grid: Grid, assigns: { value: string; position: number }[], elims: { value: string; position: number }[], ): string { - const noun = "position"; - const prep = "in"; + const { noun, posLabel } = axisTerms(grid); const parts: string[] = []; for (const a of assigns) { - parts.push(`${a.value} must be ${prep} the ${ordinal(a.position)} ${noun}`); + parts.push(`${a.value} must be in the ${posLabel(a.position)} ${noun}`); } // Group eliminations by value const byValue = new Map(); @@ -28,8 +47,8 @@ export function describeResult( byValue.get(e.value)!.push(e.position); } for (const [value, positions] of byValue) { - const posStr = positions.map((p) => ordinal(p)).join(" or "); - parts.push(`${value} can't be ${prep} the ${posStr} ${noun}`); + const posStr = positions.map((p) => posLabel(p)).join(" or "); + parts.push(`${value} can't be in the ${posStr} ${noun}`); } return parts.join("; "); } @@ -40,14 +59,13 @@ export function clueRef(ci: number): string { /** Describe what we know about a value's position — used for "because" context. */ export function describeKnown(state: DeduceState, value: string): string { - const noun = "position"; - const prep = "in"; + const { noun, posLabel } = axisTerms(state.grid); const pos = getAssigned(state, value); - if (pos !== null) return `${value} is ${prep} the ${ordinal(pos)} ${noun}`; + if (pos !== null) return `${value} is in the ${posLabel(pos)} ${noun}`; const possible = getPossible(state, value); if (possible.size <= 3) { - const posStr = [...possible].map((p) => ordinal(p)).join(" or "); - return `${value} can only be ${prep} the ${posStr} ${noun}`; + const posStr = [...possible].map((p) => posLabel(p)).join(" or "); + return `${value} can only be in the ${posStr} ${noun}`; } return ""; } diff --git a/packages/logic-grid/src/deduce/structural.ts b/packages/logic-grid/src/deduce/structural.ts index 2c64839..4e66009 100644 --- a/packages/logic-grid/src/deduce/structural.ts +++ b/packages/logic-grid/src/deduce/structural.ts @@ -1,5 +1,4 @@ import type { DeductionStep } from "../types"; -import { ordinal } from "../grid-utils"; import { type DeduceState, SILENT_STEP, @@ -8,6 +7,7 @@ import { step, dedup, collectAssigns, + axisTerms, } from "./state"; // --- Structural deductions --- @@ -38,12 +38,13 @@ export function tryNakedSingles(state: DeduceState): DeductionStep | null { if (elims.length === 0) continue; if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); + const { noun, posLabel } = axisTerms(state.grid); return step( "naked_single", [], elims, assigns, - `${cat.values[vi]} has no other possible position — it must be in the ${ordinal(pos)} position. So no other ${cat.name} can be there.`, + `${cat.values[vi]} has no other possible ${noun} — it must be in the ${posLabel(pos)} ${noun}. So no other ${cat.name} can be there.`, ); } } @@ -72,12 +73,13 @@ export function tryHiddenSingles(state: DeduceState): DeductionStep | null { state.possible[ci][lastVi].clear(); state.possible[ci][lastVi].add(p); if (state.silent) return SILENT_STEP; + const { noun, posLabel } = axisTerms(state.grid); return step( "hidden_single", [], elims, [{ value: val, position: p }], - `The ${ordinal(p)} position must be ${val} (only remaining ${cat.name}).`, + `The ${posLabel(p)} ${noun} must be ${val} (only remaining ${cat.name}).`, ); } } @@ -130,13 +132,14 @@ export function tryNakedPairs(state: DeduceState): DeductionStep | null { if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); - const positions = [...ps1].map((p) => ordinal(p)).join(" and "); + const { noun, posLabel } = axisTerms(state.grid); + const positions = [...ps1].map((p) => posLabel(p)).join(" and "); return step( "naked_pair", [], elims, assigns, - `${cat.values[vi1]} and ${cat.values[vi2]} can only be in the ${positions} positions, so no other ${cat.name} can be there.`, + `${cat.values[vi1]} and ${cat.values[vi2]} can only be in the ${positions} ${noun}s, so no other ${cat.name} can be there.`, ); } } @@ -181,13 +184,14 @@ export function tryNakedTriples(state: DeduceState): DeductionStep | null { if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); - const positions = [...union].map((p) => ordinal(p)).join(", "); + const { noun, posLabel } = axisTerms(state.grid); + const positions = [...union].map((p) => posLabel(p)).join(", "); return step( "naked_triple", [], elims, assigns, - `${cat.values[vi1]}, ${cat.values[vi2]}, and ${cat.values[vi3]} can only be in the ${positions} positions, so no other ${cat.name} can be there.`, + `${cat.values[vi1]}, ${cat.values[vi2]}, and ${cat.values[vi3]} can only be in the ${positions} ${noun}s, so no other ${cat.name} can be there.`, ); } } @@ -231,12 +235,13 @@ export function tryHiddenPairs(state: DeduceState): DeductionStep | null { getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, uniqueElims); + const { noun, posLabel } = axisTerms(state.grid); return step( "hidden_pair", [], uniqueElims, assigns, - `${cat.values[vi1]} and ${cat.values[vi2]} are the only ${cat.name} values for the ${ordinal(p1)} and ${ordinal(p2)} positions, so they must be restricted to those positions.`, + `${cat.values[vi1]} and ${cat.values[vi2]} are the only ${cat.name} values for the ${posLabel(p1)} and ${posLabel(p2)} ${noun}s, so they must be restricted to those ${noun}s.`, ); } } @@ -280,12 +285,13 @@ export function tryHiddenTriples(state: DeduceState): DeductionStep | null { getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, uniqueElims); + const { noun, posLabel } = axisTerms(state.grid); return step( "hidden_triple", [], uniqueElims, assigns, - `${cat.values[vi1]}, ${cat.values[vi2]}, and ${cat.values[vi3]} are the only ${cat.name} values for the ${ordinal(p1)}, ${ordinal(p2)}, and ${ordinal(p3)} positions, so they must be restricted to those positions.`, + `${cat.values[vi1]}, ${cat.values[vi2]}, and ${cat.values[vi3]} are the only ${cat.name} values for the ${posLabel(p1)}, ${posLabel(p2)}, and ${posLabel(p3)} ${noun}s, so they must be restricted to those ${noun}s.`, ); } } From 451e10b63737befe1b8561422a131005c131d7b3 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:31:37 +0200 Subject: [PATCH 03/28] feat: optimize between encoder with rank auxiliary variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the O(M³·n³) rank-forbidding between encoder and O(M²·n²) binary encoder with rank auxiliary variables. Define r(v,k) = "value v has rank k on axis", linked via forward channeling + AMO + ALO. Between constraints now emit 3-literal clauses over rank vars instead of 6-literal clauses over position×rank pairs. Binary constraints use 2-literal rank clauses instead of 4-literal. Clause count drops from ~260k to ~1k at n=8. The RankVarAllocator caches per (axis, value) pair to ensure channeling clauses are emitted exactly once across all constraints on the same axis. --- packages/logic-grid/src/encoding.ts | 168 +++++++++++++++++---------- packages/logic-grid/src/generator.ts | 21 +++- 2 files changed, 123 insertions(+), 66 deletions(-) diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index 085c3f9..537bb0c 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -63,64 +63,47 @@ function badBinaryRankPairs( } /** - * Rank-forbidding encoder for binary comparative constraints. For each bad - * rank pair (i, j) and each ordered position pair (p1, p2) with p1 ≠ p2, - * emits the 4-literal clause - * [¬x(a, p1), ¬x(axis[i], p1), ¬x(b, p2), ¬x(axis[j], p2)] - * which forbids the simultaneous assignment. + * Rank-var encoder for binary comparative constraints. For each bad rank pair + * (i, j), emits a 2-literal clause [-r(a,i), -r(b,j)]. Channeling clauses + * link rank vars to position vars (emitted once per (axis, value) pair via + * the RankVarAllocator). * - * Clause count per constraint: |bad pairs| × n(n−1). Worst case O(M²·n²). + * Clause count per constraint: |bad pairs| × 1 + channeling. */ function encodeBinaryAxis( ctx: EncodingContext, + alloc: RankVarAllocator, a: string, b: string, axis: Category, badPairs: [number, number][], ): number[][] { - const n = ctx.numPositions; const clauses: number[][] = []; for (const [i, j] of badPairs) { - const ai = axis.values[i]; - const aj = axis.values[j]; - for (let p1 = 0; p1 < n; p1++) { - for (let p2 = 0; p2 < n; p2++) { - // When i ≠ j and p1 = p2, base clauses forbid two distinct axis - // values coexisting at the same position, so the clause is vacuous. - // When i = j, we MUST emit the p1 = p2 case — it forbids `a` and - // `b` sharing the same position (and thus the same rank). - if (p1 === p2 && i !== j) continue; - clauses.push([ - -variable(ctx, a, p1), - -variable(ctx, ai, p1), - -variable(ctx, b, p2), - -variable(ctx, aj, p2), - ]); - } - } + clauses.push([ + -alloc.rankVar(ctx, axis, a, i), + -alloc.rankVar(ctx, axis, b, j), + ]); } return clauses; } /** - * Rank-forbidding encoder for ternary between/not_between. For each bad - * (rank_o1, rank_o2, rank_middle) triple and each position triple (p1, p2, p3) - * with all positions distinct, emits a 6-literal clause forbidding the - * assignment. + * Rank-var encoder for ternary between/not_between. For each bad rank triple + * (i, j, k), emits a 3-literal clause [-r(outer1,i), -r(outer2,j), -r(middle,k)]. * - * Complexity: O(M³·n³) worst case — ~260k clause candidates at M=n=8. - * Acceptable for the supported grid size range (3–8) but would need - * optimization (e.g. exploiting ALO/AMO redundancy) if the range expanded. + * Complexity: O(M³) constraint clauses + O(V·M·n) channeling. At M=n=8 + * with 3 values: ~512 + ~192 + ~84 + 3 ≈ ~800 clauses instead of ~260k. */ function encodeBetweenAxis( ctx: EncodingContext, + alloc: RankVarAllocator, outer1: string, middle: string, outer2: string, axis: Category, forbidStrictlyBetween: boolean, ): number[][] { - const n = ctx.numPositions; const M = axis.values.length; const clauses: number[][] = []; for (let i = 0; i < M; i++) { @@ -128,37 +111,16 @@ function encodeBetweenAxis( for (let k = 0; k < M; k++) { const lo = Math.min(i, j); const hi = Math.max(i, j); - // "strictly between" requires i ≠ j (non-degenerate outer pair) and - // k lies strictly inside the open interval (lo, hi). const strictlyBetween = i !== j && k > lo && k < hi; - // `between` is violated when middle is NOT strictly between outers. - // `not_between` is violated when middle IS strictly between outers. const violates = forbidStrictlyBetween ? strictlyBetween : !strictlyBetween; if (!violates) continue; - const ai = axis.values[i]; - const aj = axis.values[j]; - const ak = axis.values[k]; - for (let p1 = 0; p1 < n; p1++) { - for (let p2 = 0; p2 < n; p2++) { - // When i ≠ j and p1 = p2, a_i and a_j collide → vacuous. - if (p1 === p2 && i !== j) continue; - for (let p3 = 0; p3 < n; p3++) { - // Same vacuous-collision skips for the middle's axis slot. - if (p3 === p1 && k !== i) continue; - if (p3 === p2 && k !== j) continue; - clauses.push([ - -variable(ctx, outer1, p1), - -variable(ctx, ai, p1), - -variable(ctx, outer2, p2), - -variable(ctx, aj, p2), - -variable(ctx, middle, p3), - -variable(ctx, ak, p3), - ]); - } - } - } + clauses.push([ + -alloc.rankVar(ctx, axis, outer1, i), + -alloc.rankVar(ctx, axis, outer2, j), + -alloc.rankVar(ctx, axis, middle, k), + ]); } } } @@ -190,6 +152,86 @@ export function createContext(grid: Grid): EncodingContext { }; } +/** + * Allocates rank auxiliary variables r(v,k) = "value v has rank k on axis". + * These variables decouple rank from position, letting comparative constraints + * use compact 2-3 literal clauses instead of the O(M²·n²)/O(M³·n³) rank- + * forbidding clauses. + * + * Channeling clauses are emitted exactly once per (axis, value) pair. The + * allocator caches variable IDs so constraints sharing an axis reuse them. + */ +export class RankVarAllocator { + /** Next available variable ID, starting above position variables. */ + private nextVar: number; + /** Cache: "axisName:value" → base var index for that value's rank vars. */ + private cache = new Map(); + /** Accumulated channeling clauses (forward + AMO + ALO). */ + readonly channeling: number[][] = []; + + constructor(ctx: EncodingContext) { + this.nextVar = ctx.numValues * ctx.numPositions + 1; + } + + /** High-water mark: the first variable ID NOT used by this allocator. */ + get varCeiling(): number { + return this.nextVar; + } + + /** + * Get or create the rank variable for (value, rank) on the given axis. + * If this is the first request for this (axis, value) pair, allocates M + * rank vars and emits channeling + AMO + ALO clauses. + */ + rankVar( + ctx: EncodingContext, + axis: Category, + value: string, + rank: number, + ): number { + const key = `${axis.name}:${value}`; + let base = this.cache.get(key); + if (base === undefined) { + base = this.nextVar; + const M = axis.values.length; + this.nextVar += M; + this.cache.set(key, base); + this.emitChanneling(ctx, axis, value, base, M); + } + return base + rank; + } + + private emitChanneling( + ctx: EncodingContext, + axis: Category, + value: string, + base: number, + M: number, + ): void { + const n = ctx.numPositions; + // Forward: x(v,p) ∧ x(axis[k],p) → r(v,k) + for (let k = 0; k < M; k++) { + for (let p = 0; p < n; p++) { + this.channeling.push([ + -variable(ctx, value, p), + -variable(ctx, axis.values[k], p), + base + k, + ]); + } + } + // AMO: at most one rank per value + for (let k1 = 0; k1 < M; k1++) { + for (let k2 = k1 + 1; k2 < M; k2++) { + this.channeling.push([-(base + k1), -(base + k2)]); + } + } + // ALO: at least one rank per value + const alo: number[] = []; + for (let k = 0; k < M; k++) alo.push(base + k); + this.channeling.push(alo); + } +} + /** SAT variable for "value v is at position p". 1-based. */ export function variable( ctx: EncodingContext, @@ -451,6 +493,7 @@ function encodePositionalExactDistance( export function encodeConstraint( ctx: EncodingContext, constraint: Constraint, + alloc?: RankVarAllocator, ): number[][] { const n = ctx.numPositions; @@ -497,7 +540,7 @@ export function encodeConstraint( 0, undefined, ); - return encodeBinaryAxis(ctx, constraint.a, constraint.b, axis, bad); + return encodeBinaryAxis(ctx, alloc!, constraint.a, constraint.b, axis, bad); } case "exact_distance": { @@ -517,7 +560,7 @@ export function encodeConstraint( constraint.distance, axis.numericValues, ); - return encodeBinaryAxis(ctx, constraint.a, constraint.b, axis, bad); + return encodeBinaryAxis(ctx, alloc!, constraint.a, constraint.b, axis, bad); } case "between": @@ -540,6 +583,7 @@ export function encodeConstraint( } return encodeBetweenAxis( ctx, + alloc!, constraint.outer1, constraint.middle, constraint.outer2, @@ -563,9 +607,11 @@ export function encodePuzzle( ctx: EncodingContext, constraints: Constraint[], ): number[][] { + const alloc = new RankVarAllocator(ctx); const clauses = encodeBase(ctx); for (const c of constraints) { - for (const clause of encodeConstraint(ctx, c)) clauses.push(clause); + for (const clause of encodeConstraint(ctx, c, alloc)) clauses.push(clause); } + for (const clause of alloc.channeling) clauses.push(clause); return clauses; } diff --git a/packages/logic-grid/src/generator.ts b/packages/logic-grid/src/generator.ts index a9b932e..67c4d7b 100644 --- a/packages/logic-grid/src/generator.ts +++ b/packages/logic-grid/src/generator.ts @@ -10,7 +10,7 @@ import type { } from "./types"; import type { SolverContext } from "./solver"; import { createSolverContext } from "./solver"; -import { encodeConstraint } from "./encoding"; +import { encodeConstraint, RankVarAllocator } from "./encoding"; import { IncrementalSolver } from "./sat"; import { renderClue } from "./clues/templates"; import { classify, EASY_TYPES, MEDIUM_TYPES } from "./difficulty"; @@ -85,10 +85,17 @@ export function generate(options?: GenerateOptions): Puzzle { shuffle(filtered, rng); // Pre-encode all constraint clauses once - const clauseCache = filtered.map((c) => encodeConstraint(solverCtx.ctx, c)); + const alloc = new RankVarAllocator(solverCtx.ctx); + const clauseCache = filtered.map((c) => + encodeConstraint(solverCtx.ctx, c, alloc), + ); // Build ONE incremental solver with activation literals for all constraints - const incSolver = buildIncrementalSolver(solverCtx, clauseCache); + const incSolver = buildIncrementalSolver( + solverCtx, + clauseCache, + alloc, + ); const minimal = minimizeConstraints( filtered, @@ -462,12 +469,16 @@ interface IncSolverCtx { function buildIncrementalSolver( solverCtx: SolverContext, clauseCache: number[][][], + alloc: RankVarAllocator, ): IncSolverCtx { - const { numValues, numPositions } = solverCtx.ctx; - const actBase = numValues * numPositions + 1; + // Activation literals must start above ALL variable ranges: position vars + // AND rank auxiliary vars allocated during constraint encoding. + const actBase = alloc.varCeiling; const total = clauseCache.length; const allClauses: number[][] = [...solverCtx.baseClauses]; + // Add rank channeling clauses (shared across constraints, not guarded) + for (const clause of alloc.channeling) allClauses.push(clause); for (let i = 0; i < total; i++) { const actVar = actBase + i; for (const clause of clauseCache[i]) { From 03ed670578745b72be7219baaa25b5f606bcbbcf Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:48:28 +0200 Subject: [PATCH 04/28] feat: remove positional fast-path and at_position/not_at_position Remove the two-path encoder asymmetry: all comparative constraints now use the rank-var encoder regardless of axis. Delete 7 positional fast-path encoder functions and the identity-pinned deduction branches. Replace at_position/not_at_position constraint types with same_position/not_same_position against display axis values. Remove the "direct" and "elimination" deduction techniques. The display axis is still pinned for symmetry breaking, but all other categories use the general rank-var path uniformly. BREAKING: atPosition() and notAtPosition() exports removed. --- packages/logic-grid-ai/src/rewrite.test.ts | 4 +- .../src/__snapshots__/index.test.ts.snap | 20 +- .../logic-grid/src/clues/constraints.test.ts | 18 - packages/logic-grid/src/clues/constraints.ts | 10 - .../logic-grid/src/clues/templates.test.ts | 55 -- packages/logic-grid/src/clues/templates.ts | 31 +- .../deduce/__snapshots__/index.test.ts.snap | 8 +- .../logic-grid/src/deduce/constraints.test.ts | 172 +++--- packages/logic-grid/src/deduce/constraints.ts | 549 ++---------------- packages/logic-grid/src/deduce/index.test.ts | 11 +- .../logic-grid/src/deduce/propagate.test.ts | 106 ++-- packages/logic-grid/src/deduce/state.test.ts | 4 +- packages/logic-grid/src/deduce/state.ts | 4 +- .../logic-grid/src/deduce/structural.test.ts | 106 ++-- packages/logic-grid/src/difficulty.test.ts | 16 +- packages/logic-grid/src/difficulty.ts | 2 - packages/logic-grid/src/encoding.test.ts | 30 +- packages/logic-grid/src/encoding.ts | 279 +-------- packages/logic-grid/src/generator.test.ts | 25 +- packages/logic-grid/src/generator.ts | 49 +- packages/logic-grid/src/index.test.ts | 11 +- packages/logic-grid/src/index.ts | 2 - packages/logic-grid/src/solver.test.ts | 12 +- packages/logic-grid/src/types.ts | 6 +- 24 files changed, 347 insertions(+), 1183 deletions(-) diff --git a/packages/logic-grid-ai/src/rewrite.test.ts b/packages/logic-grid-ai/src/rewrite.test.ts index 42cedcb..c673eab 100644 --- a/packages/logic-grid-ai/src/rewrite.test.ts +++ b/packages/logic-grid-ai/src/rewrite.test.ts @@ -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.", }, ]; @@ -101,7 +101,7 @@ describe("rewriteClues", () => { expect(capturedPrompt).toContain('"type":"same_position"'); expect(capturedPrompt).toContain('"type":"next_to"'); - expect(capturedPrompt).toContain('"type":"at_position"'); + expect(capturedPrompt).toContain('"type":"same_position"'); }); it("retries on validation failure", async () => { diff --git a/packages/logic-grid/src/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/__snapshots__/index.test.ts.snap index dc1f709..99345b9 100644 --- a/packages/logic-grid/src/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/__snapshots__/index.test.ts.snap @@ -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.", ] `; diff --git a/packages/logic-grid/src/clues/constraints.test.ts b/packages/logic-grid/src/clues/constraints.test.ts index 57640f8..0c4810f 100644 --- a/packages/logic-grid/src/clues/constraints.test.ts +++ b/packages/logic-grid/src/clues/constraints.test.ts @@ -9,8 +9,6 @@ import { notBetween, before, exactDistance, - atPosition, - notAtPosition, } from "./constraints"; describe("constraint factories", () => { @@ -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, - }); - }); }); diff --git a/packages/logic-grid/src/clues/constraints.ts b/packages/logic-grid/src/clues/constraints.ts index 520e9ba..b38d2f3 100644 --- a/packages/logic-grid/src/clues/constraints.ts +++ b/packages/logic-grid/src/clues/constraints.ts @@ -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 }; -} diff --git a/packages/logic-grid/src/clues/templates.test.ts b/packages/logic-grid/src/clues/templates.test.ts index 1b9b9fe..621780f 100644 --- a/packages/logic-grid/src/clues/templates.test.ts +++ b/packages/logic-grid/src/clues/templates.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { renderClue } from "./templates"; import { makeGrid } from "../test-helpers"; -import type { Grid } from "../types"; const grid = makeGrid({ size: 3, @@ -111,32 +110,6 @@ describe("renderClue — classic same_position paths", () => { }); }); -describe("at_position / not_at_position", () => { - it("at_position renders via position label from first ordered category", () => { - const clue = renderClue( - { type: "at_position", value: "Alice", position: 0 }, - grid, - ); - expect(clue.text).toBe("Alice lives in the first house."); - }); - - it("not_at_position uses positionAdjective for Color+House", () => { - const clue = renderClue( - { type: "not_at_position", value: "Red", position: 2 }, - grid, - ); - expect(clue.text).toBe("The third house is not red."); - }); - - it("at_position uses positionAdjective for Color+House", () => { - const clue = renderClue( - { type: "at_position", value: "Red", position: 1 }, - grid, - ); - expect(clue.text).toBe("The second house is red."); - }); -}); - describe("positionAdjective + ordered rendering rule", () => { // The auto-added House category is ordered; Color has positionAdjective. // same_position(Red, "first") must render as "The first house is red." @@ -578,31 +551,3 @@ describe("per-axis orderingPhrases in rendering", () => { expect(clue.text).toContain("2 from"); }); }); - -describe("at_position / not_at_position invariants", () => { - const bareGrid: Grid = { - size: 3, - categories: [{ name: "Name", values: ["Alice", "Bob", "Carol"], noun: "" }], - }; - - it("at_position throws when grid has no ordered category", () => { - expect(() => - renderClue( - { type: "at_position", value: "Alice", position: 0 }, - bareGrid, - ), - ).toThrow("no ordered category"); - }); - - it("not_at_position throws when grid has no ordered category", () => { - expect(() => - renderClue( - { type: "not_at_position", value: "Alice", position: 0 }, - bareGrid, - ), - ).toThrow("no ordered category"); - }); - - // "ordered category has no verb" is now a compile-time error: - // the OrderednessFields union requires verb on ordered: true. -}); diff --git a/packages/logic-grid/src/clues/templates.ts b/packages/logic-grid/src/clues/templates.ts index bce96b8..8ddc203 100644 --- a/packages/logic-grid/src/clues/templates.ts +++ b/packages/logic-grid/src/clues/templates.ts @@ -1,5 +1,5 @@ import type { Category, Constraint, Clue, Grid } from "../types"; -import { orderedCategories, resolveAxis } from "../axis"; +import { resolveAxis } from "../axis"; function findCategory(value: string, grid: Grid): Category { for (const cat of grid.categories) { @@ -182,35 +182,6 @@ function renderText(constraint: Constraint, grid: Grid): string { } return `${capitalize(la)} ${prefix} ${constraint.distance} from ${lb}.`; } - case "at_position": { - // Uses the first ordered category (identity-pinned), NOT displayAxis. - // at_position encodes row identity, which is tied to the identity-pinned - // axis. displayAxis is a presentation concern for column headers only. - const axis = orderedCategories(grid)[0]; - if (!axis) throw new Error("Grid has no ordered category"); - const axisVal = axis.values[constraint.position]; - const cat = findCategory(constraint.value, grid); - if (cat.positionAdjective) { - const v = cat.lowercase - ? constraint.value.toLowerCase() - : constraint.value; - return `${capitalize(label(axisVal, grid))} ${cat.positionAdjective[0]} ${v}.`; - } - return `${capitalize(label(constraint.value, grid))} ${axis.verb[0]} ${objectValue(axisVal, grid)}.`; - } - case "not_at_position": { - const axis = orderedCategories(grid)[0]; - if (!axis) throw new Error("Grid has no ordered category"); - const axisVal = axis.values[constraint.position]; - const cat = findCategory(constraint.value, grid); - if (cat.positionAdjective) { - const v = cat.lowercase - ? constraint.value.toLowerCase() - : constraint.value; - return `${capitalize(label(axisVal, grid))} ${cat.positionAdjective[1]} ${v}.`; - } - return `${capitalize(label(constraint.value, grid))} ${axis.verb[1]} ${objectValue(axisVal, grid)}.`; - } } } diff --git a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap index cb0409b..8de25d3 100644 --- a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap @@ -2,14 +2,14 @@ exports[`deduce > snapshots explanation strings 1`] = ` [ - "Clue 1: Red must be in the first house.", + "Clue 1: Red and first are in the same house. Red is in the first house, so both are in the first house.", "Clue 2: Red and Cat are in the same house. Red is in the first house, so both are in the first house.", - "Clue 3: Blue is directly left of Green. Blue can only be in the first or second house, so Blue can't be in the third house; Green can't be in the first house.", + "Clue 3: Blue is directly before Green on House. Blue can't be in the third house; Green can't be in the first house.", "Clue 4: Blue and Dog are in the same house. Blue can only be in the first or second house, so Dog can't be in the third house.", "Clue 5: Dog and Coffee are in the same house. Dog can only be in the first or second house, so Coffee can't be in the third house.", - "Clue 6: Tea must be in the first house.", + "Clue 6: Tea and first are in the same house. Tea is in the first house, so both are in the first house.", "Red has no other possible house — it must be in the first house. So no other Color can be there.", - "Clue 3: Blue is directly left of Green. Blue is in the second house, so Green must be in the third house.", + "Clue 3: Blue is directly before Green on House. Green must be in the third house.", "Clue 4: Blue and Dog are in the same house. Blue is in the second house, so both are in the second house.", "Clue 5: Dog and Coffee are in the same house. Dog is in the second house, so both are in the second house.", "Cat has no other possible house — it must be in the first house. So no other Pet can be there.", diff --git a/packages/logic-grid/src/deduce/constraints.test.ts b/packages/logic-grid/src/deduce/constraints.test.ts index 6ea6e58..fff0d94 100644 --- a/packages/logic-grid/src/deduce/constraints.test.ts +++ b/packages/logic-grid/src/deduce/constraints.test.ts @@ -15,24 +15,26 @@ const grid = makeGrid({ }); describe("deduce constraint types", () => { - it("direct: at_position pins the value", () => { + it("same_position pins the value via display axis", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, ]; const result = deduce(constraints, grid); - const step = result.steps.find((s) => s.technique === "direct"); + const step = result.steps.find((s) => s.technique === "same_position"); expect(step).toBeDefined(); expect(step!.assignments).toContainEqual({ value: "Red", position: 0 }); }); - it("elimination: not_at_position removes position and assigns when only one left", () => { + it("not_same_position against display axis removes position and assigns when only one left", () => { const constraints: Constraint[] = [ - { type: "not_at_position", value: "Alice", position: 0 }, - { type: "not_at_position", value: "Alice", position: 1 }, - { type: "not_at_position", value: "Alice", position: 2 }, + { type: "not_same_position", a: "Alice", b: "first" }, + { type: "not_same_position", a: "Alice", b: "second" }, + { type: "not_same_position", a: "Alice", b: "third" }, ]; const result = deduce(constraints, grid); - const elims = result.steps.filter((s) => s.technique === "elimination"); + const elims = result.steps.filter( + (s) => s.technique === "not_same_position", + ); expect(elims.length).toBeGreaterThan(0); const assigns = result.steps.flatMap((s) => s.assignments); expect(assigns).toContainEqual({ value: "Alice", position: 3 }); @@ -40,18 +42,18 @@ describe("deduce constraint types", () => { it("same_position intersects possible positions", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "same_position", a: "Red", b: "Alice" }, ]; const result = deduce(constraints, grid); - const step = result.steps.find((s) => s.technique === "same_position"); - expect(step).toBeDefined(); - expect(step!.assignments).toContainEqual({ value: "Alice", position: 0 }); + const allAssigns = result.steps.flatMap((s) => s.assignments); + expect(allAssigns).toContainEqual({ value: "Red", position: 0 }); + expect(allAssigns).toContainEqual({ value: "Alice", position: 0 }); }); it("not_same_position eliminates when one value is pinned", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "not_same_position", a: "Red", b: "Alice" }, ]; const result = deduce(constraints, grid); @@ -62,7 +64,7 @@ describe("deduce constraint types", () => { it("next_to constrains to adjacent positions", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "next_to", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -76,7 +78,7 @@ describe("deduce constraint types", () => { it("not_next_to eliminates adjacent positions when a is pinned", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 1 }, + { type: "same_position", a: "Red", b: "second" }, { type: "not_next_to", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -88,7 +90,7 @@ describe("deduce constraint types", () => { it("not_next_to eliminates adjacent positions when b is pinned", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 1 }, + { type: "same_position", a: "Alice", b: "second" }, { type: "not_next_to", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -102,8 +104,8 @@ describe("deduce constraint types", () => { // Blue can only be at {0,2} — both adjacent to position 1. // So Red cannot be at position 1 (no valid non-adjacent position exists for Blue). const constraints: Constraint[] = [ - { type: "not_at_position", value: "Blue", position: 1 }, - { type: "not_at_position", value: "Blue", position: 3 }, + { type: "not_same_position", a: "Blue", b: "second" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, { type: "not_next_to", a: "Red", b: "Blue", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -134,9 +136,9 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Blue", position: 5 }, - { type: "not_at_position", value: "Blue", position: 6 }, - { type: "not_at_position", value: "Blue", position: 7 }, + { type: "not_same_position", a: "Blue", b: "sixth" }, + { type: "not_same_position", a: "Blue", b: "seventh" }, + { type: "not_same_position", a: "Blue", b: "eighth" }, { type: "next_to", a: "Red", b: "Blue", axis: "House" }, ]; const result = deduce(constraints, grid8); @@ -146,7 +148,7 @@ describe("deduce constraint types", () => { it("left_of pins b to a+1 when a is pinned", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 1 }, + { type: "same_position", a: "Red", b: "second" }, { type: "left_of", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -158,8 +160,8 @@ describe("deduce constraint types", () => { it("left_of arc-consistency: eliminates positions with no valid neighbour even when neither is pinned", () => { // Alice can only be at {2,3} — so Red (directly left) can only be at {1,2}. const constraints: Constraint[] = [ - { type: "not_at_position", value: "Alice", position: 0 }, - { type: "not_at_position", value: "Alice", position: 1 }, + { type: "not_same_position", a: "Alice", b: "first" }, + { type: "not_same_position", a: "Alice", b: "second" }, { type: "left_of", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -191,9 +193,9 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, - { type: "not_at_position", value: "Blue", position: 2 }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, + { type: "not_same_position", a: "Blue", b: "third" }, { type: "left_of", a: "Red", b: "Blue", axis: "House" }, ]; const result = deduce(constraints, grid8); @@ -224,9 +226,9 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, - { type: "not_at_position", value: "Blue", position: 2 }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, + { type: "not_same_position", a: "Blue", b: "third" }, { type: "before", a: "Red", b: "Blue", axis: "House" }, ]; const result = deduce(constraints, grid8); @@ -238,8 +240,8 @@ describe("deduce constraint types", () => { // Red restricted to {1,3}. Alice at 1 would need Red at 0 or 2 — neither in {1,3}. // Alice at 3 would need Red at 2 or 4 — neither in {1,3}. const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Red", position: 2 }, + { type: "not_same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Red", b: "third" }, { type: "next_to", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -250,7 +252,7 @@ describe("deduce constraint types", () => { it("before eliminates positions", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 2 }, + { type: "same_position", a: "Red", b: "third" }, { type: "before", a: "Red", b: "Alice", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -264,7 +266,7 @@ describe("deduce constraint types", () => { // Blue restricted to {0,1,2}. before(Red, Blue): Red can't be at 2 or 3 (≥ maxBlue=2). // After Red becomes {0,1}, Blue can't be at 0 (≤ minRed=0). const constraints: Constraint[] = [ - { type: "not_at_position", value: "Blue", position: 3 }, + { type: "not_same_position", a: "Blue", b: "fourth" }, { type: "before", a: "Red", b: "Blue", axis: "House" }, ]; const result = deduce(constraints, grid); @@ -279,8 +281,8 @@ describe("deduce constraint types", () => { // Red at 0 needs Blue at 2 (missing) or -2 (invalid) → eliminated. // Red at 1 needs Blue at 3 (missing) or -1 (invalid) → eliminated. const constraints: Constraint[] = [ - { type: "not_at_position", value: "Blue", position: 2 }, - { type: "not_at_position", value: "Blue", position: 3 }, + { type: "not_same_position", a: "Blue", b: "third" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, { type: "exact_distance", a: "Red", @@ -352,7 +354,7 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "A" }, { type: "exact_distance", a: "Alice", @@ -383,7 +385,7 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "A" }, { type: "exact_distance", a: "Alice", @@ -400,7 +402,7 @@ describe("deduce constraint types", () => { it("exact_distance constrains positions", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "exact_distance", a: "Red", @@ -436,7 +438,7 @@ describe("deduce constraint types", () => { // Alice pinned to position 0; exact_distance 2 means Alice and Bob must be // at positions (0,1) — so Bob is at position 1. const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "3%" }, { type: "exact_distance", a: "Alice", @@ -470,7 +472,7 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "3%" }, { type: "exact_distance", a: "Alice", @@ -490,7 +492,7 @@ describe("deduce constraint types", () => { it("exact_distance distance=1 explanation uses singular noun", () => { // Alice pinned to position 0; Red must be exactly 1 house away → Red=1 const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "first" }, { type: "exact_distance", a: "Red", @@ -526,7 +528,7 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "6%" }, { type: "exact_distance", a: "Alice", @@ -561,7 +563,7 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "6%" }, { type: "exact_distance", a: "Alice", @@ -585,8 +587,8 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Blue", position: 4 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Blue", b: "fifth" }, { type: "between", outer1: "Red", @@ -610,10 +612,10 @@ describe("deduce constraint types", () => { it("between arc-consistency: middle cannot be at boundary positions", () => { // Outers restricted to {1,2}: no position can be outside middle on both sides const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 3 }, + { type: "not_same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, { type: "between", outer1: "Red", @@ -645,10 +647,10 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Red", position: 5 }, - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Red", b: "sixth" }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, { type: "between", outer1: "Red", @@ -666,8 +668,8 @@ describe("deduce constraint types", () => { it("between: pinned middle + pinned outer1 (left of middle) constrains outer2 to right", () => { // middle=Alice at 2, outer1=Red at 0 → outer2=Blue must be > 2 const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 2 }, - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Alice", b: "third" }, + { type: "same_position", a: "Red", b: "first" }, { type: "between", outer1: "Red", @@ -688,8 +690,8 @@ describe("deduce constraint types", () => { it("between: pinned middle + pinned outer1 (right of middle) constrains outer2 to left", () => { // middle=Alice at 1, outer1=Red at 3 (right of middle) → outer2=Blue must be < 1, i.e. at 0 const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 1 }, - { type: "at_position", value: "Red", position: 3 }, + { type: "same_position", a: "Alice", b: "second" }, + { type: "same_position", a: "Red", b: "fourth" }, { type: "between", outer1: "Red", @@ -711,8 +713,8 @@ describe("deduce constraint types", () => { it("between: pinned middle + pinned outer2 (left of middle) constrains outer1 to right", () => { // middle=Alice at 2, outer2=Blue at 0 (left of middle) → outer1=Red must be > 2 const constraints: Constraint[] = [ - { type: "at_position", value: "Alice", position: 2 }, - { type: "at_position", value: "Blue", position: 0 }, + { type: "same_position", a: "Alice", b: "third" }, + { type: "same_position", a: "Blue", b: "first" }, { type: "between", outer1: "Red", @@ -745,12 +747,12 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, - { type: "not_at_position", value: "Blue", position: 2 }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, + { type: "not_same_position", a: "Blue", b: "third" }, { type: "not_between", outer1: "Red", @@ -778,12 +780,12 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Red", position: 1 }, - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Blue", position: 2 }, - { type: "not_at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Blue", position: 4 }, + { type: "not_same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Red", b: "second" }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Blue", b: "third" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "fifth" }, { type: "not_between", outer1: "Red", @@ -800,8 +802,8 @@ describe("deduce constraint types", () => { it("not_between eliminates middle positions when both outers are pinned", () => { const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Blue", position: 3 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Blue", b: "fourth" }, { type: "not_between", outer1: "Red", @@ -821,8 +823,8 @@ describe("deduce constraint types", () => { // outer1=Red at 0, Blue restricted to {2,3} (min=2). // Alice at 1: Red(0) < 1 AND min(Blue)=2 > 1 → always between → eliminated. const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, { type: "not_between", outer1: "Red", @@ -840,8 +842,8 @@ describe("deduce constraint types", () => { // outer2=Blue at 3, Red restricted to {0,1} (max=1). // Alice at 2: Blue(3) > 2 AND max(Red)=1 < 2 → always between → eliminated. const constraints: Constraint[] = [ - { type: "at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Red", position: 2 }, + { type: "same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Red", b: "third" }, { type: "not_between", outer1: "Red", @@ -873,9 +875,9 @@ describe("deduce constraint types", () => { ], }); const constraints: Constraint[] = [ - { type: "at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Red", position: 1 }, + { type: "same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Red", b: "second" }, { type: "not_between", outer1: "Red", @@ -893,10 +895,10 @@ describe("deduce constraint types", () => { // outer1=Red at 0, outer2=Blue restricted to {3} only. // Alice at 1 or 2 would always be between Red(0) and Blue(3). const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, - { type: "not_at_position", value: "Blue", position: 2 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, + { type: "not_same_position", a: "Blue", b: "third" }, { type: "not_between", outer1: "Red", diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index d3cf252..b2ab240 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -22,22 +22,6 @@ import { projectRanksToPositions, } from "./state"; -/** Check whether every position in `set` is adjacent to `p`. */ -function allAdjacent(set: Set, p: number): boolean { - for (const q of set) { - if (q !== p - 1 && q !== p + 1) return false; - } - return true; -} - -/** True when `axis` is the first ordered category (identity-pinned). */ -function isIdentityPinned( - grid: { categories: Category[] }, - axis: Category, -): boolean { - return grid.categories.find((c) => c.ordered === true) === axis; -} - /** * Generic rank-space deduction for binary comparative constraints on a * non-identity-pinned axis. Computes the rank domain of both values, @@ -110,10 +94,6 @@ export function tryConstraint( ci: number, ): DeductionStep | null { switch (constraint.type) { - case "at_position": - return tryAtPosition(state, constraint, ci); - case "not_at_position": - return tryNotAtPosition(state, constraint, ci); case "same_position": return trySamePosition(state, constraint, ci); case "not_same_position": @@ -135,55 +115,6 @@ export function tryConstraint( } } -function tryAtPosition( - state: DeduceState, - c: { value: string; position: number }, - ci: number, -): DeductionStep | null { - const ps = getPossible(state, c.value); - if (ps.size <= 1) return null; - const elims: { value: string; position: number }[] = []; - for (const p of ps) { - if (p !== c.position) elims.push({ value: c.value, position: p }); - } - ps.clear(); - ps.add(c.position); - if (state.silent) return SILENT_STEP; - const { noun, posLabel } = axisTerms(state.grid); - return step( - "direct", - [ci], - elims, - [{ value: c.value, position: c.position }], - `Clue ${ci + 1}: ${c.value} must be in the ${posLabel(c.position)} ${noun}.`, - ); -} - -function tryNotAtPosition( - state: DeduceState, - c: { value: string; position: number }, - ci: number, -): DeductionStep | null { - const ps = getPossible(state, c.value); - if (!ps.has(c.position)) return null; - ps.delete(c.position); - if (state.silent) return SILENT_STEP; - const { noun, posLabel } = axisTerms(state.grid); - const assigns = - ps.size === 1 ? [{ value: c.value, position: first(ps) }] : []; - const suffix = - assigns.length > 0 - ? `, so ${c.value} must be in the ${posLabel(assigns[0].position)} ${noun}.` - : "."; - return step( - "elimination", - [ci], - [{ value: c.value, position: c.position }], - assigns, - `Clue ${ci + 1}: ${c.value} is not in the ${posLabel(c.position)} ${noun}${suffix}`, - ); -} - function trySamePosition( state: DeduceState, c: { a: string; b: string }, @@ -275,19 +206,16 @@ function tryNextTo( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - return tryBinaryRankSpace( - state, - c.a, - c.b, - axis, - ci, - "next_to", - (ra, rb) => Math.abs(ra - rb) === 1, - `${c.a} is adjacent to ${c.b} on ${axis.name}.`, - ); - } - return tryAdjacency(state, c.a, c.b, ci, "next_to", true); + return tryBinaryRankSpace( + state, + c.a, + c.b, + axis, + ci, + "next_to", + (ra, rb) => Math.abs(ra - rb) === 1, + `${c.a} is adjacent to ${c.b} on ${axis.name}.`, + ); } function tryNotNextTo( @@ -296,97 +224,15 @@ function tryNotNextTo( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - return tryBinaryRankSpace( - state, - c.a, - c.b, - axis, - ci, - "not_next_to", - (ra, rb) => Math.abs(ra - rb) !== 1, - `${c.a} is not adjacent to ${c.b} on ${axis.name}.`, - ); - } - return tryAdjacency(state, c.a, c.b, ci, "not_next_to", false); -} - -function tryAdjacency( - state: DeduceState, - a: string, - b: string, - ci: number, - technique: DeductionTechnique, - mustBeAdjacent: boolean, -): DeductionStep | null { - const n = state.n; - const pa = getPossible(state, a); - const pb = getPossible(state, b); - const elims: { value: string; position: number }[] = []; - - if (mustBeAdjacent) { - // next_to: for each value, eliminate positions where no neighbor is possible - const validForA = new Set(); - for (const p of pa) { - if ((p > 0 && pb.has(p - 1)) || (p < n - 1 && pb.has(p + 1))) { - validForA.add(p); - } - } - for (const p of pa) { - if (!validForA.has(p)) elims.push({ value: a, position: p }); - } - const validForB = new Set(); - for (const p of pb) { - if ((p > 0 && pa.has(p - 1)) || (p < n - 1 && pa.has(p + 1))) { - validForB.add(p); - } - } - for (const p of pb) { - if (!validForB.has(p)) elims.push({ value: b, position: p }); - } - } else { - // not_next_to: if one is pinned, eliminate adjacent from the other - const posA = getAssigned(state, a); - if (posA !== null) { - if (posA > 0 && pb.has(posA - 1)) - elims.push({ value: b, position: posA - 1 }); - if (posA < n - 1 && pb.has(posA + 1)) - elims.push({ value: b, position: posA + 1 }); - } - const posB = getAssigned(state, b); - if (posB !== null) { - if (posB > 0 && pa.has(posB - 1)) - elims.push({ value: a, position: posB - 1 }); - if (posB < n - 1 && pa.has(posB + 1)) - elims.push({ value: a, position: posB + 1 }); - } - // Arc-consistency: eliminate p from a if every position in b is adjacent to p - for (const p of pa) { - if (pb.size > 0 && allAdjacent(pb, p)) - elims.push({ value: a, position: p }); - } - for (const p of pb) { - if (pa.size > 0 && allAdjacent(pa, p)) - elims.push({ value: b, position: p }); - } - } - - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); - if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); - const verb = mustBeAdjacent ? "next to" : "not next to"; - const knownA = describeKnown(state, a); - const knownB = describeKnown(state, b); - const ctx = knownA || knownB; - const because = ctx ? ` ${ctx}, so ` : " "; - return step( - technique, - [ci], - uniqueElims, - assigns, - `${clueRef(ci)}${a} is ${verb} ${b}.${because}${describeResult(state.grid, assigns, uniqueElims)}.`, + return tryBinaryRankSpace( + state, + c.a, + c.b, + axis, + ci, + "not_next_to", + (ra, rb) => Math.abs(ra - rb) !== 1, + `${c.a} is not adjacent to ${c.b} on ${axis.name}.`, ); } @@ -396,44 +242,15 @@ function tryLeftOf( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - return tryBinaryRankSpace( - state, - c.a, - c.b, - axis, - ci, - "left_of", - (ra, rb) => rb === ra + 1, - `${c.a} is directly before ${c.b} on ${axis.name}.`, - ); - } - const pa = getPossible(state, c.a); - const pb = getPossible(state, c.b); - const elims: { value: string; position: number }[] = []; - - for (const p of pa) { - if (!pb.has(p + 1)) elims.push({ value: c.a, position: p }); - } - for (const p of pb) { - if (!pa.has(p - 1)) elims.push({ value: c.b, position: p }); - } - - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); - if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); - const knownA = describeKnown(state, c.a); - const knownB = describeKnown(state, c.b); - const ctx = knownA || knownB; - const because = ctx ? ` ${ctx}, so ` : " "; - return step( + return tryBinaryRankSpace( + state, + c.a, + c.b, + axis, + ci, "left_of", - [ci], - uniqueElims, - assigns, - `${clueRef(ci)}${c.a} is directly left of ${c.b}.${because}${describeResult(state.grid, assigns, uniqueElims)}.`, + (ra, rb) => rb === ra + 1, + `${c.a} is directly before ${c.b} on ${axis.name}.`, ); } @@ -443,47 +260,15 @@ function tryBefore( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - return tryBinaryRankSpace( - state, - c.a, - c.b, - axis, - ci, - "before", - (ra, rb) => ra < rb, - `${c.a} is before ${c.b} on ${axis.name}.`, - ); - } - const pa = getPossible(state, c.a); - const pb = getPossible(state, c.b); - const elims: { value: string; position: number }[] = []; - - if (pa.size === 0 || pb.size === 0) return null; - const maxB = Math.max(...pb); - for (const p of pa) { - if (p >= maxB) elims.push({ value: c.a, position: p }); - } - const minA = Math.min(...pa); - for (const p of pb) { - if (p <= minA) elims.push({ value: c.b, position: p }); - } - - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); - if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); - const knownA = describeKnown(state, c.a); - const knownB = describeKnown(state, c.b); - const ctx = knownA || knownB; - const because = ctx ? ` ${ctx}, so ` : " "; - return step( + return tryBinaryRankSpace( + state, + c.a, + c.b, + axis, + ci, "before", - [ci], - uniqueElims, - assigns, - `${clueRef(ci)}${c.a} is somewhere left of ${c.b}.${because}${describeResult(state.grid, assigns, uniqueElims)}.`, + (ra, rb) => ra < rb, + `${c.a} is before ${c.b} on ${axis.name}.`, ); } @@ -493,114 +278,7 @@ function tryBetween( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - return tryBetweenRankSpace(state, c, axis, ci, false); - } - const po1 = getPossible(state, c.outer1); - const pm = getPossible(state, c.middle); - const po2 = getPossible(state, c.outer2); - const elims: { value: string; position: number }[] = []; - - const a1 = getAssigned(state, c.outer1); - const a2 = getAssigned(state, c.outer2); - - // If both outers are pinned, middle must be strictly between them - if (a1 !== null && a2 !== null) { - const lo = Math.min(a1, a2); - const hi = Math.max(a1, a2); - for (const p of pm) { - if (p <= lo || p >= hi) elims.push({ value: c.middle, position: p }); - } - } - - // If middle and one outer are pinned, constrain the other outer to the opposite side - const am = getAssigned(state, c.middle); - if (am !== null && a1 !== null) { - for (const p of po2) { - // outer1 < middle → outer2 must be > middle; outer1 > middle → outer2 must be < middle - if (a1 < am && p <= am) elims.push({ value: c.outer2, position: p }); - if (a1 > am && p >= am) elims.push({ value: c.outer2, position: p }); - } - } - if (am !== null && a2 !== null) { - for (const p of po1) { - if (a2 < am && p <= am) elims.push({ value: c.outer1, position: p }); - if (a2 > am && p >= am) elims.push({ value: c.outer1, position: p }); - } - } - - // Arc-consistency: eliminate middle positions where no valid outer pair exists on both sides - // Skip when any set is empty — Math.min/max on empty sets returns ±Infinity - if (po1.size > 0 && po2.size > 0 && pm.size > 0) { - const minO1 = Math.min(...po1); - const maxO1 = Math.max(...po1); - const minO2 = Math.min(...po2); - const maxO2 = Math.max(...po2); - for (const p of pm) { - const case1 = minO1 < p && maxO2 > p; - const case2 = minO2 < p && maxO1 > p; - if (!case1 && !case2) elims.push({ value: c.middle, position: p }); - } - // Arc-consistency for outers - for (const p1 of po1) { - let valid = false; - for (const m of pm) { - if (p1 < m && maxO2 > m) { - valid = true; - break; - } - if (p1 > m && minO2 < m) { - valid = true; - break; - } - } - if (!valid) elims.push({ value: c.outer1, position: p1 }); - } - for (const p2 of po2) { - let valid = false; - for (const m of pm) { - if (p2 < m && maxO1 > m) { - valid = true; - break; - } - if (p2 > m && minO1 < m) { - valid = true; - break; - } - } - if (!valid) elims.push({ value: c.outer2, position: p2 }); - } - } - - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); - if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); - - let because: string; - if (a1 !== null && a2 !== null) { - const { noun, posLabel } = axisTerms(state.grid); - const parts = [ - `${c.outer1} is in the ${posLabel(a1)} ${noun}`, - `${c.outer2} is in the ${posLabel(a2)} ${noun}`, - ]; - because = ` ${parts.join(" and ")}, so `; - } else { - const knownO1 = describeKnown(state, c.outer1); - const knownO2 = describeKnown(state, c.outer2); - const knownM = describeKnown(state, c.middle); - const ctx = knownO1 || knownO2 || knownM; - because = ctx ? ` ${ctx}, so ` : " "; - } - - return step( - "between", - [ci], - uniqueElims, - assigns, - `${clueRef(ci)}${c.middle} is somewhere between ${c.outer1} and ${c.outer2}.${because}${describeResult(state.grid, assigns, uniqueElims)}.`, - ); + return tryBetweenRankSpace(state, c, axis, ci, false); } function tryNotBetween( @@ -609,76 +287,7 @@ function tryNotBetween( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - return tryBetweenRankSpace(state, c, axis, ci, true); - } - const a1 = getAssigned(state, c.outer1); - const a2 = getAssigned(state, c.outer2); - const pm = getPossible(state, c.middle); - const elims: { value: string; position: number }[] = []; - - if (a1 !== null && a2 !== null) { - // Both pinned: middle cannot be strictly between them - const lo = Math.min(a1, a2); - const hi = Math.max(a1, a2); - for (const p of pm) { - if (p > lo && p < hi) elims.push({ value: c.middle, position: p }); - } - } else if (a1 !== null || a2 !== null) { - // One outer pinned: eliminate middle positions where every position of the - // other outer would place the middle between them. - const pinnedPos = a1 ?? a2!; - const otherPossible = - a1 !== null ? getPossible(state, c.outer2) : getPossible(state, c.outer1); - if (otherPossible.size === 0) return null; - const minOther = Math.min(...otherPossible); - const maxOther = Math.max(...otherPossible); - for (const m of pm) { - if (pinnedPos < m && minOther > m) - elims.push({ value: c.middle, position: m }); - if (pinnedPos > m && maxOther < m) - elims.push({ value: c.middle, position: m }); - } - } else { - // Neither outer pinned: eliminate middle positions that are always between - // all possible outer pairs (all outer1 positions on one side, all outer2 on the other). - const po1 = getPossible(state, c.outer1); - const po2 = getPossible(state, c.outer2); - if (po1.size === 0 || po2.size === 0) return null; - const maxO1 = Math.max(...po1); - const minO1 = Math.min(...po1); - const maxO2 = Math.max(...po2); - const minO2 = Math.min(...po2); - for (const m of pm) { - if ((maxO1 < m && minO2 > m) || (minO1 > m && maxO2 < m)) - elims.push({ value: c.middle, position: m }); - } - } - - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); - if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); - - let because: string; - if (a1 !== null && a2 !== null) { - const { noun, posLabel } = axisTerms(state.grid); - because = ` ${c.outer1} is in the ${posLabel(a1)} ${noun} and ${c.outer2} is in the ${posLabel(a2)} ${noun}, so `; - } else { - // At least one outer always has a description for supported grid sizes (3–8): - // the neither-pinned case needs 4+4+1=9 positions, exceeding max size 8. - const ctx = - describeKnown(state, c.outer1) || describeKnown(state, c.outer2); - because = ` ${ctx}, so `; - } - return step( - "not_between", - [ci], - uniqueElims, - assigns, - `${clueRef(ci)}${c.middle} is not between ${c.outer1} and ${c.outer2}.${because}${describeResult(state.grid, assigns, uniqueElims)}.`, - ); + return tryBetweenRankSpace(state, c, axis, ci, true); } /** @@ -784,80 +393,24 @@ function tryExactDistance( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - if (!isIdentityPinned(state.grid, axis)) { - const numVals = axis.numericValues; - return tryBinaryRankSpace( - state, - c.a, - c.b, - axis, - ci, - "exact_distance", - (ra, rb) => { - const d = numVals - ? Math.abs(numVals[ra] - numVals[rb]) - : Math.abs(ra - rb); - return d === c.distance; - }, - `${c.a} and ${c.b} are ${c.distance} apart on ${axis.name}.`, - ); - } - const n = state.n; - const pa = getPossible(state, c.a); - const pb = getPossible(state, c.b); - const elims: { value: string; position: number }[] = []; const numVals = axis.numericValues; - - if (numVals) { - // Value-based distance: compute valid partner positions from numeric values - const partnersOf = (p: number): number[] => { - const result: number[] = []; - for (let q = 0; q < n; q++) { - if (Math.abs(numVals[p] - numVals[q]) === c.distance) result.push(q); - } - return result; - }; - for (const p of pa) { - if (!partnersOf(p).some((q) => pb.has(q))) - elims.push({ value: c.a, position: p }); - } - for (const p of pb) { - if (!partnersOf(p).some((q) => pa.has(q))) - elims.push({ value: c.b, position: p }); - } - } else { - // Position-based distance (original behavior) - for (const p of pa) { - const canB = - (p + c.distance < n && pb.has(p + c.distance)) || - (p - c.distance >= 0 && pb.has(p - c.distance)); - if (!canB) elims.push({ value: c.a, position: p }); - } - for (const p of pb) { - const canA = - (p + c.distance < n && pa.has(p + c.distance)) || - (p - c.distance >= 0 && pa.has(p - c.distance)); - if (!canA) elims.push({ value: c.b, position: p }); - } - } - - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); - if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); const unit = axis.orderingPhrases.unit; const distLabel = unit ? `${c.distance} ${c.distance === 1 ? unit[0] : unit[1]}` - : `${c.distance} ${c.distance === 1 ? axisTerms(state.grid).noun : axisTerms(state.grid).noun + "s"}`; - // At least one value always has a description for supported grid sizes (3–8). - const ctx = describeKnown(state, c.a) || describeKnown(state, c.b); - const because = ` ${ctx}, so `; - return step( + : `${c.distance} ${c.distance === 1 ? axis.noun || "position" : (axis.noun || "position") + "s"}`; + return tryBinaryRankSpace( + state, + c.a, + c.b, + axis, + ci, "exact_distance", - [ci], - uniqueElims, - assigns, - `${clueRef(ci)}${c.a} and ${c.b} are exactly ${distLabel} apart.${because}${describeResult(state.grid, assigns, uniqueElims)}.`, + (ra, rb) => { + const d = numVals + ? Math.abs(numVals[ra] - numVals[rb]) + : Math.abs(ra - rb); + return d === c.distance; + }, + `${c.a} and ${c.b} are exactly ${distLabel} apart.`, ); } diff --git a/packages/logic-grid/src/deduce/index.test.ts b/packages/logic-grid/src/deduce/index.test.ts index 025fa33..12f9244 100644 --- a/packages/logic-grid/src/deduce/index.test.ts +++ b/packages/logic-grid/src/deduce/index.test.ts @@ -42,12 +42,12 @@ const grid3x3 = makeGrid({ }); const puzzle3x3: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "same_position", a: "Red", b: "Cat" }, { type: "left_of", a: "Blue", b: "Green", axis: "House" }, { type: "same_position", a: "Blue", b: "Dog" }, { type: "same_position", a: "Dog", b: "Coffee" }, - { type: "at_position", value: "Tea", position: 0 }, + { type: "same_position", a: "Tea", b: "first" }, ]; describe("deduce", () => { @@ -74,7 +74,8 @@ describe("deduce", () => { assigned.set(a.value, a.position); } } - expect(assigned.size).toBe(9); + expect(assigned.size).toBe(10); + expect(assigned.get("first")).toBe(0); expect(assigned.get("Red")).toBe(0); expect(assigned.get("Cat")).toBe(0); expect(assigned.get("Blue")).toBe(1); @@ -86,9 +87,9 @@ describe("deduce", () => { expect(assigned.get("Water")).toBe(2); }); - it("first step uses at_position (direct assignment)", () => { + it("first step uses same_position (direct assignment)", () => { const result = deduce(puzzle3x3, grid3x3); - expect(result.steps[0].technique).toBe("direct"); + expect(result.steps[0].technique).toBe("same_position"); expect(result.steps[0].clueIndices).toContain(0); }); diff --git a/packages/logic-grid/src/deduce/propagate.test.ts b/packages/logic-grid/src/deduce/propagate.test.ts index f26b16e..706d2fb 100644 --- a/packages/logic-grid/src/deduce/propagate.test.ts +++ b/packages/logic-grid/src/deduce/propagate.test.ts @@ -32,10 +32,10 @@ const grid7 = makeGrid({ }); describe("propagateToFixpoint", () => { - it("at_position pins value from fresh state", () => { + it("same_position pins value from fresh state", () => { const state = createState(grid4); propagateToFixpoint(state, [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, ]); expect([...getPossible(state, "Red")]).toEqual([0]); }); @@ -53,7 +53,7 @@ describe("propagateToFixpoint", () => { }); const state = createState(grid3cat); propagateToFixpoint(state, [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "same_position", a: "Red", b: "Alice" }, { type: "same_position", a: "Red", b: "Cat" }, ]); @@ -61,10 +61,10 @@ describe("propagateToFixpoint", () => { expect([...getPossible(state, "Cat")]).toEqual([0]); }); - it("not_at_position removes position from fresh state", () => { + it("not_same_position removes position from fresh state", () => { const state = createState(grid4); propagateToFixpoint(state, [ - { type: "not_at_position", value: "Red", position: 0 }, + { type: "not_same_position", a: "Red", b: "first" }, ]); expect(getPossible(state, "Red").has(0)).toBe(false); expect(getPossible(state, "Red").size).toBe(3); @@ -74,7 +74,7 @@ describe("propagateToFixpoint", () => { // Red pinned at 1 (posA=1 < 3). Alice loses posA+1=2. const state = createState(grid4); propagateToFixpoint(state, [ - { type: "at_position", value: "Red", position: 1 }, + { type: "same_position", a: "Red", b: "second" }, { type: "not_next_to", a: "Red", b: "Alice", axis: "House" }, ]); expect(getPossible(state, "Alice").has(2)).toBe(false); @@ -84,7 +84,7 @@ describe("propagateToFixpoint", () => { // Alice pinned at 2 (posB=2 > 0). Red loses posB-1=1. const state = createState(grid4); propagateToFixpoint(state, [ - { type: "at_position", value: "Alice", position: 2 }, + { type: "same_position", a: "Alice", b: "third" }, { type: "not_next_to", a: "Red", b: "Alice", axis: "House" }, ]); expect(getPossible(state, "Red").has(1)).toBe(false); @@ -95,9 +95,9 @@ describe("propagateToFixpoint", () => { // So Alice loses 1 via arc-consistency (pb loop). const state = createState(grid4); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 1 }, - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Red", position: 3 }, + { type: "not_same_position", a: "Red", b: "second" }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Red", b: "fourth" }, { type: "not_next_to", a: "Red", b: "Alice", axis: "House" }, ]; propagateToFixpoint(state, constraints); @@ -109,8 +109,8 @@ describe("propagateToFixpoint", () => { // So Alice loses 1 via the pb arc-consistency loop. const state = createState(grid4); propagateToFixpoint(state, [ - { type: "not_at_position", value: "Red", position: 1 }, - { type: "not_at_position", value: "Red", position: 3 }, + { type: "not_same_position", a: "Red", b: "second" }, + { type: "not_same_position", a: "Red", b: "fourth" }, { type: "not_next_to", a: "Red", b: "Alice", axis: "House" }, ]); expect(getPossible(state, "Alice").has(1)).toBe(false); @@ -120,8 +120,8 @@ describe("propagateToFixpoint", () => { // Red=0, Blue=3 → Alice at 1 and 2 are strictly between → eliminated. const state = createState(grid4); propagateToFixpoint(state, [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Blue", position: 3 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Blue", b: "fourth" }, { type: "not_between", outer1: "Red", @@ -138,8 +138,8 @@ describe("propagateToFixpoint", () => { // Red=0, Blue restricted to {2,3} (min=2). Alice at 1: Red(0)<1 AND min(Blue)=2>1. const state = createState(grid4); propagateToFixpoint(state, [ - { type: "at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, { type: "not_between", outer1: "Red", @@ -155,12 +155,12 @@ describe("propagateToFixpoint", () => { // Red={0,1}, Blue={3,4}. Alice at 2: all Red < 2 and all Blue > 2 → eliminated. const state = createState(grid5); propagateToFixpoint(state, [ - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Blue", position: 0 }, - { type: "not_at_position", value: "Blue", position: 1 }, - { type: "not_at_position", value: "Blue", position: 2 }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Blue", b: "first" }, + { type: "not_same_position", a: "Blue", b: "second" }, + { type: "not_same_position", a: "Blue", b: "third" }, { type: "not_between", outer1: "Red", @@ -177,12 +177,12 @@ describe("propagateToFixpoint", () => { // Yellow and White must be at {3,4} and cannot occupy {0,1,2}. const state = createState(grid5); propagateToFixpoint(state, [ - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Blue", position: 4 }, - { type: "not_at_position", value: "Green", position: 3 }, - { type: "not_at_position", value: "Green", position: 4 }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "fifth" }, + { type: "not_same_position", a: "Green", b: "fourth" }, + { type: "not_same_position", a: "Green", b: "fifth" }, ]); expect(getPossible(state, "Yellow").has(0)).toBe(false); expect(getPossible(state, "Yellow").has(1)).toBe(false); @@ -196,27 +196,27 @@ describe("propagateToFixpoint", () => { // → Red loses 3, Blue loses 4, Green loses 5. const state = createState(grid7); const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Red", position: 5 }, - { type: "not_at_position", value: "Red", position: 6 }, - { type: "not_at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Blue", position: 5 }, - { type: "not_at_position", value: "Blue", position: 6 }, - { type: "not_at_position", value: "Green", position: 3 }, - { type: "not_at_position", value: "Green", position: 4 }, - { type: "not_at_position", value: "Green", position: 6 }, - { type: "not_at_position", value: "Yellow", position: 0 }, - { type: "not_at_position", value: "Yellow", position: 1 }, - { type: "not_at_position", value: "Yellow", position: 2 }, - { type: "not_at_position", value: "White", position: 0 }, - { type: "not_at_position", value: "White", position: 1 }, - { type: "not_at_position", value: "White", position: 2 }, - { type: "not_at_position", value: "Black", position: 0 }, - { type: "not_at_position", value: "Black", position: 1 }, - { type: "not_at_position", value: "Black", position: 2 }, - { type: "not_at_position", value: "Purple", position: 0 }, - { type: "not_at_position", value: "Purple", position: 1 }, - { type: "not_at_position", value: "Purple", position: 2 }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Red", b: "sixth" }, + { type: "not_same_position", a: "Red", b: "seventh" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "sixth" }, + { type: "not_same_position", a: "Blue", b: "seventh" }, + { type: "not_same_position", a: "Green", b: "fourth" }, + { type: "not_same_position", a: "Green", b: "fifth" }, + { type: "not_same_position", a: "Green", b: "seventh" }, + { type: "not_same_position", a: "Yellow", b: "first" }, + { type: "not_same_position", a: "Yellow", b: "second" }, + { type: "not_same_position", a: "Yellow", b: "third" }, + { type: "not_same_position", a: "White", b: "first" }, + { type: "not_same_position", a: "White", b: "second" }, + { type: "not_same_position", a: "White", b: "third" }, + { type: "not_same_position", a: "Black", b: "first" }, + { type: "not_same_position", a: "Black", b: "second" }, + { type: "not_same_position", a: "Black", b: "third" }, + { type: "not_same_position", a: "Purple", b: "first" }, + { type: "not_same_position", a: "Purple", b: "second" }, + { type: "not_same_position", a: "Purple", b: "third" }, ]; propagateToFixpoint(state, constraints); expect(getPossible(state, "Red").has(3)).toBe(false); @@ -227,10 +227,10 @@ describe("propagateToFixpoint", () => { it("returns false on contradiction", () => { const state = createState(grid4); const result = propagateToFixpoint(state, [ - { type: "not_at_position", value: "Red", position: 0 }, - { type: "not_at_position", value: "Red", position: 1 }, - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Red", position: 3 }, + { type: "not_same_position", a: "Red", b: "first" }, + { type: "not_same_position", a: "Red", b: "second" }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Red", b: "fourth" }, ]); expect(result).toBe(false); }); diff --git a/packages/logic-grid/src/deduce/state.test.ts b/packages/logic-grid/src/deduce/state.test.ts index a3e5c8d..647c085 100644 --- a/packages/logic-grid/src/deduce/state.test.ts +++ b/packages/logic-grid/src/deduce/state.test.ts @@ -52,9 +52,7 @@ describe("describeKnown", () => { const state = createState(grid); state.possible[1][0].clear(); state.possible[1][0].add(0); - expect(describeKnown(state, "Alice")).toBe( - "Alice is in the first house", - ); + expect(describeKnown(state, "Alice")).toBe("Alice is in the first house"); }); it("describes possible positions", () => { diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index 1a44941..fcdc222 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -104,8 +104,8 @@ export function createState(grid: Grid): DeduceState { valueInfo.set(grid.categories[ci].values[vi], [ci, vi]); } } - // Identity-pin the first ordered category to match randomSolution and - // encodeBase behavior. + // Pin the display axis to match encodeBase and randomSolution: value[k] + // is fixed at position k to break the n!-fold position symmetry. const firstOrderedIdx = grid.categories.findIndex((c) => c.ordered === true); if (firstOrderedIdx < 0) throw new Error("Grid has no ordered category"); const pinCat = grid.categories[firstOrderedIdx]; diff --git a/packages/logic-grid/src/deduce/structural.test.ts b/packages/logic-grid/src/deduce/structural.test.ts index 532b8ee..68fc725 100644 --- a/packages/logic-grid/src/deduce/structural.test.ts +++ b/packages/logic-grid/src/deduce/structural.test.ts @@ -16,9 +16,9 @@ describe("deduce structural techniques", () => { // Eliminate Red from 1,2,3 → Red forced to 0. // Naked_single then removes 0 from all other Colors. const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 1 }, - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Red", position: 3 }, + { type: "not_same_position", a: "Red", b: "second" }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Red", b: "fourth" }, ]; const result = deduce(constraints, grid); const step = result.steps.find((s) => s.technique === "naked_single"); @@ -31,9 +31,9 @@ describe("deduce structural techniques", () => { it("hidden_single assigns the only remaining candidate for a position", () => { // Red, Blue, Green excluded from position 3 → Yellow must be there. const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Green", position: 3 }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Green", b: "fourth" }, ]; const result = deduce(constraints, grid); const step = result.steps.find((s) => s.technique === "hidden_single"); @@ -44,10 +44,10 @@ describe("deduce structural techniques", () => { it("naked_pair eliminates positions from other values in category", () => { // Red and Blue can only be at {0,1} — no other Color can be there const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 2 }, - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Blue", position: 2 }, - { type: "not_at_position", value: "Blue", position: 3 }, + { type: "not_same_position", a: "Red", b: "third" }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "third" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, ]; const result = deduce(constraints, grid); const step = result.steps.find((s) => s.technique === "naked_pair"); @@ -72,12 +72,12 @@ describe("deduce structural techniques", () => { }); // Red, Blue, Green restricted to {0,1,2}; Yellow and White still have all 5 const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Blue", position: 4 }, - { type: "not_at_position", value: "Green", position: 3 }, - { type: "not_at_position", value: "Green", position: 4 }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "fifth" }, + { type: "not_same_position", a: "Green", b: "fourth" }, + { type: "not_same_position", a: "Green", b: "fifth" }, ]; const result = deduce(constraints, grid5); const step = result.steps.find((s) => s.technique === "naked_triple"); @@ -107,20 +107,20 @@ describe("deduce structural techniques", () => { }); // Red={0,1,2} and Blue={0,1,3} are the ONLY colors reachable at positions 0 or 1 const constraints: Constraint[] = [ - { type: "not_at_position", value: "Red", position: 3 }, - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Red", position: 5 }, - { type: "not_at_position", value: "Blue", position: 2 }, - { type: "not_at_position", value: "Blue", position: 4 }, - { type: "not_at_position", value: "Blue", position: 5 }, - { type: "not_at_position", value: "Green", position: 0 }, - { type: "not_at_position", value: "Green", position: 1 }, - { type: "not_at_position", value: "Yellow", position: 0 }, - { type: "not_at_position", value: "Yellow", position: 1 }, - { type: "not_at_position", value: "White", position: 0 }, - { type: "not_at_position", value: "White", position: 1 }, - { type: "not_at_position", value: "Black", position: 0 }, - { type: "not_at_position", value: "Black", position: 1 }, + { type: "not_same_position", a: "Red", b: "fourth" }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Red", b: "sixth" }, + { type: "not_same_position", a: "Blue", b: "third" }, + { type: "not_same_position", a: "Blue", b: "fifth" }, + { type: "not_same_position", a: "Blue", b: "sixth" }, + { type: "not_same_position", a: "Green", b: "first" }, + { type: "not_same_position", a: "Green", b: "second" }, + { type: "not_same_position", a: "Yellow", b: "first" }, + { type: "not_same_position", a: "Yellow", b: "second" }, + { type: "not_same_position", a: "White", b: "first" }, + { type: "not_same_position", a: "White", b: "second" }, + { type: "not_same_position", a: "Black", b: "first" }, + { type: "not_same_position", a: "Black", b: "second" }, ]; const result = deduce(constraints, grid6); const step = result.steps.find((s) => s.technique === "hidden_pair"); @@ -154,30 +154,30 @@ describe("deduce structural techniques", () => { }); const constraints: Constraint[] = [ // Red → {0,1,2,3} - { type: "not_at_position", value: "Red", position: 4 }, - { type: "not_at_position", value: "Red", position: 5 }, - { type: "not_at_position", value: "Red", position: 6 }, + { type: "not_same_position", a: "Red", b: "fifth" }, + { type: "not_same_position", a: "Red", b: "sixth" }, + { type: "not_same_position", a: "Red", b: "seventh" }, // Blue → {0,1,2,4} - { type: "not_at_position", value: "Blue", position: 3 }, - { type: "not_at_position", value: "Blue", position: 5 }, - { type: "not_at_position", value: "Blue", position: 6 }, + { type: "not_same_position", a: "Blue", b: "fourth" }, + { type: "not_same_position", a: "Blue", b: "sixth" }, + { type: "not_same_position", a: "Blue", b: "seventh" }, // Green → {0,1,2,5} - { type: "not_at_position", value: "Green", position: 3 }, - { type: "not_at_position", value: "Green", position: 4 }, - { type: "not_at_position", value: "Green", position: 6 }, + { type: "not_same_position", a: "Green", b: "fourth" }, + { type: "not_same_position", a: "Green", b: "fifth" }, + { type: "not_same_position", a: "Green", b: "seventh" }, // Yellow, White, Black, Purple → {3,4,5,6} - { type: "not_at_position", value: "Yellow", position: 0 }, - { type: "not_at_position", value: "Yellow", position: 1 }, - { type: "not_at_position", value: "Yellow", position: 2 }, - { type: "not_at_position", value: "White", position: 0 }, - { type: "not_at_position", value: "White", position: 1 }, - { type: "not_at_position", value: "White", position: 2 }, - { type: "not_at_position", value: "Black", position: 0 }, - { type: "not_at_position", value: "Black", position: 1 }, - { type: "not_at_position", value: "Black", position: 2 }, - { type: "not_at_position", value: "Purple", position: 0 }, - { type: "not_at_position", value: "Purple", position: 1 }, - { type: "not_at_position", value: "Purple", position: 2 }, + { type: "not_same_position", a: "Yellow", b: "first" }, + { type: "not_same_position", a: "Yellow", b: "second" }, + { type: "not_same_position", a: "Yellow", b: "third" }, + { type: "not_same_position", a: "White", b: "first" }, + { type: "not_same_position", a: "White", b: "second" }, + { type: "not_same_position", a: "White", b: "third" }, + { type: "not_same_position", a: "Black", b: "first" }, + { type: "not_same_position", a: "Black", b: "second" }, + { type: "not_same_position", a: "Black", b: "third" }, + { type: "not_same_position", a: "Purple", b: "first" }, + { type: "not_same_position", a: "Purple", b: "second" }, + { type: "not_same_position", a: "Purple", b: "third" }, ]; const result = deduce(constraints, grid7); const step = result.steps.find((s) => s.technique === "hidden_triple"); @@ -193,7 +193,7 @@ describe("deduce structural techniques", () => { const constraints: Constraint[] = [ { type: "same_position", a: "Red", b: "Alice" }, { type: "same_position", a: "Alice", b: "Blue" }, - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, ]; const result = deduce(constraints, grid); const allAssigns = result.steps.flatMap((s) => s.assignments); @@ -206,7 +206,7 @@ describe("deduce structural techniques", () => { const constraints: Constraint[] = [ { type: "same_position", a: "Red", b: "Alice" }, { type: "not_same_position", a: "Red", b: "Bob" }, - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "first" }, ]; const result = deduce(constraints, grid); const allElims = result.steps.flatMap((s) => s.eliminations); diff --git a/packages/logic-grid/src/difficulty.test.ts b/packages/logic-grid/src/difficulty.test.ts index f47563c..e6c5fcd 100644 --- a/packages/logic-grid/src/difficulty.test.ts +++ b/packages/logic-grid/src/difficulty.test.ts @@ -17,7 +17,7 @@ describe("classify by constraint types only", () => { const constraints: Constraint[] = [ { type: "same_position", a: "Red", b: "Cat" }, { type: "not_same_position", a: "Blue", b: "Dog" }, - { type: "at_position", value: "Tea", position: 0 }, + { type: "same_position", a: "Tea", b: "first" }, ]; expect(classify(constraints)).toBe("easy"); }); @@ -94,10 +94,10 @@ describe("classify by constraint types only", () => { describe("classify with grid (deduction depth)", () => { it("returns easy when human elimination fully solves it", () => { - // Fully pinned: every value has an at_position or same_position chain from one + // Fully pinned: every value has a same_position chain from one const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Blue", position: 1 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Blue", b: "second" }, { type: "same_position", a: "Red", b: "Cat" }, { type: "same_position", a: "Blue", b: "Dog" }, { type: "same_position", a: "Red", b: "Tea" }, @@ -107,11 +107,11 @@ describe("classify with grid (deduction depth)", () => { }); it("uses not_same_position elimination in deduction depth", () => { - // Fully pinned via at_position + same_position + not_same_position elimination + // Fully pinned via same_position + not_same_position elimination const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Blue", position: 1 }, - { type: "at_position", value: "Green", position: 2 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Blue", b: "second" }, + { type: "same_position", a: "Green", b: "third" }, { type: "same_position", a: "Red", b: "Cat" }, { type: "same_position", a: "Blue", b: "Coffee" }, { type: "same_position", a: "Red", b: "Tea" }, diff --git a/packages/logic-grid/src/difficulty.ts b/packages/logic-grid/src/difficulty.ts index e2489e8..fad0e51 100644 --- a/packages/logic-grid/src/difficulty.ts +++ b/packages/logic-grid/src/difficulty.ts @@ -4,8 +4,6 @@ import { deduce } from "./deduce"; export const EASY_TYPES: Set = new Set([ "same_position", "not_same_position", - "at_position", - "not_at_position", ]); export const MEDIUM_TYPES: Set = new Set([ diff --git a/packages/logic-grid/src/encoding.test.ts b/packages/logic-grid/src/encoding.test.ts index 451bd0e..e1aa374 100644 --- a/packages/logic-grid/src/encoding.test.ts +++ b/packages/logic-grid/src/encoding.test.ts @@ -127,30 +127,6 @@ describe("encodeConstraint", () => { } }); - it("at_position pins a value", () => { - const ctx = createContext(grid3x3); - const clauses = encodePuzzle(ctx, [ - { type: "at_position", value: "Red", position: 1 }, - ]); - const solutions = solveAllSAT(clauses, 100); - for (const sol of solutions) { - const decoded = decodeSolution(ctx, sol); - expect(decoded["Red"]).toBe(1); - } - }); - - it("not_at_position excludes a value from a position", () => { - const ctx = createContext(grid3x3); - const clauses = encodePuzzle(ctx, [ - { type: "not_at_position", value: "Red", position: 0 }, - ]); - const solutions = solveAllSAT(clauses, 100); - for (const sol of solutions) { - const decoded = decodeSolution(ctx, sol); - expect(decoded["Red"]).not.toBe(0); - } - }); - it("next_to forces adjacency", () => { const ctx = createContext(grid3x3); const clauses = encodePuzzle(ctx, [ @@ -309,9 +285,9 @@ describe("encodeConstraint", () => { // Pin everything for a 3x3 const ctx = createContext(grid3x3); const constraints: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Blue", position: 1 }, - { type: "at_position", value: "Green", position: 2 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Blue", b: "second" }, + { type: "same_position", a: "Green", b: "third" }, { type: "same_position", a: "Red", b: "Cat" }, { type: "same_position", a: "Blue", b: "Dog" }, { type: "same_position", a: "Red", b: "Tea" }, diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index 537bb0c..e289522 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -1,15 +1,6 @@ import type { Category, Constraint, Grid } from "./types"; import { resolveAxis } from "./axis"; -/** - * True when `axis` is the first ordered category in `grid`. This category is - * identity-pinned by encodeBase (rank = position), so the cheap positional - * encoders can be used for constraints targeting it. - */ -function isIdentityPinnedAxis(grid: Grid, axis: Category): boolean { - return grid.categories.find((c) => c.ordered === true) === axis; -} - /** * Comparative constraint types for binary rank relations (a, b). * `between` and `not_between` are ternary and handled separately. @@ -165,7 +156,7 @@ export class RankVarAllocator { /** Next available variable ID, starting above position variables. */ private nextVar: number; /** Cache: "axisName:value" → base var index for that value's rank vars. */ - private cache = new Map(); + private readonly cache = new Map(); /** Accumulated channeling clauses (forward + AMO + ALO). */ readonly channeling: number[][] = []; @@ -286,206 +277,16 @@ export function encodeBase(ctx: EncodingContext): number[][] { } } - // Identity-pin the first ordered category. This matches the generator's - // randomSolution behavior and keeps the row-based positional encoder - // semantically consistent with the axis-tagged comparative constraints. - const firstOrdered = grid.categories.find((c) => c.ordered === true); - if (!firstOrdered) throw new Error("Grid has no ordered category"); - for (let i = 0; i < firstOrdered.values.length; i++) { - clauses.push([variable(ctx, firstOrdered.values[i], i)]); - } - - return clauses; -} - -// --- Positional fast path for identity-pinned axes --- -// The first ordered category is identity-pinned (value i at row i) by -// encodeBase. For constraints targeting this axis, we can use direct -// position-based clauses instead of the larger rank-forbidding clauses. - -function encodePositionalNextTo( - ctx: EncodingContext, - a: string, - b: string, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, a, p)]; - if (p > 0) clause.push(variable(ctx, b, p - 1)); - if (p < n - 1) clause.push(variable(ctx, b, p + 1)); - clauses.push(clause); - } - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, b, p)]; - if (p > 0) clause.push(variable(ctx, a, p - 1)); - if (p < n - 1) clause.push(variable(ctx, a, p + 1)); - clauses.push(clause); - } - return clauses; -} - -function encodePositionalNotNextTo( - ctx: EncodingContext, - a: string, - b: string, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - for (let p = 0; p < n - 1; p++) { - clauses.push([-variable(ctx, a, p), -variable(ctx, b, p + 1)]); - clauses.push([-variable(ctx, a, p + 1), -variable(ctx, b, p)]); - } - return clauses; -} - -function encodePositionalLeftOf( - ctx: EncodingContext, - a: string, - b: string, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - for (let p = 0; p < n - 1; p++) { - clauses.push([-variable(ctx, a, p), variable(ctx, b, p + 1)]); - } - for (let p = 1; p < n; p++) { - clauses.push([-variable(ctx, b, p), variable(ctx, a, p - 1)]); + // Pin the display axis to break the n!-fold position symmetry. Without + // this, every puzzle would have n! equivalent solutions (one per + // permutation of abstract position slots). This is the only axis that + // gets identity-pinned; all others use the general rank-var encoder. + const dispAxis = grid.categories.find((c) => c.ordered === true); + if (!dispAxis) throw new Error("Grid has no ordered category"); + for (let i = 0; i < dispAxis.values.length; i++) { + clauses.push([variable(ctx, dispAxis.values[i], i)]); } - clauses.push([-variable(ctx, a, n - 1)]); - clauses.push([-variable(ctx, b, 0)]); - return clauses; -} -function encodePositionalBefore( - ctx: EncodingContext, - a: string, - b: string, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, a, p)]; - for (let q = p + 1; q < n; q++) clause.push(variable(ctx, b, q)); - clauses.push(clause); - } - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, b, p)]; - for (let q = 0; q < p; q++) clause.push(variable(ctx, a, q)); - clauses.push(clause); - } - return clauses; -} - -function encodePositionalBetween( - ctx: EncodingContext, - outer1: string, - middle: string, - outer2: string, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - for (let po1 = 0; po1 < n; po1++) { - for (let po2 = 0; po2 < n; po2++) { - if (po1 === po2) continue; - const lo = Math.min(po1, po2); - const hi = Math.max(po1, po2); - const validMiddle: number[] = []; - for (let pm = lo + 1; pm < hi; pm++) { - validMiddle.push(variable(ctx, middle, pm)); - } - if (validMiddle.length === 0) { - clauses.push([ - -variable(ctx, outer1, po1), - -variable(ctx, outer2, po2), - ]); - } else { - clauses.push([ - -variable(ctx, outer1, po1), - -variable(ctx, outer2, po2), - ...validMiddle, - ]); - } - } - } - return clauses; -} - -function encodePositionalNotBetween( - ctx: EncodingContext, - outer1: string, - middle: string, - outer2: string, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - for (let pm = 1; pm < n - 1; pm++) { - for (let po1 = 0; po1 < pm; po1++) { - for (let po2 = pm + 1; po2 < n; po2++) { - clauses.push([ - -variable(ctx, middle, pm), - -variable(ctx, outer1, po1), - -variable(ctx, outer2, po2), - ]); - clauses.push([ - -variable(ctx, middle, pm), - -variable(ctx, outer1, po2), - -variable(ctx, outer2, po1), - ]); - } - } - } - return clauses; -} - -function encodePositionalExactDistance( - ctx: EncodingContext, - a: string, - b: string, - distance: number, - numVals: number[] | undefined, -): number[][] { - const n = ctx.numPositions; - const clauses: number[][] = []; - if (numVals) { - const validPairs: [number, number][] = []; - for (let i = 0; i < n; i++) { - for (let j = i + 1; j < n; j++) { - if (Math.abs(numVals[i] - numVals[j]) === distance) { - validPairs.push([i, j]); - } - } - } - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, a, p)]; - for (const [p1, p2] of validPairs) { - if (p1 === p) clause.push(variable(ctx, b, p2)); - if (p2 === p) clause.push(variable(ctx, b, p1)); - } - clauses.push(clause); - } - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, b, p)]; - for (const [p1, p2] of validPairs) { - if (p1 === p) clause.push(variable(ctx, a, p2)); - if (p2 === p) clause.push(variable(ctx, a, p1)); - } - clauses.push(clause); - } - } else { - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, a, p)]; - if (p + distance < n) clause.push(variable(ctx, b, p + distance)); - if (p - distance >= 0) clause.push(variable(ctx, b, p - distance)); - clauses.push(clause); - } - for (let p = 0; p < n; p++) { - const clause: number[] = [-variable(ctx, b, p)]; - if (p + distance < n) clause.push(variable(ctx, a, p + distance)); - if (p - distance >= 0) clause.push(variable(ctx, a, p - distance)); - clauses.push(clause); - } - } return clauses; } @@ -522,65 +323,43 @@ export function encodeConstraint( case "left_of": case "before": { const axis = resolveAxis(ctx.grid, constraint.axis); - if (isIdentityPinnedAxis(ctx.grid, axis)) { - switch (constraint.type) { - case "next_to": - return encodePositionalNextTo(ctx, constraint.a, constraint.b); - case "not_next_to": - return encodePositionalNotNextTo(ctx, constraint.a, constraint.b); - case "left_of": - return encodePositionalLeftOf(ctx, constraint.a, constraint.b); - case "before": - return encodePositionalBefore(ctx, constraint.a, constraint.b); - } - } const bad = badBinaryRankPairs( constraint.type, axis.values.length, 0, undefined, ); - return encodeBinaryAxis(ctx, alloc!, constraint.a, constraint.b, axis, bad); + return encodeBinaryAxis( + ctx, + alloc!, + constraint.a, + constraint.b, + axis, + bad, + ); } case "exact_distance": { const axis = resolveAxis(ctx.grid, constraint.axis); - if (isIdentityPinnedAxis(ctx.grid, axis)) { - return encodePositionalExactDistance( - ctx, - constraint.a, - constraint.b, - constraint.distance, - axis.numericValues, - ); - } const bad = badBinaryRankPairs( "exact_distance", axis.values.length, constraint.distance, axis.numericValues, ); - return encodeBinaryAxis(ctx, alloc!, constraint.a, constraint.b, axis, bad); + return encodeBinaryAxis( + ctx, + alloc!, + constraint.a, + constraint.b, + axis, + bad, + ); } case "between": case "not_between": { const axis = resolveAxis(ctx.grid, constraint.axis); - if (isIdentityPinnedAxis(ctx.grid, axis)) { - return constraint.type === "between" - ? encodePositionalBetween( - ctx, - constraint.outer1, - constraint.middle, - constraint.outer2, - ) - : encodePositionalNotBetween( - ctx, - constraint.outer1, - constraint.middle, - constraint.outer2, - ); - } return encodeBetweenAxis( ctx, alloc!, @@ -591,14 +370,6 @@ export function encodeConstraint( constraint.type === "not_between", ); } - - case "at_position": { - return [[variable(ctx, constraint.value, constraint.position)]]; - } - - case "not_at_position": { - return [[-variable(ctx, constraint.value, constraint.position)]]; - } } } diff --git a/packages/logic-grid/src/generator.test.ts b/packages/logic-grid/src/generator.test.ts index f997780..73c9427 100644 --- a/packages/logic-grid/src/generator.test.ts +++ b/packages/logic-grid/src/generator.test.ts @@ -87,12 +87,6 @@ describe("generate", () => { expect(rm).toBeLessThan(hi); break; } - case "at_position": - expect(posOf.get(c.value)).toBe(c.position); - break; - case "not_at_position": - expect(posOf.get(c.value)).not.toBe(c.position); - break; case "before": { const ra = rankOf(c.a, puzzle.grid, c.axis); const rb = rankOf(c.b, puzzle.grid, c.axis); @@ -216,23 +210,19 @@ describe("generate", () => { expect(hasUniqueSolution(puzzle.constraints, puzzle.grid)).toBe(true); }); - it("prefers relational clues over at_position", () => { + it("prefers relational clues over same_position with display axis", () => { const puzzle = generate({ size: 4, categories: 4, seed: 42 }); const types: Record = {}; for (const c of puzzle.constraints) { types[c.type] = (types[c.type] || 0) + 1; } - const atPos = types["at_position"] ?? 0; const relational = - (types["same_position"] ?? 0) + (types["next_to"] ?? 0) + (types["left_of"] ?? 0) + (types["between"] ?? 0); - // Relational clues should outnumber at_position - expect(relational).toBeGreaterThan(atPos); - // at_position should be a minority (less than half of total) - expect(atPos).toBeLessThan(puzzle.constraints.length / 2); + // Relational clues should be present + expect(relational).toBeGreaterThan(0); }); it("respects difficulty easy", () => { @@ -244,12 +234,7 @@ describe("generate", () => { }); expect(puzzle.difficulty).toBe("easy"); for (const c of puzzle.constraints) { - expect([ - "same_position", - "not_same_position", - "at_position", - "not_at_position", - ]).toContain(c.type); + expect(["same_position", "not_same_position"]).toContain(c.type); } }); @@ -265,8 +250,6 @@ describe("generate", () => { expect([ "same_position", "not_same_position", - "at_position", - "not_at_position", "next_to", "left_of", "before", diff --git a/packages/logic-grid/src/generator.ts b/packages/logic-grid/src/generator.ts index 67c4d7b..19ffd85 100644 --- a/packages/logic-grid/src/generator.ts +++ b/packages/logic-grid/src/generator.ts @@ -15,7 +15,12 @@ import { IncrementalSolver } from "./sat"; import { renderClue } from "./clues/templates"; import { classify, EASY_TYPES, MEDIUM_TYPES } from "./difficulty"; import { deduce } from "./deduce"; -import { isOrdered, orderedCategories, resolveAxis } from "./axis"; +import { + displayAxisCategory, + isOrdered, + orderedCategories, + resolveAxis, +} from "./axis"; import { DEFAULT_CATEGORIES, defaultHouseCategory } from "./default-config"; const MAX_RETRIES = 100; @@ -91,11 +96,7 @@ export function generate(options?: GenerateOptions): Puzzle { ); // Build ONE incremental solver with activation literals for all constraints - const incSolver = buildIncrementalSolver( - solverCtx, - clauseCache, - alloc, - ); + const incSolver = buildIncrementalSolver(solverCtx, clauseCache, alloc); const minimal = minimizeConstraints( filtered, @@ -213,12 +214,11 @@ function sliceCategory(c: Category, size: number): Category { } function randomSolution(grid: Grid, rng: () => number): Solution { - // Identity-assign the first ordered category so row = axis rank for it. - // This lets the positional fast-path encoder handle constraints on this axis - // cheaply. Other ordered axes use the rank-forbidding encoder. - const firstOrdered = grid.categories.findIndex((c) => c.ordered === true); - return grid.categories.map((cat, idx) => { - if (idx === firstOrdered) { + // The display axis is identity-pinned (value[i] at position i) to break + // the n!-fold position symmetry. All other categories are shuffled. + const dispAxis = displayAxisCategory(grid); + return grid.categories.map((cat) => { + if (cat === dispAxis) { const assignment: Assignment = {}; for (let i = 0; i < cat.values.length; i++) { assignment[cat.values[i]] = i; @@ -417,17 +417,26 @@ function enumerateConstraints(solution: Solution, grid: Grid): Constraint[] { } } - // --- Position constraints --- - // Skip values in the first ordered category — identity pinning makes their - // rows trivially determined by encodeBase's identity pinning. - const firstOrdered = orderedCats[0]; - const firstOrderedValues = new Set(firstOrdered.values); + // --- Position constraints (via display axis) --- + // Express each value's position as same_position / not_same_position against + // the display axis values, so clues read naturally ("Alice lives in the + // first house" rather than "Alice is at position 0"). + const dispAxis = displayAxisCategory(grid); + const dispValues = new Set(dispAxis.values); for (const [val, pos] of posOf) { - if (firstOrderedValues.has(val)) continue; - constraints.push({ type: "at_position", value: val, position: pos }); + if (dispValues.has(val)) continue; + constraints.push({ + type: "same_position", + a: val, + b: dispAxis.values[pos], + }); for (let p = 0; p < n; p++) { if (p !== pos) { - constraints.push({ type: "not_at_position", value: val, position: p }); + constraints.push({ + type: "not_same_position", + a: val, + b: dispAxis.values[p], + }); } } } diff --git a/packages/logic-grid/src/index.test.ts b/packages/logic-grid/src/index.test.ts index 8d9c987..7d4cd6a 100644 --- a/packages/logic-grid/src/index.test.ts +++ b/packages/logic-grid/src/index.test.ts @@ -5,9 +5,7 @@ import { hasUniqueSolution, classify, samePosition, - nextTo, leftOf, - atPosition, } from "./index"; import { makeGrid } from "./test-helpers"; @@ -46,7 +44,7 @@ describe("public API integration", () => { ], }); const constraints = [ - atPosition("Red", 0), + samePosition("Red", "first"), samePosition("Red", "Cat"), leftOf("Blue", "Green", "House"), ]; @@ -65,13 +63,6 @@ describe("public API integration", () => { } }); - it("all constraint factories are exported", () => { - expect(typeof samePosition).toBe("function"); - expect(typeof nextTo).toBe("function"); - expect(typeof leftOf).toBe("function"); - expect(typeof atPosition).toBe("function"); - }); - it("generate with custom noun/verb produces correct clues", () => { const puzzle = generate({ size: 4, diff --git a/packages/logic-grid/src/index.ts b/packages/logic-grid/src/index.ts index 8da199c..9a14139 100644 --- a/packages/logic-grid/src/index.ts +++ b/packages/logic-grid/src/index.ts @@ -14,8 +14,6 @@ export { notBetween, before, exactDistance, - atPosition, - notAtPosition, } from "./clues/constraints"; export { renderClue } from "./clues/templates"; diff --git a/packages/logic-grid/src/solver.test.ts b/packages/logic-grid/src/solver.test.ts index 8fede80..89cde6e 100644 --- a/packages/logic-grid/src/solver.test.ts +++ b/packages/logic-grid/src/solver.test.ts @@ -21,12 +21,12 @@ const grid3x3 = makeGrid({ }); const puzzle3x3: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, + { type: "same_position", a: "Red", b: "first" }, { type: "same_position", a: "Red", b: "Cat" }, { type: "left_of", a: "Blue", b: "Green", axis: "House" }, { type: "same_position", a: "Blue", b: "Dog" }, { type: "same_position", a: "Dog", b: "Coffee" }, - { type: "at_position", value: "Tea", position: 0 }, + { type: "same_position", a: "Tea", b: "first" }, ]; describe("solve", () => { @@ -43,8 +43,8 @@ describe("solve", () => { it("returns null for contradictory constraints", () => { const impossible: Constraint[] = [ - { type: "at_position", value: "Red", position: 0 }, - { type: "at_position", value: "Red", position: 1 }, + { type: "same_position", a: "Red", b: "first" }, + { type: "same_position", a: "Red", b: "second" }, ]; expect(solve(impossible, grid3x3)).toBeNull(); }); @@ -71,14 +71,14 @@ const grid4x4 = makeGrid({ }); const puzzle4x4: Constraint[] = [ - { type: "at_position", value: "Alice", position: 0 }, + { type: "same_position", a: "Alice", b: "first" }, { type: "same_position", a: "Alice", b: "Red" }, { type: "same_position", a: "Alice", b: "Tea" }, { type: "next_to", a: "Bob", b: "Alice", axis: "House" }, { type: "same_position", a: "Dave", b: "Yellow" }, { type: "left_of", a: "Blue", b: "Green", axis: "House" }, { type: "same_position", a: "Carol", b: "Fish" }, - { type: "at_position", value: "Milk", position: 2 }, + { type: "same_position", a: "Milk", b: "third" }, { type: "same_position", a: "Dog", b: "Coffee" }, { type: "left_of", a: "Dog", b: "Fish", axis: "House" }, { type: "not_same_position", a: "Alice", b: "Bird" }, diff --git a/packages/logic-grid/src/types.ts b/packages/logic-grid/src/types.ts index 7541f8a..9996758 100644 --- a/packages/logic-grid/src/types.ts +++ b/packages/logic-grid/src/types.ts @@ -159,9 +159,7 @@ export type Constraint = b: string; distance: number; axis: string; - } - | { type: "at_position"; value: string; position: number } - | { type: "not_at_position"; value: string; position: number }; + }; /** Puzzle difficulty level, determined by constraint types and deduction depth. */ export type Difficulty = "easy" | "medium" | "hard" | "expert"; @@ -183,8 +181,6 @@ export interface Puzzle { /** The technique used in a deduction step. */ export type DeductionTechnique = - | "direct" - | "elimination" | "same_position" | "not_same_position" | "next_to" From ed8cc148b38f7bd80661d320f7a15a0dab8b3797 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:00:00 +0200 Subject: [PATCH 05/28] perf: skip rank vars for pinned-axis constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the constraint's axis is the pinned display axis, rank = position by construction. Use position variables directly instead of allocating rank auxiliary variables and channeling clauses. Saves ~(V·M·n + V·M²) clauses per pinned-axis constraint. --- packages/logic-grid/src/encoding.ts | 31 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index e289522..b96d371 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -1,6 +1,11 @@ import type { Category, Constraint, Grid } from "./types"; import { resolveAxis } from "./axis"; +/** True when `axis` is pinned (rank = position) by encodeBase for symmetry breaking. */ +function isPinnedAxis(grid: Grid, axis: Category): boolean { + return grid.categories.find((c) => c.ordered === true) === axis; +} + /** * Comparative constraint types for binary rank relations (a, b). * `between` and `not_between` are ternary and handled separately. @@ -70,11 +75,17 @@ function encodeBinaryAxis( badPairs: [number, number][], ): number[][] { const clauses: number[][] = []; - for (const [i, j] of badPairs) { - clauses.push([ - -alloc.rankVar(ctx, axis, a, i), - -alloc.rankVar(ctx, axis, b, j), - ]); + if (isPinnedAxis(ctx.grid, axis)) { + // Pinned axis: rank = position, use position vars directly. + for (const [i, j] of badPairs) + clauses.push([-variable(ctx, a, i), -variable(ctx, b, j)]); + } else { + for (const [i, j] of badPairs) { + clauses.push([ + -alloc.rankVar(ctx, axis, a, i), + -alloc.rankVar(ctx, axis, b, j), + ]); + } } return clauses; } @@ -96,6 +107,10 @@ function encodeBetweenAxis( forbidStrictlyBetween: boolean, ): number[][] { const M = axis.values.length; + const pinned = isPinnedAxis(ctx.grid, axis); + const v = pinned + ? (val: string, rank: number) => variable(ctx, val, rank) + : (val: string, rank: number) => alloc.rankVar(ctx, axis, val, rank); const clauses: number[][] = []; for (let i = 0; i < M; i++) { for (let j = 0; j < M; j++) { @@ -107,11 +122,7 @@ function encodeBetweenAxis( ? strictlyBetween : !strictlyBetween; if (!violates) continue; - clauses.push([ - -alloc.rankVar(ctx, axis, outer1, i), - -alloc.rankVar(ctx, axis, outer2, j), - -alloc.rankVar(ctx, axis, middle, k), - ]); + clauses.push([-v(outer1, i), -v(outer2, j), -v(middle, k)]); } } } From 5c965053896c67363abfa41e8e54c1dfb5c5653c Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:10:39 +0200 Subject: [PATCH 06/28] =?UTF-8?q?refactor:=20review=20cleanup=20=E2=80=94?= =?UTF-8?q?=20cache=20axis=20terms,=20extract=20helpers,=20fix=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache AxisTerms on DeduceState instead of recomputing per step - Extract lc() helper in templates.ts for the repeated lowercase conditional - Make alloc parameter required on encodeConstraint (was optional but always needed) - Fix SILENT_STEP using removed "direct" technique type - Single-pass between rank validation instead of three passes - Fix stale at_position reference in verb JSDoc --- packages/logic-grid/src/clues/templates.ts | 17 ++--- packages/logic-grid/src/deduce/constraints.ts | 71 +++++-------------- .../logic-grid/src/deduce/contradiction.ts | 4 +- packages/logic-grid/src/deduce/state.test.ts | 8 ++- packages/logic-grid/src/deduce/state.ts | 31 ++++---- packages/logic-grid/src/deduce/structural.ts | 13 ++-- packages/logic-grid/src/encoding.ts | 8 +-- packages/logic-grid/src/types.ts | 2 +- 8 files changed, 63 insertions(+), 91 deletions(-) diff --git a/packages/logic-grid/src/clues/templates.ts b/packages/logic-grid/src/clues/templates.ts index 8ddc203..8126dc2 100644 --- a/packages/logic-grid/src/clues/templates.ts +++ b/packages/logic-grid/src/clues/templates.ts @@ -8,20 +8,23 @@ function findCategory(value: string, grid: Grid): Category { throw new Error(`Unknown value: ${value}`); } +/** Lowercase a value if the category opts in. */ +function lc(value: string, cat: Category): string { + return cat.lowercase ? value.toLowerCase() : value; +} + /** Natural noun phrase: "Alice", "the red house", "the cat owner". */ function label(value: string, grid: Grid): string { const cat = findCategory(value, grid); if (!cat.noun) return value; - const v = cat.lowercase ? value.toLowerCase() : value; - return `the ${v} ${cat.noun}`; + return `the ${lc(value, cat)} ${cat.noun}`; } /** Value as it appears in the object position of a same_position clue. */ function objectValue(value: string, grid: Grid): string { const cat = findCategory(value, grid); - const v = cat.lowercase ? value.toLowerCase() : value; const suffix = cat.valueSuffix; - return suffix ? `${v} ${suffix}` : v; + return suffix ? `${lc(value, cat)} ${suffix}` : lc(value, cat); } /** Look up a symmetric comparator (plain string). */ @@ -98,12 +101,10 @@ function renderSamePosition( // adjective verb. Recovers the classical Color+House idiom: // `same_position(Red, "1st")` → "The 1st house is red." if (catA.positionAdjective && catB.ordered === true) { - const v = catA.lowercase ? constraint.a.toLowerCase() : constraint.a; - return `${capitalize(label(constraint.b, grid))} ${catA.positionAdjective[idx]} ${v}.`; + return `${capitalize(label(constraint.b, grid))} ${catA.positionAdjective[idx]} ${lc(constraint.a, catA)}.`; } if (catB.positionAdjective && catA.ordered === true) { - const v = catB.lowercase ? constraint.b.toLowerCase() : constraint.b; - return `${capitalize(label(constraint.a, grid))} ${catB.positionAdjective[idx]} ${v}.`; + return `${capitalize(label(constraint.a, grid))} ${catB.positionAdjective[idx]} ${lc(constraint.b, catB)}.`; } const [subj, obj] = ordered(constraint.a, constraint.b, grid); diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index b2ab240..c0a2445 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -17,7 +17,6 @@ import { describeResult, clueRef, describeKnown, - axisTerms, axisRankDomain, projectRanksToPositions, } from "./state"; @@ -82,7 +81,7 @@ function tryBinaryRankSpace( [ci], uniqueElims, assigns, - `${clueRef(ci)}${description} ${describeResult(state.grid, assigns, uniqueElims)}.`, + `${clueRef(ci)}${description} ${describeResult(state.terms, assigns, uniqueElims)}.`, ); } @@ -150,12 +149,12 @@ function trySamePosition( const ctx = knownA || knownB; const because = ctx ? `. ${ctx}, so ` : ", so "; - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; let explanation: string; if (assigns.length > 0) { explanation = `${clueRef(ci)}${c.a} and ${c.b} are in the same ${noun}${because}both are in the ${posLabel(assigns[0].position)} ${noun}.`; } else { - explanation = `${clueRef(ci)}${c.a} and ${c.b} are in the same ${noun}${because}${describeResult(state.grid, assigns, elims)}.`; + explanation = `${clueRef(ci)}${c.a} and ${c.b} are in the same ${noun}${because}${describeResult(state.terms, assigns, elims)}.`; } return step("same_position", [ci], elims, assigns, explanation); } @@ -186,7 +185,7 @@ function tryNotSamePosition( const pinned = posA !== null ? c.a : c.b; const pinnedPos = posA ?? posB!; const other = posA !== null ? c.b : c.a; - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; const assignSuffix = assigns.length > 0 ? ` ${assigns.map((a) => `${a.value} must be in the ${posLabel(a.position)} ${noun}`).join("; ")}.` @@ -308,62 +307,28 @@ function tryBetweenRankSpace( const rM = axisRankDomain(state, c.middle, axis); if (rO1.size === 0 || rO2.size === 0 || rM.size === 0) return null; - // For `between`: middle rank must be strictly between the two outers. - // Eliminate middle ranks where no valid outer pair exists on both sides. - // For `not_between`: middle rank must NOT be strictly between. - const badM = new Set(); - for (const rm of rM) { - let ok = false; - for (const r1 of rO1) { - for (const r2 of rO2) { - const lo = Math.min(r1, r2); - const hi = Math.max(r1, r2); - const isBetween = r1 !== r2 && rm > lo && rm < hi; - if (isNotBetween ? !isBetween : isBetween) { - ok = true; - break; - } - } - if (ok) break; - } - if (!ok) badM.add(rm); - } - - // Similarly eliminate outer ranks that can't participate in any valid triple. - const badO1 = new Set(); + // Single-pass: find which ranks are valid for each role by scanning all + // triples once instead of three separate O(|rO1|·|rO2|·|rM|) passes. + const okO1 = new Set(); + const okO2 = new Set(); + const okM = new Set(); for (const r1 of rO1) { - let ok = false; for (const r2 of rO2) { + const lo = Math.min(r1, r2); + const hi = Math.max(r1, r2); for (const rm of rM) { - const lo = Math.min(r1, r2); - const hi = Math.max(r1, r2); - const isBetween = r1 !== r2 && rm > lo && rm < hi; - if (isNotBetween ? !isBetween : isBetween) { - ok = true; - break; - } - } - if (ok) break; - } - if (!ok) badO1.add(r1); - } - const badO2 = new Set(); - for (const r2 of rO2) { - let ok = false; - for (const r1 of rO1) { - for (const rm of rM) { - const lo = Math.min(r1, r2); - const hi = Math.max(r1, r2); const isBetween = r1 !== r2 && rm > lo && rm < hi; if (isNotBetween ? !isBetween : isBetween) { - ok = true; - break; + okO1.add(r1); + okO2.add(r2); + okM.add(rm); } } - if (ok) break; } - if (!ok) badO2.add(r2); } + const badO1 = new Set([...rO1].filter((r) => !okO1.has(r))); + const badO2 = new Set([...rO2].filter((r) => !okO2.has(r))); + const badM = new Set([...rM].filter((r) => !okM.has(r))); if (badM.size === 0 && badO1.size === 0 && badO2.size === 0) return null; @@ -383,7 +348,7 @@ function tryBetweenRankSpace( [ci], uniqueElims, assigns, - `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}. ${describeResult(state.grid, assigns, uniqueElims)}.`, + `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}. ${describeResult(state.terms, assigns, uniqueElims)}.`, ); } diff --git a/packages/logic-grid/src/deduce/contradiction.ts b/packages/logic-grid/src/deduce/contradiction.ts index 6ad5168..7648651 100644 --- a/packages/logic-grid/src/deduce/contradiction.ts +++ b/packages/logic-grid/src/deduce/contradiction.ts @@ -1,5 +1,5 @@ import type { Constraint, DeductionStep } from "../types"; -import { type DeduceState, first, step, cloneState, axisTerms } from "./state"; +import { type DeduceState, first, step, cloneState } from "./state"; import { propagateToFixpoint } from "./propagate"; /** @@ -31,7 +31,7 @@ export function tryContradiction( ps.delete(p); const value = state.grid.categories[ci].values[vi]; const assigns = ps.size === 1 ? [{ value, position: first(ps) }] : []; - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; const assignSuffix = assigns.length > 0 ? ` So ${value} must be in the ${posLabel(assigns[0].position)} ${noun}.` diff --git a/packages/logic-grid/src/deduce/state.test.ts b/packages/logic-grid/src/deduce/state.test.ts index 647c085..8817ae9 100644 --- a/packages/logic-grid/src/deduce/state.test.ts +++ b/packages/logic-grid/src/deduce/state.test.ts @@ -10,20 +10,22 @@ const grid = makeGrid({ ], }); +const terms = createState(grid).terms; + describe("describeResult", () => { it("describes assignments", () => { - const result = describeResult(grid, [{ value: "Alice", position: 0 }], []); + const result = describeResult(terms, [{ value: "Alice", position: 0 }], []); expect(result).toBe("Alice must be in the first house"); }); it("describes eliminations", () => { - const result = describeResult(grid, [], [{ value: "Bob", position: 1 }]); + const result = describeResult(terms, [], [{ value: "Bob", position: 1 }]); expect(result).toBe("Bob can't be in the second house"); }); it("combines assignments and eliminations", () => { const result = describeResult( - grid, + terms, [{ value: "Alice", position: 0 }], [{ value: "Bob", position: 2 }], ); diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index fcdc222..efae635 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -4,7 +4,6 @@ import type { DeductionStep, DeductionTechnique, } from "../types"; -import { orderedCategories } from "../axis"; import { ordinal } from "../grid-utils"; // --- Display utilities --- @@ -15,13 +14,9 @@ export interface AxisTerms { posLabel: (p: number) => string; } -/** - * Get axis-aware terminology for explanations from the first ordered category - * (identity-pinned axis). Returns noun (e.g. "house") and position label - * function (e.g. p=0 → "first"). - */ -export function axisTerms(grid: Grid): AxisTerms { - const axis = orderedCategories(grid)[0]; +/** Compute axis terms for the grid's display axis. */ +function computeAxisTerms(grid: Grid): AxisTerms { + const axis = grid.categories.find((c) => c.ordered === true); return { noun: axis?.noun || "position", posLabel: (p) => axis?.values[p] ?? ordinal(p), @@ -29,11 +24,11 @@ export function axisTerms(grid: Grid): AxisTerms { } export function describeResult( - grid: Grid, + terms: AxisTerms, assigns: { value: string; position: number }[], elims: { value: string; position: number }[], ): string { - const { noun, posLabel } = axisTerms(grid); + const { noun, posLabel } = terms; const parts: string[] = []; for (const a of assigns) { parts.push(`${a.value} must be in the ${posLabel(a.position)} ${noun}`); @@ -59,7 +54,7 @@ export function clueRef(ci: number): string { /** Describe what we know about a value's position — used for "because" context. */ export function describeKnown(state: DeduceState, value: string): string { - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; const pos = getAssigned(state, value); if (pos !== null) return `${value} is in the ${posLabel(pos)} ${noun}`; const possible = getPossible(state, value); @@ -77,6 +72,8 @@ export interface DeduceState { n: number; possible: Set[][]; valueInfo: Map; + /** Cached axis-aware terminology for deduction explanations. */ + terms: AxisTerms; /** When true, try* functions skip explanation building and return SILENT_STEP. */ silent: boolean; } @@ -86,7 +83,7 @@ export interface DeduceState { * Only used as a truthy non-null return value — callers check `!== null`, never inspect fields. */ export const SILENT_STEP: DeductionStep = Object.freeze({ - technique: "direct" as DeductionTechnique, + technique: "same_position" as DeductionTechnique, clueIndices: [], eliminations: [], assignments: [], @@ -113,7 +110,14 @@ export function createState(grid: Grid): DeduceState { possible[firstOrderedIdx][vi].clear(); possible[firstOrderedIdx][vi].add(vi); } - return { grid, n, possible, valueInfo, silent: false }; + return { + grid, + n, + possible, + valueInfo, + terms: computeAxisTerms(grid), + silent: false, + }; } export function getPossible(state: DeduceState, value: string): Set { @@ -142,6 +146,7 @@ export function cloneState(state: DeduceState): DeduceState { n: state.n, possible: state.possible.map((cat) => cat.map((ps) => new Set(ps))), valueInfo: state.valueInfo, + terms: state.terms, silent: state.silent, }; } diff --git a/packages/logic-grid/src/deduce/structural.ts b/packages/logic-grid/src/deduce/structural.ts index 4e66009..8a1b64c 100644 --- a/packages/logic-grid/src/deduce/structural.ts +++ b/packages/logic-grid/src/deduce/structural.ts @@ -7,7 +7,6 @@ import { step, dedup, collectAssigns, - axisTerms, } from "./state"; // --- Structural deductions --- @@ -38,7 +37,7 @@ export function tryNakedSingles(state: DeduceState): DeductionStep | null { if (elims.length === 0) continue; if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; return step( "naked_single", [], @@ -73,7 +72,7 @@ export function tryHiddenSingles(state: DeduceState): DeductionStep | null { state.possible[ci][lastVi].clear(); state.possible[ci][lastVi].add(p); if (state.silent) return SILENT_STEP; - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; return step( "hidden_single", [], @@ -132,7 +131,7 @@ export function tryNakedPairs(state: DeduceState): DeductionStep | null { if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; const positions = [...ps1].map((p) => posLabel(p)).join(" and "); return step( "naked_pair", @@ -184,7 +183,7 @@ export function tryNakedTriples(state: DeduceState): DeductionStep | null { if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; const positions = [...union].map((p) => posLabel(p)).join(", "); return step( "naked_triple", @@ -235,7 +234,7 @@ export function tryHiddenPairs(state: DeduceState): DeductionStep | null { getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, uniqueElims); - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; return step( "hidden_pair", [], @@ -285,7 +284,7 @@ export function tryHiddenTriples(state: DeduceState): DeductionStep | null { getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, uniqueElims); - const { noun, posLabel } = axisTerms(state.grid); + const { noun, posLabel } = state.terms; return step( "hidden_triple", [], diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index b96d371..e5fd677 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -305,7 +305,7 @@ export function encodeBase(ctx: EncodingContext): number[][] { export function encodeConstraint( ctx: EncodingContext, constraint: Constraint, - alloc?: RankVarAllocator, + alloc: RankVarAllocator, ): number[][] { const n = ctx.numPositions; @@ -342,7 +342,7 @@ export function encodeConstraint( ); return encodeBinaryAxis( ctx, - alloc!, + alloc, constraint.a, constraint.b, axis, @@ -360,7 +360,7 @@ export function encodeConstraint( ); return encodeBinaryAxis( ctx, - alloc!, + alloc, constraint.a, constraint.b, axis, @@ -373,7 +373,7 @@ export function encodeConstraint( const axis = resolveAxis(ctx.grid, constraint.axis); return encodeBetweenAxis( ctx, - alloc!, + alloc, constraint.outer1, constraint.middle, constraint.outer2, diff --git a/packages/logic-grid/src/types.ts b/packages/logic-grid/src/types.ts index 9996758..a5694da 100644 --- a/packages/logic-grid/src/types.ts +++ b/packages/logic-grid/src/types.ts @@ -63,7 +63,7 @@ interface CategoryCore { type OrderednessFields = | { ordered: true; - /** Verb phrases for same-position / at_position clues: `[positive, negative]`. Required on ordered categories. */ + /** Verb phrases for same-position clues: `[positive, negative]`. Required on ordered categories. */ verb: [string, string]; /** Per-rank numeric values for non-equidistant `exact_distance`. Must match `values` length and be ascending. */ numericValues?: number[]; From 865fb12702176d4c5ac21caae0156a9e5547ce96 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:12:11 +0200 Subject: [PATCH 07/28] chore: fix stale identity-pinning and at_position references in comments --- packages/logic-grid/src/deduce/constraints.ts | 4 ++-- packages/logic-grid/src/encoding.ts | 2 +- packages/logic-grid/src/generator.ts | 6 +++--- packages/logic-grid/src/types.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index c0a2445..2a8a30d 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -23,7 +23,7 @@ import { /** * Generic rank-space deduction for binary comparative constraints on a - * non-identity-pinned axis. Computes the rank domain of both values, + * non-pinned axis. Computes the rank domain of both values, * applies the predicate `isValid(rankA, rankB)` to decide which ranks * to eliminate, then projects back to position eliminations. */ @@ -290,7 +290,7 @@ function tryNotBetween( } /** - * Rank-space deduction for between / not_between on non-identity-pinned axes. + * Rank-space deduction for between / not_between. */ function tryBetweenRankSpace( state: DeduceState, diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index e5fd677..b109cc4 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -291,7 +291,7 @@ export function encodeBase(ctx: EncodingContext): number[][] { // Pin the display axis to break the n!-fold position symmetry. Without // this, every puzzle would have n! equivalent solutions (one per // permutation of abstract position slots). This is the only axis that - // gets identity-pinned; all others use the general rank-var encoder. + // gets pinned; all others use the general rank-var encoder. const dispAxis = grid.categories.find((c) => c.ordered === true); if (!dispAxis) throw new Error("Grid has no ordered category"); for (let i = 0; i < dispAxis.values.length; i++) { diff --git a/packages/logic-grid/src/generator.ts b/packages/logic-grid/src/generator.ts index 19ffd85..1076ed6 100644 --- a/packages/logic-grid/src/generator.ts +++ b/packages/logic-grid/src/generator.ts @@ -214,8 +214,8 @@ function sliceCategory(c: Category, size: number): Category { } function randomSolution(grid: Grid, rng: () => number): Solution { - // The display axis is identity-pinned (value[i] at position i) to break - // the n!-fold position symmetry. All other categories are shuffled. + // The display axis is pinned (value[i] at position i) to break the + // n!-fold position symmetry. All other categories are shuffled. const dispAxis = displayAxisCategory(grid); return grid.categories.map((cat) => { if (cat === dispAxis) { @@ -252,7 +252,7 @@ function enumerateConstraints(solution: Solution, grid: Grid): Constraint[] { catArr.push(ci); } } - // Map from value name → its row position. Used for the at_position block. + // Map from value name → its row position. Used for position constraints. const posOf = new Map(); for (let i = 0; i < allValues.length; i++) posOf.set(allValues[i], posArr[i]); diff --git a/packages/logic-grid/src/types.ts b/packages/logic-grid/src/types.ts index a5694da..cab93ad 100644 --- a/packages/logic-grid/src/types.ts +++ b/packages/logic-grid/src/types.ts @@ -56,7 +56,7 @@ interface CategoryCore { * `ordered: true` implies: * - `values` array defines the canonical total order (rank = array index). * - The category may be referenced as `axis` on any comparative constraint. - * - `verb` is required (used for at_position rendering). + * - `verb` is required (used for same-position clue rendering). * - `numericValues` and `orderingPhrases` become legal. * - The category participates in multi-axis generation, deduction, rendering. */ From f291e3c0d2951a8665a724a40cc56f154f4a7b83 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:21:33 +0200 Subject: [PATCH 08/28] refactor: remove dead code, reach 100% branch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove dead dedup() function and its calls — the callers never produce duplicate eliminations. Remove unreachable ordinal() fallback in computeAxisTerms. Add tests for contradictory and duplicate unit clauses in SAT solver, and for ordered categories with no noun. Restructure processUnitClauses for v8 coverage accuracy. --- packages/logic-grid/src/deduce/constraints.ts | 25 +++++++-------- packages/logic-grid/src/deduce/state.test.ts | 31 +++++++++++++++++++ packages/logic-grid/src/deduce/state.ts | 20 +++--------- packages/logic-grid/src/deduce/structural.ts | 21 ++++++------- packages/logic-grid/src/sat.test.ts | 10 ++++++ packages/logic-grid/src/sat.ts | 6 ++-- 6 files changed, 69 insertions(+), 44 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 2a8a30d..160e725 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -12,7 +12,6 @@ import { getAssigned, first, step, - dedup, collectAssigns, describeResult, clueRef, @@ -71,17 +70,16 @@ function tryBinaryRankSpace( ...projectRanksToPositions(state, a, axis, badRanksA), ...projectRanksToPositions(state, b, axis, badRanksB), ]; - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); + if (elims.length === 0) return null; + for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); + const assigns = collectAssigns(state, elims); return step( technique, [ci], - uniqueElims, + elims, assigns, - `${clueRef(ci)}${description} ${describeResult(state.terms, assigns, uniqueElims)}.`, + `${clueRef(ci)}${description} ${describeResult(state.terms, assigns, elims)}.`, ); } @@ -337,18 +335,17 @@ function tryBetweenRankSpace( ...projectRanksToPositions(state, c.outer1, axis, badO1), ...projectRanksToPositions(state, c.outer2, axis, badO2), ]; - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) return null; - for (const e of uniqueElims) getPossible(state, e.value).delete(e.position); + if (elims.length === 0) return null; + for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); + const assigns = collectAssigns(state, elims); const verb = isNotBetween ? "is not between" : "is between"; return step( technique, [ci], - uniqueElims, + elims, assigns, - `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}. ${describeResult(state.terms, assigns, uniqueElims)}.`, + `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}. ${describeResult(state.terms, assigns, elims)}.`, ); } @@ -362,7 +359,7 @@ function tryExactDistance( const unit = axis.orderingPhrases.unit; const distLabel = unit ? `${c.distance} ${c.distance === 1 ? unit[0] : unit[1]}` - : `${c.distance} ${c.distance === 1 ? axis.noun || "position" : (axis.noun || "position") + "s"}`; + : `${c.distance} ${c.distance === 1 ? state.terms.noun : state.terms.noun + "s"}`; return tryBinaryRankSpace( state, c.a, diff --git a/packages/logic-grid/src/deduce/state.test.ts b/packages/logic-grid/src/deduce/state.test.ts index 8817ae9..406c475 100644 --- a/packages/logic-grid/src/deduce/state.test.ts +++ b/packages/logic-grid/src/deduce/state.test.ts @@ -48,6 +48,37 @@ describe("createState invariant", () => { }); }); +describe("axisTerms fallback", () => { + it("uses 'position' when ordered category has no noun", () => { + const noNounGrid = { + size: 2, + categories: [ + { + name: "Idx", + values: ["A", "B"], + ordered: true as const, + verb: ["is", "is not"] as [string, string], + orderingPhrases: { + comparators: { + before: ["is before", "is after"] as [string, string], + left_of: ["is left of", "is right of"] as [string, string], + next_to: "is next to", + not_next_to: "is not next to", + between: "is between", + not_between: "is not between", + exact_distance: "is exactly", + }, + }, + }, + { name: "X", values: ["x1", "x2"] }, + ], + }; + const state = createState(noNounGrid); + expect(state.terms.noun).toBe("position"); + expect(state.terms.posLabel(0)).toBe("A"); + }); +}); + describe("describeKnown", () => { // makeGrid auto-prepends a House category; Name is now categories[1]. it("describes assigned value", () => { diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index efae635..9be11ed 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -4,7 +4,6 @@ import type { DeductionStep, DeductionTechnique, } from "../types"; -import { ordinal } from "../grid-utils"; // --- Display utilities --- @@ -16,10 +15,11 @@ export interface AxisTerms { /** Compute axis terms for the grid's display axis. */ function computeAxisTerms(grid: Grid): AxisTerms { - const axis = grid.categories.find((c) => c.ordered === true); + // createState throws if no ordered category exists, so axis is always defined here. + const axis = grid.categories.find((c) => c.ordered === true)!; return { - noun: axis?.noun || "position", - posLabel: (p) => axis?.values[p] ?? ordinal(p), + noun: axis.noun || "position", + posLabel: (p) => axis.values[p], }; } @@ -170,18 +170,6 @@ export function first(set: Set): number { // --- Helpers --- -export function dedup( - elims: { value: string; position: number }[], -): { value: string; position: number }[] { - const seen = new Set(); - return elims.filter((e) => { - const key = `${e.value}:${e.position}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - export function collectAssigns( state: DeduceState, elims: { value: string; position: number }[], diff --git a/packages/logic-grid/src/deduce/structural.ts b/packages/logic-grid/src/deduce/structural.ts index 8a1b64c..aa0ec39 100644 --- a/packages/logic-grid/src/deduce/structural.ts +++ b/packages/logic-grid/src/deduce/structural.ts @@ -5,7 +5,6 @@ import { first, getPossible, step, - dedup, collectAssigns, } from "./state"; @@ -227,18 +226,18 @@ export function tryHiddenPairs(state: DeduceState): DeductionStep | null { elims.push({ value: cat.values[vi2], position: p }); } - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) continue; - for (const e of uniqueElims) + if (elims.length === 0) continue; + + for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); + const assigns = collectAssigns(state, elims); const { noun, posLabel } = state.terms; return step( "hidden_pair", [], - uniqueElims, + elims, assigns, `${cat.values[vi1]} and ${cat.values[vi2]} are the only ${cat.name} values for the ${posLabel(p1)} and ${posLabel(p2)} ${noun}s, so they must be restricted to those ${noun}s.`, ); @@ -277,18 +276,18 @@ export function tryHiddenTriples(state: DeduceState): DeductionStep | null { if (p !== p1 && p !== p2 && p !== p3) elims.push({ value: cat.values[vi3], position: p }); - const uniqueElims = dedup(elims); - if (uniqueElims.length === 0) continue; + + if (elims.length === 0) continue; - for (const e of uniqueElims) + for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; - const assigns = collectAssigns(state, uniqueElims); + const assigns = collectAssigns(state, elims); const { noun, posLabel } = state.terms; return step( "hidden_triple", [], - uniqueElims, + elims, assigns, `${cat.values[vi1]}, ${cat.values[vi2]}, and ${cat.values[vi3]} are the only ${cat.name} values for the ${posLabel(p1)}, ${posLabel(p2)}, and ${posLabel(p3)} ${noun}s, so they must be restricted to those ${noun}s.`, ); diff --git a/packages/logic-grid/src/sat.test.ts b/packages/logic-grid/src/sat.test.ts index 2ad527b..09bf8f7 100644 --- a/packages/logic-grid/src/sat.test.ts +++ b/packages/logic-grid/src/sat.test.ts @@ -125,6 +125,16 @@ describe("IncrementalSolver", () => { expect(solver.isUniqueUnder([-1])).toBe(false); }); + it("init returns false for contradictory unit clauses", () => { + const solver = new IncrementalSolver([[1], [-1]]); + expect(solver.init()).toBe(false); + }); + + it("init succeeds with duplicate unit clauses", () => { + const solver = new IncrementalSolver([[1], [1], [2, -2]]); + expect(solver.init()).toBe(true); + }); + it("skips assumption when variable already has same value", () => { // x1 must be true (unit clause forces it). Exactly one of x2,x3 is true. const solver = new IncrementalSolver([[1], [2, 3], [-2, -3]]); diff --git a/packages/logic-grid/src/sat.ts b/packages/logic-grid/src/sat.ts index 97c68aa..ec34b1a 100644 --- a/packages/logic-grid/src/sat.ts +++ b/packages/logic-grid/src/sat.ts @@ -184,10 +184,10 @@ class SATBase { const lit = this.litBuf[this.clauseOff[ci]]; const v = lit > 0 ? lit : -lit; const val = lit > 0 ? TRUE : FALSE; - if (this.values[v] === UNDEF) { + if (this.values[v] !== UNDEF) { + if (this.values[v] !== val) return false; + } else { this.assign(v, val); - } else if (this.values[v] !== val) { - return false; } } } From ea0cf300b735aa1604ba0cb240ec82b6e7c65b66 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:28:07 +0200 Subject: [PATCH 09/28] perf: pinned-axis fast path in deduction layer When the constraint's axis is pinned (rank = position), skip axisRankDomain/projectRanksToPositions and work directly with possible position sets. Recovers ~25-30% of the deduction regression at 6x6/8x8 grid sizes. --- packages/logic-grid/src/deduce/constraints.ts | 112 +++++++++++------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 160e725..8c25479 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -20,11 +20,16 @@ import { projectRanksToPositions, } from "./state"; +/** True when `axis` is the pinned display axis (rank = position). */ +function isPinnedAxis(state: DeduceState, axis: Category): boolean { + return state.grid.categories.find((c) => c.ordered === true) === axis; +} + /** - * Generic rank-space deduction for binary comparative constraints on a - * non-pinned axis. Computes the rank domain of both values, - * applies the predicate `isValid(rankA, rankB)` to decide which ranks - * to eliminate, then projects back to position eliminations. + * Generic rank-space deduction for binary comparative constraints. + * When the axis is pinned, rank = position so we work directly with + * possible sets (O(|ps|) per value). For non-pinned axes, computes + * rank domains and projects back through the axis assignment. */ function tryBinaryRankSpace( state: DeduceState, @@ -36,40 +41,42 @@ function tryBinaryRankSpace( isValid: (rankA: number, rankB: number) => boolean, description: string, ): DeductionStep | null { - const rankA = axisRankDomain(state, a, axis); - const rankB = axisRankDomain(state, b, axis); - if (rankA.size === 0 || rankB.size === 0) return null; + const pinned = isPinnedAxis(state, axis); + const pa = pinned ? getPossible(state, a) : axisRankDomain(state, a, axis); + const pb = pinned ? getPossible(state, b) : axisRankDomain(state, b, axis); + if (pa.size === 0 || pb.size === 0) return null; - // Find ranks to eliminate: a rank is bad for value X if no rank of Y satisfies the predicate. - const badRanksA = new Set(); - for (const ra of rankA) { - let hasValidB = false; - for (const rb of rankB) { - if (isValid(ra, rb)) { - hasValidB = true; - break; - } + // Find ranks to eliminate: bad for X if no Y satisfies the predicate. + const badA = new Set(); + const badB = new Set(); + for (const ra of pa) { + let ok = false; + for (const rb of pb) { + if (isValid(ra, rb)) { ok = true; break; } } - if (!hasValidB) badRanksA.add(ra); + if (!ok) badA.add(ra); } - const badRanksB = new Set(); - for (const rb of rankB) { - let hasValidA = false; - for (const ra of rankA) { - if (isValid(ra, rb)) { - hasValidA = true; - break; - } + for (const rb of pb) { + let ok = false; + for (const ra of pa) { + if (isValid(ra, rb)) { ok = true; break; } } - if (!hasValidA) badRanksB.add(rb); + if (!ok) badB.add(rb); } + if (badA.size === 0 && badB.size === 0) return null; - if (badRanksA.size === 0 && badRanksB.size === 0) return null; + // Project to position eliminations: pinned axis = direct, otherwise via axis. + const elims: { value: string; position: number }[] = []; + if (pinned) { + for (const r of badA) elims.push({ value: a, position: r }); + for (const r of badB) elims.push({ value: b, position: r }); + } else { + elims.push( + ...projectRanksToPositions(state, a, axis, badA), + ...projectRanksToPositions(state, b, axis, badB), + ); + } - const elims = [ - ...projectRanksToPositions(state, a, axis, badRanksA), - ...projectRanksToPositions(state, b, axis, badRanksB), - ]; if (elims.length === 0) return null; for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; @@ -300,13 +307,19 @@ function tryBetweenRankSpace( const technique: DeductionTechnique = isNotBetween ? "not_between" : "between"; - const rO1 = axisRankDomain(state, c.outer1, axis); - const rO2 = axisRankDomain(state, c.outer2, axis); - const rM = axisRankDomain(state, c.middle, axis); + const pinned = isPinnedAxis(state, axis); + const rO1 = pinned + ? getPossible(state, c.outer1) + : axisRankDomain(state, c.outer1, axis); + const rO2 = pinned + ? getPossible(state, c.outer2) + : axisRankDomain(state, c.outer2, axis); + const rM = pinned + ? getPossible(state, c.middle) + : axisRankDomain(state, c.middle, axis); if (rO1.size === 0 || rO2.size === 0 || rM.size === 0) return null; - // Single-pass: find which ranks are valid for each role by scanning all - // triples once instead of three separate O(|rO1|·|rO2|·|rM|) passes. + // Single-pass: find which ranks are valid for each role. const okO1 = new Set(); const okO2 = new Set(); const okM = new Set(); @@ -324,17 +337,24 @@ function tryBetweenRankSpace( } } } - const badO1 = new Set([...rO1].filter((r) => !okO1.has(r))); - const badO2 = new Set([...rO2].filter((r) => !okO2.has(r))); - const badM = new Set([...rM].filter((r) => !okM.has(r))); - - if (badM.size === 0 && badO1.size === 0 && badO2.size === 0) return null; - const elims = [ - ...projectRanksToPositions(state, c.middle, axis, badM), - ...projectRanksToPositions(state, c.outer1, axis, badO1), - ...projectRanksToPositions(state, c.outer2, axis, badO2), - ]; + // Collect eliminations: for pinned axis, rank = position directly. + const elims: { value: string; position: number }[] = []; + if (pinned) { + for (const r of rM) if (!okM.has(r)) elims.push({ value: c.middle, position: r }); + for (const r of rO1) if (!okO1.has(r)) elims.push({ value: c.outer1, position: r }); + for (const r of rO2) if (!okO2.has(r)) elims.push({ value: c.outer2, position: r }); + } else { + const badM = new Set([...rM].filter((r) => !okM.has(r))); + const badO1 = new Set([...rO1].filter((r) => !okO1.has(r))); + const badO2 = new Set([...rO2].filter((r) => !okO2.has(r))); + if (badM.size === 0 && badO1.size === 0 && badO2.size === 0) return null; + elims.push( + ...projectRanksToPositions(state, c.middle, axis, badM), + ...projectRanksToPositions(state, c.outer1, axis, badO1), + ...projectRanksToPositions(state, c.outer2, axis, badO2), + ); + } if (elims.length === 0) return null; for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; From 576fbc6b54336a5c2deb5a16d4996cec2aa597fc Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:41:16 +0200 Subject: [PATCH 10/28] fix: update demo for removed direct/elimination techniques --- packages/demo/src/lib/nudge-text.test.ts | 19 +------------------ packages/demo/src/lib/nudge-text.ts | 2 -- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/demo/src/lib/nudge-text.test.ts b/packages/demo/src/lib/nudge-text.test.ts index 41acc5b..6c86897 100644 --- a/packages/demo/src/lib/nudge-text.test.ts +++ b/packages/demo/src/lib/nudge-text.test.ts @@ -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({ @@ -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 }], }), @@ -150,8 +135,6 @@ describe("buildNudgeText", () => { it("TECHNIQUE_HINTS covers all techniques", () => { const techniques = [ - "direct", - "elimination", "same_position", "not_same_position", "next_to", diff --git a/packages/demo/src/lib/nudge-text.ts b/packages/demo/src/lib/nudge-text.ts index 4591eb4..dcebe67 100644 --- a/packages/demo/src/lib/nudge-text.ts +++ b/packages/demo/src/lib/nudge-text.ts @@ -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 = { - 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?", From 4456eb0b962ce783253a2f4e32af764aef45653f Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:48:59 +0200 Subject: [PATCH 11/28] style: fix formatting --- packages/logic-grid/src/deduce/constraints.ts | 19 ++++++++++++++----- packages/logic-grid/src/deduce/structural.ts | 8 ++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 8c25479..00c303d 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -52,14 +52,20 @@ function tryBinaryRankSpace( for (const ra of pa) { let ok = false; for (const rb of pb) { - if (isValid(ra, rb)) { ok = true; break; } + if (isValid(ra, rb)) { + ok = true; + break; + } } if (!ok) badA.add(ra); } for (const rb of pb) { let ok = false; for (const ra of pa) { - if (isValid(ra, rb)) { ok = true; break; } + if (isValid(ra, rb)) { + ok = true; + break; + } } if (!ok) badB.add(rb); } @@ -341,9 +347,12 @@ function tryBetweenRankSpace( // Collect eliminations: for pinned axis, rank = position directly. const elims: { value: string; position: number }[] = []; if (pinned) { - for (const r of rM) if (!okM.has(r)) elims.push({ value: c.middle, position: r }); - for (const r of rO1) if (!okO1.has(r)) elims.push({ value: c.outer1, position: r }); - for (const r of rO2) if (!okO2.has(r)) elims.push({ value: c.outer2, position: r }); + for (const r of rM) + if (!okM.has(r)) elims.push({ value: c.middle, position: r }); + for (const r of rO1) + if (!okO1.has(r)) elims.push({ value: c.outer1, position: r }); + for (const r of rO2) + if (!okO2.has(r)) elims.push({ value: c.outer2, position: r }); } else { const badM = new Set([...rM].filter((r) => !okM.has(r))); const badO1 = new Set([...rO1].filter((r) => !okO1.has(r))); diff --git a/packages/logic-grid/src/deduce/structural.ts b/packages/logic-grid/src/deduce/structural.ts index aa0ec39..6dba136 100644 --- a/packages/logic-grid/src/deduce/structural.ts +++ b/packages/logic-grid/src/deduce/structural.ts @@ -226,11 +226,9 @@ export function tryHiddenPairs(state: DeduceState): DeductionStep | null { elims.push({ value: cat.values[vi2], position: p }); } - if (elims.length === 0) continue; - for (const e of elims) - getPossible(state, e.value).delete(e.position); + for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); const { noun, posLabel } = state.terms; @@ -276,11 +274,9 @@ export function tryHiddenTriples(state: DeduceState): DeductionStep | null { if (p !== p1 && p !== p2 && p !== p3) elims.push({ value: cat.values[vi3], position: p }); - if (elims.length === 0) continue; - for (const e of elims) - getPossible(state, e.value).delete(e.position); + for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); const { noun, posLabel } = state.terms; From f5c8ac4e84c52738833e3773e5a5a175359f67a8 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:50:30 +0200 Subject: [PATCH 12/28] =?UTF-8?q?chore:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20at=5Fposition=20refs,=20naming,=20test=20du?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - theme.ts: rewrite positionAdjective docs to describe same-position inversion instead of removed at_position - rewrite.test.ts: drop duplicate same_position assertion - Rename tryBinaryRankSpace → tryBinaryAxis and tryBetweenRankSpace → tryBetweenAxis since they handle both pinned (position) and non- pinned (rank) axes --- packages/logic-grid-ai/src/rewrite.test.ts | 1 - packages/logic-grid-ai/src/theme.ts | 4 ++-- packages/logic-grid/src/deduce/constraints.ts | 22 +++++++++---------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/logic-grid-ai/src/rewrite.test.ts b/packages/logic-grid-ai/src/rewrite.test.ts index c673eab..d28362c 100644 --- a/packages/logic-grid-ai/src/rewrite.test.ts +++ b/packages/logic-grid-ai/src/rewrite.test.ts @@ -101,7 +101,6 @@ describe("rewriteClues", () => { expect(capturedPrompt).toContain('"type":"same_position"'); expect(capturedPrompt).toContain('"type":"next_to"'); - expect(capturedPrompt).toContain('"type":"same_position"'); }); it("retries on validation failure", async () => { diff --git a/packages/logic-grid-ai/src/theme.ts b/packages/logic-grid-ai/src/theme.ts index 642a00e..4624e77 100644 --- a/packages/logic-grid-ai/src/theme.ts +++ b/packages/logic-grid-ai/src/theme.ts @@ -53,7 +53,7 @@ function buildSchema(size: number, categories: number): JSONSchema { 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, lowercase: true, 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", @@ -140,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 diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 00c303d..0ee9c7e 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -26,12 +26,12 @@ function isPinnedAxis(state: DeduceState, axis: Category): boolean { } /** - * Generic rank-space deduction for binary comparative constraints. + * Generic deduction for binary comparative constraints. * When the axis is pinned, rank = position so we work directly with * possible sets (O(|ps|) per value). For non-pinned axes, computes * rank domains and projects back through the axis assignment. */ -function tryBinaryRankSpace( +function tryBinaryAxis( state: DeduceState, a: string, b: string, @@ -216,7 +216,7 @@ function tryNextTo( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - return tryBinaryRankSpace( + return tryBinaryAxis( state, c.a, c.b, @@ -234,7 +234,7 @@ function tryNotNextTo( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - return tryBinaryRankSpace( + return tryBinaryAxis( state, c.a, c.b, @@ -252,7 +252,7 @@ function tryLeftOf( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - return tryBinaryRankSpace( + return tryBinaryAxis( state, c.a, c.b, @@ -270,7 +270,7 @@ function tryBefore( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - return tryBinaryRankSpace( + return tryBinaryAxis( state, c.a, c.b, @@ -288,7 +288,7 @@ function tryBetween( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - return tryBetweenRankSpace(state, c, axis, ci, false); + return tryBetweenAxis(state, c, axis, ci, false); } function tryNotBetween( @@ -297,13 +297,13 @@ function tryNotBetween( ci: number, ): DeductionStep | null { const axis = resolveAxis(state.grid, c.axis); - return tryBetweenRankSpace(state, c, axis, ci, true); + return tryBetweenAxis(state, c, axis, ci, true); } /** - * Rank-space deduction for between / not_between. + * Generic deduction for between / not_between (pinned or non-pinned axis). */ -function tryBetweenRankSpace( +function tryBetweenAxis( state: DeduceState, c: { outer1: string; middle: string; outer2: string; axis: string }, axis: Category, @@ -389,7 +389,7 @@ function tryExactDistance( const distLabel = unit ? `${c.distance} ${c.distance === 1 ? unit[0] : unit[1]}` : `${c.distance} ${c.distance === 1 ? state.terms.noun : state.terms.noun + "s"}`; - return tryBinaryRankSpace( + return tryBinaryAxis( state, c.a, c.b, From 17af7848b4d1b597e95cffe5c950ec0ff38e7410 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:52:53 +0200 Subject: [PATCH 13/28] chore: drop redundant DeductionTechnique cast on SILENT_STEP --- packages/logic-grid/src/deduce/state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index 9be11ed..773bb1a 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -83,7 +83,7 @@ export interface DeduceState { * Only used as a truthy non-null return value — callers check `!== null`, never inspect fields. */ export const SILENT_STEP: DeductionStep = Object.freeze({ - technique: "same_position" as DeductionTechnique, + technique: "same_position", clueIndices: [], eliminations: [], assignments: [], From 6e20e84fe24f8804214b454959d9c901532ca3bf Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:35:55 +0200 Subject: [PATCH 14/28] feat: recall current state in comparative and between hints Add because-context to tryBinaryAxis (next_to, not_next_to, left_of, before, exact_distance) and tryBetweenAxis (between, not_between). Between joins all non-empty anchors since its deductions usually depend on multiple values' positions. Capture state via describeKnown BEFORE mutating possibilities so the context reports the TRIGGER, not the conclusion. Same fix applied to trySamePosition which had the same pre/post-mutation bug. Before: "Cat is between Alice and Blue on House. Cat can't be in the first or fourth house." After: "Cat is between Alice and Blue on House. Alice is in the fourth house and Blue is in the second house, so Cat must be in the third house." --- .../deduce/__snapshots__/index.test.ts.snap | 8 +++--- packages/logic-grid/src/deduce/constraints.ts | 25 ++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap index 8de25d3..6bfe5c4 100644 --- a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap @@ -2,14 +2,14 @@ exports[`deduce > snapshots explanation strings 1`] = ` [ - "Clue 1: Red and first are in the same house. Red is in the first house, so both are in the first house.", + "Clue 1: Red and first are in the same house. Red can only be in the first or second or third house, so both are in the first house.", "Clue 2: Red and Cat are in the same house. Red is in the first house, so both are in the first house.", - "Clue 3: Blue is directly before Green on House. Blue can't be in the third house; Green can't be in the first house.", + "Clue 3: Blue is directly before Green on House. Blue can only be in the first or second or third house, so Blue can't be in the third house; Green can't be in the first house.", "Clue 4: Blue and Dog are in the same house. Blue can only be in the first or second house, so Dog can't be in the third house.", "Clue 5: Dog and Coffee are in the same house. Dog can only be in the first or second house, so Coffee can't be in the third house.", - "Clue 6: Tea and first are in the same house. Tea is in the first house, so both are in the first house.", + "Clue 6: Tea and first are in the same house. Tea can only be in the first or second or third house, so both are in the first house.", "Red has no other possible house — it must be in the first house. So no other Color can be there.", - "Clue 3: Blue is directly before Green on House. Green must be in the third house.", + "Clue 3: Blue is directly before Green on House. Blue is in the second house, so Green must be in the third house.", "Clue 4: Blue and Dog are in the same house. Blue is in the second house, so both are in the second house.", "Clue 5: Dog and Coffee are in the same house. Dog is in the second house, so both are in the second house.", "Cat has no other possible house — it must be in the first house. So no other Pet can be there.", diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 0ee9c7e..4d5fa65 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -84,6 +84,10 @@ function tryBinaryAxis( } if (elims.length === 0) return null; + // Capture "because" context from pre-elim state — describeKnown after the + // mutation would report this step's own conclusions as the reason. + const ctx = describeKnown(state, a) || describeKnown(state, b); + const because = ctx ? ` ${ctx}, so` : ""; for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); @@ -92,7 +96,7 @@ function tryBinaryAxis( [ci], elims, assigns, - `${clueRef(ci)}${description} ${describeResult(state.terms, assigns, elims)}.`, + `${clueRef(ci)}${description}${because} ${describeResult(state.terms, assigns, elims)}.`, ); } @@ -140,6 +144,11 @@ function trySamePosition( if (!pa.has(p)) elims.push({ value: c.b, position: p }); } if (elims.length === 0) return null; + // Capture "because" context from pre-intersection state — after we collapse + // pa/pb to their intersection describeKnown would report this step's result. + const knownA = describeKnown(state, c.a); + const knownB = describeKnown(state, c.b); + const ctx = knownA || knownB; const intersection = new Set([...pa].filter((p) => pb.has(p))); pa.clear(); pb.clear(); @@ -154,10 +163,6 @@ function trySamePosition( assigns.push({ value: c.a, position: p }); assigns.push({ value: c.b, position: p }); } - // Build "because" context from whichever value is more constrained - const knownA = describeKnown(state, c.a); - const knownB = describeKnown(state, c.b); - const ctx = knownA || knownB; const because = ctx ? `. ${ctx}, so ` : ", so "; const { noun, posLabel } = state.terms; @@ -365,6 +370,14 @@ function tryBetweenAxis( ); } if (elims.length === 0) return null; + // Capture "because" context from pre-elim state. Between needs both anchors + // to explain the middle's placement, so join all non-empty descriptions. + const parts = [ + describeKnown(state, c.outer1), + describeKnown(state, c.outer2), + describeKnown(state, c.middle), + ].filter((s) => s !== ""); + const because = parts.length > 0 ? ` ${parts.join(" and ")}, so` : ""; for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); @@ -374,7 +387,7 @@ function tryBetweenAxis( [ci], elims, assigns, - `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}. ${describeResult(state.terms, assigns, elims)}.`, + `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}.${because} ${describeResult(state.terms, assigns, elims)}.`, ); } From d3339bbfd3f318ecb8243548a7fe3afb4dbdd33e Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:40:38 +0200 Subject: [PATCH 15/28] fix: skip vacuous 'can only be anywhere' context in describeKnown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The size <= 3 cutoff was fine before because callers sampled AFTER mutation, so fresh full-range possibles never triggered. With the pre-mutation capture for because-context, full-range values emit "X can only be in the 1st or 2nd or 3rd house" on a 3-grid — true but useless, and shadows informative pinned descriptions in trySamePosition (knownA wins over knownB even when knownA is vacuous). Add < state.n guard so we only emit when the domain is actually narrower than the full range. --- .../logic-grid/src/deduce/__snapshots__/index.test.ts.snap | 6 +++--- packages/logic-grid/src/deduce/state.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap index 6bfe5c4..bbf5a61 100644 --- a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap @@ -2,12 +2,12 @@ exports[`deduce > snapshots explanation strings 1`] = ` [ - "Clue 1: Red and first are in the same house. Red can only be in the first or second or third house, so both are in the first house.", + "Clue 1: Red and first are in the same house. first is in the first house, so both are in the first house.", "Clue 2: Red and Cat are in the same house. Red is in the first house, so both are in the first house.", - "Clue 3: Blue is directly before Green on House. Blue can only be in the first or second or third house, so Blue can't be in the third house; Green can't be in the first house.", + "Clue 3: Blue is directly before Green on House. Blue can't be in the third house; Green can't be in the first house.", "Clue 4: Blue and Dog are in the same house. Blue can only be in the first or second house, so Dog can't be in the third house.", "Clue 5: Dog and Coffee are in the same house. Dog can only be in the first or second house, so Coffee can't be in the third house.", - "Clue 6: Tea and first are in the same house. Tea can only be in the first or second or third house, so both are in the first house.", + "Clue 6: Tea and first are in the same house. first is in the first house, so both are in the first house.", "Red has no other possible house — it must be in the first house. So no other Color can be there.", "Clue 3: Blue is directly before Green on House. Blue is in the second house, so Green must be in the third house.", "Clue 4: Blue and Dog are in the same house. Blue is in the second house, so both are in the second house.", diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index 773bb1a..1784a28 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -58,7 +58,7 @@ export function describeKnown(state: DeduceState, value: string): string { const pos = getAssigned(state, value); if (pos !== null) return `${value} is in the ${posLabel(pos)} ${noun}`; const possible = getPossible(state, value); - if (possible.size <= 3) { + if (possible.size <= 3 && possible.size < state.n) { const posStr = [...possible].map((p) => posLabel(p)).join(" or "); return `${value} can only be in the ${posStr} ${noun}`; } From 9680ef6d1fe8a5d27169f811596225e020860b48 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:01:49 +0200 Subject: [PATCH 16/28] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20concise=20axis-value=20phrasing,=20comparator=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) Display-axis operand direct phrasing: when same_position or not_same_position involves a display-axis value (e.g. "first"), use "X must be in the " / "X is not in the " instead of the symmetric/tautological form. describeKnown also skips the tautology for pinned axis values to avoid shadowing informative operands. (2) Restore old pinned-axis wording in comparative descriptions: "directly left of", "somewhere left of", "next to", "not next to" instead of the generic semantic names. Drop " on " suffix for single-axis grids (retained for multi-axis disambiguation). (3) Document varCeiling's capture-after-allocation contract. (4) Replace "axisName:value" string cache key with nested Map> — collision-safe. (5) Document SILENT_STEP.technique as an opaque placeholder. --- .../deduce/__snapshots__/index.test.ts.snap | 8 +-- packages/logic-grid/src/deduce/constraints.ts | 50 ++++++++++++++++--- packages/logic-grid/src/deduce/state.ts | 12 ++++- packages/logic-grid/src/encoding.ts | 22 +++++--- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap index bbf5a61..05647f0 100644 --- a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap @@ -2,14 +2,14 @@ exports[`deduce > snapshots explanation strings 1`] = ` [ - "Clue 1: Red and first are in the same house. first is in the first house, so both are in the first house.", + "Clue 1: Red must be in the first house.", "Clue 2: Red and Cat are in the same house. Red is in the first house, so both are in the first house.", - "Clue 3: Blue is directly before Green on House. Blue can't be in the third house; Green can't be in the first house.", + "Clue 3: Blue is directly left of Green. Blue can't be in the third house; Green can't be in the first house.", "Clue 4: Blue and Dog are in the same house. Blue can only be in the first or second house, so Dog can't be in the third house.", "Clue 5: Dog and Coffee are in the same house. Dog can only be in the first or second house, so Coffee can't be in the third house.", - "Clue 6: Tea and first are in the same house. first is in the first house, so both are in the first house.", + "Clue 6: Tea must be in the first house.", "Red has no other possible house — it must be in the first house. So no other Color can be there.", - "Clue 3: Blue is directly before Green on House. Blue is in the second house, so Green must be in the third house.", + "Clue 3: Blue is directly left of Green. Blue is in the second house, so Green must be in the third house.", "Clue 4: Blue and Dog are in the same house. Blue is in the second house, so both are in the second house.", "Clue 5: Dog and Coffee are in the same house. Dog is in the second house, so both are in the second house.", "Cat has no other possible house — it must be in the first house. So no other Pet can be there.", diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 4d5fa65..fdae9f0 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -25,6 +25,14 @@ function isPinnedAxis(state: DeduceState, axis: Category): boolean { return state.grid.categories.find((c) => c.ordered === true) === axis; } +/** " on " suffix for multi-axis grids; empty string otherwise. */ +function axisSuffix(state: DeduceState, axis: Category): string { + const orderedCount = state.grid.categories.filter( + (c) => c.ordered === true, + ).length; + return orderedCount > 1 ? ` on ${axis.name}` : ""; +} + /** * Generic deduction for binary comparative constraints. * When the axis is pinned, rank = position so we work directly with @@ -165,7 +173,21 @@ function trySamePosition( } const because = ctx ? `. ${ctx}, so ` : ", so "; - const { noun, posLabel } = state.terms; + const { noun, posLabel, isAxisValue } = state.terms; + // When one operand is a display-axis value, use the concise direct form: + // "Clue N: X must be in the ." rather than the symmetric + // "X and axisVal are in the same " which reads as a tautology. + const axisSide = isAxisValue(c.a) ? c.a : isAxisValue(c.b) ? c.b : null; + if (axisSide !== null && assigns.length > 0) { + const other = axisSide === c.a ? c.b : c.a; + return step( + "same_position", + [ci], + elims, + assigns, + `${clueRef(ci)}${other} must be in the ${axisSide} ${noun}.`, + ); + } let explanation: string; if (assigns.length > 0) { explanation = `${clueRef(ci)}${c.a} and ${c.b} are in the same ${noun}${because}both are in the ${posLabel(assigns[0].position)} ${noun}.`; @@ -201,11 +223,25 @@ function tryNotSamePosition( const pinned = posA !== null ? c.a : c.b; const pinnedPos = posA ?? posB!; const other = posA !== null ? c.b : c.a; - const { noun, posLabel } = state.terms; + const { noun, posLabel, isAxisValue } = state.terms; const assignSuffix = assigns.length > 0 ? ` ${assigns.map((a) => `${a.value} must be in the ${posLabel(a.position)} ${noun}`).join("; ")}.` : ""; + // When one operand is a display-axis value, use the concise direct form: + // "Clue N: X is not in the ." The pinned-is-here reason is + // tautological for axis values. + const axisSide = isAxisValue(c.a) ? c.a : isAxisValue(c.b) ? c.b : null; + if (axisSide !== null) { + const nonAxis = axisSide === c.a ? c.b : c.a; + return step( + "not_same_position", + [ci], + elims, + assigns, + `${clueRef(ci)}${nonAxis} is not in the ${axisSide} ${noun}.${assignSuffix}`, + ); + } return step( "not_same_position", [ci], @@ -229,7 +265,7 @@ function tryNextTo( ci, "next_to", (ra, rb) => Math.abs(ra - rb) === 1, - `${c.a} is adjacent to ${c.b} on ${axis.name}.`, + `${c.a} is next to ${c.b}${axisSuffix(state, axis)}.`, ); } @@ -247,7 +283,7 @@ function tryNotNextTo( ci, "not_next_to", (ra, rb) => Math.abs(ra - rb) !== 1, - `${c.a} is not adjacent to ${c.b} on ${axis.name}.`, + `${c.a} is not next to ${c.b}${axisSuffix(state, axis)}.`, ); } @@ -265,7 +301,7 @@ function tryLeftOf( ci, "left_of", (ra, rb) => rb === ra + 1, - `${c.a} is directly before ${c.b} on ${axis.name}.`, + `${c.a} is directly left of ${c.b}${axisSuffix(state, axis)}.`, ); } @@ -283,7 +319,7 @@ function tryBefore( ci, "before", (ra, rb) => ra < rb, - `${c.a} is before ${c.b} on ${axis.name}.`, + `${c.a} is somewhere left of ${c.b}${axisSuffix(state, axis)}.`, ); } @@ -387,7 +423,7 @@ function tryBetweenAxis( [ci], elims, assigns, - `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2} on ${axis.name}.${because} ${describeResult(state.terms, assigns, elims)}.`, + `${clueRef(ci)}${c.middle} ${verb} ${c.outer1} and ${c.outer2}${axisSuffix(state, axis)}.${because} ${describeResult(state.terms, assigns, elims)}.`, ); } diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index 1784a28..b5ea244 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -11,15 +11,19 @@ import type { export interface AxisTerms { noun: string; posLabel: (p: number) => string; + /** True when `value` is a display-axis value (e.g. "first" on the House axis). */ + isAxisValue: (value: string) => boolean; } /** Compute axis terms for the grid's display axis. */ function computeAxisTerms(grid: Grid): AxisTerms { // createState throws if no ordered category exists, so axis is always defined here. const axis = grid.categories.find((c) => c.ordered === true)!; + const axisValues = new Set(axis.values); return { noun: axis.noun || "position", posLabel: (p) => axis.values[p], + isAxisValue: (value) => axisValues.has(value), }; } @@ -56,7 +60,12 @@ export function clueRef(ci: number): string { export function describeKnown(state: DeduceState, value: string): string { const { noun, posLabel } = state.terms; const pos = getAssigned(state, value); - if (pos !== null) return `${value} is in the ${posLabel(pos)} ${noun}`; + if (pos !== null) { + // Display-axis values are pinned to their own index — "first is in the + // first house" is tautological and shadows more informative operands. + if (posLabel(pos) === value) return ""; + return `${value} is in the ${posLabel(pos)} ${noun}`; + } const possible = getPossible(state, value); if (possible.size <= 3 && possible.size < state.n) { const posStr = [...possible].map((p) => posLabel(p)).join(" or "); @@ -81,6 +90,7 @@ export interface DeduceState { /** * Sentinel returned by try* functions in silent mode (state was mutated, no step details). * Only used as a truthy non-null return value — callers check `!== null`, never inspect fields. + * The `technique` value here is an arbitrary placeholder; nothing reads it. */ export const SILENT_STEP: DeductionStep = Object.freeze({ technique: "same_position", diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index b109cc4..e142409 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -166,8 +166,8 @@ export function createContext(grid: Grid): EncodingContext { export class RankVarAllocator { /** Next available variable ID, starting above position variables. */ private nextVar: number; - /** Cache: "axisName:value" → base var index for that value's rank vars. */ - private readonly cache = new Map(); + /** Cache: axis → value → base var index for that value's rank vars on that axis. */ + private readonly cache = new Map>(); /** Accumulated channeling clauses (forward + AMO + ALO). */ readonly channeling: number[][] = []; @@ -175,7 +175,13 @@ export class RankVarAllocator { this.nextVar = ctx.numValues * ctx.numPositions + 1; } - /** High-water mark: the first variable ID NOT used by this allocator. */ + /** + * High-water mark: the first variable ID NOT used by this allocator. + * Callers that add their own variables (e.g. activation literals) MUST + * capture this AFTER all rank var allocations are complete. Allocating + * more rank vars after a caller has captured varCeiling will silently + * collide with the caller's variables. + */ get varCeiling(): number { return this.nextVar; } @@ -191,13 +197,17 @@ export class RankVarAllocator { value: string, rank: number, ): number { - const key = `${axis.name}:${value}`; - let base = this.cache.get(key); + let axisCache = this.cache.get(axis); + if (!axisCache) { + axisCache = new Map(); + this.cache.set(axis, axisCache); + } + let base = axisCache.get(value); if (base === undefined) { base = this.nextVar; const M = axis.values.length; this.nextVar += M; - this.cache.set(key, base); + axisCache.set(value, base); this.emitChanneling(ctx, axis, value, base, M); } return base + rank; From 9c1f7e1f85e56882079895075e70e3c7d3968d88 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:19:34 +0200 Subject: [PATCH 17/28] perf: implication-form comparative encoder (single unified path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bad-pairs encoder (|bad pairs| × 1 binary clauses) with implication form: for each rank i of a, emit [¬a@i, b@j₁, b@j₂, ...] where jₖ are the valid b ranks under isValid. A single rankOrPos helper resolves to a position variable on the pinned axis and a rank auxiliary on non-pinned axes. The pinned case collapses to the classic positional implication-chain form without a separate code path — no duplication of per-constraint positional encoders. Applied to next_to, not_next_to, left_of, before, exact_distance, between, and not_between. Removed badBinaryRankPairs. Also fixes a latent bug in the not_next_to predicate: requiring i !== j would have forbidden two cross-category values from sharing the same position, which is normal (AMO only governs within a category). Silence v8 coverage on the defensive "retry exhausted" throw in generator.ts — unreachable for supported grid sizes, matches the existing convention for the minimize-returns-empty failsafe. Tests: 8x8 generation 710ms → 188ms, full suite ~570ms. Coverage: 100% (was flaky on the retry-exhausted path). --- packages/logic-grid/src/encoding.ts | 237 +++++++++++++++------------ packages/logic-grid/src/generator.ts | 3 + 2 files changed, 137 insertions(+), 103 deletions(-) diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index e142409..b244be7 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -7,64 +7,58 @@ function isPinnedAxis(grid: Grid, axis: Category): boolean { } /** - * Comparative constraint types for binary rank relations (a, b). - * `between` and `not_between` are ternary and handled separately. + * Predicate `isValid(rank_a, rank_b)` for a binary comparative. + * exact_distance is not here because its predicate depends on `distance` + * and optional numericValues — handled inline at the dispatch site. */ -type BinaryComparativeType = - | "before" - | "left_of" - | "next_to" - | "not_next_to" - | "exact_distance"; +function binaryPredicate( + type: "before" | "left_of" | "next_to" | "not_next_to", +): (i: number, j: number) => boolean { + switch (type) { + case "before": + return (i, j) => i < j; + case "left_of": + return (i, j) => j === i + 1; + case "next_to": + return (i, j) => Math.abs(i - j) === 1; + case "not_next_to": + return (i, j) => Math.abs(i - j) !== 1; + } +} /** - * Enumerate "bad" rank pairs for a binary comparative — the (rank_a, rank_b) - * combinations that violate the constraint and must be forbidden. + * Resolve rank `k` on `axis` for `value` to a SAT variable: + * - Pinned axis: rank = position, return the position variable directly. + * - Non-pinned axis: return the rank auxiliary variable (allocator handles + * channeling to position vars under the hood). */ -function badBinaryRankPairs( - type: BinaryComparativeType, - M: number, - distance: number, - numericValues: number[] | undefined, -): [number, number][] { - const bad: [number, number][] = []; - for (let i = 0; i < M; i++) { - for (let j = 0; j < M; j++) { - let violates: boolean; - switch (type) { - case "before": - violates = i >= j; - break; - case "left_of": - violates = j !== i + 1; - break; - case "next_to": - violates = Math.abs(i - j) !== 1; - break; - case "not_next_to": - violates = Math.abs(i - j) === 1; - break; - case "exact_distance": { - const d = numericValues - ? Math.abs(numericValues[i] - numericValues[j]) - : Math.abs(i - j); - violates = d !== distance; - break; - } - } - if (violates) bad.push([i, j]); - } - } - return bad; +function rankOrPos( + ctx: EncodingContext, + alloc: RankVarAllocator, + axis: Category, + value: string, + rank: number, +): number { + return isPinnedAxis(ctx.grid, axis) + ? variable(ctx, value, rank) + : alloc.rankVar(ctx, axis, value, rank); } /** - * Rank-var encoder for binary comparative constraints. For each bad rank pair - * (i, j), emits a 2-literal clause [-r(a,i), -r(b,j)]. Channeling clauses - * link rank vars to position vars (emitted once per (axis, value) pair via - * the RankVarAllocator). + * Encode a binary comparative constraint in implication form: + * For each rank i of a: [¬a@i, b@j₁, b@j₂, ...] where jₖ are valid b ranks. + * For each rank j of b: symmetric. + * + * On a pinned axis `rankOrPos` returns position vars, so this collapses to + * the classic positional implication-chain form (e.g. for `next_to`: + * "if a at p then b at p-1 or p+1"). On a non-pinned axis it emits the + * same structure over rank vars, with channeling clauses added once per + * (axis, value) pair by the allocator. * - * Clause count per constraint: |bad pairs| × 1 + channeling. + * Implication form produces tight, propagation-friendly clauses when valid + * ranks per operand form a narrow set (next_to, left_of, exact_distance). + * For constraints where the valid set is wide (not_next_to), the clauses + * are longer but still O(M) per side — acceptable. */ function encodeBinaryAxis( ctx: EncodingContext, @@ -72,57 +66,92 @@ function encodeBinaryAxis( a: string, b: string, axis: Category, - badPairs: [number, number][], + isValid: (i: number, j: number) => boolean, ): number[][] { + const M = axis.values.length; const clauses: number[][] = []; - if (isPinnedAxis(ctx.grid, axis)) { - // Pinned axis: rank = position, use position vars directly. - for (const [i, j] of badPairs) - clauses.push([-variable(ctx, a, i), -variable(ctx, b, j)]); - } else { - for (const [i, j] of badPairs) { - clauses.push([ - -alloc.rankVar(ctx, axis, a, i), - -alloc.rankVar(ctx, axis, b, j), - ]); + for (let i = 0; i < M; i++) { + const clause: number[] = [-rankOrPos(ctx, alloc, axis, a, i)]; + for (let j = 0; j < M; j++) { + if (isValid(i, j)) clause.push(rankOrPos(ctx, alloc, axis, b, j)); + } + clauses.push(clause); + } + for (let j = 0; j < M; j++) { + const clause: number[] = [-rankOrPos(ctx, alloc, axis, b, j)]; + for (let i = 0; i < M; i++) { + if (isValid(i, j)) clause.push(rankOrPos(ctx, alloc, axis, a, i)); } + clauses.push(clause); } return clauses; } /** - * Rank-var encoder for ternary between/not_between. For each bad rank triple - * (i, j, k), emits a 3-literal clause [-r(outer1,i), -r(outer2,j), -r(middle,k)]. + * Encode `between` in implication form: + * For each distinct rank pair (i, j) of the outers, emit + * [¬outer1@i, ¬outer2@j, middle@k₁, ..., middle@kₘ] where kₘ are the + * ranks strictly between lo=min(i,j) and hi=max(i,j). * - * Complexity: O(M³) constraint clauses + O(V·M·n) channeling. At M=n=8 - * with 3 values: ~512 + ~192 + ~84 + 3 ≈ ~800 clauses instead of ~260k. + * On pinned axis this is the classic positional between form; on non-pinned + * the same structure over rank vars plus channeling. */ -function encodeBetweenAxis( +function encodeBetween( ctx: EncodingContext, alloc: RankVarAllocator, outer1: string, middle: string, outer2: string, axis: Category, - forbidStrictlyBetween: boolean, ): number[][] { const M = axis.values.length; - const pinned = isPinnedAxis(ctx.grid, axis); - const v = pinned - ? (val: string, rank: number) => variable(ctx, val, rank) - : (val: string, rank: number) => alloc.rankVar(ctx, axis, val, rank); const clauses: number[][] = []; for (let i = 0; i < M; i++) { for (let j = 0; j < M; j++) { - for (let k = 0; k < M; k++) { - const lo = Math.min(i, j); - const hi = Math.max(i, j); - const strictlyBetween = i !== j && k > lo && k < hi; - const violates = forbidStrictlyBetween - ? strictlyBetween - : !strictlyBetween; - if (!violates) continue; - clauses.push([-v(outer1, i), -v(outer2, j), -v(middle, k)]); + if (i === j) continue; + const lo = Math.min(i, j); + const hi = Math.max(i, j); + const clause: number[] = [ + -rankOrPos(ctx, alloc, axis, outer1, i), + -rankOrPos(ctx, alloc, axis, outer2, j), + ]; + for (let k = lo + 1; k < hi; k++) { + clause.push(rankOrPos(ctx, alloc, axis, middle, k)); + } + clauses.push(clause); + } + } + return clauses; +} + +/** + * Encode `not_between` as bad-triples: for each middle rank k strictly + * between outer1 rank p1 and outer2 rank p2 (in either order), emit + * [¬middle@k, ¬outer1@p1, ¬outer2@p2]. + */ +function encodeNotBetween( + ctx: EncodingContext, + alloc: RankVarAllocator, + outer1: string, + middle: string, + outer2: string, + axis: Category, +): number[][] { + const M = axis.values.length; + const clauses: number[][] = []; + for (let k = 1; k < M - 1; k++) { + for (let p1 = 0; p1 < k; p1++) { + for (let p2 = k + 1; p2 < M; p2++) { + clauses.push([ + -rankOrPos(ctx, alloc, axis, middle, k), + -rankOrPos(ctx, alloc, axis, outer1, p1), + -rankOrPos(ctx, alloc, axis, outer2, p2), + ]); + clauses.push([ + -rankOrPos(ctx, alloc, axis, middle, k), + -rankOrPos(ctx, alloc, axis, outer1, p2), + -rankOrPos(ctx, alloc, axis, outer2, p1), + ]); } } } @@ -344,52 +373,54 @@ export function encodeConstraint( case "left_of": case "before": { const axis = resolveAxis(ctx.grid, constraint.axis); - const bad = badBinaryRankPairs( - constraint.type, - axis.values.length, - 0, - undefined, - ); + const isValid = binaryPredicate(constraint.type); return encodeBinaryAxis( ctx, alloc, constraint.a, constraint.b, axis, - bad, + isValid, ); } case "exact_distance": { const axis = resolveAxis(ctx.grid, constraint.axis); - const bad = badBinaryRankPairs( - "exact_distance", - axis.values.length, - constraint.distance, - axis.numericValues, - ); + const numVals = axis.numericValues; + const d = constraint.distance; + const isValid = numVals + ? (i: number, j: number) => Math.abs(numVals[i] - numVals[j]) === d + : (i: number, j: number) => Math.abs(i - j) === d; return encodeBinaryAxis( ctx, alloc, constraint.a, constraint.b, axis, - bad, + isValid, ); } case "between": case "not_between": { const axis = resolveAxis(ctx.grid, constraint.axis); - return encodeBetweenAxis( - ctx, - alloc, - constraint.outer1, - constraint.middle, - constraint.outer2, - axis, - constraint.type === "not_between", - ); + return constraint.type === "between" + ? encodeBetween( + ctx, + alloc, + constraint.outer1, + constraint.middle, + constraint.outer2, + axis, + ) + : encodeNotBetween( + ctx, + alloc, + constraint.outer1, + constraint.middle, + constraint.outer2, + axis, + ); } } } diff --git a/packages/logic-grid/src/generator.ts b/packages/logic-grid/src/generator.ts index 1076ed6..bbcb1bf 100644 --- a/packages/logic-grid/src/generator.ts +++ b/packages/logic-grid/src/generator.ts @@ -127,6 +127,9 @@ export function generate(options?: GenerateOptions): Puzzle { }; } + // Defensive failsafe: unreachable for supported grid sizes and difficulties + // — `generate` can always find a matching puzzle within 100 attempts. + /* v8 ignore next 4 */ throw new Error( `Failed to generate a puzzle after ${MAX_RETRIES} attempts. ` + `Try a smaller size or easier difficulty.`, From 89479589b353b564cc1cfe2db2297b7490c90a86 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:06:13 +0200 Subject: [PATCH 18/28] perf: cache multiAxis flag on AxisTerms; stabilize coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - axisSuffix() walked grid.categories on every comparative deduction step to count ordered categories. Compute once in computeAxisTerms and store as multiAxis: boolean. - Fix previously flaky 99.92% coverage on generator.ts line 117 (difficulty-retry branch): the 'expert puzzles require contradiction' test had no seed — Math.random sometimes produced an expert puzzle on the first attempt, skipping the retry branch. Pin seed: 1 so at least one retry always occurs (seed 1's first-attempt natural difficulty is 'hard'). 30/30 runs at 100% coverage. --- packages/logic-grid/src/deduce/constraints.ts | 5 +---- packages/logic-grid/src/deduce/state.ts | 5 +++++ packages/logic-grid/src/generator.test.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index fdae9f0..5679986 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -27,10 +27,7 @@ function isPinnedAxis(state: DeduceState, axis: Category): boolean { /** " on " suffix for multi-axis grids; empty string otherwise. */ function axisSuffix(state: DeduceState, axis: Category): string { - const orderedCount = state.grid.categories.filter( - (c) => c.ordered === true, - ).length; - return orderedCount > 1 ? ` on ${axis.name}` : ""; + return state.terms.multiAxis ? ` on ${axis.name}` : ""; } /** diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index b5ea244..aa92e25 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -13,6 +13,9 @@ export interface AxisTerms { posLabel: (p: number) => string; /** True when `value` is a display-axis value (e.g. "first" on the House axis). */ isAxisValue: (value: string) => boolean; + /** True when the grid has more than one ordered category — used to decide + * whether comparative deductions include the " on " disambiguator. */ + multiAxis: boolean; } /** Compute axis terms for the grid's display axis. */ @@ -20,10 +23,12 @@ function computeAxisTerms(grid: Grid): AxisTerms { // createState throws if no ordered category exists, so axis is always defined here. const axis = grid.categories.find((c) => c.ordered === true)!; const axisValues = new Set(axis.values); + const orderedCount = grid.categories.filter((c) => c.ordered === true).length; return { noun: axis.noun || "position", posLabel: (p) => axis.values[p], isAxisValue: (value) => axisValues.has(value), + multiAxis: orderedCount > 1, }; } diff --git a/packages/logic-grid/src/generator.test.ts b/packages/logic-grid/src/generator.test.ts index 73c9427..80ced24 100644 --- a/packages/logic-grid/src/generator.test.ts +++ b/packages/logic-grid/src/generator.test.ts @@ -287,7 +287,14 @@ describe("generate", () => { }); it("expert puzzles require contradiction", () => { - const puzzle = generate({ size: 4, categories: 4, difficulty: "expert" }); + // Seed chosen so the first generation attempt produces a non-expert + // puzzle, forcing at least one difficulty-retry iteration. + const puzzle = generate({ + size: 4, + categories: 4, + difficulty: "expert", + seed: 1, + }); expect(puzzle.difficulty).toBe("expert"); const result = deduce(puzzle.constraints, puzzle.grid); expect(result.complete).toBe(true); From 00d8687f853eafbc529b6d83b98477b3f1bcf283 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:39:44 +0200 Subject: [PATCH 19/28] test: pin expert-retry premise so seed shifts fail loudly Add an assertion that seed 1's natural difficulty is non-expert. If RNG or difficulty scoring ever changes such that seed 1 produces expert on the first attempt, this test fails immediately rather than silently losing coverage of the difficulty-retry branch. --- packages/logic-grid/src/generator.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/logic-grid/src/generator.test.ts b/packages/logic-grid/src/generator.test.ts index 80ced24..abc3162 100644 --- a/packages/logic-grid/src/generator.test.ts +++ b/packages/logic-grid/src/generator.test.ts @@ -288,7 +288,13 @@ describe("generate", () => { it("expert puzzles require contradiction", () => { // Seed chosen so the first generation attempt produces a non-expert - // puzzle, forcing at least one difficulty-retry iteration. + // puzzle, forcing at least one difficulty-retry iteration. The natural + // assertion below pins that premise — if RNG/scoring shifts and seed 1 + // starts producing expert naturally, this test fails loudly so we can + // pick a new seed instead of silently losing retry-branch coverage. + const natural = generate({ size: 4, categories: 4, seed: 1 }); + expect(natural.difficulty).not.toBe("expert"); + const puzzle = generate({ size: 4, categories: 4, From bc467b51bd2c25107fe622e48fac2959c0d6d76a Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:58:25 +0200 Subject: [PATCH 20/28] refactor: extract pinnedAxis/isPinnedAxis to axis.ts, fix randomSolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review fixes: - Extract pinnedAxis() and isPinnedAxis() to axis.ts so encoding.ts, deduce/constraints.ts, and deduce/state.ts can't drift on their three previously-local copies of grid.categories.find(c.ordered). - Fix randomSolution() to pin the first-ordered axis, matching what encodeBase pins. It previously used displayAxisCategory() which diverges when grid.displayAxis points at a non-first ordered category — the SAT-canonical solution would then be a permutation of randomSolution's solution. Functionally harmless (constraints are relational, uniqueness still holds) but a real divergence with no test coverage. Now they agree. - Add a test that uses displayAxis pointing at the second ordered category and asserts: uniqueness still holds, and the canonical solution pins Year (first ordered) at identity — locks in the invariant that displayAxis is a UI hint, not a SAT concern. Push back on: - SILENT_STEP.technique sentinel: comment is sufficient; adding "silent" to the public DeductionTechnique union pollutes a public type for a private implementation detail. - Unguarded channeling clauses: deliberate — channeling defines rank var semantics globally, not per-constraint. --- packages/logic-grid/src/axis.ts | 15 +++++ packages/logic-grid/src/deduce/constraints.ts | 11 +--- packages/logic-grid/src/deduce/state.ts | 19 ++++--- packages/logic-grid/src/encoding.ts | 19 +++---- packages/logic-grid/src/generator.test.ts | 57 +++++++++++++++++++ packages/logic-grid/src/generator.ts | 10 ++-- 6 files changed, 98 insertions(+), 33 deletions(-) diff --git a/packages/logic-grid/src/axis.ts b/packages/logic-grid/src/axis.ts index 56d1bb6..e6e086b 100644 --- a/packages/logic-grid/src/axis.ts +++ b/packages/logic-grid/src/axis.ts @@ -50,6 +50,21 @@ 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. diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 5679986..452e8ff 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -4,7 +4,7 @@ import type { DeductionStep, DeductionTechnique, } from "../types"; -import { resolveAxis } from "../axis"; +import { isPinnedAxis, resolveAxis } from "../axis"; import { type DeduceState, SILENT_STEP, @@ -20,11 +20,6 @@ import { projectRanksToPositions, } from "./state"; -/** True when `axis` is the pinned display axis (rank = position). */ -function isPinnedAxis(state: DeduceState, axis: Category): boolean { - return state.grid.categories.find((c) => c.ordered === true) === axis; -} - /** " on " suffix for multi-axis grids; empty string otherwise. */ function axisSuffix(state: DeduceState, axis: Category): string { return state.terms.multiAxis ? ` on ${axis.name}` : ""; @@ -46,7 +41,7 @@ function tryBinaryAxis( isValid: (rankA: number, rankB: number) => boolean, description: string, ): DeductionStep | null { - const pinned = isPinnedAxis(state, axis); + const pinned = isPinnedAxis(state.grid, axis); const pa = pinned ? getPossible(state, a) : axisRankDomain(state, a, axis); const pb = pinned ? getPossible(state, b) : axisRankDomain(state, b, axis); if (pa.size === 0 || pb.size === 0) return null; @@ -351,7 +346,7 @@ function tryBetweenAxis( const technique: DeductionTechnique = isNotBetween ? "not_between" : "between"; - const pinned = isPinnedAxis(state, axis); + const pinned = isPinnedAxis(state.grid, axis); const rO1 = pinned ? getPossible(state, c.outer1) : axisRankDomain(state, c.outer1, axis); diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index aa92e25..83bb8c3 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -4,6 +4,7 @@ import type { DeductionStep, DeductionTechnique, } from "../types"; +import { pinnedAxis } from "../axis"; // --- Display utilities --- @@ -18,10 +19,10 @@ export interface AxisTerms { multiAxis: boolean; } -/** Compute axis terms for the grid's display axis. */ +/** Compute axis terms for the grid's pinned axis (the row anchor). */ function computeAxisTerms(grid: Grid): AxisTerms { // createState throws if no ordered category exists, so axis is always defined here. - const axis = grid.categories.find((c) => c.ordered === true)!; + const axis = pinnedAxis(grid)!; const axisValues = new Set(axis.values); const orderedCount = grid.categories.filter((c) => c.ordered === true).length; return { @@ -116,14 +117,14 @@ export function createState(grid: Grid): DeduceState { valueInfo.set(grid.categories[ci].values[vi], [ci, vi]); } } - // Pin the display axis to match encodeBase and randomSolution: value[k] - // is fixed at position k to break the n!-fold position symmetry. - const firstOrderedIdx = grid.categories.findIndex((c) => c.ordered === true); - if (firstOrderedIdx < 0) throw new Error("Grid has no ordered category"); - const pinCat = grid.categories[firstOrderedIdx]; + // Pin the first ordered axis to match encodeBase and randomSolution: + // value[k] is fixed at position k to break the n!-fold position symmetry. + const pinCat = pinnedAxis(grid); + if (!pinCat) throw new Error("Grid has no ordered category"); + const pinCatIdx = grid.categories.indexOf(pinCat); for (let vi = 0; vi < pinCat.values.length; vi++) { - possible[firstOrderedIdx][vi].clear(); - possible[firstOrderedIdx][vi].add(vi); + possible[pinCatIdx][vi].clear(); + possible[pinCatIdx][vi].add(vi); } return { grid, diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index b244be7..08a1baf 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -1,10 +1,5 @@ import type { Category, Constraint, Grid } from "./types"; -import { resolveAxis } from "./axis"; - -/** True when `axis` is pinned (rank = position) by encodeBase for symmetry breaking. */ -function isPinnedAxis(grid: Grid, axis: Category): boolean { - return grid.categories.find((c) => c.ordered === true) === axis; -} +import { isPinnedAxis, pinnedAxis, resolveAxis } from "./axis"; /** * Predicate `isValid(rank_a, rank_b)` for a binary comparative. @@ -327,14 +322,14 @@ export function encodeBase(ctx: EncodingContext): number[][] { } } - // Pin the display axis to break the n!-fold position symmetry. Without - // this, every puzzle would have n! equivalent solutions (one per + // Pin the first ordered axis to break the n!-fold position symmetry. + // Without this, every puzzle would have n! equivalent solutions (one per // permutation of abstract position slots). This is the only axis that // gets pinned; all others use the general rank-var encoder. - const dispAxis = grid.categories.find((c) => c.ordered === true); - if (!dispAxis) throw new Error("Grid has no ordered category"); - for (let i = 0; i < dispAxis.values.length; i++) { - clauses.push([variable(ctx, dispAxis.values[i], i)]); + const axis = pinnedAxis(grid); + if (!axis) throw new Error("Grid has no ordered category"); + for (let i = 0; i < axis.values.length; i++) { + clauses.push([variable(ctx, axis.values[i], i)]); } return clauses; diff --git a/packages/logic-grid/src/generator.test.ts b/packages/logic-grid/src/generator.test.ts index abc3162..e55ee68 100644 --- a/packages/logic-grid/src/generator.test.ts +++ b/packages/logic-grid/src/generator.test.ts @@ -309,6 +309,63 @@ describe("generate", () => { ); }); + it("generates a unique-solution puzzle when displayAxis is a non-first ordered category", () => { + // Two ordered categories; pick the second as displayAxis. SAT pinning + // anchors the first ordered axis (encoder convention) regardless of the + // displayAxis hint — verify that constraint generation, encoding, and + // uniqueness checking all stay consistent. + const puzzle = generate({ + size: 3, + seed: 1, + categoryNames: [ + { name: "Name", values: ["Alice", "Bob", "Carol"], noun: "" }, + { + name: "Year", + values: ["2020", "2021", "2022"], + noun: "year", + verb: ["is from", "is not from"], + ordered: true, + orderingPhrases: { + comparators: { + before: ["is older than", "is younger than"], + left_of: ["is one year before", "is one year after"], + next_to: "is one year apart from", + not_next_to: "is not one year apart from", + between: "is between", + not_between: "is not between", + exact_distance: "is exactly", + }, + }, + }, + { + name: "Score", + values: ["10", "20", "30"], + noun: "score", + verb: ["scored", "did not score"], + ordered: true, + orderingPhrases: { + comparators: { + before: ["scored less than", "scored more than"], + left_of: ["scored just below", "scored just above"], + next_to: "scored adjacent to", + not_next_to: "did not score adjacent to", + between: "scored between", + not_between: "did not score between", + exact_distance: "scored exactly", + }, + }, + }, + ], + displayAxis: "Score", + }); + expect(puzzle.solution.length).toBe(3); + expect(hasUniqueSolution(puzzle.constraints, puzzle.grid)).toBe(true); + // The SAT-canonical solution pins Year (first ordered) at identity. + expect(puzzle.solution[1]["2020"]).toBe(0); + expect(puzzle.solution[1]["2021"]).toBe(1); + expect(puzzle.solution[1]["2022"]).toBe(2); + }); + it("throws when custom categoryNames count is out of range", () => { expect(() => generate({ diff --git a/packages/logic-grid/src/generator.ts b/packages/logic-grid/src/generator.ts index bbcb1bf..381abb4 100644 --- a/packages/logic-grid/src/generator.ts +++ b/packages/logic-grid/src/generator.ts @@ -19,6 +19,7 @@ import { displayAxisCategory, isOrdered, orderedCategories, + pinnedAxis, resolveAxis, } from "./axis"; import { DEFAULT_CATEGORIES, defaultHouseCategory } from "./default-config"; @@ -217,11 +218,12 @@ function sliceCategory(c: Category, size: number): Category { } function randomSolution(grid: Grid, rng: () => number): Solution { - // The display axis is pinned (value[i] at position i) to break the - // n!-fold position symmetry. All other categories are shuffled. - const dispAxis = displayAxisCategory(grid); + // Pin the same axis encodeBase pins (the first ordered category) so the + // generated solution matches what the SAT solver will canonicalize to. + // grid.displayAxis is a UI hint and does not affect SAT pinning. + const pinned = pinnedAxis(grid); return grid.categories.map((cat) => { - if (cat === dispAxis) { + if (cat === pinned) { const assignment: Assignment = {}; for (let i = 0; i < cat.values.length; i++) { assignment[cat.values[i]] = i; From b702f7e4c3d99a7fe01e743c65dec7e4e68035c5 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:04:51 +0200 Subject: [PATCH 21/28] docs: cross-reference pinnedAxis from displayAxisCategory Call out that solver/SAT concerns should use pinnedAxis, since the two functions diverge when grid.displayAxis points at a non-first ordered category. --- packages/logic-grid/src/axis.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/logic-grid/src/axis.ts b/packages/logic-grid/src/axis.ts index e6e086b..33e615f 100644 --- a/packages/logic-grid/src/axis.ts +++ b/packages/logic-grid/src/axis.ts @@ -69,6 +69,10 @@ export function isPinnedAxis(grid: Grid, axis: Category): boolean { * 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) { From a63bde0f5df15d5ee92eae30d9a86d3e47272385 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:32:27 +0200 Subject: [PATCH 22/28] refactor: harden RankVarAllocator and validate global value uniqueness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review fixes: (1) RankVarAllocator footgun — seal on first varCeiling read. Throw if rankVar() is called after varCeiling is captured, preventing silent collisions with caller-allocated variables (e.g. activation literals). Cache hits still return existing vars. 9 new unit tests cover channeling direction, AMO, ALO, cache hits, allocation growth, and the seal contract. (3) isAxisValue false-positive risk — validate globally unique values in validateCategories(). The SAT variable mapping in createContext() was already keyed by value name across categories, so duplicates silently overwrote the earlier entry. isAxisValue had the same latent risk. Now explicitly rejected with a clear message at grid construction. (4) Extract topPositionVar(ctx) helper. Previously ctx.numValues * ctx.numPositions + 1 duplicated the layout assumption from variable(). Centralized so future position-var packing changes don't desync. Push back on: (2) SILENT_STEP.technique — already addressed; adding "silent" to the public DeductionTechnique union pollutes a public type for a private implementation detail. Callers only check !== null. (7) exact_distance predicate centralization — "not important" per review. --- packages/logic-grid/src/encoding.test.ts | 142 +++++++++++++++++++++- packages/logic-grid/src/encoding.ts | 32 ++++- packages/logic-grid/src/generator.test.ts | 26 ++++ packages/logic-grid/src/generator.ts | 15 +++ 4 files changed, 209 insertions(+), 6 deletions(-) diff --git a/packages/logic-grid/src/encoding.test.ts b/packages/logic-grid/src/encoding.test.ts index e1aa374..9cf44bd 100644 --- a/packages/logic-grid/src/encoding.test.ts +++ b/packages/logic-grid/src/encoding.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { createContext, variable, encodeBase, encodePuzzle } from "./encoding"; +import { + createContext, + variable, + encodeBase, + encodePuzzle, + RankVarAllocator, + topPositionVar, +} from "./encoding"; import { solveSAT, solveAllSAT } from "./sat"; import { makeGrid, TEST_COMPARATORS } from "./test-helpers"; import type { Constraint, Grid } from "./types"; @@ -547,3 +554,136 @@ describe("encodeConstraint on non-pinned ordered axis", () => { } }); }); + +describe("RankVarAllocator", () => { + // Two-ordered-axis grid so we can exercise a non-pinned axis. + const multiGrid = makeGrid({ + size: 3, + categories: [ + { name: "Name", values: ["Alice", "Bob", "Carol"], noun: "" }, + { + name: "Year", + values: ["2020", "2021", "2022"], + noun: "fund", + verb: ["was begun in", "was not begun in"], + ordered: true, + orderingPhrases: { comparators: TEST_COMPARATORS }, + }, + { + name: "Return", + values: ["5%", "6%", "7%"], + noun: "fund", + verb: ["has a return of", "does not have a return of"], + ordered: true, + orderingPhrases: { comparators: TEST_COMPARATORS }, + }, + ], + }); + const returnAxis = multiGrid.categories.find((c) => c.name === "Return")!; + + it("channels rank var true when position + axis value coexist", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + const rAlice0 = alloc.rankVar(ctx, returnAxis, "Alice", 0); + // Force Alice at position 0 and "5%" (rank 0 on Return) at position 0. + // Forward channeling should force r(Alice, 0) = true. + const facts = [ + [variable(ctx, "Alice", 0)], + [variable(ctx, "5%", 0)], + ]; + const base = encodeBase(ctx); + const result = solveSAT([...base, ...alloc.channeling, ...facts]); + expect(result.satisfiable).toBe(true); + if (result.satisfiable) expect(result.assignment.get(rAlice0)).toBe(true); + }); + + it("AMO: at most one rank var per value", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + const rAlice0 = alloc.rankVar(ctx, returnAxis, "Alice", 0); + const rAlice1 = alloc.rankVar(ctx, returnAxis, "Alice", 1); + // Assert both rank vars true together — base + channeling should reject. + const base = encodeBase(ctx); + const result = solveSAT([ + ...base, + ...alloc.channeling, + [rAlice0], + [rAlice1], + ]); + expect(result.satisfiable).toBe(false); + }); + + it("ALO: at least one rank var per value", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + const rAlice0 = alloc.rankVar(ctx, returnAxis, "Alice", 0); + const rAlice1 = alloc.rankVar(ctx, returnAxis, "Alice", 1); + const rAlice2 = alloc.rankVar(ctx, returnAxis, "Alice", 2); + // Force all three rank vars false — should conflict with ALO. + const base = encodeBase(ctx); + const result = solveSAT([ + ...base, + ...alloc.channeling, + [-rAlice0], + [-rAlice1], + [-rAlice2], + ]); + expect(result.satisfiable).toBe(false); + }); + + it("caches rank vars per (axis, value) — no duplicate channeling", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + alloc.rankVar(ctx, returnAxis, "Alice", 0); + const afterFirst = alloc.channeling.length; + alloc.rankVar(ctx, returnAxis, "Alice", 1); + alloc.rankVar(ctx, returnAxis, "Alice", 2); + expect(alloc.channeling.length).toBe(afterFirst); + }); + + it("allocates fresh channeling for each distinct (axis, value) pair", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + alloc.rankVar(ctx, returnAxis, "Alice", 0); + const afterAlice = alloc.channeling.length; + alloc.rankVar(ctx, returnAxis, "Bob", 0); + expect(alloc.channeling.length).toBeGreaterThan(afterAlice); + }); + + it("varCeiling starts above position variables", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + expect(alloc.varCeiling).toBe(topPositionVar(ctx)); + }); + + it("varCeiling advances after allocation", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + const before = alloc.varCeiling; + // Re-seal doesn't happen if no new vars allocated (sealed flag set but + // cache hits don't throw). Make a fresh allocator to check growth. + const alloc2 = new RankVarAllocator(ctx); + alloc2.rankVar(ctx, returnAxis, "Alice", 0); + // 3 rank vars allocated for one (axis, value) pair (M=3). + expect(alloc2.varCeiling).toBe(before + 3); + }); + + it("throws if rankVar is called after varCeiling is read", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + alloc.rankVar(ctx, returnAxis, "Alice", 0); // OK + void alloc.varCeiling; // seal + expect(() => alloc.rankVar(ctx, returnAxis, "Bob", 0)).toThrow( + "cannot allocate after varCeiling", + ); + }); + + it("sealed allocator still returns cached vars", () => { + const ctx = createContext(multiGrid); + const alloc = new RankVarAllocator(ctx); + const r = alloc.rankVar(ctx, returnAxis, "Alice", 0); + void alloc.varCeiling; // seal + // Cache hit — must not throw, must return same var. + expect(alloc.rankVar(ctx, returnAxis, "Alice", 0)).toBe(r); + }); +}); diff --git a/packages/logic-grid/src/encoding.ts b/packages/logic-grid/src/encoding.ts index 08a1baf..7c89265 100644 --- a/packages/logic-grid/src/encoding.ts +++ b/packages/logic-grid/src/encoding.ts @@ -194,19 +194,23 @@ export class RankVarAllocator { private readonly cache = new Map>(); /** Accumulated channeling clauses (forward + AMO + ALO). */ readonly channeling: number[][] = []; + /** Once sealed, further rankVar calls throw to prevent silent collisions + * with variables (e.g. activation literals) allocated from varCeiling. */ + private sealed = false; constructor(ctx: EncodingContext) { - this.nextVar = ctx.numValues * ctx.numPositions + 1; + this.nextVar = topPositionVar(ctx); } /** * High-water mark: the first variable ID NOT used by this allocator. - * Callers that add their own variables (e.g. activation literals) MUST - * capture this AFTER all rank var allocations are complete. Allocating - * more rank vars after a caller has captured varCeiling will silently - * collide with the caller's variables. + * Reading this seals the allocator — subsequent rankVar calls throw. + * Callers that add their own variables (e.g. activation literals) capture + * this AFTER all rank var allocations are complete; seal-on-read enforces + * the contract at runtime. */ get varCeiling(): number { + this.sealed = true; return this.nextVar; } @@ -214,6 +218,9 @@ export class RankVarAllocator { * Get or create the rank variable for (value, rank) on the given axis. * If this is the first request for this (axis, value) pair, allocates M * rank vars and emits channeling + AMO + ALO clauses. + * + * Throws if called after varCeiling has been read — the caller has already + * frozen the variable range downstream. */ rankVar( ctx: EncodingContext, @@ -228,6 +235,11 @@ export class RankVarAllocator { } let base = axisCache.get(value); if (base === undefined) { + if (this.sealed) { + throw new Error( + "RankVarAllocator: cannot allocate after varCeiling has been read", + ); + } base = this.nextVar; const M = axis.values.length; this.nextVar += M; @@ -279,6 +291,16 @@ export function variable( return vi * ctx.numPositions + position + 1; } +/** + * First SAT variable ID NOT used by position variables — the boundary where + * auxiliary variables (rank vars, activation literals) can start. Keeps the + * layout assumption in one place so position-var packing changes don't + * desync with allocator base. + */ +export function topPositionVar(ctx: EncodingContext): number { + return ctx.numValues * ctx.numPositions + 1; +} + /** Base ALO/AMO clauses ensuring valid assignments. */ export function encodeBase(ctx: EncodingContext): number[][] { const clauses: number[][] = []; diff --git a/packages/logic-grid/src/generator.test.ts b/packages/logic-grid/src/generator.test.ts index e55ee68..4c6703c 100644 --- a/packages/logic-grid/src/generator.test.ts +++ b/packages/logic-grid/src/generator.test.ts @@ -366,6 +366,32 @@ describe("generate", () => { expect(puzzle.solution[1]["2022"]).toBe(2); }); + it("throws when a value appears in two categories", () => { + // The SAT variable mapping is keyed by value name across all categories + // — a collision silently overwrites the earlier entry. validateCategories + // rejects duplicates up front. + expect(() => + generate({ + size: 3, + categoryNames: [ + { name: "Name", values: ["Alice", "Bob", "Carol"], noun: "" }, + { + name: "Color", + values: ["Red", "Blue", "Carol"], + noun: "house", + verb: ["lives in the", "does not live in the"], + }, + { + name: "Pet", + values: ["Cat", "Dog", "Fish"], + noun: "owner", + verb: ["owns the", "does not own the"], + }, + ], + }), + ).toThrow('Duplicate value "Carol" in categories "Name" and "Color"'); + }); + it("throws when custom categoryNames count is out of range", () => { expect(() => generate({ diff --git a/packages/logic-grid/src/generator.ts b/packages/logic-grid/src/generator.ts index 381abb4..dd109ec 100644 --- a/packages/logic-grid/src/generator.ts +++ b/packages/logic-grid/src/generator.ts @@ -32,6 +32,11 @@ const MAX_RETRIES = 100; */ function validateCategories(categories: Category[]): void { const names = new Set(); + // Values must be globally unique — the SAT variable mapping in + // createContext is keyed by value name across all categories, so a + // collision silently overwrites the earlier entry. Also affects + // isAxisValue in deduce/state.ts which matches by name alone. + const valueSource = new Map(); for (const c of categories) { if (names.has(c.name)) { throw new RangeError(`Duplicate category name "${c.name}"`); @@ -44,6 +49,16 @@ function validateCategories(categories: Category[]): void { ); } + for (const v of c.values) { + const existing = valueSource.get(v); + if (existing !== undefined) { + throw new RangeError( + `Duplicate value "${v}" in categories "${existing}" and "${c.name}"`, + ); + } + valueSource.set(v, c.name); + } + if (isOrdered(c)) { if (c.numericValues !== undefined) { if (c.numericValues.length !== c.values.length) { From 6ef904a45d7ebeee936df4b935502d5f0002f765 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:38:50 +0200 Subject: [PATCH 23/28] style: prettier format encoding.test.ts --- packages/logic-grid/src/encoding.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/logic-grid/src/encoding.test.ts b/packages/logic-grid/src/encoding.test.ts index 9cf44bd..a8ca48e 100644 --- a/packages/logic-grid/src/encoding.test.ts +++ b/packages/logic-grid/src/encoding.test.ts @@ -587,10 +587,7 @@ describe("RankVarAllocator", () => { const rAlice0 = alloc.rankVar(ctx, returnAxis, "Alice", 0); // Force Alice at position 0 and "5%" (rank 0 on Return) at position 0. // Forward channeling should force r(Alice, 0) = true. - const facts = [ - [variable(ctx, "Alice", 0)], - [variable(ctx, "5%", 0)], - ]; + const facts = [[variable(ctx, "Alice", 0)], [variable(ctx, "5%", 0)]]; const base = encodeBase(ctx); const result = solveSAT([...base, ...alloc.channeling, ...facts]); expect(result.satisfiable).toBe(true); From bb3bb5325316d59932895ad028586e55f70ef2da Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:54:02 +0200 Subject: [PATCH 24/28] fix: join both operands' context in binary/same_position hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review fixes: (1) Binary ops (next_to, left_of, before, exact_distance) and same_position previously used 'knownA || knownB' for because-context, mentioning at most one operand even when both are known. Now joined with 'and' — matches the richer phrasing tryBetweenAxis already produces. (2) Document that sat.ts processUnitClauses() restructure is a v8 coverage quirk workaround (chained else-if was reported as a single branch). (4) Add defensive fallback in buildNudgeText for unknown DeductionTechnique values. Catches runtime breakage from persisted deduction traces that still reference the removed "direct" / "elimination" techniques. Test added with a cast to simulate a persisted trace. (5) Comment on describeKnown's 'possible.size < state.n' guard explaining why 'can only be anywhere' context gets suppressed. (3) PR description now explicitly calls out that any custom category not in the default set (Language, Instrument, Flower, Nationality, etc.) needs 'lowercase: true' to preserve old rendering. --- packages/demo/src/lib/nudge-text.test.ts | 15 +++++++++++++++ packages/demo/src/lib/nudge-text.ts | 8 +++++++- .../src/deduce/__snapshots__/index.test.ts.snap | 6 +++--- packages/logic-grid/src/deduce/constraints.ts | 15 +++++++++------ packages/logic-grid/src/deduce/state.ts | 4 ++++ packages/logic-grid/src/sat.ts | 9 ++++++++- 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/demo/src/lib/nudge-text.test.ts b/packages/demo/src/lib/nudge-text.test.ts index 6c86897..4b023e1 100644 --- a/packages/demo/src/lib/nudge-text.test.ts +++ b/packages/demo/src/lib/nudge-text.test.ts @@ -133,6 +133,21 @@ 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 = [ "same_position", diff --git a/packages/demo/src/lib/nudge-text.ts b/packages/demo/src/lib/nudge-text.ts index dcebe67..e41b9de 100644 --- a/packages/demo/src/lib/nudge-text.ts +++ b/packages/demo/src/lib/nudge-text.ts @@ -29,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 = "think about what this clue rules out"; + 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. diff --git a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap index 05647f0..d65bb14 100644 --- a/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap +++ b/packages/logic-grid/src/deduce/__snapshots__/index.test.ts.snap @@ -9,9 +9,9 @@ exports[`deduce > snapshots explanation strings 1`] = ` "Clue 5: Dog and Coffee are in the same house. Dog can only be in the first or second house, so Coffee can't be in the third house.", "Clue 6: Tea must be in the first house.", "Red has no other possible house — it must be in the first house. So no other Color can be there.", - "Clue 3: Blue is directly left of Green. Blue is in the second house, so Green must be in the third house.", - "Clue 4: Blue and Dog are in the same house. Blue is in the second house, so both are in the second house.", - "Clue 5: Dog and Coffee are in the same house. Dog is in the second house, so both are in the second house.", + "Clue 3: Blue is directly left of Green. Blue is in the second house and Green can only be in the second or third house, so Green must be in the third house.", + "Clue 4: Blue and Dog are in the same house. Blue is in the second house and Dog can only be in the first or second house, so both are in the second house.", + "Clue 5: Dog and Coffee are in the same house. Dog is in the second house and Coffee can only be in the first or second house, so both are in the second house.", "Cat has no other possible house — it must be in the first house. So no other Pet can be there.", "Dog has no other possible house — it must be in the second house. So no other Pet can be there.", "Tea has no other possible house — it must be in the first house. So no other Drink can be there.", diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 452e8ff..0c5faef 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -85,9 +85,12 @@ function tryBinaryAxis( if (elims.length === 0) return null; // Capture "because" context from pre-elim state — describeKnown after the - // mutation would report this step's own conclusions as the reason. - const ctx = describeKnown(state, a) || describeKnown(state, b); - const because = ctx ? ` ${ctx}, so` : ""; + // mutation would report this step's own conclusions as the reason. When + // both operands have state worth mentioning, join them. + const parts = [describeKnown(state, a), describeKnown(state, b)].filter( + (s) => s !== "", + ); + const because = parts.length > 0 ? ` ${parts.join(" and ")}, so` : ""; for (const e of elims) getPossible(state, e.value).delete(e.position); if (state.silent) return SILENT_STEP; const assigns = collectAssigns(state, elims); @@ -146,9 +149,9 @@ function trySamePosition( if (elims.length === 0) return null; // Capture "because" context from pre-intersection state — after we collapse // pa/pb to their intersection describeKnown would report this step's result. - const knownA = describeKnown(state, c.a); - const knownB = describeKnown(state, c.b); - const ctx = knownA || knownB; + const knownParts = [describeKnown(state, c.a), describeKnown(state, c.b)] + .filter((s) => s !== ""); + const ctx = knownParts.join(" and "); const intersection = new Set([...pa].filter((p) => pb.has(p))); pa.clear(); pb.clear(); diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index 83bb8c3..e13eccb 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -73,6 +73,10 @@ export function describeKnown(state: DeduceState, value: string): string { return `${value} is in the ${posLabel(pos)} ${noun}`; } const possible = getPossible(state, value); + // Only useful when the domain is both small enough to enumerate AND + // genuinely narrowed — on a 3-grid with nothing eliminated, "can only be + // in the first or second or third house" is true but useless and would + // shadow more informative operands in callers like trySamePosition. if (possible.size <= 3 && possible.size < state.n) { const posStr = [...possible].map((p) => posLabel(p)).join(" or "); return `${value} can only be in the ${posStr} ${noun}`; diff --git a/packages/logic-grid/src/sat.ts b/packages/logic-grid/src/sat.ts index ec34b1a..54d5731 100644 --- a/packages/logic-grid/src/sat.ts +++ b/packages/logic-grid/src/sat.ts @@ -176,7 +176,14 @@ class SATBase { return true; } - /** Process unit clauses and propagate. */ + /** + * Process unit clauses and propagate. The UNDEF-first / non-UNDEF-second + * branching order (rather than the inverse `if (UNDEF) else if (mismatch)`) + * is a v8-coverage quirk — chained `else if` was reported as a single + * branch, hiding the mismatch-path when it wasn't exercised. Functionally + * identical; the two tests (`contradictory unit clauses`, `duplicate unit + * clauses`) lock the behavior in. + */ protected processUnitClauses(): boolean { for (let ci = 0; ci < this.numClauses; ci++) { if (this.clauseLen[ci] === 0) return false; From a2fd9f7a301e1b8a52cdcb96f29fdef667f2f0c9 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:56:23 +0200 Subject: [PATCH 25/28] fix: add {target} placeholder to unknown-technique fallback hint --- packages/demo/src/lib/nudge-text.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/lib/nudge-text.ts b/packages/demo/src/lib/nudge-text.ts index e41b9de..fb449d8 100644 --- a/packages/demo/src/lib/nudge-text.ts +++ b/packages/demo/src/lib/nudge-text.ts @@ -32,7 +32,7 @@ function joinValues(values: string[]): string { /** 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 = "think about what this clue rules out"; +const UNKNOWN_TECHNIQUE_HINT = "what can you deduce about {target}?"; export function buildNudgeText(step: DeductionStep): string { const hintTemplate: string = From 81e249c7593e47e8206930651cadcf8b99cf879e Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:58:52 +0200 Subject: [PATCH 26/28] style: prettier format deduce/constraints.ts --- packages/logic-grid/src/deduce/constraints.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 0c5faef..5a61461 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -149,8 +149,10 @@ function trySamePosition( if (elims.length === 0) return null; // Capture "because" context from pre-intersection state — after we collapse // pa/pb to their intersection describeKnown would report this step's result. - const knownParts = [describeKnown(state, c.a), describeKnown(state, c.b)] - .filter((s) => s !== ""); + const knownParts = [ + describeKnown(state, c.a), + describeKnown(state, c.b), + ].filter((s) => s !== ""); const ctx = knownParts.join(" and "); const intersection = new Set([...pa].filter((p) => pb.has(p))); pa.clear(); From ee59ce6412de05b896ebaead4320066644aa3908 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:19:34 +0200 Subject: [PATCH 27/28] style: suppress 'on ' suffix for pinned-axis constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axisSuffix previously returned ' on ' whenever the grid had multiple ordered categories, even when the constraint was on the pinned axis — chatty noise since the pinned axis is the implicit row anchor. Now the suffix only appears for non-pinned-axis constraints where disambiguation actually helps. Addresses PR review issue (6). --- packages/logic-grid/src/deduce/constraints.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 5a61461..95a63ab 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -20,9 +20,17 @@ import { projectRanksToPositions, } from "./state"; -/** " on " suffix for multi-axis grids; empty string otherwise. */ +/** + * " on " suffix for multi-axis grids when the constraint targets + * a non-pinned axis (disambiguation needed). Single-axis grids and pinned- + * axis constraints in multi-axis grids omit the suffix — the pinned axis + * is implicit (it's the row anchor), so appending "on House" to every + * House-axis deduction is just noise. + */ function axisSuffix(state: DeduceState, axis: Category): string { - return state.terms.multiAxis ? ` on ${axis.name}` : ""; + if (!state.terms.multiAxis) return ""; + if (isPinnedAxis(state.grid, axis)) return ""; + return ` on ${axis.name}`; } /** From 45537239599b364802b617852f5f263e78b48872 Mon Sep 17 00:00:00 2001 From: Anton Stefer <59652072+antonstefer@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:37:28 +0200 Subject: [PATCH 28/28] fix: exact_distance noun fallback uses target axis, not pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review fixes: (1) tryExactDistance — when the axis has no unit, fall back to the target axis's noun instead of state.terms.noun (which describes the pinned axis). For a Year-axis exact_distance on a House-pinned grid, we now emit "3 years" instead of "3 houses". Added a regression test in constraints.test.ts that pins Rank values to House positions, so rank-space deduction on the non-pinned Rank axis fires. (2) sat.ts processUnitClauses() — revert to the cleaner `if (UNDEF) { assign } else if (mismatch) { return false }` form. v8 coverage now reports both branches correctly. (3) Comment on isAxisValue in computeAxisTerms linking it to the global-uniqueness invariant enforced by validateCategories. If the invariant weakens, this lookup silently misclassifies cross-category collisions. Push back on: - SILENT_STEP.technique "arbitrary placeholder" comment — already clear; making it `unknown as DeductionStep` would sacrifice type-safety for essentially cosmetic docs. Callers only check `!== null`. --- .../logic-grid/src/deduce/constraints.test.ts | 53 ++++++++++++++++++- packages/logic-grid/src/deduce/constraints.ts | 11 +++- packages/logic-grid/src/deduce/state.ts | 4 ++ packages/logic-grid/src/sat.ts | 15 ++---- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/packages/logic-grid/src/deduce/constraints.test.ts b/packages/logic-grid/src/deduce/constraints.test.ts index fff0d94..ad63c1d 100644 --- a/packages/logic-grid/src/deduce/constraints.test.ts +++ b/packages/logic-grid/src/deduce/constraints.test.ts @@ -4,7 +4,7 @@ import { tryConstraint } from "./constraints"; import { ordinal } from "../grid-utils"; import { createState, getPossible } from "./state"; import { makeGrid, TEST_COMPARATORS } from "../test-helpers"; -import type { Constraint } from "../types"; +import type { Constraint, Grid } from "../types"; const grid = makeGrid({ size: 4, @@ -369,6 +369,57 @@ describe("deduce constraint types", () => { expect(step!.explanation).toContain("2 ranks"); }); + it("exact_distance on non-pinned axis uses that axis's noun, not the pinned axis's", () => { + // Multi-axis grid: House (pinned, noun=house) + Rank (non-pinned, + // noun=rank, no unit). exact_distance on Rank should say "N ranks", + // not "N houses" (which would be the pinned axis's noun). + const multiGrid: Grid = { + size: 4, + categories: [ + { + name: "House", + noun: "house", + verb: ["lives in the", "does not live in the"], + valueSuffix: "house", + ordered: true, + values: ["first", "second", "third", "fourth"], + orderingPhrases: { comparators: TEST_COMPARATORS }, + }, + { name: "Name", values: ["Alice", "Bob", "Carol", "Dave"], noun: "" }, + { + name: "Rank", + values: ["A", "B", "C", "D"], + noun: "rank", + verb: ["is ranked", "is not ranked"], + ordered: true, + orderingPhrases: { comparators: TEST_COMPARATORS }, + }, + ], + }; + // Pin Rank values to specific houses so rank-space deduction has enough + // information to eliminate. Alice at House 1 (Rank 0). Bob must be 2 + // ranks away → Rank 2 → House 3 (by the Rank pinning below). + const constraints: Constraint[] = [ + { type: "same_position", a: "Alice", b: "first" }, + { type: "same_position", a: "A", b: "first" }, + { type: "same_position", a: "B", b: "second" }, + { type: "same_position", a: "C", b: "third" }, + { type: "same_position", a: "D", b: "fourth" }, + { + type: "exact_distance", + a: "Alice", + b: "Bob", + distance: 2, + axis: "Rank", + }, + ]; + const result = deduce(constraints, multiGrid); + const step = result.steps.find((s) => s.technique === "exact_distance"); + expect(step).toBeDefined(); + expect(step!.explanation).toContain("2 ranks"); + expect(step!.explanation).not.toContain("2 houses"); + }); + it("exact_distance explanation uses singular noun when distance=1 and no unit", () => { const noUnitGrid = makeGrid({ size: 4, diff --git a/packages/logic-grid/src/deduce/constraints.ts b/packages/logic-grid/src/deduce/constraints.ts index 95a63ab..a7133d3 100644 --- a/packages/logic-grid/src/deduce/constraints.ts +++ b/packages/logic-grid/src/deduce/constraints.ts @@ -440,9 +440,18 @@ function tryExactDistance( const axis = resolveAxis(state.grid, c.axis); const numVals = axis.numericValues; const unit = axis.orderingPhrases.unit; + // Fall back to the target axis's own noun, not state.terms.noun — + // state.terms describes the pinned (row-anchor) axis, which may differ + // from the constraint's axis in multi-axis grids. For a Year-axis + // exact_distance on a House-pinned grid, we want "3 years" not "3 houses". + // The `|| "position"` branch is defensive — ordered categories practically + // always declare a noun; `validateCategories` requires `verb` but not + // `noun`, so this guards the empty-noun edge case. + /* v8 ignore next */ + const axisNoun = axis.noun || "position"; const distLabel = unit ? `${c.distance} ${c.distance === 1 ? unit[0] : unit[1]}` - : `${c.distance} ${c.distance === 1 ? state.terms.noun : state.terms.noun + "s"}`; + : `${c.distance} ${c.distance === 1 ? axisNoun : axisNoun + "s"}`; return tryBinaryAxis( state, c.a, diff --git a/packages/logic-grid/src/deduce/state.ts b/packages/logic-grid/src/deduce/state.ts index e13eccb..613fda8 100644 --- a/packages/logic-grid/src/deduce/state.ts +++ b/packages/logic-grid/src/deduce/state.ts @@ -23,6 +23,10 @@ export interface AxisTerms { function computeAxisTerms(grid: Grid): AxisTerms { // createState throws if no ordered category exists, so axis is always defined here. const axis = pinnedAxis(grid)!; + // isAxisValue below matches by value name alone — correct because + // validateCategories enforces globally unique value names across all + // categories. If that invariant weakens, this lookup silently + // misclassifies cross-category collisions. const axisValues = new Set(axis.values); const orderedCount = grid.categories.filter((c) => c.ordered === true).length; return { diff --git a/packages/logic-grid/src/sat.ts b/packages/logic-grid/src/sat.ts index 54d5731..97c68aa 100644 --- a/packages/logic-grid/src/sat.ts +++ b/packages/logic-grid/src/sat.ts @@ -176,14 +176,7 @@ class SATBase { return true; } - /** - * Process unit clauses and propagate. The UNDEF-first / non-UNDEF-second - * branching order (rather than the inverse `if (UNDEF) else if (mismatch)`) - * is a v8-coverage quirk — chained `else if` was reported as a single - * branch, hiding the mismatch-path when it wasn't exercised. Functionally - * identical; the two tests (`contradictory unit clauses`, `duplicate unit - * clauses`) lock the behavior in. - */ + /** Process unit clauses and propagate. */ protected processUnitClauses(): boolean { for (let ci = 0; ci < this.numClauses; ci++) { if (this.clauseLen[ci] === 0) return false; @@ -191,10 +184,10 @@ class SATBase { const lit = this.litBuf[this.clauseOff[ci]]; const v = lit > 0 ? lit : -lit; const val = lit > 0 ? TRUE : FALSE; - if (this.values[v] !== UNDEF) { - if (this.values[v] !== val) return false; - } else { + if (this.values[v] === UNDEF) { this.assign(v, val); + } else if (this.values[v] !== val) { + return false; } } }