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): 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(); 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)}`; }