├── img ├── chart-48.png ├── copy-48.png ├── favicon.png └── download-48.png ├── .gitignore ├── package.json ├── tests ├── helpers │ ├── test_runner.js │ ├── test_env.js │ └── constraint_test_utils.js ├── run_all_tests.js ├── user_script_executor.test.js ├── engine_debug_state.test.js ├── bench │ ├── micro │ │ ├── mask_to_index.bench.js │ │ ├── popcount16.bench.js │ │ ├── iterate_bits.bench.js │ │ ├── range_span.bench.js │ │ ├── return_values.bench.js │ │ └── loop_forms.bench.js │ ├── run_all_benchmarks.js │ ├── bench_harness.js │ ├── lookup_tables.bench.js │ └── util.bench.js ├── user_script_worker.test.js ├── conflict_scores.test.js ├── e2e.test.js ├── solver │ ├── candidate_selector_interesting.test.js │ └── candidate_selector_invariants.test.js ├── sum_handler.test.js └── engine │ └── cell_exclusions.test.js ├── README.md ├── LICENSE ├── lib ├── prism-tomorrow.min.css ├── prism-javascript.min.js └── codejar.min.js ├── css ├── help.css ├── sandbox.css └── debug.css ├── js ├── grid_shape.js ├── sandbox │ ├── env.js │ ├── sandbox.js │ └── examples.js ├── solver_worker.js ├── user_script_worker.js ├── solver │ ├── lookup_tables.js │ └── nfa_handler.js ├── help │ └── help.js └── sudoku_parser.js ├── sandbox.html └── help.html /img/chart-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sigh/Interactive-Sudoku-Solver/HEAD/img/chart-48.png -------------------------------------------------------------------------------- /img/copy-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sigh/Interactive-Sudoku-Solver/HEAD/img/copy-48.png -------------------------------------------------------------------------------- /img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sigh/Interactive-Sudoku-Solver/HEAD/img/favicon.png -------------------------------------------------------------------------------- /img/download-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sigh/Interactive-Sudoku-Solver/HEAD/img/download-48.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _site/ 2 | .sass-cache/ 3 | .jekyll-cache/ 4 | .jekyll-metadata 5 | .vscode/ 6 | .ignore/ 7 | .agent/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "interactive-sudoku-solver", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "test": "node tests/run_all_tests.js", 7 | "bench": "node tests/bench/run_all_benchmarks.js" 8 | } 9 | } -------------------------------------------------------------------------------- /tests/helpers/test_runner.js: -------------------------------------------------------------------------------- 1 | export const runTest = async (name, fn) => { 2 | try { 3 | await fn(); 4 | console.log(`✓ ${name}`); 5 | } catch (error) { 6 | console.error(`✗ ${name}`); 7 | throw error; 8 | } 9 | }; 10 | 11 | export const logSuiteComplete = (suiteName) => { 12 | console.log(`All ${suiteName} tests passed.`); 13 | }; 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive Sudoku Solver (ISS) 2 | 3 | A fast web-based solver written in JavaScript. 4 | 5 | It solve sudoku puzzles including a large number of variants, 6 | while making it easy to explore the solution space including: 7 | 8 | - Iterating over the solutions. 9 | - Counting the numbers solutions. 10 | - Seeing all possible values a cell can take. 11 | - Stepping through the solving process. 12 | 13 | It does _not_ aim to follow human algorithms for solving sudoku, but instead 14 | aims to be as fast as possible. 15 | 16 | It is hosted at 17 | 18 | ## Running locally 19 | 20 | Run locally using [Jekyll](https://jekyllrb.com/): 21 | 22 | ```bash 23 | jekyll serve 24 | ``` 25 | 26 | ## Tests 27 | 28 | Execute the test suite with: 29 | 30 | ```bash 31 | npm test 32 | ``` 33 | 34 | ## Contributions 35 | 36 | Contributions are welcome including: 37 | 38 | - New constraints/variants 39 | - Solver optimizations 40 | - UI improvements 41 | - Bug fixes 42 | - Code health and documentation 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 sigh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/prism-tomorrow.min.css: -------------------------------------------------------------------------------- 1 | code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green} -------------------------------------------------------------------------------- /tests/helpers/test_env.js: -------------------------------------------------------------------------------- 1 | export const ensureGlobalEnvironment = (options = {}) => { 2 | const { 3 | needWindow = false, 4 | windowObject, 5 | documentValue, 6 | locationValue, 7 | performance, 8 | } = options; 9 | 10 | const g = globalThis; 11 | 12 | if (!g.self) { 13 | g.self = g; 14 | } 15 | 16 | if (needWindow || windowObject) { 17 | if (!g.window) { 18 | g.window = windowObject || g; 19 | } 20 | } 21 | 22 | const hasDocumentOption = Object.prototype.hasOwnProperty.call(options, 'documentValue'); 23 | if (hasDocumentOption) { 24 | g.document = documentValue; 25 | } else if (needWindow && typeof g.document === 'undefined') { 26 | g.document = {}; 27 | } 28 | 29 | if (typeof g.VERSION_PARAM === 'undefined') { 30 | g.VERSION_PARAM = ''; 31 | } 32 | 33 | const resolvedLocation = locationValue ?? (needWindow ? { search: '' } : null); 34 | if (resolvedLocation && !g.location) { 35 | g.location = resolvedLocation; 36 | } 37 | 38 | if (performance && typeof g.performance === 'undefined') { 39 | g.performance = performance; 40 | } 41 | 42 | if (typeof g.atob !== 'function') { 43 | g.atob = (b64) => Buffer.from(b64, 'base64').toString('binary'); 44 | } 45 | if (typeof g.btoa !== 'function') { 46 | g.btoa = (binary) => Buffer.from(binary, 'binary').toString('base64'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /tests/run_all_tests.js: -------------------------------------------------------------------------------- 1 | import { readdir } from 'node:fs/promises'; 2 | import { join } from 'node:path'; 3 | import { fileURLToPath, pathToFileURL } from 'node:url'; 4 | 5 | const testsDirUrl = new URL('.', import.meta.url); 6 | const testsDirPath = fileURLToPath(testsDirUrl); 7 | 8 | const largeTests = [ 9 | 'e2e.test.js', 10 | ]; 11 | 12 | const findTests = async (dir, relativePath = '') => { 13 | const entries = await readdir(dir, { withFileTypes: true }); 14 | const tests = []; 15 | for (const entry of entries) { 16 | if (entry.isDirectory()) { 17 | tests.push(...await findTests(join(dir, entry.name), join(relativePath, entry.name))); 18 | } else if (entry.name.endsWith('.test.js')) { 19 | tests.push(join(relativePath, entry.name)); 20 | } 21 | } 22 | return tests; 23 | }; 24 | 25 | const discoveredTests = await findTests(testsDirPath); 26 | 27 | const orderedTests = [ 28 | ...discoveredTests 29 | .filter((name) => !largeTests.includes(name)) 30 | .sort(), 31 | ...largeTests.filter((name) => discoveredTests.includes(name)), 32 | ]; 33 | 34 | if (orderedTests.length === 0) { 35 | console.warn('No test files (*.test.js) found under tests/.'); 36 | process.exit(0); 37 | } 38 | 39 | for (const testFile of orderedTests) { 40 | console.log(`\n▶ Running ${testFile}`); 41 | await import(pathToFileURL(join(testsDirPath, testFile))); 42 | } 43 | 44 | console.log('\n✓ All tests completed successfully'); 45 | -------------------------------------------------------------------------------- /tests/user_script_executor.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import { ensureGlobalEnvironment } from './helpers/test_env.js'; 3 | 4 | ensureGlobalEnvironment({ 5 | needWindow: true, 6 | }); 7 | 8 | // Mock Worker 9 | class MockWorker { 10 | constructor(script) { 11 | this.script = script; 12 | this.onmessage = null; 13 | setTimeout(() => { 14 | // Simulate ready message 15 | if (this.onmessage) { 16 | this.onmessage({ data: { type: 'ready' } }); 17 | } 18 | }, 10); 19 | } 20 | postMessage(msg) { 21 | // Echo back with a delay 22 | setTimeout(() => { 23 | if (this.onmessage) { 24 | this.onmessage({ data: { id: msg.id, result: 'success', type: 'response' } }); 25 | } 26 | }, 50); 27 | } 28 | terminate() { } 29 | } 30 | globalThis.Worker = MockWorker; 31 | 32 | // Import UserScriptExecutor 33 | const { UserScriptExecutor } = await import('../js/sudoku_constraint.js'); 34 | 35 | // Test 36 | { 37 | console.log('Test: UserScriptExecutor timeout override'); 38 | const executor = new UserScriptExecutor(); 39 | 40 | // Default timeout is passed as argument. 41 | // Let's try a call with a short timeout that should pass (mock worker takes 50ms) 42 | await executor._call('test', {}, 100); 43 | console.log('Normal call passed'); 44 | 45 | // Now set global timeout to be very short to force timeout 46 | self.USER_SCRIPT_TIMEOUT = 10; 47 | try { 48 | await executor._call('test', {}, 100); 49 | assert.fail('Should have timed out due to global override'); 50 | } catch (e) { 51 | assert.match(e.message, /Execution timed out/); 52 | console.log('Global override caused timeout as expected'); 53 | } 54 | 55 | // Reset 56 | delete self.USER_SCRIPT_TIMEOUT; 57 | } 58 | 59 | console.log('user_script_executor.test.js passed!'); 60 | -------------------------------------------------------------------------------- /tests/helpers/constraint_test_utils.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | 3 | const g = globalThis; 4 | if (!g.self) { 5 | g.self = g; 6 | } 7 | if (typeof g.VERSION_PARAM === 'undefined') { 8 | g.VERSION_PARAM = ''; 9 | } 10 | 11 | const { LookupTables } = await import('../../js/solver/lookup_tables.js'); 12 | 13 | const DEFAULT_NUM_VALUES = 9; 14 | const DEFAULT_NUM_CELLS = 81; 15 | 16 | export const setupConstraintTest = ({ 17 | numValues = DEFAULT_NUM_VALUES, 18 | numCells = DEFAULT_NUM_CELLS, 19 | } = {}) => { 20 | const shape = { numValues, numCells }; 21 | const lookupTables = LookupTables.get(numValues); 22 | const createGrid = () => new Uint16Array(shape.numCells).fill(lookupTables.allValues); 23 | return { shape, lookupTables, createGrid }; 24 | }; 25 | 26 | export const mask = (...values) => LookupTables.fromValuesArray(values); 27 | 28 | export const createAccumulator = () => { 29 | const touched = new Set(); 30 | return { 31 | touched, 32 | addForCell(cell) { 33 | touched.add(cell); 34 | }, 35 | }; 36 | }; 37 | 38 | export const createCellExclusions = ({ allUnique = true } = {}) => ({ 39 | isMutuallyExclusive: allUnique ? () => true : () => false, 40 | getPairExclusions: () => [], 41 | getArray: () => [], 42 | getListExclusions: () => [], 43 | }); 44 | 45 | export const applyCandidates = (grid, assignments) => { 46 | for (const [cellKey, values] of Object.entries(assignments)) { 47 | const cellIndex = Number(cellKey); 48 | if (Array.isArray(values)) { 49 | grid[cellIndex] = mask(...values); 50 | } else if (typeof values === 'number') { 51 | grid[cellIndex] = values; 52 | } else { 53 | throw new TypeError('Assignments must be arrays of values or numeric bitmasks'); 54 | } 55 | } 56 | return grid; 57 | }; 58 | 59 | export const initializeConstraintHandler = ( 60 | HandlerCtor, 61 | { 62 | args = [], 63 | context, 64 | shapeConfig, 65 | cellExclusions = createCellExclusions(), 66 | state = {}, 67 | } = {} 68 | ) => { 69 | const resolvedContext = context ?? setupConstraintTest(shapeConfig ?? {}); 70 | const handler = new HandlerCtor(...args); 71 | const initialGrid = resolvedContext.createGrid(); 72 | assert.equal( 73 | handler.initialize(initialGrid, cellExclusions, resolvedContext.shape, state), 74 | true, 75 | 'constraint handler should initialize' 76 | ); 77 | return { handler, context: resolvedContext }; 78 | }; 79 | -------------------------------------------------------------------------------- /tests/engine_debug_state.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | 3 | import { ensureGlobalEnvironment } from './helpers/test_env.js'; 4 | import { runTest, logSuiteComplete } from './helpers/test_runner.js'; 5 | 6 | ensureGlobalEnvironment(); 7 | 8 | const { SudokuBuilder } = await import('../js/solver/sudoku_builder.js'); 9 | const { SudokuConstraint } = await import('../js/sudoku_constraint.js'); 10 | 11 | const makeEasyClassicConstraint = () => { 12 | const givens = [ 13 | ['R1C1', 5], ['R1C2', 3], ['R1C5', 7], 14 | ['R2C1', 6], ['R2C4', 1], ['R2C5', 9], ['R2C6', 5], 15 | ['R3C2', 9], ['R3C3', 8], ['R3C8', 6], 16 | ['R4C1', 8], ['R4C5', 6], ['R4C9', 3], 17 | ['R5C1', 4], ['R5C4', 8], ['R5C6', 3], ['R5C9', 1], 18 | ['R6C1', 7], ['R6C5', 2], ['R6C9', 6], 19 | ['R7C2', 6], ['R7C7', 2], ['R7C8', 8], 20 | ['R8C4', 4], ['R8C5', 1], ['R8C6', 9], ['R8C9', 5], 21 | ['R9C5', 8], ['R9C8', 7], ['R9C9', 9], 22 | ]; 23 | 24 | return new SudokuConstraint.Set( 25 | givens.map(([cell, value]) => new SudokuConstraint.Given(cell, value)) 26 | ); 27 | }; 28 | 29 | await runTest('debugState should be null when debugging disabled', () => { 30 | const constraint = makeEasyClassicConstraint(); 31 | const solver = SudokuBuilder.build(constraint, { 32 | logLevel: 0, 33 | enableStepLogs: false, 34 | exportConflictHeatmap: false, 35 | exportStackTrace: false, 36 | }); 37 | 38 | assert.equal(solver.debugState(), null); 39 | }); 40 | 41 | await runTest('debugState should include stackTrace when enabled', () => { 42 | const constraint = makeEasyClassicConstraint(); 43 | const solver = SudokuBuilder.build(constraint, { 44 | exportStackTrace: true, 45 | }); 46 | 47 | let maxStackDepth = 0; 48 | solver.setProgressCallback(() => { 49 | const dbg = solver.debugState(); 50 | const st = dbg?.stackTrace; 51 | if (!st?.cells?.length) return; 52 | maxStackDepth = Math.max(maxStackDepth, st.cells.length); 53 | assert.ok(st.values, 'expected stackTrace.values'); 54 | assert.equal(st.values.length, st.cells.length); 55 | 56 | // For classic Sudoku, values should be in [1, 9]. 57 | // (If a non-singleton ever appears, toValue() would typically return 0.) 58 | for (let i = 0; i < st.values.length; i++) { 59 | assert.ok(st.values[i] >= 0 && st.values[i] <= 9); 60 | } 61 | }, 0); 62 | 63 | const solution = solver.nthSolution(0); 64 | assert.ok(solution, 'expected a solution'); 65 | assert.ok(maxStackDepth > 0, `expected stackTrace depth > 0, got ${maxStackDepth}`); 66 | }); 67 | 68 | logSuiteComplete('engine_debug_state'); 69 | -------------------------------------------------------------------------------- /tests/bench/micro/mask_to_index.bench.js: -------------------------------------------------------------------------------- 1 | import { bench, benchGroup, runIfMain } from '../bench_harness.js'; 2 | 3 | // Microbench: single-bit mask -> index/value conversions. 4 | // Used heavily throughout solver code (e.g., iterating bitmasks). 5 | 6 | let sink = 0; 7 | const consume = (x) => { sink ^= (x | 0); }; 8 | 9 | const makeLCG = (seed) => { 10 | let s = seed >>> 0; 11 | return () => { 12 | s = (1664525 * s + 1013904223) >>> 0; 13 | return s; 14 | }; 15 | }; 16 | 17 | // Precompute a 16-bit mask -> index table (-1 for non-single-bit). 18 | const INDEX16 = (() => { 19 | const t = new Int8Array(1 << 16); 20 | t.fill(-1); 21 | for (let i = 0; i < 16; i++) t[1 << i] = i; 22 | return t; 23 | })(); 24 | 25 | const VALUE16 = (() => { 26 | const t = new Int8Array(1 << 16); 27 | // 0 stays 0. 28 | for (let i = 0; i < 16; i++) t[1 << i] = i + 1; 29 | return t; 30 | })(); 31 | 32 | const INPUT_COUNT = 4096; 33 | const inputs = (() => { 34 | const rng = makeLCG(0xA11CE); 35 | const arr = new Uint16Array(INPUT_COUNT); 36 | for (let i = 0; i < arr.length; i++) arr[i] = 1 << (rng() & 15); 37 | return arr; 38 | })(); 39 | 40 | const INDEX_MASK = INPUT_COUNT - 1; 41 | 42 | benchGroup('micro::mask_to_index', () => { 43 | { 44 | let i = 0; 45 | bench('baseline(xor)', () => { 46 | consume(inputs[i++ & INDEX_MASK]); 47 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 48 | } 49 | 50 | { 51 | let i = 0; 52 | bench('index: 31-clz32(mask)', () => { 53 | const m = inputs[i++ & INDEX_MASK]; 54 | consume(31 - Math.clz32(m)); 55 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 56 | } 57 | 58 | { 59 | let i = 0; 60 | bench('index: log2(mask)|0', () => { 61 | const m = inputs[i++ & INDEX_MASK]; 62 | consume(Math.log2(m) | 0); 63 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 64 | } 65 | 66 | { 67 | let i = 0; 68 | bench('index: INDEX16[mask]', () => { 69 | const m = inputs[i++ & INDEX_MASK]; 70 | consume(INDEX16[m]); 71 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 72 | } 73 | 74 | { 75 | let i = 0; 76 | bench('value: 32-clz32(mask)', () => { 77 | const m = inputs[i++ & INDEX_MASK]; 78 | consume(32 - Math.clz32(m)); 79 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 80 | } 81 | 82 | { 83 | let i = 0; 84 | bench('value: VALUE16[mask]', () => { 85 | const m = inputs[i++ & INDEX_MASK]; 86 | consume(VALUE16[m]); 87 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 88 | } 89 | }); 90 | 91 | export const _benchSink = () => sink; 92 | await runIfMain(import.meta.url); 93 | -------------------------------------------------------------------------------- /css/help.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #0066cc; 3 | --text-medium: #666; 4 | --border-color: #ddd; 5 | --background-light: #f9f9f9; 6 | --background-blue: #f0f8ff; 7 | 8 | --font-family-mono: monospace; 9 | } 10 | 11 | ul { 12 | padding-left: 20px; 13 | } 14 | 15 | li { 16 | margin: 8px 0; 17 | } 18 | 19 | a { 20 | font-weight: normal; 21 | } 22 | 23 | h2 { 24 | margin: 40px 0 20px 0; 25 | padding-bottom: 10px; 26 | border-bottom: 3px solid var(--primary-color); 27 | font-size: 1.5em; 28 | } 29 | 30 | h3 { 31 | margin: 20px 0 10px 0; 32 | font-size: 1.2em; 33 | padding-bottom: 5px; 34 | border-bottom: 2px solid var(--border-color); 35 | } 36 | 37 | h4 { 38 | margin: 0 0 8px 0; 39 | } 40 | 41 | p { 42 | color: var(--text-medium); 43 | line-height: 1.4; 44 | } 45 | 46 | pre { 47 | margin-left: 20px; 48 | padding: 12px 16px; 49 | max-width: 80ch; 50 | background-color: #f5f5f5; 51 | color: #333; 52 | border-left: 3px solid var(--primary-color); 53 | overflow-x: auto; 54 | font-family: var(--font-family-mono); 55 | font-size: 0.9em; 56 | line-height: 1.5; 57 | } 58 | 59 | .help-container { 60 | max-width: 1200px; 61 | margin: 0 auto; 62 | padding: 20px; 63 | display: grid; 64 | grid-template-columns: 250px 1fr; 65 | gap: 30px; 66 | align-items: start; 67 | } 68 | 69 | .help-header { 70 | grid-column: 1 / -1; 71 | display: flex; 72 | justify-content: space-between; 73 | align-items: center; 74 | margin-bottom: 20px; 75 | 76 | h1 { 77 | margin: 0; 78 | } 79 | } 80 | 81 | .help-content { 82 | min-width: 0; 83 | } 84 | 85 | .toc { 86 | margin: 0; 87 | padding: 15px 20px; 88 | background-color: var(--background-light); 89 | border: 1px solid var(--border-color); 90 | border-radius: 8px; 91 | position: sticky; 92 | top: 20px; 93 | max-height: calc(100vh - 40px); 94 | overflow-y: auto; 95 | 96 | ul { 97 | padding: 0 0 0 10px; 98 | list-style: none; 99 | } 100 | } 101 | 102 | .toc-title { 103 | margin: 0 0 10px 0; 104 | font-weight: bold; 105 | } 106 | 107 | .category-name { 108 | font-weight: bold; 109 | margin-bottom: 10px; 110 | } 111 | 112 | .category-overview-item { 113 | display: grid; 114 | grid-template-columns: 250px 1fr; 115 | gap: 30px; 116 | margin-bottom: 30px; 117 | align-items: start; 118 | } 119 | 120 | .category-overview-content { 121 | p { 122 | margin: 0 0 15px 0; 123 | } 124 | } 125 | 126 | .instructions { 127 | color: var(--text-medium); 128 | font-style: italic; 129 | font-size: 0.9em; 130 | line-height: 1.6; 131 | padding: 15px; 132 | background-color: var(--background-blue); 133 | border-left: 3px solid var(--primary-color); 134 | border-radius: 4px; 135 | white-space: pre-line; 136 | margin: 0; 137 | } 138 | 139 | .category-section { 140 | margin-bottom: 40px; 141 | 142 | p { 143 | margin: 0 0 20px 0; 144 | } 145 | } 146 | 147 | .constraint-list { 148 | display: grid; 149 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 150 | gap: 20px; 151 | } 152 | 153 | .constraint-item { 154 | border: 1px solid var(--border-color); 155 | border-radius: 8px; 156 | padding: 15px; 157 | background-color: var(--background-light); 158 | 159 | p { 160 | margin: 0; 161 | } 162 | } -------------------------------------------------------------------------------- /tests/bench/micro/popcount16.bench.js: -------------------------------------------------------------------------------- 1 | import { bench, benchGroup, runIfMain } from '../bench_harness.js'; 2 | 3 | // Microbench: 16-bit popcount variants. 4 | // The solver uses popcount in many hot paths (candidate counts, etc.). 5 | 6 | let sink = 0; 7 | const consume = (x) => { sink ^= (x | 0); }; 8 | 9 | const makeLCG = (seed) => { 10 | let s = seed >>> 0; 11 | return () => { 12 | s = (1664525 * s + 1013904223) >>> 0; 13 | return s; 14 | }; 15 | }; 16 | 17 | const popcountSWAR = (x) => { 18 | x -= (x >> 1) & 0x55555555; 19 | x = (x & 0x33333333) + ((x >> 2) & 0x33333333); 20 | x = (x + (x >> 4)) & 0x0f0f0f0f; 21 | x += x >> 8; 22 | return x & 0x1f; 23 | }; 24 | 25 | const POPCNT16 = (() => { 26 | const t = new Uint8Array(1 << 16); 27 | for (let i = 1; i < t.length; i++) t[i] = t[i & (i - 1)] + 1; 28 | return t; 29 | })(); 30 | 31 | const popcountTable16 = (x) => POPCNT16[x & 0xffff]; 32 | 33 | const popcountLoop = (x) => { 34 | x &= 0xffff; 35 | let c = 0; 36 | while (x) { 37 | x &= x - 1; 38 | c++; 39 | } 40 | return c; 41 | }; 42 | 43 | const INPUT_COUNT = 4096; 44 | const inputsSparse = (() => { 45 | const rng = makeLCG(0xC0FFEE); 46 | const arr = new Uint16Array(INPUT_COUNT); 47 | for (let i = 0; i < arr.length; i++) { 48 | const a = rng() & 15; 49 | const b = rng() & 15; 50 | arr[i] = (1 << a) | (1 << b); 51 | } 52 | return arr; 53 | })(); 54 | 55 | const inputsMixed = (() => { 56 | const rng = makeLCG(0xBADC0DE); 57 | const arr = new Uint16Array(INPUT_COUNT); 58 | for (let i = 0; i < arr.length; i++) arr[i] = rng() & 0xffff; 59 | return arr; 60 | })(); 61 | 62 | const INDEX_MASK = INPUT_COUNT - 1; 63 | 64 | benchGroup('micro::popcount16', () => { 65 | { 66 | let i = 0; 67 | bench('baseline(xor, sparse)', () => { 68 | consume(inputsSparse[i++ & INDEX_MASK]); 69 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 70 | } 71 | 72 | // Sparse (typical Sudoku candidates: 1-4 bits). 73 | { 74 | let i = 0; 75 | bench('SWAR (sparse)', () => { 76 | consume(popcountSWAR(inputsSparse[i++ & INDEX_MASK])); 77 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 78 | } 79 | 80 | { 81 | let i = 0; 82 | bench('table[mask] (sparse)', () => { 83 | consume(popcountTable16(inputsSparse[i++ & INDEX_MASK])); 84 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 85 | } 86 | 87 | { 88 | let i = 0; 89 | bench('x&=x-1 loop (sparse)', () => { 90 | consume(popcountLoop(inputsSparse[i++ & INDEX_MASK])); 91 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 92 | } 93 | 94 | // Mixed (uniform random 16-bit masks). 95 | { 96 | let i = 0; 97 | bench('SWAR (mixed)', () => { 98 | consume(popcountSWAR(inputsMixed[i++ & INDEX_MASK])); 99 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 100 | } 101 | 102 | { 103 | let i = 0; 104 | bench('table[mask] (mixed)', () => { 105 | consume(popcountTable16(inputsMixed[i++ & INDEX_MASK])); 106 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 107 | } 108 | 109 | { 110 | let i = 0; 111 | bench('x&=x-1 loop (mixed)', () => { 112 | consume(popcountLoop(inputsMixed[i++ & INDEX_MASK])); 113 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 114 | } 115 | }); 116 | 117 | export const _benchSink = () => sink; 118 | await runIfMain(import.meta.url); 119 | -------------------------------------------------------------------------------- /tests/bench/micro/iterate_bits.bench.js: -------------------------------------------------------------------------------- 1 | import { bench, benchGroup, runIfMain } from '../bench_harness.js'; 2 | 3 | // Microbench: iterate set bits in small masks. 4 | // Pattern appears throughout the solver: `while (m) { b=m&-m; m^=b; ... }`. 5 | 6 | let sink = 0; 7 | const consume = (x) => { sink ^= (x | 0); }; 8 | 9 | const makeLCG = (seed) => { 10 | let s = seed >>> 0; 11 | return () => { 12 | s = (1664525 * s + 1013904223) >>> 0; 13 | return s; 14 | }; 15 | }; 16 | 17 | const INDEX16 = (() => { 18 | const t = new Int8Array(1 << 16); 19 | t.fill(-1); 20 | for (let i = 0; i < 16; i++) t[1 << i] = i; 21 | return t; 22 | })(); 23 | 24 | const makeMasks = (numValues, mode, seed) => { 25 | const rng = makeLCG(seed); 26 | const arr = new Uint16Array(4096); 27 | const all = (1 << numValues) - 1; 28 | 29 | for (let i = 0; i < arr.length; i++) { 30 | let m = 0; 31 | if (mode === 'sparse') { 32 | const a = rng() % numValues; 33 | const b = rng() % numValues; 34 | m = (1 << a) | (1 << b); 35 | } else if (mode === 'half') { 36 | for (let b = 0; b < numValues; b++) if (rng() & 1) m |= 1 << b; 37 | m ||= 1; 38 | } else if (mode === 'dense') { 39 | m = all; 40 | const clears = rng() % 3; 41 | for (let j = 0; j < clears; j++) m &= ~(1 << (rng() % numValues)); 42 | } else { 43 | m = rng() & all; 44 | m ||= 1; 45 | } 46 | 47 | arr[i] = m; 48 | } 49 | 50 | return arr; 51 | }; 52 | 53 | const INPUT_COUNT = 4096; 54 | const INDEX_MASK = INPUT_COUNT - 1; 55 | 56 | const masks9Sparse = makeMasks(9, 'sparse', 0xC0FFEE); 57 | const masks9Half = makeMasks(9, 'half', 0xBADC0DE); 58 | const masks9Dense = makeMasks(9, 'dense', 0xFEEDFACE); 59 | 60 | const benchIterators = (label, masks, numValues) => { 61 | // Lowbit loop; uses clz32 to convert bit->index. 62 | { 63 | let i = 0; 64 | bench(`${label} :: lowbit+clz32`, () => { 65 | let m = masks[i++ & INDEX_MASK]; 66 | let acc = 0; 67 | while (m) { 68 | const b = m & -m; 69 | m ^= b; 70 | acc += 31 - Math.clz32(b); 71 | } 72 | consume(acc); 73 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 74 | } 75 | 76 | // Lowbit loop; uses table to convert bit->index. 77 | { 78 | let i = 0; 79 | bench(`${label} :: lowbit+INDEX16`, () => { 80 | let m = masks[i++ & INDEX_MASK]; 81 | let acc = 0; 82 | while (m) { 83 | const b = m & -m; 84 | m ^= b; 85 | acc += INDEX16[b]; 86 | } 87 | consume(acc); 88 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 89 | } 90 | 91 | // Scan bits 0..N-1. 92 | { 93 | let i = 0; 94 | bench(`${label} :: scan(0..N-1)`, () => { 95 | const m = masks[i++ & INDEX_MASK]; 96 | let acc = 0; 97 | for (let b = 0; b < numValues; b++) { 98 | if (m & (1 << b)) acc += b; 99 | } 100 | consume(acc); 101 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 102 | } 103 | }; 104 | 105 | benchGroup('micro::iterate_bits', () => { 106 | // Baseline. 107 | { 108 | let i = 0; 109 | bench('baseline(xor)', () => { 110 | consume(masks9Half[i++ & INDEX_MASK]); 111 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 112 | } 113 | 114 | benchIterators('9v sparse', masks9Sparse, 9); 115 | benchIterators('9v half', masks9Half, 9); 116 | benchIterators('9v dense', masks9Dense, 9); 117 | }); 118 | 119 | export const _benchSink = () => sink; 120 | await runIfMain(import.meta.url); 121 | -------------------------------------------------------------------------------- /tests/bench/micro/range_span.bench.js: -------------------------------------------------------------------------------- 1 | import { bench, benchGroup, runIfMain } from '../bench_harness.js'; 2 | 3 | // Microbench: compute bit-span (maxBitIndex - minBitIndex) for a 16-bit mask. 4 | // This mirrors the `sum_handler` pattern: 5 | // const clz = Math.clz32(v); 6 | // const span = Math.clz32(v & -v) - clz; 7 | // (where v is non-zero). 8 | 9 | let sink = 0; 10 | const consume = (x) => { sink ^= (x | 0); }; 11 | 12 | const makeLCG = (seed) => { 13 | let s = seed >>> 0; 14 | return () => { 15 | s = (1664525 * s + 1013904223) >>> 0; 16 | return s; 17 | }; 18 | }; 19 | 20 | // Precompute span for all 16-bit masks. 21 | // For mask==0, span=0 (undefined in real use, but keeps table total). 22 | const SPAN16 = (() => { 23 | const t = new Uint8Array(1 << 16); 24 | for (let m = 1; m < t.length; m++) { 25 | const hi = 31 - Math.clz32(m); 26 | const lo = 31 - Math.clz32(m & -m); 27 | t[m] = hi - lo; 28 | } 29 | return t; 30 | })(); 31 | 32 | const spanClzCached = (v) => { 33 | const clz = Math.clz32(v); 34 | return Math.clz32(v & -v) - clz; 35 | }; 36 | 37 | const spanClzNoCache = (v) => { 38 | return Math.clz32(v & -v) - Math.clz32(v); 39 | }; 40 | 41 | const spanTable = (v) => SPAN16[v & 0xffff]; 42 | 43 | const INPUT_COUNT = 4096; 44 | const INDEX_MASK = INPUT_COUNT - 1; 45 | 46 | const makeMasks = (numValues, mode, seed) => { 47 | const rng = makeLCG(seed); 48 | const arr = new Uint16Array(INPUT_COUNT); 49 | const all = (1 << numValues) - 1; 50 | 51 | for (let i = 0; i < arr.length; i++) { 52 | let m = 0; 53 | if (mode === 'sparse') { 54 | const a = rng() % numValues; 55 | const b = rng() % numValues; 56 | m = (1 << a) | (1 << b); 57 | } else if (mode === 'half') { 58 | for (let b = 0; b < numValues; b++) if (rng() & 1) m |= 1 << b; 59 | m ||= 1; 60 | } else if (mode === 'dense') { 61 | m = all; 62 | const clears = rng() % 3; 63 | for (let j = 0; j < clears; j++) m &= ~(1 << (rng() % numValues)); 64 | } else { 65 | m = rng() & all; 66 | m ||= 1; 67 | } 68 | 69 | arr[i] = m; 70 | } 71 | 72 | return arr; 73 | }; 74 | 75 | const masks9Sparse = makeMasks(9, 'sparse', 0xC0FFEE); 76 | const masks9Half = makeMasks(9, 'half', 0xBADC0DE); 77 | const masks9Dense = makeMasks(9, 'dense', 0xFEEDFACE); 78 | 79 | const benchOne = (label, masks, spanFn) => { 80 | let i = 0; 81 | bench(label, () => { 82 | const v = masks[i++ & INDEX_MASK]; 83 | consume(spanFn(v)); 84 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 85 | }; 86 | 87 | benchGroup('micro::range_span', () => { 88 | { 89 | let i = 0; 90 | bench('baseline(xor)', () => { 91 | consume(masks9Half[i++ & INDEX_MASK]); 92 | }, { innerIterations: 6_000_000, minSampleTimeMs: 25 }); 93 | } 94 | 95 | // 9-value masks, representative of Sudoku candidates. 96 | benchOne('9v sparse :: clz cached', masks9Sparse, spanClzCached); 97 | benchOne('9v sparse :: clz no-cache', masks9Sparse, spanClzNoCache); 98 | benchOne('9v sparse :: table', masks9Sparse, spanTable); 99 | 100 | benchOne('9v half :: clz cached', masks9Half, spanClzCached); 101 | benchOne('9v half :: clz no-cache', masks9Half, spanClzNoCache); 102 | benchOne('9v half :: table', masks9Half, spanTable); 103 | 104 | benchOne('9v dense :: clz cached', masks9Dense, spanClzCached); 105 | benchOne('9v dense :: clz no-cache', masks9Dense, spanClzNoCache); 106 | benchOne('9v dense :: table', masks9Dense, spanTable); 107 | }); 108 | 109 | export const _benchSink = () => sink; 110 | await runIfMain(import.meta.url); 111 | -------------------------------------------------------------------------------- /tests/bench/micro/return_values.bench.js: -------------------------------------------------------------------------------- 1 | import { bench, benchGroup, runIfMain } from '../bench_harness.js'; 2 | 3 | // Standalone microbench: compare strategies for returning two values from a hot function. 4 | // Intentionally independent of application code. 5 | 6 | // Prevent V8 from DCEing results. 7 | let sink = 0; 8 | const consume = (x) => { sink ^= (x | 0); }; 9 | 10 | const makeLCG = (seed) => { 11 | let s = seed >>> 0; 12 | return () => { 13 | s = (1664525 * s + 1013904223) >>> 0; 14 | return s; 15 | }; 16 | }; 17 | 18 | const rng = makeLCG(0xC0FFEE); 19 | 20 | // Deterministic inputs; sized as power-of-two for cheap indexing. 21 | const INPUT_COUNT = 4096; 22 | const inputs = (() => { 23 | const arr = new Uint32Array(INPUT_COUNT); 24 | for (let i = 0; i < arr.length; i++) arr[i] = rng(); 25 | return arr; 26 | })(); 27 | 28 | const INDEX_MASK = INPUT_COUNT - 1; 29 | 30 | // Return strategies. 31 | const retArray = (x) => { 32 | const a = x & 0xffff; 33 | const b = x >>> 16; 34 | return [a, b]; 35 | }; 36 | 37 | const retObject = (x) => { 38 | const a = x & 0xffff; 39 | const b = x >>> 16; 40 | return { a, b }; 41 | }; 42 | 43 | const outU32 = (x, out) => { 44 | out[0] = x & 0xffff; 45 | out[1] = x >>> 16; 46 | }; 47 | 48 | const outArray = (x, out) => { 49 | out[0] = x & 0xffff; 50 | out[1] = x >>> 16; 51 | }; 52 | 53 | // Packed return (single number) as a baseline for "no container". 54 | const retPacked32 = (x) => x; // already a packed pair (low/high 16 bits) 55 | 56 | benchGroup('micro::return_values', () => { 57 | // Baseline loop body cost. 58 | { 59 | let i = 0; 60 | bench('baseline(xor)', () => { 61 | const x = inputs[i++ & INDEX_MASK]; 62 | consume(x); 63 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 64 | } 65 | 66 | // Return [a,b] and consume both. 67 | { 68 | let i = 0; 69 | bench('return array [a,b]', () => { 70 | const x = inputs[i++ & INDEX_MASK]; 71 | const r = retArray(x); 72 | consume((r[0] + r[1]) | 0); 73 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 74 | } 75 | 76 | // Return {a,b} and consume both. 77 | { 78 | let i = 0; 79 | bench('return object {a,b}', () => { 80 | const x = inputs[i++ & INDEX_MASK]; 81 | const r = retObject(x); 82 | consume((r.a + r.b) | 0); 83 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 84 | } 85 | 86 | // Out parameter: preallocated Uint32Array(2). 87 | { 88 | const out = new Uint32Array(2); 89 | let i = 0; 90 | bench('out-param Uint32Array(2)', () => { 91 | const x = inputs[i++ & INDEX_MASK]; 92 | outU32(x, out); 93 | consume((out[0] + out[1]) | 0); 94 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 95 | } 96 | 97 | // Out parameter: preallocated plain Array(2). 98 | { 99 | const out = [0, 0]; 100 | let i = 0; 101 | bench('out-param Array(2)', () => { 102 | const x = inputs[i++ & INDEX_MASK]; 103 | outArray(x, out); 104 | consume((out[0] + out[1]) | 0); 105 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 106 | } 107 | 108 | // Packed return: consume both halves without allocating. 109 | { 110 | let i = 0; 111 | bench('return packed uint32', () => { 112 | const x = inputs[i++ & INDEX_MASK]; 113 | const p = retPacked32(x); 114 | consume(((p & 0xffff) + (p >>> 16)) | 0); 115 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 116 | } 117 | }); 118 | 119 | // Export a value so the module has an observable side-effect. 120 | export const _benchSink = () => sink; 121 | await runIfMain(import.meta.url); 122 | -------------------------------------------------------------------------------- /js/grid_shape.js: -------------------------------------------------------------------------------- 1 | const { memoize } = await import('./util.js' + self.VERSION_PARAM); 2 | 3 | export class GridShape { 4 | static MIN_SIZE = 1; 5 | static MAX_SIZE = 16; 6 | static isValidGridSize(size) { 7 | return Number.isInteger(size) && size >= this.MIN_SIZE && size <= this.MAX_SIZE; 8 | } 9 | 10 | static fromGridSize = memoize((gridSize) => { 11 | if (!this.isValidGridSize(gridSize)) return null; 12 | return new GridShape(undefined, gridSize); 13 | }); 14 | 15 | static fromGridSpec(gridSpec) { 16 | const parts = gridSpec.split('x'); 17 | const gridSize = parseInt(parts[0]); 18 | if (parts.length != 2 || parts[0] !== parts[1] || 19 | gridSize.toString() !== parts[0]) { 20 | throw ('Invalid grid spec format: ' + gridSpec); 21 | } 22 | return this.fromGridSize(gridSize); 23 | }; 24 | 25 | static fromNumCells(numCells) { 26 | const gridSize = Math.sqrt(numCells); 27 | return this.fromGridSize(gridSize); 28 | } 29 | static fromNumPencilmarks(numPencilmarks) { 30 | const gridSize = Math.cbrt(numPencilmarks); 31 | return this.fromGridSize(gridSize); 32 | } 33 | 34 | static makeName(gridSize) { 35 | return `${gridSize}x${gridSize}`; 36 | } 37 | 38 | static baseCharCode(shape) { 39 | return shape.numValues < 10 ? '1'.charCodeAt(0) : 'A'.charCodeAt(0); 40 | } 41 | 42 | constructor(do_not_call, gridSize) { 43 | if (do_not_call !== undefined) { 44 | throw Error('Use GridShape.fromGridSize() instead.'); 45 | } 46 | 47 | this.gridSize = gridSize; 48 | [this.boxHeight, this.boxWidth] = this.constructor._boxDims(gridSize); 49 | this.numValues = gridSize; 50 | this.numCells = gridSize * gridSize; 51 | this.numPencilmarks = this.numCells * this.numValues; 52 | this.noDefaultBoxes = this.boxHeight === 1 || this.boxWidth === 1; 53 | 54 | this.name = this.constructor.makeName(gridSize); 55 | 56 | this._valueBase = this.numValues + 1; 57 | 58 | this.allCells = []; 59 | for (let i = 0; i < this.numCells; i++) this.allCells.push(i); 60 | 61 | this.maxSum = this.gridSize * (this.gridSize + 1) / 2; 62 | 63 | Object.freeze(this); 64 | } 65 | 66 | static _boxDims(gridSize) { 67 | for (let i = Math.sqrt(gridSize) | 0; i >= 1; i--) { 68 | if (gridSize % i === 0) { 69 | return [i, gridSize / i]; 70 | } 71 | } 72 | throw ('Invalid grid size: ' + gridSize); 73 | } 74 | 75 | makeValueId = (cellIndex, n) => { 76 | const cellId = this.makeCellIdFromIndex(cellIndex); 77 | return `${cellId}_${n}`; 78 | } 79 | 80 | makeCellId = (row, col) => { 81 | return `R${(row + 1).toString(this._valueBase)}C${(col + 1).toString(this._valueBase)}`; 82 | } 83 | 84 | makeCellIdFromIndex = (i) => { 85 | return this.makeCellId(...this.splitCellIndex(i)); 86 | } 87 | 88 | cellIndex = (row, col) => { 89 | return row * this.gridSize + col; 90 | } 91 | 92 | splitCellIndex = (cell) => { 93 | return [cell / this.gridSize | 0, cell % this.gridSize | 0]; 94 | } 95 | 96 | parseValueId = (valueId) => { 97 | let [cellId, ...values] = valueId.split('_'); 98 | return { 99 | values: values.map(v => parseInt(v)), 100 | cellId: cellId, 101 | ...this.parseCellId(cellId), 102 | }; 103 | } 104 | 105 | parseCellId = (cellId) => { 106 | let row = parseInt(cellId[1], this._valueBase) - 1; 107 | let col = parseInt(cellId[3], this._valueBase) - 1; 108 | return { 109 | cell: this.cellIndex(row, col), 110 | row: row, 111 | col: col, 112 | }; 113 | } 114 | } 115 | 116 | export const SHAPE_MAX = GridShape.fromGridSize(GridShape.MAX_SIZE); 117 | export const SHAPE_9x9 = GridShape.fromGridSize(9); -------------------------------------------------------------------------------- /js/sandbox/env.js: -------------------------------------------------------------------------------- 1 | import { SudokuConstraint } from '../sudoku_constraint.js'; 2 | import { SudokuParser } from '../sudoku_parser.js'; 3 | import { SudokuBuilder } from '../solver/sudoku_builder.js'; 4 | import { GridShape, SHAPE_9x9, SHAPE_MAX } from '../grid_shape.js'; 5 | 6 | const HELP_TEXT = ` 7 | === Constraint Sandbox Help === 8 | 9 | ACCEPTED RETURN VALUES 10 | 11 | Your code should return one of the following: 12 | - A constraint object (e.g. new Cage(...)) 13 | - A constraint string (e.g. ".Cage~12~R1C1_R1C2_R1C3") 14 | - An array of constraints or constraint strings 15 | 16 | CELL IDENTIFIERS 17 | 18 | Cells are identified using 'R{row}C{col}' format, with rows and columns 19 | starting at 1. 20 | e.g. 'R1C1' is the top-left cell, 'R9C9' is the bottom-right cell in a 9x9 grid 21 | 22 | The following convenience functions are available for working with cell IDs: 23 | parseCellId('R3C4') => { row: 3, col: 4 } 24 | makeCellId(3, 4) => 'R3C4' 25 | 26 | CONSTRAINT OBJECTS 27 | 28 | Constraint class names match their serialization names. For example: 29 | new Cage(sum, ...cells) 30 | new Thermo(...cells) 31 | 32 | The type of a constraint instance c can be found with c.type. 33 | 34 | parseConstraint(constraintString) can parse a constraint string into an array 35 | of constraint objects. e.g. parseConstraint('.Cage~10~R1C1~R1C2') => [Cage] 36 | 37 | Use help('') for details on a specific constraint. 38 | 39 | UTILITIES 40 | 41 | Use console.log() for debug output. 42 | Use help() function to display this message. 43 | `.trim(); 44 | 45 | const getConstraintList = () => { 46 | const byCategory = {}; 47 | for (const [name, cls] of Object.entries(SudokuConstraint)) { 48 | if (typeof cls !== 'function') continue; 49 | if (!cls.CATEGORY || cls.CATEGORY === 'Experimental') continue; 50 | (byCategory[cls.CATEGORY] ||= []).push(name); 51 | } 52 | 53 | let output = '\nCONSTRAINTS BY CATEGORY\n'; 54 | for (const [category, names] of Object.entries(byCategory).sort()) { 55 | output += `\n ${category}:\n`; 56 | output += ' ' + names.sort().join(', ') + '\n'; 57 | } 58 | return output; 59 | }; 60 | 61 | const getConstructorArgs = (cls) => { 62 | const match = String(cls).match(/constructor\s*\(([^)]*)\)/); 63 | return match?.[1]?.trim() || ''; 64 | }; 65 | 66 | const help = (arg) => { 67 | const cls = arg && SudokuConstraint[arg]; 68 | if (cls) { 69 | const args = getConstructorArgs(cls); 70 | console.log(`${arg}${args ? `(${args})` : ''}`); 71 | if (cls.DESCRIPTION) { 72 | console.log('\n ' + cls.DESCRIPTION.trim().replace(/\s+/g, ' ')); 73 | } 74 | if (cls.CATEGORY) { 75 | console.log(`\n Category: ${cls.CATEGORY}`); 76 | } 77 | } else { 78 | if (arg) { 79 | console.error(`Unknown constraint: '${arg}'\n`); 80 | } 81 | console.log(HELP_TEXT); 82 | console.log(getConstraintList()); 83 | } 84 | console.log(); 85 | }; 86 | 87 | const parseCellId = (cellId) => { 88 | const parsed = SHAPE_MAX.parseCellId(cellId); 89 | return { 90 | row: parsed.row + 1, 91 | col: parsed.col + 1, 92 | }; 93 | }; 94 | 95 | const makeCellId = (row, col) => SHAPE_MAX.makeCellId(row - 1, col - 1); 96 | 97 | const parseConstraint = (str) => { 98 | const parsed = SudokuParser.parseString(str); 99 | const resolved = SudokuBuilder.resolveConstraint(parsed); 100 | if (resolved.type === 'Set') return resolved.constraints; 101 | return [resolved]; 102 | }; 103 | 104 | export const SANDBOX_GLOBALS = { 105 | parseConstraint, 106 | parseCellId, 107 | makeCellId, 108 | help, 109 | SHAPE_9x9, 110 | SHAPE_MAX, 111 | GridShape, 112 | ...SudokuConstraint, 113 | }; -------------------------------------------------------------------------------- /sandbox.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: null 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | Constraint Sandbox - Interactive Sudoku Solver 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 |
28 |
29 |

Interactive Sudoku Solver (ISS) – Constraint Sandbox

30 | ← Back to Solver 31 |
32 | 33 |
34 |

35 | Write JavaScript to generate constraints. 36 |

37 | 38 |

39 | Return a constraint or an array of constraints. A constraint can either 40 | be an instance of SudokuConstraint or a constraint string. 41 |

42 | 43 |

44 | help() will display detailed help or visit the 45 | help page for more general information about the 46 | solver. 47 | Visit the github repository to 48 | view the source code and report bugs. 50 |

51 |
52 | 53 |
54 |
55 |
56 |
57 | 60 | 61 | 65 |
66 |
67 |
68 |
69 | 70 |
71 |
72 |

Console Output – use console.log()

73 |
74 |
75 | 76 |
77 |
78 |

Constraint String

79 | 81 |
82 |
83 | 84 | 87 |
88 |
89 |
90 |
91 |
92 | 93 | 94 | -------------------------------------------------------------------------------- /tests/user_script_worker.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import { ensureGlobalEnvironment } from './helpers/test_env.js'; 3 | 4 | // Setup global environment to mock a Worker 5 | const messages = []; 6 | const postMessage = (msg) => { 7 | messages.push(msg); 8 | }; 9 | 10 | ensureGlobalEnvironment({ 11 | needWindow: false, 12 | locationValue: { search: '' }, 13 | }); 14 | 15 | // Mock self and postMessage 16 | globalThis.self = globalThis; 17 | globalThis.postMessage = postMessage; 18 | 19 | // Import the worker script 20 | // This will trigger the top-level execution and the async modulesPromise 21 | console.log('Importing user_script_worker.js...'); 22 | await import('../js/user_script_worker.js'); 23 | 24 | // Helper to wait for a message 25 | const waitForMessage = async (predicate, timeout = 1000) => { 26 | const start = Date.now(); 27 | while (Date.now() - start < timeout) { 28 | const msg = messages.find(predicate); 29 | if (msg) return msg; 30 | await new Promise(r => setTimeout(r, 10)); 31 | } 32 | throw new Error('Timeout waiting for message'); 33 | }; 34 | 35 | // Helper to send a message to the worker and wait for response 36 | const sendMessage = async (type, payload) => { 37 | const id = Date.now() + Math.random(); 38 | const responsePromise = waitForMessage(m => m.id === id); 39 | 40 | // Simulate onmessage event 41 | await globalThis.onmessage({ data: { id, type, payload } }); 42 | 43 | return responsePromise; 44 | }; 45 | 46 | // Test Suite 47 | console.log('Waiting for worker ready...'); 48 | await waitForMessage(m => m.type === 'ready'); 49 | console.log('Worker is ready.'); 50 | 51 | // Test 1: compilePairwise 52 | { 53 | console.log('Test: compilePairwise'); 54 | const response = await sendMessage('compilePairwise', { 55 | type: 'Binary', 56 | fnStr: 'a !== b', 57 | numValues: 9 58 | }); 59 | assert.equal(response.error, undefined); 60 | assert.ok(response.result); 61 | // Binary constraint key for a !== b with 9 values should be a specific string or structure 62 | // We just check it returns something truthy and looks like a constraint key (usually a string or object) 63 | } 64 | 65 | // Test 2: compileStateMachine (Unified) 66 | { 67 | console.log('Test: compileStateMachine (Unified)'); 68 | const spec = ` 69 | const startState = 0; 70 | const transition = (state, value) => (state + 1) % 4; 71 | const accept = (state) => state === 3; 72 | `; 73 | const response = await sendMessage('compileStateMachine', { 74 | spec, 75 | numValues: 9, 76 | isUnified: true 77 | }); 78 | if (response.error) console.error('StateMachine Error:', response.error); 79 | assert.equal(response.error, undefined); 80 | assert.ok(response.result); 81 | } 82 | 83 | // Test 3: runSandboxCode 84 | { 85 | console.log('Test: runSandboxCode'); 86 | const code = ` 87 | console.log("Hello from sandbox"); 88 | return "ConstraintString"; 89 | `; 90 | const response = await sendMessage('runSandboxCode', { code }); 91 | assert.equal(response.error, undefined); 92 | assert.equal(response.result.constraintStr, "ConstraintString"); 93 | assert.ok(response.result.logs.some(l => l.includes("Hello from sandbox"))); 94 | } 95 | 96 | // Test 4: runSandboxCode with error 97 | { 98 | console.log('Test: runSandboxCode with error'); 99 | const code = ` 100 | console.log("About to fail"); 101 | throw new Error("Sandbox Error"); 102 | `; 103 | const response = await sendMessage('runSandboxCode', { code }); 104 | assert.ok(response.error); 105 | assert.ok(response.error.includes("Sandbox Error")); 106 | assert.ok(response.logs.some(l => l.includes("About to fail"))); 107 | } 108 | 109 | console.log('user_script_worker.test.js passed!'); 110 | -------------------------------------------------------------------------------- /tests/conflict_scores.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | 3 | import { ensureGlobalEnvironment } from './helpers/test_env.js'; 4 | import { runTest, logSuiteComplete } from './helpers/test_runner.js'; 5 | 6 | ensureGlobalEnvironment(); 7 | 8 | const { ConflictScores } = await import('../js/solver/candidate_selector.js'); 9 | 10 | await runTest('ConflictScores.increment should increment cell and value scores', () => { 11 | const cs = new ConflictScores([0, 0, 0], /* numValues= */ 16); 12 | const valueMask = 1 << 3; 13 | 14 | cs.increment(1, valueMask); 15 | 16 | assert.equal(cs.scores[1], 1); 17 | // Value scores are observable via getMaxValueScore once they are significant. 18 | const info = cs.getMaxValueScore(); 19 | assert.deepEqual(info, { value: 0, score: 0 }); 20 | }); 21 | 22 | await runTest('ConflictScores.decay should decay cell scores', () => { 23 | const cs = new ConflictScores([5, 3], /* numValues= */ 16); 24 | cs.decay(); 25 | assert.deepEqual(cs.scores, [2, 1]); 26 | }); 27 | 28 | await runTest('ConflictScores.increment should trigger decay when countdown hits zero', () => { 29 | // Keep this black-box-ish: only assert visible effects (cell scores). 30 | // We still need to force the countdown because the default is large. 31 | const cs = new ConflictScores([3], /* numValues= */ 16); 32 | const valueMask = 1 << 4; 33 | cs._decayCountdown = 1; 34 | 35 | cs.increment(0, valueMask); 36 | 37 | // After increment: scores[0] was 4, then decay applied => 4 >> 1. 38 | assert.equal(cs.scores[0], 2); 39 | }); 40 | 41 | await runTest('ConflictScores.getMaxValueScore should return zero when max is not significant', () => { 42 | const cs = new ConflictScores([0], /* numValues= */ 16); 43 | // Build max=15 (< numValues) via increments. 44 | const valueMask = 1 << 1; 45 | for (let i = 0; i < 15; i++) cs.increment(0, valueMask); 46 | assert.deepEqual(cs.getMaxValueScore(), { value: 0, score: 0 }); 47 | }); 48 | 49 | await runTest('ConflictScores.getMaxValueScore should return zero when spread is insufficient (boundary)', () => { 50 | const cs = new ConflictScores([0], /* numValues= */ 16); 51 | const minMask = 1 << 2; 52 | const maxMask = 1 << 5; 53 | 54 | // min=16 55 | for (let i = 0; i < 16; i++) cs.increment(0, minMask); 56 | // max=24 (exactly 1.5*min) => should NOT pass 57 | for (let i = 0; i < 24; i++) cs.increment(0, maxMask); 58 | 59 | assert.deepEqual(cs.getMaxValueScore(), { value: 0, score: 0 }); 60 | }); 61 | 62 | await runTest('ConflictScores.getMaxValueScore should return best value+score when spread is sufficient', () => { 63 | const cs = new ConflictScores([0], /* numValues= */ 16); 64 | const minMask = 1 << 2; 65 | const maxMask = 1 << 5; 66 | 67 | // min=16 68 | for (let i = 0; i < 16; i++) cs.increment(0, minMask); 69 | // max=32 => max > 1.5*min and >= numValues 70 | for (let i = 0; i < 32; i++) cs.increment(0, maxMask); 71 | 72 | assert.deepEqual(cs.getMaxValueScore(), { value: maxMask, score: 32 }); 73 | }); 74 | 75 | await runTest('ConflictScores.getMaxValueScore should ignore zeros when computing min', () => { 76 | const cs = new ConflictScores([0], /* numValues= */ 16); 77 | const onlyMask = 1 << 7; 78 | for (let i = 0; i < 32; i++) cs.increment(0, onlyMask); 79 | 80 | // With only one non-zero value, min == max so spread check fails. 81 | assert.deepEqual(cs.getMaxValueScore(), { value: 0, score: 0 }); 82 | }); 83 | 84 | await runTest('ConflictScores.decay should decay value scores (observable via getMaxValueScore)', () => { 85 | const cs = new ConflictScores([0], /* numValues= */ 16); 86 | const minMask = 1 << 2; 87 | const maxMask = 1 << 5; 88 | 89 | for (let i = 0; i < 16; i++) cs.increment(0, minMask); 90 | for (let i = 0; i < 32; i++) cs.increment(0, maxMask); 91 | assert.deepEqual(cs.getMaxValueScore(), { value: maxMask, score: 32 }); 92 | 93 | cs.decay(); 94 | // Value scores decay by >>2, so max becomes 8 (< numValues), which fails significance. 95 | assert.deepEqual(cs.getMaxValueScore(), { value: 0, score: 0 }); 96 | }); 97 | 98 | logSuiteComplete('ConflictScores'); 99 | -------------------------------------------------------------------------------- /css/sandbox.css: -------------------------------------------------------------------------------- 1 | /* Sandbox page styles */ 2 | 3 | /* Override style.css body width: fit-content */ 4 | body { 5 | width: unset; 6 | /* Have space at the bottom to make it easier to expand the editor. */ 7 | margin-bottom: 120px; 8 | } 9 | 10 | .intro { 11 | max-width: 600px; 12 | } 13 | 14 | .sandbox-container { 15 | margin: 20px; 16 | max-width: 1200px; 17 | } 18 | 19 | .help-header { 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | margin-bottom: 20px; 24 | } 25 | 26 | .help-header h1 { 27 | margin: 0; 28 | font-size: 1.5em; 29 | } 30 | 31 | p { 32 | color: var(--color-text-muted); 33 | line-height: 1.4; 34 | } 35 | 36 | p code { 37 | background: #f5f5f5; 38 | padding: 2px 6px; 39 | border-radius: 3px; 40 | font-family: var(--font-family-mono); 41 | } 42 | 43 | /* Two-column layout using flexbox for natural wrapping */ 44 | .editor-container { 45 | display: flex; 46 | flex-wrap: wrap; 47 | gap: 20px; 48 | } 49 | 50 | .editor-panel { 51 | flex: 0 0 auto; 52 | } 53 | 54 | .output-panel { 55 | flex: 1 1 300px; 56 | min-width: 300px; 57 | } 58 | 59 | /* Panel headers */ 60 | .panel-header { 61 | display: flex; 62 | align-items: center; 63 | flex-wrap: wrap; 64 | gap: 4px 8px; 65 | margin-bottom: 10px; 66 | min-height: 28px; 67 | } 68 | 69 | .panel-header h2 { 70 | margin: 0; 71 | font-size: 1.1em; 72 | color: var(--color-text-muted); 73 | } 74 | 75 | /* Button group */ 76 | .btn-group { 77 | display: flex; 78 | flex-wrap: wrap; 79 | gap: 4px 8px; 80 | align-items: center; 81 | } 82 | 83 | .btn-group button { 84 | white-space: nowrap; 85 | } 86 | 87 | /* Code editor */ 88 | .editor { 89 | font-family: var(--font-family-mono); 90 | font-size: var(--font-size-small); 91 | line-height: 1.5; 92 | padding: 15px; 93 | border-radius: 5px; 94 | border: 1px solid var(--color-border); 95 | background: #2d2d44; 96 | color: #f8f8f2; 97 | overflow: auto; 98 | tab-size: 2; 99 | white-space: pre-wrap; 100 | word-wrap: break-word; 101 | resize: vertical; 102 | width: 80ch; 103 | height: 400px; 104 | } 105 | 106 | .editor:focus { 107 | outline: 2px solid var(--color-primary); 108 | } 109 | 110 | /* Console output */ 111 | .output { 112 | background: white; 113 | border: 1px solid var(--color-border); 114 | border-radius: 5px; 115 | padding: 15px; 116 | font-family: var(--font-family-mono); 117 | font-size: var(--font-size-small); 118 | white-space: pre-wrap; 119 | word-break: break-word; 120 | overflow: auto; 121 | height: 200px; 122 | } 123 | 124 | .output.error { 125 | color: var(--color-error); 126 | } 127 | 128 | .output.success { 129 | color: #2e7d32; 130 | } 131 | 132 | /* Constraint string section */ 133 | .result-section { 134 | margin-top: 15px; 135 | } 136 | 137 | .result-box { 138 | display: flex; 139 | align-items: flex-start; 140 | gap: 8px; 141 | background: #f5f5f5; 142 | border: 1px solid var(--color-border); 143 | border-radius: 5px; 144 | padding: 10px; 145 | font-family: var(--font-family-mono); 146 | font-size: var(--font-size-small); 147 | min-height: 40px; 148 | overflow: hidden; 149 | } 150 | 151 | .result-box.copied { 152 | background: #e8f5e9; 153 | } 154 | 155 | #constraint-string { 156 | flex: 1; 157 | min-width: 0; 158 | white-space: pre-wrap; 159 | word-break: break-word; 160 | overflow: hidden; 161 | } 162 | 163 | /* Copy button */ 164 | .copy-button { 165 | flex-shrink: 0; 166 | background: transparent; 167 | border: none; 168 | padding: 4px; 169 | margin: -4px -4px -4px 4px; 170 | cursor: pointer; 171 | opacity: 0.5; 172 | border-radius: 4px; 173 | transition: opacity 0.2s, background-color 0.2s; 174 | text-shadow: none; 175 | box-shadow: none; 176 | } 177 | 178 | .copy-button:hover, 179 | .copy-button:hover:enabled { 180 | opacity: 1; 181 | background: rgba(0, 0, 0, 0.1); 182 | } 183 | 184 | .copy-button img { 185 | width: 16px; 186 | height: 16px; 187 | display: block; 188 | filter: none; 189 | } -------------------------------------------------------------------------------- /js/solver_worker.js: -------------------------------------------------------------------------------- 1 | // Set up onmessage handler now so that it can be called immediately (before the 2 | // worker is fully loaded), but ensure it waits for the worker to load before 3 | // doing anything else. 4 | let resolveWorkerLoaded; 5 | const workerLoadedPromise = new Promise(resolve => { 6 | resolveWorkerLoaded = resolve; 7 | }); 8 | self.onmessage = async (msg) => { 9 | await workerLoadedPromise; 10 | handleWorkerMessage(msg); 11 | }; 12 | 13 | const START_INIT_WORKER = performance.now(); 14 | 15 | self.VERSION_PARAM = self.location.search; 16 | if (!self.VERSION_PARAM.endsWith('&sync')) { 17 | // Preload all required modules asynchronously, unless we've been told 18 | // otherwise via the &sync parameter. 19 | import('./util.js' + self.VERSION_PARAM); 20 | import('./solver/lookup_tables.js' + self.VERSION_PARAM); 21 | import('./solver/handlers.js' + self.VERSION_PARAM); 22 | import('./solver/engine.js' + self.VERSION_PARAM); 23 | import('./solver/optimizer.js' + self.VERSION_PARAM); 24 | import('./solver/candidate_selector.js' + self.VERSION_PARAM); 25 | import('./solver/sum_handler.js' + self.VERSION_PARAM); 26 | import('./solver/nfa_handler.js' + self.VERSION_PARAM); 27 | import('./grid_shape.js' + self.VERSION_PARAM); 28 | import('./sudoku_constraint.js' + self.VERSION_PARAM); 29 | } 30 | const { SudokuBuilder } = await import('./solver/sudoku_builder.js' + self.VERSION_PARAM); 31 | const { Timer } = await import('./util.js' + self.VERSION_PARAM); 32 | 33 | let workerSolver = null; 34 | let workerSolverSetUpTime = 0; 35 | 36 | const handleWorkerMessage = (msg) => { 37 | try { 38 | let result = handleWorkerMethod(msg.data.method, msg.data.payload); 39 | sendState(); 40 | self.postMessage({ 41 | type: 'result', 42 | result: result, 43 | }); 44 | } catch (e) { 45 | self.postMessage({ 46 | type: 'exception', 47 | error: e, 48 | }); 49 | } 50 | }; 51 | 52 | const handleWorkerMethod = (method, payload) => { 53 | switch (method) { 54 | case 'init': 55 | const timer = new Timer(); 56 | timer.runTimed(() => { 57 | const constraint = SudokuBuilder.resolveConstraint(payload.constraint); 58 | workerSolver = SudokuBuilder.build(constraint, payload.debugOptions); 59 | }); 60 | workerSolverSetUpTime = timer.elapsedMs(); 61 | 62 | if (payload.logUpdateFrequency) { 63 | workerSolver.setProgressCallback(sendState, payload.logUpdateFrequency); 64 | } 65 | 66 | return true; 67 | 68 | case 'solveAllPossibilities': 69 | return workerSolver.solveAllPossibilities(); 70 | 71 | case 'validateLayout': 72 | return workerSolver.validateLayout(); 73 | 74 | case 'nthSolution': 75 | return workerSolver.nthSolution(payload); 76 | 77 | case 'nthStep': 78 | return workerSolver.nthStep(...payload); 79 | 80 | case 'countSolutions': 81 | return workerSolver.countSolutions(); 82 | 83 | case 'estimatedCountSolutions': 84 | return workerSolver.estimatedCountSolutions(); 85 | } 86 | throw (`Unknown method ${method}`); 87 | }; 88 | 89 | const debugCount = (key, value) => { 90 | workerSolver?.incDebugCounter(key, value); 91 | } 92 | globalThis.debugCount = debugCount; 93 | 94 | const sendState = (extraState) => { 95 | const state = workerSolver.state(); 96 | state.extra = extraState; 97 | state.puzzleSetupTime = workerSolverSetUpTime; 98 | self.postMessage({ 99 | type: 'state', 100 | state: state, 101 | }); 102 | const debugState = workerSolver.debugState(); 103 | if (debugState && Object.keys(debugState).length) { 104 | debugState.timeMs = state.timeMs; 105 | self.postMessage({ 106 | type: 'debug', 107 | data: debugState, 108 | }); 109 | } 110 | }; 111 | 112 | const END_INIT_WORKER = performance.now(); 113 | const workerSetupMs = Math.ceil(END_INIT_WORKER - START_INIT_WORKER); 114 | 115 | console.log(`Worker initialized in ${Math.ceil(workerSetupMs)}ms`); 116 | 117 | // Resolve the promise to indicate the worker is fully loaded. 118 | resolveWorkerLoaded(); -------------------------------------------------------------------------------- /tests/bench/run_all_benchmarks.js: -------------------------------------------------------------------------------- 1 | import { readdir } from 'node:fs/promises'; 2 | import { join } from 'node:path'; 3 | import { fileURLToPath, pathToFileURL } from 'node:url'; 4 | 5 | import { getRegisteredBenches, printBenchResult, runBench } from './bench_harness.js'; 6 | 7 | const benchesDirUrl = new URL('.', import.meta.url); 8 | const benchesDirPath = fileURLToPath(benchesDirUrl); 9 | 10 | const parseArgs = (argv) => { 11 | const args = { file: null, name: null, help: false }; 12 | for (let i = 2; i < argv.length; i++) { 13 | const a = argv[i]; 14 | if (a === '--help' || a === '-h') { 15 | args.help = true; 16 | } else if (a === '--file') { 17 | args.file = argv[++i] ?? ''; 18 | } else if (a.startsWith('--file=')) { 19 | args.file = a.slice('--file='.length); 20 | } else if (a === '--name') { 21 | args.name = argv[++i] ?? ''; 22 | } else if (a.startsWith('--name=')) { 23 | args.name = a.slice('--name='.length); 24 | } 25 | } 26 | return args; 27 | }; 28 | 29 | const toNameMatcher = (nameArg) => { 30 | if (!nameArg) return null; 31 | const trimmed = String(nameArg).trim(); 32 | if (!trimmed) return null; 33 | 34 | // Support /regex/flags in addition to substring matching. 35 | if (trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0) { 36 | const lastSlash = trimmed.lastIndexOf('/'); 37 | const pattern = trimmed.slice(1, lastSlash); 38 | const flags = trimmed.slice(lastSlash + 1); 39 | try { 40 | const re = new RegExp(pattern, flags); 41 | return (s) => re.test(s); 42 | } catch { 43 | // Fall back to substring match on invalid regex. 44 | } 45 | } 46 | 47 | const needle = trimmed.toLowerCase(); 48 | return (s) => s.toLowerCase().includes(needle); 49 | }; 50 | 51 | const findBenches = async (dir, relativePath = '') => { 52 | const entries = await readdir(dir, { withFileTypes: true }); 53 | const files = []; 54 | for (const entry of entries) { 55 | if (entry.isDirectory()) { 56 | files.push(...await findBenches(join(dir, entry.name), join(relativePath, entry.name))); 57 | } else if (entry.name.endsWith('.bench.js')) { 58 | files.push(join(relativePath, entry.name)); 59 | } 60 | } 61 | return files; 62 | }; 63 | 64 | const args = parseArgs(process.argv); 65 | if (args.help) { 66 | console.log('Usage: node tests/bench/run_all_benchmarks.js [--file ] [--name ]'); 67 | console.log(''); 68 | console.log('Examples:'); 69 | console.log(' node tests/bench/run_all_benchmarks.js --file util'); 70 | console.log(' node tests/bench/run_all_benchmarks.js --file lookup_tables'); 71 | console.log(" node tests/bench/run_all_benchmarks.js --name 'BitSet'"); 72 | console.log(" node tests/bench/run_all_benchmarks.js --name '/^util::base64/'"); 73 | process.exit(0); 74 | } 75 | 76 | let benchFiles = (await findBenches(benchesDirPath)).sort(); 77 | if (args.file) { 78 | const needle = String(args.file).toLowerCase(); 79 | benchFiles = benchFiles.filter((p) => p.toLowerCase().includes(needle)); 80 | } 81 | if (benchFiles.length === 0) { 82 | console.warn('No benchmark files (*.bench.js) found under tests/bench/.'); 83 | process.exit(0); 84 | } 85 | 86 | console.log(`▶ Discovered ${benchFiles.length} benchmark file(s)`); 87 | 88 | for (const benchFile of benchFiles) { 89 | await import(pathToFileURL(join(benchesDirPath, benchFile))); 90 | } 91 | 92 | let benches = getRegisteredBenches(); 93 | if (benches.length === 0) { 94 | console.warn('No benchmarks registered.'); 95 | process.exit(0); 96 | } 97 | 98 | const nameMatches = toNameMatcher(args.name); 99 | if (nameMatches) { 100 | benches = benches.filter((b) => nameMatches(`${b.group} :: ${b.name}`)); 101 | } 102 | 103 | if (benches.length === 0) { 104 | console.warn('No benchmarks matched the provided filters.'); 105 | process.exit(0); 106 | } 107 | 108 | console.log(`\n▶ Running ${benches.length} benchmark(s)`); 109 | 110 | // Stable ordering for diffs. 111 | benches.sort((a, b) => (a.group + a.name).localeCompare(b.group + b.name)); 112 | 113 | for (const b of benches) { 114 | const result = await runBench(b); 115 | printBenchResult(b, result); 116 | } 117 | 118 | console.log('\n✓ Benchmarks completed'); 119 | -------------------------------------------------------------------------------- /tests/bench/micro/loop_forms.bench.js: -------------------------------------------------------------------------------- 1 | import { bench, benchGroup, runIfMain } from '../bench_harness.js'; 2 | 3 | // Microbench: loop forms on Arrays vs TypedArrays. 4 | // This targets "non-idiomatic" choices like preferring indexed `for` loops 5 | // over `for...of` / `forEach` in hot code. 6 | 7 | let sink = 0; 8 | const consume = (x) => { sink ^= (x | 0); }; 9 | 10 | const makeLCG = (seed) => { 11 | let s = seed >>> 0; 12 | return () => { 13 | s = (1664525 * s + 1013904223) >>> 0; 14 | return s; 15 | }; 16 | }; 17 | 18 | const rng = makeLCG(0xC0FFEE); 19 | 20 | const COUNT = 8192; 21 | 22 | // Keep this microbench fast: the harness will auto-increase inner iterations 23 | // until `minSampleTimeMs` is reached, but it will not auto-decrease if the 24 | // chosen `innerIterations` is too slow. 25 | const OPT_BASELINE = { innerIterations: 2_000_000, minSampleTimeMs: 10 }; 26 | const OPT_FAST = { innerIterations: 5_000, minSampleTimeMs: 10 }; 27 | const OPT_MED = { innerIterations: 1_000, minSampleTimeMs: 10 }; 28 | const OPT_SLOW = { innerIterations: 200, minSampleTimeMs: 10 }; 29 | const typed = (() => { 30 | const a = new Uint32Array(COUNT); 31 | for (let i = 0; i < a.length; i++) a[i] = rng(); 32 | return a; 33 | })(); 34 | 35 | const plain = (() => Array.from(typed))(); 36 | 37 | benchGroup('micro::loop_forms', () => { 38 | // ------------------------------------------------------------------------- 39 | // Baselines 40 | // ------------------------------------------------------------------------- 41 | { 42 | let i = 0; 43 | bench('baseline(xor)', () => { 44 | consume(typed[i++ & (COUNT - 1)]); 45 | }, OPT_BASELINE); 46 | } 47 | 48 | // ------------------------------------------------------------------------- 49 | // TypedArray iteration 50 | // ------------------------------------------------------------------------- 51 | { 52 | bench('typed: for(i) { 53 | let acc = 0; 54 | for (let i = 0; i < typed.length; i++) acc ^= typed[i]; 55 | consume(acc); 56 | }, OPT_FAST); 57 | } 58 | 59 | { 60 | bench('typed: for(i) { 61 | let acc = 0; 62 | for (let i = 0, n = typed.length; i < n; i++) acc ^= typed[i]; 63 | consume(acc); 64 | }, OPT_FAST); 65 | } 66 | 67 | { 68 | bench('typed: for(i)>=0', () => { 69 | let acc = 0; 70 | for (let i = typed.length - 1; i >= 0; i--) acc ^= typed[i]; 71 | consume(acc); 72 | }, OPT_FAST); 73 | } 74 | 75 | { 76 | bench('typed: for-of', () => { 77 | let acc = 0; 78 | for (const v of typed) acc ^= v; 79 | consume(acc); 80 | }, OPT_SLOW); 81 | } 82 | 83 | { 84 | bench('typed: forEach', () => { 85 | let acc = 0; 86 | typed.forEach((v) => { acc ^= v; }); 87 | consume(acc); 88 | }, OPT_SLOW); 89 | } 90 | 91 | // ------------------------------------------------------------------------- 92 | // Plain Array iteration 93 | // ------------------------------------------------------------------------- 94 | { 95 | bench('array: for(i) { 96 | let acc = 0; 97 | for (let i = 0; i < plain.length; i++) acc ^= plain[i]; 98 | consume(acc); 99 | }, OPT_FAST); 100 | } 101 | 102 | { 103 | bench('array: for(i) { 104 | let acc = 0; 105 | for (let i = 0, n = plain.length; i < n; i++) acc ^= plain[i]; 106 | consume(acc); 107 | }, OPT_FAST); 108 | } 109 | 110 | { 111 | bench('array: for(i)>=0', () => { 112 | let acc = 0; 113 | for (let i = plain.length - 1; i >= 0; i--) acc ^= plain[i]; 114 | consume(acc); 115 | }, OPT_FAST); 116 | } 117 | 118 | { 119 | bench('array: for-of', () => { 120 | let acc = 0; 121 | for (const v of plain) acc ^= v; 122 | consume(acc); 123 | }, OPT_SLOW); 124 | } 125 | 126 | { 127 | bench('array: forEach', () => { 128 | let acc = 0; 129 | plain.forEach((v) => { acc ^= v; }); 130 | consume(acc); 131 | }, OPT_MED); 132 | } 133 | 134 | { 135 | bench('array: reduce', () => { 136 | const acc = plain.reduce((p, v) => (p ^ v), 0); 137 | consume(acc); 138 | }, OPT_MED); 139 | } 140 | }); 141 | 142 | export const _benchSink = () => sink; 143 | await runIfMain(import.meta.url); 144 | -------------------------------------------------------------------------------- /lib/prism-javascript.min.js: -------------------------------------------------------------------------------- 1 | Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; -------------------------------------------------------------------------------- /js/sandbox/sandbox.js: -------------------------------------------------------------------------------- 1 | import { CodeJar } from '../../lib/codejar.min.js'; 2 | import { autoSaveField } from '../util.js'; 3 | import { DEFAULT_CODE, EXAMPLES } from './examples.js'; 4 | import { UserScriptExecutor } from '../sudoku_constraint.js'; 5 | 6 | class Sandbox { 7 | constructor() { 8 | this.editorElement = document.getElementById('editor'); 9 | this.outputElement = document.getElementById('output'); 10 | this.constraintElement = document.getElementById('constraint-string'); 11 | this.solverLinkElement = document.getElementById('open-solver-link'); 12 | this.examplesSelect = document.getElementById('examples-select'); 13 | 14 | this._userScriptExecutor = new UserScriptExecutor(); 15 | 16 | this._initEditor(); 17 | this._initExamples(); 18 | this._initEventListeners(); 19 | } 20 | 21 | _initEditor() { 22 | const highlight = (editor) => { 23 | const code = editor.textContent; 24 | editor.innerHTML = Prism.highlight(code, Prism.languages.javascript, 'javascript'); 25 | }; 26 | 27 | this.jar = CodeJar(this.editorElement, highlight, { tab: ' ' }); 28 | 29 | // Load saved code or use default 30 | autoSaveField(this.editorElement); 31 | this.jar.updateCode(this.editorElement.textContent || DEFAULT_CODE); 32 | 33 | // Save code on changes 34 | this.jar.onUpdate((code) => { 35 | this.editorElement.dispatchEvent(new Event('change')); 36 | }); 37 | } 38 | 39 | _initExamples() { 40 | for (const name of Object.keys(EXAMPLES)) { 41 | this.examplesSelect.add(new Option(name, name)); 42 | } 43 | } 44 | 45 | _initEventListeners() { 46 | document.getElementById('run-btn').addEventListener('click', () => this.runCode()); 47 | document.getElementById('clear-btn').addEventListener('click', () => this.clear()); 48 | document.getElementById('copy-btn').addEventListener('click', () => this._copyConstraint()); 49 | 50 | document.addEventListener('keydown', (e) => { 51 | if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { 52 | e.preventDefault(); 53 | this.runCode(); 54 | } 55 | }, { capture: true }); 56 | 57 | this.examplesSelect.addEventListener('change', () => { 58 | const name = this.examplesSelect.value; 59 | if (name) this.jar.updateCode(EXAMPLES[name]); 60 | this.examplesSelect.value = ''; 61 | }); 62 | } 63 | 64 | _copyConstraint() { 65 | const text = this.constraintElement.textContent; 66 | if (text && text !== '(no constraint returned)' && text !== '(error)') { 67 | navigator.clipboard.writeText(text); 68 | const resultBox = this.constraintElement.parentElement; 69 | resultBox.classList.add('copied'); 70 | setTimeout(() => resultBox.classList.remove('copied'), 1000); 71 | } 72 | } 73 | 74 | async runCode() { 75 | const btn = document.getElementById('run-btn'); 76 | const spinner = btn.querySelector('.spinner'); 77 | 78 | btn.disabled = true; 79 | spinner.classList.add('active'); 80 | 81 | const code = this.jar.toString(); 82 | 83 | try { 84 | const { constraintStr, logs } = await this._userScriptExecutor.runSandboxCode(code); 85 | 86 | this.outputElement.textContent = logs.join('\n') || 'No console output'; 87 | this.outputElement.className = 'output'; 88 | 89 | if (constraintStr) { 90 | this.constraintElement.textContent = constraintStr; 91 | 92 | const url = `./?q=${encodeURIComponent(constraintStr)}`; 93 | this.solverLinkElement.href = url; 94 | this.solverLinkElement.style.display = 'inline-block'; 95 | 96 | this.outputElement.className = 'output success'; 97 | if (logs.length === 0) { 98 | this.outputElement.textContent = 'Constraint generated successfully!'; 99 | } 100 | } else { 101 | this.constraintElement.textContent = '(no constraint returned)'; 102 | this.solverLinkElement.style.display = 'none'; 103 | } 104 | } catch (err) { 105 | // If we have logs from the worker, show them. 106 | const logs = err.logs || []; 107 | const errorOutput = logs.length > 0 ? logs.join('\n') + '\n\n' : ''; 108 | this.outputElement.textContent = `${errorOutput}Error: ${err.message}`; 109 | this.outputElement.className = 'output error'; 110 | this.constraintElement.textContent = '(error)'; 111 | this.solverLinkElement.style.display = 'none'; 112 | } finally { 113 | btn.disabled = false; 114 | spinner.classList.remove('active'); 115 | } 116 | } 117 | 118 | clear() { 119 | this.jar.updateCode(''); 120 | this.outputElement.textContent = ''; 121 | this.constraintElement.textContent = ''; 122 | this.solverLinkElement.style.display = 'none'; 123 | } 124 | } 125 | 126 | new Sandbox(); -------------------------------------------------------------------------------- /js/user_script_worker.js: -------------------------------------------------------------------------------- 1 | 2 | self.VERSION_PARAM = self.location.search; 3 | 4 | // Preload modules. 5 | const modulesPromise = (async () => { 6 | try { 7 | const [ 8 | { SudokuConstraint }, 9 | { SudokuParser }, 10 | ] = await Promise.all([ 11 | import('./sudoku_constraint.js' + self.VERSION_PARAM), 12 | import('./sudoku_parser.js' + self.VERSION_PARAM), 13 | ]); 14 | self.postMessage({ type: 'ready' }); 15 | return { SudokuConstraint, SudokuParser }; 16 | } catch (e) { 17 | self.postMessage({ type: 'initError', error: e.message || String(e) }); 18 | throw e; 19 | } 20 | })(); 21 | 22 | self.onmessage = async (e) => { 23 | const { id, type, payload } = e.data; 24 | try { 25 | const modules = await modulesPromise; 26 | let result; 27 | switch (type) { 28 | case 'compilePairwise': 29 | result = compilePairwise(modules, payload); 30 | break; 31 | case 'compileStateMachine': 32 | result = compileStateMachine(modules, payload); 33 | break; 34 | case 'convertUnifiedToSplit': 35 | result = convertUnifiedToSplit(payload); 36 | break; 37 | case 'runSandboxCode': 38 | result = await runSandboxCode(modules, payload); 39 | break; 40 | default: 41 | throw new Error(`Unknown message type: ${type}`); 42 | } 43 | self.postMessage({ id, result }); 44 | } catch (error) { 45 | // If the error object has logs, include them in the response. 46 | const response = { id, error: error.message || String(error) }; 47 | if (error.logs) response.logs = error.logs; 48 | self.postMessage(response); 49 | } 50 | }; 51 | 52 | const compilePairwise = ({ SudokuConstraint }, { type, fnStr, numValues }) => { 53 | const typeCls = SudokuConstraint[type]; 54 | if (!typeCls) throw new Error(`Unknown constraint type: ${type}`); 55 | 56 | const fn = new Function(`return ((a,b)=>${fnStr})`)(); 57 | return typeCls.fnToKey(fn, numValues); 58 | } 59 | 60 | const compileStateMachine = ({ SudokuConstraint }, { spec, numValues, isUnified }) => { 61 | let parsedSpec; 62 | if (isUnified) { 63 | parsedSpec = new Function(`${spec}; return {startState, transition, accept};`)(); 64 | } else { 65 | const { startExpression, transitionBody, acceptBody } = spec; 66 | const startState = new Function('"use strict"; return (' + startExpression + ');')(); 67 | const transition = new Function('state', 'value', transitionBody); 68 | const accept = new Function('state', acceptBody); 69 | parsedSpec = { startState, transition, accept }; 70 | } 71 | 72 | return SudokuConstraint.NFA.encodeSpec(parsedSpec, numValues); 73 | } 74 | 75 | const convertUnifiedToSplit = ({ code }) => { 76 | const parsed = new Function(`${code}; return {startState, transition, accept};`)(); 77 | 78 | const extractFunctionBody = (fn) => { 79 | const source = fn.toString(); 80 | const start = source.indexOf('{\n') + 2; 81 | const end = source.lastIndexOf('\n}'); 82 | return source.slice(start, end).replace(/^ {2}/gm, ''); 83 | }; 84 | 85 | return { 86 | startExpression: JSON.stringify(parsed.startState), 87 | transitionBody: extractFunctionBody(parsed.transition), 88 | acceptBody: extractFunctionBody(parsed.accept), 89 | }; 90 | } 91 | 92 | let sandboxEnvPromise; 93 | 94 | const runSandboxCode = async ({ SudokuConstraint, SudokuParser }, { code }) => { 95 | if (!sandboxEnvPromise) { 96 | sandboxEnvPromise = import('./sandbox/env.js' + self.VERSION_PARAM); 97 | } 98 | const { SANDBOX_GLOBALS } = await sandboxEnvPromise; 99 | 100 | const logs = []; 101 | const originalConsole = { 102 | log: console.log, 103 | error: console.error, 104 | warn: console.warn, 105 | }; 106 | 107 | const formatArg = (a) => 108 | typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a); 109 | 110 | console.log = (...args) => logs.push(args.map(formatArg).join(' ')); 111 | console.error = (...args) => logs.push('ERROR: ' + args.map(formatArg).join(' ')); 112 | console.warn = (...args) => logs.push('WARN: ' + args.map(formatArg).join(' ')); 113 | 114 | try { 115 | const keys = Object.keys(SANDBOX_GLOBALS); 116 | const values = Object.values(SANDBOX_GLOBALS); 117 | const asyncFn = new Function(...keys, `return (async () => { ${code} })();`); 118 | const result = await asyncFn(...values); 119 | 120 | let constraintStr = ''; 121 | if (result) { 122 | if (Array.isArray(result)) { 123 | const parsed = result.map(item => 124 | typeof item === 'string' ? SudokuParser.parseString(item) : item 125 | ); 126 | constraintStr = String(new SudokuConstraint.Set(parsed)); 127 | } else { 128 | constraintStr = String(result); 129 | } 130 | } 131 | 132 | return { constraintStr, logs }; 133 | } catch (e) { 134 | // If an error occurs, we still want to return the logs. 135 | throw { message: e.message || String(e), logs }; 136 | } finally { 137 | Object.assign(console, originalConsole); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/e2e.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import { performance as perf } from 'node:perf_hooks'; 3 | 4 | import { ensureGlobalEnvironment } from './helpers/test_env.js'; 5 | import { logSuiteComplete } from './helpers/test_runner.js'; 6 | 7 | ensureGlobalEnvironment({ 8 | needWindow: true, 9 | documentValue: undefined, 10 | locationValue: { search: '' }, 11 | performance: perf, 12 | }); 13 | 14 | const debugModule = await import('../js/debug/debug.js'); 15 | const { 16 | debugFilesLoaded, 17 | runSolveTests, 18 | runValidateLayoutTests, 19 | PuzzleRunner, 20 | } = debugModule; 21 | await debugFilesLoaded; 22 | 23 | const { SudokuBuilder } = await import('../js/solver/sudoku_builder.js'); 24 | const { Timer } = await import('../js/util.js'); 25 | 26 | const LOG_UPDATE_FREQUENCY = 13; 27 | 28 | class LocalSolverProxy { 29 | constructor(solver, stateHandler, setupTimeMs) { 30 | this._solver = solver; 31 | this._stateHandler = stateHandler || (() => { }); 32 | this._setupTimeMs = setupTimeMs; 33 | this._terminated = false; 34 | 35 | if (typeof solver.setProgressCallback === 'function') { 36 | solver.setProgressCallback(() => this._notifyState(), LOG_UPDATE_FREQUENCY); 37 | } 38 | } 39 | 40 | _notifyState(extraState) { 41 | if (!this._solver || !this._stateHandler) return; 42 | const state = this._solver.state?.(); 43 | if (!state) return; 44 | state.puzzleSetupTime = this._setupTimeMs; 45 | if (extraState !== undefined) { 46 | state.extra = extraState; 47 | } 48 | this._stateHandler(state); 49 | } 50 | 51 | _call(methodName, ...args) { 52 | if (!this._solver) { 53 | throw new Error('Solver has been terminated.'); 54 | } 55 | const result = this._solver[methodName](...args); 56 | this._notifyState(); 57 | return result; 58 | } 59 | 60 | async solveAllPossibilities() { return this._call('solveAllPossibilities'); } 61 | async validateLayout() { return this._call('validateLayout'); } 62 | async nthSolution(n) { return this._call('nthSolution', n); } 63 | async nthStep(n, stepGuides) { return this._call('nthStep', n, stepGuides); } 64 | async countSolutions() { return this._call('countSolutions'); } 65 | async estimatedCountSolutions() { return this._call('estimatedCountSolutions'); } 66 | 67 | terminate() { 68 | this._solver = null; 69 | this._terminated = true; 70 | } 71 | 72 | isTerminated() { 73 | return this._terminated; 74 | } 75 | } 76 | 77 | const makeLocalSolverFactory = () => { 78 | return async (constraint, stateHandler) => { 79 | const timer = new Timer(); 80 | let solver; 81 | timer.runTimed(() => { 82 | const resolved = SudokuBuilder.resolveConstraint(constraint); 83 | solver = SudokuBuilder.build(resolved); 84 | }); 85 | 86 | const proxy = new LocalSolverProxy(solver, stateHandler, timer.elapsedMs()); 87 | proxy._notifyState(); 88 | return proxy; 89 | }; 90 | }; 91 | 92 | const runner = new PuzzleRunner({ 93 | solverFactory: makeLocalSolverFactory(), 94 | enableConsoleLogs: false, 95 | }); 96 | 97 | const expectStatsStructure = (result, label) => { 98 | assert.ok(result, `${label} returned nothing`); 99 | assert.ok(Array.isArray(result.stats), `${label} stats should be an array`); 100 | assert.ok(result.stats.total, `${label} stats should include totals`); 101 | }; 102 | 103 | const formatNumber = (value) => value.toLocaleString('en-US'); 104 | const formatSeconds = (ms) => `${(ms / 1000).toFixed(2)}s`; 105 | 106 | const logCollectionSummary = (result, label = result.collection) => { 107 | const total = result.stats.total || {}; 108 | const parts = [`${label}: ${result.stats.length} puzzles`]; 109 | const runtimeMs = typeof total.rumtimeMs === 'number' ? total.rumtimeMs : total.runtimeMs; 110 | if (typeof runtimeMs === 'number') { 111 | parts.push(`runtime ${formatSeconds(runtimeMs)}`); 112 | } 113 | if (typeof total.guesses === 'number') { 114 | parts.push(`guesses ${formatNumber(total.guesses)}`); 115 | } 116 | console.log(' ' + parts.join(' | ')); 117 | }; 118 | 119 | const runSolveResults = await runSolveTests((puzzle, err) => { 120 | throw new Error(`Puzzle ${puzzle.name} failed: ${err}`); 121 | }, runner); 122 | assert.equal(runSolveResults.length, 3, 'runSolveTests should return three collections'); 123 | runSolveResults.forEach((result) => expectStatsStructure(result, `solve tests (${result.collection})`)); 124 | console.log('✓ runSolveTests completed'); 125 | runSolveResults.forEach((result) => logCollectionSummary(result)); 126 | 127 | const runLayoutResults = await runValidateLayoutTests((puzzle, err) => { 128 | throw new Error(`Layout puzzle ${puzzle.name} failed: ${err}`); 129 | }, runner); 130 | assert.equal(runLayoutResults.length, 1, 'runValidateLayoutTests should return a single collection'); 131 | runLayoutResults.forEach((result) => expectStatsStructure(result, 'layout tests')); 132 | console.log('✓ runValidateLayoutTests completed'); 133 | runLayoutResults.forEach((result) => logCollectionSummary(result)); 134 | 135 | logSuiteComplete('End-to-end'); 136 | -------------------------------------------------------------------------------- /js/solver/lookup_tables.js: -------------------------------------------------------------------------------- 1 | const { memoize, LegacyBase64Codec } = await import('../util.js' + self.VERSION_PARAM); 2 | 3 | export class LookupTables { 4 | static get = memoize((numValues) => { 5 | return new LookupTables(true, numValues); 6 | }); 7 | 8 | static fromValue = (i) => { 9 | return 1 << (i - 1); 10 | }; 11 | 12 | static fromIndex = (i) => { 13 | return 1 << i; 14 | }; 15 | 16 | static fromValuesArray = (xs) => { 17 | let result = 0; 18 | for (const x of xs) { 19 | result |= this.fromValue(x); 20 | } 21 | return result; 22 | }; 23 | 24 | static toValue(v) { 25 | return 32 - Math.clz32(v); 26 | }; 27 | 28 | static maxValue(v) { 29 | return 32 - Math.clz32(v); 30 | }; 31 | 32 | static minValue(v) { 33 | return 32 - Math.clz32(v & -v); 34 | }; 35 | 36 | // Combines min and max into a single integer: 37 | // Layout: [min: 16 bits, max: 16 bits] 38 | // The extra bits allow these values to be summed to determine the total 39 | // of mins and maxs. 40 | // 16-bits ensures we won't overflow. 41 | // (Since we only support 16x16 grids,the max sum is 16*16*16 = 4096) 42 | static minMax16bitValue(v) { 43 | return 0x200020 - (Math.clz32(v & -v) << 16) - Math.clz32(v); 44 | } 45 | 46 | static valueRangeInclusive(v) { 47 | return (1 << (32 - Math.clz32(v))) - (v & -v); 48 | }; 49 | 50 | static valueRangeExclusive(v) { 51 | return (1 << (31 - Math.clz32(v))) - ((v & -v) << 1); 52 | }; 53 | 54 | static toIndex(v) { 55 | return 31 - Math.clz32(v); 56 | }; 57 | 58 | static toValuesArray(values) { 59 | let result = []; 60 | while (values) { 61 | let value = values & -values; 62 | values ^= value; 63 | result.push(LookupTables.toValue(value)); 64 | } 65 | return result; 66 | } 67 | 68 | constructor(do_not_call, numValues) { 69 | if (!do_not_call) throw ('Use LookupTables.get(shape.numValues)'); 70 | 71 | this.allValues = (1 << numValues) - 1; 72 | this.combinations = 1 << numValues; 73 | 74 | const combinations = this.combinations; 75 | 76 | this.sum = (() => { 77 | let table = new Uint8Array(combinations); 78 | for (let i = 1; i < combinations; i++) { 79 | // SUM is the value of the lowest set bit plus the sum of the rest. 80 | table[i] = table[i & (i - 1)] + LookupTables.toValue(i & -i); 81 | } 82 | return table; 83 | })(); 84 | 85 | // Combines useful info about the range of numbers in a cell. 86 | // Designed to be summed, so that the aggregate stats can be found. 87 | // Layout: [isFixed: 4 bits, fixed: 8 bits, min: 8 bits, max: 8 bits] 88 | // 89 | // Sum of isFixed gives the number of fixed cells. 90 | // Sum of fixed gives the sum of fixed cells. 91 | this.rangeInfo = (() => { 92 | const table = new Uint32Array(combinations); 93 | for (let i = 1; i < combinations; i++) { 94 | const max = LookupTables.maxValue(i); 95 | const min = LookupTables.minValue(i); 96 | const fixed = (i & (i - 1)) ? 0 : LookupTables.toValue(i); 97 | const isFixed = fixed ? 1 : 0; 98 | table[i] = ((isFixed << 24) | (fixed << 16) | (min << 8) | max); 99 | } 100 | // If there are no values, set a high value for isFixed to indicate the 101 | // result is invalid. This is intended to be detectable after summing. 102 | table[0] = numValues << 24; 103 | return table; 104 | })(); 105 | 106 | this.reverse = (() => { 107 | let table = new Uint16Array(combinations); 108 | for (let i = 1; i <= numValues; i++) { 109 | table[LookupTables.fromValue(i)] = 110 | LookupTables.fromValue(numValues + 1 - i); 111 | } 112 | for (let i = 1; i < combinations; i++) { 113 | table[i] = table[i & (i - 1)] | table[i & -i]; 114 | } 115 | return table; 116 | })(); 117 | 118 | const NUM_BITS_BASE64 = 6; 119 | const keyArr = new Uint8Array( 120 | LegacyBase64Codec.lengthOf6BitArray(numValues * numValues)); 121 | 122 | this.forBinaryKey = memoize((key) => { 123 | const table = new Uint16Array(combinations); 124 | const tableInv = new Uint16Array(combinations); 125 | 126 | keyArr.fill(0); 127 | LegacyBase64Codec.decodeTo6BitArray(key, keyArr); 128 | 129 | // Populate base cases, where there is a single value set. 130 | let keyIndex = 0; 131 | let vIndex = 0; 132 | for (let i = 0; i < numValues; i++) { 133 | for (let j = 0; j < numValues; j++) { 134 | const v = keyArr[keyIndex] & 1; 135 | table[1 << i] |= v << j; 136 | tableInv[1 << j] |= v << i; 137 | 138 | keyArr[keyIndex] >>= 1; 139 | if (++vIndex == NUM_BITS_BASE64) { 140 | vIndex = 0; 141 | keyIndex++; 142 | } 143 | } 144 | } 145 | 146 | // To fill in the rest, OR together all the valid settings for each value 147 | // set. 148 | for (let i = 1; i < combinations; i++) { 149 | table[i] = table[i & (i - 1)] | table[i & -i]; 150 | tableInv[i] = tableInv[i & (i - 1)] | tableInv[i & -i]; 151 | } 152 | return [table, tableInv]; 153 | }); 154 | 155 | // Checks that if (X, V) is a valid pair and (V, Y) is valid, then 156 | // (X, Y) is valid. 157 | this.binaryKeyIsTransitive = memoize((key) => { 158 | const [t0, t1] = this.forBinaryKey(key); 159 | for (let i = 0; i < numValues; i++) { 160 | const v = 1 << i; 161 | let validPred = t1[v]; 162 | const validSucc = t0[v]; 163 | 164 | while (validPred) { 165 | const x = validPred & -validPred; 166 | validPred ^= x; 167 | if ((validSucc & ~t0[x]) !== 0) { 168 | return false; 169 | } 170 | } 171 | } 172 | 173 | return true; 174 | }); 175 | } 176 | } -------------------------------------------------------------------------------- /css/debug.css: -------------------------------------------------------------------------------- 1 | .debug-candidate-group { 2 | fill: rgb(230, 50, 50); 3 | font-weight: bold; 4 | } 5 | 6 | .debug-value-group { 7 | opacity: 0.9; 8 | fill: rgb(255, 0, 225); 9 | filter: drop-shadow(0px 0px 2px white); 10 | } 11 | 12 | .highlight-group { 13 | >.debug-hover { 14 | fill: rgba(250, 250, 250); 15 | opacity: 0.9; 16 | filter: drop-shadow(0 0 5px black); 17 | } 18 | } 19 | 20 | #debug-container { 21 | display: flex; 22 | flex-direction: column; 23 | border-bottom: 1px solid var(--color-text-muted); 24 | 25 | .debug-main { 26 | display: flex; 27 | flex-direction: row; 28 | } 29 | 30 | #debug-logs { 31 | resize: both; 32 | overflow: auto; 33 | height: 210px; 34 | width: 900px; 35 | box-shadow: inset 0px 0px 3px #555; 36 | padding: 3px; 37 | margin-bottom: 10px; 38 | cursor: pointer; 39 | 40 | >div>span:first-child { 41 | color: var(--color-text-muted); 42 | } 43 | 44 | .duplicate-log-line { 45 | margin-left: 50px; 46 | } 47 | 48 | .important-log-line { 49 | font-weight: bold; 50 | margin: 5px 0; 51 | } 52 | } 53 | 54 | #debug-panel { 55 | display: flex; 56 | flex-direction: column; 57 | width: 200px; 58 | gap: 10px; 59 | 60 | #debug-puzzle-input { 61 | width: 95%; 62 | } 63 | 64 | #close-debug-button { 65 | width: fit-content; 66 | font-size: var(--font-size-base); 67 | } 68 | 69 | label { 70 | display: flex; 71 | align-items: center; 72 | gap: 10px; 73 | } 74 | 75 | .debug-toggle-row { 76 | display: flex; 77 | flex-wrap: wrap; 78 | align-items: center; 79 | gap: 6px; 80 | } 81 | } 82 | 83 | #debug-counters { 84 | display: flex; 85 | flex-direction: column; 86 | gap: 5px; 87 | width: 150px; 88 | 89 | &:empty { 90 | width: 0; 91 | } 92 | 93 | >div { 94 | display: flex; 95 | justify-content: space-between; 96 | } 97 | 98 | span { 99 | padding: 0 5px; 100 | font-size: var(--font-size-small); 101 | } 102 | } 103 | 104 | .debug-stack-trace-footer { 105 | border-top: 1px solid var(--color-text-muted); 106 | font-size: var(--font-size-small); 107 | padding: 4px 6px; 108 | display: flex; 109 | flex-direction: column; 110 | align-items: stretch; 111 | contain: inline-size; 112 | min-height: 1.2em; 113 | gap: 4px; 114 | 115 | &:empty { 116 | display: none; 117 | } 118 | } 119 | 120 | .debug-stack-trace-header { 121 | display: flex; 122 | align-items: center; 123 | gap: 8px; 124 | min-width: 0; 125 | } 126 | 127 | #debug-flame-toggle { 128 | width: 1em; 129 | height: 1em; 130 | margin: 0; 131 | padding: 0; 132 | border: 0; 133 | background: none; 134 | box-shadow: none; 135 | color: black; 136 | cursor: pointer; 137 | display: flex; 138 | align-items: center; 139 | justify-content: center; 140 | flex-shrink: 0; 141 | 142 | &:hover, 143 | &:active { 144 | background: none; 145 | box-shadow: none; 146 | } 147 | 148 | &:focus { 149 | outline: none; 150 | } 151 | 152 | &:after { 153 | content: ""; 154 | width: 6px; 155 | height: 6px; 156 | border-left: 1px solid currentColor; 157 | border-top: 1px solid currentColor; 158 | transform: rotate(-135deg); 159 | pointer-events: none; 160 | } 161 | 162 | &.expanded:after { 163 | transform: rotate(45deg); 164 | } 165 | } 166 | 167 | .debug-stack-trace { 168 | display: flex; 169 | flex-wrap: wrap; 170 | flex: 1; 171 | align-items: flex-start; 172 | align-content: flex-start; 173 | min-width: 0; 174 | padding: 0; 175 | overflow-x: auto; 176 | white-space: nowrap; 177 | height: 1.5em; 178 | resize: both; 179 | 180 | span { 181 | cursor: pointer; 182 | padding: 0 2px; 183 | line-height: 1.2em; 184 | 185 | &:hover { 186 | filter: saturate(3) brightness(0.95); 187 | } 188 | } 189 | 190 | &:empty { 191 | display: none; 192 | } 193 | } 194 | 195 | .debug-flame-graph { 196 | position: relative; 197 | height: 160px; 198 | box-sizing: border-box; 199 | resize: vertical; 200 | overflow: auto; 201 | 202 | svg { 203 | display: block; 204 | } 205 | 206 | rect.debug-flame-rect { 207 | stroke-opacity: 0.22; 208 | shape-rendering: crispEdges; 209 | cursor: pointer; 210 | } 211 | 212 | .debug-flame-node-outline { 213 | fill: none; 214 | stroke: currentColor; 215 | stroke-opacity: 0.5; 216 | stroke-width: 0.5; 217 | shape-rendering: crispEdges; 218 | pointer-events: none; 219 | } 220 | 221 | text.debug-flame-label { 222 | fill: currentColor; 223 | opacity: 0.75; 224 | font-size: 10px; 225 | pointer-events: none; 226 | } 227 | 228 | .debug-flame-node-outline.debug-flame-node-hover { 229 | stroke-opacity: 0.55; 230 | stroke-width: 1.5; 231 | stroke-dasharray: none; 232 | filter: drop-shadow(0 0 5px black); 233 | } 234 | 235 | rect.debug-flame-rect.debug-flame-seg-hover { 236 | filter: saturate(3) brightness(0.95); 237 | } 238 | 239 | .debug-flame-tooltip { 240 | position: absolute; 241 | pointer-events: none; 242 | z-index: 1; 243 | padding: 2px 6px; 244 | background: white; 245 | border: 1px solid var(--color-border-light); 246 | border-radius: 4px; 247 | color: black; 248 | font-size: 12px; 249 | white-space: nowrap; 250 | } 251 | } 252 | } 253 | 254 | .debug-checkbox { 255 | display: none; 256 | 257 | &+label { 258 | padding: 5px; 259 | width: fit-content; 260 | cursor: pointer; 261 | color: black; 262 | background-color: #f0f0f0; 263 | border: 1px solid var(--color-border-light); 264 | border-radius: 5px; 265 | } 266 | 267 | &:checked+label { 268 | color: white; 269 | background-color: #888; 270 | } 271 | } -------------------------------------------------------------------------------- /lib/codejar.min.js: -------------------------------------------------------------------------------- 1 | const globalWindow = window; export function CodeJar(t, e, n = {}) { const o = { tab: "\t", indentOn: /[({\[]$/, moveToNewLine: /^[)}\]]/, spellcheck: !1, catchTab: !0, preserveIdent: !0, addClosing: !0, history: !0, window: globalWindow, ...n }, r = o.window, i = r.document, s = [], d = []; let a, c = -1, l = !1, f = () => { }; t.setAttribute("contenteditable", "plaintext-only"), t.setAttribute("spellcheck", o.spellcheck ? "true" : "false"), t.style.outline = "none", t.style.overflowWrap = "break-word", t.style.overflowY = "auto", t.style.whiteSpace = "pre-wrap"; const u = (t, n) => { e(t, n) }; let p = !1; "plaintext-only" !== t.contentEditable && (p = !0), p && t.setAttribute("contenteditable", "true"); const h = A((() => { const e = C(); u(t, e), E(e) }), 30); let g = !1; const y = t => !M(t) && !D(t) && "Meta" !== t.key && "Control" !== t.key && "Alt" !== t.key && !t.key.startsWith("Arrow"), N = A((t => { y(t) && (k(), g = !1) }), 300), T = (e, n) => { s.push([e, n]), t.addEventListener(e, n) }; function C() { const e = K(), n = { start: 0, end: 0, dir: void 0 }; let { anchorNode: o, anchorOffset: r, focusNode: s, focusOffset: d } = e; if (!o || !s) throw "error1"; if (o === t && s === t) return n.start = r > 0 && t.textContent ? t.textContent.length : 0, n.end = d > 0 && t.textContent ? t.textContent.length : 0, n.dir = d >= r ? "->" : "<-", n; if (o.nodeType === Node.ELEMENT_NODE) { const t = i.createTextNode(""); o.insertBefore(t, o.childNodes[r]), o = t, r = 0 } if (s.nodeType === Node.ELEMENT_NODE) { const t = i.createTextNode(""); s.insertBefore(t, s.childNodes[d]), s = t, d = 0 } return v(t, (t => { if (t === o && t === s) return n.start += r, n.end += d, n.dir = r <= d ? "->" : "<-", "stop"; if (t === o) { if (n.start += r, n.dir) return "stop"; n.dir = "->" } else if (t === s) { if (n.end += d, n.dir) return "stop"; n.dir = "<-" } t.nodeType === Node.TEXT_NODE && ("->" != n.dir && (n.start += t.nodeValue.length), "<-" != n.dir && (n.end += t.nodeValue.length)) })), t.normalize(), n } function E(e) { const n = K(); let o, r, s = 0, d = 0; if (e.dir || (e.dir = "->"), e.start < 0 && (e.start = 0), e.end < 0 && (e.end = 0), "<-" == e.dir) { const { start: t, end: n } = e; e.start = n, e.end = t } let a = 0; v(t, (t => { if (t.nodeType !== Node.TEXT_NODE) return; const n = (t.nodeValue || "").length; if (a + n > e.start && (o || (o = t, s = e.start - a), a + n > e.end)) return r = t, d = e.end - a, "stop"; a += n })), o || (o = t, s = t.childNodes.length), r || (r = t, d = t.childNodes.length), "<-" == e.dir && ([o, s, r, d] = [r, d, o, s]); { const t = m(o); if (t) { const e = i.createTextNode(""); t.parentNode?.insertBefore(e, t), o = e, s = 0 } const e = m(r); if (e) { const t = i.createTextNode(""); e.parentNode?.insertBefore(t, e), r = t, d = 0 } } n.setBaseAndExtent(o, s, r, d), t.normalize() } function m(e) { for (; e && e !== t;) { if (e.nodeType === Node.ELEMENT_NODE) { const t = e; if ("false" == t.getAttribute("contenteditable")) return t } e = e.parentNode } } function b() { const e = K().getRangeAt(0), n = i.createRange(); return n.selectNodeContents(t), n.setEnd(e.startContainer, e.startOffset), n.toString() } function x() { const e = K().getRangeAt(0), n = i.createRange(); return n.selectNodeContents(t), n.setStart(e.endContainer, e.endOffset), n.toString() } function w(t) { if (p && "Enter" === t.key) if (H(t), t.stopPropagation(), "" == x()) { L("\n "); const t = C(); t.start = --t.end, E(t) } else L("\n") } function k() { if (!l) return; const e = t.innerHTML, n = C(), o = d[c]; if (o && o.html === e && o.pos.start === n.start && o.pos.end === n.end) return; c++, d[c] = { html: e, pos: n }, d.splice(c + 1); c > 300 && (c = 300, d.splice(0, 1)) } function v(t, e) { const n = []; t.firstChild && n.push(t.firstChild); let o = n.pop(); for (; o && "stop" !== e(o);)o.nextSibling && n.push(o.nextSibling), o.firstChild && n.push(o.firstChild), o = n.pop() } function O(t) { return t.metaKey || t.ctrlKey } function M(t) { return O(t) && !t.shiftKey && "Z" === S(t) } function D(t) { return O(t) && t.shiftKey && "Z" === S(t) } function S(t) { let e = t.key || t.keyCode || t.which; if (e) return ("string" == typeof e ? e : String.fromCharCode(e)).toUpperCase() } function L(t) { t = t.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"), i.execCommand("insertHTML", !1, t) } function A(t, e) { let n = 0; return (...o) => { clearTimeout(n), n = r.setTimeout((() => t(...o)), e) } } function _(t) { let e = t.length - 1; for (; e >= 0 && "\n" !== t[e];)e--; e++; let n = e; for (; n < t.length && /[ \t]/.test(t[n]);)n++; return [t.substring(e, n) || "", e, n] } function B() { return t.textContent || "" } function H(t) { t.preventDefault() } function K() { return t.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE ? t.parentNode.getSelection() : r.getSelection() } return T("keydown", (e => { e.defaultPrevented || (a = B(), o.preserveIdent ? function (t) { if ("Enter" === t.key) { const e = b(), n = x(); let [r] = _(e), i = r; if (o.indentOn.test(e) && (i += o.tab), i.length > 0 ? (H(t), t.stopPropagation(), L("\n" + i)) : w(t), i !== r && o.moveToNewLine.test(n)) { const t = C(); L("\n" + r), E(t) } } }(e) : w(e), o.catchTab && function (t) { if ("Tab" === t.key) if (H(t), t.shiftKey) { const t = b(); let [e, n] = _(t); if (e.length > 0) { const t = C(), r = Math.min(o.tab.length, e.length); E({ start: n, end: n + r }), i.execCommand("delete"), t.start -= r, t.end -= r, E(t) } } else L(o.tab) }(e), o.addClosing && function (t) { const e = "([{'\"", n = ")]}'\""; if (e.includes(t.key)) { H(t); const o = C(), r = o.start == o.end ? "" : K().toString(); L(t.key + r + n[e.indexOf(t.key)]), o.start++, o.end++, E(o) } }(e), o.history && (!function (e) { if (M(e)) { H(e), c--; const n = d[c]; n && (t.innerHTML = n.html, E(n.pos)), c < 0 && (c = 0) } if (D(e)) { H(e), c++; const n = d[c]; n && (t.innerHTML = n.html, E(n.pos)), c >= d.length && c-- } }(e), y(e) && !g && (k(), g = !0)), p && !function (t) { return O(t) && "C" === S(t) }(e) && E(C())) })), T("keyup", (t => { t.defaultPrevented || t.isComposing || (a !== B() && h(), N(t), f(B())) })), T("focus", (t => { l = !0 })), T("blur", (t => { l = !1 })), T("paste", (e => { k(), function (e) { if (e.defaultPrevented) return; H(e); const n = e.originalEvent ?? e, o = n.clipboardData.getData("text/plain").replace(/\r\n?/g, "\n"), r = C(); L(o), u(t), E({ start: Math.min(r.start, r.end) + o.length, end: Math.min(r.start, r.end) + o.length, dir: "<-" }) }(e), k(), f(B()) })), T("cut", (e => { k(), function (e) { const n = C(), o = K(), r = e.originalEvent ?? e; r.clipboardData.setData("text/plain", o.toString()), i.execCommand("delete"), u(t), E({ start: Math.min(n.start, n.end), end: Math.min(n.start, n.end), dir: "<-" }), H(e) }(e), k(), f(B()) })), { updateOptions(t) { Object.assign(o, t) }, updateCode(e) { t.textContent = e, u(t), f(e) }, onUpdate(t) { f = t }, toString: B, save: C, restore: E, recordHistory: k, destroy() { for (let [e, n] of s) t.removeEventListener(e, n) } } } -------------------------------------------------------------------------------- /tests/solver/candidate_selector_interesting.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | 3 | import { ensureGlobalEnvironment } from '../helpers/test_env.js'; 4 | import { runTest } from '../helpers/test_runner.js'; 5 | 6 | ensureGlobalEnvironment(); 7 | 8 | const { GridShape } = await import('../../js/grid_shape.js'); 9 | const { CandidateSelector, ConflictScores, SeenCandidateSet } = await import('../../js/solver/candidate_selector.js'); 10 | 11 | const makeDebugLogger = () => ({ 12 | enableStepLogs: false, 13 | enableLogs: false, 14 | log: () => { }, 15 | }); 16 | 17 | await runTest('CandidateSelector prefers interesting values when uninterestingValues set', () => { 18 | const shape = GridShape.fromGridSize(4); 19 | 20 | const seenCandidateSet = new SeenCandidateSet(shape.numCells); 21 | seenCandidateSet.enabledInSolver = true; 22 | const candidates = seenCandidateSet.candidates; 23 | // Mark value 1 as already-seen for cell 0. 24 | candidates[0] = 1 << 0; 25 | 26 | seenCandidateSet._lastInterestingCell = 0; 27 | 28 | const selector = new CandidateSelector( 29 | shape, 30 | /* handlerSet */[], 31 | makeDebugLogger(), 32 | seenCandidateSet, 33 | ); 34 | 35 | const conflictScores = new ConflictScores(new Int32Array(shape.numCells), shape.numValues); 36 | selector.reset(conflictScores); 37 | 38 | // All cells have 4 candidates, so the selector should choose cell 0. 39 | const gridState = new Uint16Array(shape.numCells); 40 | gridState.fill((1 << shape.numValues) - 1); 41 | 42 | const [nextDepth, value, count] = selector.selectNextCandidate( 43 | /* cellDepth */ 0, 44 | gridState, 45 | /* stepState */ null, 46 | /* isNewNode */ true, 47 | ); 48 | 49 | assert.equal(nextDepth, 1); 50 | assert.equal(count, 4); 51 | // Should prefer value 2 (bit 1) over value 1 (bit 0). 52 | assert.equal(value, 1 << 1); 53 | }); 54 | 55 | await runTest('CandidateSelector falls back when no interesting values exist', () => { 56 | const shape = GridShape.fromGridSize(4); 57 | 58 | const seenCandidateSet = new SeenCandidateSet(shape.numCells); 59 | seenCandidateSet.enabledInSolver = true; 60 | const candidates = seenCandidateSet.candidates; 61 | // Mark all values already-seen for cell 0. 62 | candidates[0] = (1 << shape.numValues) - 1; 63 | 64 | seenCandidateSet._lastInterestingCell = 0; 65 | 66 | const selector = new CandidateSelector( 67 | shape, 68 | /* handlerSet */[], 69 | makeDebugLogger(), 70 | seenCandidateSet, 71 | ); 72 | 73 | const conflictScores = new ConflictScores(new Int32Array(shape.numCells), shape.numValues); 74 | selector.reset(conflictScores); 75 | 76 | const gridState = new Uint16Array(shape.numCells); 77 | gridState.fill((1 << shape.numValues) - 1); 78 | 79 | const [nextDepth, value, count] = selector.selectNextCandidate( 80 | /* cellDepth */ 0, 81 | gridState, 82 | /* stepState */ null, 83 | /* isNewNode */ true, 84 | ); 85 | 86 | assert.equal(nextDepth, 1); 87 | assert.equal(count, 4); 88 | // Falls back to the default "lowest-bit" value. 89 | assert.equal(value, 1 << 0); 90 | }); 91 | 92 | await runTest('CandidateSelector selects only from interesting cells when prefix is interesting', () => { 93 | const shape = GridShape.fromGridSize(4); 94 | 95 | const seenCandidateSet = new SeenCandidateSet(shape.numCells); 96 | seenCandidateSet.enabledInSolver = true; 97 | 98 | const allValues = (1 << shape.numValues) - 1; 99 | const candidates = seenCandidateSet.candidates; 100 | // Cell 0 is fixed to value 1, and value 1 has not been seen -> interesting prefix. 101 | candidates[0] = 0; 102 | 103 | // Make cell 1 non-interesting (all values already seen), and cell 2 interesting. 104 | candidates[1] = allValues; 105 | candidates[2] = 0; 106 | 107 | const selector = new CandidateSelector( 108 | shape, 109 | /* handlerSet */[], 110 | makeDebugLogger(), 111 | seenCandidateSet, 112 | ); 113 | 114 | // Give cell 1 a high conflict score so it would normally be selected. 115 | const initialScores = new Int32Array(shape.numCells); 116 | initialScores[1] = 100; 117 | const conflictScores = new ConflictScores(initialScores, shape.numValues); 118 | selector.reset(conflictScores); 119 | 120 | const gridState = new Uint16Array(shape.numCells); 121 | gridState.fill(allValues); 122 | gridState[0] = 1 << 0; 123 | 124 | const [nextDepth, value, count] = selector.selectNextCandidate( 125 | /* cellDepth */ 1, 126 | gridState, 127 | /* stepState */ null, 128 | /* isNewNode */ true, 129 | ); 130 | 131 | assert.equal(selector.getCellAtDepth(1), 2); 132 | assert.equal(nextDepth, 2); 133 | assert.equal(count, 4); 134 | assert.equal(value, 1 << 0); 135 | }); 136 | 137 | await runTest('CandidateSelector custom candidates pop interesting cell first', () => { 138 | const shape = GridShape.fromGridSize(4); 139 | 140 | const seenCandidateSet = new SeenCandidateSet(shape.numCells); 141 | seenCandidateSet.enabledInSolver = true; 142 | const allValues = (1 << shape.numValues) - 1; 143 | 144 | // Ensure prefix is interesting at depth 1. 145 | seenCandidateSet.candidates[0] = 0; 146 | 147 | // For the nominated value (1), cell 1 is not interesting, cell 2 is interesting. 148 | const nominatedValue = 1 << 0; 149 | seenCandidateSet.candidates[1] = nominatedValue; 150 | seenCandidateSet.candidates[2] = 0; 151 | 152 | const finder = { 153 | cells: [1, 2], 154 | maybeFindCandidate: (grid, conflictScores, result) => { 155 | result.score = 1e9; 156 | result.value = nominatedValue; 157 | result.cells.length = 0; 158 | result.cells.push(1, 2); 159 | return true; 160 | }, 161 | }; 162 | 163 | const handler = { 164 | candidateFinders: () => [finder], 165 | }; 166 | 167 | const selector = new CandidateSelector( 168 | shape, 169 | /* handlerSet */[handler], 170 | makeDebugLogger(), 171 | seenCandidateSet, 172 | ); 173 | 174 | const initialScores = new Int32Array(shape.numCells); 175 | // Ensure custom candidate mode is eligible (conflictScores[cell] > 0). 176 | initialScores[1] = 20; 177 | initialScores[2] = 20; 178 | const conflictScores = new ConflictScores(initialScores, shape.numValues); 179 | selector.reset(conflictScores); 180 | 181 | const gridState = new Uint16Array(shape.numCells); 182 | gridState.fill(allValues); 183 | gridState[0] = nominatedValue; 184 | 185 | // With depth=1, custom candidates should trigger and choose the interesting 186 | // cell (2) first by popping it from the end of the list. 187 | const [nextDepth, value, count] = selector.selectNextCandidate( 188 | /* cellDepth */ 1, 189 | gridState, 190 | /* stepState */ null, 191 | /* isNewNode */ true, 192 | ); 193 | 194 | assert.equal(nextDepth, 2); 195 | assert.equal(count, 2); 196 | assert.equal(value, nominatedValue); 197 | assert.equal(selector.getCellAtDepth(1), 2); 198 | }); 199 | -------------------------------------------------------------------------------- /tests/bench/bench_harness.js: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'node:url'; 2 | 3 | const nowNs = () => process.hrtime.bigint(); 4 | 5 | const formatNumber = (value) => value.toLocaleString('en-US'); 6 | 7 | const formatSmall = (value) => { 8 | if (!Number.isFinite(value)) return 'n/a'; 9 | const abs = Math.abs(value); 10 | if (abs >= 100) return value.toFixed(0); 11 | if (abs >= 10) return value.toFixed(1); 12 | return value.toFixed(2); 13 | }; 14 | 15 | const formatPerOpNs = (ns) => { 16 | if (!Number.isFinite(ns) || ns < 0) return 'n/a'; 17 | if (ns === 0) return '0 ns'; 18 | 19 | if (ns < 1000) return `${formatSmall(ns)} ns`; 20 | if (ns < 1e6) return `${formatSmall(ns / 1e3)} µs`; 21 | if (ns < 1e9) return `${formatSmall(ns / 1e6)} ms`; 22 | return `${formatSmall(ns / 1e9)} s`; 23 | }; 24 | 25 | const formatOpsPerSec = (opsPerSec) => { 26 | if (!Number.isFinite(opsPerSec) || opsPerSec <= 0) return 'n/a'; 27 | if (opsPerSec >= 1e9) return `${(opsPerSec / 1e9).toFixed(2)} Gops/s`; 28 | if (opsPerSec >= 1e6) return `${(opsPerSec / 1e6).toFixed(2)} Mops/s`; 29 | if (opsPerSec >= 1e3) return `${(opsPerSec / 1e3).toFixed(2)} Kops/s`; 30 | return `${opsPerSec.toFixed(2)} ops/s`; 31 | }; 32 | 33 | export const DEFAULT_BENCH_OPTIONS = Object.freeze({ 34 | warmupIterations: 5, 35 | iterations: 10, 36 | innerIterations: 50_000, 37 | minSampleTimeMs: 10, 38 | }); 39 | 40 | const parseArgs = (argv) => { 41 | const args = { name: null, help: false }; 42 | for (let i = 2; i < argv.length; i++) { 43 | const a = argv[i]; 44 | if (a === '--help' || a === '-h') { 45 | args.help = true; 46 | } else if (a === '--name') { 47 | args.name = argv[++i] ?? ''; 48 | } else if (a.startsWith('--name=')) { 49 | args.name = a.slice('--name='.length); 50 | } 51 | } 52 | return args; 53 | }; 54 | 55 | const toNameMatcher = (nameArg) => { 56 | if (!nameArg) return null; 57 | const trimmed = String(nameArg).trim(); 58 | if (!trimmed) return null; 59 | 60 | // Support /regex/flags in addition to substring matching. 61 | if (trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0) { 62 | const lastSlash = trimmed.lastIndexOf('/'); 63 | const pattern = trimmed.slice(1, lastSlash); 64 | const flags = trimmed.slice(lastSlash + 1); 65 | try { 66 | const re = new RegExp(pattern, flags); 67 | return (s) => re.test(s); 68 | } catch { 69 | // Fall back to substring match on invalid regex. 70 | } 71 | } 72 | 73 | const needle = trimmed.toLowerCase(); 74 | return (s) => s.toLowerCase().includes(needle); 75 | }; 76 | 77 | /** 78 | * Registry is module-global so `run_all_benchmarks.js` can discover results. 79 | */ 80 | const registry = []; 81 | let currentGroup = null; 82 | 83 | export const benchGroup = (name, fn) => { 84 | const prev = currentGroup; 85 | currentGroup = name; 86 | try { 87 | fn(); 88 | } finally { 89 | currentGroup = prev; 90 | } 91 | }; 92 | 93 | export const bench = (name, fn, options = {}) => { 94 | registry.push({ 95 | group: currentGroup || 'default', 96 | name, 97 | fn, 98 | options: { ...DEFAULT_BENCH_OPTIONS, ...options }, 99 | }); 100 | }; 101 | 102 | export const getRegisteredBenches = () => registry.slice(); 103 | 104 | const maybeGc = () => { 105 | // Works only if node is run with `--expose-gc`. 106 | if (typeof globalThis.gc === 'function') { 107 | globalThis.gc(); 108 | } 109 | }; 110 | 111 | const measureOnceNs = (fn, innerIterations) => { 112 | const start = nowNs(); 113 | for (let i = 0; i < innerIterations; i++) fn(); 114 | const end = nowNs(); 115 | return Number(end - start); 116 | }; 117 | 118 | const median = (arr) => { 119 | const xs = arr.slice().sort((a, b) => a - b); 120 | const mid = (xs.length / 2) | 0; 121 | return xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2; 122 | }; 123 | 124 | export const runBench = async (b) => { 125 | const { fn, options } = b; 126 | const { 127 | warmupIterations, 128 | iterations, 129 | innerIterations, 130 | minSampleTimeMs, 131 | } = options; 132 | 133 | // Warmup (JIT + caches). 134 | for (let i = 0; i < warmupIterations; i++) { 135 | measureOnceNs(fn, Math.min(1_000, innerIterations)); 136 | } 137 | 138 | const samples = []; 139 | for (let i = 0; i < iterations; i++) { 140 | maybeGc(); 141 | 142 | // Ensure each sample is long enough to reduce timer noise. 143 | let usedInner = innerIterations; 144 | const minSampleTimeNs = minSampleTimeMs * 1e6; 145 | let elapsedNs = measureOnceNs(fn, usedInner); 146 | while (elapsedNs < minSampleTimeNs) { 147 | usedInner = Math.min(usedInner * 2, 50_000_000); 148 | elapsedNs = measureOnceNs(fn, usedInner); 149 | } 150 | 151 | samples.push({ ns: elapsedNs, innerIterations: usedInner }); 152 | } 153 | 154 | const perOpNs = samples.map((s) => s.ns / s.innerIterations); 155 | const medPerOpNs = median(perOpNs); 156 | const opsPerSec = 1e9 / medPerOpNs; 157 | 158 | return { 159 | samples, 160 | medianPerOpNs: medPerOpNs, 161 | opsPerSec, 162 | }; 163 | }; 164 | 165 | export const printBenchResult = (b, result) => { 166 | const label = `${b.group} :: ${b.name}`; 167 | const perOp = result.medianPerOpNs; 168 | 169 | // Display an approximate per-op time; note that the loop overhead is included. 170 | const perOpDisplay = formatPerOpNs(perOp); 171 | const opsDisplay = formatOpsPerSec(result.opsPerSec); 172 | const sampleCount = result.samples.length; 173 | const inner = formatNumber(result.samples[0]?.innerIterations ?? 0); 174 | 175 | console.log(`${label}`); 176 | console.log(` median/op: ${perOpDisplay} | throughput: ${opsDisplay} | samples: ${sampleCount} | inner: ${inner}`); 177 | }; 178 | 179 | export const isMain = (importMetaUrl, argv = process.argv) => { 180 | const mainPath = argv?.[1]; 181 | if (!mainPath) return false; 182 | try { 183 | return pathToFileURL(mainPath).href === importMetaUrl; 184 | } catch { 185 | return false; 186 | } 187 | }; 188 | 189 | export const runIfMain = async (importMetaUrl, argv = process.argv) => { 190 | if (!isMain(importMetaUrl, argv)) return; 191 | 192 | const args = parseArgs(argv); 193 | if (args.help) { 194 | console.log('Usage: node .bench.js [--name ]'); 195 | process.exit(0); 196 | } 197 | 198 | let benches = getRegisteredBenches(); 199 | if (benches.length === 0) { 200 | console.warn('No benchmarks registered.'); 201 | process.exit(0); 202 | } 203 | 204 | const nameMatches = toNameMatcher(args.name); 205 | if (nameMatches) { 206 | benches = benches.filter((b) => nameMatches(`${b.group} :: ${b.name}`)); 207 | } 208 | 209 | if (benches.length === 0) { 210 | console.warn('No benchmarks matched the provided filters.'); 211 | process.exit(0); 212 | } 213 | 214 | console.log(`▶ Running ${benches.length} benchmark(s)`); 215 | 216 | benches.sort((a, b) => (a.group + a.name).localeCompare(b.group + b.name)); 217 | for (const b of benches) { 218 | const result = await runBench(b); 219 | printBenchResult(b, result); 220 | } 221 | 222 | console.log('\n✓ Benchmarks completed'); 223 | }; 224 | -------------------------------------------------------------------------------- /js/solver/nfa_handler.js: -------------------------------------------------------------------------------- 1 | const { SudokuConstraintHandler } = await import('./handlers.js' + self.VERSION_PARAM); 2 | const { BitSet } = await import('../util.js' + self.VERSION_PARAM); 3 | 4 | class CompressedNFA { 5 | constructor(numStates, acceptingStates, startingStates, transitionLists) { 6 | this.numStates = numStates; 7 | this.acceptingStates = acceptingStates; 8 | this.startingStates = startingStates; 9 | this.transitionLists = transitionLists; 10 | } 11 | 12 | static makeTransitionEntry(mask, state) { 13 | // Transition entry layout: [state: 16 bits, mask: 16 bits] 14 | // This allows us to store the transitions compactly in a single Uint32Array. 15 | // The entry can be checked directly against a value since values are 16 | // also at most 16 bits. 17 | return (state << 16) | mask; 18 | } 19 | } 20 | 21 | export const compressNFA = (nfa) => { 22 | nfa.seal(); 23 | nfa.closeOverEpsilonTransitions(); 24 | 25 | const numStates = nfa.numStates(); 26 | if (numStates > (1 << 16)) { 27 | throw new Error('NFA has too many states to represent'); 28 | } 29 | 30 | const acceptingStates = new BitSet(numStates); 31 | const startingStates = new BitSet(numStates); 32 | 33 | for (const id of nfa.getStartIds()) { 34 | startingStates.add(id); 35 | } 36 | 37 | // Build transition lists with compressed entries. 38 | // For each state, group targets by state and combine symbol masks. 39 | const transitionListsRaw = []; 40 | let totalTransitions = 0; 41 | 42 | for (let stateId = 0; stateId < numStates; stateId++) { 43 | if (nfa.isAccepting(stateId)) { 44 | acceptingStates.add(stateId); 45 | } 46 | 47 | const stateTransitions = nfa.getStateTransitions(stateId); 48 | const targetMasks = new Map(); 49 | 50 | for (let symbolIndex = 0; symbolIndex < stateTransitions.length; symbolIndex++) { 51 | const targets = stateTransitions[symbolIndex]; 52 | if (!targets) continue; 53 | const mask = 1 << symbolIndex; 54 | for (const target of targets) { 55 | targetMasks.set(target, (targetMasks.get(target) || 0) | mask); 56 | } 57 | } 58 | 59 | const transitionList = []; 60 | for (const [target, mask] of targetMasks) { 61 | transitionList.push(CompressedNFA.makeTransitionEntry(mask, target)); 62 | } 63 | transitionListsRaw.push(transitionList); 64 | totalTransitions += transitionList.length; 65 | } 66 | 67 | // Flatten into a single backing array for memory efficiency. 68 | const transitionBackingArray = new Uint32Array(totalTransitions); 69 | const transitionLists = []; 70 | let transitionOffset = 0; 71 | 72 | for (let i = 0; i < numStates; i++) { 73 | const rawList = transitionListsRaw[i]; 74 | const numTransitions = rawList.length; 75 | const transitionList = transitionBackingArray.subarray( 76 | transitionOffset, 77 | transitionOffset + numTransitions); 78 | transitionOffset += numTransitions; 79 | transitionList.set(rawList); 80 | transitionLists.push(transitionList); 81 | } 82 | 83 | return new CompressedNFA( 84 | numStates, 85 | acceptingStates, 86 | startingStates, 87 | transitionLists, 88 | ); 89 | }; 90 | 91 | // Enforces a linear regex constraint by compiling the pattern into an NFA and 92 | // propagating it across candidate sets to prune unsupported values. 93 | export class NFAConstraint extends SudokuConstraintHandler { 94 | constructor(cells, cnfa) { 95 | super(cells); 96 | this._cnfa = cnfa; 97 | 98 | const stateCapacity = this._cnfa.numStates; 99 | const slots = this.cells.length + 1; 100 | const { bitsets, words } = BitSet.allocatePool(stateCapacity, slots); 101 | this._stateWords = words; 102 | this._statesList = bitsets; 103 | } 104 | 105 | getNFA() { 106 | return this._cnfa; 107 | } 108 | 109 | enforceConsistency(grid, handlerAccumulator) { 110 | const cells = this.cells; 111 | const numCells = cells.length; 112 | const cnfa = this._cnfa; 113 | const transitionLists = cnfa.transitionLists; 114 | const statesList = this._statesList; 115 | 116 | // Clear all the states so we can reuse the bitsets without reallocating. 117 | this._stateWords.fill(0); 118 | 119 | // Forward pass: Find all states reachable from the start state. 120 | statesList[0].copyFrom(cnfa.startingStates); 121 | 122 | for (let i = 0; i < numCells; i++) { 123 | const nextStates = statesList[i + 1]; 124 | const currentStatesWords = statesList[i].words; 125 | const values = grid[cells[i]]; 126 | 127 | // Note: We operate directly on the bitset words for performance. 128 | // Encapsulating this in methods caused significant overhead. 129 | for (let wordIndex = 0; wordIndex < currentStatesWords.length; wordIndex++) { 130 | let word = currentStatesWords[wordIndex]; 131 | while (word) { 132 | const lowestBit = word & -word; 133 | word ^= lowestBit; 134 | const stateIndex = BitSet.bitIndex(wordIndex, lowestBit); 135 | const transitionList = transitionLists[stateIndex]; 136 | for (let j = 0; j < transitionList.length; j++) { 137 | const entry = transitionList[j]; 138 | if (values & entry) { 139 | nextStates.add(entry >>> 16); 140 | } 141 | } 142 | } 143 | } 144 | 145 | if (nextStates.isEmpty()) return false; 146 | } 147 | 148 | // Backward pass: Filter down to only the states that can reach an accepting 149 | // state. Prune any unsupported values from the grid. 150 | const finalStates = statesList[numCells]; 151 | finalStates.intersect(cnfa.acceptingStates); 152 | if (finalStates.isEmpty()) return false; 153 | 154 | for (let i = numCells - 1; i >= 0; i--) { 155 | const currentStatesWords = statesList[i].words; 156 | const nextStates = statesList[i + 1]; 157 | const values = grid[cells[i]]; 158 | let supportedValues = 0; 159 | 160 | // Note: We operate directly on the bitset words for performance. 161 | // Encapsulating this in methods caused significant overhead. 162 | for (let wordIndex = 0; wordIndex < currentStatesWords.length; wordIndex++) { 163 | let word = currentStatesWords[wordIndex]; 164 | let keptWord = 0; 165 | while (word) { 166 | const lowestBit = word & -word; 167 | word ^= lowestBit; 168 | const stateIndex = BitSet.bitIndex(wordIndex, lowestBit); 169 | const transitionList = transitionLists[stateIndex]; 170 | let stateSupportedValues = 0; 171 | for (let j = 0; j < transitionList.length; j++) { 172 | const entry = transitionList[j]; 173 | const maskedValues = values & entry; 174 | if (maskedValues) { 175 | if (nextStates.has(entry >>> 16)) { 176 | stateSupportedValues |= maskedValues; 177 | } 178 | } 179 | } 180 | 181 | if (stateSupportedValues) { 182 | keptWord |= lowestBit; 183 | supportedValues |= stateSupportedValues; 184 | } 185 | } 186 | currentStatesWords[wordIndex] = keptWord; 187 | } 188 | 189 | if (!supportedValues) return false; 190 | 191 | if (values !== supportedValues) { 192 | grid[cells[i]] = supportedValues; 193 | handlerAccumulator.addForCell(cells[i]); 194 | } 195 | } 196 | 197 | return true; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /js/sandbox/examples.js: -------------------------------------------------------------------------------- 1 | // Extract function body as string, removing wrapper and common indentation. 2 | const fnToCode = (fn) => { 3 | const lines = fn.toString().split('\n').slice(1, -1); 4 | // Find minimum indentation (ignoring empty lines). 5 | const minIndent = lines 6 | .filter(line => line.trim()) 7 | .reduce((min, line) => Math.min(min, line.match(/^\s*/)[0].length), Infinity); 8 | // Remove the common indentation. 9 | return lines.map(line => line.slice(minIndent)).join('\n'); 10 | }; 11 | 12 | const DEFAULT_CODE_FN = () => { 13 | // Create a miracle sudoku 14 | // (https://www.youtube.com/watch?v=yKf9aUIxdb4) 15 | const constraints = [ 16 | new AntiKnight(), 17 | new AntiKing(), 18 | new AntiConsecutive(), 19 | new Given('R5C3', 1), 20 | new Given('R6C7', 2), 21 | ]; 22 | 23 | console.log('Creating ', constraints.length, 'constraints', '\n'); 24 | 25 | help(); // Usage instructions 26 | 27 | return constraints; 28 | }; 29 | 30 | const SHAPE_FN = () => { 31 | // Create a 6x6 Sudoku 32 | return [ 33 | new Shape('6x6'), 34 | new Given('R1C5', 4), 35 | new Given('R2C2', 1), 36 | new Given('R2C4', 3), 37 | new Given('R2C6', 5), 38 | new Given('R3C4', 2), 39 | new Given('R4C3', 3), 40 | new Given('R5C1', 6), 41 | new Given('R5C3', 2), 42 | new Given('R5C5', 5), 43 | new Given('R6C2', 5), 44 | ]; 45 | }; 46 | 47 | const COLUMN_CONSTRAINTS_FN = () => { 48 | // Puzzle: https://sudokupad.app/gdc/flat-pack/gw 49 | // Generate constraints for each column 50 | const columnConstraints = []; 51 | const gridSize = 6; 52 | 53 | // A constraint for each column. 54 | for (let column = 1; column <= gridSize; column++) { 55 | const cells = []; 56 | for (let row = 1; row <= gridSize; row++) { 57 | cells.push(makeCellId(row, column)); 58 | } 59 | columnConstraints.push(new Regex('.*(12|24).*', ...cells)); 60 | } 61 | 62 | return [ 63 | ...columnConstraints, 64 | new Shape('6x6'), 65 | new Whisper(3, 'R1C1', 'R2C1', 'R3C1', 'R4C1', 'R5C1'), 66 | new Whisper(3, 'R6C3', 'R5C4', 'R6C5'), 67 | new Whisper(3, 'R2C4', 'R3C5', 'R4C4', 'R3C3', 'R2C4'), 68 | ]; 69 | }; 70 | 71 | const COMPOSITE_CONSTRAINT_FN = () => { 72 | // Composite constraint (https://sudokupad.app/1i71uad30f) 73 | 74 | const lines = ['R9', 'C9']; 75 | const orParts = []; 76 | for (let i = 1; i <= 9; i++) { 77 | const andParts = []; 78 | for (const line of lines) { 79 | andParts.push(new HiddenSkyscraper(line, i)); 80 | } 81 | orParts.push(new And(andParts)); 82 | } 83 | const orConstraint = new Or(orParts); 84 | 85 | // Example of adding extra serialized constraints. 86 | // (For example, copied from the ISS UI). 87 | const base = '.HiddenSkyscraper~C4~8~.HiddenSkyscraper~C5~8~.HiddenSkyscraper~C6~8~.HiddenSkyscraper~C7~6~.HiddenSkyscraper~R2~2~2.HiddenSkyscraper~R7~~5.HiddenSkyscraper~C2~~6.HiddenSkyscraper~C3~~7.HiddenSkyscraper~R1~3~.HiddenSkyscraper~R3~1~.HiddenSkyscraper~R4~7~.HiddenSkyscraper~R5~7~.HiddenSkyscraper~R6~7~'; 88 | 89 | return [base, orConstraint]; 90 | }; 91 | 92 | const STATE_MACHINE_FN = () => { 93 | // Arithmetic progression NFA 94 | // All differences between consecutive cells must be equal 95 | const spec = { 96 | startState: { lastVal: null, diff: null }, 97 | transition: (state, value) => { 98 | if (state.lastVal === null) { 99 | return { lastVal: value, diff: null }; 100 | } 101 | const diff = value - state.lastVal; 102 | if (state.diff === null || state.diff === diff) { 103 | return { lastVal: value, diff: diff }; 104 | } 105 | // Invalid - difference doesn't match 106 | return undefined; 107 | }, 108 | accept: (state) => true, 109 | }; 110 | 111 | const encodedNFA = NFA.encodeSpec(spec, /* numValues= */ 9); 112 | return [ 113 | new NFA(encodedNFA, 'AP', 'R7C4', 'R8C4', 'R9C4', 'R9C5', 'R9C6', 'R8C6', 'R7C6', 'R7C7'), 114 | new NFA(encodedNFA, 'AP', 'R1C5', 'R2C4', 'R3C3', 'R3C4'), 115 | new NFA(encodedNFA, 'AP', 'R4C3', 'R5C2', 'R5C3', 'R6C2', 'R7C1', 'R7C2'), 116 | new NFA(encodedNFA, 'AP', 'R3C5', 'R4C5', 'R5C5', 'R6C5'), 117 | new NFA(encodedNFA, 'AP', 'R4C7', 'R5C8', 'R5C7'), 118 | new NFA(encodedNFA, 'AP', 'R6C8', 'R7C9', 'R7C8'), 119 | new NFA(encodedNFA, 'AP', 'R2C6', 'R3C7', 'R3C6'), 120 | new Given('R7C5', 1), 121 | new Given('R8C5', 9), 122 | new Given('R3C1', 1), 123 | new Given('R3C2', 2), 124 | new Given('R5C9', 2), 125 | new Given('R6C9', 4), 126 | ]; 127 | }; 128 | 129 | const MODIFYING_CONSTRAINTS_FN = () => { 130 | // Parse existing constraints and modify them. 131 | 132 | // This example creates "Ambiguous Arrows" for https://sudokupad.app/i20kqjopap 133 | // In this puzzle, the bulb of the arrow can appear in any location. 134 | 135 | // First we create the puzzle in the UI, as if it had normal Arrow constraints. 136 | const base = '.~R1C2_6~R1C3_2~R4C4_7~R6C6_3~R6C3_1~R7C4_4~R8C2_2~R8C1_1~R7C1_5~R9C7_2~R9C8_4~R4C7_5~R3C6_9~R2C8_6~R2C9_5~R3C9_1.Arrow~R1C1~R1C2~R1C3~R1C4.Arrow~R1C8~R2C8~R3C8.Arrow~R1C9~R2C9~R3C9~R4C9.Arrow~R2C7~R3C7~R4C7.Arrow~R4C3~R4C4~R4C5.Arrow~R6C3~R7C3~R8C3.Arrow~R7C2~R8C2~R9C2.Arrow~R6C1~R7C1~R8C1~R9C1.Arrow~R9C6~R9C7~R9C8~R9C9.Arrow~R6C5~R6C6~R6C7'; 137 | 138 | const result = []; 139 | 140 | // Then we parse the constraints, and update them to create the ambiguous 141 | // versions. 142 | for (const c of parseConstraint(base)) { 143 | if (c.type === 'Arrow') { 144 | // Create a rotated version of the arrow for each cell. 145 | const cells = [...c.cells]; 146 | const group = []; 147 | for (let i = 0; i < cells.length; i++) { 148 | group.push(new Arrow(...cells)); 149 | cells.push(cells.shift()); 150 | } 151 | result.push(new Or(group)); 152 | } else { 153 | // Keep other constraints as-is. 154 | result.push(c); 155 | } 156 | } 157 | 158 | return result; 159 | }; 160 | 161 | const CHECKERBOARD_FN = () => { 162 | // Generate a checkerboard min/max grid. 163 | // Cells alternate between local minima and maxima. 164 | // Note: This grid has no solutions. 165 | 166 | const size = 5; 167 | const constraints = [new Shape('5x5')]; 168 | 169 | for (let row = 1; row <= size; row++) { 170 | for (let col = 1; col <= size; col++) { 171 | // On even squares, cell is greater than neighbors. 172 | // On odd squares, cell is less than neighbors. 173 | const isMax = (row + col) % 2 === 0; 174 | const cell = makeCellId(row, col); 175 | 176 | if (col < size) { 177 | const neighbor = makeCellId(row, col + 1); 178 | constraints.push(new GreaterThan(isMax ? cell : neighbor, isMax ? neighbor : cell)); 179 | } 180 | if (row < size) { 181 | const neighbor = makeCellId(row + 1, col); 182 | constraints.push(new GreaterThan(isMax ? cell : neighbor, isMax ? neighbor : cell)); 183 | } 184 | } 185 | } 186 | 187 | return constraints; 188 | }; 189 | 190 | export const DEFAULT_CODE = fnToCode(DEFAULT_CODE_FN); 191 | 192 | export const EXAMPLES = { 193 | 'Default Template': DEFAULT_CODE, 194 | 'Shape': fnToCode(SHAPE_FN), 195 | 'Column constraints': fnToCode(COLUMN_CONSTRAINTS_FN), 196 | 'Composite constraint': fnToCode(COMPOSITE_CONSTRAINT_FN), 197 | 'State machine': fnToCode(STATE_MACHINE_FN), 198 | 'Modifying constraints': fnToCode(MODIFYING_CONSTRAINTS_FN), 199 | 'Checkerboard min/max': fnToCode(CHECKERBOARD_FN), 200 | }; -------------------------------------------------------------------------------- /tests/bench/lookup_tables.bench.js: -------------------------------------------------------------------------------- 1 | import { ensureGlobalEnvironment } from '../helpers/test_env.js'; 2 | import { bench, benchGroup, runIfMain } from './bench_harness.js'; 3 | 4 | ensureGlobalEnvironment(); 5 | 6 | const { LookupTables } = await import('../../js/solver/lookup_tables.js' + self.VERSION_PARAM); 7 | 8 | // Keep this file focused on hot primitives and stable inputs. 9 | // As the suite grows, prefer adding new *.bench.js files per module. 10 | 11 | const NUM_VALUES = 9; 12 | const TABLES = LookupTables.get(NUM_VALUES); 13 | const COMBINATIONS = TABLES.combinations; // 1 << NUM_VALUES 14 | const ALL = TABLES.allValues; 15 | 16 | const makeLCG = (seed) => { 17 | let s = seed >>> 0; 18 | return () => { 19 | // Numerical Recipes LCG. 20 | s = (1664525 * s + 1013904223) >>> 0; 21 | return s; 22 | }; 23 | }; 24 | 25 | const rng = makeLCG(0xC0FFEE); 26 | 27 | const singleBitMasks = (() => { 28 | const arr = new Uint16Array(NUM_VALUES); 29 | for (let i = 0; i < NUM_VALUES; i++) arr[i] = 1 << i; 30 | return arr; 31 | })(); 32 | 33 | const values1toN = (() => { 34 | const arr = new Uint8Array(NUM_VALUES); 35 | for (let i = 0; i < NUM_VALUES; i++) arr[i] = i + 1; 36 | return arr; 37 | })(); 38 | 39 | const masksSparse = (() => { 40 | const arr = new Uint16Array(2048); 41 | for (let i = 0; i < arr.length; i++) { 42 | // 1–2 bits set. 43 | const a = rng() % NUM_VALUES; 44 | const b = rng() % NUM_VALUES; 45 | arr[i] = (1 << a) | (1 << b); 46 | } 47 | return arr; 48 | })(); 49 | 50 | const masksHalf = (() => { 51 | const arr = new Uint16Array(2048); 52 | for (let i = 0; i < arr.length; i++) { 53 | // Roughly half the bits set. 54 | let m = 0; 55 | for (let b = 0; b < NUM_VALUES; b++) { 56 | if (rng() & 1) m |= 1 << b; 57 | } 58 | // Avoid degenerate 0 mask. 59 | arr[i] = m || 1; 60 | } 61 | return arr; 62 | })(); 63 | 64 | const masksDense = (() => { 65 | const arr = new Uint16Array(2048); 66 | for (let i = 0; i < arr.length; i++) { 67 | // Clear 0–2 bits from ALL. 68 | let m = ALL; 69 | const clears = rng() % 3; 70 | for (let j = 0; j < clears; j++) { 71 | m &= ~(1 << (rng() % NUM_VALUES)); 72 | } 73 | arr[i] = m; 74 | } 75 | return arr; 76 | })(); 77 | 78 | const allMasks = (() => { 79 | // Iterate through all possible masks in a fixed order. 80 | const arr = new Uint16Array(COMBINATIONS); 81 | for (let i = 0; i < COMBINATIONS; i++) arr[i] = i; 82 | return arr; 83 | })(); 84 | 85 | // Prevent V8 from DCEing results. 86 | let sink = 0; 87 | const consume = (x) => { sink ^= (x | 0); }; 88 | 89 | const valueArraysSparse = Array.from(masksSparse, (m) => LookupTables.toValuesArray(m)); 90 | const valueArraysHalf = Array.from(masksHalf, (m) => LookupTables.toValuesArray(m)); 91 | const valueArraysDense = Array.from(masksDense, (m) => LookupTables.toValuesArray(m)); 92 | 93 | benchGroup('lookup_tables', () => { 94 | // --------------------------------------------------------------------------- 95 | // Basic value<->bit conversions 96 | // --------------------------------------------------------------------------- 97 | { 98 | let i = 0; 99 | bench('fromValue(1..N)', () => { 100 | consume(LookupTables.fromValue(values1toN[i++ % values1toN.length])); 101 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 102 | } 103 | 104 | { 105 | let i = 0; 106 | bench('fromIndex(0..N-1)', () => { 107 | consume(LookupTables.fromIndex(i++ % NUM_VALUES)); 108 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 109 | } 110 | 111 | // --------------------------------------------------------------------------- 112 | // Basic bit->value conversions 113 | // --------------------------------------------------------------------------- 114 | { 115 | let i = 0; 116 | bench('toValue(single-bit)', () => { 117 | consume(LookupTables.toValue(singleBitMasks[i++ % singleBitMasks.length])); 118 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 119 | } 120 | 121 | { 122 | let i = 0; 123 | bench('toIndex(single-bit)', () => { 124 | consume(LookupTables.toIndex(singleBitMasks[i++ % singleBitMasks.length])); 125 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 126 | } 127 | 128 | { 129 | let i = 0; 130 | bench('minMax16bitValue(mixed)', () => { 131 | consume(LookupTables.minMax16bitValue(masksHalf[i++ % masksHalf.length])); 132 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 133 | } 134 | 135 | { 136 | let i = 0; 137 | bench('minValue(mixed)', () => { 138 | consume(LookupTables.minValue(masksHalf[i++ % masksHalf.length])); 139 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 140 | } 141 | 142 | { 143 | let i = 0; 144 | bench('maxValue(mixed)', () => { 145 | consume(LookupTables.maxValue(masksHalf[i++ % masksHalf.length])); 146 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 147 | } 148 | 149 | { 150 | let i = 0; 151 | bench('valueRangeInclusive(mixed)', () => { 152 | consume(LookupTables.valueRangeInclusive(masksHalf[i++ % masksHalf.length])); 153 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 154 | } 155 | 156 | { 157 | let i = 0; 158 | bench('valueRangeExclusive(mixed)', () => { 159 | consume(LookupTables.valueRangeExclusive(masksHalf[i++ % masksHalf.length])); 160 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 161 | } 162 | 163 | // --------------------------------------------------------------------------- 164 | // Mask -> array conversion (allocation-heavy, but hot in UI/debug paths) 165 | // --------------------------------------------------------------------------- 166 | { 167 | let i = 0; 168 | bench('fromValuesArray(sparse)', () => { 169 | consume(LookupTables.fromValuesArray(valueArraysSparse[i++ % valueArraysSparse.length])); 170 | }, { innerIterations: 250_000, minSampleTimeMs: 25 }); 171 | } 172 | 173 | { 174 | let i = 0; 175 | bench('fromValuesArray(half)', () => { 176 | consume(LookupTables.fromValuesArray(valueArraysHalf[i++ % valueArraysHalf.length])); 177 | }, { innerIterations: 150_000, minSampleTimeMs: 25 }); 178 | } 179 | 180 | { 181 | let i = 0; 182 | bench('fromValuesArray(dense)', () => { 183 | consume(LookupTables.fromValuesArray(valueArraysDense[i++ % valueArraysDense.length])); 184 | }, { innerIterations: 150_000, minSampleTimeMs: 25 }); 185 | } 186 | 187 | { 188 | let i = 0; 189 | bench('toValuesArray(sparse)', () => { 190 | consume(LookupTables.toValuesArray(masksSparse[i++ % masksSparse.length]).length); 191 | }, { innerIterations: 250_000, minSampleTimeMs: 25 }); 192 | } 193 | 194 | { 195 | let i = 0; 196 | bench('toValuesArray(half)', () => { 197 | consume(LookupTables.toValuesArray(masksHalf[i++ % masksHalf.length]).length); 198 | }, { innerIterations: 150_000, minSampleTimeMs: 25 }); 199 | } 200 | 201 | { 202 | let i = 0; 203 | bench('toValuesArray(dense)', () => { 204 | consume(LookupTables.toValuesArray(masksDense[i++ % masksDense.length]).length); 205 | }, { innerIterations: 150_000, minSampleTimeMs: 25 }); 206 | } 207 | 208 | // --------------------------------------------------------------------------- 209 | // Table lookups (these are the intended fast-path primitives) 210 | // --------------------------------------------------------------------------- 211 | { 212 | let i = 1; // skip 0 to avoid special-case entries dominating 213 | bench('sum[mask] (table lookup)', () => { 214 | consume(TABLES.sum[allMasks[i++ % allMasks.length]]); 215 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 216 | } 217 | 218 | { 219 | let i = 1; 220 | bench('rangeInfo[mask] (table lookup)', () => { 221 | consume(TABLES.rangeInfo[allMasks[i++ % allMasks.length]]); 222 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 223 | } 224 | 225 | { 226 | let i = 1; 227 | bench('reverse[mask] (table lookup)', () => { 228 | consume(TABLES.reverse[allMasks[i++ % allMasks.length]]); 229 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 230 | } 231 | }); 232 | 233 | // Export a value so the module has an observable side-effect. 234 | export const _benchSink = () => sink; 235 | await runIfMain(import.meta.url); 236 | -------------------------------------------------------------------------------- /tests/sum_handler.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | 3 | import { ensureGlobalEnvironment } from './helpers/test_env.js'; 4 | import { runTest, logSuiteComplete } from './helpers/test_runner.js'; 5 | import { 6 | setupConstraintTest, 7 | createCellExclusions, 8 | createAccumulator, 9 | mask, 10 | applyCandidates, 11 | initializeConstraintHandler, 12 | } from './helpers/constraint_test_utils.js'; 13 | 14 | ensureGlobalEnvironment(); 15 | 16 | const { Sum } = await import('../js/solver/sum_handler.js'); 17 | 18 | const defaultContext = setupConstraintTest(); 19 | const uniqueCells = () => createCellExclusions({ allUnique: true }); 20 | const nonUniqueCells = () => createCellExclusions({ allUnique: false }); 21 | 22 | const initializeSum = (options = {}) => { 23 | const { 24 | numCells, 25 | sum, 26 | coeffs, 27 | context = defaultContext, 28 | cellExclusions = uniqueCells(), 29 | } = options; 30 | 31 | const cells = Array.from({ length: numCells }, (_, i) => i); 32 | return initializeConstraintHandler(Sum, { 33 | args: [cells, sum, coeffs], 34 | context, 35 | cellExclusions, 36 | }); 37 | }; 38 | 39 | await runTest('Sum should force a unique combination once candidates align', () => { 40 | const { handler, context } = initializeSum({ numCells: 4, sum: 14 }); 41 | const grid = applyCandidates(context.createGrid(), { 42 | 0: [1, 2], 43 | 1: [2, 3], 44 | 2: [3, 4], 45 | 3: [4, 5], 46 | }); 47 | 48 | const result = handler.enforceConsistency(grid, createAccumulator()); 49 | 50 | assert.equal(result, true); 51 | assert.equal(grid[0], mask(2)); 52 | assert.equal(grid[1], mask(3)); 53 | assert.equal(grid[2], mask(4)); 54 | assert.equal(grid[3], mask(5)); 55 | }); 56 | 57 | await runTest('Sum should reject impossible cages', () => { 58 | const { handler, context } = initializeSum({ numCells: 4, sum: 30 }); 59 | const grid = applyCandidates(context.createGrid(), { 60 | 0: [1, 2], 61 | 1: [2, 3], 62 | 2: [3, 4], 63 | 3: [4, 5], 64 | }); 65 | 66 | const result = handler.enforceConsistency(grid, createAccumulator()); 67 | assert.equal(result, false, 'handler should detect unsatisfiable sums'); 68 | }); 69 | 70 | await runTest('Sum should solve mixed coefficient cages with negative terms', () => { 71 | const { handler, context } = initializeSum({ numCells: 4, sum: 12, coeffs: [2, -1, 1, 1] }); 72 | const grid = applyCandidates(context.createGrid(), { 73 | 0: [3, 4], 74 | 1: [1, 2], 75 | 2: [2], 76 | 3: [3], 77 | }); 78 | 79 | const result = handler.enforceConsistency(grid, createAccumulator()); 80 | 81 | assert.equal(result, true, 'handler should solve the linear equation'); 82 | assert.equal(grid[0], mask(4), 'first cell forced by coefficient scaling'); 83 | assert.equal(grid[1], mask(1), 'second cell forced by negative coefficient'); 84 | assert.equal(grid[2], mask(2), 'fixed term should remain consistent'); 85 | assert.equal(grid[3], mask(3), 'final cell resolved by remaining balance'); 86 | }); 87 | 88 | await runTest('Sum should resolve cages with more than three unfixed cells', () => { 89 | const { handler, context } = initializeSum({ numCells: 4, sum: 22 }); 90 | const grid = applyCandidates(context.createGrid(), { 91 | 0: [1, 8], 92 | 1: [2, 7], 93 | 2: [3, 6], 94 | 3: [4, 5], 95 | }); 96 | 97 | const accumulator = createAccumulator(); 98 | const result = handler.enforceConsistency(grid, accumulator); 99 | 100 | assert.equal(result, true, 'handler should keep solvable cages valid'); 101 | assert.equal(grid[0], mask(8)); 102 | assert.equal(grid[1], mask(7)); 103 | assert.equal(grid[2], mask(3)); 104 | assert.equal(grid[3], mask(4)); 105 | }); 106 | 107 | await runTest('Sum should handle cages longer than fifteen cells', () => { 108 | const longContext = setupConstraintTest({ numValues: 16, numCells: 32 }); 109 | const { handler, context } = initializeSum({ 110 | numCells: 16, 111 | sum: 136, 112 | context: longContext, 113 | cellExclusions: nonUniqueCells(), 114 | }); 115 | const assignments = {}; 116 | for (let i = 0; i < 12; i++) { 117 | assignments[i] = [i + 1]; 118 | } 119 | assignments[12] = [13, 14]; 120 | assignments[13] = [14, 15]; 121 | assignments[14] = [15, 16]; 122 | assignments[15] = [16]; 123 | const grid = applyCandidates(context.createGrid(), assignments); 124 | 125 | const result = handler.enforceConsistency(grid, createAccumulator()); 126 | 127 | assert.equal(result, true, 'handler should solve long cages'); 128 | assert.equal(grid[12], mask(13)); 129 | assert.equal(grid[13], mask(14)); 130 | assert.equal(grid[14], mask(15)); 131 | }); 132 | 133 | await runTest('Sum should restrict values based on complement cells', () => { 134 | const complementCells = [2, 3, 4, 5, 6, 7, 8, 9]; 135 | const { handler, context } = initializeSum({ numCells: 2, sum: 10 }); 136 | handler.setComplementCells(complementCells); 137 | 138 | const assignments = { 139 | 0: [1, 2, 8, 9], 140 | 1: [1, 2, 8, 9], 141 | }; 142 | for (const complementCell of complementCells) { 143 | assignments[complementCell] = [1, 2, 3, 4, 5, 6, 7, 8]; 144 | } 145 | const grid = applyCandidates(context.createGrid(), assignments); 146 | 147 | const result = handler.enforceConsistency(grid, createAccumulator()); 148 | 149 | assert.equal(result, true, 'handler should remain consistent with complement data'); 150 | assert.equal(grid[0], mask(1, 9), 'only digits paired with complement availability should remain'); 151 | assert.equal(grid[1], mask(1, 9)); 152 | }); 153 | 154 | await runTest('Sum should prohibit repeated digits when cells are mutually exclusive', () => { 155 | const { handler, context } = initializeSum({ 156 | numCells: 4, 157 | sum: 15, 158 | cellExclusions: uniqueCells(), 159 | }); 160 | const grid = applyCandidates(context.createGrid(), { 161 | 0: [1, 2, 3], 162 | 1: [1, 2, 3], 163 | 2: [5], 164 | 3: [6], 165 | }); 166 | 167 | const result = handler.enforceConsistency(grid, createAccumulator()); 168 | assert.equal(result, true, 'handler should remain consistent under uniqueness constraints'); 169 | assert.equal(grid[0], mask(1, 3)); 170 | assert.equal(grid[1], mask(1, 3)); 171 | assert.equal(grid[2], mask(5)); 172 | assert.equal(grid[3], mask(6)); 173 | }); 174 | 175 | await runTest('Sum should allow repeated digits when cells are non-exclusive', () => { 176 | const { handler, context } = initializeSum({ 177 | numCells: 4, 178 | sum: 15, 179 | cellExclusions: nonUniqueCells(), 180 | }); 181 | const grid = applyCandidates(context.createGrid(), { 182 | 0: [1, 2, 3], 183 | 1: [1, 2, 3], 184 | 2: [5], 185 | 3: [6], 186 | }); 187 | 188 | const result = handler.enforceConsistency(grid, createAccumulator()); 189 | assert.equal(result, true, 'non-exclusive cages can reuse digits'); 190 | assert.equal(grid[0], mask(1, 2, 3)); 191 | assert.equal(grid[1], mask(1, 2, 3)); 192 | assert.equal(grid[2], mask(5)); 193 | assert.equal(grid[3], mask(6)); 194 | }); 195 | 196 | await runTest('Sum should reject cages with sums above the maximum', () => { 197 | const handler = new Sum([0, 1, 2, 3], 100); 198 | const initialized = handler.initialize( 199 | defaultContext.createGrid(), 200 | uniqueCells(), 201 | defaultContext.shape, 202 | {}, 203 | ); 204 | assert.equal(initialized, false, 'handler should refuse impossible cage sums'); 205 | }); 206 | 207 | await runTest('Sum should reject non-integer totals during initialization', () => { 208 | const handler = new Sum([0, 1, 2, 3], 4.5); 209 | const initialized = handler.initialize( 210 | defaultContext.createGrid(), 211 | uniqueCells(), 212 | defaultContext.shape, 213 | {}, 214 | ); 215 | assert.equal(initialized, false, 'handler should require integer sums'); 216 | }); 217 | 218 | await runTest('Sum should detect impossible bounds when minimum exceeds the target', () => { 219 | const { handler, context } = initializeSum({ numCells: 4, sum: 5 }); 220 | const grid = applyCandidates(context.createGrid(), { 221 | 0: [8, 9], 222 | 1: [7, 9], 223 | 2: [7, 8], 224 | 3: [6, 9], 225 | }); 226 | 227 | const result = handler.enforceConsistency(grid, createAccumulator()); 228 | assert.equal(result, false, 'handler should fail when even the min sum is too large'); 229 | }); 230 | 231 | logSuiteComplete('Sum handler'); 232 | -------------------------------------------------------------------------------- /js/help/help.js: -------------------------------------------------------------------------------- 1 | const { SudokuConstraint } = await import('../sudoku_constraint.js' + self.VERSION_PARAM); 2 | const { clearDOMNode } = await import('../util.js' + self.VERSION_PARAM); 3 | 4 | const CATEGORY_CONFIGS = { 5 | 'LinesAndSets': { 6 | description: 'Constraints that apply to lines, regions, or sets of cells', 7 | instructions: ` 8 | Select cells by click and dragging on the grid then select a constraint 9 | from the "Lines & Sets" panel. 10 | Cells can also be added and removed by holding down shift while 11 | clicking.`, 12 | }, 13 | 'OutsideClue': { 14 | description: 'Constraints that use clues outside the grid', 15 | instructions: ` 16 | Click on an arrow outside the grid then select a constraint from the 17 | "Outside Clues" panel.` 18 | }, 19 | 'LayoutCheckbox': { 20 | description: 'Layout and structural constraints', 21 | instructions: `Use checkboxes in the "Layout constraints" panel.` 22 | }, 23 | 'GlobalCheckbox': { 24 | description: 'Constraints that apply to the entire grid', 25 | instructions: `Use checkboxes in the "Global constraints" panel.` 26 | }, 27 | 'GivenCandidates': { 28 | description: 'Restrictions on the initial values of cells', 29 | instructions: ` 30 | Select a cell by clicking on it then typing to enter a value or backspace 31 | to clear the cells. 32 | Use the "Set multiple values" panel to set more than one value. 33 | Select extra cells by dragging, or shift-clicking.` 34 | }, 35 | 'Pairwise': { 36 | description: 'Custom pairwise relationships between cells', 37 | instructions: ` 38 | Select cells by click and dragging on the grid then configuring the 39 | constraint in the "JavaScript constraints" panel using the Pairwise tab 40 | (see panel for instructions). 41 | Cells can also be added and removed by holding down shift while 42 | clicking.`, 43 | }, 44 | 'StateMachine': { 45 | description: 'Finite-state machine for accepting cell sequences', 46 | instructions: ` 47 | Select cells by click and dragging on the grid, then use the 48 | "Custom JavaScript constraints" panel with the State machine tab to define 49 | the start state, transition, and accept logic (see panel for guidance). 50 | Cells can also be added and removed by holding shift while clicking.`, 51 | }, 52 | 'Jigsaw': { 53 | description: 'Irregular grid regions', 54 | instructions: ` 55 | Select cells by click and dragging on the grid then pressing 56 | "Add Jigsaw Piece" in the "Layout constraints" panel. 57 | The selected region size must match the row/column length. 58 | Cells can also be added and removed by holding down shift while 59 | clicking.`, 60 | }, 61 | 'Composite': { 62 | description: 'Composite constraints that group other constraints', 63 | instructions: ` 64 | Use the "Composite constraints" panel to create a new composite group 65 | by pressing the button of the group type you want. 66 | When a group is selected, new constraints you create will be added to it. 67 | ` 68 | }, 69 | 'Shape': { 70 | description: 'Overall grid size', 71 | instructions: `Select the grid shape using the "Shape" dropdown.` 72 | }, 73 | }; 74 | 75 | const getAllConstraintClasses = () => { 76 | const classes = []; 77 | for (const [name, constraintClass] of Object.entries(SudokuConstraint)) { 78 | if (typeof constraintClass !== 'function' || !constraintClass.CATEGORY) { 79 | continue; 80 | } 81 | 82 | if (!CATEGORY_CONFIGS.hasOwnProperty(constraintClass.CATEGORY)) { 83 | continue; 84 | } 85 | 86 | classes.push({ 87 | name: name, 88 | class: constraintClass, 89 | category: constraintClass.CATEGORY, 90 | displayName: constraintClass.displayName ? constraintClass.displayName() : name, 91 | description: constraintClass.DESCRIPTION || null 92 | }); 93 | } 94 | return classes; 95 | }; 96 | 97 | const groupConstraintsByCategory = (constraints) => { 98 | const grouped = new Map(); 99 | 100 | for (const category of Object.keys(CATEGORY_CONFIGS)) { 101 | grouped.set(category, []); 102 | } 103 | 104 | for (const constraint of constraints) { 105 | grouped.get(constraint.category).push(constraint); 106 | } 107 | return grouped; 108 | }; 109 | 110 | const formatCategoryName = (category) => { 111 | return category.replace(/([A-Z])/g, ' $1').trim(); 112 | }; 113 | 114 | const formatInstructions = (instructions) => { 115 | // Format instructions with one sentence per line. 116 | const cleaned = instructions.trim().replace(/\s+/g, ' '); 117 | const sentences = cleaned.split('.').filter( 118 | sentence => sentence.trim().length > 0); 119 | return sentences.map(sentence => sentence.trim() + '.').join('\n'); 120 | }; 121 | 122 | const createCategoryOverviewItem = (category, config) => { 123 | const overviewItem = document.createElement('div'); 124 | overviewItem.className = 'category-overview-item'; 125 | 126 | // Left column: category content 127 | const categoryContent = document.createElement('div'); 128 | categoryContent.className = 'category-overview-content'; 129 | 130 | const categoryTitle = document.createElement('div'); 131 | categoryTitle.className = 'category-name'; 132 | const categoryLink = document.createElement('a'); 133 | categoryLink.href = `#${category}`; 134 | categoryLink.textContent = formatCategoryName(category); 135 | categoryTitle.appendChild(categoryLink); 136 | 137 | const categoryDescription = document.createElement('p'); 138 | categoryDescription.textContent = config.description || ''; 139 | 140 | categoryContent.appendChild(categoryTitle); 141 | categoryContent.appendChild(categoryDescription); 142 | 143 | // Right column: instructions 144 | const categoryInstructions = document.createElement('div'); 145 | categoryInstructions.className = 'instructions'; 146 | categoryInstructions.textContent = formatInstructions(config.instructions); 147 | 148 | overviewItem.appendChild(categoryContent); 149 | overviewItem.appendChild(categoryInstructions); 150 | 151 | return overviewItem; 152 | }; 153 | 154 | const createConstraintItem = (constraint) => { 155 | const constraintItem = document.createElement('div'); 156 | constraintItem.className = 'constraint-item'; 157 | 158 | const constraintName = document.createElement('h4'); 159 | constraintName.textContent = constraint.displayName; 160 | 161 | const constraintDescription = document.createElement('p'); 162 | constraintDescription.textContent = constraint.description || ''; 163 | 164 | constraintItem.appendChild(constraintName); 165 | constraintItem.appendChild(constraintDescription); 166 | 167 | return constraintItem; 168 | }; 169 | 170 | const createCategorySection = (category, constraints) => { 171 | const categorySection = document.createElement('div'); 172 | categorySection.className = 'category-section'; 173 | 174 | const categoryTitle = document.createElement('h3'); 175 | categoryTitle.id = category; 176 | categoryTitle.textContent = formatCategoryName(category); 177 | 178 | const categoryDescription = document.createElement('p'); 179 | categoryDescription.textContent = CATEGORY_CONFIGS[category].description || ''; 180 | 181 | const categoryInstructions = document.createElement('p'); 182 | categoryInstructions.className = 'instructions'; 183 | categoryInstructions.textContent = formatInstructions( 184 | CATEGORY_CONFIGS[category].instructions); 185 | 186 | const constraintList = document.createElement('div'); 187 | constraintList.className = 'constraint-list'; 188 | 189 | for (const constraint of constraints) { 190 | const constraintItem = createConstraintItem(constraint); 191 | constraintList.appendChild(constraintItem); 192 | } 193 | 194 | categorySection.appendChild(categoryTitle); 195 | categorySection.appendChild(categoryDescription); 196 | categorySection.appendChild(categoryInstructions); 197 | categorySection.appendChild(constraintList); 198 | 199 | return categorySection; 200 | }; 201 | 202 | export const renderHelpPage = () => { 203 | const categoriesContainer = document.getElementById('categories-content'); 204 | const constraintsContainer = document.getElementById('constraints-content'); 205 | const constraints = getAllConstraintClasses(); 206 | const grouped = groupConstraintsByCategory(constraints); 207 | 208 | clearDOMNode(categoriesContainer); 209 | clearDOMNode(constraintsContainer); 210 | 211 | // Create categories overview section 212 | const categoriesOverview = document.createElement('div'); 213 | categoriesOverview.className = 'categories-overview'; 214 | 215 | for (const category of Object.keys(CATEGORY_CONFIGS)) { 216 | const overviewItem = createCategoryOverviewItem( 217 | category, CATEGORY_CONFIGS[category]); 218 | categoriesOverview.appendChild(overviewItem); 219 | } 220 | 221 | categoriesContainer.appendChild(categoriesOverview); 222 | 223 | // Create detailed constraints section 224 | for (const category of Object.keys(CATEGORY_CONFIGS)) { 225 | const categoryConstraints = grouped.get(category).sort( 226 | (a, b) => a.displayName.localeCompare(b.displayName)); 227 | const categorySection = createCategorySection(category, categoryConstraints); 228 | constraintsContainer.appendChild(categorySection); 229 | } 230 | }; -------------------------------------------------------------------------------- /tests/engine/cell_exclusions.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import { ensureGlobalEnvironment } from '../helpers/test_env.js'; 3 | import { runTest, logSuiteComplete } from '../helpers/test_runner.js'; 4 | 5 | ensureGlobalEnvironment(); 6 | 7 | const { CellExclusions, HandlerSet } = await import('../../js/solver/engine.js' + self.VERSION_PARAM); 8 | const HandlerModule = await import('../../js/solver/handlers.js' + self.VERSION_PARAM); 9 | const { BitSet } = await import('../../js/util.js' + self.VERSION_PARAM); 10 | 11 | const SHAPE_9x9 = { 12 | numCells: 81, 13 | numValues: 9, 14 | gridSize: 9, 15 | boxWidth: 3, 16 | boxHeight: 3, 17 | }; 18 | 19 | const createHandlerSet = (handlers = []) => { 20 | return new HandlerSet(handlers, SHAPE_9x9); 21 | }; 22 | 23 | await runTest('CellExclusions should initialize empty', () => { 24 | const handlerSet = createHandlerSet(); 25 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 26 | 27 | assert.equal(exclusions.isMutuallyExclusive(0, 1), false); 28 | }); 29 | 30 | await runTest('CellExclusions should respect handler exclusions', () => { 31 | // Create a row handler for the first 9 cells. 32 | const cells = [0, 1, 2, 3, 4, 5, 6, 7, 8]; 33 | const handler = new HandlerModule.AllDifferent(cells); 34 | const handlerSet = createHandlerSet([handler]); 35 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 36 | 37 | assert.equal(exclusions.isMutuallyExclusive(0, 1), true); 38 | assert.equal(exclusions.isMutuallyExclusive(0, 8), true); 39 | assert.equal(exclusions.isMutuallyExclusive(0, 9), false); 40 | }); 41 | 42 | await runTest('CellExclusions should allow adding manual exclusions', () => { 43 | const handlerSet = createHandlerSet(); 44 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 45 | 46 | exclusions.addMutualExclusion(0, 1); 47 | assert.equal(exclusions.isMutuallyExclusive(0, 1), true); 48 | assert.equal(exclusions.isMutuallyExclusive(0, 2), false); 49 | }); 50 | 51 | await runTest('CellExclusions should propagate exclusions for same values', () => { 52 | const handlerSet = createHandlerSet(); 53 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 54 | 55 | exclusions.addMutualExclusion(0, 2); 56 | // If 0 and 1 are the same value, then 1 must also be exclusive with 2. 57 | exclusions.areSameValue(0, 1); 58 | 59 | assert.equal(exclusions.isMutuallyExclusive(1, 2), true); 60 | }); 61 | 62 | await runTest('CellExclusions should return BitSet', () => { 63 | const handlerSet = createHandlerSet(); 64 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 65 | exclusions.addMutualExclusion(0, 1); 66 | exclusions.addMutualExclusion(0, 5); 67 | 68 | const bitSet = exclusions.getBitSet(0); 69 | assert.ok(bitSet instanceof BitSet); 70 | assert.equal(bitSet.has(1), true); 71 | assert.equal(bitSet.has(5), true); 72 | assert.equal(bitSet.has(2), false); 73 | }); 74 | 75 | await runTest('CellExclusions should seal after reading BitSet', () => { 76 | const handlerSet = createHandlerSet(); 77 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 78 | exclusions.getBitSet(0); 79 | 80 | assert.throws(() => exclusions.addMutualExclusion(1, 2), /Cannot add exclusions after caching/); 81 | }); 82 | 83 | await runTest('CellExclusions should compute pair exclusions', () => { 84 | const handlerSet = createHandlerSet(); 85 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 86 | 87 | // 0 is exclusive with 2 88 | exclusions.addMutualExclusion(0, 2); 89 | // 1 is exclusive with 2 90 | exclusions.addMutualExclusion(1, 2); 91 | // 0 is exclusive with 3 92 | exclusions.addMutualExclusion(0, 3); 93 | 94 | // Intersection of exclusions for 0 and 1 should contain 2. 95 | const pairExclusions = exclusions.getPairExclusions((0 << 8) | 1); 96 | assert.deepEqual(pairExclusions, [2]); 97 | }); 98 | 99 | await runTest('CellExclusions should compute list exclusions', () => { 100 | const handlerSet = createHandlerSet(); 101 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 102 | 103 | exclusions.addMutualExclusion(0, 3); 104 | exclusions.addMutualExclusion(1, 3); 105 | exclusions.addMutualExclusion(2, 3); 106 | 107 | exclusions.addMutualExclusion(0, 4); 108 | 109 | const listExclusions = exclusions.getListExclusions([0, 1, 2]); 110 | assert.deepEqual(listExclusions, [3]); 111 | }); 112 | 113 | await runTest('CellExclusions should seal after reading Array', () => { 114 | const handlerSet = createHandlerSet(); 115 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 116 | exclusions.getArray(0); 117 | 118 | assert.throws(() => exclusions.addMutualExclusion(1, 2), /Cannot add exclusions after caching/); 119 | }); 120 | 121 | await runTest('CellExclusions should seal after reading PairExclusions', () => { 122 | const handlerSet = createHandlerSet(); 123 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 124 | exclusions.getPairExclusions((0 << 8) | 1); 125 | 126 | assert.throws(() => exclusions.addMutualExclusion(1, 2), /Cannot add exclusions after caching/); 127 | }); 128 | 129 | await runTest('CellExclusions should seal after reading ListExclusions', () => { 130 | const handlerSet = createHandlerSet(); 131 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 132 | exclusions.getListExclusions([0, 1]); 133 | 134 | assert.throws(() => exclusions.addMutualExclusion(1, 2), /Cannot add exclusions after caching/); 135 | }); 136 | 137 | await runTest('CellExclusions should throw when calling areSameValue after sealing', () => { 138 | const handlerSet = createHandlerSet(); 139 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 140 | exclusions.getArray(0); 141 | 142 | assert.throws(() => exclusions.areSameValue(1, 2), /Cannot add exclusions after caching/); 143 | }); 144 | 145 | await runTest('CellExclusions should check areMutuallyExclusive correctly', () => { 146 | const handlerSet = createHandlerSet(); 147 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 148 | exclusions.addMutualExclusion(0, 1); 149 | exclusions.addMutualExclusion(0, 2); 150 | exclusions.addMutualExclusion(1, 2); 151 | 152 | assert.equal(exclusions.areMutuallyExclusive([0, 1, 2]), true); 153 | assert.equal(exclusions.areMutuallyExclusive([0, 1, 3]), false); 154 | }); 155 | 156 | await runTest('CellExclusions should clone correctly and preserve sealed state', () => { 157 | const handlerSet = createHandlerSet(); 158 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 159 | exclusions.addMutualExclusion(0, 1); 160 | exclusions.getArray(0); // Seals it 161 | 162 | const clone = exclusions.clone(); 163 | assert.equal(clone.isMutuallyExclusive(0, 1), true); 164 | assert.throws(() => clone.addMutualExclusion(1, 2), /Cannot add exclusions after caching/); 165 | }); 166 | 167 | await runTest('CellExclusions should allow modifications on clone if not sealed', () => { 168 | const handlerSet = createHandlerSet(); 169 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 170 | exclusions.addMutualExclusion(0, 1); 171 | 172 | const clone = exclusions.clone(); 173 | clone.addMutualExclusion(1, 2); 174 | 175 | assert.equal(clone.isMutuallyExclusive(1, 2), true); 176 | assert.equal(exclusions.isMutuallyExclusive(1, 2), false); 177 | }); 178 | 179 | await runTest('CellExclusions should merge sets when calling areSameValue', () => { 180 | const handlerSet = createHandlerSet(); 181 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 182 | 183 | exclusions.addMutualExclusion(0, 2); 184 | exclusions.addMutualExclusion(1, 3); 185 | 186 | // Merge 0 and 1. 187 | exclusions.areSameValue(0, 1); 188 | 189 | // 0 should now have 1's exclusions (3) 190 | assert.equal(exclusions.isMutuallyExclusive(0, 3), true); 191 | // 1 should now have 0's exclusions (2) 192 | assert.equal(exclusions.isMutuallyExclusive(1, 2), true); 193 | 194 | // Adding to one should affect the other. 195 | exclusions.addMutualExclusion(0, 4); 196 | assert.equal(exclusions.isMutuallyExclusive(1, 4), true); 197 | }); 198 | 199 | await runTest('CellExclusions should return sorted array from getArray', () => { 200 | const handlerSet = createHandlerSet(); 201 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 202 | exclusions.addMutualExclusion(0, 5); 203 | exclusions.addMutualExclusion(0, 1); 204 | exclusions.addMutualExclusion(0, 3); 205 | 206 | const arr = exclusions.getArray(0); 207 | assert.deepEqual(arr, [1, 3, 5]); 208 | }); 209 | 210 | await runTest('CellExclusions should handle empty BitSet', () => { 211 | const handlerSet = createHandlerSet(); 212 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 213 | const bitSet = exclusions.getBitSet(0); 214 | assert.ok(bitSet.isEmpty()); 215 | }); 216 | 217 | await runTest('CellExclusions should cache pair exclusions regardless of order', () => { 218 | const handlerSet = createHandlerSet(); 219 | const exclusions = new CellExclusions(handlerSet, SHAPE_9x9); 220 | exclusions.addMutualExclusion(0, 2); 221 | exclusions.addMutualExclusion(1, 2); 222 | 223 | const pair1 = exclusions.getPairExclusions((0 << 8) | 1); 224 | const pair2 = exclusions.getPairExclusions((1 << 8) | 0); 225 | 226 | assert.deepEqual(pair1, [2]); 227 | assert.deepEqual(pair2, [2]); 228 | // Note: The implementation might return different array instances if not explicitly caching both keys, 229 | // but the content should be the same. If it caches based on computed result, they might be same instance. 230 | // The current implementation caches `revKey` inside `_computePairExclusions` but `getPairExclusions` 231 | // stores the result under the requested key. 232 | // So `pair1` is stored under `(0<<8)|1`. 233 | // When requesting `(1<<8)|0`, `_computePairExclusions` checks `(0<<8)|1` and returns it. 234 | // So they should be the same instance if the first one was computed first. 235 | assert.equal(pair1, pair2); 236 | }); 237 | 238 | logSuiteComplete('CellExclusions'); 239 | -------------------------------------------------------------------------------- /tests/solver/candidate_selector_invariants.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | 3 | import { ensureGlobalEnvironment } from '../helpers/test_env.js'; 4 | import { runTest } from '../helpers/test_runner.js'; 5 | 6 | ensureGlobalEnvironment(); 7 | 8 | const { GridShape } = await import('../../js/grid_shape.js'); 9 | const { CandidateSelector, ConflictScores, SeenCandidateSet } = await import('../../js/solver/candidate_selector.js'); 10 | 11 | const makeDebugLogger = () => ({ 12 | enableStepLogs: false, 13 | enableLogs: false, 14 | log: () => { }, 15 | }); 16 | 17 | const makeSelector = (shape, { handlerSet = [], seenCandidateSet } = {}) => { 18 | const selector = new CandidateSelector( 19 | shape, 20 | handlerSet, 21 | makeDebugLogger(), 22 | seenCandidateSet || new SeenCandidateSet(shape.numCells), 23 | ); 24 | 25 | const conflictScores = new ConflictScores(new Int32Array(shape.numCells), shape.numValues); 26 | selector.reset(conflictScores); 27 | return { selector, conflictScores }; 28 | }; 29 | 30 | await runTest('CandidateSelector moves all singletons to the front when next cell is a singleton', () => { 31 | const shape = GridShape.fromGridSize(4); 32 | const allValues = (1 << shape.numValues) - 1; 33 | 34 | const { selector } = makeSelector(shape); 35 | 36 | const gridState = new Uint16Array(shape.numCells); 37 | gridState.fill(allValues); 38 | 39 | // Ensure the next cell is a singleton and there are other singletons later. 40 | gridState[0] = 1 << 0; 41 | gridState[3] = 1 << 1; 42 | gridState[7] = 1 << 2; 43 | 44 | const [nextDepth, value, count] = selector.selectNextCandidate( 45 | /* cellDepth */ 0, 46 | gridState, 47 | /* stepState */ null, 48 | /* isNewNode */ true, 49 | ); 50 | 51 | assert.equal(count, 1); 52 | assert.equal(value, 1 << 0); 53 | assert.equal(nextDepth, 3); 54 | 55 | const cellOrder = selector.getCellOrder(); 56 | const prefixCells = Array.from(cellOrder.subarray(0, nextDepth)); 57 | assert.deepEqual(prefixCells, [0, 3, 7]); 58 | 59 | for (let i = 0; i < nextDepth; i++) { 60 | const v = gridState[cellOrder[i]]; 61 | assert.ok(v && ((v & (v - 1)) === 0), `Expected singleton at depth ${i}`); 62 | } 63 | for (let i = nextDepth; i < shape.numCells; i++) { 64 | const v = gridState[cellOrder[i]]; 65 | assert.ok((v & (v - 1)) !== 0, `Expected non-singleton at depth ${i}`); 66 | } 67 | }); 68 | 69 | await runTest('CandidateSelector returns [cellOrder,0,0] when a wipeout (0) exists while bubbling singletons', () => { 70 | const shape = GridShape.fromGridSize(4); 71 | const allValues = (1 << shape.numValues) - 1; 72 | 73 | const { selector } = makeSelector(shape); 74 | 75 | const gridState = new Uint16Array(shape.numCells); 76 | gridState.fill(allValues); 77 | 78 | // Make the next cell a singleton so that _updateCellOrder scans for other 79 | // singletons and detects wipeouts. 80 | gridState[0] = 1 << 0; 81 | gridState[5] = 0; 82 | 83 | const [cellOrderOrZero, value, count] = selector.selectNextCandidate( 84 | /* cellDepth */ 0, 85 | gridState, 86 | /* stepState */ null, 87 | /* isNewNode */ true, 88 | ); 89 | 90 | assert.ok(cellOrderOrZero instanceof Uint8Array); 91 | assert.equal(value, 0); 92 | assert.equal(count, 0); 93 | }); 94 | 95 | await runTest('CandidateSelector consumes custom candidate state across backtracks (count reflects remaining custom options)', () => { 96 | const shape = GridShape.fromGridSize(4); 97 | const allValues = (1 << shape.numValues) - 1; 98 | const nominatedValue = 1 << 0; 99 | 100 | const finder = { 101 | cells: [2, 5, 7], 102 | maybeFindCandidate: (grid, conflictScores, result) => { 103 | result.score = 1e9; 104 | result.value = nominatedValue; 105 | result.cells.length = 0; 106 | result.cells.push(2, 5, 7); 107 | return true; 108 | }, 109 | }; 110 | 111 | const handler = { 112 | candidateFinders: () => [finder], 113 | }; 114 | 115 | const { selector, conflictScores } = makeSelector(shape, { handlerSet: [handler] }); 116 | 117 | // Make default selection eligible for custom candidates: best cell has count>2 and cs>0. 118 | // Ensure minCS is beaten by at least one finder cell. 119 | conflictScores.scores[0] = 100; 120 | conflictScores.scores[2] = 60; 121 | conflictScores.scores[5] = 70; 122 | conflictScores.scores[7] = 80; 123 | 124 | const gridState = new Uint16Array(shape.numCells); 125 | gridState.fill(allValues); 126 | 127 | // First visit: custom candidate state should be created and the highest 128 | // conflict-score cell (7) should be popped first. 129 | { 130 | const [nextDepth, value, count] = selector.selectNextCandidate(0, gridState, null, true); 131 | assert.equal(nextDepth, 1); 132 | assert.equal(value, nominatedValue); 133 | assert.equal(count, 3); 134 | assert.equal(selector.getCellAtDepth(0), 7); 135 | } 136 | 137 | // Backtrack: should continue consuming the custom state (count=2), popping 5 next. 138 | { 139 | const [nextDepth, value, count] = selector.selectNextCandidate(0, gridState, null, false); 140 | assert.equal(nextDepth, 1); 141 | assert.equal(value, nominatedValue); 142 | assert.equal(count, 2); 143 | assert.equal(selector.getCellAtDepth(0), 5); 144 | } 145 | 146 | // Backtrack again: last custom option (count=1), popping 2. 147 | { 148 | const [nextDepth, value, count] = selector.selectNextCandidate(0, gridState, null, false); 149 | assert.equal(nextDepth, 1); 150 | assert.equal(value, nominatedValue); 151 | assert.equal(count, 1); 152 | assert.equal(selector.getCellAtDepth(0), 2); 153 | } 154 | 155 | // After exhaustion: should fall back to default selection (count=domain size). 156 | { 157 | const [nextDepth, value, count] = selector.selectNextCandidate(0, gridState, null, false); 158 | assert.equal(nextDepth, 1); 159 | assert.equal(count, 4); 160 | assert.equal(selector.getCellAtDepth(0), 0); 161 | assert.equal(value, 1 << 0); 162 | } 163 | }); 164 | 165 | await runTest('CandidateSelector stepState override clears any pending custom-candidate state', () => { 166 | const shape = GridShape.fromGridSize(4); 167 | const allValues = (1 << shape.numValues) - 1; 168 | const nominatedValue = 1 << 0; 169 | 170 | const finder = { 171 | cells: [2, 5, 7], 172 | maybeFindCandidate: (grid, conflictScores, result) => { 173 | result.score = 1e9; 174 | result.value = nominatedValue; 175 | result.cells.length = 0; 176 | result.cells.push(2, 5, 7); 177 | return true; 178 | }, 179 | }; 180 | 181 | const handler = { 182 | candidateFinders: () => [finder], 183 | }; 184 | 185 | const { selector, conflictScores } = makeSelector(shape, { handlerSet: [handler] }); 186 | 187 | conflictScores.scores[0] = 100; 188 | conflictScores.scores[2] = 60; 189 | conflictScores.scores[5] = 70; 190 | conflictScores.scores[7] = 80; 191 | 192 | const gridState = new Uint16Array(shape.numCells); 193 | gridState.fill(allValues); 194 | 195 | // First call seeds the custom candidate state. 196 | { 197 | const [, , count] = selector.selectNextCandidate(0, gridState, null, true); 198 | assert.equal(count, 3); 199 | } 200 | 201 | // Second call would normally be custom (count=2), but we override with a guided cell. 202 | const guidedCell = 1; 203 | const stepState = { 204 | step: 0, 205 | stepGuides: new Map([[0, { cell: guidedCell }]]), 206 | }; 207 | 208 | { 209 | const [nextDepth, , count] = selector.selectNextCandidate(0, gridState, stepState, false); 210 | assert.equal(nextDepth, 1); 211 | assert.equal(count, 4); 212 | assert.equal(selector.getCellAtDepth(0), guidedCell); 213 | } 214 | 215 | // Third call: custom state should have been cleared by the adjustment, so we should 216 | // see default selection behavior (count=4, best cell=0). 217 | { 218 | const [nextDepth, value, count] = selector.selectNextCandidate(0, gridState, null, false); 219 | assert.equal(nextDepth, 1); 220 | assert.equal(count, 4); 221 | assert.equal(selector.getCellAtDepth(0), 0); 222 | assert.equal(value, 1 << 0); 223 | } 224 | }); 225 | 226 | await runTest('CandidateSelector falls back cleanly when filtering to interesting cells but none are interesting', () => { 227 | const shape = GridShape.fromGridSize(4); 228 | const allValues = (1 << shape.numValues) - 1; 229 | 230 | const seenCandidateSet = new SeenCandidateSet(shape.numCells); 231 | seenCandidateSet.enabledInSolver = true; 232 | 233 | // Prefix (cell 0) is interesting (fixed value not previously seen). 234 | seenCandidateSet.candidates[0] = 0; 235 | 236 | // Make all remaining cells non-interesting (all values already seen). 237 | for (let i = 1; i < shape.numCells; i++) { 238 | seenCandidateSet.candidates[i] = allValues; 239 | } 240 | 241 | const selector = new CandidateSelector( 242 | shape, 243 | /* handlerSet */[], 244 | makeDebugLogger(), 245 | seenCandidateSet, 246 | ); 247 | 248 | const initialScores = new Int32Array(shape.numCells); 249 | initialScores[5] = 123; 250 | const conflictScores = new ConflictScores(initialScores, shape.numValues); 251 | selector.reset(conflictScores); 252 | 253 | const gridState = new Uint16Array(shape.numCells); 254 | gridState.fill(allValues); 255 | gridState[0] = 1 << 0; 256 | 257 | const [nextDepth, , count] = selector.selectNextCandidate(1, gridState, null, true); 258 | 259 | assert.equal(nextDepth, 2); 260 | assert.equal(count, 4); 261 | assert.equal(selector.getCellAtDepth(1), 5); 262 | }); 263 | 264 | await runTest('CandidateSelector returns [cellOrder,0,0] when current depth cell is already a wipeout (0)', () => { 265 | const shape = GridShape.fromGridSize(4); 266 | const allValues = (1 << shape.numValues) - 1; 267 | 268 | const { selector } = makeSelector(shape); 269 | 270 | const gridState = new Uint16Array(shape.numCells); 271 | gridState.fill(allValues); 272 | gridState[0] = 0; 273 | 274 | const [cellOrderOrZero, value, count] = selector.selectNextCandidate( 275 | /* cellDepth */ 0, 276 | gridState, 277 | /* stepState */ null, 278 | /* isNewNode */ true, 279 | ); 280 | 281 | assert.ok(cellOrderOrZero instanceof Uint8Array); 282 | assert.equal(value, 0); 283 | assert.equal(count, 0); 284 | }); 285 | 286 | await runTest('CandidateSelector interesting-cell filtering works on the maxScore==0 (minCount) path', () => { 287 | const shape = GridShape.fromGridSize(4); 288 | const allValues = (1 << shape.numValues) - 1; 289 | 290 | const seenCandidateSet = new SeenCandidateSet(shape.numCells); 291 | seenCandidateSet.enabledInSolver = true; 292 | 293 | // Prefix is interesting at depth=1. 294 | seenCandidateSet.candidates[0] = 0; 295 | 296 | // Make cell 1 non-interesting; cells 2 and 3 interesting. 297 | seenCandidateSet.candidates[1] = allValues; 298 | seenCandidateSet.candidates[2] = 0; 299 | seenCandidateSet.candidates[3] = 0; 300 | 301 | const selector = new CandidateSelector( 302 | shape, 303 | /* handlerSet */[], 304 | makeDebugLogger(), 305 | seenCandidateSet, 306 | ); 307 | 308 | // All conflict scores are 0 => maxScore==0 branch. 309 | const conflictScores = new ConflictScores(new Int32Array(shape.numCells), shape.numValues); 310 | selector.reset(conflictScores); 311 | 312 | const gridState = new Uint16Array(shape.numCells); 313 | gridState.fill(allValues); 314 | gridState[0] = 1 << 0; 315 | 316 | // Make the interesting cells have different counts. 317 | gridState[2] = (1 << 0) | (1 << 1); // count=2 318 | gridState[3] = (1 << 0) | (1 << 1) | (1 << 2); // count=3 319 | 320 | const [nextDepth, value, count] = selector.selectNextCandidate(1, gridState, null, true); 321 | 322 | assert.equal(nextDepth, 2); 323 | assert.equal(count, 2); 324 | assert.equal(selector.getCellAtDepth(1), 2); 325 | assert.equal(value, 1 << 0); 326 | }); 327 | -------------------------------------------------------------------------------- /help.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: null 3 | --- 4 | 5 | 6 | 7 | Help - Interactive Sudoku Solver 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 24 | 25 | 33 | 34 | 35 | 36 |
37 |
38 |

Interactive Sudoku Solver (ISS) – Help

39 | ← Back to Solver 40 |
41 | 42 | 43 | 65 | 66 |
67 | 68 |

Overview

69 |
70 |

71 | The Interactive Sudoku Solver (ISS) is a solver designed to quickly 72 | solve variant sudoku puzzles. It does not use human solving techniques, 73 | instead optimizing for speed. 74 |

75 | 76 |

77 | Use it to find all the solutions, count the solutions, or 78 | see all the valid values for each cell in a puzzle. 79 |

80 | 81 |

82 | Visit the github repository to 83 | view the source code and report bugs. 85 |

86 |
87 | 88 | 89 |

Constraint Categories

90 |
91 | 92 | 93 |

All Constraints by Category

94 |
95 | 96 | 97 |

Recipes

98 |
99 |

Some constraint types aren't directly supported but can be constructed using combinations of available 100 | constraints.

101 | 102 |

Clone

103 |

104 | Description: Two regions of the same shape and size, 105 | which must have the same values in corresponding cells. 106 |

107 |

108 | Use the SameValueSets on every pair of corresponding cells 109 | to mark them as equal. Each pair must be a separate constraint. 110 |

111 | 112 |

3x3 Magic Square

113 |

114 | Description: 115 | A square of cells, where the sum of the values in each row, column, 116 | and diagonal is the same. 117 |

118 |

119 | Use the Cage constrain each row, column, and diagonal to 120 | sum to 15. 121 |

122 | 123 |

Odd/Even Thermo

124 |

125 | Description: 126 | A thermometer (values are strictly increasing) where the values are 127 | either all odd or all even. 128 |

129 |

130 | Create a pairwise constraint in the "Custom JavaScript constraints" 131 | panel with the following condition: 132 |

a < b && (a % 2 == b % 2)
133 |

134 | 135 |

Nabner Line

136 |

137 | Description: 138 | No two digits along the line may be be consecutive. 139 | ("Nabner" is "Renban" spelled backwards.) 140 |

141 |

142 | Create a pairwise constraint in the "Custom JavaScript constraints" 143 | panel with "Chain handling" set to "All pairs" and use the following 144 | condition: 145 |

146 |

147 |

Math.abs(a - b) > 1
148 |

149 | 150 |

Not Renban

151 |

152 | Description: 153 | Unlike Nabner, this is how to create a set where all the values taken 154 | together are not consecutive. 155 |

156 | 157 |

158 | Implement this by configuring a state machine in the 159 | "Custom JavaScript constraints" panel. The NFA tracks the minimum and 160 | maximum values seen, accepting if max - min != count - 1 161 | (i.e., the values are not all consecutive). If you know all the 162 | values are distinct, then this is sufficient: 163 |

164 | NUM_CELLS = 3;  // Number of cells in the set
165 | startState = { min: 16, max: -1 };
166 | 
167 | function transition(state, value) {
168 |   return {
169 |     min: Math.min(state.min, value),
170 |     max: Math.max(state.max, value),
171 |   };
172 | }
173 | 
174 | function accept(state) {
175 |   return state.max - state.min !== NUM_CELLS - 1;
176 | }
177 |

178 |

179 | If the values may not be distinct, then you need to also need to check 180 | for duplicates. This can be done efficiently by using non-deterministic 181 | branches to check each digit: 182 |

183 | NUM_CELLS = 3;  // Number of cells in the set
184 | startState = {type: 'start'};
185 | 
186 | function transition(state, value) {
187 |   if (state.type === 'start') {
188 |     return [
189 |       { type: "rangeCheck", min: value, max: value },
190 |       { type: "duplicateCheck", value },
191 |     ];
192 |   }
193 |   if (state.type === 'rangeCheck') {
194 |     return [{
195 |       type: "rangeCheck",
196 |       min: Math.min(state.min, value),
197 |       max: Math.max(state.max, value),
198 |     }];
199 |   }
200 |   if (state.type === 'duplicateCheck') {
201 |     if (value === state.value) {
202 |       return [{ type: 'hasDuplicate' }];
203 |     } else {
204 |       return [
205 |         state,
206 |         { type: 'duplicateCheck', value }
207 |       ];
208 |     }
209 |   }
210 |   if (state.type === 'hasDuplicate') return [state];
211 | }
212 | 
213 | function accept(state) {
214 |   if (state.type == 'rangeCheck') {
215 |     return state.max - state.min !== NUM_CELLS - 1;
216 |   }
217 |   return (state.type === 'hasDuplicate');
218 | }
219 |

220 | 221 |

222 | You can generalize this to a single state machine that works for any 223 | number of cells, but it becomes a larger and less efficient. 224 |

225 | 226 |

Arithmetic Progression

227 |

228 | Description: 229 | Digits along the line must form an arithmetic progression. That is, 230 | the difference between all consecutive digits is same. 231 |

232 | 233 |

234 | Implement this by configuring a state machine in the 235 | "Custom JavaScript constraints" panel: 236 |

237 | startState = { lastVal: null, diff: null };
238 | 
239 | function transition(state, value) {
240 |   if (state.lastVal == null) {
241 |     return { lastVal: value, diff: null};
242 |   }
243 | 
244 |   const diff = value - state.lastVal;
245 | 
246 |   if (state.diff == null || state.diff == diff) {
247 |     return { lastVal: value, diff: diff };
248 |   }
249 | }
250 | 
251 | function accept(state) {
252 |   return true;
253 | }
254 |

255 | 256 |

Successor Arrows

257 |

258 | Description: 259 | If a digit N is placed in an arrow cell, the digit 260 | N + 1 must appear exactly N cells away in the 261 | arrow's direction. 262 |

263 | 264 |

265 | Select the arrow cells and all the cells along the arrow's direction, 266 | then add a Regex constraint from "Line and Sets" panel. 267 | The regex pattern should alternate over each possible starting digit: 268 |

269 |
(12|2.3|3.{2}4|4.{3}5|5.{4}6|6.{5}7|7.{6}8|8.{7}9).*
270 | 271 |

Flatmates

272 | 273 |

274 | Description: 275 | Generalize the "Dutch Flatmates" constraint to apply to an arbitrary 276 | center digit D, which must have either digit A 277 | above it or digit B below it (or both). 278 |

279 | 280 |

281 | Use a Regex constraint from the "Line and Sets" panel, 282 | applied to every column (replace A, B and 283 | D): 284 |

285 |
.*(AD|DB).*
286 | 287 |

Odd/Even Lots

288 | 289 |

290 | Description: 291 | A digit in a marked cell indicates the number of Odd/Even digits which 292 | appear along the attached line, including itself. 293 |

294 | 295 |

296 | Implement this by configuring a state machine in the 297 | "Custom JavaScript constraints" panel. Select the cells in the line 298 | so that the marked cell is first, the order of the rest don't matter. 299 | The following code implements Odd Lots, but change to the 300 | isEven function for Even Lots: 301 |

302 | startState = { remaining: null };
303 | 
304 | function transition(state, value) {
305 |   const isOdd = x => x%2 == 1;
306 |   const isEven = x => x%2 == 0;
307 | 
308 |   let remaining = state.remaining;
309 | 
310 |   // If we are starting, then initialize the count of remaining values.
311 |   if (remaining == null) remaining = value;
312 | 
313 |   // Update remaining count if we see a matching value.
314 |   if (isOdd(value)) remaining = remaining - 1;
315 | 
316 |   // Only return a new state if it is valid.
317 |   if (remaining >= 0) return { remaining: remaining };
318 | }
319 | 
320 | function accept(state) {
321 |   return state.remaining == 0;
322 | }
323 |

324 |
325 | 326 | 327 |

Constraint Sandbox

328 |
329 |

330 | For more complex constraint generation, use the 331 | Constraint Sandbox to write JavaScript code 332 | that programmatically creates constraints. 333 |

334 |

335 | The sandbox provides access to all constraint types and utility 336 | functions for working with cell IDs. You can generate constraints 337 | dynamically, parse and modify existing constraints, or create 338 | constraints that would be tedious to add manually. 339 |

340 |
341 |
342 |
343 | 344 | 345 | -------------------------------------------------------------------------------- /js/sudoku_parser.js: -------------------------------------------------------------------------------- 1 | const { SudokuConstraint, CompositeConstraintBase } = await import('./sudoku_constraint.js' + self.VERSION_PARAM); 2 | const { GridShape, SHAPE_9x9 } = await import('./grid_shape.js' + self.VERSION_PARAM); 3 | 4 | export class SudokuParser { 5 | static parseShortKillerFormat(text) { 6 | // Reference for format: 7 | // http://forum.enjoysudoku.com/understandable-snarfable-killer-cages-t6119.html 8 | 9 | const shape = SHAPE_9x9; 10 | const numCells = shape.numCells; 11 | const gridSize = shape.gridSize; 12 | 13 | if (text.length != numCells) return null; 14 | // Note: The second ` is just there so my syntax highlighter is happy. 15 | if (!text.match(/[^`',`]/)) return null; 16 | if (!text.match(/^[0-9A-Za-j^`'',.`]*$/)) return null; 17 | 18 | // Determine the cell directions. 19 | let cellDirections = []; 20 | for (let i = 0; i < numCells; i++) { 21 | switch (text[i]) { 22 | case 'v': 23 | cellDirections.push(i + gridSize); 24 | break; 25 | case '^': 26 | cellDirections.push(i - gridSize); 27 | break; 28 | case '<': 29 | cellDirections.push(i - 1); 30 | break; 31 | case '>': 32 | cellDirections.push(i + 1); 33 | break; 34 | case '`': 35 | cellDirections.push(i - gridSize - 1); 36 | break; 37 | case '\'': 38 | cellDirections.push(i - gridSize + 1); 39 | break; 40 | case ',': 41 | cellDirections.push(i + gridSize - 1); 42 | break; 43 | case '.': 44 | cellDirections.push(i + gridSize + 1); 45 | break; 46 | default: 47 | cellDirections.push(i); 48 | } 49 | } 50 | 51 | let cages = new Map(); 52 | for (let i = 0; i < numCells; i++) { 53 | let cageCell = i; 54 | let count = 0; 55 | while (cellDirections[cageCell] != cageCell) { 56 | cageCell = cellDirections[cageCell]; 57 | count++; 58 | if (count > gridSize) { 59 | throw ('Loop in Killer Sudoku input.'); 60 | } 61 | } 62 | if (!cages.has(cageCell)) { 63 | let c = text[cageCell]; 64 | let sum; 65 | if (c >= '0' && c <= '9') { 66 | sum = +c; 67 | } else if (c >= 'A' && c <= 'Z') { 68 | sum = c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; 69 | } else if (c >= 'a' && c <= 'j') { 70 | sum = c.charCodeAt(0) - 'a'.charCodeAt(0) + 36; 71 | } else { 72 | // Not a valid cage, ignore. 73 | continue; 74 | } 75 | cages.set(cageCell, { 76 | sum: sum, 77 | cells: [], 78 | }); 79 | } 80 | cages.get(cageCell).cells.push(shape.makeCellIdFromIndex(i)); 81 | } 82 | 83 | let constraints = []; 84 | for (const config of cages.values()) { 85 | constraints.push(new SudokuConstraint.Cage(config.sum, ...config.cells)); 86 | } 87 | return new SudokuConstraint.Set(constraints); 88 | } 89 | 90 | static parseLongKillerFormat(text) { 91 | // Reference to format definition: 92 | // http://www.sudocue.net/forum/viewtopic.php?f=1&t=519 93 | 94 | if (!text.startsWith('3x3:')) return null; 95 | 96 | const shape = SHAPE_9x9; 97 | const numCells = shape.numCells; 98 | 99 | let parts = text.split(':'); 100 | if (parts[2] != 'k') return null; 101 | if (parts.length != numCells + 4) return null; 102 | 103 | let cages = new Map(); 104 | for (let i = 0; i < numCells; i++) { 105 | let value = +parts[i + 3]; 106 | let cageId = value % 256; 107 | let cageSum = value / 256 | 0; 108 | 109 | if (!cageSum) continue; 110 | 111 | if (!cages.has(cageId)) { 112 | cages.set(cageId, { sum: cageSum, cells: [] }); 113 | } 114 | cages.get(cageId).cells.push(shape.makeCellIdFromIndex(i)); 115 | } 116 | 117 | let constraints = []; 118 | if (parts[1] == 'd') { 119 | constraints.push(new SudokuConstraint.Diagonal(1)); 120 | constraints.push(new SudokuConstraint.Diagonal(-1)); 121 | } 122 | for (const config of cages.values()) { 123 | constraints.push(new SudokuConstraint.Cage(config.sum, ...config.cells)); 124 | } 125 | return new SudokuConstraint.Set(constraints); 126 | } 127 | 128 | static parsePlainSudoku(text) { 129 | const shape = GridShape.fromNumCells(text.length); 130 | if (!shape) return null; 131 | 132 | const numCells = shape.numCells; 133 | const gridSize = shape.gridSize; 134 | 135 | const baseCharCode = GridShape.baseCharCode(shape); 136 | if (!baseCharCode) return null; 137 | 138 | let fixedValues = []; 139 | let nonValueCharacters = []; 140 | for (let i = 0; i < numCells; i++) { 141 | let c = text.charCodeAt(i); 142 | if (c >= baseCharCode && c <= baseCharCode + gridSize - 1) { 143 | fixedValues.push(shape.makeValueId(i, c - baseCharCode + 1)); 144 | } else { 145 | nonValueCharacters.push(c); 146 | } 147 | } 148 | if (new Set(nonValueCharacters).size > 1) return null; 149 | return new SudokuConstraint.Set([ 150 | new SudokuConstraint.Shape(shape.name), 151 | ...SudokuConstraint.Given.makeFromArgs(...fixedValues), 152 | ]); 153 | } 154 | 155 | static parseJigsawLayout(text) { 156 | const shape = GridShape.fromNumCells(text.length); 157 | if (!shape) return null; 158 | 159 | const numCells = shape.numCells; 160 | const gridSize = shape.gridSize; 161 | 162 | const chars = new Set(text); 163 | if (chars.size != gridSize) return null; 164 | 165 | const counter = {}; 166 | chars.forEach(c => counter[c] = 0); 167 | for (let i = 0; i < numCells; i++) { 168 | counter[text[i]]++; 169 | } 170 | 171 | if (Object.values(counter).some(c => c != gridSize)) return null; 172 | 173 | return new SudokuConstraint.Set([ 174 | new SudokuConstraint.Shape(shape.name), 175 | ...SudokuConstraint.Jigsaw.makeFromArgs(text), 176 | new SudokuConstraint.NoBoxes(), 177 | ]); 178 | } 179 | 180 | static parseJigsaw(text) { 181 | if (text.length % 2 !== 0) return null; 182 | 183 | const shape = GridShape.fromNumCells(text.length / 2); 184 | if (!shape) return null; 185 | 186 | const numCells = shape.numCells; 187 | 188 | const layout = this.parseJigsawLayout(text.substr(numCells)); 189 | if (layout == null) return null; 190 | 191 | const fixedValues = this.parsePlainSudoku(text.substr(0, numCells)); 192 | if (fixedValues == null) return null; 193 | 194 | return new SudokuConstraint.Set([layout, fixedValues]); 195 | } 196 | 197 | static parseSolution(text) { 198 | if (!text.startsWith('=')) return null; 199 | return this.parsePlainSudoku(text.substring(1)); 200 | } 201 | 202 | static parseGridLayout(rawText) { 203 | // Only allow digits, dots, spaces and separators. 204 | if (rawText.search(/[^\d\s.|_-]/) != -1) return null; 205 | 206 | const parts = [...rawText.matchAll(/[.]|\d+/g)]; 207 | const numParts = parts.length; 208 | 209 | const shape = GridShape.fromNumCells(numParts); 210 | if (!shape) return null; 211 | 212 | let fixedValues = []; 213 | for (let i = 0; i < numParts; i++) { 214 | const cell = parts[i]; 215 | if (cell == '.') continue; 216 | fixedValues.push(shape.makeValueId(i, cell)); 217 | } 218 | 219 | return new SudokuConstraint.Set([ 220 | new SudokuConstraint.Shape(shape.name), 221 | ...SudokuConstraint.Given.makeFromArgs(...fixedValues), 222 | ]); 223 | } 224 | 225 | static parsePencilmarks(text) { 226 | const shape = GridShape.fromNumPencilmarks(text.length); 227 | if (!shape) return null; 228 | 229 | // Only allow digits, and dots. 230 | if (text.search(/[^\d.]/) != -1) return null; 231 | 232 | const numValues = shape.numValues; 233 | 234 | // Split into segments of 9 characters. 235 | const pencilmarks = []; 236 | for (let i = 0; i < shape.numCells; i++) { 237 | const cellId = shape.makeCellIdFromIndex(i); 238 | const values = ( 239 | text.substr(i * numValues, numValues) 240 | .split('') 241 | .filter(c => c != '.') 242 | .join('_')); 243 | pencilmarks.push(`${cellId}_${values}`); 244 | } 245 | 246 | return new SudokuConstraint.Set([ 247 | new SudokuConstraint.Shape(shape.name), 248 | ...SudokuConstraint.Given.makeFromArgs(...pencilmarks), 249 | ]); 250 | } 251 | 252 | static parseTextLine(rawText) { 253 | // Remove all whitespace. 254 | const text = rawText.replace(/\s+/g, ''); 255 | 256 | let constraint; 257 | 258 | // Need this to avoid parsing this as a 1x1 grid. 259 | if (text.length === 1) return null; 260 | 261 | constraint = this.parseSolution(text); 262 | if (constraint) return constraint; 263 | 264 | constraint = this.parseShortKillerFormat(text); 265 | if (constraint) return constraint; 266 | 267 | constraint = this.parseLongKillerFormat(text); 268 | if (constraint) return constraint; 269 | 270 | constraint = this.parseJigsaw(text); 271 | if (constraint) return constraint; 272 | 273 | constraint = this.parseJigsawLayout(text); 274 | if (constraint) return constraint; 275 | 276 | constraint = this.parsePlainSudoku(text); 277 | if (constraint) return constraint; 278 | 279 | constraint = this.parseGridLayout(rawText); 280 | if (constraint) return constraint; 281 | 282 | constraint = this.parsePencilmarks(text); 283 | if (constraint) return constraint; 284 | 285 | return null; 286 | } 287 | 288 | static parseText(rawText) { 289 | const constraints = []; 290 | // Replace comment lines starting with # 291 | const uncommentedText = rawText.replace(/^#.*$/gm, ''); 292 | 293 | // Parse sections separated by a blank line separately, 294 | // and then merge their constraints. 295 | for (const part of uncommentedText.split(/\n\s*\n/)) { 296 | let constraint = this.parseTextLine(part); 297 | if (!constraint) { 298 | constraint = this.parseString(part); 299 | } 300 | constraints.push(constraint); 301 | } 302 | if (constraints.length == 1) return constraints[0]; 303 | return new SudokuConstraint.Set(constraints); 304 | } 305 | 306 | static _resolveCompositeConstraints(revConstraints, compositeClass) { 307 | // NOTE: The constraints are reversed so we can efficiently pop them off the 308 | // end. The result will be that everything is in the original order. 309 | 310 | const items = []; 311 | 312 | while (revConstraints.length) { 313 | const c = revConstraints.pop(); 314 | if (c.type === 'End') break; 315 | 316 | if (c.constructor.IS_COMPOSITE) { 317 | const resolvedComposite = this._resolveCompositeConstraints( 318 | revConstraints, c.constructor); 319 | if (compositeClass.CAN_ABSORB.includes(c.constructor.name)) { 320 | // We can directly add the sub-constraints to this composite. 321 | items.push(...resolvedComposite.constraints); 322 | } else { 323 | items.push(resolvedComposite); 324 | } 325 | } else { 326 | items.push(c); 327 | } 328 | } 329 | return new compositeClass(items); 330 | } 331 | 332 | static parseString(rawStr) { 333 | const str = rawStr.replace(/\s+/g, ''); 334 | let items = str.split('.'); 335 | if (items[0]) throw ( 336 | 'Invalid constraint string: Constraint must start with a ".".\n' + 337 | rawStr); 338 | items.shift(); 339 | 340 | const constraints = []; 341 | for (const item of items) { 342 | const args = item.split('~'); 343 | const type = args.shift() || SudokuConstraint.Given.name; 344 | const cls = SudokuConstraint[type]; 345 | if (!cls) { 346 | throw ('Unknown constraint type: ' + type); 347 | } 348 | const constraintParts = [...cls.makeFromArgs(...args)]; 349 | if (constraintParts.length > 1 350 | && CompositeConstraintBase.allowedConstraintClass(cls)) { 351 | // If this item was split into multiple constraints, then we wrap it 352 | // in an 'And' constraint, since they may need to be treated as a unit 353 | // when nested in an 'Or'. 354 | // We only need to do this for constraints that are allowed inside 355 | // composite constraints. 356 | constraints.push( 357 | new SudokuConstraint.And(), 358 | ...constraintParts, 359 | new SudokuConstraint.End()); 360 | } else { 361 | constraints.push(...constraintParts); 362 | } 363 | } 364 | 365 | return this._resolveCompositeConstraints( 366 | constraints.reverse(), SudokuConstraint.Set); 367 | } 368 | 369 | static extractConstraintTypes(str) { 370 | const types = str.matchAll(/[.]([^.~]+)/g); 371 | const uniqueTypes = new Set(); 372 | for (const type of types) { 373 | const value = type[1].trim(); 374 | if (SudokuConstraint[value]) { 375 | uniqueTypes.add(value); 376 | } 377 | } 378 | return [...uniqueTypes]; 379 | } 380 | } 381 | 382 | export const toShortSolution = (solution, shape) => { 383 | const baseCharCode = GridShape.baseCharCode(shape); 384 | const DEFAULT_VALUE = '.'; 385 | 386 | const result = new Array(solution.length).fill(DEFAULT_VALUE); 387 | 388 | for (let i = 0; i < solution.length; i++) { 389 | result[i] = String.fromCharCode(baseCharCode + solution[i] - 1); 390 | } 391 | return result.join(''); 392 | } -------------------------------------------------------------------------------- /tests/bench/util.bench.js: -------------------------------------------------------------------------------- 1 | import { ensureGlobalEnvironment } from '../helpers/test_env.js'; 2 | import { bench, benchGroup, runIfMain } from './bench_harness.js'; 3 | 4 | ensureGlobalEnvironment(); 5 | 6 | const { 7 | Base64Codec, 8 | BitSet, 9 | RandomIntGenerator, 10 | arrayDifference, 11 | arrayIntersect, 12 | arrayIntersectSize, 13 | arrayRemoveValue, 14 | arraysAreEqual, 15 | countOnes16bit, 16 | memoize, 17 | requiredBits, 18 | setDifference, 19 | setIntersectSize, 20 | setIntersectionToArray, 21 | setPeek, 22 | } = await import('../../js/util.js' + self.VERSION_PARAM); 23 | 24 | // Keep inputs deterministic and avoid allocations inside timed sections. 25 | 26 | let sink = 0; 27 | const consume = (x) => { sink ^= (x | 0); }; 28 | 29 | const makeLCG = (seed) => { 30 | let s = seed >>> 0; 31 | return () => { 32 | s = (1664525 * s + 1013904223) >>> 0; 33 | return s; 34 | }; 35 | }; 36 | 37 | benchGroup('util::bitset', () => { 38 | const CAPACITY = 256; 39 | const wordCount = BitSet._wordCountFor(CAPACITY); 40 | 41 | const rng = makeLCG(0xC0FFEE); 42 | const indexes = (() => { 43 | const arr = new Uint16Array(4096); 44 | for (let i = 0; i < arr.length; i++) arr[i] = rng() % CAPACITY; 45 | return arr; 46 | })(); 47 | 48 | { 49 | const bs = new BitSet(CAPACITY); 50 | let i = 0; 51 | bench('add(index)', () => { 52 | bs.add(indexes[i++ % indexes.length]); 53 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 54 | } 55 | 56 | { 57 | const bs = new BitSet(CAPACITY); 58 | let i = 0; 59 | bench('remove(index)', () => { 60 | bs.remove(indexes[i++ % indexes.length]); 61 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 62 | } 63 | 64 | { 65 | const bs = new BitSet(CAPACITY); 66 | // Seed with some bits set. 67 | for (let k = 0; k < CAPACITY; k += 3) bs.add(k); 68 | 69 | let i = 0; 70 | bench('has(index)', () => { 71 | consume(bs.has(indexes[i++ % indexes.length])); 72 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 73 | } 74 | 75 | { 76 | const bs = new BitSet(CAPACITY); 77 | for (let k = 0; k < CAPACITY; k += 2) bs.add(k); 78 | bench('isEmpty()', () => { 79 | consume(bs.isEmpty()); 80 | }, { innerIterations: 500_000, minSampleTimeMs: 25 }); 81 | } 82 | 83 | { 84 | const bs = new BitSet(CAPACITY); 85 | for (let k = 0; k < CAPACITY; k += 2) bs.add(k); 86 | bench('clear()', () => { 87 | bs.clear(); 88 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 89 | } 90 | 91 | { 92 | const a = new BitSet(CAPACITY); 93 | const b = new BitSet(CAPACITY); 94 | for (let k = 0; k < CAPACITY; k += 2) a.add(k); 95 | for (let k = 0; k < CAPACITY; k += 3) b.add(k); 96 | 97 | bench('union(other)', () => { 98 | a.union(b); 99 | consume(a.words[0]); 100 | // Restore a to stable baseline. 101 | a.words.fill(0); 102 | for (let k = 0; k < CAPACITY; k += 2) a.add(k); 103 | }, { innerIterations: 50_000, minSampleTimeMs: 25 }); 104 | } 105 | 106 | { 107 | const a = new BitSet(CAPACITY); 108 | const b = new BitSet(CAPACITY); 109 | for (let k = 0; k < CAPACITY; k += 2) a.add(k); 110 | for (let k = 0; k < CAPACITY; k += 3) b.add(k); 111 | 112 | bench('intersect(other)', () => { 113 | a.intersect(b); 114 | consume(a.words[0]); 115 | // Restore a to stable baseline. 116 | a.words.fill(0); 117 | for (let k = 0; k < CAPACITY; k += 2) a.add(k); 118 | }, { innerIterations: 50_000, minSampleTimeMs: 25 }); 119 | } 120 | 121 | { 122 | const a = new BitSet(CAPACITY); 123 | const b = new BitSet(CAPACITY); 124 | for (let k = 0; k < CAPACITY; k += 2) a.add(k); 125 | for (let k = 0; k < CAPACITY; k += 3) b.add(k); 126 | 127 | bench('copyFrom(other)', () => { 128 | a.copyFrom(b); 129 | consume(a.words[wordCount - 1]); 130 | }, { innerIterations: 200_000, minSampleTimeMs: 25 }); 131 | } 132 | 133 | { 134 | const words = new Uint32Array(wordCount); 135 | for (let i = 0; i < wordCount; i++) words[i] = rng(); 136 | 137 | let wi = 0; 138 | bench('bitIndex(word, lowbit)', () => { 139 | const w = words[wi++ % words.length] || 1; 140 | const low = w & -w; 141 | consume(BitSet.bitIndex(wi & 7, low)); 142 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 143 | } 144 | }); 145 | 146 | benchGroup('util::base64', () => { 147 | const rng = makeLCG(0xBADC0DE); 148 | 149 | const make6BitArray = (len) => { 150 | const arr = new Uint8Array(len); 151 | for (let i = 0; i < len; i++) arr[i] = rng() & 63; 152 | return arr; 153 | }; 154 | 155 | const a54 = make6BitArray(54); 156 | const a216 = make6BitArray(216); 157 | const out54 = new Uint8Array(54); 158 | const out216 = new Uint8Array(216); 159 | 160 | const s54 = Base64Codec.encode6BitArray(Array.from(a54)); 161 | const s216 = Base64Codec.encode6BitArray(Array.from(a216)); 162 | 163 | { 164 | bench('decodeTo6BitArray(len=54)', () => { 165 | const out = Base64Codec.decodeTo6BitArray(s54, out54); 166 | consume(out[0]); 167 | }, { innerIterations: 400_000, minSampleTimeMs: 25 }); 168 | } 169 | 170 | { 171 | bench('decodeTo6BitArray(len=216)', () => { 172 | const out = Base64Codec.decodeTo6BitArray(s216, out216); 173 | consume(out[0]); 174 | }, { innerIterations: 200_000, minSampleTimeMs: 25 }); 175 | } 176 | 177 | { 178 | const arr = Array.from(a54); 179 | bench('encode6BitArray(len=54)', () => { 180 | const s = Base64Codec.encode6BitArray(arr); 181 | consume(s.length); 182 | }, { innerIterations: 150_000, minSampleTimeMs: 25 }); 183 | } 184 | 185 | { 186 | const arr = Array.from(a216); 187 | bench('encode6BitArray(len=216)', () => { 188 | const s = Base64Codec.encode6BitArray(arr); 189 | consume(s.length); 190 | }, { innerIterations: 60_000, minSampleTimeMs: 25 }); 191 | } 192 | 193 | { 194 | const binary = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='.repeat(4); 195 | bench('encodeString(len~256)', () => { 196 | const s = Base64Codec.encodeString(binary); 197 | consume(s.length); 198 | }, { innerIterations: 50_000, minSampleTimeMs: 25 }); 199 | } 200 | 201 | { 202 | const binary = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='.repeat(4); 203 | const encoded = Base64Codec.encodeString(binary); 204 | bench('decodeToString(len~256)', () => { 205 | const s = Base64Codec.decodeToString(encoded); 206 | consume(s.length); 207 | }, { innerIterations: 50_000, minSampleTimeMs: 25 }); 208 | } 209 | }); 210 | 211 | benchGroup('util::memoize', () => { 212 | // Hit path (single-arg key) is used heavily by LookupTables.get. 213 | const f1 = memoize((x) => x + 1); 214 | f1(123); 215 | 216 | bench('memoize(hit, 1 arg)', () => { 217 | consume(f1(123)); 218 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 219 | 220 | // Miss path (single arg). 221 | let miss = 0; 222 | const fMiss = memoize((x) => x ^ 0x5a5a5a5a); 223 | bench('memoize(miss, 1 arg)', () => { 224 | consume(fMiss(miss++)); 225 | }, { innerIterations: 200_000, minSampleTimeMs: 25 }); 226 | 227 | // Multi-arg key uses JSON.stringify; useful to quantify. 228 | const f2 = memoize((a, b, c) => a + b + c); 229 | f2(1, 2, 3); 230 | 231 | bench('memoize(hit, 3 args)', () => { 232 | consume(f2(1, 2, 3)); 233 | }, { innerIterations: 300_000, minSampleTimeMs: 25 }); 234 | 235 | let miss2 = 0; 236 | bench('memoize(miss, 3 args)', () => { 237 | consume(f2(miss2++, 2, 3)); 238 | }, { innerIterations: 50_000, minSampleTimeMs: 25 }); 239 | }); 240 | 241 | benchGroup('util::math', () => { 242 | const rng = makeLCG(0xFEEDFACE); 243 | const xs = (() => { 244 | const arr = new Uint32Array(4096); 245 | for (let i = 0; i < arr.length; i++) arr[i] = rng(); 246 | return arr; 247 | })(); 248 | 249 | { 250 | let i = 0; 251 | bench('countOnes16bit(x)', () => { 252 | consume(countOnes16bit(xs[i++ % xs.length] & 0xffff)); 253 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 254 | } 255 | 256 | { 257 | let i = 0; 258 | bench('requiredBits(x)', () => { 259 | consume(requiredBits(xs[i++ % xs.length] | 1)); 260 | }, { innerIterations: 3_000_000, minSampleTimeMs: 25 }); 261 | } 262 | }); 263 | 264 | benchGroup('util::array', () => { 265 | const rng = makeLCG(0xA11A11A1); 266 | 267 | const makeArray = (len, maxVal) => { 268 | const arr = new Array(len); 269 | for (let i = 0; i < len; i++) arr[i] = rng() % maxVal; 270 | return arr; 271 | }; 272 | 273 | // Two arrays with partial overlap. 274 | const a64 = makeArray(64, 128); 275 | const b64 = makeArray(64, 128); 276 | 277 | const a256 = makeArray(256, 512); 278 | const b256 = makeArray(256, 512); 279 | 280 | // Equality cases. 281 | const eq128a = makeArray(128, 256); 282 | const eq128b = eq128a.slice(); 283 | const ne128 = eq128a.slice(); 284 | ne128[ne128.length >> 1] ^= 1; 285 | 286 | { 287 | bench('arrayIntersect(len=64)', () => { 288 | consume(arrayIntersect(a64, b64).length); 289 | }, { innerIterations: 40_000, minSampleTimeMs: 25 }); 290 | } 291 | 292 | { 293 | bench('arrayDifference(len=64)', () => { 294 | consume(arrayDifference(a64, b64).length); 295 | }, { innerIterations: 40_000, minSampleTimeMs: 25 }); 296 | } 297 | 298 | { 299 | bench('arrayIntersectSize(len=64)', () => { 300 | consume(arrayIntersectSize(a64, b64)); 301 | }, { innerIterations: 80_000, minSampleTimeMs: 25 }); 302 | } 303 | 304 | { 305 | bench('arrayIntersect(len=256)', () => { 306 | consume(arrayIntersect(a256, b256).length); 307 | }, { innerIterations: 2_500, minSampleTimeMs: 25 }); 308 | } 309 | 310 | { 311 | bench('arrayDifference(len=256)', () => { 312 | consume(arrayDifference(a256, b256).length); 313 | }, { innerIterations: 2_500, minSampleTimeMs: 25 }); 314 | } 315 | 316 | { 317 | bench('arrayIntersectSize(len=256)', () => { 318 | consume(arrayIntersectSize(a256, b256)); 319 | }, { innerIterations: 5_000, minSampleTimeMs: 25 }); 320 | } 321 | 322 | { 323 | bench('arraysAreEqual(equal, len=128)', () => { 324 | consume(arraysAreEqual(eq128a, eq128b)); 325 | }, { innerIterations: 200_000, minSampleTimeMs: 25 }); 326 | } 327 | 328 | { 329 | bench('arraysAreEqual(not equal, len=128)', () => { 330 | consume(arraysAreEqual(eq128a, ne128)); 331 | }, { innerIterations: 200_000, minSampleTimeMs: 25 }); 332 | } 333 | 334 | { 335 | // Avoid per-iteration allocations by mutating and restoring. 336 | const pool = (() => { 337 | const xs = new Array(256); 338 | for (let i = 0; i < xs.length; i++) { 339 | const arr = makeArray(64, 256); 340 | // Ensure 'needle' exists. 341 | arr[32] = 123; 342 | xs[i] = arr; 343 | } 344 | return xs; 345 | })(); 346 | 347 | let i = 0; 348 | bench('arrayRemoveValue(found, len=64)', () => { 349 | const arr = pool[i++ & (pool.length - 1)]; 350 | arrayRemoveValue(arr, 123); 351 | // Restore so the next iteration is comparable. 352 | arr.push(123); 353 | consume(arr.length); 354 | }, { innerIterations: 200_000, minSampleTimeMs: 25 }); 355 | } 356 | }); 357 | 358 | benchGroup('util::set', () => { 359 | const rng = makeLCG(0x5E7BEEF); 360 | 361 | const makeSet = (size, maxVal) => { 362 | const s = new Set(); 363 | while (s.size < size) s.add(rng() % maxVal); 364 | return s; 365 | }; 366 | 367 | const makeArray = (len, maxVal) => { 368 | const arr = new Array(len); 369 | for (let i = 0; i < len; i++) arr[i] = rng() % maxVal; 370 | return arr; 371 | }; 372 | 373 | const setA = makeSet(256, 1024); 374 | const iterB64 = makeArray(64, 1024); 375 | const iterB256 = makeArray(256, 1024); 376 | 377 | { 378 | bench('setIntersectSize(iter len=64)', () => { 379 | consume(setIntersectSize(setA, iterB64)); 380 | }, { innerIterations: 150_000, minSampleTimeMs: 25 }); 381 | } 382 | 383 | { 384 | bench('setIntersectionToArray(iter len=64)', () => { 385 | consume(setIntersectionToArray(setA, iterB64).length); 386 | }, { innerIterations: 80_000, minSampleTimeMs: 25 }); 387 | } 388 | 389 | { 390 | bench('setIntersectSize(iter len=256)', () => { 391 | consume(setIntersectSize(setA, iterB256)); 392 | }, { innerIterations: 40_000, minSampleTimeMs: 25 }); 393 | } 394 | 395 | { 396 | bench('setIntersectionToArray(iter len=256)', () => { 397 | consume(setIntersectionToArray(setA, iterB256).length); 398 | }, { innerIterations: 20_000, minSampleTimeMs: 25 }); 399 | } 400 | 401 | { 402 | const setB = makeSet(256, 1024); 403 | bench('setDifference(set,size=256)', () => { 404 | consume(setDifference(setA, setB).size); 405 | }, { innerIterations: 10_000, minSampleTimeMs: 25 }); 406 | } 407 | 408 | { 409 | bench('setPeek(size=256)', () => { 410 | const v = setPeek(setA); 411 | consume((v ?? 0) | 0); 412 | }, { innerIterations: 2_000_000, minSampleTimeMs: 25 }); 413 | } 414 | }); 415 | 416 | benchGroup('util::rng', () => { 417 | const rng = new RandomIntGenerator(123456); 418 | 419 | bench('RandomIntGenerator._next()', () => { 420 | consume(rng._next()); 421 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 422 | 423 | bench('RandomIntGenerator.randomInt(max=255)', () => { 424 | consume(rng.randomInt(255)); 425 | }, { innerIterations: 1_000_000, minSampleTimeMs: 25 }); 426 | }); 427 | 428 | export const _benchSink = () => sink; 429 | await runIfMain(import.meta.url); 430 | --------------------------------------------------------------------------------