Files
sudoku-tools/src/helpers/candidates.ts
T

380 lines
11 KiB
TypeScript

import {
compilePuzzle,
type CompiledPuzzle,
type NormalizedPuzzle,
type SudokuUnit,
type UnitKind,
} from "../domain";
export interface CandidateNode {
readonly cell: number;
readonly value: number;
}
export type CandidateLinkKind = "strong" | "weak";
export interface CandidateLinkContext {
readonly kind: "cell" | "house";
readonly label: string;
readonly unitKind?: UnitKind;
readonly unitIndex?: number;
}
/**
* A graph edge between two candidates. Strong edges mean that at least one
* endpoint must be true; weak edges mean that both endpoints cannot be true.
* A strong Sudoku link is also weak, but is rendered once as the more useful
* strong relationship.
*/
export interface CandidateLink {
readonly id: string;
readonly kind: CandidateLinkKind;
readonly a: CandidateNode;
readonly b: CandidateNode;
readonly contexts: readonly CandidateLinkContext[];
}
export interface CandidatePosition {
readonly value: number;
readonly cells: readonly number[];
readonly linkKind: CandidateLinkKind | "none";
}
export interface HouseCandidateInspection {
readonly unitIndex: number;
readonly kind: UnitKind;
readonly index: number;
readonly label: string;
readonly cells: readonly number[];
readonly missingValues: readonly number[];
readonly positions: readonly CandidatePosition[];
}
export interface CellCandidateInspection {
readonly cell: number;
readonly values: readonly number[];
readonly houseLabels: readonly string[];
}
export interface CandidateOverlay {
readonly activeValues: readonly number[];
readonly candidateCells: readonly number[];
readonly links: readonly CandidateLink[];
}
export interface CandidateLinkOptions {
/** Only derive house links from these compiled unit indices. */
readonly unitIndices?: readonly number[];
/** Only retain links whose two endpoints use one of these values. */
readonly values?: readonly number[];
/** Cells from which same-cell links are derived. Defaults to every cell. */
readonly cellIndices?: readonly number[];
readonly includeCellLinks?: boolean;
}
function nodeKey(node: CandidateNode): string {
return `${String(node.cell)}:${String(node.value)}`;
}
function orderedNodes(
first: CandidateNode,
second: CandidateNode,
): readonly [CandidateNode, CandidateNode] {
return nodeKey(first) < nodeKey(second) ? [first, second] : [second, first];
}
function linkKey(a: CandidateNode, b: CandidateNode): string {
const [first, second] = orderedNodes(a, b);
return `${nodeKey(first)}-${nodeKey(second)}`;
}
function addLink(
links: Map<
string,
{
kind: CandidateLinkKind;
a: CandidateNode;
b: CandidateNode;
contexts: CandidateLinkContext[];
}
>,
kind: CandidateLinkKind,
first: CandidateNode,
second: CandidateNode,
context: CandidateLinkContext,
): void {
const [a, b] = orderedNodes(first, second);
const key = linkKey(a, b);
const existing = links.get(key);
if (existing === undefined) {
links.set(key, { kind, a, b, contexts: [context] });
return;
}
if (kind === "strong") existing.kind = "strong";
if (!existing.contexts.some(({ label }) => label === context.label)) {
existing.contexts.push(context);
}
}
function candidateValues(mask: number, size: number): number[] {
const result: number[] = [];
for (let value = 1; value <= size; value += 1) {
if ((mask & (1 << (value - 1))) !== 0) result.push(value);
}
return result;
}
export function candidateValuesFromMask(mask: number, size: number): number[] {
if (!Number.isInteger(size) || size < 1 || size > 30) return [];
return candidateValues(Number.isInteger(mask) ? mask : 0, size);
}
export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): string {
switch (unit.kind) {
case "row":
return `Row ${String(unit.index + 1)}`;
case "column":
return `Column ${String(unit.index + 1)}`;
case "region":
return `Region ${String(unit.index + 1)}`;
case "diagonal":
return unit.index === 0 ? "Main diagonal" : "Anti-diagonal";
}
}
/** All houses touching at least one of the supplied cells, in compile order. */
export function candidateUnitIndicesForCells(
puzzle: NormalizedPuzzle,
cells: readonly number[],
): number[] {
const compiled = compilePuzzle(puzzle);
const indices = new Set<number>();
for (const cell of cells) {
for (const unitIndex of compiled.unitsByCell[cell] ?? []) {
indices.add(unitIndex);
}
}
return [...indices].sort((a, b) => a - b);
}
export function inspectCandidateCells(
puzzle: NormalizedPuzzle,
candidateMasks: readonly number[],
cells: readonly number[],
): CellCandidateInspection[] {
const compiled = compilePuzzle(puzzle);
return [...new Set(cells)]
.filter(
(cell) => Number.isInteger(cell) && cell >= 0 && cell < puzzle.size ** 2,
)
.sort((a, b) => a - b)
.map((cell) => ({
cell,
values: candidateValues(candidateMasks[cell] ?? 0, puzzle.size),
houseLabels: (compiled.unitsByCell[cell] ?? []).map((unitIndex) =>
houseLabel(compiled.units[unitIndex]!),
),
}));
}
export function inspectCandidateHouse(
puzzle: NormalizedPuzzle,
values: readonly number[],
candidateMasks: readonly number[],
unitIndex: number,
): HouseCandidateInspection | undefined {
const compiled = compilePuzzle(puzzle);
return inspectCandidateHouseWithCompiled(
compiled,
values,
candidateMasks,
unitIndex,
);
}
function inspectCandidateHouseWithCompiled(
compiled: CompiledPuzzle,
values: readonly number[],
candidateMasks: readonly number[],
unitIndex: number,
): HouseCandidateInspection | undefined {
const { puzzle } = compiled;
const unit = compiled.units[unitIndex];
if (unit === undefined) return undefined;
const placed = new Set(
unit.cells
.map((cell) => values[cell] ?? 0)
.filter((value) => value >= 1 && value <= puzzle.size),
);
const missingValues = Array.from(
{ length: puzzle.size },
(_, index) => index + 1,
).filter((value) => !placed.has(value));
const positions = missingValues.map((value) => {
const cells = unit.cells.filter(
(cell) =>
(values[cell] ?? 0) === 0 &&
((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
);
return {
value,
cells,
linkKind:
cells.length === 2
? ("strong" as const)
: cells.length > 2
? ("weak" as const)
: ("none" as const),
};
});
return {
unitIndex,
kind: unit.kind,
index: unit.index,
label: houseLabel(unit),
cells: unit.cells,
missingValues,
positions,
};
}
export function inspectCandidateHouses(
puzzle: NormalizedPuzzle,
values: readonly number[],
candidateMasks: readonly number[],
unitIndices?: readonly number[],
): HouseCandidateInspection[] {
const compiled = compilePuzzle(puzzle);
const indices = unitIndices ?? compiled.units.map((_, index) => index);
return [...new Set(indices)]
.map((unitIndex) =>
inspectCandidateHouseWithCompiled(
compiled,
values,
candidateMasks,
unitIndex,
),
)
.filter((inspection): inspection is HouseCandidateInspection =>
Boolean(inspection),
);
}
/**
* Derive the standard candidate-link graph from houses and cells.
*
* - two positions for a digit in a house form a strong link;
* - three or more positions form pairwise weak links;
* - two candidates in one cell form a strong link;
* - three or more candidates in one cell form pairwise weak links.
*/
export function deriveCandidateLinks(
puzzle: NormalizedPuzzle,
candidateMasks: readonly number[],
options: CandidateLinkOptions = {},
): CandidateLink[] {
const compiled = compilePuzzle(puzzle);
const activeValues =
options.values === undefined
? undefined
: new Set(
options.values.filter(
(value) =>
Number.isInteger(value) && value >= 1 && value <= puzzle.size,
),
);
const links = new Map<
string,
{
kind: CandidateLinkKind;
a: CandidateNode;
b: CandidateNode;
contexts: CandidateLinkContext[];
}
>();
const unitIndices =
options.unitIndices ?? compiled.units.map((_, index) => index);
for (const unitIndex of new Set(unitIndices)) {
const unit = compiled.units[unitIndex];
if (unit === undefined) continue;
for (let value = 1; value <= puzzle.size; value += 1) {
if (activeValues !== undefined && !activeValues.has(value)) continue;
const nodes = unit.cells
.filter(
(cell) => ((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
)
.map((cell) => ({ cell, value }));
if (nodes.length < 2) continue;
const kind: CandidateLinkKind = nodes.length === 2 ? "strong" : "weak";
for (let a = 0; a < nodes.length; a += 1) {
for (let b = a + 1; b < nodes.length; b += 1) {
addLink(links, kind, nodes[a]!, nodes[b]!, {
kind: "house",
label: houseLabel(unit),
unitKind: unit.kind,
unitIndex,
});
}
}
}
}
if (options.includeCellLinks !== false) {
const cellIndices =
options.cellIndices ??
Array.from({ length: puzzle.size ** 2 }, (_, cell) => cell);
for (const cell of new Set(cellIndices)) {
if (!Number.isInteger(cell) || cell < 0 || cell >= puzzle.size ** 2)
continue;
const allValues = candidateValues(candidateMasks[cell] ?? 0, puzzle.size);
if (allValues.length < 2) continue;
const kind: CandidateLinkKind =
allValues.length === 2 ? "strong" : "weak";
const visibleValues =
activeValues === undefined
? allValues
: allValues.filter((value) => activeValues.has(value));
for (let a = 0; a < visibleValues.length; a += 1) {
for (let b = a + 1; b < visibleValues.length; b += 1) {
addLink(
links,
kind,
{ cell, value: visibleValues[a]! },
{ cell, value: visibleValues[b]! },
{ kind: "cell", label: `Cell ${cellLabel(cell, puzzle.size)}` },
);
}
}
}
}
return [...links.entries()]
.map(([id, link]) => ({ id, ...link }))
.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "strong" ? -1 : 1;
return a.id.localeCompare(b.id, undefined, { numeric: true });
});
}
export function candidateCellsForValues(
candidateMasks: readonly number[],
values: readonly number[],
): number[] {
const combinedMask = values.reduce(
(mask, value) =>
Number.isInteger(value) && value >= 1 && value <= 30
? mask | (1 << (value - 1))
: mask,
0,
);
if (combinedMask === 0) return [];
return candidateMasks.flatMap((mask, cell) =>
(mask & combinedMask) !== 0 ? [cell] : [],
);
}
export function cellLabel(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}