import { PuzzleValidationError, candidatesForCell, compilePuzzle, findConflicts, normalizePuzzle, type NormalizedPuzzle, type PuzzleDefinition, type ValidationIssue, } from "../domain"; import { seededRandom, shuffled, type RandomSource } from "./random"; export interface ExactSolveOptions { readonly values?: readonly number[]; /** Stop after this many solutions. Defaults to 2 so uniqueness can be tested. */ readonly maxSolutions?: number; readonly maxNodes?: number; readonly timeoutMs?: number; /** Randomizes equal choices deterministically when supplied. */ readonly seed?: string | number; } export type ExactLimitReason = "solution-cap" | "node-cap" | "timeout"; export interface ExactSolveResult { readonly solutions: readonly (readonly number[])[]; readonly count: number; readonly truncated: boolean; readonly limitReason?: ExactLimitReason; readonly nodes: number; readonly elapsedMs: number; } function boundedInteger( value: number | undefined, fallback: number, minimum: number, maximum: number, name: string, ): number { const result = value ?? fallback; if (!Number.isInteger(result) || result < minimum || result > maximum) { throw new RangeError( `${name} must be an integer from ${minimum} to ${maximum}.`, ); } 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: `must be an integer from 0 to ${normalized.size}`, }); } 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) { for (const conflict of findConflicts(compilePuzzle(normalized), values)) { issues.push({ path: "values", message: conflict.message }); } } if (issues.length > 0) throw new PuzzleValidationError(issues); } export function solveExact( puzzle: PuzzleDefinition | NormalizedPuzzle, options: ExactSolveOptions = {}, ): ExactSolveResult { const normalized = normalizePuzzle(puzzle); const board = [...(options.values ?? normalized.givens)]; validateStart(normalized, board); const maxSolutions = boundedInteger( options.maxSolutions, 2, 1, 100, "maxSolutions", ); const maxNodes = boundedInteger( options.maxNodes, 2_000_000, 1, 100_000_000, "maxNodes", ); const timeoutMs = boundedInteger( options.timeoutMs, 10_000, 1, 120_000, "timeoutMs", ); const compiled = compilePuzzle(normalized); const preferredSolution = normalized.solution !== undefined && board.every( (value, cell) => value === 0 || value === normalized.solution?.[cell], ) ? normalized.solution : undefined; const random: RandomSource | undefined = options.seed === undefined ? undefined : seededRandom(options.seed); const solutions: number[][] = []; const started = Date.now(); let nodes = 0; let limitReason: ExactLimitReason | undefined; const search = (): void => { if (solutions.length >= maxSolutions) { limitReason = "solution-cap"; return; } if (nodes >= maxNodes) { limitReason = "node-cap"; return; } if (Date.now() - started >= timeoutMs) { limitReason = "timeout"; return; } nodes += 1; const tied: Array = []; let minimum = normalized.size + 1; for (let cell = 0; cell < board.length; cell += 1) { if (board[cell] !== 0) continue; const candidates = candidatesForCell(compiled, board, cell); if (candidates.length === 0) return; if (candidates.length < minimum) { minimum = candidates.length; tied.length = 0; tied.push([cell, candidates]); } else if (candidates.length === minimum) { tied.push([cell, candidates]); } if (minimum === 1 && random === undefined) break; } if (tied.length === 0) { solutions.push([...board]); return; } const selection = random === undefined ? tied[0] : tied[Math.floor(random() * tied.length)]; if (selection === undefined) return; const chosen = selection[0]; let choices = selection[1]; if (random !== undefined) choices = shuffled(choices, random); else { const preferred = preferredSolution?.[chosen]; if (preferred !== undefined && choices.includes(preferred)) { choices = [ preferred, ...choices.filter((value) => value !== preferred), ]; } } for (const value of choices) { board[chosen] = value; search(); board[chosen] = 0; if (limitReason !== undefined) return; } }; search(); const result: ExactSolveResult = { solutions, count: solutions.length, truncated: limitReason !== undefined, nodes, elapsedMs: Date.now() - started, ...(limitReason === undefined ? {} : { limitReason }), }; return result; } export function countSolutions( puzzle: PuzzleDefinition | NormalizedPuzzle, options: Omit & { readonly maxSolutions?: number; } = {}, ): number { return solveExact(puzzle, options).count; }