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 261 additions and 94 deletions

View File

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

View File

@ -1,6 +1,13 @@
import { Application, bodyParser } from "./mods.js";
import { applyGraphQL } from "./graphql.js";
import { typeDefs, resolvers } from "./graphql/index.js";
import {
initializeWorkers,
solve,
generate,
solveSync,
generateSync,
} from "./sudoku/index.js";
import stoppable from "stoppable";
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({
app,
typeDefs: typeDefs,
resolvers: resolvers,
context: () => sudokuFuncs,
});
runtime.server = stoppable(

View File

@ -114,10 +114,10 @@ export class DLX {
let row = c as DNode;
while ((row = row.down) !== c) {
// traverse DOWN the rows this column contained
// traverse down the rows this column
let col = 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
this.updates++;

View File

@ -1,7 +1,9 @@
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 { prettyPrint } from "./util.js";
const TIMEOUT = 20000;
@ -13,6 +15,12 @@ export type GenerateArguments = {
clues: number;
};
export type SolveArguments = {
regionWidth: number;
regionHeight: number;
cells: Cell[];
};
export type Sudoku = {
regionWidth: number;
regionHeight: number;
@ -20,19 +28,41 @@ export type Sudoku = {
cells: Cell[];
};
function getWorker() {
return spawn(new Worker("./worker"));
type SudokuWorker = {
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 = [];
function initialize() {
export function initializeWorkers() {
console.log(`Starting ${WORKERS} worker threads`);
for (let n = 0; n < WORKERS; n++) {
getWorker().then((worker) => available.push(worker));
spawnWorker();
}
}
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.
@ -52,27 +82,33 @@ function withTimeout<T>(promise: Promise<T>, ms: number, cb: () => any) {
}).finally(() => clearTimeout(timeout!));
}
export async function generate(
regionWidth: number,
regionHeight: number,
clues: number
): Promise<Sudoku> {
const proxy = available.pop();
if (!proxy) {
throw new Error("No workers available right now. Please try again.");
}
const puzzle = await withTimeout<number[]>(
proxy.generate(regionWidth, regionHeight, clues),
TIMEOUT,
() => {
Thread.terminate(proxy);
getWorker().then((worker) => available.push(worker));
return new Error("Timed out. Try reducing the number of clues.");
function workerTaskWithTimeout(
task: (worker: ModuleThread) => Promise<any>,
timeout: number
) {
const worker = pickWorker();
let timedOut = false;
return withTimeout(task(worker), timeout, () => {
timedOut = true;
Thread.terminate(worker);
spawnWorker();
return new Error("Timed out.");
}).finally(() => {
if (!timedOut) {
available.push(worker);
}
);
});
}
available.push(proxy);
export async function generate({
regionWidth,
regionHeight,
clues,
}: GenerateArguments): Promise<Sudoku> {
const puzzle = await workerTaskWithTimeout(
(worker) => worker.generate(regionWidth, regionHeight, clues),
TIMEOUT
);
prettyPrint(regionWidth, regionHeight, puzzle);
return {
regionWidth,
@ -81,3 +117,67 @@ export async function generate(
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,27 +141,28 @@ export class SudokuMath {
return [...firstRow, ...Array(this.values2 - this.values).fill(0)];
}
generateComplete() {
generateComplete(): [number[], number] {
const result = this._baseBoard();
const [header] = this.getDLXHeader(result, true);
const callback = (solution: DNode[]) => {
const dlx = new DLX(header, (solution: DNode[]) => {
solution.forEach((node) => {
const meta: NodeMeta = node.meta;
result[meta.index] = meta.value;
});
// return the first solution
// stop after the first solution
return true;
};
const dlx = new DLX(header, callback);
});
dlx.search();
return result;
return [result, dlx.updates];
}
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
@ -176,61 +177,93 @@ export class SudokuMath {
candidates[meta.index][meta.value - 1] = node;
});
// board positions which have been removed
const removed = new Set<number>();
const masked: DNode[] = [];
const hasOneSolution = () => {
solutions = 0;
dlx.search();
updates += dlx.updates;
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 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 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 = () => {
// attempt remove cells until 'clues' cells remain
// attempt to remove cells until 'clues' cells remain
attempts--;
removed.clear();
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) {
break;
}
let toRemove = removeable[n];
removed.add(toRemove);
mask();
unmaskNextCell();
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 (
@ -240,8 +273,6 @@ export class SudokuMath {
) {
// try to reach the clue goal up to `attempts` times or as long as
// elapsed time is less than `totalTime`
attempts--;
removed.clear();
attempt();
}
@ -251,16 +282,28 @@ export class SudokuMath {
return completed;
}
solve(existing: Cell[]): void {
const [header] = this.getDLXHeader(existing);
solve(puzzle: Cell[]) {
const [header] = this.getDLXHeader(puzzle);
let solved = false;
const callback = (solution: DNode[]) => {
solution.forEach((node) => {
const meta: NodeMeta = node.meta;
existing[meta.index] = meta.value;
puzzle[meta.index] = meta.value;
});
solved = true;
// return the first solution
return true;
};
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 { expose } from "threads/worker";
const maths = {};
expose({
generate(regionWidth, regionHeight, clues) {
const math =
maths[`${regionWidth}:${regionHeight}`] ||
(maths[`${regionWidth}:${regionHeight}`] = new SudokuMath(
regionWidth,
regionHeight
));
return math.generate(clues);
return SudokuMath.get(regionWidth, regionHeight).generate(clues);
},
solve(regionWidth, regionHeight, cells) {
const result = SudokuMath.get(regionWidth, regionHeight).solve(cells);
return [result, cells];
},
});