845 lines
26 KiB
TypeScript
845 lines
26 KiB
TypeScript
import {
|
|
PuzzleValidationError,
|
|
candidatesForCell,
|
|
compilePuzzle,
|
|
findConflicts,
|
|
isSolved,
|
|
normalizePuzzle,
|
|
type CellId,
|
|
type CompiledPuzzle,
|
|
type NormalizedPuzzle,
|
|
type PuzzleDefinition,
|
|
type SudokuUnit,
|
|
type ValidationIssue,
|
|
} from "../domain";
|
|
import { findAdvancedLogicalStep } from "./advancedLogical";
|
|
|
|
export type LogicalTechnique =
|
|
| "naked-single"
|
|
| "hidden-single"
|
|
| "naked-pair"
|
|
| "naked-triple"
|
|
| "naked-quad"
|
|
| "hidden-pair"
|
|
| "hidden-triple"
|
|
| "hidden-quad"
|
|
| "pointing"
|
|
| "claiming"
|
|
| "x-wing"
|
|
| "swordfish"
|
|
| "jellyfish"
|
|
| "finned-x-wing"
|
|
| "finned-swordfish"
|
|
| "skyscraper"
|
|
| "two-string-kite"
|
|
| "simple-colouring"
|
|
| "w-wing"
|
|
| "x-chain"
|
|
| "xy-chain"
|
|
| "aic"
|
|
| "unique-rectangle"
|
|
| "xy-wing"
|
|
| "xyz-wing"
|
|
| "killer-cage";
|
|
|
|
export interface LogicalPlacement {
|
|
readonly cell: CellId;
|
|
readonly value: number;
|
|
}
|
|
|
|
export interface LogicalElimination {
|
|
readonly cell: CellId;
|
|
readonly values: readonly number[];
|
|
}
|
|
|
|
export interface LogicalStep {
|
|
readonly technique: LogicalTechnique;
|
|
readonly placements: readonly LogicalPlacement[];
|
|
readonly eliminations: readonly LogicalElimination[];
|
|
readonly focusCells: readonly CellId[];
|
|
readonly explanation: string;
|
|
}
|
|
|
|
export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
|
|
|
|
export interface LogicalSolveOptions {
|
|
readonly values?: readonly number[];
|
|
/**
|
|
* Optional per-cell candidate restrictions to resume a logical solve after
|
|
* applying an elimination-only step. Each entry contains ordinary Sudoku
|
|
* digits (1 through the puzzle size), rather than an implementation-specific
|
|
* bit mask. Restrictions are intersected with the candidates that remain
|
|
* legal on the supplied board; entries for filled cells are ignored.
|
|
*/
|
|
readonly candidates?: readonly (readonly number[])[];
|
|
/**
|
|
* Enables uniqueness-dependent deductions only after the caller has proved
|
|
* exactly one solution with an exhaustive solver result. Never inferred.
|
|
*/
|
|
readonly uniquenessProven?: boolean;
|
|
readonly maxSteps?: number;
|
|
}
|
|
|
|
export interface LogicalSolveResult {
|
|
readonly status: LogicalSolveStatus;
|
|
readonly values: readonly number[];
|
|
readonly candidates: readonly (readonly number[])[];
|
|
readonly steps: readonly LogicalStep[];
|
|
}
|
|
|
|
interface LogicalState {
|
|
readonly compiled: CompiledPuzzle;
|
|
readonly values: number[];
|
|
readonly masks: number[];
|
|
}
|
|
|
|
function digitBit(value: number): number {
|
|
return 1 << value;
|
|
}
|
|
|
|
function popcount(mask: number): number {
|
|
let value = mask >>> 0;
|
|
let count = 0;
|
|
while (value !== 0) {
|
|
value &= value - 1;
|
|
count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function digits(mask: number, size: number): number[] {
|
|
const result: number[] = [];
|
|
for (let value = 1; value <= size; value += 1) {
|
|
if ((mask & digitBit(value)) !== 0) result.push(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function onlyDigit(mask: number, size: number): number {
|
|
return digits(mask, size)[0] ?? 0;
|
|
}
|
|
|
|
function combinations<T>(values: readonly T[], count: number): T[][] {
|
|
const result: T[][] = [];
|
|
const current: T[] = [];
|
|
const visit = (start: number): void => {
|
|
if (current.length === count) {
|
|
result.push([...current]);
|
|
return;
|
|
}
|
|
for (
|
|
let index = start;
|
|
index <= values.length - (count - current.length);
|
|
index += 1
|
|
) {
|
|
const value = values[index];
|
|
if (value === undefined) continue;
|
|
current.push(value);
|
|
visit(index + 1);
|
|
current.pop();
|
|
}
|
|
};
|
|
visit(0);
|
|
return result;
|
|
}
|
|
|
|
function validateStart(
|
|
normalized: NormalizedPuzzle,
|
|
values: readonly number[],
|
|
): void {
|
|
const issues: ValidationIssue[] = [];
|
|
if (values.length !== normalized.size * normalized.size) {
|
|
issues.push({
|
|
path: "values",
|
|
message: `must contain exactly ${normalized.size ** 2} values`,
|
|
});
|
|
} else {
|
|
values.forEach((value, cell) => {
|
|
if (!Number.isInteger(value) || value < 0 || value > normalized.size) {
|
|
issues.push({
|
|
path: `values[${cell}]`,
|
|
message: "contains an out-of-range value",
|
|
});
|
|
}
|
|
const given = normalized.givens[cell] ?? 0;
|
|
if (given !== 0 && value !== given) {
|
|
issues.push({
|
|
path: `values[${cell}]`,
|
|
message: "must preserve the given value",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
if (issues.length > 0) throw new PuzzleValidationError(issues);
|
|
}
|
|
|
|
function compileCandidateRestrictions(
|
|
normalized: NormalizedPuzzle,
|
|
input: unknown,
|
|
): number[] | undefined {
|
|
if (input === undefined) return undefined;
|
|
|
|
const issues: ValidationIssue[] = [];
|
|
const cellCount = normalized.size * normalized.size;
|
|
if (!Array.isArray(input)) {
|
|
throw new PuzzleValidationError([
|
|
{ path: "candidates", message: "must be an array of candidate arrays" },
|
|
]);
|
|
}
|
|
if (input.length !== cellCount) {
|
|
issues.push({
|
|
path: "candidates",
|
|
message: `must contain exactly ${cellCount} candidate arrays`,
|
|
});
|
|
}
|
|
|
|
const masks = new Array<number>(cellCount).fill(0);
|
|
for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
|
|
const cellCandidates: unknown = input[cell];
|
|
if (!Array.isArray(cellCandidates)) {
|
|
issues.push({
|
|
path: `candidates[${cell}]`,
|
|
message: "must be an array of candidate digits",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let mask = 0;
|
|
for (let index = 0; index < cellCandidates.length; index += 1) {
|
|
const value: unknown = cellCandidates[index];
|
|
if (
|
|
typeof value !== "number" ||
|
|
!Number.isInteger(value) ||
|
|
value < 1 ||
|
|
value > normalized.size
|
|
) {
|
|
issues.push({
|
|
path: `candidates[${cell}][${index}]`,
|
|
message: `must be an integer from 1 to ${normalized.size}`,
|
|
});
|
|
continue;
|
|
}
|
|
const bit = digitBit(value);
|
|
if ((mask & bit) !== 0) {
|
|
issues.push({
|
|
path: `candidates[${cell}][${index}]`,
|
|
message: `contains duplicate candidate ${value}`,
|
|
});
|
|
continue;
|
|
}
|
|
mask |= bit;
|
|
}
|
|
masks[cell] = mask;
|
|
}
|
|
|
|
if (issues.length > 0) throw new PuzzleValidationError(issues);
|
|
return masks;
|
|
}
|
|
|
|
function initializeState(
|
|
normalized: NormalizedPuzzle,
|
|
values: readonly number[],
|
|
candidateRestrictions?: readonly number[],
|
|
): LogicalState {
|
|
const compiled = compilePuzzle(normalized);
|
|
return {
|
|
compiled,
|
|
values: [...values],
|
|
masks: values.map((value, cell) => {
|
|
if (value !== 0) return 0;
|
|
const legalMask = candidatesForCell(compiled, values, cell).reduce(
|
|
(mask, candidate) => mask | digitBit(candidate),
|
|
0,
|
|
);
|
|
return candidateRestrictions === undefined
|
|
? legalMask
|
|
: legalMask & (candidateRestrictions[cell] ?? 0);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function eliminationStep(
|
|
technique: LogicalTechnique,
|
|
eliminations: readonly LogicalElimination[],
|
|
focusCells: readonly CellId[],
|
|
explanation: string,
|
|
): LogicalStep | undefined {
|
|
return eliminations.length === 0
|
|
? undefined
|
|
: { technique, placements: [], eliminations, focusCells, explanation };
|
|
}
|
|
|
|
function findNakedSingle(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
for (let cell = 0; cell < state.values.length; cell += 1) {
|
|
const mask = state.masks[cell] ?? 0;
|
|
if (state.values[cell] === 0 && popcount(mask) === 1) {
|
|
const value = onlyDigit(mask, size);
|
|
return {
|
|
technique: "naked-single",
|
|
placements: [{ cell, value }],
|
|
eliminations: [],
|
|
focusCells: [cell],
|
|
explanation: `Cell ${cell + 1} has only one candidate: ${value}.`,
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findHiddenSingle(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
for (const unit of state.compiled.units) {
|
|
for (let value = 1; value <= size; value += 1) {
|
|
if (unit.cells.some((cell) => state.values[cell] === value)) continue;
|
|
const cells = unit.cells.filter(
|
|
(cell) =>
|
|
state.values[cell] === 0 &&
|
|
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
);
|
|
if (cells.length === 1) {
|
|
const cell = cells[0] as number;
|
|
return {
|
|
technique: "hidden-single",
|
|
placements: [{ cell, value }],
|
|
eliminations: [],
|
|
focusCells: unit.cells,
|
|
explanation: `${value} has only one possible cell in this ${unit.kind}.`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function subsetName(hidden: boolean, count: number): LogicalTechnique {
|
|
const suffix = count === 2 ? "pair" : count === 3 ? "triple" : "quad";
|
|
return `${hidden ? "hidden" : "naked"}-${suffix}` as LogicalTechnique;
|
|
}
|
|
|
|
function findNakedSubset(state: LogicalState): LogicalStep | undefined {
|
|
for (const unit of state.compiled.units) {
|
|
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
|
|
for (let count = 2; count <= 4; count += 1) {
|
|
const eligible = empty.filter((cell) => {
|
|
const total = popcount(state.masks[cell] ?? 0);
|
|
return total >= 2 && total <= count;
|
|
});
|
|
for (const cells of combinations(eligible, count)) {
|
|
const union = cells.reduce(
|
|
(mask, cell) => mask | (state.masks[cell] ?? 0),
|
|
0,
|
|
);
|
|
if (popcount(union) !== count) continue;
|
|
const selected = new Set(cells);
|
|
const eliminations = empty
|
|
.filter(
|
|
(cell) =>
|
|
!selected.has(cell) && ((state.masks[cell] ?? 0) & union) !== 0,
|
|
)
|
|
.map((cell) => ({
|
|
cell,
|
|
values: digits(
|
|
(state.masks[cell] ?? 0) & union,
|
|
state.compiled.puzzle.size,
|
|
),
|
|
}));
|
|
const step = eliminationStep(
|
|
subsetName(false, count),
|
|
eliminations,
|
|
cells,
|
|
`${count} cells contain only the same ${count} candidates in this ${unit.kind}.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findHiddenSubset(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
const values = Array.from({ length: size }, (_, index) => index + 1);
|
|
for (const unit of state.compiled.units) {
|
|
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
|
|
for (let count = 2; count <= 4; count += 1) {
|
|
for (const selectedValues of combinations(values, count)) {
|
|
const subsetMask = selectedValues.reduce(
|
|
(mask, value) => mask | digitBit(value),
|
|
0,
|
|
);
|
|
if (
|
|
selectedValues.some(
|
|
(value) =>
|
|
!empty.some(
|
|
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
),
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
const cells = empty.filter(
|
|
(cell) => ((state.masks[cell] ?? 0) & subsetMask) !== 0,
|
|
);
|
|
if (cells.length !== count) continue;
|
|
const eliminations = cells
|
|
.filter((cell) => ((state.masks[cell] ?? 0) & ~subsetMask) !== 0)
|
|
.map((cell) => ({
|
|
cell,
|
|
values: digits((state.masks[cell] ?? 0) & ~subsetMask, size),
|
|
}));
|
|
const step = eliminationStep(
|
|
subsetName(true, count),
|
|
eliminations,
|
|
cells,
|
|
`${selectedValues.join(", ")} can occur only in ${count} cells of this ${unit.kind}.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findPointingOrClaiming(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
const regions = state.compiled.units.filter((unit) => unit.kind === "region");
|
|
for (const region of regions) {
|
|
const regionSet = new Set(region.cells);
|
|
for (let value = 1; value <= size; value += 1) {
|
|
const cells = region.cells.filter(
|
|
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
);
|
|
if (cells.length < 2) continue;
|
|
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
|
|
const columns = new Set(cells.map((cell) => cell % size));
|
|
const aligned =
|
|
rows.size === 1
|
|
? (["row", [...rows][0]] as const)
|
|
: columns.size === 1
|
|
? (["column", [...columns][0]] as const)
|
|
: undefined;
|
|
if (aligned === undefined || aligned[1] === undefined) continue;
|
|
const unit = state.compiled.units.find(
|
|
(candidate) =>
|
|
candidate.kind === aligned[0] && candidate.index === aligned[1],
|
|
);
|
|
if (unit === undefined) continue;
|
|
const eliminations = unit.cells
|
|
.filter(
|
|
(cell) =>
|
|
!regionSet.has(cell) &&
|
|
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
)
|
|
.map((cell) => ({ cell, values: [value] }));
|
|
const step = eliminationStep(
|
|
"pointing",
|
|
eliminations,
|
|
cells,
|
|
`${value} is confined to one ${aligned[0]} inside a region.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
|
|
const lines = state.compiled.units.filter(
|
|
(unit) => unit.kind === "row" || unit.kind === "column",
|
|
);
|
|
for (const line of lines) {
|
|
for (let value = 1; value <= size; value += 1) {
|
|
const cells = line.cells.filter(
|
|
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
);
|
|
if (cells.length < 2) continue;
|
|
const regionIds = new Set(
|
|
cells.map((cell) => state.compiled.puzzle.regions[cell]),
|
|
);
|
|
if (regionIds.size !== 1) continue;
|
|
const regionId = [...regionIds][0];
|
|
const region = regions.find((unit) => unit.index === regionId);
|
|
if (region === undefined) continue;
|
|
const lineSet = new Set(line.cells);
|
|
const eliminations = region.cells
|
|
.filter(
|
|
(cell) =>
|
|
!lineSet.has(cell) &&
|
|
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
)
|
|
.map((cell) => ({ cell, values: [value] }));
|
|
const step = eliminationStep(
|
|
"claiming",
|
|
eliminations,
|
|
cells,
|
|
`${value} in this ${line.kind} is confined to a single region.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function lineUnit(
|
|
compiled: CompiledPuzzle,
|
|
kind: "row" | "column",
|
|
index: number,
|
|
): SudokuUnit | undefined {
|
|
return compiled.units.find(
|
|
(unit) => unit.kind === kind && unit.index === index,
|
|
);
|
|
}
|
|
|
|
function findFish(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
for (const count of [2, 3]) {
|
|
for (const [baseKind, coverKind] of [
|
|
["row", "column"],
|
|
["column", "row"],
|
|
] as const) {
|
|
for (let value = 1; value <= size; value += 1) {
|
|
const eligible = Array.from(
|
|
{ length: size },
|
|
(_, index) => index,
|
|
).filter((index) => {
|
|
const unit = lineUnit(state.compiled, baseKind, index);
|
|
const total =
|
|
unit?.cells.filter(
|
|
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
|
|
).length ?? 0;
|
|
return total >= 2 && total <= count;
|
|
});
|
|
for (const baseIndices of combinations(eligible, count)) {
|
|
const coverIndices = new Set<number>();
|
|
const focus: number[] = [];
|
|
for (const baseIndex of baseIndices) {
|
|
const unit = lineUnit(state.compiled, baseKind, baseIndex);
|
|
for (const cell of unit?.cells ?? []) {
|
|
if (((state.masks[cell] ?? 0) & digitBit(value)) === 0) continue;
|
|
focus.push(cell);
|
|
coverIndices.add(
|
|
coverKind === "column" ? cell % size : Math.floor(cell / size),
|
|
);
|
|
}
|
|
}
|
|
if (coverIndices.size !== count) continue;
|
|
const baseSet = new Set(baseIndices);
|
|
const eliminations: LogicalElimination[] = [];
|
|
for (const coverIndex of coverIndices) {
|
|
const unit = lineUnit(state.compiled, coverKind, coverIndex);
|
|
for (const cell of unit?.cells ?? []) {
|
|
const baseIndex =
|
|
baseKind === "row" ? Math.floor(cell / size) : cell % size;
|
|
if (
|
|
!baseSet.has(baseIndex) &&
|
|
((state.masks[cell] ?? 0) & digitBit(value)) !== 0
|
|
) {
|
|
eliminations.push({ cell, values: [value] });
|
|
}
|
|
}
|
|
}
|
|
const technique: LogicalTechnique =
|
|
count === 2 ? "x-wing" : "swordfish";
|
|
const step = eliminationStep(
|
|
technique,
|
|
eliminations,
|
|
focus,
|
|
`${value} forms a ${technique} across ${count} ${baseKind}s.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function commonPeers(
|
|
compiled: CompiledPuzzle,
|
|
cells: readonly CellId[],
|
|
): Set<CellId> {
|
|
const first = cells[0];
|
|
if (first === undefined) return new Set();
|
|
const result = new Set(compiled.peers[first]);
|
|
for (const cell of cells.slice(1)) {
|
|
for (const candidate of result) {
|
|
if (!compiled.peers[cell]?.has(candidate)) result.delete(candidate);
|
|
}
|
|
}
|
|
for (const cell of cells) result.delete(cell);
|
|
return result;
|
|
}
|
|
|
|
function findXyWing(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
|
|
const pivotMask = state.masks[pivot] ?? 0;
|
|
if (popcount(pivotMask) !== 2) continue;
|
|
const wings = [...(state.compiled.peers[pivot] ?? [])].filter(
|
|
(cell) => popcount(state.masks[cell] ?? 0) === 2,
|
|
);
|
|
for (const pair of combinations(wings, 2)) {
|
|
const a = pair[0];
|
|
const b = pair[1];
|
|
if (a === undefined || b === undefined) continue;
|
|
const aMask = state.masks[a] ?? 0;
|
|
const bMask = state.masks[b] ?? 0;
|
|
const sharedA = aMask & pivotMask;
|
|
const sharedB = bMask & pivotMask;
|
|
if (
|
|
popcount(sharedA) !== 1 ||
|
|
popcount(sharedB) !== 1 ||
|
|
sharedA === sharedB
|
|
)
|
|
continue;
|
|
const zMask = aMask & bMask & ~pivotMask;
|
|
if (popcount(zMask) !== 1) continue;
|
|
const value = onlyDigit(zMask, size);
|
|
const eliminations = [...commonPeers(state.compiled, [a, b])]
|
|
.filter((cell) => ((state.masks[cell] ?? 0) & zMask) !== 0)
|
|
.map((cell) => ({ cell, values: [value] }));
|
|
const step = eliminationStep(
|
|
"xy-wing",
|
|
eliminations,
|
|
[pivot, a, b],
|
|
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XY-Wing eliminating ${value}.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findXyzWing(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
|
|
const pivotMask = state.masks[pivot] ?? 0;
|
|
if (popcount(pivotMask) !== 3) continue;
|
|
const wings = [...(state.compiled.peers[pivot] ?? [])].filter((cell) => {
|
|
const mask = state.masks[cell] ?? 0;
|
|
return popcount(mask) === 2 && (mask & ~pivotMask) === 0;
|
|
});
|
|
for (const pair of combinations(wings, 2)) {
|
|
const a = pair[0];
|
|
const b = pair[1];
|
|
if (a === undefined || b === undefined) continue;
|
|
const aMask = state.masks[a] ?? 0;
|
|
const bMask = state.masks[b] ?? 0;
|
|
if ((aMask | bMask) !== pivotMask) continue;
|
|
const shared = aMask & bMask;
|
|
if (popcount(shared) !== 1) continue;
|
|
const value = onlyDigit(shared, size);
|
|
const eliminations = [...commonPeers(state.compiled, [pivot, a, b])]
|
|
.filter((cell) => ((state.masks[cell] ?? 0) & shared) !== 0)
|
|
.map((cell) => ({ cell, values: [value] }));
|
|
const step = eliminationStep(
|
|
"xyz-wing",
|
|
eliminations,
|
|
[pivot, a, b],
|
|
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XYZ-Wing eliminating ${value}.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findKillerReduction(state: LogicalState): LogicalStep | undefined {
|
|
const size = state.compiled.puzzle.size;
|
|
for (const constraint of state.compiled.puzzle.constraints) {
|
|
if (constraint.type !== "killer-cage" || constraint.negated === true)
|
|
continue;
|
|
const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
|
|
if (empty.length === 0) continue;
|
|
const assigned = constraint.cells
|
|
.map((cell) => state.values[cell] ?? 0)
|
|
.filter((value) => value !== 0);
|
|
const target =
|
|
constraint.sum - assigned.reduce((sum, value) => sum + value, 0);
|
|
const used = new Set(assigned);
|
|
const allowed = new Map<CellId, number>();
|
|
empty.forEach((cell) => allowed.set(cell, 0));
|
|
let visits = 0;
|
|
let truncated = false;
|
|
const chosen = new Set<number>(used);
|
|
const visit = (index: number, remaining: number): void => {
|
|
if (visits >= 100_000) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
visits += 1;
|
|
if (index === empty.length) {
|
|
if (remaining === 0) {
|
|
empty.forEach((cell) => {
|
|
const value = state.values[cell] ?? 0;
|
|
// Temporary chosen values are stored just beyond the live board.
|
|
const selected = assignment[indexByCell.get(cell) ?? -1] ?? value;
|
|
allowed.set(cell, (allowed.get(cell) ?? 0) | digitBit(selected));
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const cell = empty[index];
|
|
if (cell === undefined) return;
|
|
const remainingCells = empty.length - index - 1;
|
|
for (const value of digits(state.masks[cell] ?? 0, size)) {
|
|
if (constraint.noRepeat !== false && chosen.has(value)) continue;
|
|
const next = remaining - value;
|
|
if (next < remainingCells || next > remainingCells * size) continue;
|
|
assignment[index] = value;
|
|
chosen.add(value);
|
|
visit(index + 1, next);
|
|
chosen.delete(value);
|
|
}
|
|
};
|
|
const assignment = new Array<number>(empty.length).fill(0);
|
|
const indexByCell = new Map(empty.map((cell, index) => [cell, index]));
|
|
visit(0, target);
|
|
if (truncated) continue;
|
|
const eliminations = empty
|
|
.filter(
|
|
(cell) => ((state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0)) !== 0,
|
|
)
|
|
.map((cell) => ({
|
|
cell,
|
|
values: digits(
|
|
(state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0),
|
|
size,
|
|
),
|
|
}));
|
|
const step = eliminationStep(
|
|
"killer-cage",
|
|
eliminations,
|
|
constraint.cells,
|
|
`Only sum-compatible assignments remain in the ${constraint.sum} cage.`,
|
|
);
|
|
if (step !== undefined) return step;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findStep(
|
|
state: LogicalState,
|
|
uniquenessProven: boolean,
|
|
): LogicalStep | undefined {
|
|
return (
|
|
findNakedSingle(state) ??
|
|
findHiddenSingle(state) ??
|
|
findNakedSubset(state) ??
|
|
findHiddenSubset(state) ??
|
|
findPointingOrClaiming(state) ??
|
|
findFish(state) ??
|
|
findXyWing(state) ??
|
|
findXyzWing(state) ??
|
|
findAdvancedLogicalStep({
|
|
size: state.compiled.puzzle.size,
|
|
values: state.values,
|
|
masks: state.masks,
|
|
regions: state.compiled.puzzle.regions,
|
|
peers: state.compiled.peers,
|
|
units: state.compiled.units,
|
|
uniquenessProven,
|
|
uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0,
|
|
}) ??
|
|
findKillerReduction(state)
|
|
);
|
|
}
|
|
|
|
function applyStep(state: LogicalState, step: LogicalStep): void {
|
|
for (const elimination of step.eliminations) {
|
|
let mask = state.masks[elimination.cell] ?? 0;
|
|
for (const value of elimination.values) mask &= ~digitBit(value);
|
|
state.masks[elimination.cell] = mask;
|
|
}
|
|
for (const placement of step.placements) {
|
|
state.values[placement.cell] = placement.value;
|
|
state.masks[placement.cell] = 0;
|
|
}
|
|
if (step.placements.length > 0) {
|
|
for (let cell = 0; cell < state.values.length; cell += 1) {
|
|
if (state.values[cell] !== 0) continue;
|
|
const raw = candidatesForCell(state.compiled, state.values, cell).reduce(
|
|
(mask, value) => mask | digitBit(value),
|
|
0,
|
|
);
|
|
state.masks[cell] = (state.masks[cell] ?? 0) & raw;
|
|
}
|
|
}
|
|
}
|
|
|
|
function exposedCandidates(
|
|
state: LogicalState,
|
|
): readonly (readonly number[])[] {
|
|
return state.masks.map((mask) => digits(mask, state.compiled.puzzle.size));
|
|
}
|
|
|
|
export function solveLogically(
|
|
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
|
options: LogicalSolveOptions = {},
|
|
): LogicalSolveResult {
|
|
const normalized = normalizePuzzle(puzzle);
|
|
const values = options.values ?? normalized.givens;
|
|
validateStart(normalized, values);
|
|
const maxSteps = options.maxSteps ?? 1_000;
|
|
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
|
|
throw new RangeError("maxSteps must be an integer from 1 to 10000");
|
|
}
|
|
if (
|
|
options.uniquenessProven !== undefined &&
|
|
typeof options.uniquenessProven !== "boolean"
|
|
) {
|
|
throw new TypeError("uniquenessProven must be a boolean when supplied");
|
|
}
|
|
const candidateRestrictions = compileCandidateRestrictions(
|
|
normalized,
|
|
options.candidates,
|
|
);
|
|
const state = initializeState(normalized, values, candidateRestrictions);
|
|
const steps: LogicalStep[] = [];
|
|
if (findConflicts(state.compiled, state.values).length > 0) {
|
|
return {
|
|
status: "invalid",
|
|
values: state.values,
|
|
candidates: exposedCandidates(state),
|
|
steps,
|
|
};
|
|
}
|
|
while (steps.length < maxSteps) {
|
|
if (isSolved(state.compiled, state.values)) {
|
|
return {
|
|
status: "solved",
|
|
values: state.values,
|
|
candidates: exposedCandidates(state),
|
|
steps,
|
|
};
|
|
}
|
|
if (
|
|
state.values.some(
|
|
(value, cell) => value === 0 && (state.masks[cell] ?? 0) === 0,
|
|
)
|
|
) {
|
|
return {
|
|
status: "invalid",
|
|
values: state.values,
|
|
candidates: exposedCandidates(state),
|
|
steps,
|
|
};
|
|
}
|
|
const step = findStep(state, options.uniquenessProven === true);
|
|
if (step === undefined) {
|
|
return {
|
|
status: "stuck",
|
|
values: state.values,
|
|
candidates: exposedCandidates(state),
|
|
steps,
|
|
};
|
|
}
|
|
applyStep(state, step);
|
|
steps.push(step);
|
|
}
|
|
return {
|
|
status: isSolved(state.compiled, state.values) ? "solved" : "step-limit",
|
|
values: state.values,
|
|
candidates: exposedCandidates(state),
|
|
steps,
|
|
};
|
|
}
|