Compare commits

..

8 Commits

Author SHA1 Message Date
9ee76b10fa Add USE_WORKER_THREADS environment var 2021-03-05 12:56:20 -07:00
15b69c510c Add solve endpoint
Also implement currently unused solveSync and generateSync methods,
useful for testing/debugging.

Add static get() to SudokuMath which returns cached SudokuMath
instances.
2021-03-05 12:32:02 -07:00
4e6cdab9ee Return blank array when requested clues == 0 2021-03-04 21:35:25 -07:00
8526dcd083 optimize generate()
Before we masked all except the removed cell rows, then unmasked all
rows on every removal attempt.

Now, we mask all except the removed cell rows once each attempt in an
order such that we can simply unmask the last (values - 1) rows to try
the next cell. An unmask/remask cycle is only required when a removal
attempt fails.
2021-03-03 23:15:55 -07:00
938954d621 Log the number of dancing link updates 2021-03-03 18:18:15 -07:00
058dc13c1c Simplified selectColumnSizeHeuristic 2021-02-25 11:30:20 -07:00
18e468b17f Update Dockerfile 2021-02-25 11:30:20 -07:00
24d8ab6763 Update all imports to .js for esm support, use threads.js
threads.js has better support for modules - no need to give a
project-relative path to the worker file, which complicated the build.

Add rudimentary thread pooling w/ execution timeout.
2021-02-25 11:30:20 -07:00
6 changed files with 279 additions and 108 deletions

View File

@ -1,5 +1,5 @@
import { gql } from "../mods.js"; import { gql } from "../mods.js";
import { generate, GenerateArguments } from "../sudoku/index.js"; import { Sudoku, GenerateArguments, SolveArguments } from "../sudoku/index.js";
export const typeDefs = gql` export const typeDefs = gql`
""" """
@ -22,16 +22,22 @@ export const typeDefs = gql`
type Query { type Query {
"Generates a new sudoku." "Generates a new sudoku."
generate(regionWidth: Int = 3, regionHeight: Int = 3, clues: Int!): Sudoku! generate(regionWidth: Int = 3, regionHeight: Int = 3, clues: Int!): Sudoku!
"Solves the given sudoku."
solve(regionWidth: Int = 3, regionHeight: Int = 3, cells: [Int!]!): Sudoku!
} }
`; `;
interface SudokuFuncs {
solve(args: SolveArguments): Promise<Sudoku> | Sudoku;
generate(args: GenerateArguments): Promise<Sudoku> | Sudoku;
}
export const resolvers = { export const resolvers = {
Query: { Query: {
generate: ( generate: (obj: any, args: GenerateArguments, ctx: SudokuFuncs) =>
obj: any, ctx.generate(args),
{ regionWidth, regionHeight, clues }: GenerateArguments solve: (obj: any, args: SolveArguments, ctx: SudokuFuncs) =>
) => { ctx.solve(args),
return generate(regionWidth, regionHeight, clues);
},
}, },
}; };

View File

@ -1,6 +1,13 @@
import { Application, bodyParser } from "./mods.js"; import { Application, bodyParser } from "./mods.js";
import { applyGraphQL } from "./graphql.js"; import { applyGraphQL } from "./graphql.js";
import { typeDefs, resolvers } from "./graphql/index.js"; import { typeDefs, resolvers } from "./graphql/index.js";
import {
initializeWorkers,
solve,
generate,
solveSync,
generateSync,
} from "./sudoku/index.js";
import stoppable from "stoppable"; import stoppable from "stoppable";
import cors from "@koa/cors"; import cors from "@koa/cors";
@ -31,10 +38,25 @@ async function main() {
); );
}); });
let sudokuFuncs: any;
if (process.env.USE_WORKER_THREADS) {
initializeWorkers();
sudokuFuncs = {
solve,
generate,
};
} else {
sudokuFuncs = {
solve: solveSync,
generate: generateSync,
};
}
applyGraphQL({ applyGraphQL({
app, app,
typeDefs: typeDefs, typeDefs: typeDefs,
resolvers: resolvers, resolvers: resolvers,
context: () => sudokuFuncs,
}); });
runtime.server = stoppable( runtime.server = stoppable(

View File

@ -114,10 +114,10 @@ export class DLX {
let row = c as DNode; let row = c as DNode;
while ((row = row.down) !== c) { while ((row = row.down) !== c) {
// traverse DOWN the rows this column contained // traverse down the rows this column
let col = row; let col = row;
while ((col = col.right) !== row) { while ((col = col.right) !== row) {
// traverse the columns of this row (to the RIGHT) // traverse the columns of this row to the right
// remove this node from its column, and shrink its column's size // remove this node from its column, and shrink its column's size
this.updates++; this.updates++;

View File

@ -1,7 +1,9 @@
import { spawn, Thread, Worker } from "threads"; import { spawn, Thread, Worker } from "threads";
import type { ModuleThread } from "threads";
import { SudokuMath } from "./math.js";
import { prettyPrint } from "./util.js";
import WORKERS from "physical-cpu-count"; import WORKERS from "physical-cpu-count";
import { prettyPrint } from "./util.js";
const TIMEOUT = 20000; const TIMEOUT = 20000;
@ -13,6 +15,12 @@ export type GenerateArguments = {
clues: number; clues: number;
}; };
export type SolveArguments = {
regionWidth: number;
regionHeight: number;
cells: Cell[];
};
export type Sudoku = { export type Sudoku = {
regionWidth: number; regionWidth: number;
regionHeight: number; regionHeight: number;
@ -20,21 +28,51 @@ export type Sudoku = {
cells: Cell[]; cells: Cell[];
}; };
function getWorker() { type SudokuWorker = {
return spawn(new Worker("./worker")); generate: (
regionWidth: number,
regionHeight: number,
clues: number
) => Promise<number[]>;
solve: (
regionWidth: number,
regionHeight: number,
cells: number[]
) => Promise<[boolean, number[]]>;
};
const available: ModuleThread<SudokuWorker>[] = [];
function spawnWorker() {
spawn<SudokuWorker>(new Worker("./worker")).then((worker) =>
available.push(worker)
);
} }
const available: any = []; export function initializeWorkers() {
function initialize() {
console.log(`Starting ${WORKERS} worker threads`); console.log(`Starting ${WORKERS} worker threads`);
for (let n = 0; n < WORKERS; n++) { for (let n = 0; n < WORKERS; n++) {
getWorker().then((worker) => available.push(worker)); spawnWorker();
} }
} }
initialize();
function withTimeout<T>(promise: Promise<T>, ms: number, cb: () => Error) { function pickWorker() {
const proxy = available.pop();
if (!proxy) {
throw new Error("No workers available right now. Please try again.");
}
return proxy;
}
/**
* Awaits a promise with a timeout.
*
* @param promise the promise to await
* @param ms the timeout in milliseconds
* @param cb a callback to call when the timeout is reached. The promise is
* rejected with whatever gets returned here.
*/
function withTimeout<T>(promise: Promise<T>, ms: number, cb: () => any) {
let timeout: NodeJS.Timeout; let timeout: NodeJS.Timeout;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
timeout = setTimeout(() => { timeout = setTimeout(() => {
@ -44,28 +82,33 @@ function withTimeout<T>(promise: Promise<T>, ms: number, cb: () => Error) {
}).finally(() => clearTimeout(timeout!)); }).finally(() => clearTimeout(timeout!));
} }
export async function generate( function workerTaskWithTimeout(
regionWidth: number, task: (worker: ModuleThread) => Promise<any>,
regionHeight: number, timeout: number
clues: number ) {
): Promise<Sudoku> { const worker = pickWorker();
const proxy = available.shift(); let timedOut = false;
if (!proxy) { return withTimeout(task(worker), timeout, () => {
throw new Error("No workers available right now. Please try again."); timedOut = true;
Thread.terminate(worker);
spawnWorker();
return new Error("Timed out.");
}).finally(() => {
if (!timedOut) {
available.push(worker);
} }
});
}
try { export async function generate({
const puzzle: number[] = await withTimeout( regionWidth,
proxy.generate(regionWidth, regionHeight, clues), regionHeight,
TIMEOUT, clues,
() => { }: GenerateArguments): Promise<Sudoku> {
Thread.terminate(proxy); const puzzle = await workerTaskWithTimeout(
getWorker().then((worker) => available.push(worker)); (worker) => worker.generate(regionWidth, regionHeight, clues),
return new Error("Timed out. Try reducing the number of clues."); TIMEOUT
}
); );
available.unshift(proxy);
prettyPrint(regionWidth, regionHeight, puzzle); prettyPrint(regionWidth, regionHeight, puzzle);
return { return {
regionWidth, regionWidth,
@ -73,7 +116,68 @@ export async function generate(
size: (regionWidth * regionHeight) ** 2, size: (regionWidth * regionHeight) ** 2,
cells: puzzle, cells: puzzle,
}; };
} catch (err) { }
throw err;
} export function generateSync({
regionWidth,
regionHeight,
clues,
}: GenerateArguments): Sudoku {
const math = SudokuMath.get(regionWidth, regionHeight);
const cells = math.generate(clues, 1, TIMEOUT);
prettyPrint(regionWidth, regionHeight, cells);
return {
regionWidth,
regionHeight,
size: (regionWidth * regionHeight) ** 2,
cells,
};
}
export async function solve({
regionWidth,
regionHeight,
cells,
}: SolveArguments): Promise<Sudoku> {
const size = (regionWidth * regionHeight) ** 2;
if (size !== cells.length) {
throw new Error(
"The given region dimensions do not align with the number of cells."
);
}
const [solved, result] = await workerTaskWithTimeout(
(proxy) => proxy.solve(regionWidth, regionHeight, cells),
TIMEOUT
);
if (!solved) {
throw new Error("The given puzzle has no solution.");
}
return {
regionWidth,
regionHeight,
size,
cells: result,
};
}
export function solveSync({
regionWidth,
regionHeight,
cells,
}: SolveArguments): Sudoku {
const size = (regionWidth * regionHeight) ** 2;
if (size !== cells.length) {
throw new Error(
"The given region dimensions do not align with the number of cells."
);
}
if (!SudokuMath.get(regionWidth, regionHeight).solve(cells)) {
throw new Error("The given puzzle has no solution.");
}
return {
regionWidth,
regionHeight,
size,
cells,
};
} }

View File

@ -141,27 +141,28 @@ export class SudokuMath {
return [...firstRow, ...Array(this.values2 - this.values).fill(0)]; return [...firstRow, ...Array(this.values2 - this.values).fill(0)];
} }
generateComplete() { generateComplete(): [number[], number] {
const result = this._baseBoard(); const result = this._baseBoard();
const [header] = this.getDLXHeader(result, true); const [header] = this.getDLXHeader(result, true);
const dlx = new DLX(header, (solution: DNode[]) => {
const callback = (solution: DNode[]) => {
solution.forEach((node) => { solution.forEach((node) => {
const meta: NodeMeta = node.meta; const meta: NodeMeta = node.meta;
result[meta.index] = meta.value; result[meta.index] = meta.value;
}); });
// return the first solution // stop after the first solution
return true; return true;
}; });
const dlx = new DLX(header, callback);
dlx.search(); dlx.search();
return result; return [result, dlx.updates];
} }
generate(clues: number, attempts = Infinity, totalTime = Infinity) { generate(clues: number, attempts = Infinity, totalTime = Infinity) {
const completed = this.generateComplete(); if (clues === 0) {
return Array(this.values2).fill(0);
}
let [completed, updates] = this.generateComplete();
const [header, dlxRows] = this.getDLXHeader(); // complete header - no candidates removed const [header, dlxRows] = this.getDLXHeader(); // complete header - no candidates removed
@ -176,61 +177,93 @@ export class SudokuMath {
candidates[meta.index][meta.value - 1] = node; candidates[meta.index][meta.value - 1] = node;
}); });
// board positions which have been removed
const removed = new Set<number>();
const masked: DNode[] = [];
const hasOneSolution = () => { const hasOneSolution = () => {
solutions = 0; solutions = 0;
dlx.search(); dlx.search();
updates += dlx.updates;
return solutions === 1; return solutions === 1;
}; };
const mask = () => {
// mask all DLX rows which are nullified by existing values
for (let n = 0; n < this.values2; n++) {
if (removed.has(n)) {
continue;
}
const nodes = candidates[n];
nodes.forEach((node) => {
if (node.meta.value !== completed[n]) {
masked.push(node);
maskRow(node);
}
});
}
};
const unmask = () => {
// unmask all DLX rows
while (masked.length > 0) {
unmaskRow(masked.pop()!);
}
};
const start = Date.now(); const start = Date.now();
const elapsed = () => Date.now() - start; const elapsed = () => Date.now() - start;
// masked rows in the order they were masked
const masked: DNode[] = [];
const maskAtIdx = (idx: number) => {
const nodes = candidates[idx];
nodes.forEach((node) => {
if (node.meta.value !== completed[idx]) {
masked.push(node);
maskRow(node);
}
});
};
const removed = new Set<number>();
const removeable = Array.from(this.indexes); const removeable = Array.from(this.indexes);
const maskUpto = (n: number) => {
for (let j = 0; j < n; j++) {
// process up to what we've handled
const idx = removeable[j];
if (removed.has(idx)) {
// if we've removed this cell, do not mask it
continue;
}
// otherwise, we had given up it; mask it leaving the original value
// we won't try this cell again
maskAtIdx(idx);
}
for (let j = this.values2 - 1; j >= n; j--) {
// for all those we haven't handled, mask leaving the original values
// for us to to attempt to unmask one cell at a time
maskAtIdx(removeable[j]);
}
};
const unmaskAll = () => {
let node;
while ((node = masked.pop())) {
unmaskRow(node);
}
};
const unmaskNextCell = () => {
for (let j = 0; j < this.values - 1; j++) {
unmaskRow(masked.pop()!);
}
};
let best = 0;
const attempt = () => { const attempt = () => {
// attempt remove cells until 'clues' cells remain // attempt to remove cells until 'clues' cells remain
attempts--;
removed.clear();
shuffle(removeable); shuffle(removeable);
for (let n = 0; n < this.values2; n++) { maskUpto(0);
for (let i = 0; i < this.values2; i++) {
if (elapsed() > totalTime || this.values2 - removed.size == clues) { if (elapsed() > totalTime || this.values2 - removed.size == clues) {
break; break;
} }
unmaskNextCell();
let toRemove = removeable[n];
removed.add(toRemove);
mask();
if (!hasOneSolution()) { if (!hasOneSolution()) {
removed.delete(toRemove); // failed attempt. prepare for next
unmaskAll();
maskUpto(i + 1);
continue;
} }
unmask(); removed.add(removeable[i]);
} }
if (removed.size > best) {
best = removed.size;
}
unmaskAll();
}; };
while ( while (
@ -240,8 +273,6 @@ export class SudokuMath {
) { ) {
// try to reach the clue goal up to `attempts` times or as long as // try to reach the clue goal up to `attempts` times or as long as
// elapsed time is less than `totalTime` // elapsed time is less than `totalTime`
attempts--;
removed.clear();
attempt(); attempt();
} }
@ -251,16 +282,28 @@ export class SudokuMath {
return completed; return completed;
} }
solve(existing: Cell[]): void { solve(puzzle: Cell[]) {
const [header] = this.getDLXHeader(existing); const [header] = this.getDLXHeader(puzzle);
let solved = false;
const callback = (solution: DNode[]) => { const callback = (solution: DNode[]) => {
solution.forEach((node) => { solution.forEach((node) => {
const meta: NodeMeta = node.meta; const meta: NodeMeta = node.meta;
existing[meta.index] = meta.value; puzzle[meta.index] = meta.value;
}); });
solved = true;
// return the first solution // return the first solution
return true; return true;
}; };
new DLX(header, callback).search(); new DLX(header, callback).search();
return solved;
}
static maths: { [key: string]: SudokuMath } = {};
static get(regionWidth: number, regionHeight: number) {
const key = `${regionWidth}:${regionHeight}`;
return (
SudokuMath.maths[key] ??
(SudokuMath.maths[key] = new SudokuMath(regionWidth, regionHeight))
);
} }
} }

View File

@ -1,16 +1,12 @@
import { SudokuMath } from "./math.js"; import { SudokuMath } from "./math.js";
import { expose } from "threads/worker"; import { expose } from "threads/worker";
const maths = {};
expose({ expose({
generate(regionWidth, regionHeight, clues) { generate(regionWidth, regionHeight, clues) {
const math = return SudokuMath.get(regionWidth, regionHeight).generate(clues);
maths[`${regionWidth}:${regionHeight}`] || },
(maths[`${regionWidth}:${regionHeight}`] = new SudokuMath( solve(regionWidth, regionHeight, cells) {
regionWidth, const result = SudokuMath.get(regionWidth, regionHeight).solve(cells);
regionHeight return [result, cells];
));
return math.generate(clues);
}, },
}); });