Compare commits

..

3 Commits

Author SHA1 Message Date
3c6d69c343 Simplified selectColumnSizeHeuristic 2021-02-25 11:16:16 -07:00
1ff71d099e Update Dockerfile 2021-02-25 11:16:16 -07:00
ae9016fbf6 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:16:16 -07:00
6 changed files with 94 additions and 261 deletions

View File

@ -1,5 +1,5 @@
import { gql } from "../mods.js"; import { gql } from "../mods.js";
import { Sudoku, GenerateArguments, SolveArguments } from "../sudoku/index.js"; import { generate, GenerateArguments } from "../sudoku/index.js";
export const typeDefs = gql` export const typeDefs = gql`
""" """
@ -22,22 +22,16 @@ 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: (obj: any, args: GenerateArguments, ctx: SudokuFuncs) => generate: (
ctx.generate(args), obj: any,
solve: (obj: any, args: SolveArguments, ctx: SudokuFuncs) => { regionWidth, regionHeight, clues }: GenerateArguments
ctx.solve(args), ) => {
return generate(regionWidth, regionHeight, clues);
},
}, },
}; };

View File

@ -1,13 +1,6 @@
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";
@ -38,25 +31,10 @@ 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 // traverse DOWN the rows this column contained
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,9 +1,7 @@
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;
@ -15,12 +13,6 @@ 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;
@ -28,41 +20,19 @@ export type Sudoku = {
cells: Cell[]; cells: Cell[];
}; };
type SudokuWorker = { function getWorker() {
generate: ( return spawn(new Worker("./worker"));
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)
);
} }
export function initializeWorkers() { const available: any = [];
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++) {
spawnWorker(); getWorker().then((worker) => available.push(worker));
} }
} }
initialize();
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. * Awaits a promise with a timeout.
@ -82,33 +52,27 @@ function withTimeout<T>(promise: Promise<T>, ms: number, cb: () => any) {
}).finally(() => clearTimeout(timeout!)); }).finally(() => clearTimeout(timeout!));
} }
function workerTaskWithTimeout( export async function generate(
task: (worker: ModuleThread) => Promise<any>, regionWidth: number,
timeout: number regionHeight: number,
) { clues: number
const worker = pickWorker(); ): Promise<Sudoku> {
let timedOut = false; const proxy = available.pop();
return withTimeout(task(worker), timeout, () => { if (!proxy) {
timedOut = true; throw new Error("No workers available right now. Please try again.");
Thread.terminate(worker); }
spawnWorker();
return new Error("Timed out.");
}).finally(() => {
if (!timedOut) {
available.push(worker);
}
});
}
export async function generate({ const puzzle = await withTimeout<number[]>(
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.push(proxy);
prettyPrint(regionWidth, regionHeight, puzzle); prettyPrint(regionWidth, regionHeight, puzzle);
return { return {
regionWidth, regionWidth,
@ -117,67 +81,3 @@ export async function generate({
cells: puzzle, cells: puzzle,
}; };
} }
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,28 +141,27 @@ export class SudokuMath {
return [...firstRow, ...Array(this.values2 - this.values).fill(0)]; return [...firstRow, ...Array(this.values2 - this.values).fill(0)];
} }
generateComplete(): [number[], number] { generateComplete() {
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;
}); });
// stop after the first solution // return the first solution
return true; return true;
}); };
const dlx = new DLX(header, callback);
dlx.search(); dlx.search();
return [result, dlx.updates]; return result;
} }
generate(clues: number, attempts = Infinity, totalTime = Infinity) { generate(clues: number, attempts = Infinity, totalTime = Infinity) {
if (clues === 0) { const completed = this.generateComplete();
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
@ -177,93 +176,61 @@ 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 to remove cells until 'clues' cells remain // attempt remove cells until 'clues' cells remain
attempts--;
removed.clear();
shuffle(removeable); shuffle(removeable);
maskUpto(0); for (let n = 0; n < this.values2; n++) {
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()) {
// failed attempt. prepare for next removed.delete(toRemove);
unmaskAll();
maskUpto(i + 1);
continue;
} }
removed.add(removeable[i]); unmask();
} }
if (removed.size > best) {
best = removed.size;
}
unmaskAll();
}; };
while ( while (
@ -273,6 +240,8 @@ 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();
} }
@ -282,28 +251,16 @@ export class SudokuMath {
return completed; return completed;
} }
solve(puzzle: Cell[]) { solve(existing: Cell[]): void {
const [header] = this.getDLXHeader(puzzle); const [header] = this.getDLXHeader(existing);
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;
puzzle[meta.index] = meta.value; existing[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,12 +1,16 @@
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) {
return SudokuMath.get(regionWidth, regionHeight).generate(clues); const math =
}, maths[`${regionWidth}:${regionHeight}`] ||
solve(regionWidth, regionHeight, cells) { (maths[`${regionWidth}:${regionHeight}`] = new SudokuMath(
const result = SudokuMath.get(regionWidth, regionHeight).solve(cells); regionWidth,
return [result, cells]; regionHeight
));
return math.generate(clues);
}, },
}); });