@wsabol/sudoku-solver 0.1.9 → 0.1.12

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.
@@ -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" | "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 {
@@ -43,6 +43,12 @@ export default class SudokuSolver {
43
43
  private findHiddenSingleInCol;
44
44
  private findHiddenSingleInBox;
45
45
  private findPointingPairTriple;
46
+ /**
47
+ * Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
48
+ * a single box, that digit can be eliminated from the rest of that box outside the row/column.
49
+ * This is the complement of Pointing Pairs/Triples.
50
+ */
51
+ private findBoxLineReduction;
46
52
  /**
47
53
  * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
48
54
  * Returns only empty cells where `digit` is still a candidate.
@@ -55,13 +61,59 @@ export default class SudokuSolver {
55
61
  * Checks row-based orientation first, then column-based.
56
62
  */
57
63
  private findFishOfSize;
64
+ private cellsSeeEachOther;
65
+ /** Empty cells in `house` that have `digit` as a candidate. */
66
+ private getCandidateCellsInHouse;
67
+ /**
68
+ * All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
69
+ * `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
70
+ * Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
71
+ */
72
+ private getBivalueCells;
73
+ /**
74
+ * XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
75
+ * sees both pincers. Any cell that sees both pincers cannot contain Z.
76
+ */
77
+ private findXYWing;
78
+ /**
79
+ * W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
80
+ * strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
81
+ * C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
82
+ * eliminated from any cell seen by both A and D.
83
+ */
84
+ private findWWing;
58
85
  private eachHouseInOrder;
86
+ /**
87
+ * Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
88
+ *
89
+ * An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
90
+ * has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
91
+ * if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
92
+ * column, and box), it appears only once.
93
+ *
94
+ * The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
95
+ * ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
96
+ * returns all bi-value cells.
97
+ */
98
+ private enumerateALS;
59
99
  private findNakedSubsetElimination;
60
100
  /** `houseCells` is one full row, column, or box (9 cells). */
61
101
  private getHouseContext;
102
+ /**
103
+ * Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
104
+ * candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
105
+ * and can be queried independently by future algorithms.
106
+ */
107
+ private findNakedLockedSetsInHouse;
62
108
  private tryNakedSubsetInHouse;
63
109
  private findHiddenSubsetElimination;
64
110
  private findNakedHiddenQuadsElimination;
111
+ /**
112
+ * Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
113
+ * only in exactly k empty cells of the house. The result drives elimination in
114
+ * `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
115
+ */
116
+ private findHiddenLockedSetsInHouse;
65
117
  private tryHiddenSubsetInHouse;
66
118
  private findHiddenSingle;
67
119
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,gBAAgB,EAAoB,MAAM,eAAe,CAAC;AACrF,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAiB,eAAe,EAAE,MAAM,WAAW,CAAC;AAEtE,MAAM,MAAM,SAAS,GACf,YAAY,GACZ,YAAY,GACZ,cAAc,GACd,eAAe,GACf,eAAe,GACf,iBAAiB,GACjB,QAAQ,GACR,WAAW,GACX,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,aAAa,GACb,eAAe,GACf,aAAa,CAAC;AAEpB,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,YAAY,GAAG,YAAY,CAAC;AAEvF,MAAM,MAAM,oBAAoB,GAC1B,kBAAkB,GAClB,qBAAqB,GACrB,kBAAkB,GAClB,eAAe,GACf,sBAAsB,GACtB,0BAA0B,GAC1B,0BAA0B,GAC1B,sBAAsB,CAAC;AAuD7B,MAAM,CAAC,OAAO,OAAO,YAAY;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CASnC;IAEF,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,aAAa,CAAe;gBAExB,KAAK,EAAE,MAAM,GAAG,KAAK;IAOjC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAS5B,OAAO,IAAI,KAAK;IAIhB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE;IAIhD,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI;IAIjE,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAmB7D,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI;IAM7C,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI;IAQ3B,UAAU,IAAI,OAAO;IAIrB,eAAe,IAAI,MAAM;IAIzB,WAAW,CAAC,KAAK,GAAE,MAAU,GAAG,MAAM;IAOtC,QAAQ,IAAI,gBAAgB;IAiG5B,OAAO,IAAI,OAAO;IAKlB,UAAU,IAAI,eAAe;IAiB7B,WAAW,IAAI,IAAI,GAAG,IAAI;IAO1B,KAAK,IAAI,OAAO;IAchB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,MAAM;IAYd,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,gBAAgB;IAuBxB,OAAO,CAAC,eAAe;IA+CvB,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,qBAAqB;IAuB7B,OAAO,CAAC,sBAAsB;IAqD9B;;;OAGG;IACH,OAAO,CAAC,mBAAmB;IAW3B;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IAqDtB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,0BAA0B;IAUlC,8DAA8D;IAC9D,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;IAwD7B,OAAO,CAAC,2BAA2B;IAUnC,OAAO,CAAC,+BAA+B;IAWvC,OAAO,CAAC,sBAAsB;IAgE9B,OAAO,CAAC,gBAAgB;CAe3B"}
1
+ {"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"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"}
@@ -39,9 +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",
46
+ "XYWing",
47
+ "WWing",
45
48
  "Swordfish",
46
49
  "NakedHiddenQuads",
47
50
  ];
@@ -324,8 +327,14 @@ export default class SudokuSolver {
324
327
  return this.findHiddenSingle();
325
328
  case "Pointing":
326
329
  return this.findPointingPairTriple();
330
+ case "BoxLineReduction":
331
+ return this.findBoxLineReduction();
327
332
  case "Fish":
328
333
  return this.findFishOfSize(2);
334
+ case "XYWing":
335
+ return this.findXYWing();
336
+ case "WWing":
337
+ return this.findWWing();
329
338
  case "Swordfish":
330
339
  return this.findFishOfSize(3);
331
340
  case "NakedSubset":
@@ -432,14 +441,9 @@ export default class SudokuSolver {
432
441
  return null;
433
442
  }
434
443
  findHiddenSingleInBox(ibox) {
444
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
435
445
  for (let value = 1; value <= 9; value++) {
436
- const candidates = [];
437
- for (let idx = 0; idx < 9; idx++) {
438
- const { row, col } = this.boxToPuzzle(ibox, idx);
439
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(value)) {
440
- candidates.push({ row, col });
441
- }
442
- }
446
+ const candidates = this.getCandidateCellsInHouse(boxHouse, value);
443
447
  if (candidates.length === 1) {
444
448
  const { row: r, col: c } = candidates[0];
445
449
  let algorithm = "Hidden Single";
@@ -458,14 +462,9 @@ export default class SudokuSolver {
458
462
  const boxStartRow = Math.floor(ibox / 3) * 3;
459
463
  const boxStartCol = (ibox % 3) * 3;
460
464
  const boxNum = ibox + 1;
465
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
461
466
  for (let digit = 1; digit <= 9; digit++) {
462
- const cells = [];
463
- for (let idx = 0; idx < 9; idx++) {
464
- const { row, col } = this.boxToPuzzle(ibox, idx);
465
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
466
- cells.push({ row, col });
467
- }
468
- }
467
+ const cells = this.getCandidateCellsInHouse(boxHouse, digit);
469
468
  if (cells.length < 2)
470
469
  continue;
471
470
  if (cells.every((c) => c.row === cells[0].row)) {
@@ -504,19 +503,44 @@ export default class SudokuSolver {
504
503
  }
505
504
  return null;
506
505
  }
506
+ /**
507
+ * Box/Line Reduction (Claiming): if all candidates for a digit in a row or column lie within
508
+ * a single box, that digit can be eliminated from the rest of that box outside the row/column.
509
+ * This is the complement of Pointing Pairs/Triples.
510
+ */
511
+ findBoxLineReduction() {
512
+ for (const byRow of [true, false]) {
513
+ const lineLabel = byRow ? "row" : "column";
514
+ for (let lineIdx = 0; lineIdx < 9; lineIdx++) {
515
+ const lineHouse = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
516
+ for (let digit = 1; digit <= 9; digit++) {
517
+ const cells = this.getCandidateCellsInHouse(lineHouse, digit);
518
+ if (cells.length < 2)
519
+ continue;
520
+ const firstBox = this.boxIndex(cells[0].row, cells[0].col);
521
+ if (!cells.every((c) => this.boxIndex(c.row, c.col) === firstBox))
522
+ continue;
523
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(firstBox, idx));
524
+ const eliminations = this.getCandidateCellsInHouse(boxHouse, digit)
525
+ .filter(({ row, col }) => byRow ? row !== lineIdx : col !== lineIdx)
526
+ .map(({ row, col }) => ({ row, col, value: digit }));
527
+ if (eliminations.length > 0) {
528
+ const boxNum = firstBox + 1;
529
+ 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}.`;
530
+ return this.finalizeElimination(eliminations, "Box/Line Reduction", reasoning);
531
+ }
532
+ }
533
+ }
534
+ }
535
+ return null;
536
+ }
507
537
  /**
508
538
  * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
509
539
  * Returns only empty cells where `digit` is still a candidate.
510
540
  */
511
541
  coverIndicesForLine(lineIdx, digit, byRow) {
512
- const result = [];
513
- for (let crossIdx = 0; crossIdx < 9; crossIdx++) {
514
- const [row, col] = byRow ? [lineIdx, crossIdx] : [crossIdx, lineIdx];
515
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
516
- result.push(crossIdx);
517
- }
518
- }
519
- return result;
542
+ const house = Array.from({ length: 9 }, (_, crossIdx) => byRow ? { row: lineIdx, col: crossIdx } : { row: crossIdx, col: lineIdx });
543
+ return this.getCandidateCellsInHouse(house, digit).map(({ row, col }) => byRow ? col : row);
520
544
  }
521
545
  /**
522
546
  * Generic N-fish detector (X-Wing = 2, Swordfish = 3).
@@ -572,6 +596,177 @@ export default class SudokuSolver {
572
596
  }
573
597
  return null;
574
598
  }
599
+ cellsSeeEachOther(r1, c1, r2, c2) {
600
+ return r1 === r2 || c1 === c2 || this.boxIndex(r1, c1) === this.boxIndex(r2, c2);
601
+ }
602
+ /** Empty cells in `house` that have `digit` as a candidate. */
603
+ getCandidateCellsInHouse(house, digit) {
604
+ return house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit));
605
+ }
606
+ /**
607
+ * All empty cells with exactly two candidates (ALS of size 1), as a convenience view over
608
+ * `enumerateALS(1)`. The `a`/`b` fields are the two candidates in sorted order.
609
+ * Used by XY-Wing and W-Wing; future ALS-based techniques should consume `enumerateALS()`.
610
+ */
611
+ getBivalueCells() {
612
+ return this.enumerateALS(1).map(({ cells, digits }) => {
613
+ const [a, b] = [...digits].sort((x, y) => x - y);
614
+ const { row, col } = cells[0];
615
+ return { row, col, a, b };
616
+ });
617
+ }
618
+ /**
619
+ * XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
620
+ * sees both pincers. Any cell that sees both pincers cannot contain Z.
621
+ */
622
+ findXYWing() {
623
+ const bivalue = this.getBivalueCells();
624
+ for (const pivot of bivalue) {
625
+ const X = pivot.a;
626
+ const Y = pivot.b;
627
+ // Candidate pincers: bi-value cells that see the pivot and share exactly one digit with it.
628
+ const pincersX = [];
629
+ const pincersY = [];
630
+ for (const cell of bivalue) {
631
+ if (cell.row === pivot.row && cell.col === pivot.col)
632
+ continue;
633
+ if (!this.cellsSeeEachOther(pivot.row, pivot.col, cell.row, cell.col))
634
+ continue;
635
+ const { a, b } = cell;
636
+ // Cell has {X, Z} where Z ≠ Y
637
+ if (a === X && b !== Y)
638
+ pincersX.push({ row: cell.row, col: cell.col, Z: b });
639
+ if (b === X && a !== Y)
640
+ pincersX.push({ row: cell.row, col: cell.col, Z: a });
641
+ // Cell has {Y, Z} where Z ≠ X
642
+ if (a === Y && b !== X)
643
+ pincersY.push({ row: cell.row, col: cell.col, Z: b });
644
+ if (b === Y && a !== X)
645
+ pincersY.push({ row: cell.row, col: cell.col, Z: a });
646
+ }
647
+ for (const p1 of pincersX) {
648
+ for (const p2 of pincersY) {
649
+ if (p1.Z !== p2.Z)
650
+ continue;
651
+ if (p1.row === p2.row && p1.col === p2.col)
652
+ continue;
653
+ const Z = p1.Z;
654
+ const eliminations = [];
655
+ for (let row = 0; row < 9; row++) {
656
+ for (let col = 0; col < 9; col++) {
657
+ if (row === p1.row && col === p1.col)
658
+ continue;
659
+ if (row === p2.row && col === p2.col)
660
+ continue;
661
+ if (this.board[row][col] !== 0)
662
+ continue;
663
+ if (!this.possiblesGrid[row][col].includes(Z))
664
+ continue;
665
+ if (this.cellsSeeEachOther(row, col, p1.row, p1.col) &&
666
+ this.cellsSeeEachOther(row, col, p2.row, p2.col)) {
667
+ eliminations.push({ row, col, value: Z });
668
+ }
669
+ }
670
+ }
671
+ if (eliminations.length > 0) {
672
+ const reasoning = `XY-Wing: pivot r${pivot.row + 1}c${pivot.col + 1} (${X}/${Y}) links ` +
673
+ `pincers r${p1.row + 1}c${p1.col + 1} (${X}/${Z}) and ` +
674
+ `r${p2.row + 1}c${p2.col + 1} (${Y}/${Z}). ` +
675
+ `Whatever value the pivot takes, ${Z} must appear in one of the pincers, ` +
676
+ `so ${Z} cannot appear in any cell seen by both.`;
677
+ return this.finalizeElimination(eliminations, "XY-Wing", reasoning);
678
+ }
679
+ }
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+ /**
685
+ * W-Wing: two bi-value cells A and D sharing the same candidate pair {W, X}, connected by a
686
+ * strong link on one of those digits (X) through cells B and C (A sees B, B=X=C strong link,
687
+ * C sees D). Whatever value A takes, W must appear in one of the two endpoints, so W can be
688
+ * eliminated from any cell seen by both A and D.
689
+ */
690
+ findWWing() {
691
+ const bivalue = this.getBivalueCells();
692
+ if (bivalue.length < 2)
693
+ return null;
694
+ const strongLinks = Array.from({ length: 10 }, () => []);
695
+ for (const house of this.eachHouseInOrder()) {
696
+ for (let d = 1; d <= 9; d++) {
697
+ const cands = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d));
698
+ if (cands.length === 2) {
699
+ strongLinks[d].push([cands[0], cands[1]]);
700
+ }
701
+ }
702
+ }
703
+ for (let i = 0; i < bivalue.length; i++) {
704
+ for (let j = i + 1; j < bivalue.length; j++) {
705
+ const A = bivalue[i];
706
+ const D = bivalue[j];
707
+ if (A.a !== D.a || A.b !== D.b)
708
+ continue;
709
+ const W = A.a;
710
+ const X = A.b;
711
+ // Try each digit as the link digit; the other is the digit to eliminate.
712
+ for (const [linkDigit, elimDigit] of [[X, W], [W, X]]) {
713
+ for (const [B, C] of strongLinks[linkDigit]) {
714
+ // B and C must not coincide with A or D (endpoints already have both digits).
715
+ if ((B.row === A.row && B.col === A.col) ||
716
+ (B.row === D.row && B.col === D.col) ||
717
+ (C.row === A.row && C.col === A.col) ||
718
+ (C.row === D.row && C.col === D.col))
719
+ continue;
720
+ // The chain is A -(weak)- B =X= C -(weak)- D.
721
+ // Check orientation: A sees B and C sees D, or A sees C and B sees D.
722
+ let nearA = null;
723
+ let nearD = null;
724
+ if (this.cellsSeeEachOther(A.row, A.col, B.row, B.col) &&
725
+ this.cellsSeeEachOther(C.row, C.col, D.row, D.col)) {
726
+ nearA = B;
727
+ nearD = C;
728
+ }
729
+ else if (this.cellsSeeEachOther(A.row, A.col, C.row, C.col) &&
730
+ this.cellsSeeEachOther(B.row, B.col, D.row, D.col)) {
731
+ nearA = C;
732
+ nearD = B;
733
+ }
734
+ if (!nearA || !nearD)
735
+ continue;
736
+ const eliminations = [];
737
+ for (let row = 0; row < 9; row++) {
738
+ for (let col = 0; col < 9; col++) {
739
+ if (row === A.row && col === A.col)
740
+ continue;
741
+ if (row === D.row && col === D.col)
742
+ continue;
743
+ if (this.board[row][col] !== 0)
744
+ continue;
745
+ if (!this.possiblesGrid[row][col].includes(elimDigit))
746
+ continue;
747
+ if (this.cellsSeeEachOther(row, col, A.row, A.col) &&
748
+ this.cellsSeeEachOther(row, col, D.row, D.col)) {
749
+ eliminations.push({ row, col, value: elimDigit });
750
+ }
751
+ }
752
+ }
753
+ if (eliminations.length > 0) {
754
+ const reasoning = `W-Wing: r${A.row + 1}c${A.col + 1} and r${D.row + 1}c${D.col + 1} ` +
755
+ `both have candidates {${W}/${X}}. ` +
756
+ `r${nearA.row + 1}c${nearA.col + 1} and r${nearD.row + 1}c${nearD.col + 1} ` +
757
+ `form a strong link on ${linkDigit}. ` +
758
+ `r${A.row + 1}c${A.col + 1} sees r${nearA.row + 1}c${nearA.col + 1} ` +
759
+ `and r${D.row + 1}c${D.col + 1} sees r${nearD.row + 1}c${nearD.col + 1}, ` +
760
+ `so ${elimDigit} must appear in one of the two wing cells, ` +
761
+ `eliminating ${elimDigit} from any cell seen by both.`;
762
+ return this.finalizeElimination(eliminations, "W-Wing", reasoning);
763
+ }
764
+ }
765
+ }
766
+ }
767
+ }
768
+ return null;
769
+ }
575
770
  eachHouseInOrder() {
576
771
  const houses = [];
577
772
  for (let r = 0; r < 9; r++) {
@@ -589,6 +784,66 @@ export default class SudokuSolver {
589
784
  }
590
785
  return houses;
591
786
  }
787
+ /**
788
+ * Enumerate all Almost Locked Sets (ALS) of sizes 1..maxSize across all 27 houses.
789
+ *
790
+ * An ALS of size N is a set of N cells (all lying in one house) whose union of candidates
791
+ * has exactly N+1 digits. A bi-value cell is an ALS of size 1. Results are deduplicated:
792
+ * if the same cell set is found via multiple houses (e.g. a bi-value cell appears in its row,
793
+ * column, and box), it appears only once.
794
+ *
795
+ * The returned collection is the shared primitive for ALS-based techniques (ALS-XZ Rule,
796
+ * ALS-XY-Wing, ALS-Chain) and makes `getBivalueCells()` a special case: `enumerateALS(1)`
797
+ * returns all bi-value cells.
798
+ */
799
+ enumerateALS(maxSize = 4) {
800
+ const seen = new Set();
801
+ const result = [];
802
+ for (const house of this.eachHouseInOrder()) {
803
+ const empties = house.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
804
+ const indices = [];
805
+ const digits = new Set();
806
+ const dfs = (start) => {
807
+ if (indices.length > 0) {
808
+ if (digits.size === indices.length + 1) {
809
+ const cells = indices.map((i) => empties[i]);
810
+ const key = cells
811
+ .map(({ row, col }) => row * 9 + col)
812
+ .sort((a, b) => a - b)
813
+ .join(",");
814
+ if (!seen.has(key)) {
815
+ seen.add(key);
816
+ result.push({ cells, digits: new Set(digits) });
817
+ }
818
+ }
819
+ // Prune: digit count already exceeds ALS threshold; adding more cells can only
820
+ // increase both the cell count and the digit count by the same amount or more,
821
+ // so the invariant digits.size === cells.length + 1 cannot be recovered.
822
+ if (digits.size > indices.length + 1)
823
+ return;
824
+ }
825
+ if (indices.length === maxSize)
826
+ return;
827
+ for (let i = start; i < empties.length; i++) {
828
+ const { row, col } = empties[i];
829
+ const added = [];
830
+ for (const d of this.possiblesGrid[row][col]) {
831
+ if (!digits.has(d)) {
832
+ digits.add(d);
833
+ added.push(d);
834
+ }
835
+ }
836
+ indices.push(i);
837
+ dfs(i + 1);
838
+ indices.pop();
839
+ for (const d of added)
840
+ digits.delete(d);
841
+ }
842
+ };
843
+ dfs(0);
844
+ }
845
+ return result;
846
+ }
592
847
  findNakedSubsetElimination() {
593
848
  for (const houseCells of this.eachHouseInOrder()) {
594
849
  for (let k = 2; k <= 3; k++) {
@@ -608,57 +863,69 @@ export default class SudokuSolver {
608
863
  const h = houseCells[0];
609
864
  return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
610
865
  }
611
- tryNakedSubsetInHouse(houseCells, k) {
866
+ /**
867
+ * Enumerate all naked locked sets of size k in `houseCells`: groups of k empty cells whose
868
+ * candidate union is exactly k digits. The result drives elimination in `tryNakedSubsetInHouse`
869
+ * and can be queried independently by future algorithms.
870
+ */
871
+ findNakedLockedSetsInHouse(houseCells, k) {
612
872
  const empties = houseCells.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
613
- if (empties.length < k) {
614
- return null;
615
- }
616
- const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
617
- const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
618
- const n = empties.length;
873
+ if (empties.length < k)
874
+ return [];
875
+ const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
876
+ const result = [];
619
877
  const indices = [];
878
+ const union = new Set();
620
879
  const dfs = (start) => {
621
880
  if (indices.length === k) {
622
- const subset = indices.map((i) => empties[i]);
623
- const union = new Set();
624
- for (const { row, col } of subset) {
625
- for (const v of this.possiblesGrid[row][col]) {
626
- union.add(v);
627
- }
881
+ if (union.size === k) {
882
+ result.push({ cells: indices.map((i) => empties[i]), digits: new Set(union), house, wherePhrase });
628
883
  }
629
- if (union.size !== k) {
630
- return null;
631
- }
632
- const subsetKeys = new Set(subset.map(({ row, col }) => row * 9 + col));
633
- const eliminations = [];
634
- for (const { row, col } of houseCells) {
635
- if (this.board[row][col] !== 0 || subsetKeys.has(row * 9 + col)) {
636
- continue;
637
- }
638
- const poss = this.possiblesGrid[row][col];
639
- for (const d of union) {
640
- if (poss.includes(d)) {
641
- eliminations.push({ row, col, value: d });
642
- }
884
+ return;
885
+ }
886
+ // Prune: digit count already exceeds k; adding more cells can't reduce it.
887
+ if (union.size > k)
888
+ return;
889
+ for (let i = start; i < empties.length; i++) {
890
+ const { row, col } = empties[i];
891
+ const added = [];
892
+ for (const v of this.possiblesGrid[row][col]) {
893
+ if (!union.has(v)) {
894
+ union.add(v);
895
+ added.push(v);
643
896
  }
644
897
  }
645
- if (eliminations.length === 0) {
646
- return null;
647
- }
648
- const digitStr = [...union].sort((a, b) => a - b).join("/");
649
- const reasoning = `${algorithm} ${digitStr} in ${wherePhrase} means those digits can be eliminated from the other cells in that same ${sameKindWord}.`;
650
- return this.finalizeElimination(eliminations, algorithm, reasoning);
651
- }
652
- for (let i = start; i < n; i++) {
653
898
  indices.push(i);
654
- const res = dfs(i + 1);
899
+ dfs(i + 1);
655
900
  indices.pop();
656
- if (res)
657
- return res;
901
+ for (const v of added)
902
+ union.delete(v);
658
903
  }
659
- return null;
660
904
  };
661
- return dfs(0);
905
+ dfs(0);
906
+ return result;
907
+ }
908
+ tryNakedSubsetInHouse(houseCells, k) {
909
+ const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
910
+ for (const ls of this.findNakedLockedSetsInHouse(houseCells, k)) {
911
+ const subsetKeys = new Set(ls.cells.map(({ row, col }) => row * 9 + col));
912
+ const eliminations = [];
913
+ for (const { row, col } of houseCells) {
914
+ if (this.board[row][col] !== 0 || subsetKeys.has(row * 9 + col))
915
+ continue;
916
+ for (const d of ls.digits) {
917
+ if (this.possiblesGrid[row][col].includes(d)) {
918
+ eliminations.push({ row, col, value: d });
919
+ }
920
+ }
921
+ }
922
+ if (eliminations.length > 0) {
923
+ const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
924
+ const reasoning = `${algorithm} ${digitStr} in ${ls.wherePhrase} means those digits can be eliminated from the other cells in that same ${ls.house}.`;
925
+ return this.finalizeElimination(eliminations, algorithm, reasoning);
926
+ }
927
+ }
928
+ return null;
662
929
  }
663
930
  findHiddenSubsetElimination() {
664
931
  for (const houseCells of this.eachHouseInOrder()) {
@@ -681,67 +948,59 @@ export default class SudokuSolver {
681
948
  }
682
949
  return null;
683
950
  }
684
- tryHiddenSubsetInHouse(houseCells, k) {
685
- const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
686
- const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
951
+ /**
952
+ * Enumerate all hidden locked sets of size k in `houseCells`: groups of k digits that appear
953
+ * only in exactly k empty cells of the house. The result drives elimination in
954
+ * `tryHiddenSubsetInHouse` and can be queried independently by future algorithms.
955
+ */
956
+ findHiddenLockedSetsInHouse(houseCells, k) {
957
+ const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
958
+ const result = [];
687
959
  const digitIndices = [];
688
- const dfsDigits = (start) => {
960
+ const dfs = (start) => {
689
961
  if (digitIndices.length === k) {
690
962
  const digits = digitIndices.map((i) => COMPLETE[i]);
691
- const digitSet = new Set(digits);
692
- for (const d of digits) {
693
- let seen = false;
694
- for (const { row, col } of houseCells) {
695
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)) {
696
- seen = true;
697
- break;
698
- }
699
- }
700
- if (!seen) {
701
- return null;
702
- }
703
- }
704
- const reserved = [];
705
- for (const { row, col } of houseCells) {
706
- if (this.board[row][col] !== 0) {
707
- continue;
708
- }
709
- for (const d of digits) {
710
- if (this.possiblesGrid[row][col].includes(d)) {
711
- reserved.push({ row, col });
712
- break;
713
- }
714
- }
963
+ // Every chosen digit must appear in at least one empty cell of the house.
964
+ const allPresent = digits.every((d) => houseCells.some(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(d)));
965
+ if (!allPresent)
966
+ return;
967
+ // Cells that contain at least one of the k digits — the "reserved" cells.
968
+ const cells = houseCells.filter(({ row, col }) => this.board[row][col] === 0 &&
969
+ digits.some((d) => this.possiblesGrid[row][col].includes(d)));
970
+ // Hidden set condition: the k digits are confined to exactly k cells.
971
+ if (cells.length === k) {
972
+ result.push({ cells, digits: new Set(digits), house, wherePhrase });
715
973
  }
716
- if (reserved.length !== k) {
717
- return null;
718
- }
719
- const eliminations = [];
720
- for (const { row, col } of reserved) {
721
- for (const v of this.possiblesGrid[row][col]) {
722
- if (!digitSet.has(v)) {
723
- eliminations.push({ row, col, value: v });
724
- }
725
- }
726
- }
727
- if (eliminations.length === 0) {
728
- return null;
729
- }
730
- const digitStr = [...digits].sort((a, b) => a - b).join("/");
731
- const cellPhrase = reserved.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
732
- 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.`;
733
- return this.finalizeElimination(eliminations, algorithm, reasoning);
974
+ return;
734
975
  }
735
976
  for (let i = start; i < 9; i++) {
736
977
  digitIndices.push(i);
737
- const res = dfsDigits(i + 1);
978
+ dfs(i + 1);
738
979
  digitIndices.pop();
739
- if (res)
740
- return res;
741
980
  }
742
- return null;
743
981
  };
744
- return dfsDigits(0);
982
+ dfs(0);
983
+ return result;
984
+ }
985
+ tryHiddenSubsetInHouse(houseCells, k) {
986
+ const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
987
+ for (const ls of this.findHiddenLockedSetsInHouse(houseCells, k)) {
988
+ const eliminations = [];
989
+ for (const { row, col } of ls.cells) {
990
+ for (const v of this.possiblesGrid[row][col]) {
991
+ if (!ls.digits.has(v)) {
992
+ eliminations.push({ row, col, value: v });
993
+ }
994
+ }
995
+ }
996
+ if (eliminations.length > 0) {
997
+ const digitStr = [...ls.digits].sort((a, b) => a - b).join("/");
998
+ const cellPhrase = ls.cells.map(({ row, col }) => `r${row + 1}c${col + 1}`).join(", ");
999
+ 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.`;
1000
+ return this.finalizeElimination(eliminations, algorithm, reasoning);
1001
+ }
1002
+ }
1003
+ return null;
745
1004
  }
746
1005
  findHiddenSingle() {
747
1006
  for (let row = 0; row < 9; row++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wsabol/sudoku-solver",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
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",