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 +27 -6
- package/dist/src/engine/Clue.d.ts +12 -0
- package/dist/src/engine/GenerativeSession.d.ts +18 -0
- package/dist/src/engine/GenerativeSession.js +184 -21
- package/dist/src/engine/Generator.d.ts +6 -0
- package/dist/src/engine/Generator.js +141 -0
- package/dist/src/engine/LogicGrid.d.ts +10 -0
- package/dist/src/engine/LogicGrid.js +45 -0
- package/dist/src/engine/Solver.d.ts +1 -0
- package/dist/src/engine/Solver.js +139 -14
- package/dist/src/types.d.ts +13 -0
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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()
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
@@ -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);
|
|
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;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
}
|