@wsabol/sudoku-solver 0.1.10 → 0.1.13

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.
File without changes
File without changes
package/dist/boardGeo.js CHANGED
File without changes
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ interface SolveResult {
7
7
  board: Board;
8
8
  }
9
9
  interface DescribeResult {
10
+ countGivens: number;
11
+ countEmptyCells: number;
10
12
  isValid: boolean;
11
13
  isComplete: boolean;
12
14
  message: string;
@@ -19,6 +21,7 @@ interface MoveResult {
19
21
  message: string;
20
22
  }
21
23
  declare function solve(boardInput: string | Board): SolveResult;
24
+ export declare function bruteForceSolve(board: Board): Board | null;
22
25
  declare function nextMove(boardInput: string | Board): MoveResult;
23
26
  declare function validate(boardInput: string | Board): ValidationResult;
24
27
  declare function describeBoard(boardInput: string | Board): DescribeResult;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,EAAE,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAA8C,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAElF,UAAU,WAAW;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;CAChB;AAED,UAAU,cAAc;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,UAAU;IAChB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,iBAAS,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,WAAW,CAOtD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CA0BxD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,gBAAgB,CAY9D;AAED,iBAAS,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,cAAc,CAoCjE;AAED,QAAA,MAAM,MAAM;;;;;CAKX,CAAC;AAEF,OAAO,EACH,YAAY,EACf,CAAA;AAGD,YAAY,EACR,SAAS,EACT,KAAK,EACL,IAAI,EACJ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,UAAU,EACV,cAAc,EACjB,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,EAAE,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAA8C,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAElF,UAAU,WAAW;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;CAChB;AAED,UAAU,cAAc;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,UAAU;IAChB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,iBAAS,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,WAAW,CAOtD;AAuCD,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,GAAG,IAAI,CAiB1D;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CA0BxD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,gBAAgB,CAY9D;AAED,iBAAS,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,cAAc,CAsCjE;AAED,QAAA,MAAM,MAAM;;;;;CAKX,CAAC;AAEF,OAAO,EACH,YAAY,EACf,CAAA;AAGD,YAAY,EACR,SAAS,EACT,KAAK,EACL,IAAI,EACJ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,UAAU,EACV,cAAc,EACjB,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/dist/index.js CHANGED
@@ -8,6 +8,57 @@ function solve(boardInput) {
8
8
  board: sudoku.toArray(),
9
9
  };
10
10
  }
11
+ function countSolutions(board) {
12
+ const copy = new SudokuSolver(board);
13
+ let count = 0;
14
+ function backtrack(startIdx = 0) {
15
+ // Prevent hanging on boards with many solutions (only need to know if 0, 1, or >1)
16
+ if (count >= 2)
17
+ return;
18
+ let emptyR = -1;
19
+ let emptyC = -1;
20
+ for (let i = startIdx; i < 81; i++) {
21
+ const r = Math.floor(i / 9);
22
+ const c = i % 9;
23
+ if (copy.isCellEmpty(r, c)) {
24
+ emptyR = r;
25
+ emptyC = c;
26
+ break;
27
+ }
28
+ }
29
+ if (emptyR === -1) {
30
+ count++;
31
+ return;
32
+ }
33
+ const possibles = copy.getPossibles(emptyR, emptyC);
34
+ for (const num of possibles) {
35
+ copy.setSquareValue(emptyR, emptyC, num);
36
+ backtrack(emptyR * 9 + emptyC + 1);
37
+ copy.setCellEmpty(emptyR, emptyC);
38
+ }
39
+ }
40
+ backtrack();
41
+ return count;
42
+ }
43
+ export function bruteForceSolve(board) {
44
+ const copy = new SudokuSolver(board);
45
+ for (let r = 0; r < 9; r++) {
46
+ for (let c = 0; c < 9; c++) {
47
+ if (copy.isCellEmpty(r, c)) {
48
+ for (let num = 1; num <= 9; num++) {
49
+ if (copy.getPossibles(r, c).includes(num)) {
50
+ copy.setSquareValue(r, c, num);
51
+ if (bruteForceSolve(copy.toArray()))
52
+ return copy.toArray();
53
+ copy.setCellEmpty(r, c);
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ }
59
+ }
60
+ return copy.toArray();
61
+ }
11
62
  function nextMove(boardInput) {
12
63
  const sudoku = new SudokuSolver(boardInput);
13
64
  const validation = sudoku.validate();
@@ -50,6 +101,8 @@ function describeBoard(boardInput) {
50
101
  const initValidation = sudoku.validate();
51
102
  if (!initValidation.isValid) {
52
103
  return {
104
+ countGivens: sudoku.countPlaced(),
105
+ countEmptyCells: sudoku.countEmptyCells(),
53
106
  isValid: false,
54
107
  isComplete: false,
55
108
  message: initValidation.message,
@@ -58,20 +111,20 @@ function describeBoard(boardInput) {
58
111
  };
59
112
  }
60
113
  let result = {
114
+ countGivens: sudoku.countPlaced(),
115
+ countEmptyCells: sudoku.countEmptyCells(),
61
116
  isValid: true,
62
117
  isComplete: sudoku.isComplete(),
63
118
  message: '',
64
119
  difficulty: sudoku.difficulty(),
65
- solutions: 0,
120
+ solutions: countSolutions(sudoku.toArray()),
66
121
  };
67
122
  if (result.isComplete) {
68
- result.solutions = 1;
69
123
  result.message = 'Solvable with a single solution';
70
124
  }
71
125
  else {
72
126
  sudoku.solve();
73
127
  if (sudoku.isComplete()) {
74
- result.solutions = 1;
75
128
  result.message = 'Unique Solution';
76
129
  }
77
130
  else {
package/dist/move.d.ts CHANGED
File without changes
File without changes
package/dist/move.js CHANGED
File without changes
package/dist/sudoku.d.ts CHANGED
File without changes
File without changes
package/dist/sudoku.js CHANGED
File without changes
@@ -1,7 +1,7 @@
1
1
  import { ValidationResult } from "./validate.js";
2
2
  import type { Board } from "./boardGeo.js";
3
3
  import type { Move, EliminationMove } from "./move.js";
4
- export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "X-Wing" | "XY-Wing" | "Swordfish" | "Naked Pair" | "Naked Triple" | "Naked Quad" | "Hidden Pair" | "Hidden Triple" | "Hidden Quad";
4
+ export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "Box/Line Reduction" | "X-Wing" | "XY-Wing" | "W-Wing" | "Swordfish" | "Naked Pair" | "Naked Triple" | "Naked Quad" | "Hidden Pair" | "Hidden Triple" | "Hidden Quad";
5
5
  export type DifficultyLevel = "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible";
6
6
  export type ValidationReasonType = "duplicate_in_row" | "duplicate_in_column" | "duplicate_in_box" | "invalid_value" | "invalid_board_length" | "invalid_board_characters" | "empty_cell_no_candidates" | "too_many_empty_cells";
7
7
  export default class SudokuSolver {
@@ -14,6 +14,9 @@ export default class SudokuSolver {
14
14
  getPossibles(row: number, col: number): number[];
15
15
  setPossibles(row: number, col: number, possibles: number[]): void;
16
16
  setSquareValue(row: number, col: number, value: number): void;
17
+ setCellEmpty(row: number, col: number): void;
18
+ getValue(row: number, col: number): number;
19
+ isCellEmpty(row: number, col: number): boolean;
17
20
  applyElimination(move: EliminationMove): void;
18
21
  applyMove(move: Move): void;
19
22
  isComplete(): boolean;
@@ -43,6 +46,12 @@ export default class SudokuSolver {
43
46
  private findHiddenSingleInCol;
44
47
  private findHiddenSingleInBox;
45
48
  private findPointingPairTriple;
49
+ /**
50
+ * Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
51
+ * a single box, that digit can be eliminated from the rest of that box outside the row/column.
52
+ * This is the complement of Pointing Pairs/Triples.
53
+ */
54
+ private findBoxLineReduction;
46
55
  /**
47
56
  * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
48
57
  * Returns only empty cells where `digit` is still a candidate.
@@ -56,18 +65,58 @@ export default class SudokuSolver {
56
65
  */
57
66
  private findFishOfSize;
58
67
  private cellsSeeEachOther;
68
+ /** Empty cells in `house` that have `digit` as a candidate. */
69
+ private getCandidateCellsInHouse;
70
+ /**
71
+ * All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
72
+ * `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
73
+ * Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
74
+ */
75
+ private getBivalueCells;
59
76
  /**
60
77
  * XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
61
78
  * sees both pincers. Any cell that sees both pincers cannot contain Z.
62
79
  */
63
80
  private findXYWing;
81
+ /**
82
+ * W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
83
+ * strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
84
+ * C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
85
+ * eliminated from any cell seen by both A and D.
86
+ */
87
+ private findWWing;
64
88
  private eachHouseInOrder;
89
+ /**
90
+ * Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
91
+ *
92
+ * An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
93
+ * has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
94
+ * if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
95
+ * column, and box), it appears only once.
96
+ *
97
+ * The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
98
+ * ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
99
+ * returns all bi-value cells.
100
+ */
101
+ private enumerateALS;
65
102
  private findNakedSubsetElimination;
66
103
  /** `houseCells` is one full row, column, or box (9 cells). */
67
104
  private getHouseContext;
105
+ /**
106
+ * Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
107
+ * candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
108
+ * and can be queried independently by future algorithms.
109
+ */
110
+ private findNakedLockedSetsInHouse;
68
111
  private tryNakedSubsetInHouse;
69
112
  private findHiddenSubsetElimination;
70
113
  private findNakedHiddenQuadsElimination;
114
+ /**
115
+ * Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
116
+ * only in exactly k empty cells of the house. The result drives elimination in
117
+ * `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
118
+ */
119
+ private findHiddenLockedSetsInHouse;
71
120
  private tryHiddenSubsetInHouse;
72
121
  private findHiddenSingle;
73
122
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"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"}
@@ -39,10 +39,12 @@ export default class SudokuSolver {
39
39
  "NakedSingle",
40
40
  "HiddenSingle",
41
41
  "Pointing",
42
+ "BoxLineReduction",
42
43
  "NakedSubset",
43
44
  "HiddenSubset",
44
45
  "Fish",
45
46
  "XYWing",
47
+ "WWing",
46
48
  "Swordfish",
47
49
  "NakedHiddenQuads",
48
50
  ];
@@ -70,6 +72,9 @@ export default class SudokuSolver {
70
72
  this.possiblesGrid[row][col] = possibles.filter((v) => v > 0 && v <= 9).sort((a, b) => a - b);
71
73
  }
72
74
  setSquareValue(row, col, value) {
75
+ if (value < 1 || value > 9) {
76
+ throw new Error(`Invalid value ${value} at row ${row}, col ${col}`);
77
+ }
73
78
  this.board[row][col] = value;
74
79
  this.possiblesGrid[row][col] = [];
75
80
  const ibox = this.boxIndex(row, col);
@@ -87,6 +92,27 @@ export default class SudokuSolver {
87
92
  }
88
93
  }
89
94
  }
95
+ setCellEmpty(row, col) {
96
+ this.board[row][col] = 0;
97
+ for (let i = 0; i < 9; i++) {
98
+ this.possiblesGrid[row][i] = this.calcSquarePossibles(row, i);
99
+ this.possiblesGrid[i][col] = this.calcSquarePossibles(i, col);
100
+ }
101
+ const ibox = this.boxIndex(row, col);
102
+ const startRow = Math.floor(ibox / 3) * 3;
103
+ const startCol = (ibox % 3) * 3;
104
+ for (let r = startRow; r < startRow + 3; r++) {
105
+ for (let c = startCol; c < startCol + 3; c++) {
106
+ this.possiblesGrid[r][c] = this.calcSquarePossibles(r, c);
107
+ }
108
+ }
109
+ }
110
+ getValue(row, col) {
111
+ return this.board[row][col];
112
+ }
113
+ isCellEmpty(row, col) {
114
+ return this.getValue(row, col) === 0;
115
+ }
90
116
  applyElimination(move) {
91
117
  for (const { row, col, value } of move.eliminations) {
92
118
  this.possiblesGrid[row][col] = this.possiblesGrid[row][col].filter((v) => v !== value);
@@ -325,10 +351,14 @@ export default class SudokuSolver {
325
351
  return this.findHiddenSingle();
326
352
  case "Pointing":
327
353
  return this.findPointingPairTriple();
354
+ case "BoxLineReduction":
355
+ return this.findBoxLineReduction();
328
356
  case "Fish":
329
357
  return this.findFishOfSize(2);
330
358
  case "XYWing":
331
359
  return this.findXYWing();
360
+ case "WWing":
361
+ return this.findWWing();
332
362
  case "Swordfish":
333
363
  return this.findFishOfSize(3);
334
364
  case "NakedSubset":
@@ -435,14 +465,9 @@ export default class SudokuSolver {
435
465
  return null;
436
466
  }
437
467
  findHiddenSingleInBox(ibox) {
468
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
438
469
  for (let value = 1; value <= 9; value++) {
439
- const candidates = [];
440
- for (let idx = 0; idx < 9; idx++) {
441
- const { row, col } = this.boxToPuzzle(ibox, idx);
442
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(value)) {
443
- candidates.push({ row, col });
444
- }
445
- }
470
+ const candidates = this.getCandidateCellsInHouse(boxHouse, value);
446
471
  if (candidates.length === 1) {
447
472
  const { row: r, col: c } = candidates[0];
448
473
  let algorithm = "Hidden Single";
@@ -461,14 +486,9 @@ export default class SudokuSolver {
461
486
  const boxStartRow = Math.floor(ibox / 3) * 3;
462
487
  const boxStartCol = (ibox % 3) * 3;
463
488
  const boxNum = ibox + 1;
489
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
464
490
  for (let digit = 1; digit <= 9; digit++) {
465
- const cells = [];
466
- for (let idx = 0; idx < 9; idx++) {
467
- const { row, col } = this.boxToPuzzle(ibox, idx);
468
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
469
- cells.push({ row, col });
470
- }
471
- }
491
+ const cells = this.getCandidateCellsInHouse(boxHouse, digit);
472
492
  if (cells.length < 2)
473
493
  continue;
474
494
  if (cells.every((c) => c.row === cells[0].row)) {
@@ -507,19 +527,44 @@ export default class SudokuSolver {
507
527
  }
508
528
  return null;
509
529
  }
530
+ /**
531
+ * Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
532
+ * a single box, that digit can be eliminated from the rest of that box outside the row/column.
533
+ * This is the complement of Pointing Pairs/Triples.
534
+ */
535
+ findBoxLineReduction() {
536
+ for (const byRow of [true, false]) {
537
+ const lineLabel = byRow ? "row" : "column";
538
+ for (let lineIdx = 0; lineIdx < 9; lineIdx++) {
539
+ const lineHouse = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
540
+ for (let digit = 1; digit <= 9; digit++) {
541
+ const cells = this.getCandidateCellsInHouse(lineHouse, digit);
542
+ if (cells.length < 2)
543
+ continue;
544
+ const firstBox = this.boxIndex(cells[0].row, cells[0].col);
545
+ if (!cells.every((c) => this.boxIndex(c.row, c.col) === firstBox))
546
+ continue;
547
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(firstBox, idx));
548
+ const eliminations = this.getCandidateCellsInHouse(boxHouse, digit)
549
+ .filter(({ row, col }) => byRow ? row !== lineIdx : col !== lineIdx)
550
+ .map(({ row, col }) => ({ row, col, value: digit }));
551
+ if (eliminations.length > 0) {
552
+ const boxNum = firstBox + 1;
553
+ const reasoning = `In ${lineLabel} ${lineIdx + 1}, all candidates for ${digit} lie within box ${boxNum}, so ${digit} cannot appear elsewhere in box ${boxNum} outside that ${lineLabel}.`;
554
+ return this.finalizeElimination(eliminations, "Box/Line Reduction", reasoning);
555
+ }
556
+ }
557
+ }
558
+ }
559
+ return null;
560
+ }
510
561
  /**
511
562
  * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
512
563
  * Returns only empty cells where `digit` is still a candidate.
513
564
  */
514
565
  coverIndicesForLine(lineIdx, digit, byRow) {
515
- const result = [];
516
- for (let crossIdx = 0; crossIdx < 9; crossIdx++) {
517
- const [row, col] = byRow ? [lineIdx, crossIdx] : [crossIdx, lineIdx];
518
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
519
- result.push(crossIdx);
520
- }
521
- }
522
- return result;
566
+ const house = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
567
+ return this.getCandidateCellsInHouse(house, digit).map(({ row, col }) => byRow ? col : row);
523
568
  }
524
569
  /**
525
570
  * Generic N-fish detector (X-Wing = 2, Swordfish = 3).
@@ -578,20 +623,28 @@ export default class SudokuSolver {
578
623
  cellsSeeEachOther(r1, c1, r2, c2) {
579
624
  return r1 === r2 || c1 === c2 || this.boxIndex(r1, c1) === this.boxIndex(r2, c2);
580
625
  }
626
+ /** Empty cells in `house` that have `digit` as a candidate. */
627
+ getCandidateCellsInHouse(house, digit) {
628
+ return house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit));
629
+ }
630
+ /**
631
+ * All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
632
+ * `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
633
+ * Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
634
+ */
635
+ getBivalueCells() {
636
+ return this.enumerateALS(1).map(({ cells, digits }) => {
637
+ const [a, b] = [...digits].sort((x, y) => x - y);
638
+ const { row, col } = cells[0];
639
+ return { row, col, a, b };
640
+ });
641
+ }
581
642
  /**
582
643
  * XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
583
644
  * sees both pincers. Any cell that sees both pincers cannot contain Z.
584
645
  */
585
646
  findXYWing() {
586
- const bivalue = [];
587
- for (let row = 0; row < 9; row++) {
588
- for (let col = 0; col < 9; col++) {
589
- const p = this.possiblesGrid[row][col];
590
- if (this.board[row][col] === 0 && p.length === 2) {
591
- bivalue.push({ row, col, a: p[0], b: p[1] });
592
- }
593
- }
594
- }
647
+ const bivalue = this.getBivalueCells();
595
648
  for (const pivot of bivalue) {
596
649
  const X = pivot.a;
597
650
  const Y = pivot.b;
@@ -652,6 +705,92 @@ export default class SudokuSolver {
652
705
  }
653
706
  return null;
654
707
  }
708
+ /**
709
+ * W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
710
+ * strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
711
+ * C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
712
+ * eliminated from any cell seen by both A and D.
713
+ */
714
+ findWWing() {
715
+ const bivalue = this.getBivalueCells();
716
+ if (bivalue.length < 2)
717
+ return null;
718
+ const strongLinks = Array.from({ length: 10 }, () => []);
719
+ for (const house of this.eachHouseInOrder()) {
720
+ for (let d = 1; d <= 9; d++) {
721
+ const cands = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d));
722
+ if (cands.length === 2) {
723
+ strongLinks[d].push([cands[0], cands[1]]);
724
+ }
725
+ }
726
+ }
727
+ for (let i = 0; i < bivalue.length; i++) {
728
+ for (let j = i + 1; j < bivalue.length; j++) {
729
+ const A = bivalue[i];
730
+ const D = bivalue[j];
731
+ if (A.a !== D.a || A.b !== D.b)
732
+ continue;
733
+ const W = A.a;
734
+ const X = A.b;
735
+ // Try each digit as the link digit; the other is the digit to eliminate.
736
+ for (const [linkDigit, elimDigit] of [[X, W], [W, X]]) {
737
+ for (const [B, C] of strongLinks[linkDigit]) {
738
+ // B and C must not coincide with A or D (endpoints already have both digits).
739
+ if ((B.row === A.row && B.col === A.col) ||
740
+ (B.row === D.row && B.col === D.col) ||
741
+ (C.row === A.row && C.col === A.col) ||
742
+ (C.row === D.row && C.col === D.col))
743
+ continue;
744
+ // The chain is A -(weak)- B =X= C -(weak)- D.
745
+ // Check orientation: A sees B and C sees D, or A sees C and B sees D.
746
+ let nearA = null;
747
+ let nearD = null;
748
+ if (this.cellsSeeEachOther(A.row, A.col, B.row, B.col) &&
749
+ this.cellsSeeEachOther(C.row, C.col, D.row, D.col)) {
750
+ nearA = B;
751
+ nearD = C;
752
+ }
753
+ else if (this.cellsSeeEachOther(A.row, A.col, C.row, C.col) &&
754
+ this.cellsSeeEachOther(B.row, B.col, D.row, D.col)) {
755
+ nearA = C;
756
+ nearD = B;
757
+ }
758
+ if (!nearA || !nearD)
759
+ continue;
760
+ const eliminations = [];
761
+ for (let row = 0; row < 9; row++) {
762
+ for (let col = 0; col < 9; col++) {
763
+ if (row === A.row && col === A.col)
764
+ continue;
765
+ if (row === D.row && col === D.col)
766
+ continue;
767
+ if (this.board[row][col] !== 0)
768
+ continue;
769
+ if (!this.possiblesGrid[row][col].includes(elimDigit))
770
+ continue;
771
+ if (this.cellsSeeEachOther(row, col, A.row, A.col) &&
772
+ this.cellsSeeEachOther(row, col, D.row, D.col)) {
773
+ eliminations.push({ row, col, value: elimDigit });
774
+ }
775
+ }
776
+ }
777
+ if (eliminations.length > 0) {
778
+ const reasoning = `W-Wing: r${A.row + 1}c${A.col + 1} and r${D.row + 1}c${D.col + 1} ` +
779
+ `both have candidates {${W}/${X}}. ` +
780
+ `r${nearA.row + 1}c${nearA.col + 1} and r${nearD.row + 1}c${nearD.col + 1} ` +
781
+ `form a strong link on ${linkDigit}. ` +
782
+ `r${A.row + 1}c${A.col + 1} sees r${nearA.row + 1}c${nearA.col + 1} ` +
783
+ `and r${D.row + 1}c${D.col + 1} sees r${nearD.row + 1}c${nearD.col + 1}, ` +
784
+ `so ${elimDigit} must appear in one of the two wing cells, ` +
785
+ `eliminating ${elimDigit} from any cell seen by both.`;
786
+ return this.finalizeElimination(eliminations, "W-Wing", reasoning);
787
+ }
788
+ }
789
+ }
790
+ }
791
+ }
792
+ return null;
793
+ }
655
794
  eachHouseInOrder() {
656
795
  const houses = [];
657
796
  for (let r = 0; r < 9; r++) {
@@ -669,6 +808,66 @@ export default class SudokuSolver {
669
808
  }
670
809
  return houses;
671
810
  }
811
+ /**
812
+ * Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
813
+ *
814
+ * An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
815
+ * has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
816
+ * if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
817
+ * column, and box), it appears only once.
818
+ *
819
+ * The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
820
+ * ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
821
+ * returns all bi-value cells.
822
+ */
823
+ enumerateALS(maxSize = 4) {
824
+ const seen = new Set();
825
+ const result = [];
826
+ for (const house of this.eachHouseInOrder()) {
827
+ const empties = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
828
+ const indices = [];
829
+ const digits = new Set();
830
+ const dfs = (start) => {
831
+ if (indices.length > 0) {
832
+ if (digits.size === indices.length + 1) {
833
+ const cells = indices.map((i) => empties[i]);
834
+ const key = cells
835
+ .map(({ row, col }) => row * 9 + col)
836
+ .sort((a, b) => a - b)
837
+ .join(",");
838
+ if (!seen.has(key)) {
839
+ seen.add(key);
840
+ result.push({ cells, digits: new Set(digits) });
841
+ }
842
+ }
843
+ // Prune: digit count already exceeds ALS threshold; adding more cells can only
844
+ // increase both the cell count and the digit count by the same amount or more,
845
+ // so the invariant digits.size === cells.length + 1 cannot be recovered.
846
+ if (digits.size > indices.length + 1)
847
+ return;
848
+ }
849
+ if (indices.length === maxSize)
850
+ return;
851
+ for (let i = start; i < empties.length; i++) {
852
+ const { row, col } = empties[i];
853
+ const added = [];
854
+ for (const d of this.possiblesGrid[row][col]) {
855
+ if (!digits.has(d)) {
856
+ digits.add(d);
857
+ added.push(d);
858
+ }
859
+ }
860
+ indices.push(i);
861
+ dfs(i + 1);
862
+ indices.pop();
863
+ for (const d of added)
864
+ digits.delete(d);
865
+ }
866
+ };
867
+ dfs(0);
868
+ }
869
+ return result;
870
+ }
672
871
  findNakedSubsetElimination() {
673
872
  for (const houseCells of this.eachHouseInOrder()) {
674
873
  for (let k = 2; k <= 3; k++) {
@@ -688,57 +887,69 @@ export default class SudokuSolver {
688
887
  const h = houseCells[0];
689
888
  return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
690
889
  }
691
- tryNakedSubsetInHouse(houseCells, k) {
890
+ /**
891
+ * Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
892
+ * candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
893
+ * and can be queried independently by future algorithms.
894
+ */
895
+ findNakedLockedSetsInHouse(houseCells, k) {
692
896
  const empties = houseCells.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
693
- if (empties.length < k) {
694
- return null;
695
- }
696
- const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
697
- const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
698
- const n = empties.length;
897
+ if (empties.length < k)
898
+ return [];
899
+ const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
900
+ const result = [];
699
901
  const indices = [];
902
+ const union = new Set();
700
903
  const dfs = (start) => {
701
904
  if (indices.length === k) {
702
- const subset = indices.map((i) => empties[i]);
703
- const union = new Set();
704
- for (const { row, col } of subset) {
705
- for (const v of this.possiblesGrid[row][col]) {
706
- union.add(v);
707
- }
905
+ if (union.size === k) {
906
+ result.push({ cells: indices.map((i) => empties[i]), digits: new Set(union), house, wherePhrase });
708
907
  }
709
- if (union.size !== k) {
710
- return null;
711
- }
712
- const subsetKeys = new Set(subset.map(({ row, col }) => row * 9 + col));
713
- const eliminations = [];
714
- for (const { row, col } of houseCells) {
715
- if (this.board[row][col] !== 0 || subsetKeys.has(row * 9 + col)) {
716
- continue;
717
- }
718
- const poss = this.possiblesGrid[row][col];
719
- for (const d of union) {
720
- if (poss.includes(d)) {
721
- eliminations.push({ row, col, value: d });
722
- }
908
+ return;
909
+ }
910
+ // Prune: digit count already exceeds k; adding more cells can't reduce it.
911
+ if (union.size > k)
912
+ return;
913
+ for (let i = start; i < empties.length; i++) {
914
+ const { row, col } = empties[i];
915
+ const added = [];
916
+ for (const v of this.possiblesGrid[row][col]) {
917
+ if (!union.has(v)) {
918
+ union.add(v);
919
+ added.push(v);
723
920
  }
724
921
  }
725
- if (eliminations.length === 0) {
726
- return null;
727
- }
728
- const digitStr = [...union].sort((a, b) => a - b).join("/");
729
- const reasoning = `${algorithm} ${digitStr} in ${wherePhrase} means those digits can be eliminated from the other cells in that same ${sameKindWord}.`;
730
- return this.finalizeElimination(eliminations, algorithm, reasoning);
731
- }
732
- for (let i = start; i < n; i++) {
733
922
  indices.push(i);
734
- const res = dfs(i + 1);
923
+ dfs(i + 1);
735
924
  indices.pop();
736
- if (res)
737
- return res;
925
+ for (const v of added)
926
+ union.delete(v);
738
927
  }
739
- return null;
740
928
  };
741
- return dfs(0);
929
+ dfs(0);
930
+ return result;
931
+ }
932
+ tryNakedSubsetInHouse(houseCells, k) {
933
+ const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
934
+ for (const ls of this.findNakedLockedSetsInHouse(houseCells, k)) {
935
+ const subsetKeys = new Set(ls.cells.map(({ row, col }) => row * 9 + col));
936
+ const eliminations = [];
937
+ for (const { row, col } of houseCells) {
938
+ if (this.board[row][col] !== 0 || subsetKeys.has(row * 9 + col))
939
+ continue;
940
+ for (const d of ls.digits) {
941
+ if (this.possiblesGrid[row][col].includes(d)) {
942
+ eliminations.push({ row, col, value: d });
943
+ }
944
+ }
945
+ }
946
+ if (eliminations.length > 0) {
947
+ const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
948
+ const reasoning = `${algorithm} ${digitStr} in ${ls.wherePhrase} means those digits can be eliminated from the other cells in that same ${ls.house}.`;
949
+ return this.finalizeElimination(eliminations, algorithm, reasoning);
950
+ }
951
+ }
952
+ return null;
742
953
  }
743
954
  findHiddenSubsetElimination() {
744
955
  for (const houseCells of this.eachHouseInOrder()) {
@@ -761,67 +972,59 @@ export default class SudokuSolver {
761
972
  }
762
973
  return null;
763
974
  }
764
- tryHiddenSubsetInHouse(houseCells, k) {
765
- const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
766
- const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
975
+ /**
976
+ * Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
977
+ * only in exactly k empty cells of the house. The result drives elimination in
978
+ * `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
979
+ */
980
+ findHiddenLockedSetsInHouse(houseCells, k) {
981
+ const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
982
+ const result = [];
767
983
  const digitIndices = [];
768
- const dfsDigits = (start) => {
984
+ const dfs = (start) => {
769
985
  if (digitIndices.length === k) {
770
986
  const digits = digitIndices.map((i) => COMPLETE[i]);
771
- const digitSet = new Set(digits);
772
- for (const d of digits) {
773
- let seen = false;
774
- for (const { row, col } of houseCells) {
775
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)) {
776
- seen = true;
777
- break;
778
- }
779
- }
780
- if (!seen) {
781
- return null;
782
- }
783
- }
784
- const reserved = [];
785
- for (const { row, col } of houseCells) {
786
- if (this.board[row][col] !== 0) {
787
- continue;
788
- }
789
- for (const d of digits) {
790
- if (this.possiblesGrid[row][col].includes(d)) {
791
- reserved.push({ row, col });
792
- break;
793
- }
794
- }
795
- }
796
- if (reserved.length !== k) {
797
- return null;
987
+ // Every chosen digit must appear in at least one empty cell of the house.
988
+ const allPresent = digits.every((d) => houseCells.some(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)));
989
+ if (!allPresent)
990
+ return;
991
+ // Cells that contain at least one of the k digits — the "reserved" cells.
992
+ const cells = houseCells.filter(({ row, col }) => this.board[row][col] === 0 &&
993
+ digits.some((d) => this.possiblesGrid[row][col].includes(d)));
994
+ // Hidden set condition: the k digits are confined to exactly k cells.
995
+ if (cells.length === k) {
996
+ result.push({ cells, digits: new Set(digits), house, wherePhrase });
798
997
  }
799
- const eliminations = [];
800
- for (const { row, col } of reserved) {
801
- for (const v of this.possiblesGrid[row][col]) {
802
- if (!digitSet.has(v)) {
803
- eliminations.push({ row, col, value: v });
804
- }
805
- }
806
- }
807
- if (eliminations.length === 0) {
808
- return null;
809
- }
810
- const digitStr = [...digits].sort((a, b) => a - b).join("/");
811
- const cellPhrase = reserved.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
812
- const reasoning = `${algorithm} ${digitStr} in ${wherePhrase}: in that ${sameKindWord}, those digits appear only in ${cellPhrase}, so candidates other than ${digitStr} can be removed from those cells.`;
813
- return this.finalizeElimination(eliminations, algorithm, reasoning);
998
+ return;
814
999
  }
815
1000
  for (let i = start; i < 9; i++) {
816
1001
  digitIndices.push(i);
817
- const res = dfsDigits(i + 1);
1002
+ dfs(i + 1);
818
1003
  digitIndices.pop();
819
- if (res)
820
- return res;
821
1004
  }
822
- return null;
823
1005
  };
824
- return dfsDigits(0);
1006
+ dfs(0);
1007
+ return result;
1008
+ }
1009
+ tryHiddenSubsetInHouse(houseCells, k) {
1010
+ const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
1011
+ for (const ls of this.findHiddenLockedSetsInHouse(houseCells, k)) {
1012
+ const eliminations = [];
1013
+ for (const { row, col } of ls.cells) {
1014
+ for (const v of this.possiblesGrid[row][col]) {
1015
+ if (!ls.digits.has(v)) {
1016
+ eliminations.push({ row, col, value: v });
1017
+ }
1018
+ }
1019
+ }
1020
+ if (eliminations.length > 0) {
1021
+ const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
1022
+ const cellPhrase = ls.cells.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
1023
+ const reasoning = `${algorithm} ${digitStr} in ${ls.wherePhrase}: in that ${ls.house}, those digits appear only in ${cellPhrase}, so candidates other than ${digitStr} can be removed from those cells.`;
1024
+ return this.finalizeElimination(eliminations, algorithm, reasoning);
1025
+ }
1026
+ }
1027
+ return null;
825
1028
  }
826
1029
  findHiddenSingle() {
827
1030
  for (let row = 0; row < 9; row++) {
package/dist/utils.d.ts CHANGED
File without changes
File without changes
package/dist/utils.js CHANGED
File without changes
File without changes
File without changes
package/dist/validate.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wsabol/sudoku-solver",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
4
4
  "description": "TypeScript Sudoku solver module with solve, next move, describe, and validate APIs.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",