feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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 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<readonly [number, number[]]> = [];
|
||||
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);
|
||||
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<ExactSolveOptions, "maxSolutions"> & {
|
||||
readonly maxSolutions?: number;
|
||||
} = {},
|
||||
): number {
|
||||
return solveExact(puzzle, options).count;
|
||||
}
|
||||
Reference in New Issue
Block a user