feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
candidatesForCell,
|
||||
classicRegions,
|
||||
compilePuzzle,
|
||||
constraintIsFeasible,
|
||||
findConflicts,
|
||||
normalizePuzzle,
|
||||
validatePuzzle,
|
||||
type PuzzleDefinition,
|
||||
type VariantConstraint,
|
||||
} from "../../src/domain";
|
||||
|
||||
const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
|
||||
|
||||
function puzzle4(constraints: readonly VariantConstraint[]): PuzzleDefinition {
|
||||
return {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
describe("outside and local clue constraints", () => {
|
||||
it("strictly validates and clones every new bounded shape", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6 },
|
||||
{ type: "skyscraper", side: "bottom", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
];
|
||||
const definition = puzzle4(constraints);
|
||||
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
|
||||
|
||||
const normalized = normalizePuzzle(definition);
|
||||
expect(normalized.constraints).toEqual(constraints);
|
||||
expect(normalized.constraints).not.toBe(constraints);
|
||||
expect(normalized.constraints[2]).not.toBe(constraints[2]);
|
||||
|
||||
const malformed = [
|
||||
{ type: "x-sum", side: "near", index: 0, sum: 6 },
|
||||
{ type: "x-sum", side: "left", index: 4, sum: 6 },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 2 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 0 },
|
||||
{ type: "quadruple", cells: [0, 1], digits: [1, 2, 3] },
|
||||
{ type: "quadruple", cells: [0, 2, 4, 6], digits: [1, 2] },
|
||||
{ type: "maximum", cell: 16 },
|
||||
{
|
||||
type: "maximum",
|
||||
cell: 5,
|
||||
negated: "yes",
|
||||
},
|
||||
];
|
||||
for (const constraint of malformed) {
|
||||
expect(validatePuzzle(puzzle4([constraint as never])).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows bounded impossible numbers only for false numeric clues", () => {
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1, 2],
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
).valid,
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([{ type: "killer-cage", cells: [0, 1, 2], sum: 42 }]),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
|
||||
for (const constraint of [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 42 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 42 },
|
||||
] as const) {
|
||||
expect(
|
||||
validatePuzzle(puzzle4([{ ...constraint, negated: true }])).valid,
|
||||
).toBe(true);
|
||||
expect(validatePuzzle(puzzle4([constraint])).valid).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 257,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches outside clues to their full lines and local clues to every affected cell", () => {
|
||||
const compiled = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([
|
||||
{ type: "x-sum", side: "top", index: 1, sum: 6 },
|
||||
{ type: "skyscraper", side: "right", index: 2, count: 2 },
|
||||
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(compiled.constraintsByCell[13]).toContain(0);
|
||||
expect(compiled.constraintsByCell[8]).toContain(1);
|
||||
expect(compiled.constraintsByCell[6]).toContain(2);
|
||||
for (const cell of [5, 1, 9, 4, 6]) {
|
||||
expect(compiled.constraintsByCell[cell]).toContain(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the first digit and exact distinct subset sums to prune X-sums", () => {
|
||||
const constraint = {
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 6,
|
||||
} as const;
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 4;
|
||||
expect(candidatesForCell(puzzle4([constraint]), values, 0)).toEqual([2]);
|
||||
|
||||
const reusedOutsidePrefix = new Array<number>(16).fill(0);
|
||||
reusedOutsidePrefix.splice(0, 4, 3, 1, 0, 2);
|
||||
expect(constraintIsFeasible(constraint, reusedOutsidePrefix, 4)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 1 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "bottom", index: 0, sum: 6 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "x-sum", side: "left", index: 0, sum: 6 }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
expect(conflict?.cells).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("enforces exact skyscraper visibility and safely prunes partial lines", () => {
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 4 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "right", index: 0, count: 1 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 3 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
candidatesForCell(
|
||||
puzzle4([{ type: "skyscraper", side: "left", index: 0, count: 1 }]),
|
||||
new Array<number>(16).fill(0),
|
||||
0,
|
||||
),
|
||||
).toEqual([4]);
|
||||
const impossiblePrefix = new Array<number>(16).fill(0);
|
||||
impossiblePrefix[0] = 2;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 4 },
|
||||
impossiblePrefix,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 8 },
|
||||
new Array<number>(16 * 16).fill(0),
|
||||
16,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("honours quadruple multiplicity in candidates and completed boards", () => {
|
||||
const constraint = {
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [2, 2, 4],
|
||||
} as const;
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 2;
|
||||
values[2] = 3;
|
||||
values[5] = 4;
|
||||
expect(candidatesForCell(puzzle4([constraint]), values, 6)).toEqual([2]);
|
||||
|
||||
const wrong = [...values];
|
||||
wrong[6] = 1;
|
||||
expect(constraintIsFeasible(constraint, wrong, 4)).toBe(false);
|
||||
const right = [...values];
|
||||
right[6] = 2;
|
||||
expect(constraintIsFeasible(constraint, right, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("prunes false clues as soon as their positive statement is fixed", () => {
|
||||
const xSumValues = new Array<number>(16).fill(0);
|
||||
xSumValues.splice(0, 3, 3, 1, 2);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true },
|
||||
xSumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 7, negated: true },
|
||||
xSumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const quadrupleValues = new Array<number>(16).fill(0);
|
||||
quadrupleValues[0] = 1;
|
||||
quadrupleValues[1] = 2;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 2],
|
||||
negated: true,
|
||||
},
|
||||
quadrupleValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const maximumValues = new Array<number>(16).fill(0);
|
||||
maximumValues[5] = 4;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
maximumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a maximum cell to exceed every orthogonal neighbour", () => {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 4;
|
||||
expect(
|
||||
candidatesForCell(puzzle4([{ type: "maximum", cell: 5 }]), values, 5),
|
||||
).toEqual([]);
|
||||
|
||||
expect(constraintIsFeasible({ type: "maximum", cell: 5 }, solved4, 4)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(constraintIsFeasible({ type: "maximum", cell: 6 }, solved4, 4)).toBe(
|
||||
false,
|
||||
);
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "maximum", cell: 6 }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
expect(conflict?.cells).toEqual([6, 2, 10, 5, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("false-clue semantics", () => {
|
||||
it("validates and clones polarity for every local clue type", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
{ type: "thermo", cells: [0, 1], negated: true },
|
||||
{ type: "arrow", bulb: [0], line: [1], negated: true },
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
{ type: "xv", a: 0, b: 1, total: 5, negated: true },
|
||||
{ type: "inequality", lesser: 0, greater: 1, negated: true },
|
||||
{ type: "renban", cells: [0, 1], negated: true },
|
||||
{ type: "palindrome", cells: [0, 1], negated: true },
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 2,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 1,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 1],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
];
|
||||
|
||||
const definition = puzzle4(constraints);
|
||||
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
|
||||
const normalized = normalizePuzzle(definition);
|
||||
expect(normalized.constraints).toEqual(constraints);
|
||||
expect(normalized.constraints).not.toBe(constraints);
|
||||
expect(normalized.constraints[0]).not.toBe(constraints[0]);
|
||||
});
|
||||
|
||||
it("does not turn a false cage's no-repeat rule into an unconditional peer", () => {
|
||||
const falseCage = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 3,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const ordinaryCage = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([{ type: "killer-cage", cells: [0, 6], sum: 3 }]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(falseCage.peers[0]?.has(6)).toBe(false);
|
||||
expect(ordinaryCage.peers[0]?.has(6)).toBe(true);
|
||||
|
||||
const repeated = new Array<number>(16).fill(0);
|
||||
repeated[0] = 1;
|
||||
repeated[6] = 1;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 3,
|
||||
negated: true,
|
||||
},
|
||||
repeated,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("inverts completed dots, XV, thermometers and cages", () => {
|
||||
const cases: Array<
|
||||
readonly [VariantConstraint, readonly number[], boolean]
|
||||
> = [
|
||||
[
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
[1, 2],
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
[1, 3],
|
||||
true,
|
||||
],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 4], false],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 3], true],
|
||||
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 2, 3], false],
|
||||
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 3, 2], true],
|
||||
[
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3, negated: true },
|
||||
[1, 2],
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 4, negated: true },
|
||||
[1, 2],
|
||||
true,
|
||||
],
|
||||
];
|
||||
for (const [constraint, relevant, expected] of cases) {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
relevant.forEach((value, cell) => {
|
||||
values[cell] = value;
|
||||
});
|
||||
expect(
|
||||
constraintIsFeasible(constraint, values, 4),
|
||||
JSON.stringify(constraint),
|
||||
).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("inverts each completed outside and local clue", () => {
|
||||
const cases: Array<readonly [VariantConstraint, boolean]> = [
|
||||
[{ type: "x-sum", side: "left", index: 0, sum: 1, negated: true }, false],
|
||||
[{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true }, true],
|
||||
[
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 4,
|
||||
negated: true,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 3,
|
||||
negated: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [1, 2, 3, 4],
|
||||
negated: true,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [2, 2, 4],
|
||||
negated: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[{ type: "maximum", cell: 5, negated: true }, false],
|
||||
[{ type: "maximum", cell: 6, negated: true }, true],
|
||||
];
|
||||
for (const [constraint, expected] of cases) {
|
||||
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps partial false clues feasible and prunes a completed true pair", () => {
|
||||
const falseDot = {
|
||||
type: "kropki",
|
||||
a: 0,
|
||||
b: 1,
|
||||
kind: "white",
|
||||
negated: true,
|
||||
} as const;
|
||||
const partial = new Array<number>(16).fill(0);
|
||||
partial[0] = 1;
|
||||
expect(constraintIsFeasible(falseDot, partial, 4)).toBe(true);
|
||||
expect(candidatesForCell(puzzle4([falseDot]), partial, 1)).toEqual([3, 4]);
|
||||
|
||||
const falseThermo = {
|
||||
type: "thermo",
|
||||
cells: [0, 1, 2],
|
||||
negated: true,
|
||||
} as const;
|
||||
partial[1] = 2;
|
||||
expect(constraintIsFeasible(falseThermo, partial, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a true false-clue as the contradiction", () => {
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "xv", a: 0, b: 3, total: 5, negated: true }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
|
||||
expect(conflict?.message).toBe("xv clue is true but must be false.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user