logic-puzzle-generator 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,7 @@ The main class. Use `new Generator(seed)` to initialize.
197
197
  - `includeSubjects`: `string[]` (New in v1.1.X). Restrict to clues involving these values.
198
198
  - `excludeSubjects`: `string[]` (New in v1.1.X). Exclude clues involving these values.
199
199
  - `minDeductions`: `number` (New in v1.1.X). Min new info required (default 1). Set to 0 for filler.
200
+ - `maxDeductions`: `number`. Max new info allowed. Set to 0 to force redundant clues.
200
201
  - `options.onTrace`: **(Debug)** Callback `(msg: string) => void`. Receives real-time logs about the generation process.
201
202
  - `generatePuzzleAsync(...)`: **New in v1.1.0**. Non-blocking version of `generatePuzzle`. Returns `Promise<Puzzle>`.
202
203
  - `getClueCountBounds(categories, target)`: Returns plausible Min/Max clue counts.
@@ -236,7 +237,7 @@ The logical engine responsible for applying clues and performing deductions.
236
237
  Manages a stateful, step-by-step puzzle generation process.
237
238
  - `getNextClue(constraints?)`: Returns `{ clue: Clue | null, remaining: number, solved: boolean }`.
238
239
  - Generates and selects the next best clue based on the current grid state.
239
- -### `session.getNextClue(options?: ClueGenerationConstraints)`
240
+ ### `session.getNextClue(options?: ClueGenerationConstraints)`
240
241
 
241
242
  Generates the next step in the puzzle, returning the best available clue based on internal scoring and provided constraints.
242
243
 
@@ -259,11 +260,30 @@ Each result object contains:
259
260
 
260
261
  ### `session.useClue(clue: Clue)`
261
262
 
262
- Manually applies a specific clue to the board. Useful for interactive search features.
263
+ Manually applies a specific clue to the board.
264
+ **Safety**: This method validates the clue against the hidden solution. If the clue implies a contradiction (i.e. is logically false), it throws an `Error`.
263
265
 
264
- ### `session.rollbackLastClue()`: Returns `{ success: boolean, clue: Clue | null }`. Undoes the last step.
265
- - `getNextClueAsync(constraints?)`: **New in v1.1.1**. Non-blocking version. Returns `Promise<{ clue, remaining, solved }>`.
266
- - `rollbackLastClue()`: Returns `{ success: boolean, clue: Clue | null }`. Undoes the last step.
266
+ ### `session.rollbackLastClue()`
267
+ Returns `{ success: boolean, clue: Clue | null }`. Undoes the last step.
268
+
269
+ ### `session.removeClue(index: number)`
270
+ **New in v1.2.0**. Removes a clue from the current proof chain at the specified index.
271
+ - Automatically recalculates all deductions for subsequent clues.
272
+ - Updates the "Target Solved" status if the removal breaks the logical path to the goal.
273
+
274
+ ### `session.moveClue(fromIndex: number, toIndex: number)`
275
+ **New in v1.2.0**. Reorders clues in the proof chain.
276
+ - Returns `true` if the move was valid and successful.
277
+ - Recalculates the entire puzzle state based on the new order.
278
+ - This allows for "What If?" scenarios: "What if I knew this fact earlier?"
279
+
280
+ ### `session.getTargetSolvedStepIndex()`
281
+ **New in v1.2.0**. Returns the index of the clue that first makes the Target Fact deducible.
282
+ - Returns `-1` if the target is not yet solved.
283
+ - This is dynamic: removing or reordering clues will change this index, allowing you to find the exact "Eureka!" moment in the chain.
284
+
285
+ ### `session.getNextClueAsync(constraints?)`
286
+ **New in v1.1.1**. Non-blocking version. Returns `Promise<{ clue, remaining, solved }>`.
267
287
  - `getGrid()`: Returns the current `LogicGrid` state.
268
288
  - `getSolution()`: Returns the target `Solution` map.
269
289
  - `getProofChain()`: Returns the list of `Clue`s applied so far.
@@ -310,7 +330,8 @@ while (!solved) {
310
330
  allowedClueTypes: [ClueType.BINARY, ClueType.ORDINAL],
311
331
  includeSubjects: ['Mustard', 'Plum'], // Only clues about these entities
312
332
  excludeSubjects: ['Revolver'], // No clues dealing with Revolvers
313
- minDeductions: 0 // Allow "useless" clues (flavor text)
333
+ minDeductions: 0, // Allow "useless" clues (flavor text)
334
+ maxDeductions: 0 // STRICTLY "useless" clues (0 deductions)
314
335
  });
315
336
  });
316
337
 
@@ -72,4 +72,16 @@ export interface CrossOrdinalClue {
72
72
  /** The offset from the anchor (-1 = before, +1 = after) */
73
73
  offset2: number;
74
74
  }
75
+ /**
76
+ * Union type representing any valid clue.
77
+ */
75
78
  export type Clue = BinaryClue | OrdinalClue | SuperlativeClue | UnaryClue | CrossOrdinalClue;
79
+ /**
80
+ * Extension for clues with metadata attached during generation/solving.
81
+ */
82
+ export type ClueWithMetadata = Clue & {
83
+ deductions?: number;
84
+ updates?: number;
85
+ reasons?: import('../types').DeductionReason[];
86
+ percentComplete?: number;
87
+ };
@@ -13,16 +13,20 @@ export declare class GenerativeSession {
13
13
  private availableClues;
14
14
  private proofChain;
15
15
  private solver;
16
+ private targetSolvedStepIndex;
16
17
  private historyStack;
17
18
  constructor(generator: Generator, categories: CategoryConfig[], solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>, valueMap: Map<ValueLabel, Record<string, ValueLabel>>, targetFact: TargetFact);
18
19
  getTotalClueCount(): number;
20
+ getTargetSolvedStepIndex(): number;
19
21
  getMatchingClueCount(constraints?: ClueGenerationConstraints): number;
20
22
  getMatchingClues(constraints?: ClueGenerationConstraints, limit?: number): Clue[];
21
23
  getScoredMatchingClues(constraints?: ClueGenerationConstraints, limit?: number): {
22
24
  clue: Clue;
23
25
  score: number;
24
26
  deductions: number;
27
+ updates: number;
25
28
  isDirectAnswer: boolean;
29
+ percentComplete: number;
26
30
  }[];
27
31
  useClue(clue: Clue): {
28
32
  remaining: number;
@@ -30,6 +34,8 @@ export declare class GenerativeSession {
30
34
  };
31
35
  private filterClues;
32
36
  private applyAndSave;
37
+ private checkTargetSolvedInternal;
38
+ private checkTargetSolved;
33
39
  getNextClue(constraints?: ClueGenerationConstraints): {
34
40
  clue: Clue | null;
35
41
  remaining: number;
@@ -49,6 +55,18 @@ export declare class GenerativeSession {
49
55
  clue: Clue | null;
50
56
  };
51
57
  private isUseful;
58
+ /**
59
+ * Removes a clue from the proof chain at the specified index.
60
+ * Replays all subsequent clues to ensure state consistency.
61
+ * @param index Index of the clue in the proofChain (0-based)
62
+ */
63
+ removeClueAt(index: number): boolean;
64
+ /**
65
+ * Moves a clue from one index to another in the proof chain.
66
+ * Replays all clues to update metadata and target detection.
67
+ */
68
+ moveClue(fromIndex: number, toIndex: number): boolean;
69
+ private replayProofChain;
52
70
  getGrid(): LogicGrid;
53
71
  getProofChain(): Clue[];
54
72
  getSolution(): Solution;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GenerativeSession = void 0;
4
4
  const types_1 = require("../types");
5
+ const errors_1 = require("../errors");
5
6
  const LogicGrid_1 = require("./LogicGrid");
6
7
  const Solver_1 = require("./Solver");
7
8
  class GenerativeSession {
@@ -14,9 +15,11 @@ class GenerativeSession {
14
15
  this.targetFact = targetFact;
15
16
  this.availableClues = [];
16
17
  this.proofChain = [];
18
+ this.targetSolvedStepIndex = -1; // -1 means logic not solved by clues yet
17
19
  this.historyStack = [];
18
20
  this.grid = new LogicGrid_1.LogicGrid(categories);
19
21
  this.solver = new Solver_1.Solver();
22
+ this.checkTargetSolved(); // Check initial state (unlikely solved unless trivial)
20
23
  // Initial Generation of ALL possibilities (we filter later)
21
24
  // We need access to the generator's clue generation logic.
22
25
  // We will call a public method on generator.
@@ -25,6 +28,9 @@ class GenerativeSession {
25
28
  getTotalClueCount() {
26
29
  return this.availableClues.length;
27
30
  }
31
+ getTargetSolvedStepIndex() {
32
+ return this.targetSolvedStepIndex;
33
+ }
28
34
  getMatchingClueCount(constraints) {
29
35
  return this.filterClues(constraints).length;
30
36
  }
@@ -33,22 +39,23 @@ class GenerativeSession {
33
39
  return clues.slice(0, limit);
34
40
  }
35
41
  getScoredMatchingClues(constraints, limit = 50) {
36
- const clues = this.filterClues(constraints);
37
- // Score all (or up to a reasonable hard limit to avoid perf issues, say 200)
38
- // Then sort and take top 'limit'
39
- const candidateLimit = 200;
40
- const candidates = clues.slice(0, candidateLimit);
41
- const scored = candidates.map(clue => {
42
- // Score Logic - similar to getNextClue but without the constraints checks (already done)
42
+ const validClues = this.filterClues(constraints);
43
+ const results = [];
44
+ const minDeductions = constraints?.minDeductions ?? 0;
45
+ const maxDeductions = constraints?.maxDeductions;
46
+ if (maxDeductions !== undefined && minDeductions > maxDeductions) {
47
+ throw new errors_1.ConfigurationError(`Invalid constraints: minDeductions (${minDeductions}) cannot be greater than maxDeductions (${maxDeductions}).`);
48
+ }
49
+ for (const clue of validClues) {
43
50
  const tempGrid = this.grid.clone();
44
51
  const { deductions } = this.solver.applyClue(tempGrid, clue);
45
- // Calculate real score
52
+ if (deductions < minDeductions)
53
+ continue;
54
+ if (maxDeductions !== undefined && deductions > maxDeductions)
55
+ continue;
46
56
  const score = this.generator.calculateClueScore(this.grid, this.targetFact, deductions, clue, this.proofChain, this.solution, this.reverseSolution);
47
- // Check if this is the "Direct Answer" (Positive link for the target fact)
48
57
  let isDirectAnswer = false;
49
58
  if (clue.type === types_1.ClueType.BINARY) {
50
- // Check if it links the target subject to the target category
51
- // Positive operators: IS (0). Assuming enum values.
52
59
  const isPositive = clue.operator === types_1.BinaryOperator.IS;
53
60
  if (isPositive) {
54
61
  const c = clue;
@@ -59,17 +66,28 @@ class GenerativeSession {
59
66
  }
60
67
  }
61
68
  }
62
- return { clue, score, deductions, isDirectAnswer };
63
- });
64
- // Sort by score descending
65
- scored.sort((a, b) => b.score - a.score);
66
- // Filter by minDeductions if provided
67
- // Defaults to 0 if undefined (allow all)
68
- const minDeductions = constraints?.minDeductions ?? 0;
69
- const finalResults = scored.filter(s => s.deductions >= minDeductions);
70
- return finalResults.slice(0, limit);
69
+ // Calculate Projected % Complete
70
+ const stats = tempGrid.getGridStats();
71
+ const range = stats.totalPossible - stats.solutionPossible;
72
+ const progress = stats.totalPossible - stats.currentPossible;
73
+ let percentComplete = 0;
74
+ if (range > 0) {
75
+ percentComplete = Math.min(100, Math.max(0, (progress / range) * 100));
76
+ }
77
+ else {
78
+ percentComplete = 100;
79
+ }
80
+ const updates = tempGrid.compareVisualState(this.grid);
81
+ results.push({ clue, score, deductions, updates, isDirectAnswer, percentComplete });
82
+ }
83
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
71
84
  }
72
85
  useClue(clue) {
86
+ // Validate against solution (Anti-Cheat / Logic Guard)
87
+ const isConsistent = this.generator.checkClueConsistency(clue, this.solution, this.reverseSolution, this.valueMap);
88
+ if (!isConsistent) {
89
+ throw new Error("Invalid Clue: This clue contradicts the puzzle solution.");
90
+ }
73
91
  // Ensure the clue is removed from available if it was there
74
92
  // (If searching, the UI passes a Clue object that should exist in our availableClues)
75
93
  this.applyAndSave(clue);
@@ -108,13 +126,72 @@ class GenerativeSession {
108
126
  // Save state
109
127
  this.historyStack.push(this.grid.clone());
110
128
  // Apply it
111
- this.solver.applyClue(this.grid, clue);
129
+ const result = this.solver.applyClue(this.grid, clue);
130
+ clue.deductions = result.deductions;
131
+ clue.reasons = result.reasons;
132
+ // Calculate % Complete
133
+ // Grid starts with 'totalPossible' and ends with 'solutionPossible'.
134
+ // % = (Total - Current) / (Total - Solution)
135
+ const stats = this.grid.getGridStats();
136
+ // Avoid division by zero if puzzle is tiny or trivial
137
+ const range = stats.totalPossible - stats.solutionPossible;
138
+ const progress = stats.totalPossible - stats.currentPossible;
139
+ if (range > 0) {
140
+ clue.percentComplete = Math.min(100, Math.max(0, (progress / range) * 100));
141
+ }
142
+ else {
143
+ clue.percentComplete = 100;
144
+ }
145
+ // Calculate Visual Updates (Red Crosses + Green Checks)
146
+ // We compare the grid before (this.historyStack.last?) application
147
+ // Wait, 'this.grid' is ALREADY modified here.
148
+ // We saved the previous state in historyStack.
149
+ const prevGrid = this.historyStack[this.historyStack.length - 1];
150
+ if (prevGrid) {
151
+ clue.updates = this.grid.compareVisualState(prevGrid);
152
+ }
153
+ else {
154
+ // Should not happen unless history empty? Initial state?
155
+ clue.updates = clue.deductions; // Fallback
156
+ }
112
157
  this.proofChain.push(clue);
158
+ // Check if target is solved now (if it wasn't before)
159
+ if (this.targetSolvedStepIndex === -1) {
160
+ const isSolved = this.checkTargetSolvedInternal();
161
+ if (isSolved) {
162
+ this.targetSolvedStepIndex = this.proofChain.length - 1;
163
+ }
164
+ }
113
165
  // Remove from available
114
166
  const idx = this.availableClues.indexOf(clue);
115
167
  if (idx > -1)
116
168
  this.availableClues.splice(idx, 1);
117
169
  }
170
+ checkTargetSolvedInternal() {
171
+ // Check if the specific target fact is confirmed in the grid
172
+ // Fact: (Cat1, Val1) is linked to (Cat2) -> ?
173
+ // We need to check if grid has determined the link.
174
+ // targetFact has Cat1, Val1, Cat2.
175
+ // Ideally we check if Possibilities(Cat1, Val1, Cat2) === 1.
176
+ // Wait, TargetFact structure:
177
+ // { category1Id, value1, category2Id, value2? }
178
+ // If value2 is present (specific pairing target), we check isFactConfirmed.
179
+ // If value2 is NOT present (general "find the husband" target), we check if ANY link is confirmed?
180
+ // Actually, usually the target is "Find the Value in Cat2 for (Cat1, Val1)".
181
+ // So we check if the Number of Possibilities for (Cat1, Val1) in Cat2 is exactly 1.
182
+ return this.grid.getPossibilitiesCount(this.targetFact.category1Id, this.targetFact.value1, this.targetFact.category2Id) === 1;
183
+ }
184
+ checkTargetSolved() {
185
+ if (this.checkTargetSolvedInternal()) {
186
+ // If solved at start (step -1?) logic handles index relative to proof chain.
187
+ // If solved initially, maybe index is -1?
188
+ // But if it's -1, it means "Before any clues".
189
+ // Let's stick to -1 being "Not Found Yet" vs "Found at Initial State"?
190
+ // If found at start, we might want to know.
191
+ // But normally target is unknown.
192
+ // Let's assume -1 is "Not Solved".
193
+ }
194
+ }
118
195
  getNextClue(constraints) {
119
196
  const validClues = this.filterClues(constraints);
120
197
  // Score them
@@ -126,6 +203,10 @@ class GenerativeSession {
126
203
  const searchLimit = 50;
127
204
  let checked = 0;
128
205
  const minDeductions = constraints?.minDeductions ?? 0;
206
+ const maxDeductions = constraints?.maxDeductions;
207
+ if (maxDeductions !== undefined && minDeductions > maxDeductions) {
208
+ throw new errors_1.ConfigurationError(`Invalid constraints: minDeductions (${minDeductions}) cannot be greater than maxDeductions (${maxDeductions}).`);
209
+ }
129
210
  for (const clue of candidates) {
130
211
  checked++;
131
212
  if (checked > searchLimit && bestClue)
@@ -138,12 +219,31 @@ class GenerativeSession {
138
219
  if (deductions < minDeductions) {
139
220
  continue;
140
221
  }
222
+ if (maxDeductions !== undefined && deductions > maxDeductions) {
223
+ continue;
224
+ }
141
225
  // Re-calc score with deductions
142
226
  const realScore = this.generator.calculateClueScore(this.grid, this.targetFact, deductions, clue, this.proofChain, this.solution, this.reverseSolution);
227
+ // Calculate Projected % Complete
228
+ const stats = tempGrid.getGridStats();
229
+ const range = stats.totalPossible - stats.solutionPossible;
230
+ const progress = stats.totalPossible - stats.currentPossible;
231
+ let percentComplete = 0;
232
+ if (range > 0) {
233
+ percentComplete = Math.min(100, Math.max(0, (progress / range) * 100));
234
+ }
235
+ else {
236
+ percentComplete = 100;
237
+ }
143
238
  if (realScore > bestScore) {
144
239
  bestScore = realScore;
145
240
  bestClue = clue;
146
241
  }
242
+ // Note: getScoredMatchingClues will need to collect this.
243
+ // But this is getNextClue.
244
+ // Wait, I need to update getScoredMatchingClues, not getNextClue loop?
245
+ // Yes, user said "search list".
246
+ // Let's modify getScoredMatchingClues below, but first let me fix the tool call target.
147
247
  }
148
248
  if (bestClue) {
149
249
  this.applyAndSave(bestClue);
@@ -189,6 +289,69 @@ class GenerativeSession {
189
289
  // If deductions > 0, it's useful.
190
290
  return true;
191
291
  }
292
+ /**
293
+ * Removes a clue from the proof chain at the specified index.
294
+ * Replays all subsequent clues to ensure state consistency.
295
+ * @param index Index of the clue in the proofChain (0-based)
296
+ */
297
+ removeClueAt(index) {
298
+ if (index < 0 || index >= this.proofChain.length)
299
+ return false;
300
+ const removedClue = this.proofChain[index];
301
+ // 1. Remove from proofChain
302
+ this.proofChain.splice(index, 1);
303
+ // 2. Return to availableClues
304
+ this.availableClues.push(removedClue);
305
+ // 3. Replay Logic
306
+ return this.replayProofChain();
307
+ }
308
+ /**
309
+ * Moves a clue from one index to another in the proof chain.
310
+ * Replays all clues to update metadata and target detection.
311
+ */
312
+ moveClue(fromIndex, toIndex) {
313
+ if (fromIndex < 0 || fromIndex >= this.proofChain.length)
314
+ return false;
315
+ if (toIndex < 0 || toIndex >= this.proofChain.length)
316
+ return false;
317
+ if (fromIndex === toIndex)
318
+ return true; // No op
319
+ const clue = this.proofChain[fromIndex];
320
+ // Remove
321
+ this.proofChain.splice(fromIndex, 1);
322
+ // Insert
323
+ this.proofChain.splice(toIndex, 0, clue);
324
+ return this.replayProofChain();
325
+ }
326
+ replayProofChain() {
327
+ // Reset grid to initial state
328
+ this.grid = new LogicGrid_1.LogicGrid(this.categories);
329
+ this.historyStack = []; // Reset history
330
+ this.solver = new Solver_1.Solver(); // Fresh solver (stateless anyway)
331
+ this.targetSolvedStepIndex = -1; // Reset target detection
332
+ // Check if solved initially (unlikely)
333
+ if (this.checkTargetSolvedInternal()) {
334
+ // If solved with 0 clues, maybe set to -1 (start)?
335
+ // Or a special value? Let's leave it -1 and handle it?
336
+ // Actually if it's solved at start, then stepIndex = -1 makes sense if we consider 0-based index of clues.
337
+ // But usually we mark "Revealed AFTER step X".
338
+ }
339
+ // Put ALL proofChain clues back into available first (so applyAndSave finds them)
340
+ // We must push them all back because applyAndSave will splice them out.
341
+ // Note: availableClues might ideally be a Set or map for speed, but array is fine for now.
342
+ // We should add them ONLY if they are not already there?
343
+ // Actually, proofChain clues are NOT in availableClues.
344
+ this.availableClues.push(...this.proofChain);
345
+ // Capture chain
346
+ const chainToReplay = [...this.proofChain];
347
+ // Clear proofChain
348
+ this.proofChain = [];
349
+ // Re-apply
350
+ for (const c of chainToReplay) {
351
+ this.applyAndSave(c);
352
+ }
353
+ return true;
354
+ }
192
355
  // Getters for UI
193
356
  getGrid() { return this.grid; }
194
357
  getProofChain() { return this.proofChain; }
@@ -140,5 +140,11 @@ export declare class Generator {
140
140
  * Higher scores represent better clues according to the current strategy.
141
141
  */
142
142
  calculateClueScore(grid: LogicGrid, target: TargetFact, deductions: number, clue: Clue, previouslySelectedClues: Clue[], solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>): number;
143
+ /**
144
+ * Checks if a clue is logically consistent with the provided solution.
145
+ * Returns true if the clue is TRUE in the context of the solution.
146
+ * Returns false if the clue is FALSE (contradicts the solution).
147
+ */
148
+ checkClueConsistency(clue: Clue, solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>, valueMap: Map<ValueLabel, Record<string, ValueLabel>>): boolean;
143
149
  isPuzzleSolved(grid: LogicGrid, solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>): boolean;
144
150
  }
@@ -440,6 +440,28 @@ class Generator {
440
440
  if (this.isPuzzleSolved(logicGrid, this.solution, this.reverseSolution))
441
441
  break;
442
442
  }
443
+ // ---------------------------------------------------------
444
+ // METADATA REPLAY PASS
445
+ // ---------------------------------------------------------
446
+ // Re-apply clues to a fresh grid to calculate accurate metadata (deductions, % complete)
447
+ // for the final solution view.
448
+ const replayGrid = new LogicGrid_1.LogicGrid(categories);
449
+ const replayStats = replayGrid.getGridStats();
450
+ const totalRange = replayStats.totalPossible - replayStats.solutionPossible;
451
+ for (const step of proofChain) {
452
+ const result = this.solver.applyClue(replayGrid, step.clue);
453
+ step.clue.deductions = result.deductions;
454
+ const currentStats = replayGrid.getGridStats();
455
+ const progress = currentStats.totalPossible - currentStats.currentPossible;
456
+ if (totalRange > 0) {
457
+ step.clue.percentComplete = Math.min(100, Math.max(0, (progress / totalRange) * 100));
458
+ }
459
+ else {
460
+ step.clue.percentComplete = 100;
461
+ }
462
+ // Capture Deduction Reasons (XAI)
463
+ step.clue.reasons = result.reasons;
464
+ }
443
465
  return {
444
466
  solution: this.solution,
445
467
  clues: proofChain.map(p => p.clue),
@@ -1096,6 +1118,125 @@ class Generator {
1096
1118
  const score = ((synergyScore * complexityBonus) + (completenessScore * 5)) * repetitionPenalty;
1097
1119
  return score;
1098
1120
  }
1121
+ /**
1122
+ * Checks if a clue is logically consistent with the provided solution.
1123
+ * Returns true if the clue is TRUE in the context of the solution.
1124
+ * Returns false if the clue is FALSE (contradicts the solution).
1125
+ */
1126
+ checkClueConsistency(clue, solution, reverseSolution, valueMap) {
1127
+ // Helper to get real value
1128
+ const getRealValue = (catId, val, targetCatId) => {
1129
+ const baseVal = reverseSolution.get(catId)?.get(val);
1130
+ if (!baseVal)
1131
+ return undefined;
1132
+ return solution[targetCatId][baseVal];
1133
+ };
1134
+ const getBaseValue = (catId, val) => {
1135
+ return reverseSolution.get(catId)?.get(val);
1136
+ };
1137
+ // Helper to get Ordinal Numeric Value
1138
+ const getOrdinalValue = (catId, val, ordinalCatId) => {
1139
+ const baseVal = getBaseValue(catId, val);
1140
+ if (!baseVal)
1141
+ return undefined;
1142
+ const mappings = valueMap.get(baseVal);
1143
+ if (!mappings)
1144
+ return undefined;
1145
+ const ordVal = mappings[ordinalCatId];
1146
+ return typeof ordVal === 'number' ? ordVal : undefined;
1147
+ };
1148
+ switch (clue.type) {
1149
+ case types_1.ClueType.BINARY: {
1150
+ const b = clue;
1151
+ const realVal2 = getRealValue(b.cat1, b.val1, b.cat2);
1152
+ if (realVal2 === undefined)
1153
+ return false; // Should not happen in consistent state
1154
+ if (b.operator === types_1.BinaryOperator.IS) {
1155
+ return realVal2 === b.val2;
1156
+ }
1157
+ else if (b.operator === types_1.BinaryOperator.IS_NOT) {
1158
+ return realVal2 !== b.val2;
1159
+ }
1160
+ break;
1161
+ }
1162
+ case types_1.ClueType.ORDINAL: {
1163
+ const o = clue;
1164
+ const v1 = getOrdinalValue(o.item1Cat, o.item1Val, o.ordinalCat);
1165
+ const v2 = getOrdinalValue(o.item2Cat, o.item2Val, o.ordinalCat);
1166
+ if (v1 === undefined || v2 === undefined)
1167
+ return false;
1168
+ switch (o.operator) {
1169
+ case types_1.OrdinalOperator.GREATER_THAN: return v1 > v2;
1170
+ case types_1.OrdinalOperator.LESS_THAN: return v1 < v2;
1171
+ case types_1.OrdinalOperator.NOT_GREATER_THAN: return v1 <= v2; // Not After
1172
+ case types_1.OrdinalOperator.NOT_LESS_THAN: return v1 >= v2; // Not Before
1173
+ }
1174
+ break;
1175
+ }
1176
+ case types_1.ClueType.SUPERLATIVE: {
1177
+ const s = clue;
1178
+ const v = getOrdinalValue(s.targetCat, s.targetVal, s.ordinalCat);
1179
+ if (v === undefined)
1180
+ return false;
1181
+ // We need the min/max of the ordinal category
1182
+ // Since we don't have categories passed in directly, we can infer from valueMap or we need categories?
1183
+ // valueMap has all values.
1184
+ // But simpler: we know mappings for ALL items in ordinal cat.
1185
+ // Let's iterate all base values to find min/max for that ordinal cat.
1186
+ let min = Infinity;
1187
+ let max = -Infinity;
1188
+ // valueMap keys are baseValues (Category[0] values)
1189
+ for (const baseVal of this.valueMap.keys()) {
1190
+ const mappings = this.valueMap.get(baseVal);
1191
+ if (mappings) {
1192
+ const ov = mappings[s.ordinalCat];
1193
+ if (typeof ov === 'number') {
1194
+ if (ov < min)
1195
+ min = ov;
1196
+ if (ov > max)
1197
+ max = ov;
1198
+ }
1199
+ }
1200
+ }
1201
+ if (min === Infinity)
1202
+ return false; // No ordinal values found?
1203
+ switch (s.operator) {
1204
+ case types_1.SuperlativeOperator.MIN: return v === min;
1205
+ case types_1.SuperlativeOperator.MAX: return v === max;
1206
+ case types_1.SuperlativeOperator.NOT_MIN: return v !== min;
1207
+ case types_1.SuperlativeOperator.NOT_MAX: return v !== max;
1208
+ }
1209
+ break;
1210
+ }
1211
+ case types_1.ClueType.UNARY: {
1212
+ const u = clue;
1213
+ const v = getOrdinalValue(u.targetCat, u.targetVal, u.ordinalCat);
1214
+ if (v === undefined)
1215
+ return false;
1216
+ switch (u.filter) {
1217
+ case types_1.UnaryFilter.IS_ODD: return (v % 2 !== 0);
1218
+ case types_1.UnaryFilter.IS_EVEN: return (v % 2 === 0);
1219
+ }
1220
+ break;
1221
+ }
1222
+ case types_1.ClueType.CROSS_ORDINAL: {
1223
+ const c = clue;
1224
+ const v1 = getOrdinalValue(c.item1Cat, c.item1Val, c.ordinal1);
1225
+ const v2 = getOrdinalValue(c.item2Cat, c.item2Val, c.ordinal2);
1226
+ if (v1 === undefined || v2 === undefined)
1227
+ return false;
1228
+ const op = c.operator;
1229
+ switch (op) {
1230
+ case types_1.OrdinalOperator.GREATER_THAN: return v1 > v2;
1231
+ case types_1.OrdinalOperator.LESS_THAN: return v1 < v2;
1232
+ case types_1.OrdinalOperator.NOT_GREATER_THAN: return v1 <= v2;
1233
+ case types_1.OrdinalOperator.NOT_LESS_THAN: return v1 >= v2;
1234
+ }
1235
+ break;
1236
+ }
1237
+ }
1238
+ return true;
1239
+ }
1099
1240
  isPuzzleSolved(grid, solution, reverseSolution) {
1100
1241
  const categories = grid.categories;
1101
1242
  const baseCategory = categories[0];
@@ -67,4 +67,14 @@ export declare class LogicGrid {
67
67
  * @returns A new LogicGrid instance with the exact same state.
68
68
  */
69
69
  clone(): LogicGrid;
70
+ /**
71
+ * Compares this grid with a previous state and counts visual updates.
72
+ * A "Visual Update" is defined as:
73
+ * 1. A cell changing from Possible (True) to Eliminated (False) [Red Cross]
74
+ * 2. A cell changing from Ambiguous (Count > 1) to Unique (Count == 1) [Green Check]
75
+ *
76
+ * @param prevGrid - The previous grid state.
77
+ * @returns The number of visual updates.
78
+ */
79
+ compareVisualState(prevGrid: LogicGrid): number;
70
80
  }
@@ -186,5 +186,50 @@ class LogicGrid {
186
186
  ]));
187
187
  return newGrid;
188
188
  }
189
+ /**
190
+ * Compares this grid with a previous state and counts visual updates.
191
+ * A "Visual Update" is defined as:
192
+ * 1. A cell changing from Possible (True) to Eliminated (False) [Red Cross]
193
+ * 2. A cell changing from Ambiguous (Count > 1) to Unique (Count == 1) [Green Check]
194
+ *
195
+ * @param prevGrid - The previous grid state.
196
+ * @returns The number of visual updates.
197
+ */
198
+ compareVisualState(prevGrid) {
199
+ let updates = 0;
200
+ const categories = this.categories;
201
+ for (const cat1 of categories) {
202
+ for (const val1 of cat1.values) {
203
+ for (const cat2 of categories) {
204
+ if (cat1.id >= cat2.id)
205
+ continue;
206
+ // 1. Check for Red Crosses (True -> False)
207
+ // We iterate individual cells
208
+ const val1Map = this.grid.get(cat1.id)?.get(val1);
209
+ const prevVal1Map = prevGrid.grid.get(cat1.id)?.get(val1);
210
+ if (val1Map && prevVal1Map) {
211
+ const vals2Arr = val1Map.get(cat2.id);
212
+ const prevVals2Arr = prevVal1Map.get(cat2.id);
213
+ if (vals2Arr && prevVals2Arr) {
214
+ for (let i = 0; i < vals2Arr.length; i++) {
215
+ // If it WAS true and IS NOW false
216
+ if (prevVals2Arr[i] && !vals2Arr[i]) {
217
+ updates++;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ // 2. Check for Green Checks (Ambiguous -> Unique)
223
+ // We check the Row Context (Possibilities Count)
224
+ const prevCount = prevGrid.getPossibilitiesCount(cat1.id, val1, cat2.id);
225
+ const currCount = this.getPossibilitiesCount(cat1.id, val1, cat2.id);
226
+ if (prevCount > 1 && currCount === 1) {
227
+ updates++;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return updates;
233
+ }
189
234
  }
190
235
  exports.LogicGrid = LogicGrid;
@@ -20,6 +20,7 @@ export declare class Solver {
20
20
  applyClue(grid: LogicGrid, clue: Clue): {
21
21
  grid: LogicGrid;
22
22
  deductions: number;
23
+ reasons: import('../types').DeductionReason[];
23
24
  };
24
25
  private applyCrossOrdinalClue;
25
26
  private applyUnaryClue;
@@ -21,31 +21,32 @@ class Solver {
21
21
  */
22
22
  applyClue(grid, clue) {
23
23
  let deductions = 0;
24
+ const reasons = [];
24
25
  switch (clue.type) {
25
26
  case types_1.ClueType.BINARY:
26
- deductions += this.applyBinaryClue(grid, clue);
27
+ deductions += this.applyBinaryClue(grid, clue, reasons);
27
28
  break;
28
29
  case types_1.ClueType.SUPERLATIVE:
29
- deductions += this.applySuperlativeClue(grid, clue);
30
+ deductions += this.applySuperlativeClue(grid, clue, reasons);
30
31
  break;
31
32
  case types_1.ClueType.ORDINAL:
32
- deductions += this.applyOrdinalClue(grid, clue);
33
+ deductions += this.applyOrdinalClue(grid, clue, reasons);
33
34
  break;
34
35
  case types_1.ClueType.UNARY:
35
- deductions += this.applyUnaryClue(grid, clue);
36
+ deductions += this.applyUnaryClue(grid, clue, reasons);
36
37
  break;
37
38
  case types_1.ClueType.CROSS_ORDINAL:
38
- deductions += this.applyCrossOrdinalClue(grid, clue); // Cast as any because import might lag or circular deps? no, just strict TS.
39
+ deductions += this.applyCrossOrdinalClue(grid, clue, reasons);
39
40
  break;
40
41
  }
41
42
  let newDeductions;
42
43
  do {
43
- newDeductions = this.runDeductionLoop(grid);
44
+ newDeductions = this.runDeductionLoop(grid, reasons);
44
45
  deductions += newDeductions;
45
46
  } while (newDeductions > 0);
46
- return { grid, deductions };
47
+ return { grid, deductions, reasons };
47
48
  }
48
- applyCrossOrdinalClue(grid, clue) {
49
+ applyCrossOrdinalClue(grid, clue, reasons) {
49
50
  let deductions = 0;
50
51
  const categories = grid.categories;
51
52
  const ord1Config = categories.find(c => c.id === clue.ordinal1);
@@ -71,6 +72,11 @@ class Solver {
71
72
  if (targetVal1 === undefined) {
72
73
  grid.setPossibility(clue.item1Cat, clue.item1Val, clue.ordinal1, cand1.val, false);
73
74
  deductions++;
75
+ reasons.push({
76
+ type: 'cross_ordinal',
77
+ description: `Cross-Ordinal: ${clue.item1Val} cannot be ${cand1.val} because offset ${clue.offset1} goes out of bounds.`,
78
+ cells: [{ cat: clue.item1Cat, val: clue.item1Val }, { cat: clue.ordinal1, val: cand1.val }]
79
+ });
74
80
  continue;
75
81
  }
76
82
  // Compatibility check
@@ -90,6 +96,11 @@ class Solver {
90
96
  if (!supported) {
91
97
  grid.setPossibility(clue.item1Cat, clue.item1Val, clue.ordinal1, cand1.val, false);
92
98
  deductions++;
99
+ reasons.push({
100
+ type: 'cross_ordinal',
101
+ description: `Cross-Ordinal: ${clue.item1Val} as ${cand1.val} finds no compatible ${clue.item2Val} at offset pair.`,
102
+ cells: [{ cat: clue.item1Cat, val: clue.item1Val }, { cat: clue.ordinal1, val: cand1.val }]
103
+ });
93
104
  }
94
105
  }
95
106
  // Filter 2 based on 1 (Symmetric)
@@ -99,6 +110,11 @@ class Solver {
99
110
  if (targetVal2 === undefined) {
100
111
  grid.setPossibility(clue.item2Cat, clue.item2Val, clue.ordinal2, cand2.val, false);
101
112
  deductions++;
113
+ reasons.push({
114
+ type: 'cross_ordinal',
115
+ description: `Cross-Ordinal: ${clue.item2Val} cannot be ${cand2.val} because offset ${clue.offset2} goes out of bounds.`,
116
+ cells: [{ cat: clue.item2Cat, val: clue.item2Val }, { cat: clue.ordinal1, val: cand2.val }]
117
+ });
102
118
  continue;
103
119
  }
104
120
  let supported = false;
@@ -115,6 +131,11 @@ class Solver {
115
131
  if (!supported) {
116
132
  grid.setPossibility(clue.item2Cat, clue.item2Val, clue.ordinal2, cand2.val, false);
117
133
  deductions++;
134
+ reasons.push({
135
+ type: 'cross_ordinal',
136
+ description: `Cross-Ordinal: ${clue.item2Val} as ${cand2.val} finds no compatible ${clue.item1Val} at offset pair.`,
137
+ cells: [{ cat: clue.item2Cat, val: clue.item2Val }, { cat: clue.ordinal2, val: cand2.val }]
138
+ });
118
139
  }
119
140
  }
120
141
  // Lock linkage if unique
@@ -130,6 +151,11 @@ class Solver {
130
151
  if (grid.getPossibilitiesCount(clue.ordinal1, v1, clue.ordinal2) > 1) {
131
152
  grid.setPossibility(clue.ordinal1, v1, clue.ordinal2, v2, true);
132
153
  deductions++;
154
+ reasons.push({
155
+ type: 'cross_ordinal',
156
+ description: `Cross-Ordinal Link: ${v1} must be ${v2} based on forced offsets.`,
157
+ cells: [{ cat: clue.ordinal1, val: v1 }, { cat: clue.ordinal2, val: v2 }]
158
+ });
133
159
  }
134
160
  }
135
161
  }
@@ -160,6 +186,11 @@ class Solver {
160
186
  if (grid.isPossible(clue.ordinal1, v1, clue.ordinal2, v2)) {
161
187
  grid.setPossibility(clue.ordinal1, v1, clue.ordinal2, v2, false);
162
188
  deductions++;
189
+ reasons.push({
190
+ type: 'cross_ordinal',
191
+ description: `Cross-Ordinal NOT: ${v1} cannot be ${v2} because positions are forbidden.`,
192
+ cells: [{ cat: clue.ordinal1, val: v1 }, { cat: clue.ordinal2, val: v2 }]
193
+ });
163
194
  }
164
195
  }
165
196
  }
@@ -328,6 +359,11 @@ class Solver {
328
359
  if (grid.isPossible(clue.ordinal1, v1, clue.ordinal2, v2)) {
329
360
  grid.setPossibility(clue.item2Cat, clue.item2Val, clue.ordinal2, cand2.val, false);
330
361
  deductions++;
362
+ reasons.push({
363
+ type: 'cross_ordinal',
364
+ description: `Cross-Ordinal NOT: ${clue.item2Val} cannot be ${cand2.val} because it implies forbidden link to ${v1}.`,
365
+ cells: [{ cat: clue.item2Cat, val: clue.item2Val }, { cat: clue.ordinal2, val: cand2.val }]
366
+ });
331
367
  }
332
368
  }
333
369
  }
@@ -345,6 +381,11 @@ class Solver {
345
381
  if (grid.isPossible(clue.ordinal1, v1, clue.ordinal2, v2)) {
346
382
  grid.setPossibility(clue.item1Cat, clue.item1Val, clue.ordinal1, cand1.val, false);
347
383
  deductions++;
384
+ reasons.push({
385
+ type: 'cross_ordinal',
386
+ description: `Cross-Ordinal NOT: ${clue.item1Val} cannot be ${cand1.val} because it implies forbidden link to ${v2}.`,
387
+ cells: [{ cat: clue.item1Cat, val: clue.item1Val }, { cat: clue.ordinal1, val: cand1.val }]
388
+ });
348
389
  }
349
390
  }
350
391
  }
@@ -353,7 +394,7 @@ class Solver {
353
394
  }
354
395
  return deductions;
355
396
  }
356
- applyUnaryClue(grid, clue) {
397
+ applyUnaryClue(grid, clue, reasons) {
357
398
  let deductions = 0;
358
399
  const categories = grid.categories;
359
400
  const ordinalCatConfig = categories.find(c => c.id === clue.ordinalCat);
@@ -369,12 +410,17 @@ class Solver {
369
410
  if (grid.isPossible(clue.targetCat, clue.targetVal, clue.ordinalCat, ordVal)) {
370
411
  grid.setPossibility(clue.targetCat, clue.targetVal, clue.ordinalCat, ordVal, false);
371
412
  deductions++;
413
+ reasons.push({
414
+ type: 'unary',
415
+ description: `Unary Rule: ${ordVal} eliminated for ${clue.targetVal} because it is ${isEven ? 'not even' : 'not odd'}.`,
416
+ cells: [{ cat: clue.targetCat, val: clue.targetVal }, { cat: clue.ordinalCat, val: String(ordVal) }]
417
+ });
372
418
  }
373
419
  }
374
420
  }
375
421
  return deductions;
376
422
  }
377
- applyBinaryClue(grid, clue) {
423
+ applyBinaryClue(grid, clue, reasons) {
378
424
  let deductions = 0;
379
425
  const categories = grid.categories;
380
426
  const cat1Config = categories.find(c => c.id === clue.cat1);
@@ -382,6 +428,15 @@ class Solver {
382
428
  if (!cat1Config || !cat2Config)
383
429
  return 0;
384
430
  if (clue.operator === types_1.BinaryOperator.IS) {
431
+ // Count the "Positive Confirmation" (Green Check) as a deduction if it wasn't already known
432
+ if (grid.getPossibilitiesCount(clue.cat1, clue.val1, clue.cat2) > 1) {
433
+ deductions++;
434
+ reasons.push({
435
+ type: 'confirmation',
436
+ description: `Directly from clue: ${clue.val1} is ${clue.val2}.`,
437
+ cells: [{ cat: clue.cat1, val: clue.val1 }, { cat: clue.cat2, val: clue.val2 }]
438
+ });
439
+ }
385
440
  if (grid.isPossible(clue.cat1, clue.val1, clue.cat2, clue.val2)) {
386
441
  // This is not a deduction, but a fact application. Still, we need to eliminate other possibilities.
387
442
  }
@@ -391,6 +446,11 @@ class Solver {
391
446
  if (grid.isPossible(clue.cat1, clue.val1, clue.cat2, val)) {
392
447
  grid.setPossibility(clue.cat1, clue.val1, clue.cat2, val, false);
393
448
  deductions++;
449
+ reasons.push({
450
+ type: 'elimination',
451
+ description: `Since ${clue.val1} is ${clue.val2}, it cannot be ${val}.`,
452
+ cells: [{ cat: clue.cat1, val: clue.val1 }, { cat: clue.cat2, val: val }]
453
+ });
394
454
  }
395
455
  }
396
456
  }
@@ -399,6 +459,11 @@ class Solver {
399
459
  if (grid.isPossible(clue.cat1, val, clue.cat2, clue.val2)) {
400
460
  grid.setPossibility(clue.cat1, val, clue.cat2, clue.val2, false);
401
461
  deductions++;
462
+ reasons.push({
463
+ type: 'elimination',
464
+ description: `Since ${clue.val2} is ${clue.val1}, it cannot be ${val}.`,
465
+ cells: [{ cat: clue.cat1, val: val }, { cat: clue.cat2, val: clue.val2 }]
466
+ });
402
467
  }
403
468
  }
404
469
  }
@@ -407,11 +472,16 @@ class Solver {
407
472
  if (grid.isPossible(clue.cat1, clue.val1, clue.cat2, clue.val2)) {
408
473
  grid.setPossibility(clue.cat1, clue.val1, clue.cat2, clue.val2, false);
409
474
  deductions++;
475
+ reasons.push({
476
+ type: 'elimination',
477
+ description: `Directly from clue: ${clue.val1} is NOT ${clue.val2}.`,
478
+ cells: [{ cat: clue.cat1, val: clue.val1 }, { cat: clue.cat2, val: clue.val2 }]
479
+ });
410
480
  }
411
481
  }
412
482
  return deductions;
413
483
  }
414
- runDeductionLoop(grid) {
484
+ runDeductionLoop(grid, reasons) {
415
485
  let deductions = 0;
416
486
  const categories = grid.categories;
417
487
  for (const cat1 of categories) {
@@ -429,6 +499,11 @@ class Solver {
429
499
  if (grid.isPossible(cat1.id, otherVal1, cat2.id, val2)) {
430
500
  grid.setPossibility(cat1.id, otherVal1, cat2.id, val2, false);
431
501
  deductions++;
502
+ reasons.push({
503
+ type: 'uniqueness',
504
+ description: `Since ${val2} is uniquely ${val1} in ${cat1.id}, it cannot be ${otherVal1}.`,
505
+ cells: [{ cat: cat1.id, val: val1 }, { cat: cat2.id, val: val2 }, { cat: cat1.id, val: otherVal1 }]
506
+ });
432
507
  }
433
508
  }
434
509
  }
@@ -449,6 +524,11 @@ class Solver {
449
524
  else if (grid.getPossibilitiesCount(cat1.id, val1, cat3.id) > 1) {
450
525
  grid.setPossibility(cat1.id, val1, cat3.id, definiteVal3, true);
451
526
  deductions++;
527
+ reasons.push({
528
+ type: 'transitivity',
529
+ description: `Since ${cat1.id}:${val1} is ${cat2.id}:${definiteVal2}, and that is ${cat3.id}:${definiteVal3}, ${val1} must be ${definiteVal3}.`,
530
+ cells: [{ cat: cat1.id, val: val1 }, { cat: cat2.id, val: definiteVal2 }, { cat: cat3.id, val: definiteVal3 }]
531
+ });
452
532
  }
453
533
  }
454
534
  }
@@ -459,6 +539,11 @@ class Solver {
459
539
  if (!isPathPossible) {
460
540
  grid.setPossibility(cat1.id, val1, cat3.id, val3, false);
461
541
  deductions++;
542
+ reasons.push({
543
+ type: 'transitivity',
544
+ description: `Negative Transitivity: No path exists between ${val1} and ${val3} via ${cat2.id}.`,
545
+ cells: [{ cat: cat1.id, val: val1 }, { cat: cat3.id, val: val3 }]
546
+ });
462
547
  }
463
548
  }
464
549
  }
@@ -468,7 +553,7 @@ class Solver {
468
553
  }
469
554
  return deductions;
470
555
  }
471
- applyOrdinalClue(grid, constraint) {
556
+ applyOrdinalClue(grid, constraint, reasons) {
472
557
  let deductions = 0;
473
558
  const categories = grid.categories;
474
559
  const ordCatConfig = categories.find(c => c.id === constraint.ordinalCat);
@@ -490,6 +575,11 @@ class Solver {
490
575
  if (grid.isPossible(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val)) {
491
576
  grid.setPossibility(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val, false);
492
577
  deductions++;
578
+ reasons.push({
579
+ type: 'ordinal',
580
+ description: `${constraint.item1Val} cannot be ${pval1.val} (idx ${pval1.idx}) because it must be > ${constraint.item2Val}.`,
581
+ cells: [{ cat: constraint.item1Cat, val: constraint.item1Val }, { cat: constraint.ordinalCat, val: pval1.val }]
582
+ });
493
583
  }
494
584
  }
495
585
  }
@@ -501,6 +591,11 @@ class Solver {
501
591
  if (grid.isPossible(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val)) {
502
592
  grid.setPossibility(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val, false);
503
593
  deductions++;
594
+ reasons.push({
595
+ type: 'ordinal',
596
+ description: `${constraint.item1Val} cannot be ${pval1.val} (idx ${pval1.idx}) because it must be < ${constraint.item2Val}.`,
597
+ cells: [{ cat: constraint.item1Cat, val: constraint.item1Val }, { cat: constraint.ordinalCat, val: pval1.val }]
598
+ });
504
599
  }
505
600
  }
506
601
  }
@@ -512,6 +607,11 @@ class Solver {
512
607
  if (grid.isPossible(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val)) {
513
608
  grid.setPossibility(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val, false);
514
609
  deductions++;
610
+ reasons.push({
611
+ type: 'ordinal',
612
+ description: `${constraint.item1Val} cannot be ${pval1.val} (idx ${pval1.idx}) because it must be <= ${constraint.item2Val}.`,
613
+ cells: [{ cat: constraint.item1Cat, val: constraint.item1Val }, { cat: constraint.ordinalCat, val: pval1.val }]
614
+ });
515
615
  }
516
616
  }
517
617
  }
@@ -523,6 +623,11 @@ class Solver {
523
623
  if (grid.isPossible(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val)) {
524
624
  grid.setPossibility(constraint.item1Cat, constraint.item1Val, constraint.ordinalCat, pval1.val, false);
525
625
  deductions++;
626
+ reasons.push({
627
+ type: 'ordinal',
628
+ description: `${constraint.item1Val} cannot be ${pval1.val} (idx ${pval1.idx}) because it must be >= ${constraint.item2Val}.`,
629
+ cells: [{ cat: constraint.item1Cat, val: constraint.item1Val }, { cat: constraint.ordinalCat, val: pval1.val }]
630
+ });
526
631
  }
527
632
  }
528
633
  }
@@ -535,6 +640,11 @@ class Solver {
535
640
  if (grid.isPossible(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val)) {
536
641
  grid.setPossibility(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val, false);
537
642
  deductions++;
643
+ reasons.push({
644
+ type: 'ordinal',
645
+ description: `${constraint.item2Val} cannot be ${pval2.val} (idx ${pval2.idx}) because it must be < ${constraint.item1Val}.`,
646
+ cells: [{ cat: constraint.item2Cat, val: constraint.item2Val }, { cat: constraint.ordinalCat, val: pval2.val }]
647
+ });
538
648
  }
539
649
  }
540
650
  }
@@ -546,6 +656,11 @@ class Solver {
546
656
  if (grid.isPossible(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val)) {
547
657
  grid.setPossibility(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val, false);
548
658
  deductions++;
659
+ reasons.push({
660
+ type: 'ordinal',
661
+ description: `${constraint.item2Val} cannot be ${pval2.val} (idx ${pval2.idx}) because it must be > ${constraint.item1Val}.`,
662
+ cells: [{ cat: constraint.item2Cat, val: constraint.item2Val }, { cat: constraint.ordinalCat, val: pval2.val }]
663
+ });
549
664
  }
550
665
  }
551
666
  }
@@ -557,6 +672,11 @@ class Solver {
557
672
  if (grid.isPossible(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val)) {
558
673
  grid.setPossibility(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val, false);
559
674
  deductions++;
675
+ reasons.push({
676
+ type: 'ordinal',
677
+ description: `${constraint.item2Val} cannot be ${pval2.val} (idx ${pval2.idx}) because it must be >= ${constraint.item1Val}.`,
678
+ cells: [{ cat: constraint.item2Cat, val: constraint.item2Val }, { cat: constraint.ordinalCat, val: pval2.val }]
679
+ });
560
680
  }
561
681
  }
562
682
  }
@@ -568,13 +688,18 @@ class Solver {
568
688
  if (grid.isPossible(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val)) {
569
689
  grid.setPossibility(constraint.item2Cat, constraint.item2Val, constraint.ordinalCat, pval2.val, false);
570
690
  deductions++;
691
+ reasons.push({
692
+ type: 'ordinal',
693
+ description: `${constraint.item2Val} cannot be ${pval2.val} (idx ${pval2.idx}) because it must be <= ${constraint.item1Val}.`,
694
+ cells: [{ cat: constraint.item2Cat, val: constraint.item2Val }, { cat: constraint.ordinalCat, val: pval2.val }]
695
+ });
571
696
  }
572
697
  }
573
698
  }
574
699
  }
575
700
  return deductions;
576
701
  }
577
- applySuperlativeClue(grid, clue) {
702
+ applySuperlativeClue(grid, clue, reasons) {
578
703
  const categories = grid.categories;
579
704
  const ordinalCatConfig = categories.find(c => c.id === clue.ordinalCat);
580
705
  if (!ordinalCatConfig || ordinalCatConfig.type !== types_1.CategoryType.ORDINAL)
@@ -607,7 +732,7 @@ class Solver {
607
732
  val2: extremeValue,
608
733
  operator: isNot ? types_1.BinaryOperator.IS_NOT : types_1.BinaryOperator.IS,
609
734
  };
610
- return this.applyBinaryClue(grid, binaryClue);
735
+ return this.applyBinaryClue(grid, binaryClue, reasons);
611
736
  }
612
737
  }
613
738
  exports.Solver = Solver;
@@ -97,4 +97,17 @@ export interface ClueGenerationConstraints {
97
97
  * Default is 0 (allow all clues). Set to 1 to ensure progress.
98
98
  */
99
99
  minDeductions?: number;
100
+ /**
101
+ * Maximum number of new deductions this clue is allowed to provide.
102
+ * Useful for finding low-impact or "filler" clues, or ensuring a clue isn't *too* powerful.
103
+ */
104
+ maxDeductions?: number;
105
+ }
106
+ export interface DeductionReason {
107
+ type: 'elimination' | 'confirmation' | 'uniqueness' | 'transitivity' | 'clue' | 'unary' | 'ordinal' | 'cross_ordinal';
108
+ description: string;
109
+ cells?: {
110
+ cat: string;
111
+ val: ValueLabel;
112
+ }[];
100
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logic-puzzle-generator",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"