@wsabol/sudoku-solver 0.1.10 → 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" | "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 {
@@ -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.
@@ -56,18 +62,58 @@ export default class SudokuSolver {
56
62
  */
57
63
  private findFishOfSize;
58
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;
59
73
  /**
60
74
  * XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
61
75
  * sees both pincers. Any cell that sees both pincers cannot contain Z.
62
76
  */
63
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;
64
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;
65
99
  private findNakedSubsetElimination;
66
100
  /** `houseCells` is one full row, column, or box (9 cells). */
67
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;
68
108
  private tryNakedSubsetInHouse;
69
109
  private findHiddenSubsetElimination;
70
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;
71
117
  private tryHiddenSubsetInHouse;
72
118
  private findHiddenSingle;
73
119
  }
@@ -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
  ];
@@ -325,10 +327,14 @@ export default class SudokuSolver {
325
327
  return this.findHiddenSingle();
326
328
  case "Pointing":
327
329
  return this.findPointingPairTriple();
330
+ case "BoxLineReduction":
331
+ return this.findBoxLineReduction();
328
332
  case "Fish":
329
333
  return this.findFishOfSize(2);
330
334
  case "XYWing":
331
335
  return this.findXYWing();
336
+ case "WWing":
337
+ return this.findWWing();
332
338
  case "Swordfish":
333
339
  return this.findFishOfSize(3);
334
340
  case "NakedSubset":
@@ -435,14 +441,9 @@ export default class SudokuSolver {
435
441
  return null;
436
442
  }
437
443
  findHiddenSingleInBox(ibox) {
444
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
438
445
  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
- }
446
+ const candidates = this.getCandidateCellsInHouse(boxHouse, value);
446
447
  if (candidates.length === 1) {
447
448
  const { row: r, col: c } = candidates[0];
448
449
  let algorithm = "Hidden Single";
@@ -461,14 +462,9 @@ export default class SudokuSolver {
461
462
  const boxStartRow = Math.floor(ibox / 3) * 3;
462
463
  const boxStartCol = (ibox % 3) * 3;
463
464
  const boxNum = ibox + 1;
465
+ const boxHouse = Array.from({ length: 9 }, (_, idx) => this.boxToPuzzle(ibox, idx));
464
466
  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
- }
467
+ const cells = this.getCandidateCellsInHouse(boxHouse, digit);
472
468
  if (cells.length < 2)
473
469
  continue;
474
470
  if (cells.every((c) => c.row === cells[0].row)) {
@@ -507,19 +503,44 @@ export default class SudokuSolver {
507
503
  }
508
504
  return null;
509
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
+ }
510
537
  /**
511
538
  * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
512
539
  * Returns only empty cells where `digit` is still a candidate.
513
540
  */
514
541
  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;
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);
523
544
  }
524
545
  /**
525
546
  * Generic N-fish detector (X-Wing = 2, Swordfish = 3).
@@ -578,20 +599,28 @@ export default class SudokuSolver {
578
599
  cellsSeeEachOther(r1, c1, r2, c2) {
579
600
  return r1 === r2 || c1 === c2 || this.boxIndex(r1, c1) === this.boxIndex(r2, c2);
580
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
+ }
581
618
  /**
582
619
  * XY-Wing: three bi-value cells — pivot {X,Y}, pincer1 {X,Z}, pincer2 {Y,Z} — where the pivot
583
620
  * sees both pincers. Any cell that sees both pincers cannot contain Z.
584
621
  */
585
622
  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
- }
623
+ const bivalue = this.getBivalueCells();
595
624
  for (const pivot of bivalue) {
596
625
  const X = pivot.a;
597
626
  const Y = pivot.b;
@@ -652,6 +681,92 @@ export default class SudokuSolver {
652
681
  }
653
682
  return null;
654
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
+ }
655
770
  eachHouseInOrder() {
656
771
  const houses = [];
657
772
  for (let r = 0; r < 9; r++) {
@@ -669,6 +784,66 @@ export default class SudokuSolver {
669
784
  }
670
785
  return houses;
671
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
+ }
672
847
  findNakedSubsetElimination() {
673
848
  for (const houseCells of this.eachHouseInOrder()) {
674
849
  for (let k = 2; k <= 3; k++) {
@@ -688,57 +863,69 @@ export default class SudokuSolver {
688
863
  const h = houseCells[0];
689
864
  return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
690
865
  }
691
- 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) {
692
872
  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;
873
+ if (empties.length < k)
874
+ return [];
875
+ const { wherePhrase, sameKindWord: house } = this.getHouseContext(houseCells);
876
+ const result = [];
699
877
  const indices = [];
878
+ const union = new Set();
700
879
  const dfs = (start) => {
701
880
  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
- }
708
- }
709
- if (union.size !== k) {
710
- return null;
881
+ if (union.size === k) {
882
+ result.push({ cells: indices.map((i) => empties[i]), digits: new Set(union), house, wherePhrase });
711
883
  }
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
- }
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);
723
896
  }
724
897
  }
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
898
  indices.push(i);
734
- const res = dfs(i + 1);
899
+ dfs(i + 1);
735
900
  indices.pop();
736
- if (res)
737
- return res;
901
+ for (const v of added)
902
+ union.delete(v);
738
903
  }
739
- return null;
740
904
  };
741
- 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;
742
929
  }
743
930
  findHiddenSubsetElimination() {
744
931
  for (const houseCells of this.eachHouseInOrder()) {
@@ -761,67 +948,59 @@ export default class SudokuSolver {
761
948
  }
762
949
  return null;
763
950
  }
764
- tryHiddenSubsetInHouse(houseCells, k) {
765
- const algorithm = k === 2 ? "Hidden Pair" : k === 3 ? "Hidden Triple" : "Hidden Quad";
766
- 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 = [];
767
959
  const digitIndices = [];
768
- const dfsDigits = (start) => {
960
+ const dfs = (start) => {
769
961
  if (digitIndices.length === k) {
770
962
  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
- }
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 });
783
973
  }
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;
798
- }
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);
974
+ return;
814
975
  }
815
976
  for (let i = start; i < 9; i++) {
816
977
  digitIndices.push(i);
817
- const res = dfsDigits(i + 1);
978
+ dfs(i + 1);
818
979
  digitIndices.pop();
819
- if (res)
820
- return res;
821
980
  }
822
- return null;
823
981
  };
824
- 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;
825
1004
  }
826
1005
  findHiddenSingle() {
827
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.10",
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",