From 584648ffd2299a04dff3e77eb2ef31648bbe40ad Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 21:39:30 +0200 Subject: [PATCH 01/11] perf: compact and stabilize dancing links search Move stable search kernels to module scope, batch row covering, and represent immutable rows as contiguous node ranges. This removes V8 wrong-call-target deopts, horizontal pointer chasing, repeated view loads, and redundant per-node payload storage. Build exact-capacity struct-of-arrays buffers directly, use 16-bit indices when safe with a transparent 32-bit fallback, share immutable template metadata, and clone only mutable search state. Every accepted optimization is documented inline with its CPU or allocation rationale. Against the original internal solve benchmark, sparse throughput improved about 143% for Sudoku, 70% for pentomino-one, 39% for pentomino-ten, and 40% for pentomino-100. These ordinary-solver measurements rebuild the matrix every invocation; no implicit repeated-solve cache is used. The explicit template API gained another 6-11% on Sudoku and 2-3% on pentomino-one from mutable-only cloning. Add regression coverage for matrices whose node and column counts exceed Uint16 capacity, compiled-template invalidation, and solver/template state isolation. --- lib/core/algorithm.ts | 525 +++++++++++++++++------------- lib/core/data-structures.ts | 201 +++++------- lib/core/problem-builder.ts | 162 +++++---- lib/solvers/solver.ts | 40 ++- lib/solvers/template.ts | 29 +- test/unit/problem-solver.spec.ts | 18 + test/unit/solver-template.spec.ts | 24 ++ 7 files changed, 563 insertions(+), 436 deletions(-) diff --git a/lib/core/algorithm.ts b/lib/core/algorithm.ts index d4e30c9..4310a46 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,16 @@ 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 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) { currentSearchState = SearchState.RECOVER } else { // Must be: hasStarted=true && level=0 (backtracked to root) @@ -83,242 +315,67 @@ export function search(context: SearchContext, numSolutions: number): Resu // 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] - - columns.next[leftColIndex] = colIndex - columns.prev[rightColIndex] = colIndex - } + case SearchState.ADVANCE: { + const currentNodeIndex = context.currentNodeIndex + if (currentNodeIndex === context.bestColIndex) { + currentSearchState = SearchState.BACKUP + break + } - function pickBestColumn() { - const rootNext = columns.next[rootColIndex] - let lowestLen = columns.len[rootNext] - let lowest = rootNext + coverRow(nodes, columns, currentNodeIndex) - /** - * 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 - } + if (next[rootColIndex] === rootColIndex) { + solutions.push(materializeSolution(nodes, context.rows, context.choice, context.level)) - /** - * 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..dc365bb 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,96 @@ */ const NULL_INDEX = -1 - -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) - } - - /** - * Allocate a new node and return its index - */ - allocateNode(): number { - if (this._size >= this.capacity) { - throw new Error(`NodeStore capacity exceeded: ${this.capacity}`) +const UINT16_SENTINEL = 0xffff + +type IndexArray = Int32Array | Uint16Array + +export class NodeStore { + readonly size: 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, + sourceBuffer?: ArrayBuffer, + immutableSource?: NodeStore + ) { + this.size = maxNodes + // Most exact-cover matrices fit below the reserved Uint16 sentinel. Using + // 16-bit indices halves navigation bandwidth and working-set size; larger + // matrices transparently retain 32-bit indices with identical semantics. + const IndexArrayConstructor = maxNodes <= UINT16_SENTINEL ? Uint16Array : Int32Array + const bytesPerElement = IndexArrayConstructor.BYTES_PER_ELEMENT + const nodeFieldBytes = maxNodes * bytesPerElement + const bufferBytes = immutableSource + ? nodeFieldBytes * 2 + : nodeFieldBytes * 4 + (maxRows + 1) * bytesPerElement + 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 IndexArrayConstructor(this.buffer, 0, maxNodes) + this.down = new IndexArrayConstructor(this.buffer, nodeFieldBytes, maxNodes) + if (immutableSource) { + // Search writes only up/down. Template clones share these read-only views, + // cutting copied node bytes by 60% while keeping ordinary builds in one + // allocation. The private template owns their lifetime and is never searched. + this.col = immutableSource.col + this.rowIndex = immutableSource.rowIndex + this.rowStart = immutableSource.rowStart + } else { + this.col = new IndexArrayConstructor(this.buffer, nodeFieldBytes * 2, maxNodes) + this.rowIndex = new IndexArrayConstructor(this.buffer, nodeFieldBytes * 3, maxNodes) + this.rowStart = new IndexArrayConstructor(this.buffer, nodeFieldBytes * 4, maxRows + 1) } - 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 + 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.rowStart.length - 1, + this.buffer.slice(0, mutableBytes), + this + ) } } 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}`) - } - 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 - } - - /** - * Link two columns horizontally - */ - linkColumns(leftIndex: number, rightIndex: number): void { - this.next[leftIndex] = rightIndex - this.prev[rightIndex] = leftIndex + 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..0447ec1 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[] } /** @@ -61,8 +67,8 @@ 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) + const columns = new ColumnStore(numColumns, numNodes) // Build column structure this.buildColumns(nodes, columns, numPrimary, numSecondary) @@ -77,7 +83,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 +113,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 +158,41 @@ 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 - - for (const columnIndex of row.coveredColumns) { - const nodeIndex = nodes.allocateNode() - nodes.initializeNode(nodeIndex, columnIndex + ROOT_COLUMN_OFFSET, i, row.data) + const { up, down, col, rowIndex, rowStart } = nodes + const { len } = columns + let nextNodeIndex = columns.size - 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]++ } } + + // The sentinel boundary makes every row's half-open range available as + // [rowStart[row], rowStart[row + 1]) without a separate length array. + rowStart[rows.length] = nextNodeIndex } } diff --git a/lib/solvers/solver.ts b/lib/solvers/solver.ts index 5b17daf..d7e182d 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,25 @@ export class ProblemSolver { } 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 } @@ -68,11 +76,13 @@ export class ProblemSolver { * @internal */ 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 } @@ -154,12 +164,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 +180,23 @@ 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) } } diff --git a/lib/solvers/template.ts b/lib/solvers/template.ts index b7f3572..d6fc6cd 100644 --- a/lib/solvers/template.ts +++ b/lib/solvers/template.ts @@ -32,9 +32,13 @@ import { ComplexSolverConfig } from '../types/interfaces.js' import { SimpleConstraintHandler, ComplexConstraintHandler } from '../constraints/index.js' +import { ProblemBuilder, type SearchContext } from '../core/problem-builder.js' import { ProblemSolver } from './solver.js' export class SolverTemplate { + /** Immutable compiled layout; every constraint mutation invalidates it. */ + private contextTemplate?: SearchContext + constructor(private handler: ConstraintHandler) {} validateConstraints(): this { @@ -43,31 +47,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,6 +100,7 @@ export class SolverTemplate { // Check if template has validation enabled const templateValidationEnabled = this.handler.isValidationEnabled() + const contextTemplate = this.getContextTemplate(constraints) // Use explicit mode detection instead of inferring from getNumSecondary() if (this.handler.mode === 'complex') { @@ -102,7 +113,7 @@ export class SolverTemplate { } newHandler.addRows(constraints) - return new ProblemSolver(newHandler) as ProblemSolver + return new ProblemSolver(newHandler, contextTemplate) as ProblemSolver } else { const config = this.handler.getConfig() as SimpleSolverConfig const newHandler = new SimpleConstraintHandler(config) @@ -113,7 +124,21 @@ export class SolverTemplate { } newHandler.addRows(constraints) - return new ProblemSolver(newHandler) as ProblemSolver + return new ProblemSolver(newHandler, contextTemplate) as ProblemSolver + } + } + + private getContextTemplate(constraints: ConstraintRow[]): SearchContext { + if (!this.contextTemplate) { + // Snapshot the row table as well as compiling the links. Later template + // additions cannot alter solvers already created from this immutable + // layout, while all future creates reuse the same compilation work. + this.contextTemplate = ProblemBuilder.buildContext({ + numPrimary: this.handler.getNumPrimary(), + numSecondary: this.handler.getNumSecondary(), + rows: constraints.slice() + }) } + return this.contextTemplate } } diff --git a/test/unit/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index a647827..cb83e2d 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -83,6 +83,24 @@ 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) + }) + describe('Generator Interface', function () { it('should yield solutions that match findAll() results', function () { const dlx = new DancingLinks() diff --git a/test/unit/solver-template.spec.ts b/test/unit/solver-template.spec.ts index 2cbab1a..89192c4 100644 --- a/test/unit/solver-template.spec.ts +++ b/test/unit/solver-template.spec.ts @@ -158,6 +158,30 @@ describe('SolverTemplate', function () { }) describe('Template State Isolation', function () { + 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 }) From 093d0b920687cb32130ba0278126ebe2a80ea4b9 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 21:44:19 +0200 Subject: [PATCH 02/11] perf: keep row construction optimized through return Move the once-per-matrix final row-boundary write into a stable helper. V8 can OSR-optimize buildRows inside its inner loop before that keyed store receives feedback, causing a recurring deoptimization when construction reaches the sentinel. Isolating the store keeps the full row-building loop optimized through return. Alternating fresh-solver end-to-end measurements improved cold Sudoku throughput by about 0.4% and cold pentomino-one by about 1.4%. The deoptimization trace no longer reports buildRows bailouts. This adds no reuse cache and leaves construction/search behavior unchanged. --- lib/core/problem-builder.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/core/problem-builder.ts b/lib/core/problem-builder.ts index 0447ec1..09c6ff6 100644 --- a/lib/core/problem-builder.ts +++ b/lib/core/problem-builder.ts @@ -55,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 */ @@ -74,7 +86,8 @@ export class ProblemBuilder { 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, @@ -161,7 +174,7 @@ export class ProblemBuilder { nodes: NodeStore, columns: ColumnStore, rows: ConstraintRow[] - ): void { + ): number { const { up, down, col, rowIndex, rowStart } = nodes const { len } = columns let nextNodeIndex = columns.size @@ -191,8 +204,6 @@ export class ProblemBuilder { } } - // The sentinel boundary makes every row's half-open range available as - // [rowStart[row], rowStart[row + 1]) without a separate length array. - rowStart[rows.length] = nextNodeIndex + return nextNodeIndex } } From 1315b92ecbe369d57dfc7c2209cc4a4636a96c34 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 21:52:46 +0200 Subject: [PATCH 03/11] fix: widen storage when row indices exceed uint16 Include both node and row index domains when selecting the compact NodeStore width. A matrix with more than 65,535 mostly empty rows can have few nodes but still require a 32-bit rowIndex; the node-only decision could wrap a selected row back to the wrong input payload. The documented 16-bit fast path remains unchanged for ordinary matrices. Add a behavioral regression with 65,536 empty rows followed by a covering row and assert that the returned index and data both remain 65,536. The existing test continues to cover the separate node-count and column-length fallback. --- lib/core/data-structures.ts | 9 ++++++--- test/unit/problem-solver.spec.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/lib/core/data-structures.ts b/lib/core/data-structures.ts index dc365bb..df121a4 100644 --- a/lib/core/data-structures.ts +++ b/lib/core/data-structures.ts @@ -39,9 +39,12 @@ export class NodeStore { ) { this.size = maxNodes // Most exact-cover matrices fit below the reserved Uint16 sentinel. Using - // 16-bit indices halves navigation bandwidth and working-set size; larger - // matrices transparently retain 32-bit indices with identical semantics. - const IndexArrayConstructor = maxNodes <= UINT16_SENTINEL ? Uint16Array : Int32Array + // 16-bit indices halves navigation bandwidth and working-set size. Both node + // and row indices must fit: many empty rows can make maxRows exceed maxNodes. + // Either overflow domain transparently selects the behaviorally identical + // 32-bit fallback. + const IndexArrayConstructor = + maxNodes <= UINT16_SENTINEL && maxRows <= UINT16_SENTINEL ? Uint16Array : Int32Array const bytesPerElement = IndexArrayConstructor.BYTES_PER_ELEMENT const nodeFieldBytes = maxNodes * bytesPerElement const bufferBytes = immutableSource diff --git a/test/unit/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index cb83e2d..9098451 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -101,6 +101,25 @@ describe('ProblemSolver', function () { 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] }) + + 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 }]) + }) + describe('Generator Interface', function () { it('should yield solutions that match findAll() results', function () { const dlx = new DancingLinks() From e7cff9922e8f451f26952f28739abcb75e97e779 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 22:45:24 +0200 Subject: [PATCH 04/11] style: format optimized search with locked prettier Apply the repository lockfile's Prettier 3.6.2 spacing to four empty-update for-loops in the optimized cover/uncover kernels. This is a TypeScript formatting-only correction with identical emitted control flow and runtime behavior; it makes a clean npm ci workspace pass format:check. --- lib/core/algorithm.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/core/algorithm.ts b/lib/core/algorithm.ts index 4310a46..1c0998f 100644 --- a/lib/core/algorithm.ts +++ b/lib/core/algorithm.ts @@ -40,7 +40,7 @@ function cover(nodes: NodeStore, columns: ColumnStore, colIndex: number): void { next[leftColIndex] = rightColIndex prev[rightColIndex] = leftColIndex const colHeadIndex = colIndex - for (let rr = down[colHeadIndex]; rr !== colHeadIndex;) { + for (let rr = down[colHeadIndex]; rr !== colHeadIndex; ) { const nextRR = down[rr] const row = rowIndex[rr] const firstInRow = rowStart[row] @@ -70,7 +70,7 @@ 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;) { + for (let rr = up[colHeadIndex]; rr !== colHeadIndex; ) { const nextRR = up[rr] const row = rowIndex[rr] const firstInRow = rowStart[row] @@ -120,7 +120,7 @@ function coverRow(nodes: NodeStore, columns: ColumnStore, currentNodeIndex: numb const rightColIndex = next[colIndex] next[leftColIndex] = rightColIndex prev[rightColIndex] = leftColIndex - for (let rr = down[colIndex]; rr !== colIndex;) { + for (let rr = down[colIndex]; rr !== colIndex; ) { const nextRR = down[rr] const row = rowIndex[rr] const firstInRow = rowStart[row] @@ -161,7 +161,7 @@ function uncoverRow(nodes: NodeStore, columns: ColumnStore, currentNodeIndex: nu while (true) { for (; pp > beforeRange; pp--) { const colIndex = col[pp] - for (let rr = up[colIndex]; rr !== colIndex;) { + for (let rr = up[colIndex]; rr !== colIndex; ) { const nextRR = up[rr] const row = rowIndex[rr] const firstInRow = rowStart[row] From 64ecc76431c5784dc0ac5b07ff049eb619125d2a Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 22:46:40 +0200 Subject: [PATCH 05/11] perf: defer template row copies until mutation Share the compiled template's packed row-reference table with each new solver in O(1), and shallow-copy it only when that solver adds local constraints. Ordinary solver construction and handler identities stay unchanged so fresh/template workloads remain monomorphic.\n\nSnapshot caller-owned column arrays once while retaining handler-owned row objects and a packed outer array. This preserves topology for later solver-local rebuilds without introducing wrong-map or holey-array deoptimizations in shared build/search kernels.\n\nAdd simple caller-mutation and complex copy-on-write isolation tests. Honest timings keep solver creation and search inside each operation: Sudoku template create+search improves 6-9%, alternating template+fresh pairs improve 1-2%, mutation/rebuild fallback improves 2-4%, and fresh-only solves remain effectively neutral. No implicit result cache is introduced. --- lib/constraints/handlers/complex.ts | 18 ++++++++++++++ lib/constraints/handlers/simple.ts | 22 +++++++++++++++++ lib/solvers/solver.ts | 38 ++++++++++++++++++++++++----- lib/solvers/template.ts | 38 ++++++++++++++++++++++++----- lib/types/interfaces.ts | 2 ++ test/unit/solver-template.spec.ts | 38 +++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 12 deletions(-) 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..9a657ef 100644 --- a/lib/constraints/handlers/simple.ts +++ b/lib/constraints/handlers/simple.ts @@ -88,6 +88,28 @@ export class SimpleConstraintHandler 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/solvers/solver.ts b/lib/solvers/solver.ts index d7e182d..2d7d98c 100644 --- a/lib/solvers/solver.ts +++ b/lib/solvers/solver.ts @@ -45,25 +45,35 @@ export class ProblemSolver { } addSparseConstraint(data: T, columnIndices: SparseColumnIndices): this { - this.contextTemplate = undefined + this.detachContextTemplate() this.handler.addSparseConstraint(data, columnIndices) return this } addSparseConstraints(constraints: SparseConstraintBatch): this { - this.contextTemplate = undefined + // 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.contextTemplate = undefined + this.detachContextTemplate() this.handler.addBinaryConstraint(data, columnValues) return this } addBinaryConstraints(constraints: BinaryConstraintBatch): this { - this.contextTemplate = undefined + // 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 } @@ -76,13 +86,13 @@ export class ProblemSolver { * @internal */ addRow(row: ConstraintRow): this { - this.contextTemplate = undefined + this.detachContextTemplate() this.handler.addRow(row) return this } addRows(rows: ConstraintRow[]): this { - this.contextTemplate = undefined + this.detachContextTemplate() this.handler.addRows(rows) return this } @@ -199,4 +209,20 @@ export class ProblemSolver { rows: constraints }) } + + /** + * 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 + } + } } diff --git a/lib/solvers/template.ts b/lib/solvers/template.ts index d6fc6cd..3359409 100644 --- a/lib/solvers/template.ts +++ b/lib/solvers/template.ts @@ -35,6 +35,30 @@ import { SimpleConstraintHandler, ComplexConstraintHandler } from '../constraint import { ProblemBuilder, type SearchContext } from '../core/problem-builder.js' import { ProblemSolver } 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 @@ -102,41 +126,43 @@ export class SolverTemplate { 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, 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, contextTemplate) as ProblemSolver } } private getContextTemplate(constraints: ConstraintRow[]): SearchContext { if (!this.contextTemplate) { - // Snapshot the row table as well as compiling the links. Later template - // additions cannot alter solvers already created from this immutable - // layout, while all future creates reuse the same compilation work. + // 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: constraints.slice() + 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/solver-template.spec.ts b/test/unit/solver-template.spec.ts index 89192c4..3e5ca9b 100644 --- a/test/unit/solver-template.spec.ts +++ b/test/unit/solver-template.spec.ts @@ -158,6 +158,44 @@ describe('SolverTemplate', function () { }) describe('Template State Isolation', function () { + 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 invalidate the compiled layout when the template changes', function () { const template = new DancingLinks().createSolverTemplate({ columns: 2 }) From 168d1901ddcfff894014e6a55e6e94b340546737 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 22:58:46 +0200 Subject: [PATCH 06/11] test: benchmark adaptive index-width search Add exact 65,535-node Uint16 and 65,536-node Int32 workloads whose only solution forces search through the highest allocated node. Time a fresh public solver, batch ingestion, typed-store build, and exhaustive search on every operation. Add a sequential Uint16-to-Int32 task to expose mixed-width CPU and JIT costs without reusing compiled topology or cached results. Fail benchmark validation when required internal setup or any timed task fails, and verify the public result outside timing. Cover both storage widths and the terminal solution in a focused unit test. These new cases establish honest baselines for future pull requests; this pull request has no merge-base results for them. --- benchmark/config/cases.ts | 26 ++- benchmark/config/groups.ts | 9 +- benchmark/config/problems.ts | 2 + benchmark/config/solvers.ts | 6 + benchmark/problems/index-width/definition.ts | 42 +++++ benchmark/runner.ts | 25 ++- benchmark/solvers/InternalIndexWidthSolver.ts | 171 ++++++++++++++++++ benchmark/types.ts | 10 +- test/unit/problem-solver.spec.ts | 50 +++++ 9 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 benchmark/problems/index-width/definition.ts create mode 100644 benchmark/solvers/InternalIndexWidthSolver.ts 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..4388a67 --- /dev/null +++ b/benchmark/solvers/InternalIndexWidthSolver.ts @@ -0,0 +1,171 @@ +/** + * 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 ExpectedIndexArray = prepared.nodeCount === 0xffff ? Uint16Array : Int32Array + const views = [ + context.nodes.up, + context.nodes.down, + context.nodes.col, + context.nodes.rowIndex, + context.nodes.rowStart, + context.columns.len, + context.columns.prev, + context.columns.next + ] + for (const view of views) { + if (!(view instanceof ExpectedIndexArray)) { + 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/test/unit/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index 9098451..50ea55d 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai' import { DancingLinks } from '../../index.js' import type { SimpleConstraint } from '../../index.js' +import { ProblemBuilder } from '../../lib/core/problem-builder.js' describe('ProblemSolver', function () { it('should solve a simple exact cover problem', function () { @@ -120,6 +121,55 @@ describe('ProblemSolver', function () { expect(solutions[0]).to.deep.equal([{ index: 65_536, data: 65_536 }]) }) + 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 ExpectedIndexArray = nodeCount === 65_535 ? Uint16Array : Int32Array + for (const view of [ + context.nodes.up, + context.nodes.down, + context.nodes.col, + context.nodes.rowIndex, + context.nodes.rowStart, + context.columns.len, + context.columns.prev, + context.columns.next + ]) { + expect(view instanceof ExpectedIndexArray).to.equal(true) + } + + // 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) + // 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() From 3ede35ddda70ad5929e17f17b3a2e1a92272c65d Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 23:13:41 +0200 Subject: [PATCH 07/11] perf: append sparse batches through packed indexed stores Cache the simple handler's packed row-table tail and append unchecked sparse batches with consecutive indexed stores. This avoids a generic Array#push call and repeated length updates per row while deliberately not pre-growing length, which would make V8 retain a slower holey elements kind. Keep validation in a separate sequential fallback so earlier valid rows remain committed when a later row fails. Cover empty and nonempty appends, throwing input access, validation-prefix retention, and template copy-on-write isolation. Alternating fresh public Sudoku A/B pairs improved 5.9% to 11.7% with a median near 9%; the official sparse task improved from 43,781 to 47,175 ops/s (7.8%). Validation stayed within 0.6%, pentomino showed no persistent regression, and 5,000 randomized matrices matched the prior build. The analogous complex-handler experiment was neutral because column conversion dominates, so this commit intentionally leaves that path unchanged. --- lib/constraints/handlers/simple.ts | 31 ++++++++++++---- test/unit/constraint-formats.spec.ts | 48 +++++++++++++++++++++++++ test/unit/solver-configurations.spec.ts | 20 +++++++++++ test/unit/solver-template.spec.ts | 29 +++++++++++++++ 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/lib/constraints/handlers/simple.ts b/lib/constraints/handlers/simple.ts index 9a657ef..754b2b7 100644 --- a/lib/constraints/handlers/simple.ts +++ b/lib/constraints/handlers/simple.ts @@ -33,19 +33,36 @@ export class SimpleConstraintHandler implements ConstraintHandler): this { + const target = this.constraints + let writeIndex = target.length + + if (!this.validationEnabled) { + // Unchecked batches are the normal sparse-ingestion path. Consecutive + // indexed stores cache the packed array's tail, avoiding Array#push's + // generic call and length load/update for every row. Do not extend length + // up front: V8 would permanently mark the array holey, slowing the shared + // builder loops and exposing empty slots if input access throws. + for (let i = 0; i < constraints.length; i++) { + const { data, columnIndices } = constraints[i] + target[writeIndex++] = { coveredColumns: columnIndices, data } + } + return this + } + + // Validation stays outside the hot default loop. Rows are still committed + // one at a time after validation, preserving the existing partial-append + // behavior when a later row is invalid. for (let i = 0; i < constraints.length; i++) { const { data, columnIndices } = constraints[i] - if (this.validationEnabled) { - for (let j = 0; j < columnIndices.length; j++) { - const col = columnIndices[j] - if (col < 0 || col >= this.numColumns) { - throw new Error(`Column index ${col} exceeds columns limit of ${this.numColumns}`) - } + for (let j = 0; j < columnIndices.length; j++) { + const col = columnIndices[j] + if (col < 0 || col >= this.numColumns) { + throw new Error(`Column index ${col} exceeds columns limit of ${this.numColumns}`) } } - this.constraints.push({ coveredColumns: columnIndices, data }) + target[writeIndex++] = { coveredColumns: columnIndices, data } } return this } diff --git a/test/unit/constraint-formats.spec.ts b/test/unit/constraint-formats.spec.ts index 6356571..d430d52 100644 --- a/test/unit/constraint-formats.spec.ts +++ b/test/unit/constraint-formats.spec.ts @@ -29,6 +29,54 @@ 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 support binary constraint format for compatibility', 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..641f051 100644 --- a/test/unit/solver-configurations.spec.ts +++ b/test/unit/solver-configurations.spec.ts @@ -27,6 +27,26 @@ 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 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 3e5ca9b..da2c1b2 100644 --- a/test/unit/solver-template.spec.ts +++ b/test/unit/solver-template.spec.ts @@ -196,6 +196,35 @@ describe('SolverTemplate', function () { 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 }) From 63eb72685c23e44b01aef96f1d41f1116481d033 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 23:19:24 +0200 Subject: [PATCH 08/11] fix: preserve reentrant sparse batch semantics Read the live packed-array tail for every unchecked append instead of caching it for the whole batch. A caller-provided getter may append through the same solver; the live tail keeps that nested row and commits the outer row after it rather than overwriting it. Keep validation detection and the original push fallback per row so a getter that enables validation affects the current and remaining rows. Add regression tests for both nested appends and reentrant validation, alongside the existing throwing-input and partial-prefix cases. The semantics-safe unchecked path retains the optimization: the latest fresh public Sudoku pair measured 43,151 to 47,493 ops/s (+10.1%). The validated fallback measured 45,123 to 45,180 ops/s (+0.1%). --- lib/constraints/handlers/simple.ts | 40 +++++++++++-------------- test/unit/constraint-formats.spec.ts | 21 +++++++++++++ test/unit/solver-configurations.spec.ts | 27 +++++++++++++++++ 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/lib/constraints/handlers/simple.ts b/lib/constraints/handlers/simple.ts index 754b2b7..65892ea 100644 --- a/lib/constraints/handlers/simple.ts +++ b/lib/constraints/handlers/simple.ts @@ -34,35 +34,31 @@ export class SimpleConstraintHandler implements ConstraintHandler): this { const target = this.constraints - let writeIndex = target.length - - if (!this.validationEnabled) { - // Unchecked batches are the normal sparse-ingestion path. Consecutive - // indexed stores cache the packed array's tail, avoiding Array#push's - // generic call and length load/update for every row. Do not extend length - // up front: V8 would permanently mark the array holey, slowing the shared - // builder loops and exposing empty slots if input access throws. - for (let i = 0; i < constraints.length; i++) { - const { data, columnIndices } = constraints[i] - target[writeIndex++] = { coveredColumns: columnIndices, data } - } - return this - } - // Validation stays outside the hot default loop. Rows are still committed - // one at a time after validation, preserving the existing partial-append - // behavior when a later row is invalid. for (let i = 0; i < constraints.length; i++) { const { data, columnIndices } = constraints[i] - for (let j = 0; j < columnIndices.length; j++) { - const col = columnIndices[j] - if (col < 0 || col >= this.numColumns) { - throw new Error(`Column index ${col} exceeds columns limit of ${this.numColumns}`) + if (this.validationEnabled) { + for (let j = 0; j < columnIndices.length; j++) { + const col = columnIndices[j] + if (col < 0 || col >= this.numColumns) { + throw new Error(`Column index ${col} exceeds columns limit of ${this.numColumns}`) + } } + + // Validation is the behavioral fallback: commit only after this row is + // valid, using the original live-tail append so a later failure keeps + // the already accepted prefix exactly as before. + target.push({ coveredColumns: columnIndices, data }) + continue } - target[writeIndex++] = { coveredColumns: columnIndices, data } + // A direct store at the live tail avoids Array#push's generic method call + // while preserving reentrant getter behavior: nested appends advance + // target.length before this row is committed. Growing one slot at a time + // also retains V8's PACKED elements kind; pre-growing length would make + // the shared builder consume a permanently slower HOLEY array. + target[target.length] = { coveredColumns: columnIndices, data } } return this } diff --git a/test/unit/constraint-formats.spec.ts b/test/unit/constraint-formats.spec.ts index d430d52..189c364 100644 --- a/test/unit/constraint-formats.spec.ts +++ b/test/unit/constraint-formats.spec.ts @@ -77,6 +77,27 @@ describe('Constraint Formats', function () { ]) }) + 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/solver-configurations.spec.ts b/test/unit/solver-configurations.spec.ts index 641f051..fbe1046 100644 --- a/test/unit/solver-configurations.spec.ts +++ b/test/unit/solver-configurations.spec.ts @@ -47,6 +47,33 @@ describe('Solver Configurations', function () { ]) }) + 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() From 8a07b10dd08c395834afa12ebcc0d4f97c22d5c0 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 23:21:16 +0200 Subject: [PATCH 09/11] fix: resume generators after root-level solutions Recognize an empty covered root list as a resumable search state even when the current depth is zero. Limited searches can pause after choosing a root-column row, so depth alone cannot distinguish that state from exhausted search. Add a one-column regression with two direct choices: findAll already returned both, while createGenerator previously stopped after the first. The generator now recovers the first row and yields the second before reporting exhaustion. --- lib/core/algorithm.ts | 10 ++++++---- test/unit/problem-solver.spec.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/core/algorithm.ts b/lib/core/algorithm.ts index 1c0998f..f1b4ee3 100644 --- a/lib/core/algorithm.ts +++ b/lib/core/algorithm.ts @@ -290,12 +290,17 @@ export function search(context: SearchContext, numSolutions: number): Resu const { next } = columns const solutions: Result[][] = [] + // Root column is always at index 0 (created by ProblemBuilder) + const rootColIndex = 0 let currentSearchState: SearchState if (!context.hasStarted) { currentSearchState = SearchState.FORWARD context.hasStarted = true - } else if (context.level > 0) { + } 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) @@ -312,9 +317,6 @@ export function search(context: SearchContext, numSolutions: number): Resu return [] } - // Root column is always at index 0 (created by ProblemBuilder) - const rootColIndex = 0 - // 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. diff --git a/test/unit/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index 50ea55d..5a54e05 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -198,6 +198,21 @@ 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 support early termination', function () { const dlx = new DancingLinks() const solver = dlx.createSolver({ columns: 3 }) From 1624168d627f6b5a877b4ccdc0f7dcd0a4dfa55e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 23:38:27 +0200 Subject: [PATCH 10/11] perf: size node metadata by its index domain Select navigation and row-boundary width from node count, column-ID width from column count, and row-ID width from row count. Large matrices can now keep col and rowIndex in Uint16 while up, down, and rowStart use the required Int32 fallback. Align the mixed typed-array views inside one backing allocation and retain up/down as the leading mutable region, so template clones still perform one native navigation copy while sharing immutable metadata. Narrow metadata cuts the 65,536-node NodeStore field bytes by 24.9% and keeps col/rowIndex load sites monomorphic across adjacent node widths. Honest fresh build-plus-search results moved from 1,654 to 1,946 ops/s for Int32 (+17.7%) and 943 to 1,098 paired ops/s for alternating Uint16/Int32 (+16.4%); Uint16 remained neutral at 3,995 to 4,029 (+0.8%). Four process-isolated mixed A/B pairs improved 14% to 16%. Cover independent node, row, and column cutoffs, odd mixed-width alignment, exact high-index storage, clone copy/share identity, clone search, and the public ingestion/search fallback. --- benchmark/solvers/InternalIndexWidthSolver.ts | 29 +++-- lib/core/data-structures.ts | 67 ++++++---- lib/core/problem-builder.ts | 2 +- test/unit/problem-solver.spec.ts | 115 ++++++++++++++++-- 4 files changed, 167 insertions(+), 46 deletions(-) diff --git a/benchmark/solvers/InternalIndexWidthSolver.ts b/benchmark/solvers/InternalIndexWidthSolver.ts index 4388a67..947bf12 100644 --- a/benchmark/solvers/InternalIndexWidthSolver.ts +++ b/benchmark/solvers/InternalIndexWidthSolver.ts @@ -60,19 +60,24 @@ function verifyWidthAndSearch(prepared: PreparedIndexWidth): void { numSecondary: 0, rows: prepared.rows }) - const ExpectedIndexArray = prepared.nodeCount === 0xffff ? Uint16Array : Int32Array - const views = [ - context.nodes.up, - context.nodes.down, - context.nodes.col, - context.nodes.rowIndex, - context.nodes.rowStart, - context.columns.len, - context.columns.prev, - context.columns.next + 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 of views) { - if (!(view instanceof ExpectedIndexArray)) { + for (const [view, ExpectedArray] of views) { + if (!(view instanceof ExpectedArray)) { throw new Error(`Matrix ${prepared.nodeCount} selected the wrong integer storage width`) } } diff --git a/lib/core/data-structures.ts b/lib/core/data-structures.ts index df121a4..4c5b946 100644 --- a/lib/core/data-structures.ts +++ b/lib/core/data-structures.ts @@ -21,8 +21,14 @@ const UINT16_SENTINEL = 0xffff type IndexArray = Int32Array | Uint16Array +function alignOffset(offset: number, bytesPerElement: number): number { + return Math.ceil(offset / bytesPerElement) * bytesPerElement +} + export class NodeStore { readonly size: number + private readonly maxRows: number + private readonly maxColumns: number private readonly buffer: ArrayBuffer readonly up: IndexArray @@ -34,22 +40,39 @@ export class NodeStore { constructor( maxNodes: number, maxRows: number, + maxColumns: number, sourceBuffer?: ArrayBuffer, immutableSource?: NodeStore ) { this.size = maxNodes - // Most exact-cover matrices fit below the reserved Uint16 sentinel. Using - // 16-bit indices halves navigation bandwidth and working-set size. Both node - // and row indices must fit: many empty rows can make maxRows exceed maxNodes. - // Either overflow domain transparently selects the behaviorally identical - // 32-bit fallback. - const IndexArrayConstructor = - maxNodes <= UINT16_SENTINEL && maxRows <= UINT16_SENTINEL ? Uint16Array : Int32Array - const bytesPerElement = IndexArrayConstructor.BYTES_PER_ELEMENT - const nodeFieldBytes = maxNodes * bytesPerElement - const bufferBytes = immutableSource - ? nodeFieldBytes * 2 - : nodeFieldBytes * 4 + (maxRows + 1) * bytesPerElement + 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 @@ -57,19 +80,20 @@ export class NodeStore { // 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 IndexArrayConstructor(this.buffer, 0, maxNodes) - this.down = new IndexArrayConstructor(this.buffer, nodeFieldBytes, maxNodes) + 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 these read-only views, - // cutting copied node bytes by 60% while keeping ordinary builds in one - // allocation. The private template owns their lifetime and is never searched. + // 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 IndexArrayConstructor(this.buffer, nodeFieldBytes * 2, maxNodes) - this.rowIndex = new IndexArrayConstructor(this.buffer, nodeFieldBytes * 3, maxNodes) - this.rowStart = new IndexArrayConstructor(this.buffer, nodeFieldBytes * 4, maxRows + 1) + 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) } } @@ -79,7 +103,8 @@ export class NodeStore { const mutableBytes = this.up.byteLength + this.down.byteLength return new NodeStore( this.size, - this.rowStart.length - 1, + this.maxRows, + this.maxColumns, this.buffer.slice(0, mutableBytes), this ) diff --git a/lib/core/problem-builder.ts b/lib/core/problem-builder.ts index 09c6ff6..6ae2f53 100644 --- a/lib/core/problem-builder.ts +++ b/lib/core/problem-builder.ts @@ -79,7 +79,7 @@ export class ProblemBuilder { // Calculate required capacity and pre-allocate stores const { numNodes, numColumns } = calculateCapacity(numPrimary, numSecondary, rows) - const nodes = new NodeStore(numNodes, rows.length) + const nodes = new NodeStore(numNodes, rows.length, numColumns) const columns = new ColumnStore(numColumns, numNodes) // Build column structure diff --git a/test/unit/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index 5a54e05..ec4289b 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -1,6 +1,7 @@ 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 () { @@ -112,6 +113,26 @@ describe('ProblemSolver', function () { })) 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. @@ -121,6 +142,49 @@ describe('ProblemSolver', function () { 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) @@ -139,18 +203,44 @@ describe('ProblemSolver', function () { numSecondary: 0, rows }) - const ExpectedIndexArray = nodeCount === 65_535 ? Uint16Array : Int32Array - for (const view of [ - context.nodes.up, - context.nodes.down, - context.nodes.col, - context.nodes.rowIndex, - context.nodes.rowStart, - context.columns.len, - context.columns.prev, - context.columns.next - ]) { - expect(view instanceof ExpectedIndexArray).to.equal(true) + 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 @@ -158,6 +248,7 @@ describe('ProblemSolver', function () { // 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. From e3a606839ac14b95a3296ffa4d2fff4aecdef643 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 13 Jul 2026 23:59:17 +0200 Subject: [PATCH 11/11] fix: specialize zero-primary exact covers Return one empty cover when a nonempty problem has no primary columns, while retaining the established no-constraints error for an entirely empty input. Select the cold behavior at solver construction so ordinary searches avoid a root-empty check. Install the overrides on an ordinary ProblemSolver instance instead of deriving from it, which keeps V8 constructor feedback monomorphic and preserves instanceof behavior. Carry the specialization through templates without losing validation or copy-on-write isolation. Cover simple and complex modes, find limits, generator exhaustion, lazy no-row errors, validation, and template detach/share behavior with regression tests. The final opposite-order full internal benchmark pair classified every ordinary and adaptive-width case as no significant change; sparse Sudoku was -0.09%, Uint16 -0.08%, Int32 -0.11%, and the mixed-width pair -1.60%. --- lib/solvers/factory.ts | 10 +++++- lib/solvers/solver.ts | 42 +++++++++++++++++++++++++ lib/solvers/template.ts | 19 ++++++++++- test/unit/complex-constraints.spec.ts | 28 +++++++++++++++++ test/unit/problem-solver.spec.ts | 35 +++++++++++++++++++++ test/unit/solver-template.spec.ts | 45 +++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 2 deletions(-) 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 2d7d98c..b8b8106 100644 --- a/lib/solvers/solver.ts +++ b/lib/solvers/solver.ts @@ -226,3 +226,45 @@ export class ProblemSolver { } } } + +/** + * 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 3359409..6b5440a 100644 --- a/lib/solvers/template.ts +++ b/lib/solvers/template.ts @@ -33,7 +33,7 @@ import { } from '../types/interfaces.js' import { SimpleConstraintHandler, ComplexConstraintHandler } from '../constraints/index.js' import { ProblemBuilder, type SearchContext } from '../core/problem-builder.js' -import { ProblemSolver } from './solver.js' +import { ProblemSolver, createZeroPrimaryProblemSolver } from './solver.js' /** * Freeze the constraint topology used by a compiled template. @@ -140,6 +140,16 @@ export class SolverTemplate { newHandler.validateConstraints() } + // 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 @@ -151,6 +161,13 @@ export class SolverTemplate { newHandler.validateConstraints() } + 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 } } 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/problem-solver.spec.ts b/test/unit/problem-solver.spec.ts index ec4289b..4ab8928 100644 --- a/test/unit/problem-solver.spec.ts +++ b/test/unit/problem-solver.spec.ts @@ -69,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() @@ -304,6 +325,20 @@ describe('ProblemSolver', function () { ]) }) + 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-template.spec.ts b/test/unit/solver-template.spec.ts index da2c1b2..4ff20bb 100644 --- a/test/unit/solver-template.spec.ts +++ b/test/unit/solver-template.spec.ts @@ -158,6 +158,51 @@ 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]