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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/dist/Clue.d.ts +81 -0
  4. package/dist/Clue.js +37 -0
  5. package/dist/Generator.d.ts +58 -0
  6. package/dist/Generator.js +433 -0
  7. package/dist/LogicGrid.d.ts +70 -0
  8. package/dist/LogicGrid.js +188 -0
  9. package/dist/Solver.d.ts +29 -0
  10. package/dist/Solver.js +242 -0
  11. package/dist/examples/benchmark.d.ts +1 -0
  12. package/dist/examples/benchmark.js +129 -0
  13. package/dist/examples/cli.d.ts +1 -0
  14. package/dist/examples/cli.js +84 -0
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.js +21 -0
  17. package/dist/run_generator.d.ts +1 -0
  18. package/dist/run_generator.js +84 -0
  19. package/dist/src/defaults.d.ts +5 -0
  20. package/dist/src/defaults.js +24 -0
  21. package/dist/src/engine/BoundsCalculator.d.ts +6 -0
  22. package/dist/src/engine/BoundsCalculator.js +40 -0
  23. package/dist/src/engine/Clue.d.ts +75 -0
  24. package/dist/src/engine/Clue.js +10 -0
  25. package/dist/src/engine/DifficultyBounds.d.ts +12 -0
  26. package/dist/src/engine/DifficultyBounds.js +67 -0
  27. package/dist/src/engine/GenerativeSession.d.ts +32 -0
  28. package/dist/src/engine/GenerativeSession.js +109 -0
  29. package/dist/src/engine/Generator.d.ts +119 -0
  30. package/dist/src/engine/Generator.js +1058 -0
  31. package/dist/src/engine/LogicGrid.d.ts +70 -0
  32. package/dist/src/engine/LogicGrid.js +190 -0
  33. package/dist/src/engine/Solver.d.ts +30 -0
  34. package/dist/src/engine/Solver.js +613 -0
  35. package/dist/src/errors.d.ts +12 -0
  36. package/dist/src/errors.js +23 -0
  37. package/dist/src/index.d.ts +8 -0
  38. package/dist/src/index.js +24 -0
  39. package/dist/src/scripts/GenerateBoundsDeprecated.d.ts +1 -0
  40. package/dist/src/scripts/GenerateBoundsDeprecated.js +27 -0
  41. package/dist/src/scripts/StressTestBacktracking.d.ts +1 -0
  42. package/dist/src/scripts/StressTestBacktracking.js +49 -0
  43. package/dist/src/types.d.ts +86 -0
  44. package/dist/src/types.js +58 -0
  45. package/dist/types.d.ts +37 -0
  46. package/dist/types.js +13 -0
  47. 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;