logic-puzzle-generator 1.0.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/LICENSE +21 -0
- package/README.md +221 -0
- package/dist/Clue.d.ts +81 -0
- package/dist/Clue.js +37 -0
- package/dist/Generator.d.ts +58 -0
- package/dist/Generator.js +433 -0
- package/dist/LogicGrid.d.ts +70 -0
- package/dist/LogicGrid.js +188 -0
- package/dist/Solver.d.ts +29 -0
- package/dist/Solver.js +242 -0
- package/dist/examples/benchmark.d.ts +1 -0
- package/dist/examples/benchmark.js +129 -0
- package/dist/examples/cli.d.ts +1 -0
- package/dist/examples/cli.js +84 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/run_generator.d.ts +1 -0
- package/dist/run_generator.js +84 -0
- package/dist/src/defaults.d.ts +5 -0
- package/dist/src/defaults.js +24 -0
- package/dist/src/engine/BoundsCalculator.d.ts +6 -0
- package/dist/src/engine/BoundsCalculator.js +40 -0
- package/dist/src/engine/Clue.d.ts +75 -0
- package/dist/src/engine/Clue.js +10 -0
- package/dist/src/engine/DifficultyBounds.d.ts +12 -0
- package/dist/src/engine/DifficultyBounds.js +67 -0
- package/dist/src/engine/GenerativeSession.d.ts +32 -0
- package/dist/src/engine/GenerativeSession.js +109 -0
- package/dist/src/engine/Generator.d.ts +119 -0
- package/dist/src/engine/Generator.js +1058 -0
- package/dist/src/engine/LogicGrid.d.ts +70 -0
- package/dist/src/engine/LogicGrid.js +190 -0
- package/dist/src/engine/Solver.d.ts +30 -0
- package/dist/src/engine/Solver.js +613 -0
- package/dist/src/errors.d.ts +12 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +24 -0
- package/dist/src/scripts/GenerateBoundsDeprecated.d.ts +1 -0
- package/dist/src/scripts/GenerateBoundsDeprecated.js +27 -0
- package/dist/src/scripts/StressTestBacktracking.d.ts +1 -0
- package/dist/src/scripts/StressTestBacktracking.js +49 -0
- package/dist/src/types.d.ts +86 -0
- package/dist/src/types.js +58 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +13 -0
- package/package.json +40 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Generator = void 0;
|
|
4
|
+
const types_1 = require("../types");
|
|
5
|
+
const errors_1 = require("../errors");
|
|
6
|
+
const LogicGrid_1 = require("./LogicGrid");
|
|
7
|
+
const Solver_1 = require("./Solver");
|
|
8
|
+
const GenerativeSession_1 = require("./GenerativeSession");
|
|
9
|
+
const DifficultyBounds_1 = require("./DifficultyBounds");
|
|
10
|
+
// A simple seeded PRNG (mulberry32)
|
|
11
|
+
function mulberry32(a) {
|
|
12
|
+
return function () {
|
|
13
|
+
a |= 0;
|
|
14
|
+
a = a + 0x6D2B79F5 | 0;
|
|
15
|
+
var t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
16
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
17
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The main class responsible for generating logic puzzles.
|
|
22
|
+
*
|
|
23
|
+
* It handles the creation of a consistent solution, the generation of all possible clues,
|
|
24
|
+
* and the selection of an optimal set of clues to form a solvable puzzle with a specific target.
|
|
25
|
+
*/
|
|
26
|
+
class Generator {
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new Generator instance.
|
|
29
|
+
*
|
|
30
|
+
* @param seed - A numeric seed for the random number generator to ensure reproducibility.
|
|
31
|
+
*/
|
|
32
|
+
constructor(seed) {
|
|
33
|
+
this.solution = {};
|
|
34
|
+
this.valueMap = new Map();
|
|
35
|
+
this.reverseSolution = new Map();
|
|
36
|
+
this.seed = seed;
|
|
37
|
+
this.random = mulberry32(seed);
|
|
38
|
+
this.solver = new Solver_1.Solver();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates a fully solvable logic puzzle based on the provided configuration.
|
|
42
|
+
* Estimates the minimum and maximum number of clues required to solve a puzzle
|
|
43
|
+
* for the given configuration and target, by running several simulations.
|
|
44
|
+
* This is a computationally intensive operation.
|
|
45
|
+
*
|
|
46
|
+
* @param categories - The categories and values to include in the puzzle.
|
|
47
|
+
* @param target - The specific fact that should be the final deduction of the puzzle.
|
|
48
|
+
* @returns A promise resolving to an object containing the estimated min and max clue counts.
|
|
49
|
+
*/
|
|
50
|
+
getClueCountBounds(categories, target, maxIterations = 10) {
|
|
51
|
+
let min = Infinity;
|
|
52
|
+
let max = 0;
|
|
53
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
54
|
+
// Use temporary generator state for simulation
|
|
55
|
+
const seed = this.seed + i + 1;
|
|
56
|
+
const tempGen = new Generator(seed);
|
|
57
|
+
// Min bound: Greedy Best
|
|
58
|
+
try {
|
|
59
|
+
const minPuzzle = tempGen.internalGenerate(categories, target, 'min');
|
|
60
|
+
if (minPuzzle)
|
|
61
|
+
min = Math.min(min, minPuzzle.clues.length);
|
|
62
|
+
}
|
|
63
|
+
catch (e) { /* Ignore unsolvables in simulation */ }
|
|
64
|
+
// Max bound: Greedy Worst
|
|
65
|
+
try {
|
|
66
|
+
const tempGen2 = new Generator(seed); // fresh state
|
|
67
|
+
const maxPuzzle = tempGen2.internalGenerate(categories, target, 'max');
|
|
68
|
+
if (maxPuzzle)
|
|
69
|
+
max = Math.max(max, maxPuzzle.clues.length);
|
|
70
|
+
}
|
|
71
|
+
catch (e) { /* Ignore */ }
|
|
72
|
+
}
|
|
73
|
+
if (min === Infinity)
|
|
74
|
+
min = 0; // Failed to solve any
|
|
75
|
+
return { min, max };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Generates a fully solvable logic puzzle.
|
|
79
|
+
* @param categories - The categories config.
|
|
80
|
+
* @param target - Optional target fact. If missing, a random one is synthesized.
|
|
81
|
+
* @param config - Generation options.
|
|
82
|
+
*/
|
|
83
|
+
generatePuzzle(categories, target, config = {}) {
|
|
84
|
+
const { targetClueCount, maxCandidates = 50, timeoutMs = 10000 } = config;
|
|
85
|
+
// Validation
|
|
86
|
+
if (categories.length < 2)
|
|
87
|
+
throw new errors_1.ConfigurationError("Must have at least 2 categories.");
|
|
88
|
+
if (targetClueCount !== undefined && targetClueCount < 1)
|
|
89
|
+
throw new errors_1.ConfigurationError("Target clue count must be at least 1");
|
|
90
|
+
if (maxCandidates < 1)
|
|
91
|
+
throw new errors_1.ConfigurationError("maxCandidates must be at least 1");
|
|
92
|
+
// Synthesize Target if Missing
|
|
93
|
+
let finalTarget = target;
|
|
94
|
+
if (!finalTarget) {
|
|
95
|
+
// Pick Random Target
|
|
96
|
+
const cat1Idx = Math.floor(this.random() * categories.length);
|
|
97
|
+
let cat2Idx = Math.floor(this.random() * categories.length);
|
|
98
|
+
while (cat2Idx === cat1Idx) {
|
|
99
|
+
cat2Idx = Math.floor(this.random() * categories.length);
|
|
100
|
+
}
|
|
101
|
+
const c1 = categories[cat1Idx];
|
|
102
|
+
const c2 = categories[cat2Idx];
|
|
103
|
+
const valIdx = Math.floor(this.random() * c1.values.length);
|
|
104
|
+
finalTarget = {
|
|
105
|
+
category1Id: c1.id,
|
|
106
|
+
value1: c1.values[valIdx],
|
|
107
|
+
category2Id: c2.id
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// Validate target fact
|
|
111
|
+
const catIds = new Set(categories.map(c => c.id));
|
|
112
|
+
if (!catIds.has(finalTarget.category1Id) || !catIds.has(finalTarget.category2Id)) {
|
|
113
|
+
throw new errors_1.ConfigurationError('Target fact refers to non-existent categories.');
|
|
114
|
+
}
|
|
115
|
+
if (finalTarget.category1Id === finalTarget.category2Id) {
|
|
116
|
+
throw new errors_1.ConfigurationError('Target fact must refer to two different categories.');
|
|
117
|
+
}
|
|
118
|
+
return this.internalGenerate(categories, finalTarget, 'standard', { maxCandidates, targetClueCount, timeoutMs, constraints: config.constraints, onTrace: config.onTrace });
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Starts an interactive generative session.
|
|
122
|
+
* @param categories
|
|
123
|
+
* @param target Optional target fact.
|
|
124
|
+
*/
|
|
125
|
+
startSession(categories, target) {
|
|
126
|
+
// Validation
|
|
127
|
+
if (categories.length < 2)
|
|
128
|
+
throw new errors_1.ConfigurationError("Must have at least 2 categories.");
|
|
129
|
+
// Synthesize Target if Missing
|
|
130
|
+
let finalTarget = target;
|
|
131
|
+
if (!finalTarget) {
|
|
132
|
+
// Pick Random Target
|
|
133
|
+
const cat1Idx = Math.floor(this.random() * categories.length);
|
|
134
|
+
let cat2Idx = Math.floor(this.random() * categories.length);
|
|
135
|
+
while (cat2Idx === cat1Idx) {
|
|
136
|
+
cat2Idx = Math.floor(this.random() * categories.length);
|
|
137
|
+
}
|
|
138
|
+
const c1 = categories[cat1Idx];
|
|
139
|
+
const c2 = categories[cat2Idx];
|
|
140
|
+
const valIdx = Math.floor(this.random() * c1.values.length);
|
|
141
|
+
finalTarget = {
|
|
142
|
+
category1Id: c1.id,
|
|
143
|
+
value1: c1.values[valIdx],
|
|
144
|
+
category2Id: c2.id
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Initialize State maps locally for this session
|
|
148
|
+
const valueMap = new Map();
|
|
149
|
+
const solution = {};
|
|
150
|
+
const reverseSolution = new Map();
|
|
151
|
+
// Populate them
|
|
152
|
+
this.createSolution(categories, valueMap, solution, reverseSolution);
|
|
153
|
+
return new GenerativeSession_1.GenerativeSession(this, categories, solution, reverseSolution, valueMap, finalTarget);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Internal generation method exposed for simulations.
|
|
157
|
+
* @param strategy 'standard' | 'min' | 'max'
|
|
158
|
+
*/
|
|
159
|
+
internalGenerate(categories, target, strategy, options) {
|
|
160
|
+
if (!categories || categories.length < 2) {
|
|
161
|
+
throw new errors_1.ConfigurationError('At least 2 categories are required to generate a puzzle.');
|
|
162
|
+
}
|
|
163
|
+
const maxCandidates = options?.maxCandidates ?? Infinity;
|
|
164
|
+
const targetCount = options?.targetClueCount;
|
|
165
|
+
const constraints = options?.constraints;
|
|
166
|
+
if (options?.onTrace)
|
|
167
|
+
options.onTrace("Generator: internalGenerate started.");
|
|
168
|
+
// Ensure values exist (start of orphan code)
|
|
169
|
+
const cat1 = categories.find(c => c.id === target.category1Id);
|
|
170
|
+
if (cat1 && !cat1.values.includes(target.value1)) {
|
|
171
|
+
throw new errors_1.ConfigurationError(`Target value '${target.value1}' does not exist in category '${target.category1Id}'.`);
|
|
172
|
+
}
|
|
173
|
+
// Validate Constraints vs Categories
|
|
174
|
+
if (constraints?.allowedClueTypes) {
|
|
175
|
+
// Guard: Prevent "Ambiguous" constraint sets (Weak types only).
|
|
176
|
+
// Strong types provide specific identity or relative position (Comparison) needed to solve N > 2.
|
|
177
|
+
// Weak types (Unary, Superlative) only provide buckets or boundaries.
|
|
178
|
+
const strongTypes = [types_1.ClueType.BINARY, types_1.ClueType.ORDINAL, types_1.ClueType.CROSS_ORDINAL];
|
|
179
|
+
const hasStrongType = constraints.allowedClueTypes.some(t => strongTypes.includes(t));
|
|
180
|
+
if (constraints.allowedClueTypes.length > 0 && !hasStrongType) {
|
|
181
|
+
throw new errors_1.ConfigurationError("Invalid Constraints: The selected clue types are ambiguous on their own. Please allow at least one identity-resolving type (Binary, Ordinal, or Cross-Ordinal).");
|
|
182
|
+
}
|
|
183
|
+
const ordinalCategories = categories.filter(c => c.type === types_1.CategoryType.ORDINAL);
|
|
184
|
+
const ordinalCount = ordinalCategories.length;
|
|
185
|
+
const hasOrdinalCategory = ordinalCount > 0;
|
|
186
|
+
const ordinalDependentTypes = [
|
|
187
|
+
types_1.ClueType.ORDINAL,
|
|
188
|
+
types_1.ClueType.SUPERLATIVE,
|
|
189
|
+
types_1.ClueType.UNARY,
|
|
190
|
+
types_1.ClueType.CROSS_ORDINAL
|
|
191
|
+
];
|
|
192
|
+
const requestedOrdinal = constraints.allowedClueTypes.some(t => ordinalDependentTypes.includes(t));
|
|
193
|
+
if (requestedOrdinal && !hasOrdinalCategory) {
|
|
194
|
+
if (!constraints.allowedClueTypes.includes(types_1.ClueType.BINARY)) {
|
|
195
|
+
throw new errors_1.ConfigurationError('Invalid Constraints: Ordinal-based clue types were requested, but no Ordinal categories exist. Please add an ordinal category or allow Binary clues.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Cross-Ordinal Validation: Requires at least 2 ordinal categories
|
|
199
|
+
if (constraints.allowedClueTypes.includes(types_1.ClueType.CROSS_ORDINAL) && ordinalCount < 2) {
|
|
200
|
+
throw new errors_1.ConfigurationError("Invalid Constraints: Cross-Ordinal clues require at least two separate Ordinal categories.");
|
|
201
|
+
}
|
|
202
|
+
// Unary Clue Validation: Even/Odd requires at least one ordinal category where values have at least one odd and at least one even
|
|
203
|
+
if (constraints.allowedClueTypes.includes(types_1.ClueType.UNARY)) {
|
|
204
|
+
const hasValidUnaryCategory = ordinalCategories.some(cat => {
|
|
205
|
+
const numericValues = cat.values.map(v => Number(v)).filter(v => !isNaN(v));
|
|
206
|
+
const hasOdd = numericValues.some(v => v % 2 !== 0);
|
|
207
|
+
const hasEven = numericValues.some(v => v % 2 === 0);
|
|
208
|
+
return hasOdd && hasEven;
|
|
209
|
+
});
|
|
210
|
+
if (!hasValidUnaryCategory) {
|
|
211
|
+
throw new errors_1.ConfigurationError("Invalid Constraints: Unary clues (Even/Odd) require at least one Ordinal category to contain both odd and even values.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Validate Ordinal Categories
|
|
216
|
+
for (const cat of categories) {
|
|
217
|
+
if (cat.type === types_1.CategoryType.ORDINAL) {
|
|
218
|
+
const nonNumeric = cat.values.some(v => typeof v !== 'number' && isNaN(Number(v)));
|
|
219
|
+
if (nonNumeric) {
|
|
220
|
+
// Try to auto-fix/parse?
|
|
221
|
+
// For strictness in the library, we should probably throw or rely on the caller to provide numbers.
|
|
222
|
+
// But the JSON input from UI might be strings.
|
|
223
|
+
// The Generator expects `ValueLabel` which is `string | number`.
|
|
224
|
+
// If we want to support "1", "2" strings as numbers, we should parse them?
|
|
225
|
+
// But `category.values` is the source of truth.
|
|
226
|
+
// If the user passes ["1", "2"], they are strings.
|
|
227
|
+
// The engine treats them as discrete values.
|
|
228
|
+
// BUT Ordinal logic (Clue generation) does `(a as number) - (b as number)`.
|
|
229
|
+
// So they MUST be actual numbers or we must cast them efficiently.
|
|
230
|
+
// Let's enforce that if it is ORDINAL, the values MUST be parseable as numbers.
|
|
231
|
+
// And ideally, they should BE numbers in the config.
|
|
232
|
+
// If we throw here, we protect the engine from NaN logic.
|
|
233
|
+
throw new errors_1.ConfigurationError(`Category '${cat.id}' is ORDINAL but contains non-numeric values.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Initialize Solution & State Maps (REQUIRED for clue generation)
|
|
238
|
+
// Initialize Solution & State Maps (REQUIRED for clue generation)
|
|
239
|
+
// Note: internalGenerate uses the instance properties for state to simulate original behavior
|
|
240
|
+
// But we should clean this up to be pure if possible.
|
|
241
|
+
// For now, we reset instance properties.
|
|
242
|
+
this.valueMap = new Map();
|
|
243
|
+
this.solution = {};
|
|
244
|
+
this.reverseSolution = new Map();
|
|
245
|
+
if (options?.onTrace)
|
|
246
|
+
options.onTrace("Generator: Creating solution...");
|
|
247
|
+
this.createSolution(categories, this.valueMap, this.solution, this.reverseSolution);
|
|
248
|
+
if (options?.onTrace)
|
|
249
|
+
options.onTrace("Generator: Solution created.");
|
|
250
|
+
// Backtracking Method (Exact Target)
|
|
251
|
+
if (targetCount !== undefined) {
|
|
252
|
+
// Feasibility Check:
|
|
253
|
+
// Use precompiled bounds instead of expensive simulation.
|
|
254
|
+
// if (options?.onTrace) options.onTrace("Generator: Checking feasibility (static bounds lookup)...");
|
|
255
|
+
// Assume 5 items per cat max if 5x10, etc.
|
|
256
|
+
// Actually getRecommendedBounds needs numCats and numItems.
|
|
257
|
+
// We can approximate numItems from the first category (assuming symmetry).
|
|
258
|
+
const numCats = categories.length;
|
|
259
|
+
const numItems = categories[0].values.length;
|
|
260
|
+
const bounds = (0, DifficultyBounds_1.getRecommendedBounds)(numCats, numItems);
|
|
261
|
+
if (options?.onTrace)
|
|
262
|
+
options.onTrace(`Generator: Feasibility check complete. Recommended bounds: ${bounds.min}-${bounds.max}`);
|
|
263
|
+
const MARGIN = 0;
|
|
264
|
+
let effectiveTarget = targetCount;
|
|
265
|
+
if (bounds.min > 0 && targetCount < (bounds.min - MARGIN)) {
|
|
266
|
+
console.warn(`Target clue count ${targetCount} is too low (Estimated min: ${bounds.min}). Auto-adjusting to ${bounds.min}.`);
|
|
267
|
+
effectiveTarget = bounds.min;
|
|
268
|
+
}
|
|
269
|
+
// We need an exact count.
|
|
270
|
+
// Run backtracking search with the feasible target.
|
|
271
|
+
try {
|
|
272
|
+
return this.generateWithBacktracking(categories, target, effectiveTarget, maxCandidates, strategy, options?.timeoutMs ?? 10000, options?.constraints, options?.onTrace);
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
// If it failed (timeout or exhausted), fallback?
|
|
276
|
+
throw e;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (options?.onTrace)
|
|
280
|
+
options.onTrace("Generator: Generating all possible clues...");
|
|
281
|
+
let availableClues = this.generateAllPossibleClues(categories, options?.constraints, this.reverseSolution, this.valueMap);
|
|
282
|
+
if (options?.onTrace)
|
|
283
|
+
options.onTrace(`Generator: Generated ${availableClues.length} candidate clues.`);
|
|
284
|
+
const logicGrid = new LogicGrid_1.LogicGrid(categories);
|
|
285
|
+
const proofChain = [];
|
|
286
|
+
const MAX_STEPS = 100;
|
|
287
|
+
while (proofChain.length < MAX_STEPS) {
|
|
288
|
+
// Trace Support for Standard Mode
|
|
289
|
+
if (options?.onTrace) {
|
|
290
|
+
const depth = proofChain.length;
|
|
291
|
+
const stats = logicGrid.getGridStats();
|
|
292
|
+
// Ensure denominator is valid and non-zero.
|
|
293
|
+
// totalPossible = N*N*C*(C-1)/2 (approx).
|
|
294
|
+
// solutionPossible = N*C*(C-1)/2 (exact matches).
|
|
295
|
+
const range = Math.max(1, stats.totalPossible - stats.solutionPossible);
|
|
296
|
+
const current = Math.max(0, stats.currentPossible - stats.solutionPossible);
|
|
297
|
+
const solvedP = Math.min(100, Math.round(((range - current) / range) * 100));
|
|
298
|
+
options.onTrace(`Depth ${depth}: ${solvedP}% Solved. Candidates: ${availableClues.length}`);
|
|
299
|
+
if (solvedP >= 100 && this.isPuzzleSolved(logicGrid, this.solution, this.reverseSolution)) {
|
|
300
|
+
options.onTrace("Generator: Puzzle Solved (100%).");
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let bestCandidate = null;
|
|
305
|
+
let finalCandidate = null;
|
|
306
|
+
// Strategy-specific shuffling
|
|
307
|
+
if (maxCandidates < availableClues.length) {
|
|
308
|
+
for (let i = availableClues.length - 1; i > 0; i--) {
|
|
309
|
+
const j = Math.floor(this.random() * (i + 1));
|
|
310
|
+
[availableClues[i], availableClues[j]] = [availableClues[j], availableClues[i]];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const candidatesToCheck = Math.min(availableClues.length, maxCandidates);
|
|
314
|
+
let checkedCount = 0;
|
|
315
|
+
for (let i = availableClues.length - 1; i >= 0; i--) {
|
|
316
|
+
if (checkedCount >= candidatesToCheck)
|
|
317
|
+
break;
|
|
318
|
+
const clue = availableClues[i];
|
|
319
|
+
const tempGrid = logicGrid.clone();
|
|
320
|
+
const { deductions } = this.solver.applyClue(tempGrid, clue);
|
|
321
|
+
if (deductions === 0) {
|
|
322
|
+
availableClues.splice(i, 1);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
checkedCount++;
|
|
326
|
+
let score = 0;
|
|
327
|
+
// Strategy Logic
|
|
328
|
+
if (strategy === 'min') {
|
|
329
|
+
// Prefer HIGH deductions
|
|
330
|
+
score = deductions * 1000;
|
|
331
|
+
}
|
|
332
|
+
else if (strategy === 'max') {
|
|
333
|
+
// Prefer LOW deductions (but > 0)
|
|
334
|
+
score = (100 / deductions);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
// Standard balanced scoring
|
|
338
|
+
score = this.calculateClueScore(tempGrid, target, deductions, clue, proofChain.map(p => p.clue), this.solution, this.reverseSolution);
|
|
339
|
+
}
|
|
340
|
+
// Check for immediate solution vs normal step
|
|
341
|
+
const clueType = clue.type;
|
|
342
|
+
const targetValue = this.solution[target.category2Id][target.value1];
|
|
343
|
+
const isTargetSolved = tempGrid.isPossible(target.category1Id, target.value1, target.category2Id, targetValue) &&
|
|
344
|
+
tempGrid.getPossibilitiesCount(target.category1Id, target.value1, target.category2Id) === 1;
|
|
345
|
+
const puzzleSolved = this.isPuzzleSolved(tempGrid, this.solution, this.reverseSolution);
|
|
346
|
+
if (isTargetSolved) {
|
|
347
|
+
if (puzzleSolved) {
|
|
348
|
+
// Solves it completely - always a candidate
|
|
349
|
+
finalCandidate = { clue, score: score + 1000000 };
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// Solves target but grid incomplete - bad
|
|
353
|
+
score = -1000000;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (score > -999999) {
|
|
357
|
+
if (!bestCandidate || score > bestCandidate.score) {
|
|
358
|
+
bestCandidate = { clue, score };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const chosenCandidate = finalCandidate || bestCandidate; // Prioritize finishing if possible?
|
|
363
|
+
// Actually standard logic prioritizes 'bestCandidate_ unless final is the only option or final is better.
|
|
364
|
+
// Let's stick closer to original:
|
|
365
|
+
// if (isTargetSolved && puzzleSolved) return 1000000
|
|
366
|
+
// Simplified selection for simulation:
|
|
367
|
+
if (!chosenCandidate)
|
|
368
|
+
break;
|
|
369
|
+
const chosenClue = chosenCandidate.clue;
|
|
370
|
+
const { deductions } = this.solver.applyClue(logicGrid, chosenClue);
|
|
371
|
+
proofChain.push({ clue: chosenClue, deductions });
|
|
372
|
+
const chosenIndex = availableClues.findIndex(c => JSON.stringify(c) === JSON.stringify(chosenClue));
|
|
373
|
+
if (chosenIndex > -1)
|
|
374
|
+
availableClues.splice(chosenIndex, 1);
|
|
375
|
+
if (this.isPuzzleSolved(logicGrid, this.solution, this.reverseSolution))
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
solution: this.solution,
|
|
380
|
+
clues: proofChain.map(p => p.clue),
|
|
381
|
+
proofChain,
|
|
382
|
+
categories,
|
|
383
|
+
targetFact: target,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
generateWithBacktracking(categories, target, targetCount, maxCandidates, strategy, timeoutMs = 10000, constraints, onTrace) {
|
|
387
|
+
const availableClues = this.generateAllPossibleClues(categories, constraints, this.reverseSolution, this.valueMap);
|
|
388
|
+
// ADAPTIVE MID-RUN BACKTRACKING
|
|
389
|
+
// Instead of restarting, we increase aggression dynamically when we hit dead ends.
|
|
390
|
+
const logicGrid = new LogicGrid_1.LogicGrid(categories);
|
|
391
|
+
let backtrackCount = 0;
|
|
392
|
+
const startTime = Date.now();
|
|
393
|
+
// Define recursive search within this attempt context
|
|
394
|
+
const search = (currentGrid, currentChain, currentAvailable, overrideStrategy) => {
|
|
395
|
+
// Panic based on TIME, not backtrack count.
|
|
396
|
+
// This ensures we use the full available timeout (e.g. 270s) before giving up.
|
|
397
|
+
const elapsed = Date.now() - startTime;
|
|
398
|
+
const timeRatio = elapsed / timeoutMs; // 0.0 to 1.0
|
|
399
|
+
// detailed pacing control.
|
|
400
|
+
// Bias ramps slightly to encourage backtracking if we are stuck.
|
|
401
|
+
const aggressionBias = 1.0 + (timeRatio * 3.0);
|
|
402
|
+
// Trace Status
|
|
403
|
+
if (onTrace && currentChain.length % 1 === 0) {
|
|
404
|
+
const depth = currentChain.length + 1;
|
|
405
|
+
const stats = currentGrid.getGridStats();
|
|
406
|
+
const solvedP = Math.round(((stats.totalPossible - stats.currentPossible) / (stats.totalPossible - stats.solutionPossible)) * 100);
|
|
407
|
+
if (Math.random() < 0.05) {
|
|
408
|
+
onTrace(`Depth ${depth}/${targetCount}: ${solvedP}% Solved. Bias: ${aggressionBias.toFixed(2)} (Backtracks: ${backtrackCount})`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Check if solved
|
|
412
|
+
const isSolved = this.isPuzzleSolved(currentGrid, this.solution, this.reverseSolution);
|
|
413
|
+
if (isSolved) {
|
|
414
|
+
if (currentChain.length === targetCount) {
|
|
415
|
+
if (onTrace)
|
|
416
|
+
onTrace(`SOLVED! Exact match at ${targetCount} clues.`);
|
|
417
|
+
return currentChain;
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
if (currentChain.length >= targetCount) {
|
|
422
|
+
return null; // Overshot target without solving. Trigger backtracking to previous depth.
|
|
423
|
+
}
|
|
424
|
+
// Heuristic Sorting
|
|
425
|
+
const stats = currentGrid.getGridStats();
|
|
426
|
+
const progress = (stats.totalPossible - stats.currentPossible) / (stats.totalPossible - stats.solutionPossible);
|
|
427
|
+
const stepsTaken = currentChain.length + 1; // considering next step
|
|
428
|
+
// const idealProgressPerStep = 1.0 / targetCount;
|
|
429
|
+
// const currentExpectedProgress = stepsTaken * idealProgressPerStep;
|
|
430
|
+
// Filter and Sort Candidates
|
|
431
|
+
let candidates = [];
|
|
432
|
+
// Shuffle for variety
|
|
433
|
+
if (maxCandidates < currentAvailable.length) {
|
|
434
|
+
for (let i = currentAvailable.length - 1; i > 0; i--) {
|
|
435
|
+
const j = Math.floor(this.random() * (i + 1));
|
|
436
|
+
[currentAvailable[i], currentAvailable[j]] = [currentAvailable[j], currentAvailable[i]];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// DYNAMIC PRUNING
|
|
440
|
+
// ...
|
|
441
|
+
// Use quadratic power instead of cubic for gentler falloff
|
|
442
|
+
// At Bias 1.26 (50 backtracks): Check 50 / 2 = 25.
|
|
443
|
+
// At Bias 1.58 (100 backtracks): Check 50 / 4 = 12.
|
|
444
|
+
const baseMax = overrideStrategy ? 3 : ((maxCandidates === Infinity) ? 50 : maxCandidates);
|
|
445
|
+
const dynamicLimit = Math.max(1, Math.floor(baseMax / Math.pow(aggressionBias, 2)));
|
|
446
|
+
const limit = Math.min(currentAvailable.length, dynamicLimit);
|
|
447
|
+
// Debug pruning
|
|
448
|
+
if (onTrace && Math.random() < 0.001) {
|
|
449
|
+
onTrace(`Pruning: Limit reduced to ${limit} (Bias ${aggressionBias.toFixed(1)})`);
|
|
450
|
+
}
|
|
451
|
+
let checked = 0;
|
|
452
|
+
for (let i = 0; i < currentAvailable.length; i++) {
|
|
453
|
+
if (checked >= limit)
|
|
454
|
+
break;
|
|
455
|
+
const clue = currentAvailable[i];
|
|
456
|
+
const tempGrid = currentGrid.clone();
|
|
457
|
+
const { deductions } = this.solver.applyClue(tempGrid, clue);
|
|
458
|
+
if (deductions === 0)
|
|
459
|
+
continue;
|
|
460
|
+
checked++;
|
|
461
|
+
// Do NOT allow early solves unless it's the last step
|
|
462
|
+
const solvedNow = this.isPuzzleSolved(tempGrid, this.solution, this.reverseSolution);
|
|
463
|
+
if (solvedNow && (stepsTaken < targetCount))
|
|
464
|
+
continue;
|
|
465
|
+
// Calculate Base Quality Score (Variety, Repetition, etc.)
|
|
466
|
+
let score = this.calculateClueScore(tempGrid, target, deductions, clue, currentChain.map(p => p.clue), this.solution, this.reverseSolution);
|
|
467
|
+
// TARGET CLUE COUNT HEURISTIC
|
|
468
|
+
if (targetCount) {
|
|
469
|
+
const percentThroughTarget = stepsTaken / targetCount;
|
|
470
|
+
const percentSolved = (stats.totalPossible - tempGrid.getGridStats().currentPossible) / (stats.totalPossible - stats.solutionPossible);
|
|
471
|
+
const expectedSolved = Math.pow(percentThroughTarget, 1.8);
|
|
472
|
+
const diff = percentSolved - expectedSolved;
|
|
473
|
+
// If diff is POSITIVE, we are AHEAD (Too Fast).
|
|
474
|
+
// We need to stall -> Penalize deductions.
|
|
475
|
+
// If diff is NEGATIVE, we are BEHIND (Too Slow).
|
|
476
|
+
// We need to catch up -> Reward deductions.
|
|
477
|
+
// Weight: 50 * Diff * Deductions.
|
|
478
|
+
// If Diff is +0.2 (20% ahead), we subtract 10 * Deductions.
|
|
479
|
+
// If Diff is -0.2 (20% behind), we add 10 * Deductions.
|
|
480
|
+
score -= (diff * deductions * 50);
|
|
481
|
+
}
|
|
482
|
+
candidates.push({ clue, deductions, score, grid: tempGrid, index: i });
|
|
483
|
+
}
|
|
484
|
+
// candidates.sort((a, b) => b.score - a.score); // MOVED INSIDE LOOP
|
|
485
|
+
// ---------------------------------------------------------
|
|
486
|
+
// HYBRID PRIORITY SORTING (ITERATIVE CORRECTION)
|
|
487
|
+
// ---------------------------------------------------------
|
|
488
|
+
// Strategy: "Try Normal -> If Fail -> Try Correction -> If Fail -> Try Rest"
|
|
489
|
+
// This ensures we attempt to fix the pacing (Speed/Stall) immediately upon backtracking.
|
|
490
|
+
let orderedCandidates = [];
|
|
491
|
+
let primaryCandidate;
|
|
492
|
+
let correctionCandidate;
|
|
493
|
+
if (overrideStrategy) {
|
|
494
|
+
const isStall = overrideStrategy === 'STALL';
|
|
495
|
+
// Sort by weak/strong immediately
|
|
496
|
+
orderedCandidates = candidates.sort((a, b) => {
|
|
497
|
+
const diff = a.deductions - b.deductions;
|
|
498
|
+
return isStall ? diff : -diff; // Weakest first vs Strongest first
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
// 1. Primary Heuristic (Balance)
|
|
503
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
504
|
+
primaryCandidate = candidates[0];
|
|
505
|
+
}
|
|
506
|
+
if (!overrideStrategy) {
|
|
507
|
+
// 2. Correction Heuristic (Extreme Logic)
|
|
508
|
+
correctionCandidate = candidates[0];
|
|
509
|
+
if (targetCount) {
|
|
510
|
+
const expectedProgress = Math.pow(stepsTaken / targetCount, 1.8);
|
|
511
|
+
if (progress > expectedProgress) {
|
|
512
|
+
// STALL: Prefer Min Deductions
|
|
513
|
+
const sortedByWeakness = [...candidates].sort((a, b) => {
|
|
514
|
+
if (a.deductions !== b.deductions)
|
|
515
|
+
return a.deductions - b.deductions;
|
|
516
|
+
return b.score - a.score;
|
|
517
|
+
});
|
|
518
|
+
correctionCandidate = sortedByWeakness[0];
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
// SPEED: Prefer Max Deductions
|
|
522
|
+
const sortedByStrength = [...candidates].sort((a, b) => {
|
|
523
|
+
if (a.deductions !== b.deductions)
|
|
524
|
+
return b.deductions - a.deductions;
|
|
525
|
+
return b.score - a.score;
|
|
526
|
+
});
|
|
527
|
+
correctionCandidate = sortedByStrength[0];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// 3. Construct Final List
|
|
531
|
+
// (orderedCandidates var is from outer scope)
|
|
532
|
+
if (primaryCandidate)
|
|
533
|
+
orderedCandidates.push(primaryCandidate);
|
|
534
|
+
if (correctionCandidate && correctionCandidate !== primaryCandidate) {
|
|
535
|
+
orderedCandidates.push(correctionCandidate);
|
|
536
|
+
}
|
|
537
|
+
for (const c of candidates) {
|
|
538
|
+
if (c !== primaryCandidate && c !== correctionCandidate) {
|
|
539
|
+
orderedCandidates.push(c);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// 4. Iterate (With Timeout guard)
|
|
544
|
+
for (const cand of orderedCandidates) {
|
|
545
|
+
// Update Time Bias
|
|
546
|
+
const freshBias = 1.0 + ((Date.now() - startTime) / timeoutMs * 4.0);
|
|
547
|
+
// Smart Backtracking Check:
|
|
548
|
+
// If we are deep (Depth > 4) and under pressure (Bias > 1.1),
|
|
549
|
+
// we only permit the Primary and Correction candidates.
|
|
550
|
+
// We prune the "Rest".
|
|
551
|
+
if (targetCount && currentChain.length > 4) {
|
|
552
|
+
if (freshBias > 1.1) {
|
|
553
|
+
const isElite = (!!overrideStrategy || cand === primaryCandidate || cand === correctionCandidate);
|
|
554
|
+
if (!isElite)
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const nextAvailable = [...currentAvailable];
|
|
559
|
+
nextAvailable.splice(cand.index, 1);
|
|
560
|
+
// TRACE LOGGING: Explain Decision
|
|
561
|
+
const isPrimary = (cand === primaryCandidate);
|
|
562
|
+
const isCorrection = (cand === correctionCandidate && cand !== primaryCandidate);
|
|
563
|
+
// Determine Next Strategy (Stickiness)
|
|
564
|
+
let nextStrategy = overrideStrategy;
|
|
565
|
+
let actionStr = "";
|
|
566
|
+
if (!overrideStrategy && isCorrection && targetCount) {
|
|
567
|
+
// We are initiating a Correction Strategy. It becomes Sticky.
|
|
568
|
+
const ahead = progress > (stepsTaken / targetCount);
|
|
569
|
+
const debugVal = `(Prog ${progress.toFixed(2)} vs Exp ${(stepsTaken / targetCount).toFixed(2)})`;
|
|
570
|
+
// Set the strategy for the child
|
|
571
|
+
nextStrategy = ahead ? 'STALL' : 'SPEED';
|
|
572
|
+
actionStr = ahead ? `(Stalling - Weakest) ${debugVal}` : `(Speeding - Strongest) ${debugVal}`;
|
|
573
|
+
}
|
|
574
|
+
else if (overrideStrategy) {
|
|
575
|
+
actionStr = `(Continued ${overrideStrategy})`;
|
|
576
|
+
}
|
|
577
|
+
if (onTrace && (isCorrection || overrideStrategy || Math.random() < 0.05)) {
|
|
578
|
+
const typeStr = overrideStrategy ? `STICKY ${overrideStrategy}` : (isPrimary ? "Primary" : (isCorrection ? "CORRECTION" : "Rest"));
|
|
579
|
+
if (isCorrection) {
|
|
580
|
+
onTrace(`[BACKTRACK] Depth ${currentChain.length}: Primary Strategy Failed. Switching to ${actionStr}. Ded: ${cand.deductions}`);
|
|
581
|
+
}
|
|
582
|
+
else if (overrideStrategy) {
|
|
583
|
+
// Only log occasionally for sticky steps to avoid spam, or finding a solution
|
|
584
|
+
if (Math.random() < 0.1) {
|
|
585
|
+
onTrace(`Depth ${currentChain.length}: ${Math.round(progress * 100)}% Solved. Strategy: ${typeStr}. Ded: ${cand.deductions}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
onTrace(`Depth ${currentChain.length}: ${Math.round(progress * 100)}% Solved. Trying ${typeStr} Cand. Ded: ${cand.deductions}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const result = search(cand.grid, [...currentChain, { clue: cand.clue, deductions: cand.deductions }], nextAvailable, nextStrategy);
|
|
593
|
+
if (result)
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
backtrackCount++;
|
|
597
|
+
return null;
|
|
598
|
+
};
|
|
599
|
+
const result = search(logicGrid, [], [...availableClues]);
|
|
600
|
+
if (result) {
|
|
601
|
+
let puzzle = {
|
|
602
|
+
solution: this.solution,
|
|
603
|
+
clues: result.map(p => p.clue),
|
|
604
|
+
proofChain: result,
|
|
605
|
+
categories,
|
|
606
|
+
targetFact: target,
|
|
607
|
+
};
|
|
608
|
+
return puzzle;
|
|
609
|
+
}
|
|
610
|
+
throw new errors_1.ConfigurationError(`Could not generate puzzle with exactly ${targetCount} clues within timeout.`);
|
|
611
|
+
}
|
|
612
|
+
createSolution(categories, valueMap, solution, reverseSolution) {
|
|
613
|
+
const baseCategory = categories[0];
|
|
614
|
+
baseCategory.values.forEach(val => {
|
|
615
|
+
valueMap.set(val, { [baseCategory.id]: val });
|
|
616
|
+
});
|
|
617
|
+
for (let i = 1; i < categories.length; i++) {
|
|
618
|
+
const currentCategory = categories[i];
|
|
619
|
+
const shuffledValues = [...currentCategory.values].sort(() => this.random() - 0.5);
|
|
620
|
+
let i_shuffled = 0;
|
|
621
|
+
for (const val of baseCategory.values) {
|
|
622
|
+
const record = valueMap.get(val);
|
|
623
|
+
if (record)
|
|
624
|
+
record[currentCategory.id] = shuffledValues[i_shuffled++];
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const cat of categories) {
|
|
628
|
+
solution[cat.id] = {};
|
|
629
|
+
reverseSolution.set(cat.id, new Map());
|
|
630
|
+
}
|
|
631
|
+
for (const baseVal of baseCategory.values) {
|
|
632
|
+
const mappings = valueMap.get(baseVal);
|
|
633
|
+
if (mappings) {
|
|
634
|
+
for (const catId in mappings) {
|
|
635
|
+
solution[catId][baseVal] = mappings[catId];
|
|
636
|
+
reverseSolution.get(catId)?.set(mappings[catId], baseVal);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
generateAllPossibleClues(categories, constraints, reverseSolution, valueMap) {
|
|
642
|
+
const clues = [];
|
|
643
|
+
const baseCategory = categories[0];
|
|
644
|
+
// Type Checking Helpers
|
|
645
|
+
const isAllowed = (type) => !constraints?.allowedClueTypes || constraints.allowedClueTypes.includes(type);
|
|
646
|
+
// Generate BinaryClues
|
|
647
|
+
if (isAllowed(types_1.ClueType.BINARY)) {
|
|
648
|
+
for (const cat1 of categories) {
|
|
649
|
+
for (const val1 of cat1.values) {
|
|
650
|
+
for (const cat2 of categories) {
|
|
651
|
+
if (cat1.id >= cat2.id)
|
|
652
|
+
continue;
|
|
653
|
+
const baseVal = reverseSolution.get(cat1.id)?.get(val1);
|
|
654
|
+
if (!baseVal)
|
|
655
|
+
continue;
|
|
656
|
+
const mappings = valueMap.get(baseVal);
|
|
657
|
+
if (!mappings)
|
|
658
|
+
continue;
|
|
659
|
+
for (const val2 of cat2.values) {
|
|
660
|
+
const correctVal2 = mappings[cat2.id];
|
|
661
|
+
if (val2 === correctVal2) {
|
|
662
|
+
clues.push({
|
|
663
|
+
type: types_1.ClueType.BINARY,
|
|
664
|
+
operator: types_1.BinaryOperator.IS,
|
|
665
|
+
cat1: cat1.id,
|
|
666
|
+
val1: val1,
|
|
667
|
+
cat2: cat2.id,
|
|
668
|
+
val2: val2,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
clues.push({
|
|
673
|
+
type: types_1.ClueType.BINARY,
|
|
674
|
+
operator: types_1.BinaryOperator.IS_NOT,
|
|
675
|
+
cat1: cat1.id,
|
|
676
|
+
val1: val1,
|
|
677
|
+
cat2: cat2.id,
|
|
678
|
+
val2: val2,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// Generate Ordinal and SuperlativeClues
|
|
687
|
+
for (const ordCategory of categories.filter(c => c.type === types_1.CategoryType.ORDINAL)) {
|
|
688
|
+
const sortedValues = [...ordCategory.values].sort((a, b) => a - b);
|
|
689
|
+
const minVal = sortedValues[0];
|
|
690
|
+
const maxVal = sortedValues[sortedValues.length - 1];
|
|
691
|
+
// Generate SuperlativeClues for all categories
|
|
692
|
+
if (isAllowed(types_1.ClueType.SUPERLATIVE)) {
|
|
693
|
+
for (const targetCat of categories) {
|
|
694
|
+
if (targetCat.id === ordCategory.id)
|
|
695
|
+
continue;
|
|
696
|
+
for (const targetVal of targetCat.values) {
|
|
697
|
+
const baseVal = reverseSolution.get(targetCat.id)?.get(targetVal);
|
|
698
|
+
if (!baseVal)
|
|
699
|
+
continue;
|
|
700
|
+
const mappings = valueMap.get(baseVal);
|
|
701
|
+
if (!mappings)
|
|
702
|
+
continue;
|
|
703
|
+
const ordVal = mappings[ordCategory.id];
|
|
704
|
+
if (ordVal === minVal) {
|
|
705
|
+
clues.push({
|
|
706
|
+
type: types_1.ClueType.SUPERLATIVE,
|
|
707
|
+
operator: types_1.SuperlativeOperator.MIN,
|
|
708
|
+
targetCat: targetCat.id,
|
|
709
|
+
targetVal: targetVal,
|
|
710
|
+
ordinalCat: ordCategory.id,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
// Valid negative clue: "This item is NOT the lowest"
|
|
715
|
+
clues.push({
|
|
716
|
+
type: types_1.ClueType.SUPERLATIVE,
|
|
717
|
+
operator: types_1.SuperlativeOperator.NOT_MIN,
|
|
718
|
+
targetCat: targetCat.id,
|
|
719
|
+
targetVal: targetVal,
|
|
720
|
+
ordinalCat: ordCategory.id,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
if (ordVal === maxVal) {
|
|
724
|
+
clues.push({
|
|
725
|
+
type: types_1.ClueType.SUPERLATIVE,
|
|
726
|
+
operator: types_1.SuperlativeOperator.MAX,
|
|
727
|
+
targetCat: targetCat.id,
|
|
728
|
+
targetVal: targetVal,
|
|
729
|
+
ordinalCat: ordCategory.id,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
clues.push({
|
|
734
|
+
type: types_1.ClueType.SUPERLATIVE,
|
|
735
|
+
operator: types_1.SuperlativeOperator.NOT_MAX,
|
|
736
|
+
targetCat: targetCat.id,
|
|
737
|
+
targetVal: targetVal,
|
|
738
|
+
ordinalCat: ordCategory.id,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// Generate OrdinalClues for all pairs of categories
|
|
745
|
+
if (isAllowed(types_1.ClueType.ORDINAL)) {
|
|
746
|
+
for (const item1Cat of categories) {
|
|
747
|
+
if (item1Cat.id === ordCategory.id)
|
|
748
|
+
continue;
|
|
749
|
+
for (const item2Cat of categories) {
|
|
750
|
+
if (item2Cat.id === ordCategory.id)
|
|
751
|
+
continue;
|
|
752
|
+
for (const item1Val of item1Cat.values) {
|
|
753
|
+
for (const item2Val of item2Cat.values) {
|
|
754
|
+
if (item1Cat.id === item2Cat.id && item1Val === item2Val)
|
|
755
|
+
continue;
|
|
756
|
+
const baseVal1 = reverseSolution.get(item1Cat.id)?.get(item1Val);
|
|
757
|
+
const baseVal2 = reverseSolution.get(item2Cat.id)?.get(item2Val);
|
|
758
|
+
if (!baseVal1 || !baseVal2)
|
|
759
|
+
continue;
|
|
760
|
+
// if they are the same entity, don't compare
|
|
761
|
+
if (baseVal1 === baseVal2)
|
|
762
|
+
continue;
|
|
763
|
+
const mappings1 = valueMap.get(baseVal1);
|
|
764
|
+
const mappings2 = valueMap.get(baseVal2);
|
|
765
|
+
if (!mappings1 || !mappings2)
|
|
766
|
+
continue;
|
|
767
|
+
const ordVal1 = mappings1[ordCategory.id];
|
|
768
|
+
const ordVal2 = mappings2[ordCategory.id];
|
|
769
|
+
if (ordVal1 > ordVal2) {
|
|
770
|
+
clues.push({
|
|
771
|
+
type: types_1.ClueType.ORDINAL,
|
|
772
|
+
operator: types_1.OrdinalOperator.GREATER_THAN,
|
|
773
|
+
item1Cat: item1Cat.id,
|
|
774
|
+
item1Val: item1Val,
|
|
775
|
+
item2Cat: item2Cat.id,
|
|
776
|
+
item2Val: item2Val,
|
|
777
|
+
ordinalCat: ordCategory.id,
|
|
778
|
+
});
|
|
779
|
+
clues.push({
|
|
780
|
+
type: types_1.ClueType.ORDINAL,
|
|
781
|
+
operator: types_1.OrdinalOperator.NOT_LESS_THAN, // Not Before
|
|
782
|
+
item1Cat: item1Cat.id,
|
|
783
|
+
item1Val: item1Val,
|
|
784
|
+
item2Cat: item2Cat.id,
|
|
785
|
+
item2Val: item2Val,
|
|
786
|
+
ordinalCat: ordCategory.id,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
else if (ordVal1 < ordVal2) {
|
|
790
|
+
clues.push({
|
|
791
|
+
type: types_1.ClueType.ORDINAL,
|
|
792
|
+
operator: types_1.OrdinalOperator.LESS_THAN,
|
|
793
|
+
item1Cat: item1Cat.id,
|
|
794
|
+
item1Val: item1Val,
|
|
795
|
+
item2Cat: item2Cat.id,
|
|
796
|
+
item2Val: item2Val,
|
|
797
|
+
ordinalCat: ordCategory.id,
|
|
798
|
+
});
|
|
799
|
+
clues.push({
|
|
800
|
+
type: types_1.ClueType.ORDINAL,
|
|
801
|
+
operator: types_1.OrdinalOperator.NOT_GREATER_THAN, // Not After
|
|
802
|
+
item1Cat: item1Cat.id,
|
|
803
|
+
item1Val: item1Val,
|
|
804
|
+
item2Cat: item2Cat.id,
|
|
805
|
+
item2Val: item2Val,
|
|
806
|
+
ordinalCat: ordCategory.id,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// Generate UnaryClues
|
|
816
|
+
if (isAllowed(types_1.ClueType.UNARY)) {
|
|
817
|
+
for (const ordCategory of categories) {
|
|
818
|
+
if (ordCategory.type !== types_1.CategoryType.ORDINAL)
|
|
819
|
+
continue;
|
|
820
|
+
// Check if all values are numbers
|
|
821
|
+
if (!ordCategory.values.every(v => typeof v === 'number'))
|
|
822
|
+
continue;
|
|
823
|
+
for (const targetCategory of categories) {
|
|
824
|
+
if (targetCategory.id === ordCategory.id)
|
|
825
|
+
continue;
|
|
826
|
+
for (const targetVal of targetCategory.values) {
|
|
827
|
+
const baseVal = reverseSolution.get(targetCategory.id)?.get(targetVal);
|
|
828
|
+
if (!baseVal)
|
|
829
|
+
continue;
|
|
830
|
+
const mappings = valueMap.get(baseVal);
|
|
831
|
+
if (!mappings)
|
|
832
|
+
continue;
|
|
833
|
+
const ordValue = mappings[ordCategory.id];
|
|
834
|
+
if (ordValue % 2 === 0) {
|
|
835
|
+
clues.push({
|
|
836
|
+
type: types_1.ClueType.UNARY,
|
|
837
|
+
filter: types_1.UnaryFilter.IS_EVEN,
|
|
838
|
+
targetCat: targetCategory.id,
|
|
839
|
+
targetVal: targetVal,
|
|
840
|
+
ordinalCat: ordCategory.id,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
clues.push({
|
|
845
|
+
type: types_1.ClueType.UNARY,
|
|
846
|
+
filter: types_1.UnaryFilter.IS_ODD,
|
|
847
|
+
targetCat: targetCategory.id,
|
|
848
|
+
targetVal: targetVal,
|
|
849
|
+
ordinalCat: ordCategory.id,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return clues;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Calculates the heuristic score for a candidate clue.
|
|
860
|
+
* Higher scores represent better clues according to the current strategy.
|
|
861
|
+
*/
|
|
862
|
+
calculateClueScore(grid, target, deductions, clue, previouslySelectedClues, solution, reverseSolution) {
|
|
863
|
+
const clueType = clue.type;
|
|
864
|
+
// Resolve Correct Target Value
|
|
865
|
+
// If Target is Cat1 -> Cat2
|
|
866
|
+
// We need to know what Val1 (in Cat1) maps to in Cat2.
|
|
867
|
+
// Solution is { CatID -> { BaseVal -> ValInCat } }.
|
|
868
|
+
// We first need the BaseVal for Val1.
|
|
869
|
+
const baseVal = reverseSolution.get(target.category1Id)?.get(target.value1);
|
|
870
|
+
let targetValue;
|
|
871
|
+
if (baseVal !== undefined) {
|
|
872
|
+
targetValue = solution[target.category2Id][baseVal];
|
|
873
|
+
}
|
|
874
|
+
const isTargetSolved = targetValue !== undefined &&
|
|
875
|
+
grid.isPossible(target.category1Id, target.value1, target.category2Id, targetValue) &&
|
|
876
|
+
grid.getPossibilitiesCount(target.category1Id, target.value1, target.category2Id) === 1;
|
|
877
|
+
const puzzleSolved = this.isPuzzleSolved(grid, solution, reverseSolution);
|
|
878
|
+
// BAN DIRECT TARGET CLUES
|
|
879
|
+
// If the clue is literally "Subject IS Answer", it's too easy/boring.
|
|
880
|
+
// We want the target to be deduced, not stated.
|
|
881
|
+
if (clue.type === types_1.ClueType.BINARY && clue.operator === types_1.BinaryOperator.IS) {
|
|
882
|
+
const bc = clue;
|
|
883
|
+
// Check Forward
|
|
884
|
+
if (bc.cat1 === target.category1Id && bc.val1 === target.value1 &&
|
|
885
|
+
bc.cat2 === target.category2Id && bc.val2 === targetValue) {
|
|
886
|
+
return -Infinity;
|
|
887
|
+
}
|
|
888
|
+
// Check Reverse (if target was defined backwards)
|
|
889
|
+
if (bc.cat1 === target.category2Id && bc.val1 === targetValue &&
|
|
890
|
+
bc.cat2 === target.category1Id && bc.val2 === target.value1) {
|
|
891
|
+
return -Infinity;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (isTargetSolved && puzzleSolved) {
|
|
895
|
+
return 1000000; // This is the winning clue
|
|
896
|
+
}
|
|
897
|
+
if (isTargetSolved && !puzzleSolved) {
|
|
898
|
+
return -1000000; // This clue solves the target too early
|
|
899
|
+
}
|
|
900
|
+
const synergyScore = deductions;
|
|
901
|
+
const { totalPossible, currentPossible, solutionPossible } = grid.getGridStats();
|
|
902
|
+
const totalEliminatable = totalPossible - solutionPossible;
|
|
903
|
+
const eliminatedSoFar = totalPossible - currentPossible;
|
|
904
|
+
const completenessScore = totalEliminatable > 0 ? (eliminatedSoFar / totalEliminatable) : 0;
|
|
905
|
+
let complexityBonus = 0;
|
|
906
|
+
switch (clueType) {
|
|
907
|
+
case types_1.ClueType.ORDINAL:
|
|
908
|
+
complexityBonus = 1.5;
|
|
909
|
+
if (clue.operator >= 2)
|
|
910
|
+
complexityBonus = 5.0; // Boost Negative Ordinals
|
|
911
|
+
break;
|
|
912
|
+
case types_1.ClueType.SUPERLATIVE:
|
|
913
|
+
complexityBonus = 1.2;
|
|
914
|
+
if (clue.operator >= 2)
|
|
915
|
+
complexityBonus = 5.0; // Boost Negative Superlatives
|
|
916
|
+
break;
|
|
917
|
+
case types_1.ClueType.UNARY:
|
|
918
|
+
complexityBonus = 1.2;
|
|
919
|
+
break;
|
|
920
|
+
case types_1.ClueType.BINARY:
|
|
921
|
+
complexityBonus = 1.0;
|
|
922
|
+
// Boost IS_NOT to encourage variety, as they are weaker deduction-wise
|
|
923
|
+
if (clue.operator === types_1.BinaryOperator.IS_NOT) {
|
|
924
|
+
complexityBonus = 5.0;
|
|
925
|
+
}
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
// --- Combined Three-Part Penalty Logic ---
|
|
929
|
+
let repetitionScore = 0;
|
|
930
|
+
// 1. Subject Penalty
|
|
931
|
+
const getEntities = (c) => {
|
|
932
|
+
const safeGet = (cat, val) => this.reverseSolution.get(cat)?.get(val);
|
|
933
|
+
let primary = [];
|
|
934
|
+
let secondary = [];
|
|
935
|
+
switch (c.type) {
|
|
936
|
+
case types_1.ClueType.BINARY:
|
|
937
|
+
const b = c;
|
|
938
|
+
primary.push(safeGet(b.cat1, b.val1));
|
|
939
|
+
if (b.operator === types_1.BinaryOperator.IS_NOT) {
|
|
940
|
+
secondary.push(safeGet(b.cat2, b.val2));
|
|
941
|
+
}
|
|
942
|
+
break;
|
|
943
|
+
case types_1.ClueType.SUPERLATIVE:
|
|
944
|
+
const s = c;
|
|
945
|
+
primary.push(safeGet(s.targetCat, s.targetVal));
|
|
946
|
+
break;
|
|
947
|
+
case types_1.ClueType.ORDINAL:
|
|
948
|
+
const o = c;
|
|
949
|
+
primary.push(safeGet(o.item1Cat, o.item1Val));
|
|
950
|
+
secondary.push(safeGet(o.item2Cat, o.item2Val));
|
|
951
|
+
break;
|
|
952
|
+
case types_1.ClueType.UNARY:
|
|
953
|
+
const u = c;
|
|
954
|
+
primary.push(safeGet(u.targetCat, u.targetVal));
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
primary: primary.filter((e) => !!e),
|
|
959
|
+
secondary: secondary.filter((e) => !!e)
|
|
960
|
+
};
|
|
961
|
+
};
|
|
962
|
+
const mentionedEntities = new Set();
|
|
963
|
+
for (const pClue of previouslySelectedClues) {
|
|
964
|
+
const { primary, secondary } = getEntities(pClue);
|
|
965
|
+
primary.forEach(e => mentionedEntities.add(e));
|
|
966
|
+
secondary.forEach(e => mentionedEntities.add(e));
|
|
967
|
+
}
|
|
968
|
+
const { primary: currentPrimary, secondary: currentSecondary } = getEntities(clue);
|
|
969
|
+
currentPrimary.forEach(e => {
|
|
970
|
+
if (mentionedEntities.has(e)) {
|
|
971
|
+
repetitionScore += 1.0; // Full penalty for primary subject
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
currentSecondary.forEach(e => {
|
|
975
|
+
if (mentionedEntities.has(e)) {
|
|
976
|
+
repetitionScore += 0.5; // Half penalty for secondary subject
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
// 2. Dimension (Ordinal Category) Penalty
|
|
980
|
+
const currentOrdinalCat = clue.ordinalCat;
|
|
981
|
+
if (currentOrdinalCat) {
|
|
982
|
+
for (const pClue of previouslySelectedClues) {
|
|
983
|
+
const prevOrdinalCat = pClue.ordinalCat;
|
|
984
|
+
if (currentOrdinalCat === prevOrdinalCat) {
|
|
985
|
+
repetitionScore += 0.5; // Penalize reuse of 'Age'
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// 3. Structure (Clue Type) Penalty
|
|
990
|
+
if (previouslySelectedClues.length > 0) {
|
|
991
|
+
const lastClue = previouslySelectedClues[previouslySelectedClues.length - 1];
|
|
992
|
+
// Immediate Repetition Penalty
|
|
993
|
+
if (clue.type === lastClue.type) {
|
|
994
|
+
repetitionScore += 2.0; // Strong penalty for same type
|
|
995
|
+
// Double "IS" Penalty - Binary IS clues are very powerful but boring if repeated
|
|
996
|
+
if (clue.type === types_1.ClueType.BINARY &&
|
|
997
|
+
clue.operator === types_1.BinaryOperator.IS &&
|
|
998
|
+
lastClue.operator === types_1.BinaryOperator.IS) {
|
|
999
|
+
repetitionScore += 2.0;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
// Streak Penalty (3 in a row)
|
|
1003
|
+
if (previouslySelectedClues.length > 1) {
|
|
1004
|
+
const secondLastClue = previouslySelectedClues[previouslySelectedClues.length - 2];
|
|
1005
|
+
if (clue.type === lastClue.type && clue.type === secondLastClue.type) {
|
|
1006
|
+
repetitionScore += 5.0; // Massive penalty for 3-streak
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// 4. Complexity Variance Penalty (New)
|
|
1010
|
+
const getComplexity = (c) => {
|
|
1011
|
+
switch (c.type) {
|
|
1012
|
+
case types_1.ClueType.SUPERLATIVE: return 1; // "Highest" is very direct
|
|
1013
|
+
case types_1.ClueType.BINARY:
|
|
1014
|
+
// User feedback: Binary IS is equivalent to Superlative (Direct Tick)
|
|
1015
|
+
return c.operator === types_1.BinaryOperator.IS ? 1 : 3;
|
|
1016
|
+
case types_1.ClueType.ORDINAL: return 3;
|
|
1017
|
+
case types_1.ClueType.UNARY: return 3;
|
|
1018
|
+
case types_1.ClueType.CROSS_ORDINAL: return 4; // Hardest
|
|
1019
|
+
default: return 2;
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
const currentComplexity = getComplexity(clue);
|
|
1023
|
+
const lastComplexity = getComplexity(lastClue);
|
|
1024
|
+
if (currentComplexity === lastComplexity) {
|
|
1025
|
+
repetitionScore += 1.5; // Penalize same difficulty level back-to-back
|
|
1026
|
+
}
|
|
1027
|
+
// Penalize monotonically increasing/decreasing too fast?
|
|
1028
|
+
// No, just variety is good.
|
|
1029
|
+
}
|
|
1030
|
+
const repetitionPenalty = Math.pow(0.4, repetitionScore);
|
|
1031
|
+
const score = ((synergyScore * complexityBonus) + (completenessScore * 5)) * repetitionPenalty;
|
|
1032
|
+
return score;
|
|
1033
|
+
}
|
|
1034
|
+
isPuzzleSolved(grid, solution, reverseSolution) {
|
|
1035
|
+
const categories = grid.categories;
|
|
1036
|
+
const baseCategory = categories[0];
|
|
1037
|
+
for (const cat1 of categories) {
|
|
1038
|
+
for (const val1 of cat1.values) {
|
|
1039
|
+
for (const cat2 of categories) {
|
|
1040
|
+
if (cat1.id >= cat2.id)
|
|
1041
|
+
continue;
|
|
1042
|
+
const baseVal = reverseSolution.get(cat1.id)?.get(val1);
|
|
1043
|
+
if (!baseVal)
|
|
1044
|
+
return false; // Should not happen
|
|
1045
|
+
const correctVal2 = solution[cat2.id][baseVal];
|
|
1046
|
+
if (grid.getPossibilitiesCount(cat1.id, val1, cat2.id) > 1) {
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
if (!grid.isPossible(cat1.id, val1, cat2.id, correctVal2)) {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
exports.Generator = Generator;
|