diff --git a/benchmark/config/cases.ts b/benchmark/config/cases.ts index bd64b9f..477e72f 100644 --- a/benchmark/config/cases.ts +++ b/benchmark/config/cases.ts @@ -42,7 +42,31 @@ export const cases = { problemType: 'pentomino', parameters: {}, executeStrategy: (solver, prepared) => solver.solveCount(prepared, 100) - } satisfies BenchmarkCase<'pentomino'> + } satisfies BenchmarkCase<'pentomino'>, + + 'index-width-16': { + id: 'index-width-16', + name: 'Fresh exhaustive search at 65,535 nodes (Uint16)', + problemType: 'index-width', + parameters: { nodeCount: 65_535 }, + executeStrategy: (solver, prepared) => solver.solveAll(prepared) + } satisfies BenchmarkCase<'index-width'>, + + 'index-width-32': { + id: 'index-width-32', + name: 'Fresh exhaustive search at 65,536 nodes (Int32)', + problemType: 'index-width', + parameters: { nodeCount: 65_536 }, + executeStrategy: (solver, prepared) => solver.solveAll(prepared) + } satisfies BenchmarkCase<'index-width'>, + + 'index-width-mixed': { + id: 'index-width-mixed', + name: 'Fresh sequential Uint16 and Int32 exhaustive searches', + problemType: 'index-width', + parameters: { nodeCount: 65_536 }, + executeStrategy: (solver, prepared) => solver.solveAll(prepared) + } satisfies BenchmarkCase<'index-width'> } as const export type CaseId = keyof typeof cases diff --git a/benchmark/config/groups.ts b/benchmark/config/groups.ts index fb6b08e..28f80c2 100644 --- a/benchmark/config/groups.ts +++ b/benchmark/config/groups.ts @@ -11,7 +11,9 @@ import { externalSolversWithoutDancingLinksAlgorithm } from './solvers.js' -type BenchmarkMatrix = Record +// Groups intentionally select only relevant cases. Width-boundary regression +// tasks, for example, have no meaningful external-library comparison. +type BenchmarkMatrix = Partial> interface BenchmarkGroup { readonly name: string @@ -32,7 +34,10 @@ export const groups = { 'sudoku-hard': internalSolvers, 'pentomino-1': internalSolvers, 'pentomino-10': internalSolvers, - 'pentomino-100': internalSolvers + 'pentomino-100': internalSolvers, + 'index-width-16': ['width-fresh'], + 'index-width-32': ['width-fresh'], + 'index-width-mixed': ['width-mixed'] } }, diff --git a/benchmark/config/problems.ts b/benchmark/config/problems.ts index 6c9953e..2981f2a 100644 --- a/benchmark/config/problems.ts +++ b/benchmark/config/problems.ts @@ -8,6 +8,7 @@ import { ProblemRegistry } from '../types.js' // Problem definition imports import { generateSudokuConstraints } from '../problems/sudoku/definition.js' import { generatePentominoConstraints } from '../problems/pentomino/definition.js' +import { generateIndexWidthConstraints } from '../problems/index-width/definition.js' /** * Registry of all available problem definitions @@ -16,6 +17,7 @@ import { generatePentominoConstraints } from '../problems/pentomino/definition.j export const problems: ProblemRegistry = { sudoku: generateSudokuConstraints, pentomino: generatePentominoConstraints, + 'index-width': generateIndexWidthConstraints, 'n-queens': (() => { throw new Error('N-Queens not implemented yet') }) as any // TODO: implement diff --git a/benchmark/config/solvers.ts b/benchmark/config/solvers.ts index 9615969..94d50d7 100644 --- a/benchmark/config/solvers.ts +++ b/benchmark/config/solvers.ts @@ -8,6 +8,10 @@ import { DancingLinksSparseSolver } from '../solvers/InternalSparseSolver.js' import { DancingLinksBinarySolver } from '../solvers/InternalBinarySolver.js' import { DancingLinksTemplateSolver } from '../solvers/InternalTemplateSolver.js' import { DancingLinksGeneratorSolver } from '../solvers/InternalGeneratorSolver.js' +import { + IndexWidthFreshSolver, + IndexWidthMixedSolver +} from '../solvers/InternalIndexWidthSolver.js' // External library solver imports import { DlxlibSolver } from '../solvers/ExternalDlxlibSolver.js' @@ -23,6 +27,8 @@ export const solvers = { binary: DancingLinksBinarySolver, template: DancingLinksTemplateSolver, generator: DancingLinksGeneratorSolver, + 'width-fresh': IndexWidthFreshSolver, + 'width-mixed': IndexWidthMixedSolver, dlxlib: DlxlibSolver, dance: DanceSolver, 'dancing-links-algorithm': DancingLinksAlgorithmSolver diff --git a/benchmark/problems/index-width/definition.ts b/benchmark/problems/index-width/definition.ts new file mode 100644 index 0000000..9960b4b --- /dev/null +++ b/benchmark/problems/index-width/definition.ts @@ -0,0 +1,42 @@ +/** + * Exact node-count workloads for the Uint16/Int32 storage boundary. + * + * Column 1 appears only in the final row and is placed last in that row. Search + * must therefore select the matrix's highest-index node, then cover/uncover the + * other 63 columns. The 65,535- and 65,536-node variants differ by one filler + * node, exercising behavior immediately on both sides of the fallback cutoff. + */ + +import { IndexWidthParams, StandardConstraints } from '../../types.js' + +export const INDEX_WIDTH_PRIMARY_COLUMNS = 64 +const HEADER_NODES = INDEX_WIDTH_PRIMARY_COLUMNS + 1 +const PADDING_ROWS = 1_038 +const FORCED_COLUMN = 1 + +export function generateIndexWidthConstraints(params: IndexWidthParams): StandardConstraints { + const paddingRow: number[] = [] + for (let column = 0; column < INDEX_WIDTH_PRIMARY_COLUMNS; column++) { + if (column !== FORCED_COLUMN) { + paddingRow.push(column) + } + } + + const terminalRow = [...paddingRow, FORCED_COLUMN] + const fixedNodes = HEADER_NODES + PADDING_ROWS * paddingRow.length + terminalRow.length + const fillerWidth = params.nodeCount - fixedNodes + if (fillerWidth !== 12 && fillerWidth !== 13) { + throw new Error(`Unsupported index-width node count: ${params.nodeCount}`) + } + + // Reusing one immutable padding array keeps input setup compact. Timed solver + // ingestion still creates every row record and matrix node from scratch. + const primaryConstraints = new Array(PADDING_ROWS + 2) + for (let row = 0; row < PADDING_ROWS; row++) { + primaryConstraints[row] = paddingRow + } + primaryConstraints[PADDING_ROWS] = paddingRow.slice(0, fillerWidth) + primaryConstraints[PADDING_ROWS + 1] = terminalRow + + return { primaryConstraints } +} diff --git a/benchmark/runner.ts b/benchmark/runner.ts index 2417c1b..ab8410a 100644 --- a/benchmark/runner.ts +++ b/benchmark/runner.ts @@ -10,7 +10,7 @@ import { BenchmarkOptions, BenchmarkSection, BenchmarkResult } from './types.js' import { cases } from './config/cases.js' import { groups, getGroup } from './config/groups.js' import { problems } from './config/problems.js' -import { solvers } from './config/solvers.js' +import { externalSolvers, solvers } from './config/solvers.js' // Track deprecated tests (for compatibility with existing benchmark system) const deprecatedTests = new Set() @@ -66,7 +66,10 @@ async function runBenchmarksFromMatrix( } const constraints = problemFn(benchmarkCase.parameters as any) // Type assertion needed due to discriminated union - const bench = new Bench() + // A benchmark that throws must fail the command. Tinybench otherwise records + // an errored task and continues, which can make a broken implementation look + // fast by silently removing it from the results table. + const bench = new Bench({ throws: true }) // Add each solver to the benchmark suite for (const solverId of solverIds) { @@ -94,6 +97,13 @@ async function runBenchmarksFromMatrix( benchmarkCase.executeStrategy(solver, prepared) }) } catch (error) { + // External packages are optional comparison points and may reject a + // problem shape they do not support. Every in-repository solver is a + // required benchmark target, so setup failures must stop validation. + if (!(externalSolvers as readonly string[]).includes(solverId)) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to prepare ${solverId} for ${caseId}: ${message}`) + } if (!options.quiet) { console.warn(`Skipping ${solverId} for ${caseId}: ${(error as Error).message}`) } @@ -151,6 +161,17 @@ async function runBench( } } + // Keep this guard even with `throws: true`: an aborted or otherwise incomplete + // task has no trustworthy throughput and must not become an empty, successful + // benchmark section. + const incompleteTasks = bench.tasks.filter(task => task.result?.state !== 'completed') + if (incompleteTasks.length > 0) { + const details = incompleteTasks + .map(task => `${task.name} (${task.result?.state ?? 'missing result'})`) + .join(', ') + throw new Error(`Benchmark tasks did not complete: ${details}`) + } + // Find and display fastest if (!options.quiet && sectionResults.length > 0) { const fastest = sectionResults.reduce((prev, current) => diff --git a/benchmark/solvers/InternalIndexWidthSolver.ts b/benchmark/solvers/InternalIndexWidthSolver.ts new file mode 100644 index 0000000..947bf12 --- /dev/null +++ b/benchmark/solvers/InternalIndexWidthSolver.ts @@ -0,0 +1,176 @@ +/** + * Honest end-to-end benchmarks for adaptive 16/32-bit storage. + * + * Prepared input rows live outside timing like the other sparse benchmarks, but + * every measured operation creates a public solver, ingests all constraints, + * builds fresh typed-array stores, and exhaustively searches them. No compiled + * topology or result is reused between operations. + */ + +import { DancingLinks } from '../../index.js' +import { ProblemBuilder } from '../../lib/core/problem-builder.js' +import type { ConstraintRow, SparseConstraintBatch } from '../../lib/types/interfaces.js' +import { + generateIndexWidthConstraints, + INDEX_WIDTH_PRIMARY_COLUMNS +} from '../problems/index-width/definition.js' +import { Solver, StandardConstraints } from '../types.js' + +interface PreparedIndexWidth { + readonly nodeCount: number + readonly constraints: SparseConstraintBatch + readonly rows: ConstraintRow[] +} + +interface PreparedMixedWidths { + readonly uint16: PreparedIndexWidth + readonly int32: PreparedIndexWidth +} + +function prepareRows(constraints: StandardConstraints): PreparedIndexWidth { + const sparse = constraints.primaryConstraints.map((columnIndices, data) => ({ + data, + columnIndices + })) + const rows = sparse.map(({ data, columnIndices }) => ({ + coveredColumns: columnIndices, + data + })) + let rowNodes = 0 + for (const row of rows) { + rowNodes += row.coveredColumns.length + } + const prepared = { + nodeCount: INDEX_WIDTH_PRIMARY_COLUMNS + 1 + rowNodes, + constraints: sparse, + rows + } + verifyWidthAndSearch(prepared) + return prepared +} + +/** Prove once, outside timing, that the intended fallback and hot search path run. */ +function verifyWidthAndSearch(prepared: PreparedIndexWidth): void { + if (prepared.nodeCount !== 0xffff && prepared.nodeCount !== 0x10000) { + throw new Error(`Unexpected index-width matrix size: ${prepared.nodeCount}`) + } + + const context = ProblemBuilder.buildContext({ + numPrimary: INDEX_WIDTH_PRIMARY_COLUMNS, + numSecondary: 0, + rows: prepared.rows + }) + const ExpectedNodeIndexArray = prepared.nodeCount === 0xffff ? Uint16Array : Int32Array + const views: Array< + [ + Int32Array | Uint16Array, + typeof Int32Array | typeof Uint16Array + ] + > = [ + [context.nodes.up, ExpectedNodeIndexArray], + [context.nodes.down, ExpectedNodeIndexArray], + [context.nodes.col, Uint16Array], + [context.nodes.rowIndex, Uint16Array], + [context.nodes.rowStart, ExpectedNodeIndexArray], + [context.columns.len, ExpectedNodeIndexArray], + [context.columns.prev, ExpectedNodeIndexArray], + [context.columns.next, ExpectedNodeIndexArray] + ] + for (const [view, ExpectedArray] of views) { + if (!(view instanceof ExpectedArray)) { + throw new Error(`Matrix ${prepared.nodeCount} selected the wrong integer storage width`) + } + } + + // Root occupies index 0, so primary column 1 has header index 2. It contains + // only the terminal row, whose forced node was deliberately written last. + const highestNode = prepared.nodeCount - 1 + if (context.nodes.down[2] !== highestNode) { + throw new Error(`Search will not select the expected high node ${highestNode}`) + } + + // Exercise the same public batch-ingestion/build/search path used by the timed + // operation once outside timing. This prevents a handler regression from + // producing a fast but incorrect benchmark while the direct builder check + // above still happens to pass. + const solutions = solveFreshAll(prepared) + const terminalRow = prepared.rows.length - 1 + if ( + solutions.length !== 1 || + solutions[0]?.length !== 1 || + solutions[0][0]?.index !== terminalRow + ) { + throw new Error( + `Index-width matrix ${prepared.nodeCount} did not produce its terminal solution` + ) + } +} + +function solveFreshAll(prepared: PreparedIndexWidth) { + const solver = new DancingLinks().createSolver({ + columns: INDEX_WIDTH_PRIMARY_COLUMNS + }) + solver.addSparseConstraints(prepared.constraints) + return solver.findAll() +} + +function solveFreshOne(prepared: PreparedIndexWidth): unknown { + const solver = new DancingLinks().createSolver({ + columns: INDEX_WIDTH_PRIMARY_COLUMNS + }) + solver.addSparseConstraints(prepared.constraints) + return solver.findOne() +} + +function solveFreshCount(prepared: PreparedIndexWidth, count: number): unknown { + const solver = new DancingLinks().createSolver({ + columns: INDEX_WIDTH_PRIMARY_COLUMNS + }) + solver.addSparseConstraints(prepared.constraints) + return solver.find(count) +} + +export class IndexWidthFreshSolver extends Solver { + static readonly name = 'dancing-links (fresh build + exhaustive search)' + + prepare(constraints: StandardConstraints): PreparedIndexWidth { + return prepareRows(constraints) + } + + solveAll(prepared: PreparedIndexWidth): unknown { + return solveFreshAll(prepared) + } + + solveOne(prepared: PreparedIndexWidth): unknown { + return solveFreshOne(prepared) + } + + solveCount(prepared: PreparedIndexWidth, count: number): unknown { + return solveFreshCount(prepared, count) + } +} + +export class IndexWidthMixedSolver extends Solver { + static readonly name = 'dancing-links (fresh Uint16 → Int32 pair)' + + prepare(int32Constraints: StandardConstraints): PreparedMixedWidths { + // The case supplies the 32-bit half. Generate and verify the adjacent 16-bit + // half once so every timed pair alternates the shared kernels across widths. + return { + uint16: prepareRows(generateIndexWidthConstraints({ nodeCount: 0xffff })), + int32: prepareRows(int32Constraints) + } + } + + solveAll(prepared: PreparedMixedWidths): unknown { + return [solveFreshAll(prepared.uint16), solveFreshAll(prepared.int32)] + } + + solveOne(prepared: PreparedMixedWidths): unknown { + return [solveFreshOne(prepared.uint16), solveFreshOne(prepared.int32)] + } + + solveCount(prepared: PreparedMixedWidths, count: number): unknown { + return [solveFreshCount(prepared.uint16, count), solveFreshCount(prepared.int32, count)] + } +} diff --git a/benchmark/types.ts b/benchmark/types.ts index 4b44362..1e1a9d1 100644 --- a/benchmark/types.ts +++ b/benchmark/types.ts @@ -63,10 +63,14 @@ export interface NQueensParams { n: number } +export interface IndexWidthParams { + nodeCount: 65_535 | 65_536 +} + /** * Problem type discriminated union */ -export type ProblemType = 'sudoku' | 'pentomino' | 'n-queens' +export type ProblemType = 'sudoku' | 'pentomino' | 'n-queens' | 'index-width' /** * Problem parameters mapped to their types @@ -77,7 +81,9 @@ export type ProblemParameters = T extends 'sudoku' ? PentominoParams : T extends 'n-queens' ? NQueensParams - : never + : T extends 'index-width' + ? IndexWidthParams + : never /** * Benchmark case definition with proper type inference diff --git a/lib/constraints/handlers/complex.ts b/lib/constraints/handlers/complex.ts index d8d6227..0f87f11 100644 --- a/lib/constraints/handlers/complex.ts +++ b/lib/constraints/handlers/complex.ts @@ -123,6 +123,24 @@ export class ComplexConstraintHandler implements ConstraintHandler[]): void { + this.constraints = constraints + } + + /** + * Copy the compiled template's shared row-reference table on first mutation. + * Read-only template solvers avoid that O(number of rows) work entirely, and + * both fresh and template solvers retain this one handler class and hot shape. + */ + detachConstraints(): void { + this.constraints = this.constraints.slice() + } + getConstraints(): ConstraintRow[] { return this.constraints } diff --git a/lib/constraints/handlers/simple.ts b/lib/constraints/handlers/simple.ts index e4198e1..65892ea 100644 --- a/lib/constraints/handlers/simple.ts +++ b/lib/constraints/handlers/simple.ts @@ -33,6 +33,8 @@ export class SimpleConstraintHandler implements ConstraintHandler): this { + const target = this.constraints + for (let i = 0; i < constraints.length; i++) { const { data, columnIndices } = constraints[i] @@ -43,9 +45,20 @@ export class SimpleConstraintHandler implements ConstraintHandler implements ConstraintHandler[]): void { + this.constraints = constraints + } + + /** + * Detach a template solver from its shared immutable row-reference table. + * + * Regular handlers already own their rows and never call this method. A + * template handler starts with the compiled context's array so creation is + * O(1), then its first solver-local mutation pays the same shallow copy that + * used to be paid eagerly by every solver, including read-only instances. + */ + detachConstraints(): void { + this.constraints = this.constraints.slice() + } + getConstraints(): ConstraintRow[] { return this.constraints } diff --git a/lib/core/algorithm.ts b/lib/core/algorithm.ts index d4e30c9..f1b4ee3 100644 --- a/lib/core/algorithm.ts +++ b/lib/core/algorithm.ts @@ -8,8 +8,8 @@ * Based on: https://github.com/shreevatsa/knuth-literate-programs/blob/master/programs/dance.pdf * * ARCHITECTURE: - * - Struct-of-Arrays data layout using Int32Array for navigation fields - * - Index-based references with NULL_INDEX (-1) for null pointers + * - Struct-of-Arrays data layout using adaptive 16/32-bit navigation fields + * - Index-based references with a width-appropriate reserved sentinel * - Pre-allocated storage based on constraint matrix analysis * - State machine pattern to avoid recursion and enable goto-like control flow * @@ -17,11 +17,248 @@ * - Early termination for impossible constraints (columns with 0 options) * - Unit propagation for forced moves (columns with 1 option) * - Pre-calculated pointers to reduce data dependencies + * - Local typed-array aliases to avoid repeated object-property loads in hot loops */ -import { Result } from '../types/interfaces.js' +import { ConstraintRow, Result } from '../types/interfaces.js' +import { ColumnStore, NodeStore } from './data-structures.js' import { SearchContext } from './problem-builder.js' +/** + * Hide a column and every row that intersects it. + * + * Keep this hot operation at module scope so repeated searches share one + * function identity. Creating it inside search causes optimized callers to + * deoptimize when the next search installs a different closure at the same + * call site. + */ +function cover(nodes: NodeStore, columns: ColumnStore, colIndex: number): void { + const { down, up, col, rowIndex, rowStart } = nodes + const { len, prev, next } = columns + const leftColIndex = prev[colIndex] + const rightColIndex = next[colIndex] + next[leftColIndex] = rightColIndex + prev[rightColIndex] = leftColIndex + const colHeadIndex = colIndex + for (let rr = down[colHeadIndex]; rr !== colHeadIndex; ) { + const nextRR = down[rr] + const row = rowIndex[rr] + const firstInRow = rowStart[row] + const afterRow = rowStart[row + 1] + // This pair is the circular right traversal expressed as two contiguous + // ranges. It preserves DLX ordering while removing a dependent pointer load + // from every visited node and exposing linear access to the CPU prefetcher. + for (let nn = rr + 1; nn < afterRow; nn++) { + const uu = up[nn] + const dd = down[nn] + down[uu] = dd + up[dd] = uu + len[col[nn]]-- + } + for (let nn = firstInRow; nn < rr; nn++) { + const uu = up[nn] + const dd = down[nn] + down[uu] = dd + up[dd] = uu + len[col[nn]]-- + } + rr = nextRR + } +} +/** Restore a previously covered column. */ +function uncover(nodes: NodeStore, columns: ColumnStore, colIndex: number): void { + const { down, up, col, rowIndex, rowStart } = nodes + const { len, prev, next } = columns + const colHeadIndex = colIndex + for (let rr = up[colHeadIndex]; rr !== colHeadIndex; ) { + const nextRR = up[rr] + const row = rowIndex[rr] + const firstInRow = rowStart[row] + const afterRow = rowStart[row + 1] + // Reverse the exact contiguous ranges used by cover so vertical links are + // restored in DLX order without a per-node left-pointer dependency. + for (let nn = rr - 1; nn >= firstInRow; nn--) { + const uu = up[nn] + const dd = down[nn] + down[uu] = nn + up[dd] = nn + len[col[nn]]++ + } + for (let nn = afterRow - 1; nn > rr; nn--) { + const uu = up[nn] + const dd = down[nn] + down[uu] = nn + up[dd] = nn + len[col[nn]]++ + } + rr = nextRR + } + const leftColIndex = prev[colIndex] + const rightColIndex = next[colIndex] + next[leftColIndex] = colIndex + prev[rightColIndex] = colIndex +} +/** + * Cover every other column in a chosen row as one hot operation. + * + * A typical exact-cover row touches several columns. Batching them here loads + * the typed-array views once and crosses the JS function boundary once per row, + * rather than once per column, while preserving the circular rightward order. + */ +function coverRow(nodes: NodeStore, columns: ColumnStore, currentNodeIndex: number): void { + const { down, up, col, rowIndex, rowStart } = nodes + const { len, prev, next } = columns + const chosenRow = rowIndex[currentNodeIndex] + const firstChosenNode = rowStart[chosenRow] + const afterChosenRow = rowStart[chosenRow + 1] + let pp = currentNodeIndex + 1 + let afterRange = afterChosenRow + while (true) { + for (; pp < afterRange; pp++) { + const colIndex = col[pp] + const leftColIndex = prev[colIndex] + const rightColIndex = next[colIndex] + next[leftColIndex] = rightColIndex + prev[rightColIndex] = leftColIndex + for (let rr = down[colIndex]; rr !== colIndex; ) { + const nextRR = down[rr] + const row = rowIndex[rr] + const firstInRow = rowStart[row] + const afterRow = rowStart[row + 1] + for (let nn = rr + 1; nn < afterRow; nn++) { + const uu = up[nn] + const dd = down[nn] + down[uu] = dd + up[dd] = uu + len[col[nn]]-- + } + for (let nn = firstInRow; nn < rr; nn++) { + const uu = up[nn] + const dd = down[nn] + down[uu] = dd + up[dd] = uu + len[col[nn]]-- + } + rr = nextRR + } + } + if (afterRange === currentNodeIndex) { + return + } + pp = firstChosenNode + afterRange = currentNodeIndex + } +} +/** Restore a chosen row's other columns in the exact reverse order of coverRow. */ +function uncoverRow(nodes: NodeStore, columns: ColumnStore, currentNodeIndex: number): void { + const { down, up, col, rowIndex, rowStart } = nodes + const { len, prev, next } = columns + const chosenRow = rowIndex[currentNodeIndex] + const firstChosenNode = rowStart[chosenRow] + const afterChosenRow = rowStart[chosenRow + 1] + let pp = currentNodeIndex - 1 + let beforeRange = firstChosenNode - 1 + while (true) { + for (; pp > beforeRange; pp--) { + const colIndex = col[pp] + for (let rr = up[colIndex]; rr !== colIndex; ) { + const nextRR = up[rr] + const row = rowIndex[rr] + const firstInRow = rowStart[row] + const afterRow = rowStart[row + 1] + for (let nn = rr - 1; nn >= firstInRow; nn--) { + const uu = up[nn] + const dd = down[nn] + down[uu] = nn + up[dd] = nn + len[col[nn]]++ + } + for (let nn = afterRow - 1; nn > rr; nn--) { + const uu = up[nn] + const dd = down[nn] + down[uu] = nn + up[dd] = nn + len[col[nn]]++ + } + rr = nextRR + } + const leftColIndex = prev[colIndex] + const rightColIndex = next[colIndex] + next[leftColIndex] = colIndex + prev[rightColIndex] = colIndex + } + if (beforeRange === currentNodeIndex) { + return + } + pp = afterChosenRow - 1 + beforeRange = currentNodeIndex + } +} +/** Select the shortest active primary column. */ +function pickBestColumn(columns: ColumnStore): number { + const rootColIndex = 0 + const { len, next } = columns + const rootNext = next[rootColIndex] + let lowestLen = len[rootNext] + let lowest = rootNext + + // Zero is the absolute lower bound, so an impossible first column makes the + // rest of the minimum-selection scan redundant. + if (lowestLen === 0) { + return lowest + } + + for ( + let curColIndex = next[rootNext]; + curColIndex !== rootColIndex; + curColIndex = next[curColIndex] + ) { + const length = len[curColIndex] + + // A unit column is forced. Prioritizing it propagates that constraint + // immediately and avoids exploring a wider branching column first. + if (length === 1) { + return curColIndex + } + + if (length < lowestLen) { + lowestLen = length + lowest = curColIndex + + // No later column can improve on zero. + if (lowestLen === 0) { + break + } + } + } + + return lowest +} + +/** + * Materialize one solution outside the search loop's optimization unit. + * + * Object creation is rare relative to link updates. Isolating it here keeps + * allocation feedback out of the hot state machine, while the exact-size result + * array avoids dynamic growth for each solution that is returned. + */ +function materializeSolution( + nodes: NodeStore, + rows: ConstraintRow[], + choice: number[], + deepestLevel: number +): Result[] { + const results = new Array>(deepestLevel + 1) + for (let level = 0; level <= deepestLevel; level++) { + const nodeIndex = choice[level]! + results[level] = { + index: nodes.rowIndex[nodeIndex], + data: rows[nodes.rowIndex[nodeIndex]].data + } + } + return results +} + /** * State machine states for Dancing Links search algorithm * @@ -49,21 +286,21 @@ enum SearchState { */ export function search(context: SearchContext, numSolutions: number): Result[][] { const { nodes, columns } = context + const { down, col } = nodes + const { next } = columns const solutions: Result[][] = [] - // Determine search resumption state based on context + // Root column is always at index 0 (created by ProblemBuilder) + const rootColIndex = 0 let currentSearchState: SearchState - let running = true - - const isFirstSearch = !context.hasStarted - const isResumedAtDepth = context.hasStarted && context.level > 0 - if (isFirstSearch) { - // Starting fresh search - begin with column selection + if (!context.hasStarted) { currentSearchState = SearchState.FORWARD context.hasStarted = true - } else if (isResumedAtDepth) { - // Resuming from paused search at some depth - continue from current choice + } else if (context.level > 0 || next[rootColIndex] === rootColIndex) { + // A limited search can pause immediately after a solution at level 0. In + // that state every primary column is still covered, so the empty root list + // distinguishes a resumable choice from an exhausted root-level search. currentSearchState = SearchState.RECOVER } else { // Must be: hasStarted=true && level=0 (backtracked to root) @@ -80,245 +317,67 @@ export function search(context: SearchContext, numSolutions: number): Resu return [] } - // Root column is always at index 0 (created by ProblemBuilder) - const rootColIndex = 0 - - /** - * Cover operation - hide a column and all rows that intersect it - */ - function cover(colIndex: number) { - const leftColIndex = columns.prev[colIndex] - const rightColIndex = columns.next[colIndex] - - // Unlink column from column list - columns.next[leftColIndex] = rightColIndex - columns.prev[rightColIndex] = leftColIndex - - // From top to bottom, left to right: unlink every row node from its column - const colHeadIndex = columns.head[colIndex] - for (let rr = nodes.down[colHeadIndex]; rr !== colHeadIndex; ) { - // Store next pointer before modifying current node's links - const nextRR = nodes.down[rr] - for (let nn = nodes.right[rr]; nn !== rr; nn = nodes.right[nn]) { - const uu = nodes.up[nn] - const dd = nodes.down[nn] - - nodes.down[uu] = dd - nodes.up[dd] = uu - - columns.len[nodes.col[nn]]-- - } - rr = nextRR - } - } - - function uncover(colIndex: number) { - const colHeadIndex = columns.head[colIndex] - - // From bottom to top, right to left: relink every row node to its column - for (let rr = nodes.up[colHeadIndex]; rr !== colHeadIndex; ) { - // Store next pointer before modifying current node's links - const nextRR = nodes.up[rr] - for (let nn = nodes.left[rr]; nn !== rr; nn = nodes.left[nn]) { - const uu = nodes.up[nn] - const dd = nodes.down[nn] - - nodes.down[uu] = nn - nodes.up[dd] = nn - - columns.len[nodes.col[nn]]++ + // Keep the state transitions in one stable function. Besides avoiding call + // overhead, this prevents V8 from deoptimizing on fresh nested-function + // identities every time a new problem is solved. + while (true) { + switch (currentSearchState as SearchState) { + case SearchState.FORWARD: { + const bestColIndex = pickBestColumn(columns) + context.bestColIndex = bestColIndex + cover(nodes, columns, bestColIndex) + + const currentNodeIndex = down[bestColIndex] + context.currentNodeIndex = currentNodeIndex + context.choice[context.level] = currentNodeIndex + currentSearchState = SearchState.ADVANCE + break } - rr = nextRR - } - - // Relink column to column list - const leftColIndex = columns.prev[colIndex] - const rightColIndex = columns.next[colIndex] + case SearchState.ADVANCE: { + const currentNodeIndex = context.currentNodeIndex + if (currentNodeIndex === context.bestColIndex) { + currentSearchState = SearchState.BACKUP + break + } - columns.next[leftColIndex] = colIndex - columns.prev[rightColIndex] = colIndex - } + coverRow(nodes, columns, currentNodeIndex) - function pickBestColumn() { - const rootNext = columns.next[rootColIndex] - let lowestLen = columns.len[rootNext] - let lowest = rootNext + if (next[rootColIndex] === rootColIndex) { + solutions.push(materializeSolution(nodes, context.rows, context.choice, context.level)) - /** - * Early termination optimization for impossible constraints. - * - * When a column has zero remaining options, the current search path - * cannot lead to a valid solution since this constraint cannot be satisfied. - * Immediately selecting such columns triggers backtracking sooner, avoiding - * deeper recursion into impossible branches of the search tree. - * - * This is particularly effective in highly constrained problems where - * covering one constraint frequently eliminates all options for another. - */ - if (lowestLen === 0) { - context.bestColIndex = lowest - return - } - - /** - * Unit propagation optimization for forced constraints. - * - * When a column has exactly one remaining option, that option MUST be selected - * in any valid solution - there is no choice involved. Prioritizing these - * unit constraints reduces the search space by making forced moves immediately - * rather than exploring them through normal branching. - * - * This is highly effective in logic puzzles like Sudoku where filling one - * cell often creates cascading unit constraints in related rows/columns/blocks. - * The optimization transforms what would be deep branching trees into - * immediate constraint propagation. - */ - for ( - let curColIndex = columns.next[rootNext]; - curColIndex !== rootColIndex; - curColIndex = columns.next[curColIndex] - ) { - const length = columns.len[curColIndex] + currentSearchState = + solutions.length === numSolutions ? SearchState.DONE : SearchState.RECOVER + break + } - if (length === 1) { - context.bestColIndex = curColIndex - return + context.level++ + currentSearchState = SearchState.FORWARD + break } + case SearchState.BACKUP: + uncover(nodes, columns, context.bestColIndex) - if (length < lowestLen) { - lowestLen = length - lowest = curColIndex - - /** - * Short-circuit when impossible constraint found. - * - * If we encounter a column with zero options during our scan, - * we can immediately stop searching since no column can have - * fewer than zero options. This saves unnecessary iteration - * through remaining columns in highly constrained scenarios. - */ - if (lowestLen === 0) { + if (context.level === 0) { + currentSearchState = SearchState.DONE break } - } - } - - context.bestColIndex = lowest - } - - function forward() { - pickBestColumn() - cover(context.bestColIndex) - - context.currentNodeIndex = nodes.down[columns.head[context.bestColIndex]] - context.choice[context.level] = context.currentNodeIndex - - currentSearchState = SearchState.ADVANCE - } - - function recordSolution() { - const results: Result[] = [] - for (let l = 0; l <= context.level; l++) { - const nodeIndex = context.choice[l]! - results.push({ - index: nodes.rowIndex[nodeIndex], - data: nodes.data[nodeIndex]! - }) - } - solutions.push(results) - } - - function advance() { - const bestColHeadIndex = columns.head[context.bestColIndex] - if (context.currentNodeIndex === bestColHeadIndex) { - currentSearchState = SearchState.BACKUP - return - } - - for ( - let pp = nodes.right[context.currentNodeIndex]; - pp !== context.currentNodeIndex; - pp = nodes.right[pp] - ) { - cover(nodes.col[pp]) - } - - if (columns.next[rootColIndex] === rootColIndex) { - recordSolution() - if (solutions.length === numSolutions) { - currentSearchState = SearchState.DONE - } else { + context.level-- + context.currentNodeIndex = context.choice[context.level]! + context.bestColIndex = col[context.currentNodeIndex] currentSearchState = SearchState.RECOVER - } - return - } - - context.level = context.level + 1 - currentSearchState = SearchState.FORWARD - } - - function backup() { - // Restore the matrix state by uncovering the column we just tried - uncover(context.bestColIndex) - - const isAtRootLevel = context.level === 0 - if (isAtRootLevel) { - /** - * Reached root level during backtracking - search exhaustion. - * - * When we backtrack to level 0, it means we've tried all possible - * choices at every level and there are no more solution branches - * to explore. The search is complete. - */ - currentSearchState = SearchState.DONE - return - } - - // Move up one level in the search tree - context.level = context.level - 1 - - // Restore the choice and column context from the previous level - context.currentNodeIndex = context.choice[context.level]! - context.bestColIndex = nodes.col[context.currentNodeIndex] - - // Continue trying the next choice at this level - currentSearchState = SearchState.RECOVER - } - - function recover() { - for ( - let pp = nodes.left[context.currentNodeIndex]; - pp !== context.currentNodeIndex; - pp = nodes.left[pp] - ) { - uncover(nodes.col[pp]) - } - context.currentNodeIndex = nodes.down[context.currentNodeIndex] - context.choice[context.level] = context.currentNodeIndex - currentSearchState = SearchState.ADVANCE - } - - // Execute search state machine - while (running) { - switch (currentSearchState as SearchState) { - case SearchState.FORWARD: - forward() - break - case SearchState.ADVANCE: - advance() - break - case SearchState.BACKUP: - backup() break - case SearchState.RECOVER: - recover() + case SearchState.RECOVER: { + const currentNodeIndex = context.currentNodeIndex + uncoverRow(nodes, columns, currentNodeIndex) + const nextNodeIndex = down[currentNodeIndex] + context.currentNodeIndex = nextNodeIndex + context.choice[context.level] = nextNodeIndex + currentSearchState = SearchState.ADVANCE break + } default: - running = false - break + return solutions } } - - return solutions } diff --git a/lib/core/data-structures.ts b/lib/core/data-structures.ts index 6fbb0f3..4c5b946 100644 --- a/lib/core/data-structures.ts +++ b/lib/core/data-structures.ts @@ -4,9 +4,9 @@ * Implementation using typed arrays for better memory efficiency. * * ARCHITECTURE: - * - NodeStore: Contains all node data in separate Int32Array fields - * - ColumnStore: Contains all column data in separate Int32Array fields - * - Index-based references: Uses array indices instead of object pointers (NULL_INDEX = -1) + * - NodeStore: Contains node data in separate integer typed-array fields + * - ColumnStore: Contains column data in separate integer typed-array fields + * - Index-based references: Uses array indices and a width-appropriate sentinel * - Pre-allocated storage: Fixed-size arrays determined by constraint matrix analysis * * DESIGN CONSIDERATIONS: @@ -17,129 +17,124 @@ */ const NULL_INDEX = -1 +const UINT16_SENTINEL = 0xffff -export class NodeStore { - private capacity: number - private _size: number = 0 - - // Typed arrays for numeric fields - readonly left: Int32Array // Node indices (NULL_INDEX for null) - readonly right: Int32Array - readonly up: Int32Array - readonly down: Int32Array - readonly col: Int32Array // Column indices (NULL_INDEX for null) - readonly rowIndex: Int32Array // Original row index from input - - // Generic array for data (can't use typed array for generic T) - readonly data: Array - - constructor(maxNodes: number) { - this.capacity = maxNodes - this.left = new Int32Array(maxNodes).fill(NULL_INDEX) - this.right = new Int32Array(maxNodes).fill(NULL_INDEX) - this.up = new Int32Array(maxNodes).fill(NULL_INDEX) - this.down = new Int32Array(maxNodes).fill(NULL_INDEX) - this.col = new Int32Array(maxNodes).fill(NULL_INDEX) - this.rowIndex = new Int32Array(maxNodes).fill(NULL_INDEX) - this.data = new Array(maxNodes).fill(null) - } +type IndexArray = Int32Array | Uint16Array - /** - * Allocate a new node and return its index - */ - allocateNode(): number { - if (this._size >= this.capacity) { - throw new Error(`NodeStore capacity exceeded: ${this.capacity}`) - } - return this._size++ - } - - /** - * Initialize a node with self-referencing links (circular) - */ - initializeNode( - nodeIndex: number, - colIndex: number = NULL_INDEX, - rowIdx: number = NULL_INDEX, - nodeData: T | null = null - ): void { - this.left[nodeIndex] = nodeIndex - this.right[nodeIndex] = nodeIndex - this.up[nodeIndex] = nodeIndex - this.down[nodeIndex] = nodeIndex - this.col[nodeIndex] = colIndex - this.rowIndex[nodeIndex] = rowIdx - this.data[nodeIndex] = nodeData - } - - /** - * Link two nodes horizontally (left-right) - */ - linkHorizontal(leftIndex: number, rightIndex: number): void { - this.right[leftIndex] = rightIndex - this.left[rightIndex] = leftIndex - } - - /** - * Link two nodes vertically (up-down) - */ - linkVertical(upIndex: number, downIndex: number): void { - this.down[upIndex] = downIndex - this.up[downIndex] = upIndex - } - - get size(): number { - return this._size - } +function alignOffset(offset: number, bytesPerElement: number): number { + return Math.ceil(offset / bytesPerElement) * bytesPerElement } -export class ColumnStore { - private capacity: number - private _size: number = 0 - - readonly head: Int32Array // Head node indices - readonly len: Int32Array // Column lengths - readonly prev: Int32Array // Previous column indices (NULL_INDEX for null) - readonly next: Int32Array // Next column indices (NULL_INDEX for null) - - constructor(maxColumns: number) { - this.capacity = maxColumns - this.head = new Int32Array(maxColumns).fill(NULL_INDEX) - this.len = new Int32Array(maxColumns).fill(0) - this.prev = new Int32Array(maxColumns).fill(NULL_INDEX) - this.next = new Int32Array(maxColumns).fill(NULL_INDEX) - } - - /** - * Allocate a new column and return its index - */ - allocateColumn(): number { - if (this._size >= this.capacity) { - throw new Error(`ColumnStore capacity exceeded: ${this.capacity}`) +export class NodeStore { + readonly size: number + private readonly maxRows: number + private readonly maxColumns: number + private readonly buffer: ArrayBuffer + + readonly up: IndexArray + readonly down: IndexArray + readonly col: IndexArray // Column indices (sentinel for headers) + readonly rowIndex: IndexArray // Original row index from input + readonly rowStart: IndexArray // Contiguous node boundaries for each row + + constructor( + maxNodes: number, + maxRows: number, + maxColumns: number, + sourceBuffer?: ArrayBuffer, + immutableSource?: NodeStore + ) { + this.size = maxNodes + this.maxRows = maxRows + this.maxColumns = maxColumns + // Each field follows the domain of the values it stores. A large node table + // needs 32-bit navigation and row boundaries, but its column IDs and row IDs + // often still fit in 16 bits. Keeping those metadata streams narrow halves + // their search bandwidth and keeps the shared kernels' col/rowIndex load + // sites monomorphic when only navigation crosses the node-width boundary. + const NodeIndexArray = maxNodes <= UINT16_SENTINEL ? Uint16Array : Int32Array + const ColumnIndexArray = maxColumns <= UINT16_SENTINEL ? Uint16Array : Int32Array + const RowIndexArray = maxRows <= UINT16_SENTINEL ? Uint16Array : Int32Array + const nodeFieldBytes = maxNodes * NodeIndexArray.BYTES_PER_ELEMENT + const mutableBytes = nodeFieldBytes * 2 + + let colOffset = mutableBytes + let rowIndexOffset = mutableBytes + let rowStartOffset = mutableBytes + let bufferBytes = mutableBytes + if (!immutableSource) { + // Mixed-width views still share one allocation. Aligning only where a + // wider constructor requires it avoids separate buffers and their extra + // allocator/GC bookkeeping while adding at most a few padding bytes. + colOffset = alignOffset(bufferBytes, ColumnIndexArray.BYTES_PER_ELEMENT) + bufferBytes = colOffset + maxNodes * ColumnIndexArray.BYTES_PER_ELEMENT + rowIndexOffset = alignOffset(bufferBytes, RowIndexArray.BYTES_PER_ELEMENT) + bufferBytes = rowIndexOffset + maxNodes * RowIndexArray.BYTES_PER_ELEMENT + rowStartOffset = alignOffset(bufferBytes, NodeIndexArray.BYTES_PER_ELEMENT) + bufferBytes = rowStartOffset + (maxRows + 1) * NodeIndexArray.BYTES_PER_ELEMENT + } + this.buffer = sourceBuffer ?? new ArrayBuffer(bufferBytes) + + // ProblemBuilder writes every used slot directly. Typed arrays already + // arrive zeroed, so sentinel fills would add redundant full memory passes + // before the useful construction writes begin. One backing allocation also + // reduces allocator/GC bookkeeping and keeps the navigation tables within a + // compact virtual-memory range while retaining the SoA access pattern. + this.up = new NodeIndexArray(this.buffer, 0, maxNodes) + this.down = new NodeIndexArray(this.buffer, nodeFieldBytes, maxNodes) + if (immutableSource) { + // Search writes only up/down. Template clones share every read-only view, + // so native copying remains limited to navigation bytes even when metadata + // uses an independently narrower width. The private template owns the + // shared views' lifetime and is never searched. + this.col = immutableSource.col + this.rowIndex = immutableSource.rowIndex + this.rowStart = immutableSource.rowStart + } else { + this.col = new ColumnIndexArray(this.buffer, colOffset, maxNodes) + this.rowIndex = new RowIndexArray(this.buffer, rowIndexOffset, maxNodes) + this.rowStart = new NodeIndexArray(this.buffer, rowStartOffset, maxRows + 1) } - return this._size++ } - /** - * Initialize a column with given head node - */ - initializeColumn(colIndex: number, headNodeIndex: number): void { - this.head[colIndex] = headNodeIndex - this.len[colIndex] = 0 - this.prev[colIndex] = NULL_INDEX - this.next[colIndex] = NULL_INDEX + clone(): NodeStore { + // The mutable navigation views occupy the buffer's leading contiguous range, + // so one native slice copies exactly the state a fresh search can modify. + const mutableBytes = this.up.byteLength + this.down.byteLength + return new NodeStore( + this.size, + this.maxRows, + this.maxColumns, + this.buffer.slice(0, mutableBytes), + this + ) } +} - /** - * Link two columns horizontally - */ - linkColumns(leftIndex: number, rightIndex: number): void { - this.next[leftIndex] = rightIndex - this.prev[rightIndex] = leftIndex +export class ColumnStore { + readonly size: number + private readonly maxNodes: number + private readonly buffer: ArrayBuffer + + readonly len: IndexArray // Column lengths + readonly prev: IndexArray // Previous column indices (sentinel for null) + readonly next: IndexArray // Next column indices (sentinel for null) + + constructor(maxColumns: number, maxNodes: number, sourceBuffer?: ArrayBuffer) { + this.size = maxColumns + this.maxNodes = maxNodes + // Column lengths cannot exceed the total node count, so the node-width + // decision safely applies to all three column fields as well. + const IndexArrayConstructor = maxNodes <= UINT16_SENTINEL ? Uint16Array : Int32Array + const fieldBytes = maxColumns * IndexArrayConstructor.BYTES_PER_ELEMENT + this.buffer = sourceBuffer ?? new ArrayBuffer(fieldBytes * 3) + this.len = new IndexArrayConstructor(this.buffer, 0, maxColumns) + this.prev = new IndexArrayConstructor(this.buffer, fieldBytes, maxColumns) + this.next = new IndexArrayConstructor(this.buffer, fieldBytes * 2, maxColumns) } - get size(): number { - return this._size + clone(): ColumnStore { + return new ColumnStore(this.size, this.maxNodes, this.buffer.slice(0)) } } diff --git a/lib/core/problem-builder.ts b/lib/core/problem-builder.ts index ecf20f0..6ae2f53 100644 --- a/lib/core/problem-builder.ts +++ b/lib/core/problem-builder.ts @@ -32,10 +32,16 @@ export interface SearchContext { hasStarted: boolean /** Constraint matrix nodes with their current link state */ - nodes: NodeStore + nodes: NodeStore /** Column headers with their current lengths and links */ columns: ColumnStore + + /** + * Original rows, indexed by each node's rowIndex. One reference per input row + * avoids duplicating the generic data payload on every matrix node. + */ + rows: ConstraintRow[] } /** @@ -49,6 +55,18 @@ export interface ProblemConfig { const ROOT_COLUMN_OFFSET = 1 +/** + * Write the once-per-matrix row boundary outside buildRows' hot optimization unit. + * + * V8 can enter buildRows through on-stack replacement before this final keyed + * store has type feedback, then deoptimize the entire construction loop when it + * reaches the store. This stable helper gathers feedback independently and lets + * the row-building loop stay optimized through its return. + */ +function writeFinalRowBoundary(nodes: NodeStore, rowCount: number, nextNodeIndex: number): void { + nodes.rowStart[rowCount] = nextNodeIndex +} + /** * Builds Dancing Links data structures from constraint rows */ @@ -61,14 +79,15 @@ export class ProblemBuilder { // Calculate required capacity and pre-allocate stores const { numNodes, numColumns } = calculateCapacity(numPrimary, numSecondary, rows) - const nodes = new NodeStore(numNodes) - const columns = new ColumnStore(numColumns) + const nodes = new NodeStore(numNodes, rows.length, numColumns) + const columns = new ColumnStore(numColumns, numNodes) // Build column structure this.buildColumns(nodes, columns, numPrimary, numSecondary) // Build row structure - this.buildRows(nodes, columns, rows) + const nextNodeIndex = this.buildRows(nodes, columns, rows) + writeFinalRowBoundary(nodes, rows.length, nextNodeIndex) return { level: 0, @@ -77,7 +96,29 @@ export class ProblemBuilder { currentNodeIndex: 0, hasStarted: false, nodes, - columns + columns, + rows + } + } + + /** + * Clone an immutable, fully linked problem layout for a fresh search. + * + * Mutable node links and column state occupy contiguous backing regions, so + * this path is two native memory copies regardless of matrix shape. Immutable + * node metadata remains shared. Reusable templates therefore avoid rebuilding + * thousands of links in JavaScript and avoid copying bytes search cannot write. + */ + static cloneContext(template: SearchContext): SearchContext { + return { + level: 0, + choice: [], + bestColIndex: 0, + currentNodeIndex: 0, + hasStarted: false, + nodes: template.nodes.clone(), + columns: template.columns.clone(), + rows: template.rows } } @@ -85,50 +126,44 @@ export class ProblemBuilder { * Create column headers and linking structure */ private static buildColumns( - nodes: NodeStore, + nodes: NodeStore, columns: ColumnStore, numPrimary: number, numSecondary: number ): void { - // Create root column (index 0) - const rootColIndex = columns.allocateColumn() - const rootNodeIndex = nodes.allocateNode() - nodes.initializeNode(rootNodeIndex) - columns.initializeColumn(rootColIndex, rootNodeIndex) - - // Create primary columns - for (let i = 0; i < numPrimary; i++) { - const headNodeIndex = nodes.allocateNode() - nodes.initializeNode(headNodeIndex) - - const colIndex = columns.allocateColumn() - columns.initializeColumn(colIndex, headNodeIndex) - - // Link to previous column - if (i === 0) { - // First primary column links to root - columns.linkColumns(rootColIndex, colIndex) - } else { - // Link to previous column - columns.linkColumns(colIndex - 1, colIndex) - } + const { up, down, col, rowIndex } = nodes + const { prev, next } = columns + + // Header nodes are the first nodes and are allocated in column order. This + // identity layout removes a column-to-header lookup from every cover and + // uncover operation. Direct bulk writes also avoid allocation checks and + // small linking-method calls while the matrix is being constructed. + for (let colIndex = 0; colIndex < columns.size; colIndex++) { + up[colIndex] = colIndex + down[colIndex] = colIndex + col[colIndex] = NULL_INDEX + rowIndex[colIndex] = NULL_INDEX } - // Close the circular link: last primary -> root - if (numPrimary > 0) { - columns.linkColumns(numPrimary, rootColIndex) + // Only primary columns participate in the root's circular list. + if (numPrimary === 0) { + prev[0] = NULL_INDEX + next[0] = NULL_INDEX + } else { + prev[0] = numPrimary + next[0] = 1 + for (let colIndex = 1; colIndex <= numPrimary; colIndex++) { + prev[colIndex] = colIndex - 1 + next[colIndex] = colIndex === numPrimary ? 0 : colIndex + 1 + } } - // Create secondary columns (self-linked) - for (let i = 0; i < numSecondary; i++) { - const headNodeIndex = nodes.allocateNode() - nodes.initializeNode(headNodeIndex) - - const colIndex = columns.allocateColumn() - columns.initializeColumn(colIndex, headNodeIndex) - - // Secondary columns are self-linked - columns.linkColumns(colIndex, colIndex) + // Secondary columns are absent from the root list but retain self-links so + // cover/uncover can use the same branch-free pointer updates for both kinds. + const firstSecondary = numPrimary + ROOT_COLUMN_OFFSET + for (let colIndex = firstSecondary; colIndex < firstSecondary + numSecondary; colIndex++) { + prev[colIndex] = colIndex + next[colIndex] = colIndex } } @@ -136,41 +171,39 @@ export class ProblemBuilder { * Create row nodes and link them into the column structure */ private static buildRows( - nodes: NodeStore, + nodes: NodeStore, columns: ColumnStore, rows: ConstraintRow[] - ): void { - for (let i = 0; i < rows.length; i++) { - const row = rows[i] - let rowStartIndex: number = NULL_INDEX + ): number { + const { up, down, col, rowIndex, rowStart } = nodes + const { len } = columns + let nextNodeIndex = columns.size - for (const columnIndex of row.coveredColumns) { - const nodeIndex = nodes.allocateNode() - nodes.initializeNode(nodeIndex, columnIndex + ROOT_COLUMN_OFFSET, i, row.data) - - if (rowStartIndex === NULL_INDEX) { - rowStartIndex = nodeIndex - } else { - // Link horizontally to previous node in row - nodes.linkHorizontal(nodeIndex - 1, nodeIndex) - } - - // Link vertically into column - const colIndex = columnIndex + ROOT_COLUMN_OFFSET - const colHeadIndex = columns.head[colIndex] - const lastInColIndex = nodes.up[colHeadIndex] - - nodes.linkVertical(lastInColIndex, nodeIndex) - nodes.linkVertical(nodeIndex, colHeadIndex) - - columns.len[colIndex]++ - } - - // Close horizontal circular link for the row - if (rowStartIndex !== NULL_INDEX && row.coveredColumns.length > 1) { - const lastNodeIndex = nodes.size - 1 - nodes.linkHorizontal(lastNodeIndex, rowStartIndex) + for (let i = 0; i < rows.length; i++) { + const coveredColumns = rows[i].coveredColumns + const rowLength = coveredColumns.length + const rowStartIndex = nextNodeIndex + rowStart[i] = rowStartIndex + + // Rows never change horizontally, so their contiguous boundaries replace + // per-node left/right pointers. Sequential scans avoid pointer chasing, + // save two arrays, and give the CPU predictable adjacent memory accesses. + for (let j = 0; j < rowLength; j++) { + const nodeIndex = nextNodeIndex++ + const colIndex = coveredColumns[j] + ROOT_COLUMN_OFFSET + const lastInColIndex = up[colIndex] + + up[nodeIndex] = lastInColIndex + down[nodeIndex] = colIndex + col[nodeIndex] = colIndex + rowIndex[nodeIndex] = i + + down[lastInColIndex] = nodeIndex + up[colIndex] = nodeIndex + len[colIndex]++ } } + + return nextNodeIndex } } diff --git a/lib/solvers/factory.ts b/lib/solvers/factory.ts index df1ee76..d5430f9 100644 --- a/lib/solvers/factory.ts +++ b/lib/solvers/factory.ts @@ -26,7 +26,7 @@ import { isComplexSolverConfig } from '../types/interfaces.js' import { SimpleConstraintHandler, ComplexConstraintHandler } from '../constraints/index.js' -import { ProblemSolver } from './solver.js' +import { ProblemSolver, createZeroPrimaryProblemSolver } from './solver.js' import { SolverTemplate } from './template.js' /** @@ -65,9 +65,17 @@ export class DancingLinks { createSolver(config: SolverConfig): ProblemSolver | ProblemSolver { if (isComplexSolverConfig(config)) { const handler = new ComplexConstraintHandler(config) + // Zero-primary exact covers take a cold O(1) solution path. Selecting it + // once here keeps an extra root-empty branch out of every normal search. + if (config.primaryColumns === 0) { + return createZeroPrimaryProblemSolver(handler) + } return new ProblemSolver(handler) } else { const handler = new SimpleConstraintHandler(config) + if (config.columns === 0) { + return createZeroPrimaryProblemSolver(handler) + } return new ProblemSolver(handler) } } diff --git a/lib/solvers/solver.ts b/lib/solvers/solver.ts index 5b17daf..b8b8106 100644 --- a/lib/solvers/solver.ts +++ b/lib/solvers/solver.ts @@ -30,10 +30,14 @@ import { BinaryConstraintBatch } from '../types/interfaces.js' import { search } from '../core/algorithm.js' -import { ProblemBuilder } from '../core/problem-builder.js' +import { ProblemBuilder, type SearchContext } from '../core/problem-builder.js' export class ProblemSolver { - constructor(private handler: ConstraintHandler) {} + constructor( + private handler: ConstraintHandler, + /** Optional immutable layout supplied only by the explicit reusable-template API. */ + private contextTemplate?: SearchContext + ) {} validateConstraints(): this { this.handler.validateConstraints() @@ -41,21 +45,35 @@ export class ProblemSolver { } addSparseConstraint(data: T, columnIndices: SparseColumnIndices): this { + this.detachContextTemplate() this.handler.addSparseConstraint(data, columnIndices) return this } addSparseConstraints(constraints: SparseConstraintBatch): this { + // Keep this rare detach inline: batches are the hot ingestion API, so fresh + // solvers pay only one predictable branch before the branch-free handler. + if (this.contextTemplate) { + this.handler.detachConstraints() + this.contextTemplate = undefined + } this.handler.addSparseConstraints(constraints) return this } addBinaryConstraint(data: T, columnValues: BinaryColumnValues): this { + this.detachContextTemplate() this.handler.addBinaryConstraint(data, columnValues) return this } addBinaryConstraints(constraints: BinaryConstraintBatch): this { + // Match the sparse batch fast path; binary conversion is still performed by + // the ordinary monomorphic handler after one predictable detach check. + if (this.contextTemplate) { + this.handler.detachConstraints() + this.contextTemplate = undefined + } this.handler.addBinaryConstraints(constraints) return this } @@ -68,11 +86,13 @@ export class ProblemSolver { * @internal */ addRow(row: ConstraintRow): this { + this.detachContextTemplate() this.handler.addRow(row) return this } addRows(rows: ConstraintRow[]): this { + this.detachContextTemplate() this.handler.addRows(rows) return this } @@ -154,12 +174,7 @@ export class ProblemSolver { throw new Error('Cannot solve problem with no constraints') } - // Build search context once - key optimization - const context = ProblemBuilder.buildContext({ - numPrimary: this.handler.getNumPrimary(), - numSecondary: this.handler.getNumSecondary(), - rows: constraints - }) + const context = this.createSearchContext(constraints) // Keep calling search with numSolutions: 1 until exhausted while (true) { @@ -175,14 +190,81 @@ export class ProblemSolver { throw new Error('Cannot solve problem with no constraints') } - // Build search context from constraints - const context = ProblemBuilder.buildContext({ + const context = this.createSearchContext(constraints) + + // Execute search on context + return search(context, numSolutions) + } + + private createSearchContext(constraints: ConstraintRow[]): SearchContext { + if (this.contextTemplate) { + // The compiled template remains immutable; each solve mutates only its two + // native-copied buffers, preserving solver independence without rebuilding. + return ProblemBuilder.cloneContext(this.contextTemplate) + } + + return ProblemBuilder.buildContext({ numPrimary: this.handler.getNumPrimary(), numSecondary: this.handler.getNumSecondary(), rows: constraints }) + } - // Execute search on context - return search(context, numSolutions) + /** + * Detach template rows only when a solver actually adds local rows. + * + * Template creation used to copy the entire row-reference table for every + * solver even though read-only solvers search the compiled context directly. + * Deferring that O(number of template rows) copy makes the common read-only + * path O(1). The handler class and hot solve path remain identical for fresh + * and template solvers, avoiding mixed-workload JIT polymorphism. + */ + private detachContextTemplate(): void { + if (this.contextTemplate) { + this.handler.detachConstraints() + this.contextTemplate = undefined + } + } +} + +/** + * Cold exact-cover specialization for configurations with no primary columns. + * + * The empty row selection is their one solution; secondary-only rows remain + * optional. Handling this at solver construction keeps root-empty checks out of + * the ordinary search state machine, where they measurably slow every normal + * solve. Inherited mutation methods still provide validation and template COW. + * @internal + */ +export function createZeroPrimaryProblemSolver( + handler: ConstraintHandler, + contextTemplate?: SearchContext +): ProblemSolver { + // Construct the regular class directly and replace methods only on this cold + // instance. A derived class gives ProblemSolver multiple constructor targets + // in V8 and measurably slows short ordinary solves; own methods keep its hot + // construction site monomorphic while preserving instanceof ProblemSolver. + const solver = new ProblemSolver(handler, contextTemplate) + + const assertHasConstraints = (): void => { + if (handler.getConstraints().length === 0) { + throw new Error('Cannot solve problem with no constraints') + } } + const emptySolution = (): Result[][] => { + assertHasConstraints() + return [[]] + } + + // The public find limit is intentionally ignored: this domain has exactly one + // solution under the existing find(0) convention, so no count work is needed. + solver.findOne = emptySolution + solver.findAll = emptySolution + solver.find = emptySolution + solver.createGenerator = function* (): Generator[], void, unknown> { + assertHasConstraints() + yield [] + } + + return solver } diff --git a/lib/solvers/template.ts b/lib/solvers/template.ts index b7f3572..6b5440a 100644 --- a/lib/solvers/template.ts +++ b/lib/solvers/template.ts @@ -32,9 +32,37 @@ import { ComplexSolverConfig } from '../types/interfaces.js' import { SimpleConstraintHandler, ComplexConstraintHandler } from '../constraints/index.js' -import { ProblemSolver } from './solver.js' +import { ProblemBuilder, type SearchContext } from '../core/problem-builder.js' +import { ProblemSolver, createZeroPrimaryProblemSolver } from './solver.js' + +/** + * Freeze the constraint topology used by a compiled template. + * + * Row payloads and handler-owned row objects remain references, but each column + * array is copied once at compilation. Solver-local copy-on-write rebuilds + * therefore use the exact topology that produced the cached links, even if a + * caller later mutates an input array. + */ +function snapshotRows(constraints: ConstraintRow[]): ConstraintRow[] { + // Native slice preserves the packed array kind used by ordinary handlers. A + // pre-sized-and-filled array remains holey in V8 and would deoptimize the row + // loops when a process switches between template and fresh solvers. + const snapshot = constraints.slice() + for (let i = 0; i < constraints.length; i++) { + const row = constraints[i] + // The handler owns the row object, so detach only its caller-owned array. + // Reusing the original allocation-site map avoids wrong-map deopts when + // template and fresh rows pass through the same builder/search functions. + const mutableRow = row as { coveredColumns: number[]; data: T } + mutableRow.coveredColumns = row.coveredColumns.slice() + } + return snapshot +} export class SolverTemplate { + /** Immutable compiled layout; every constraint mutation invalidates it. */ + private contextTemplate?: SearchContext + constructor(private handler: ConstraintHandler) {} validateConstraints(): this { @@ -43,31 +71,37 @@ export class SolverTemplate { } addSparseConstraint(data: T, columnIndices: SparseColumnIndices): this { + this.contextTemplate = undefined this.handler.addSparseConstraint(data, columnIndices) return this } addSparseConstraints(constraints: SparseConstraintBatch): this { + this.contextTemplate = undefined this.handler.addSparseConstraints(constraints) return this } addBinaryConstraint(data: T, columnValues: BinaryColumnValues): this { + this.contextTemplate = undefined this.handler.addBinaryConstraint(data, columnValues) return this } addBinaryConstraints(constraints: BinaryConstraintBatch): this { + this.contextTemplate = undefined this.handler.addBinaryConstraints(constraints) return this } addRow(row: ConstraintRow): this { + this.contextTemplate = undefined this.handler.addRow(row) return this } addRows(rows: ConstraintRow[]): this { + this.contextTemplate = undefined this.handler.addRows(rows) return this } @@ -90,30 +124,64 @@ export class SolverTemplate { // Check if template has validation enabled const templateValidationEnabled = this.handler.isValidationEnabled() + const contextTemplate = this.getContextTemplate(constraints) + // Share the compiled context's immutable row table. ProblemSolver detaches + // it only on first local mutation, making read-only solver creation O(1) + // while retaining the same handler class and hot shape as fresh solvers. // Use explicit mode detection instead of inferring from getNumSecondary() if (this.handler.mode === 'complex') { const config = this.handler.getConfig() as ComplexSolverConfig const newHandler = new ComplexConstraintHandler(config) + newHandler.shareConstraints(contextTemplate.rows) // Propagate validation setting from template if (templateValidationEnabled) { newHandler.validateConstraints() } - newHandler.addRows(constraints) - return new ProblemSolver(newHandler) as ProblemSolver + // Match fresh solvers' cold specialization without putting a root-empty + // check in the shared search loop. Passing the compiled context retains + // the same O(1) row sharing and first-mutation copy-on-write behavior. + if (config.primaryColumns === 0) { + // The handler mode check above proves this generic Mode cast at runtime. + return createZeroPrimaryProblemSolver( + newHandler, + contextTemplate + ) as unknown as ProblemSolver + } + return new ProblemSolver(newHandler, contextTemplate) as ProblemSolver } else { const config = this.handler.getConfig() as SimpleSolverConfig const newHandler = new SimpleConstraintHandler(config) + newHandler.shareConstraints(contextTemplate.rows) // Propagate validation setting from template if (templateValidationEnabled) { newHandler.validateConstraints() } - newHandler.addRows(constraints) - return new ProblemSolver(newHandler) as ProblemSolver + if (config.columns === 0) { + // The handler mode check above proves this generic Mode cast at runtime. + return createZeroPrimaryProblemSolver( + newHandler, + contextTemplate + ) as unknown as ProblemSolver + } + return new ProblemSolver(newHandler, contextTemplate) as ProblemSolver + } + } + + private getContextTemplate(constraints: ConstraintRow[]): SearchContext { + if (!this.contextTemplate) { + // Compile one immutable snapshot. Later template additions cannot alter + // solvers already created from it, while future creates reuse the work. + this.contextTemplate = ProblemBuilder.buildContext({ + numPrimary: this.handler.getNumPrimary(), + numSecondary: this.handler.getNumSecondary(), + rows: snapshotRows(constraints) + }) } + return this.contextTemplate } } diff --git a/lib/types/interfaces.ts b/lib/types/interfaces.ts index 2d3b2db..a147a17 100644 --- a/lib/types/interfaces.ts +++ b/lib/types/interfaces.ts @@ -126,6 +126,8 @@ export interface ConstraintHandler { addBinaryConstraints(constraints: BinaryConstraintBatch): this addRow(row: ConstraintRow): this addRows(rows: ConstraintRow[]): this + /** @internal Copy a compiled template's shared row-reference table before mutation. */ + detachConstraints(): void getConstraints(): ConstraintRow[] getNumPrimary(): number getNumSecondary(): number diff --git a/test/unit/complex-constraints.spec.ts b/test/unit/complex-constraints.spec.ts index 00c05e6..da6809f 100644 --- a/test/unit/complex-constraints.spec.ts +++ b/test/unit/complex-constraints.spec.ts @@ -175,6 +175,34 @@ describe('Complex Constraints', () => { expect(solutions[0][0].data).to.equal('covers_primary') }) + it('should return one empty solution when there are no primary columns', () => { + const solver = new DancingLinks().createSolver({ + primaryColumns: 0, + secondaryColumns: 2 + }) + solver.addSparseConstraints([ + { data: 'optional-a', columnIndices: { primary: [], secondary: [0] } }, + { data: 'optional-b', columnIndices: { primary: [], secondary: [1] } } + ]) + + // Secondary columns are optional, so choosing no rows is the one exact + // cover. Optional-only rows must not create additional solutions. + expect(solver.findOne()).to.deep.equal([[]]) + expect(solver.find(5)).to.deep.equal([[]]) + expect(solver.findAll()).to.deep.equal([[]]) + expect([...solver.createGenerator()]).to.deep.equal([[]]) + }) + + it('should retain validation in the zero-primary specialization', () => { + const solver = new DancingLinks() + .createSolver({ primaryColumns: 0, secondaryColumns: 1 }) + .validateConstraints() + + expect(() => solver.addSparseConstraint('invalid', { primary: [], secondary: [1] })).to.throw( + 'Secondary column index 1 exceeds secondaryColumns limit of 1' + ) + }) + it('should handle scenarios with no valid solutions', () => { const dlx = new DancingLinks() const solver = dlx.createSolver({ primaryColumns: 2, secondaryColumns: 1 }) diff --git a/test/unit/constraint-formats.spec.ts b/test/unit/constraint-formats.spec.ts index 6356571..189c364 100644 --- a/test/unit/constraint-formats.spec.ts +++ b/test/unit/constraint-formats.spec.ts @@ -29,6 +29,75 @@ describe('Constraint Formats', function () { expect(solutionData).to.deep.include([1, 3]) }) + it('should append unchecked sparse batches without overwriting or leaving gaps', function () { + const emptySolver = new DancingLinks().createSolver({ columns: 1 }) + emptySolver.addSparseConstraints([]) + expect(() => emptySolver.findAll()).to.throw('Cannot solve problem with no constraints') + + const solver = new DancingLinks().createSolver({ columns: 3 }) + solver.addSparseConstraint('zero', [0]) + solver.addSparseConstraints([]) + solver.addSparseConstraints([ + { data: 'one', columnIndices: [1] }, + { data: 'two', columnIndices: [2] } + ]) + + const solutions = solver.findAll() + expect(solutions).to.have.length(1) + expect(solutions[0]!.slice().sort((a, b) => a.index - b.index)).to.deep.equal([ + { index: 0, data: 'zero' }, + { index: 1, data: 'one' }, + { index: 2, data: 'two' } + ]) + }) + + it('should commit only completed unchecked rows when reading a later row throws', function () { + const solver = new DancingLinks().createSolver({ columns: 2 }) + const throwingConstraint = { + data: 'invalid', + get columnIndices(): number[] { + throw new Error('input access failed') + } + } + + expect(() => + solver.addSparseConstraints([ + { data: 'kept', columnIndices: [0] }, + throwingConstraint, + { data: 'later', columnIndices: [1] } + ]) + ).to.throw('input access failed') + + solver.addSparseConstraint('replacement', [1]) + const solutions = solver.findAll() + expect(solutions).to.have.length(1) + expect(solutions[0]!.slice().sort((a, b) => a.index - b.index)).to.deep.equal([ + { index: 0, data: 'kept' }, + { index: 1, data: 'replacement' } + ]) + }) + + it('should append after rows added reentrantly by an input getter', function () { + const solver = new DancingLinks().createSolver({ columns: 3 }) + const reentrantConstraint = { + data: 'outer', + get columnIndices(): number[] { + solver.addSparseConstraint('nested', [1]) + return [0] + } + } + + solver.addSparseConstraints([reentrantConstraint, { data: 'later', columnIndices: [2] }]) + + const solutions = solver.findAll() + expect(solutions).to.have.length(1) + expect(solutions[0]!.slice().sort((a, b) => a.index - b.index)).to.deep.equal([ + { index: 0, data: 'nested' }, + { index: 1, data: 'outer' }, + { index: 2, data: 'later' } + ]) + }) + it('should support binary constraint format for compatibility', function () { const dlx = new DancingLinks() const solver = dlx.createSolver({ columns: 3 }) diff --git a/test/unit/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index a647827..4ab8928 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -1,6 +1,8 @@ import { expect } from 'chai' import { DancingLinks } from '../../index.js' import type { SimpleConstraint } from '../../index.js' +import { search } from '../../lib/core/algorithm.js' +import { ProblemBuilder } from '../../lib/core/problem-builder.js' describe('ProblemSolver', function () { it('should solve a simple exact cover problem', function () { @@ -67,6 +69,27 @@ describe('ProblemSolver', function () { expect(allSolutions).to.have.length(2) }) + it('should return the one empty cover when there are no columns', function () { + const solver = new DancingLinks().createSolver({ columns: 0 }) + solver.addSparseConstraint('empty-row', []) + + // With no required columns, choosing no rows is the unique exact cover. + expect(solver.findOne()).to.deep.equal([[]]) + expect(solver.find(0)).to.deep.equal([[]]) + expect(solver.find(5)).to.deep.equal([[]]) + expect(solver.findAll()).to.deep.equal([[]]) + }) + + it('should preserve the no-constraints error for a zero-column solver', function () { + const solver = new DancingLinks().createSolver({ columns: 0 }) + + expect(() => solver.findAll()).to.throw('Cannot solve problem with no constraints') + + // Generator bodies are lazy, so the same guard runs on the first advance. + const generator = solver.createGenerator() + expect(() => generator.next()).to.throw('Cannot solve problem with no constraints') + }) + it('should process identical constraints across different solvers', function () { const dlx = new DancingLinks() @@ -83,6 +106,182 @@ describe('ProblemSolver', function () { expect(() => solver2.findAll()).to.not.throw() }) + it('should preserve behavior when the matrix requires 32-bit indices', function () { + this.timeout(10_000) + + const solver = new DancingLinks().createSolver({ columns: 1 }) + const constraints = Array.from({ length: 65_536 }, (_, data) => ({ + data, + columnIndices: [0] + })) + + solver.addSparseConstraints(constraints) + + // The column length itself exceeds Uint16 capacity, so this exercises both + // the node-index and column-length fallbacks rather than only the threshold. + const solutions = solver.findOne() + expect(solutions).to.have.length(1) + expect(solutions[0]).to.have.length(1) + }) + + it('should preserve row data when only row indices require 32 bits', function () { + this.timeout(10_000) + + const solver = new DancingLinks().createSolver({ columns: 1 }) + const constraints = Array.from({ length: 65_536 }, (_, data) => ({ + data, + columnIndices: [] as number[] + })) + constraints.push({ data: 65_536, columnIndices: [0] }) + + const context = ProblemBuilder.buildContext({ + numPrimary: 1, + numSecondary: 0, + rows: constraints.map(({ data, columnIndices }) => ({ coveredColumns: columnIndices, data })) + }) + // A high row index no longer widens unrelated node and column domains. Only + // rowIndex needs Int32, while navigation and metadata remain compact. + expect(context.nodes.rowIndex).to.be.instanceOf(Int32Array) + for (const view of [ + context.nodes.up, + context.nodes.down, + context.nodes.col, + context.nodes.rowStart, + context.columns.len, + context.columns.prev, + context.columns.next + ]) { + expect(view).to.be.instanceOf(Uint16Array) + } + + solver.addSparseConstraints(constraints) + + // Empty rows add no nodes, so this crosses only the row-index boundary. + // The selected row must still point at its high-index input payload. + const solutions = solver.findOne() + expect(solutions).to.have.length(1) + expect(solutions[0]).to.deep.equal([{ index: 65_536, data: 65_536 }]) + }) + + it('should align independently widened node and row index views', function () { + this.timeout(10_000) + + // One empty row followed by 65,535 single-node rows creates an odd 65,537 + // node count and a row index of 65,535. The mixed 32/16/32-bit layout needs + // padding between col and rowIndex, so this also guards the shared buffer's + // alignment fallback rather than testing only even-sized cutoff matrices. + const rows = Array.from({ length: 65_536 }, (_, data) => ({ + coveredColumns: data === 0 ? [] : [0], + data + })) + const context = ProblemBuilder.buildContext({ numPrimary: 1, numSecondary: 0, rows }) + + expect(context.nodes.size).to.equal(65_537) + expect(context.nodes.up).to.be.instanceOf(Int32Array) + expect(context.nodes.down).to.be.instanceOf(Int32Array) + expect(context.nodes.rowStart).to.be.instanceOf(Int32Array) + expect(context.nodes.col).to.be.instanceOf(Uint16Array) + expect(context.nodes.rowIndex).to.be.instanceOf(Int32Array) + expect(context.nodes.rowIndex[65_536]).to.equal(65_535) + }) + + it('should widen column IDs before the Uint16 sentinel becomes a valid ID', function () { + this.timeout(10_000) + + const context = ProblemBuilder.buildContext({ + numPrimary: 65_535, + numSecondary: 0, + rows: [{ coveredColumns: [65_534], data: 'highest-column' }] + }) + + // Root + 65,535 columns makes 65,535 a real header index, so col must + // widen instead of confusing that value with Uint16's reserved sentinel. + // The odd node count also exercises padding before the 32-bit rowStart view. + expect(context.nodes.size).to.equal(65_537) + expect(context.nodes.col).to.be.instanceOf(Int32Array) + expect(context.nodes.col[65_536]).to.equal(65_535) + expect(context.nodes.rowIndex).to.be.instanceOf(Uint16Array) + expect(context.nodes.rowStart).to.be.instanceOf(Int32Array) + expect(context.nodes.rowStart[0]).to.equal(65_536) + expect(context.nodes.rowStart[1]).to.equal(65_537) + }) + + it('should search the highest node on both sides of the storage-width cutoff', function () { + this.timeout(10_000) + + const padding = Array.from({ length: 64 }, (_, column) => column).filter(column => column !== 1) + + for (const nodeCount of [65_535, 65_536] as const) { + const rows = Array.from({ length: 1_038 }, (_, data) => ({ + coveredColumns: padding, + data + })) + rows.push({ coveredColumns: padding.slice(0, nodeCount === 65_535 ? 12 : 13), data: 1_038 }) + rows.push({ coveredColumns: [...padding, 1], data: 1_039 }) + + const context = ProblemBuilder.buildContext({ + numPrimary: 64, + numSecondary: 0, + rows + }) + const ExpectedNodeIndexArray = nodeCount === 65_535 ? Uint16Array : Int32Array + const views: Array< + [ + Int32Array | Uint16Array, + typeof Int32Array | typeof Uint16Array + ] + > = [ + [context.nodes.up, ExpectedNodeIndexArray], + [context.nodes.down, ExpectedNodeIndexArray], + [context.nodes.col, Uint16Array], + [context.nodes.rowIndex, Uint16Array], + [context.nodes.rowStart, ExpectedNodeIndexArray], + [context.columns.len, ExpectedNodeIndexArray], + [context.columns.prev, ExpectedNodeIndexArray], + [context.columns.next, ExpectedNodeIndexArray] + ] + for (const [view, ExpectedArray] of views) { + expect(view instanceof ExpectedArray).to.equal(true) + } + + const clonedContext = ProblemBuilder.cloneContext(context) + expect(clonedContext.nodes.up).to.not.equal(context.nodes.up) + expect(clonedContext.nodes.down).to.not.equal(context.nodes.down) + expect(clonedContext.nodes.col).to.equal(context.nodes.col) + expect(clonedContext.nodes.rowIndex).to.equal(context.nodes.rowIndex) + expect(clonedContext.nodes.rowStart).to.equal(context.nodes.rowStart) + const clonedViews = [ + clonedContext.nodes.up, + clonedContext.nodes.down, + clonedContext.nodes.col, + clonedContext.nodes.rowIndex, + clonedContext.nodes.rowStart, + clonedContext.columns.len, + clonedContext.columns.prev, + clonedContext.columns.next + ] + for (let i = 0; i < views.length; i++) { + expect(clonedViews[i]!.constructor).to.equal(views[i]![0].constructor) + } + + // Primary column 1 has header index 2 and only the terminal row. Keeping + // its node last proves search traverses index 65,534/65,535, rather than + // merely allocating the width-specific store without exercising it. + expect(context.nodes.size).to.equal(nodeCount) + expect(context.nodes.down[2]).to.equal(nodeCount - 1) + expect(search(clonedContext, Infinity)).to.deep.equal([[{ index: 1_039, data: 1_039 }]]) + // Search through the public batch-ingestion path as well as inspecting the + // direct builder context, so the fallback test covers the same behavior + // users and the end-to-end width benchmarks execute. + const solver = new DancingLinks().createSolver({ columns: 64 }) + solver.addSparseConstraints( + rows.map(({ coveredColumns, data }) => ({ columnIndices: coveredColumns, data })) + ) + const solutions = solver.findAll() + expect(solutions).to.deep.equal([[{ index: 1_039, data: 1_039 }]]) + } + }) + describe('Generator Interface', function () { it('should yield solutions that match findAll() results', function () { const dlx = new DancingLinks() @@ -111,6 +310,35 @@ describe('ProblemSolver', function () { expect(normalizeAndSort(generatorSolutions)).to.deep.equal(normalizeAndSort(allSolutions)) }) + it('should resume after a solution found at the root search level', function () { + const solver = new DancingLinks().createSolver({ columns: 1 }) + solver.addSparseConstraints([ + { data: 'first', columnIndices: [0] }, + { data: 'second', columnIndices: [0] } + ]) + + // Both solutions are direct choices in the root column. The resumable + // search must recover the first row before advancing to the second. + expect([...solver.createGenerator()]).to.deep.equal([ + [{ index: 0, data: 'first' }], + [{ index: 1, data: 'second' }] + ]) + }) + + it('should yield a zero-column solution once and then exhaust', function () { + const solver = new DancingLinks().createSolver({ columns: 0 }) + solver.addSparseConstraint('empty-row', []) + + const generator = solver.createGenerator() + expect(generator.next()).to.deep.equal({ value: [], done: false }) + expect(generator.next()).to.deep.equal({ value: undefined, done: true }) + + // Each generator owns its one-yield state; exhausting one cannot consume + // the solution that an independently created generator must expose. + expect([...solver.createGenerator()]).to.deep.equal([[]]) + expect([...solver.createGenerator()]).to.deep.equal([[]]) + }) + it('should support early termination', function () { const dlx = new DancingLinks() const solver = dlx.createSolver({ columns: 3 }) diff --git a/test/unit/solver-configurations.spec.ts b/test/unit/solver-configurations.spec.ts index f7dc64b..fbe1046 100644 --- a/test/unit/solver-configurations.spec.ts +++ b/test/unit/solver-configurations.spec.ts @@ -27,6 +27,53 @@ describe('Solver Configurations', function () { ) }) + it('should preserve the valid prefix of a sparse batch when validation fails', function () { + const solver = new DancingLinks().createSolver({ columns: 2 }).validateConstraints() + + expect(() => + solver.addSparseConstraints([ + { data: 'kept', columnIndices: [0] }, + { data: 'invalid', columnIndices: [2] }, + { data: 'later', columnIndices: [1] } + ]) + ).to.throw('Column index 2 exceeds columns limit of 2') + + solver.addSparseConstraint('replacement', [1]) + const solutions = solver.findAll() + expect(solutions).to.have.length(1) + expect(solutions[0]!.slice().sort((a, b) => a.index - b.index)).to.deep.equal([ + { index: 0, data: 'kept' }, + { index: 1, data: 'replacement' } + ]) + }) + + it('should honor validation enabled reentrantly while reading a batch', function () { + const solver = new DancingLinks().createSolver({ columns: 2 }) + const enablingConstraint = { + data: 'kept', + get columnIndices(): number[] { + solver.validateConstraints() + return [0] + } + } + + expect(() => + solver.addSparseConstraints([ + enablingConstraint, + { data: 'invalid', columnIndices: [2] }, + { data: 'later', columnIndices: [1] } + ]) + ).to.throw('Column index 2 exceeds columns limit of 2') + + solver.addSparseConstraint('replacement', [1]) + const solutions = solver.findAll() + expect(solutions).to.have.length(1) + expect(solutions[0]!.slice().sort((a, b) => a.index - b.index)).to.deep.equal([ + { index: 0, data: 'kept' }, + { index: 1, data: 'replacement' } + ]) + }) + it('should validate binary constraint row length', function () { const dlx = new DancingLinks() const solver = dlx.createSolver({ columns: 3 }).validateConstraints() diff --git a/test/unit/solver-template.spec.ts b/test/unit/solver-template.spec.ts index 2cbab1a..4ff20bb 100644 --- a/test/unit/solver-template.spec.ts +++ b/test/unit/solver-template.spec.ts @@ -158,6 +158,142 @@ describe('SolverTemplate', function () { }) describe('Template State Isolation', function () { + it('should isolate a local row in a zero-column template', function () { + const template = new DancingLinks().createSolverTemplate({ columns: 0 }) + const changedSolver = template.createSolver() + const untouchedSolver = template.createSolver() + + changedSolver.addSparseConstraint('local-empty-row', []) + + expect(changedSolver.findAll()).to.deep.equal([[]]) + expect(() => untouchedSolver.findAll()).to.throw('Cannot solve problem with no constraints') + expect(() => template.createSolver().findAll()).to.throw( + 'Cannot solve problem with no constraints' + ) + + const generator = untouchedSolver.createGenerator() + expect(() => generator.next()).to.throw('Cannot solve problem with no constraints') + }) + + it('should isolate and share complex zero-primary template rows with validation', function () { + const template = new DancingLinks() + .createSolverTemplate({ primaryColumns: 0, secondaryColumns: 1 }) + .validateConstraints() + + const changedSolver = template.createSolver() + const untouchedSolver = template.createSolver() + changedSolver.addSparseConstraint('local-optional', { primary: [], secondary: [0] }) + + // A local row makes only the detached solver solvable; the empty shared + // snapshot still triggers the established no-constraints error. + expect(changedSolver.findAll()).to.deep.equal([[]]) + expect(() => untouchedSolver.findAll()).to.throw('Cannot solve problem with no constraints') + expect(() => template.createSolver().findAll()).to.throw( + 'Cannot solve problem with no constraints' + ) + + expect(() => + changedSolver.addSparseConstraint('invalid', { primary: [], secondary: [1] }) + ).to.throw('Secondary column index 1 exceeds secondaryColumns limit of 1') + + // Once the template owns a valid optional row, multiple solvers share its + // snapshot and independently expose the one empty exact cover. + template.addSparseConstraint('template-optional', { primary: [], secondary: [0] }) + expect(template.createSolver().findAll()).to.deep.equal([[]]) + expect(template.createSolver().findAll()).to.deep.equal([[]]) + }) + + it('should rebuild solver-local changes from the compiled topology snapshot', function () { + const template = new DancingLinks().createSolverTemplate({ columns: 2 }) + const callerOwnedColumns = [0] + + template.addSparseConstraint('left', callerOwnedColumns) + const solver = template.createSolver() + + // Compiled links already describe [0]. Mutating the caller's source array + // must not change the rows used when this solver detaches and rebuilds. + callerOwnedColumns.push(1) + solver.addSparseConstraint('right', [1]) + + const solutions = solver.findAll() + expect(solutions).to.have.length(1) + expect(solutions[0]!.map(result => result.data).sort()).to.deep.equal(['left', 'right']) + }) + + it('should detach complex template rows before a solver-local mutation', function () { + const template = new DancingLinks().createSolverTemplate({ + primaryColumns: 2, + secondaryColumns: 1 + }) + + template.addSparseConstraint('left', { primary: [0], secondary: [0] }) + const changedSolver = template.createSolver() + const untouchedSolver = template.createSolver() + + changedSolver.addSparseConstraint('right', { primary: [1], secondary: [] }) + + const changedSolutions = changedSolver.findAll() + expect(changedSolutions).to.have.length(1) + expect(changedSolutions[0]!.map(result => result.data).sort()).to.deep.equal([ + 'left', + 'right' + ]) + expect(untouchedSolver.findAll()).to.have.length(0) + }) + + it('should isolate a validated solver after a partially accepted local batch', function () { + const template = new DancingLinks() + .createSolverTemplate({ columns: 3 }) + .validateConstraints() + template.addSparseConstraint('base', [0]) + + const changedSolver = template.createSolver() + expect(() => + changedSolver.addSparseConstraints([ + { data: 'local', columnIndices: [1] }, + { data: 'invalid', columnIndices: [3] }, + { data: 'later', columnIndices: [2] } + ]) + ).to.throw('Column index 3 exceeds columns limit of 3') + + changedSolver.addSparseConstraint('replacement', [2]) + const changedSolutions = changedSolver.findAll() + expect(changedSolutions).to.have.length(1) + expect(changedSolutions[0]!.slice().sort((a, b) => a.index - b.index)).to.deep.equal([ + { index: 0, data: 'base' }, + { index: 1, data: 'local' }, + { index: 2, data: 'replacement' } + ]) + + // The throwing batch detached before mutation, so neither its accepted + // prefix nor an empty slot can leak back into the compiled template. + expect(template.createSolver().findAll()).to.have.length(0) + }) + + it('should invalidate the compiled layout when the template changes', function () { + const template = new DancingLinks().createSolverTemplate({ columns: 2 }) + + template.addSparseConstraint('left', [0]).addSparseConstraint('right', [1]) + + // Creating this solver compiles the original template layout. + const originalSolver = template.createSolver() + + // This row creates a second solution and must invalidate that layout. + template.addSparseConstraint('combined', [0, 1]) + const updatedSolver = template.createSolver() + + const originalSolutions = originalSolver.findAll() + const updatedSolutions = updatedSolver.findAll() + + expect( + originalSolutions.map(solution => solution.map(result => result.data).sort()) + ).to.deep.equal([['left', 'right']]) + expect(updatedSolutions.map(solution => solution.map(result => result.data))).to.deep.include( + ['combined'] + ) + expect(updatedSolutions).to.have.length(2) + }) + it('should isolate template modifications from existing solvers', function () { const dlx = new DancingLinks() const template = dlx.createSolverTemplate({ columns: 3 })