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(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(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(); 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 { 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(); empty.forEach((cell) => allowed.set(cell, 0)); let visits = 0; let truncated = false; const chosen = new Set(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(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, }; }