Skip to content
Merged
26 changes: 25 additions & 1 deletion benchmark/config/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions benchmark/config/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
externalSolversWithoutDancingLinksAlgorithm
} from './solvers.js'

type BenchmarkMatrix = Record<CaseId, readonly SolverId[]>
// Groups intentionally select only relevant cases. Width-boundary regression
// tasks, for example, have no meaningful external-library comparison.
type BenchmarkMatrix = Partial<Record<CaseId, readonly SolverId[]>>

interface BenchmarkGroup {
readonly name: string
Expand All @@ -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']
}
},

Expand Down
2 changes: 2 additions & 0 deletions benchmark/config/problems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions benchmark/config/solvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions benchmark/problems/index-width/definition.ts
Original file line number Diff line number Diff line change
@@ -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<number[]>(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 }
}
25 changes: 23 additions & 2 deletions benchmark/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`)
}
Expand Down Expand Up @@ -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) =>
Expand Down
176 changes: 176 additions & 0 deletions benchmark/solvers/InternalIndexWidthSolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Honest end-to-end benchmarks for adaptive 16/32-bit storage.
*
* Prepared input rows live outside timing like the other sparse benchmarks, but
* every measured operation creates a public solver, ingests all constraints,
* builds fresh typed-array stores, and exhaustively searches them. No compiled
* topology or result is reused between operations.
*/

import { DancingLinks } from '../../index.js'
import { ProblemBuilder } from '../../lib/core/problem-builder.js'
import type { ConstraintRow, SparseConstraintBatch } from '../../lib/types/interfaces.js'
import {
generateIndexWidthConstraints,
INDEX_WIDTH_PRIMARY_COLUMNS
} from '../problems/index-width/definition.js'
import { Solver, StandardConstraints } from '../types.js'

interface PreparedIndexWidth {
readonly nodeCount: number
readonly constraints: SparseConstraintBatch<number, 'simple'>
readonly rows: ConstraintRow<number>[]
}

interface PreparedMixedWidths {
readonly uint16: PreparedIndexWidth
readonly int32: PreparedIndexWidth
}

function prepareRows(constraints: StandardConstraints): PreparedIndexWidth {
const sparse = constraints.primaryConstraints.map((columnIndices, data) => ({
data,
columnIndices
}))
const rows = sparse.map(({ data, columnIndices }) => ({
coveredColumns: columnIndices,
data
}))
let rowNodes = 0
for (const row of rows) {
rowNodes += row.coveredColumns.length
}
const prepared = {
nodeCount: INDEX_WIDTH_PRIMARY_COLUMNS + 1 + rowNodes,
constraints: sparse,
rows
}
verifyWidthAndSearch(prepared)
return prepared
}

/** Prove once, outside timing, that the intended fallback and hot search path run. */
function verifyWidthAndSearch(prepared: PreparedIndexWidth): void {
if (prepared.nodeCount !== 0xffff && prepared.nodeCount !== 0x10000) {
throw new Error(`Unexpected index-width matrix size: ${prepared.nodeCount}`)
}

const context = ProblemBuilder.buildContext({
numPrimary: INDEX_WIDTH_PRIMARY_COLUMNS,
numSecondary: 0,
rows: prepared.rows
})
const ExpectedNodeIndexArray = prepared.nodeCount === 0xffff ? Uint16Array : Int32Array
const views: Array<
[
Int32Array<ArrayBufferLike> | Uint16Array<ArrayBufferLike>,
typeof Int32Array | typeof Uint16Array
]
> = [
[context.nodes.up, ExpectedNodeIndexArray],
[context.nodes.down, ExpectedNodeIndexArray],
[context.nodes.col, Uint16Array],
[context.nodes.rowIndex, Uint16Array],
[context.nodes.rowStart, ExpectedNodeIndexArray],
[context.columns.len, ExpectedNodeIndexArray],
[context.columns.prev, ExpectedNodeIndexArray],
[context.columns.next, ExpectedNodeIndexArray]
]
for (const [view, ExpectedArray] of views) {
if (!(view instanceof ExpectedArray)) {
throw new Error(`Matrix ${prepared.nodeCount} selected the wrong integer storage width`)
}
}

// Root occupies index 0, so primary column 1 has header index 2. It contains
// only the terminal row, whose forced node was deliberately written last.
const highestNode = prepared.nodeCount - 1
if (context.nodes.down[2] !== highestNode) {
throw new Error(`Search will not select the expected high node ${highestNode}`)
}

// Exercise the same public batch-ingestion/build/search path used by the timed
// operation once outside timing. This prevents a handler regression from
// producing a fast but incorrect benchmark while the direct builder check
// above still happens to pass.
const solutions = solveFreshAll(prepared)
const terminalRow = prepared.rows.length - 1
if (
solutions.length !== 1 ||
solutions[0]?.length !== 1 ||
solutions[0][0]?.index !== terminalRow
) {
throw new Error(
`Index-width matrix ${prepared.nodeCount} did not produce its terminal solution`
)
}
}

function solveFreshAll(prepared: PreparedIndexWidth) {
const solver = new DancingLinks<number>().createSolver({
columns: INDEX_WIDTH_PRIMARY_COLUMNS
})
solver.addSparseConstraints(prepared.constraints)
return solver.findAll()
}

function solveFreshOne(prepared: PreparedIndexWidth): unknown {
const solver = new DancingLinks<number>().createSolver({
columns: INDEX_WIDTH_PRIMARY_COLUMNS
})
solver.addSparseConstraints(prepared.constraints)
return solver.findOne()
}

function solveFreshCount(prepared: PreparedIndexWidth, count: number): unknown {
const solver = new DancingLinks<number>().createSolver({
columns: INDEX_WIDTH_PRIMARY_COLUMNS
})
solver.addSparseConstraints(prepared.constraints)
return solver.find(count)
}

export class IndexWidthFreshSolver extends Solver<void, PreparedIndexWidth> {
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<void, PreparedMixedWidths> {
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)]
}
}
10 changes: 8 additions & 2 deletions benchmark/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -77,7 +81,9 @@ export type ProblemParameters<T extends ProblemType> = T extends 'sudoku'
? PentominoParams
: T extends 'n-queens'
? NQueensParams
: never
: T extends 'index-width'
? IndexWidthParams
: never

/**
* Benchmark case definition with proper type inference
Expand Down
Loading
Loading