feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classicRegions,
|
||||
compilePuzzle,
|
||||
normalizePuzzle,
|
||||
} from "../../src/domain";
|
||||
import {
|
||||
findAic,
|
||||
findAdvancedLogicalStep,
|
||||
findFinnedFish,
|
||||
findJellyfish,
|
||||
findSimpleColouring,
|
||||
findSkyscraper,
|
||||
findTwoStringKite,
|
||||
findUniqueRectangle,
|
||||
findWWing,
|
||||
findXChain,
|
||||
findXYChain,
|
||||
solveLogically,
|
||||
type AdvancedLogicalContext,
|
||||
} from "../../src/solver";
|
||||
|
||||
const size = 9;
|
||||
const empty = normalizePuzzle({
|
||||
version: 1,
|
||||
size,
|
||||
givens: new Array<number>(size * size).fill(0),
|
||||
regions: classicRegions(size),
|
||||
constraints: [],
|
||||
});
|
||||
const compiled = compilePuzzle(empty);
|
||||
|
||||
function cell(row: number, column: number): number {
|
||||
return row * size + column;
|
||||
}
|
||||
|
||||
function mask(values: readonly number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << value), 0);
|
||||
}
|
||||
|
||||
function context(
|
||||
candidates: ReadonlyArray<readonly [number, readonly number[]]>,
|
||||
options: Pick<
|
||||
AdvancedLogicalContext,
|
||||
"uniquenessProven" | "uniquenessPatternsSafe"
|
||||
> = {},
|
||||
): AdvancedLogicalContext {
|
||||
const masks = new Array<number>(size * size).fill(0);
|
||||
for (const [candidateCell, values] of candidates) {
|
||||
masks[candidateCell] = mask(values);
|
||||
}
|
||||
return {
|
||||
size,
|
||||
values: new Array<number>(size * size).fill(0),
|
||||
masks,
|
||||
regions: empty.regions,
|
||||
peers: compiled.peers,
|
||||
units: compiled.units,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function expectElimination(
|
||||
result: ReturnType<typeof findJellyfish>,
|
||||
technique: string,
|
||||
target: number,
|
||||
value: number,
|
||||
): void {
|
||||
expect(result?.technique).toBe(technique);
|
||||
expect(result?.placements).toEqual([]);
|
||||
expect(result?.eliminations).toContainEqual({
|
||||
cell: target,
|
||||
values: [value],
|
||||
});
|
||||
expect(result?.explanation.length).toBeGreaterThan(12);
|
||||
}
|
||||
|
||||
describe("advanced logical techniques", () => {
|
||||
it("finds a row-based Jellyfish", () => {
|
||||
const target = cell(4, 0);
|
||||
const result = findJellyfish(
|
||||
context([
|
||||
[cell(0, 0), [5]],
|
||||
[cell(0, 1), [5]],
|
||||
[cell(1, 1), [5]],
|
||||
[cell(1, 2), [5]],
|
||||
[cell(2, 2), [5]],
|
||||
[cell(2, 3), [5]],
|
||||
[cell(3, 0), [5]],
|
||||
[cell(3, 3), [5]],
|
||||
[target, [5, 8]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "jellyfish", target, 5);
|
||||
expect(
|
||||
findAdvancedLogicalStep(
|
||||
context([
|
||||
[cell(0, 0), [5]],
|
||||
[cell(0, 1), [5]],
|
||||
[cell(1, 1), [5]],
|
||||
[cell(1, 2), [5]],
|
||||
[cell(2, 2), [5]],
|
||||
[cell(2, 3), [5]],
|
||||
[cell(3, 0), [5]],
|
||||
[cell(3, 3), [5]],
|
||||
[target, [5, 8]],
|
||||
]),
|
||||
),
|
||||
).toEqual(result);
|
||||
});
|
||||
|
||||
it("finds Finned X-Wings and Finned Swordfish", () => {
|
||||
const xWingTarget = cell(2, 0);
|
||||
const xWing = findFinnedFish(
|
||||
context([
|
||||
[cell(0, 0), [5]],
|
||||
[cell(0, 1), [5]],
|
||||
[cell(0, 2), [5]],
|
||||
[cell(1, 0), [5]],
|
||||
[cell(1, 1), [5]],
|
||||
[xWingTarget, [5, 8]],
|
||||
]),
|
||||
);
|
||||
expectElimination(xWing, "finned-x-wing", xWingTarget, 5);
|
||||
|
||||
const swordfishTarget = cell(1, 0);
|
||||
const swordfish = findFinnedFish(
|
||||
context([
|
||||
[cell(0, 0), [6]],
|
||||
[cell(0, 1), [6]],
|
||||
[cell(0, 2), [6]],
|
||||
[cell(3, 3), [6]],
|
||||
[cell(3, 6), [6]],
|
||||
[cell(4, 3), [6]],
|
||||
[cell(4, 6), [6]],
|
||||
[swordfishTarget, [6, 8]],
|
||||
]),
|
||||
);
|
||||
expectElimination(swordfish, "finned-swordfish", swordfishTarget, 6);
|
||||
});
|
||||
|
||||
it("finds a Skyscraper", () => {
|
||||
const target = cell(2, 5);
|
||||
const result = findSkyscraper(
|
||||
context([
|
||||
[cell(0, 0), [7]],
|
||||
[cell(0, 3), [7]],
|
||||
[cell(1, 0), [7]],
|
||||
[cell(1, 4), [7]],
|
||||
[target, [2, 7]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "skyscraper", target, 7);
|
||||
});
|
||||
|
||||
it("finds a Two-String Kite", () => {
|
||||
const target = cell(7, 6);
|
||||
const result = findTwoStringKite(
|
||||
context([
|
||||
[cell(0, 0), [6]],
|
||||
[cell(0, 6), [6]],
|
||||
[cell(1, 1), [6]],
|
||||
[cell(7, 1), [6]],
|
||||
[target, [3, 6]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "two-string-kite", target, 6);
|
||||
});
|
||||
|
||||
it("applies a simple-colouring colour trap", () => {
|
||||
const target = cell(1, 1);
|
||||
const result = findSimpleColouring(
|
||||
context([
|
||||
[cell(0, 0), [4]],
|
||||
[cell(0, 4), [4]],
|
||||
[cell(4, 4), [4]],
|
||||
[cell(4, 1), [4]],
|
||||
[target, [4, 8]],
|
||||
[cell(2, 2), [4, 7]],
|
||||
[cell(7, 1), [4, 9]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "simple-colouring", target, 4);
|
||||
});
|
||||
|
||||
it("finds a W-Wing", () => {
|
||||
const target = cell(0, 4);
|
||||
const result = findWWing(
|
||||
context([
|
||||
[cell(0, 0), [1, 2]],
|
||||
[cell(4, 4), [1, 2]],
|
||||
[cell(0, 3), [1, 3, 4]],
|
||||
[cell(4, 3), [1, 3, 4]],
|
||||
[target, [2, 4]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "w-wing", target, 2);
|
||||
});
|
||||
|
||||
it("finds an X-Chain", () => {
|
||||
const target = cell(1, 1);
|
||||
const result = findXChain(
|
||||
context([
|
||||
[cell(0, 0), [5]],
|
||||
[cell(0, 4), [5]],
|
||||
[cell(3, 4), [5]],
|
||||
[cell(3, 1), [5]],
|
||||
[target, [5, 8]],
|
||||
[cell(2, 2), [5, 7]],
|
||||
[cell(7, 1), [5, 9]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "x-chain", target, 5);
|
||||
});
|
||||
|
||||
it("finds an XY-Chain", () => {
|
||||
const target = cell(4, 0);
|
||||
const result = findXYChain(
|
||||
context([
|
||||
[cell(0, 0), [1, 2]],
|
||||
[cell(0, 4), [2, 3]],
|
||||
[cell(4, 4), [1, 3]],
|
||||
[target, [1, 4, 5]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "xy-chain", target, 1);
|
||||
});
|
||||
|
||||
it("finds a mixed Alternating Inference Chain", () => {
|
||||
const target = cell(1, 1);
|
||||
const result = findAic(
|
||||
context([
|
||||
[cell(0, 0), [1, 2]],
|
||||
[cell(0, 4), [2, 4, 5]],
|
||||
[cell(4, 4), [2, 3, 4]],
|
||||
[cell(4, 8), [1, 3, 4]],
|
||||
[cell(4, 1), [1, 5, 6]],
|
||||
[target, [1, 7, 8]],
|
||||
]),
|
||||
);
|
||||
|
||||
expectElimination(result, "aic", target, 1);
|
||||
});
|
||||
|
||||
it("never uses a Unique Rectangle without both safety gates", () => {
|
||||
const roof = cell(1, 3);
|
||||
const candidates = [
|
||||
[cell(0, 0), [1, 2]],
|
||||
[cell(0, 3), [1, 2]],
|
||||
[cell(1, 0), [1, 2]],
|
||||
[roof, [1, 2, 3]],
|
||||
] as const;
|
||||
|
||||
expect(findUniqueRectangle(context(candidates))).toBeUndefined();
|
||||
expect(
|
||||
findUniqueRectangle(context(candidates, { uniquenessProven: true })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findUniqueRectangle(
|
||||
context(candidates, {
|
||||
uniquenessProven: true,
|
||||
uniquenessPatternsSafe: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
technique: "unique-rectangle",
|
||||
eliminations: [{ cell: roof, values: [1, 2] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("integrates the explicit uniqueness proof gate into solveLogically", () => {
|
||||
const digits = Array.from({ length: size }, (_unused, index) => index + 1);
|
||||
const candidates = Array.from({ length: size * size }, () => [...digits]);
|
||||
const withoutPair = digits.filter((value) => value !== 1 && value !== 2);
|
||||
const first = cell(0, 0);
|
||||
const second = cell(0, 3);
|
||||
const third = cell(1, 0);
|
||||
const roof = cell(1, 3);
|
||||
for (let current = 0; current < size * size; current += 1) {
|
||||
const row = Math.floor(current / size);
|
||||
const column = current % size;
|
||||
if (row === 0 || column === 0 || empty.regions[current] === 0) {
|
||||
candidates[current] = [...withoutPair];
|
||||
}
|
||||
}
|
||||
candidates[first] = [1, 2];
|
||||
candidates[second] = [1, 2];
|
||||
candidates[third] = [1, 2];
|
||||
candidates[roof] = [1, 2, 3];
|
||||
|
||||
const ordinary = solveLogically(empty, { candidates, maxSteps: 1 });
|
||||
expect(ordinary.steps[0]?.technique).not.toBe("unique-rectangle");
|
||||
|
||||
const provenUnique = solveLogically(empty, {
|
||||
candidates,
|
||||
maxSteps: 1,
|
||||
uniquenessProven: true,
|
||||
});
|
||||
expect(provenUnique.steps[0]).toMatchObject({
|
||||
technique: "unique-rectangle",
|
||||
eliminations: [{ cell: roof, values: [1, 2] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
|
||||
import {
|
||||
clueOrbit,
|
||||
evaluateTechniqueProfile,
|
||||
generateVariant,
|
||||
generateVariantBatch,
|
||||
minimizePuzzleWithReport,
|
||||
solveExact,
|
||||
type ClueSymmetry,
|
||||
type DifficultyAssessment,
|
||||
type PracticeTechnique,
|
||||
} from "../../src/solver";
|
||||
|
||||
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
|
||||
|
||||
const complete4: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: solution4,
|
||||
solution: solution4,
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("Generator 2.0 clue removal", () => {
|
||||
it("builds all supported clue-symmetry orbits", () => {
|
||||
expect(clueOrbit(4, 1, "none")).toEqual([1]);
|
||||
expect(clueOrbit(4, 1, "rotational")).toEqual([1, 14]);
|
||||
expect(clueOrbit(4, 1, "horizontal")).toEqual([1, 13]);
|
||||
expect(clueOrbit(4, 1, "vertical")).toEqual([1, 2]);
|
||||
expect(clueOrbit(4, 1, "diagonal-main")).toEqual([1, 4]);
|
||||
expect(clueOrbit(4, 1, "diagonal-anti")).toEqual([1, 11]);
|
||||
expect(clueOrbit(4, 1, "orthogonal")).toEqual([1, 7, 8, 14]);
|
||||
expect(clueOrbit(4, 1, "four-way")).toEqual(clueOrbit(4, 1, "orthogonal"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rotational",
|
||||
"horizontal",
|
||||
"vertical",
|
||||
"diagonal-main",
|
||||
"diagonal-anti",
|
||||
"orthogonal",
|
||||
] as const satisfies readonly ClueSymmetry[])(
|
||||
"preserves %s symmetry during grouped removal",
|
||||
(symmetry) => {
|
||||
const result = minimizePuzzleWithReport(complete4, {
|
||||
seed: `symmetry-${symmetry}`,
|
||||
symmetry,
|
||||
targetClues: 8,
|
||||
maxChecks: 32,
|
||||
});
|
||||
for (let cell = 0; cell < 16; cell += 1) {
|
||||
const present = (result.puzzle.givens[cell] ?? 0) !== 0;
|
||||
expect(
|
||||
clueOrbit(4, cell, symmetry).every(
|
||||
(other) => ((result.puzzle.givens[other] ?? 0) !== 0) === present,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(result.minimality.symmetryPreserved).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("proves individual-given minimality when every deletion check completes", () => {
|
||||
const result = minimizePuzzleWithReport(complete4, {
|
||||
seed: "minimal-proof",
|
||||
symmetry: "rotational",
|
||||
targetClues: 8,
|
||||
minimalGivens: true,
|
||||
maxChecks: 64,
|
||||
solveTimeoutMs: 2_000,
|
||||
});
|
||||
|
||||
expect(result.minimality.status).toBe("proven-minimal");
|
||||
expect(result.minimality.unknownCells).toEqual([]);
|
||||
for (let cell = 0; cell < result.puzzle.givens.length; cell += 1) {
|
||||
if (result.puzzle.givens[cell] === 0) continue;
|
||||
const givens = [...result.puzzle.givens];
|
||||
givens[cell] = 0;
|
||||
const exact = solveExact(
|
||||
{ ...result.puzzle, givens },
|
||||
{ maxSolutions: 2 },
|
||||
);
|
||||
expect(exact.count).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unknown instead of minimal after a tiny shared check cap", () => {
|
||||
const result = minimizePuzzleWithReport(complete4, {
|
||||
seed: "minimal-bounded",
|
||||
targetClues: 16,
|
||||
minimalGivens: true,
|
||||
maxChecks: 1,
|
||||
});
|
||||
|
||||
expect(result.minimality.status).toBe("unknown");
|
||||
expect(result.minimality.checksPerformed).toBe(1);
|
||||
expect(result.minimality.unknownCells.length).toBeGreaterThan(0);
|
||||
expect(result.minimality.limitReasons).toContain("check-cap");
|
||||
});
|
||||
|
||||
it("treats a truncated deletion search as unknown evidence", () => {
|
||||
const result = minimizePuzzleWithReport(complete4, {
|
||||
seed: "minimal-node-bound",
|
||||
targetClues: 16,
|
||||
minimalGivens: true,
|
||||
maxChecks: 32,
|
||||
solveMaxNodes: 1,
|
||||
});
|
||||
|
||||
expect(result.minimality.status).toBe("unknown");
|
||||
expect(result.minimality.limitReasons).toContain("node-cap");
|
||||
expect(result.minimality.unknownCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never claims minimality before proving the input is unique", () => {
|
||||
const ambiguous: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
};
|
||||
const result = minimizePuzzleWithReport(ambiguous, {
|
||||
minimalGivens: true,
|
||||
targetClues: 0,
|
||||
maxChecks: 32,
|
||||
});
|
||||
|
||||
expect(result.minimality.status).toBe("unknown");
|
||||
expect(result.minimality.checksPerformed).toBe(1);
|
||||
expect(result.minimality.limitReasons).toContain("not-unique");
|
||||
expect(result.puzzle.givens).toEqual(ambiguous.givens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Generator 2.0 variants and profiles", () => {
|
||||
it("combines multiple compatible variant families deterministically", () => {
|
||||
const options = {
|
||||
variants: ["kropki", "thermo"] as const,
|
||||
size: 4,
|
||||
seed: "mixed-local",
|
||||
targetClues: 16,
|
||||
maxChecks: 1,
|
||||
constraintCount: 3,
|
||||
};
|
||||
const generated = generateVariant(options);
|
||||
|
||||
expect(generated.variant).toBe("mixed");
|
||||
expect(generated.families).toEqual(["thermo", "kropki"]);
|
||||
expect(
|
||||
generated.puzzle.constraints.some(({ type }) => type === "thermo"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
generated.puzzle.constraints.some(({ type }) => type === "kropki"),
|
||||
).toBe(true);
|
||||
expect(generateVariant(options)).toEqual(generated);
|
||||
});
|
||||
|
||||
it("scales local marking counts with explicit density", () => {
|
||||
const common = {
|
||||
variant: "inequality" as const,
|
||||
size: 4,
|
||||
seed: "density",
|
||||
targetClues: 16,
|
||||
maxChecks: 1,
|
||||
constraintCount: 6,
|
||||
};
|
||||
const sparse = generateVariant({ ...common, constraintDensity: "sparse" });
|
||||
const dense = generateVariant({ ...common, constraintDensity: "dense" });
|
||||
|
||||
expect(sparse.generatedConstraintCount).toBeLessThan(
|
||||
dense.generatedConstraintCount,
|
||||
);
|
||||
expect(sparse.constraintDensity).toBe("sparse");
|
||||
expect(dense.constraintDensity).toBe("dense");
|
||||
|
||||
const sparseKiller = generateVariant({
|
||||
variant: "killer",
|
||||
size: 4,
|
||||
seed: "killer-density",
|
||||
targetClues: 16,
|
||||
maxChecks: 1,
|
||||
constraintDensity: "sparse",
|
||||
});
|
||||
const denseKiller = generateVariant({
|
||||
variant: "killer",
|
||||
size: 4,
|
||||
seed: "killer-density",
|
||||
targetClues: 16,
|
||||
maxChecks: 1,
|
||||
constraintDensity: "dense",
|
||||
});
|
||||
expect(sparseKiller.generatedConstraintCount).toBeLessThanOrEqual(
|
||||
denseKiller.generatedConstraintCount,
|
||||
);
|
||||
});
|
||||
|
||||
it("evaluates required, forbidden, count and hardest-technique evidence", () => {
|
||||
const assessment: DifficultyAssessment = {
|
||||
score: 52,
|
||||
level: "medium",
|
||||
label: "Medium",
|
||||
uniqueness: "unique",
|
||||
clueCount: 30,
|
||||
emptyCount: 51,
|
||||
logicalStatus: "solved",
|
||||
logicalSteps: 7,
|
||||
hardestTechnique: "x-wing",
|
||||
techniqueCounts: {
|
||||
"naked-single": 4,
|
||||
"hidden-single": 2,
|
||||
"x-wing": 1,
|
||||
},
|
||||
exactNodes: 100,
|
||||
exactTruncated: false,
|
||||
summary: "fixture",
|
||||
};
|
||||
const matched = evaluateTechniqueProfile(assessment, {
|
||||
required: ["x-wing"],
|
||||
forbidden: ["swordfish"],
|
||||
counts: [{ technique: "naked-single", min: 3, max: 5 }],
|
||||
hardestTechnique: "x-wing",
|
||||
});
|
||||
expect(matched.status).toBe("matched");
|
||||
expect(matched.requirements.every(({ matched: ok }) => ok)).toBe(true);
|
||||
|
||||
expect(
|
||||
evaluateTechniqueProfile(assessment, {
|
||||
counts: [{ technique: "hidden-single", max: 1 }],
|
||||
}).status,
|
||||
).toBe("not-matched");
|
||||
expect(
|
||||
evaluateTechniqueProfile(
|
||||
{ ...assessment, logicalStatus: "stuck" },
|
||||
{ forbidden: ["aic"] },
|
||||
),
|
||||
).toMatchObject({ status: "not-matched", completePath: false });
|
||||
});
|
||||
|
||||
it("accepts a generation profile only after its independent path matches", () => {
|
||||
const options = {
|
||||
variant: "classic" as const,
|
||||
size: 4,
|
||||
seed: "profile-path",
|
||||
targetClues: 10,
|
||||
maxChecks: 12,
|
||||
};
|
||||
const baseline = generateVariant(options);
|
||||
expect(baseline.difficulty.logicalStatus).toBe("solved");
|
||||
expect(baseline.difficulty.hardestTechnique).toBeDefined();
|
||||
const hardest = baseline.difficulty.hardestTechnique as PracticeTechnique;
|
||||
const profiled = generateVariant({
|
||||
...options,
|
||||
techniqueProfile: {
|
||||
required: [hardest],
|
||||
forbidden: ["aic"],
|
||||
counts: [{ technique: hardest, min: 1, max: 16 }],
|
||||
hardestTechnique: hardest,
|
||||
},
|
||||
maxTechniqueAttempts: 1,
|
||||
});
|
||||
|
||||
expect(profiled.techniqueProfile).toMatchObject({
|
||||
status: "matched",
|
||||
completePath: true,
|
||||
requestedHardestTechnique: hardest,
|
||||
actualHardestTechnique: hardest,
|
||||
hardestMatched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a deterministic ranked batch with concise evidence summaries", () => {
|
||||
const options = {
|
||||
variant: "classic" as const,
|
||||
size: 4,
|
||||
seed: "batch",
|
||||
targetClues: 10,
|
||||
maxChecks: 12,
|
||||
batchSize: 3,
|
||||
ranking: "fewest-givens" as const,
|
||||
};
|
||||
const batch = generateVariantBatch(options);
|
||||
|
||||
expect(batch.completed).toBe(3);
|
||||
expect(batch.truncated).toBe(false);
|
||||
expect(batch.summaries.map(({ rank }) => rank)).toEqual([1, 2, 3]);
|
||||
expect(batch.summaries.map(({ clueCount }) => clueCount)).toEqual(
|
||||
[...batch.summaries.map(({ clueCount }) => clueCount)].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
);
|
||||
expect(generateVariantBatch(options)).toEqual(batch);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
|
||||
import {
|
||||
PuzzleValidationError,
|
||||
classicRegions,
|
||||
type PuzzleDefinition,
|
||||
} from "../../src/domain";
|
||||
import { killerDigitCombinations, solveLogically } from "../../src/solver";
|
||||
|
||||
const easy: PuzzleDefinition = {
|
||||
@@ -15,6 +19,14 @@ const easy: PuzzleDefinition = {
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
const emptyFour: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("logical solver", () => {
|
||||
it("solves a standard puzzle and emits inspectable steps", () => {
|
||||
const result = solveLogically(easy);
|
||||
@@ -60,6 +72,103 @@ describe("logical solver", () => {
|
||||
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("resumes after an elimination-only step from exposed candidates", () => {
|
||||
const candidates = Array.from({ length: 16 }, () => [1, 2, 3, 4]);
|
||||
candidates[0] = [1, 2];
|
||||
candidates[1] = [1, 2];
|
||||
candidates[4] = [1, 3];
|
||||
candidates[5] = [1, 3];
|
||||
|
||||
const first = solveLogically(emptyFour, {
|
||||
candidates,
|
||||
maxSteps: 1,
|
||||
});
|
||||
expect(first.steps[0]).toMatchObject({
|
||||
technique: "naked-pair",
|
||||
focusCells: [0, 1],
|
||||
placements: [],
|
||||
});
|
||||
expect(first.candidates[2]).toEqual([3, 4]);
|
||||
expect(first.candidates[3]).toEqual([3, 4]);
|
||||
|
||||
const next = solveLogically(emptyFour, {
|
||||
candidates: first.candidates,
|
||||
maxSteps: 1,
|
||||
});
|
||||
expect(next.steps[0]?.placements).toEqual([]);
|
||||
expect(next.steps[0]?.eliminations.length).toBeGreaterThan(0);
|
||||
expect(next.steps[0]?.eliminations).not.toEqual(
|
||||
first.steps[0]?.eliminations,
|
||||
);
|
||||
// The first elimination remains applied while the solver advances to a
|
||||
// different logical effect. This is the key resume contract for guided
|
||||
// hints that do not place a digit.
|
||||
expect(next.candidates[2]).toEqual([3, 4]);
|
||||
expect(next.candidates[3]).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it("intersects supplied restrictions with candidates legal on the board", () => {
|
||||
const candidates = Array.from({ length: 81 }, () =>
|
||||
Array.from({ length: 9 }, (_, index) => index + 1),
|
||||
);
|
||||
|
||||
const result = solveLogically(easy, { candidates, maxSteps: 1 });
|
||||
expect(result.candidates[2]).not.toContain(3);
|
||||
expect(result.candidates[2]).not.toContain(5);
|
||||
expect(result.candidates[2]).not.toContain(6);
|
||||
expect(result.candidates[2]).not.toContain(7);
|
||||
expect(result.candidates[2]).not.toContain(8);
|
||||
expect(result.candidates[2]).not.toContain(9);
|
||||
});
|
||||
|
||||
it("reports a valid empty restriction as an invalid logical state", () => {
|
||||
const candidates = Array.from({ length: 16 }, () => [1, 2, 3, 4]);
|
||||
candidates[0] = [];
|
||||
|
||||
const result = solveLogically(emptyFour, { candidates });
|
||||
expect(result.status).toBe("invalid");
|
||||
expect(result.steps).toEqual([]);
|
||||
expect(result.candidates[0]).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects malformed candidate restrictions with precise issues", () => {
|
||||
expect(() =>
|
||||
solveLogically(emptyFour, {
|
||||
candidates: Array.from({ length: 15 }, () => [1, 2, 3, 4]),
|
||||
}),
|
||||
).toThrow(PuzzleValidationError);
|
||||
|
||||
try {
|
||||
solveLogically(emptyFour, {
|
||||
candidates: [
|
||||
[1, 1],
|
||||
[0, 5],
|
||||
...Array.from({ length: 14 }, () => [1, 2, 3, 4]),
|
||||
],
|
||||
});
|
||||
throw new Error("Expected malformed candidates to be rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PuzzleValidationError);
|
||||
expect((error as PuzzleValidationError).issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "candidates[0][1]" }),
|
||||
expect.objectContaining({ path: "candidates[1][0]" }),
|
||||
expect.objectContaining({ path: "candidates[1][1]" }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
solveLogically(emptyFour, {
|
||||
candidates: [
|
||||
[1, 2, 3, 4],
|
||||
null,
|
||||
...Array.from({ length: 14 }, () => [1, 2, 3, 4]),
|
||||
] as unknown as readonly (readonly number[])[],
|
||||
}),
|
||||
).toThrow(PuzzleValidationError);
|
||||
});
|
||||
|
||||
it("returns bounded killer combinations", () => {
|
||||
expect(
|
||||
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
|
||||
import { analyzePuzzleQuality, qualityItemCells } from "../../src/solver";
|
||||
|
||||
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
|
||||
|
||||
const unique4: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
|
||||
regions: classicRegions(4),
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("setter quality analysis", () => {
|
||||
it("audits a unique puzzle without mutating it", () => {
|
||||
const before = JSON.stringify(unique4);
|
||||
const result = analyzePuzzleQuality(unique4, { proveMinimality: true });
|
||||
|
||||
expect(result.solutionStatus).toBe("unique");
|
||||
expect(result.solution).toEqual(solution4);
|
||||
expect(result.ambiguityWitness).toBeUndefined();
|
||||
expect(result.contradiction.status).toBe("not-applicable");
|
||||
expect(result.redundancy.givens).toHaveLength(
|
||||
unique4.givens.filter(Boolean).length,
|
||||
);
|
||||
expect(
|
||||
result.redundancy.givens.every(
|
||||
({ classification }) => classification !== "unknown",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.redundancy.givens.every(({ classification }) =>
|
||||
["critical", "redundant"].includes(classification),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.criticalityHeatmap).toHaveLength(16);
|
||||
expect(result.budget.checksPerformed).toBe(
|
||||
1 + unique4.givens.filter(Boolean).length,
|
||||
);
|
||||
expect(result.budget.truncated).toBe(false);
|
||||
expect(result.minimality?.status).toMatch(
|
||||
/^(proven-minimal|not-minimal)$/u,
|
||||
);
|
||||
expect(JSON.stringify(unique4)).toBe(before);
|
||||
});
|
||||
|
||||
it("returns two concrete solutions and their differing cells for ambiguity", () => {
|
||||
const ambiguous: PuzzleDefinition = {
|
||||
...unique4,
|
||||
givens: [1, ...new Array<number>(15).fill(0)],
|
||||
};
|
||||
const result = analyzePuzzleQuality(ambiguous);
|
||||
|
||||
expect(result.solutionStatus).toBe("multiple");
|
||||
expect(result.ambiguityWitness?.firstSolution).toHaveLength(16);
|
||||
expect(result.ambiguityWitness?.secondSolution).toHaveLength(16);
|
||||
expect(result.ambiguityWitness?.differences.length).toBeGreaterThan(0);
|
||||
for (const difference of result.ambiguityWitness?.differences ?? []) {
|
||||
expect(difference.first).not.toBe(difference.second);
|
||||
}
|
||||
expect(result.redundancy.givens).toEqual([
|
||||
{
|
||||
item: { kind: "given", cell: 0, value: 1 },
|
||||
classification: "unknown",
|
||||
unknownReason: "baseline-not-unique",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("localizes direct contradictory givens and reports each as critical", () => {
|
||||
const contradictory: PuzzleDefinition = {
|
||||
...unique4,
|
||||
givens: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
const before = JSON.stringify(contradictory);
|
||||
const result = analyzePuzzleQuality(contradictory);
|
||||
|
||||
expect(result.solutionStatus).toBe("unsatisfiable");
|
||||
expect(
|
||||
result.redundancy.givens.map((entry) => entry.classification),
|
||||
).toEqual(["critical", "critical"]);
|
||||
expect(result.contradiction.status).toBe("localized");
|
||||
expect(result.contradiction.core).toEqual([
|
||||
{ kind: "given", cell: 0, value: 1 },
|
||||
{ kind: "given", cell: 1, value: 1 },
|
||||
]);
|
||||
expect(result.contradiction.necessary).toEqual(result.contradiction.core);
|
||||
expect(result.contradiction.removable).toEqual([]);
|
||||
expect(JSON.stringify(contradictory)).toBe(before);
|
||||
});
|
||||
|
||||
it("finds a minimal contradictory core made from overlapping constraints", () => {
|
||||
const contradictory: PuzzleDefinition = {
|
||||
...unique4,
|
||||
givens: [0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
constraints: [
|
||||
{ type: "killer-cage", cells: [0], sum: 1 },
|
||||
{ type: "killer-cage", cells: [0], sum: 2 },
|
||||
],
|
||||
};
|
||||
const result = analyzePuzzleQuality(contradictory);
|
||||
|
||||
expect(result.solutionStatus).toBe("unsatisfiable");
|
||||
expect(
|
||||
result.redundancy.constraints.map(({ classification }) => classification),
|
||||
).toEqual(["critical", "critical"]);
|
||||
expect(result.redundancy.givens[0]?.classification).toBe("redundant");
|
||||
expect(result.contradiction.status).toBe("localized");
|
||||
expect(result.contradiction.core).toEqual([
|
||||
{ kind: "constraint", index: 0, constraintType: "killer-cage" },
|
||||
{ kind: "constraint", index: 1, constraintType: "killer-cage" },
|
||||
]);
|
||||
expect(result.contradiction.removable).toEqual([
|
||||
{ kind: "given", cell: 5, value: 4 },
|
||||
]);
|
||||
expect(result.contradiction.unknown).toEqual([]);
|
||||
});
|
||||
|
||||
it("identifies redundant givens and constraints and derives non-minimality", () => {
|
||||
const overSpecified: PuzzleDefinition = {
|
||||
...unique4,
|
||||
givens: solution4,
|
||||
solution: solution4,
|
||||
constraints: [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "diagonal", direction: "main" },
|
||||
],
|
||||
};
|
||||
const result = analyzePuzzleQuality(overSpecified, {
|
||||
proveMinimality: true,
|
||||
});
|
||||
|
||||
expect(result.solutionStatus).toBe("unique");
|
||||
expect(
|
||||
result.redundancy.givens.every(
|
||||
({ classification }) => classification === "redundant",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.redundancy.constraints.every(
|
||||
({ classification }) => classification === "redundant",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.minimality?.status).toBe("not-minimal");
|
||||
expect(result.minimality?.redundant).toHaveLength(18);
|
||||
expect(result.criticalityHeatmap[0]).toMatchObject({
|
||||
score: 0,
|
||||
criticalWeight: 0,
|
||||
redundantWeight: 3,
|
||||
unknownWeight: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("never turns per-check truncation into a uniqueness proof", () => {
|
||||
const result = analyzePuzzleQuality(unique4, {
|
||||
perCheckMaxNodes: 1,
|
||||
aggregateMaxNodes: 100,
|
||||
aggregateMaxChecks: 100,
|
||||
proveMinimality: true,
|
||||
});
|
||||
|
||||
expect(result.solutionStatus).toBe("unknown");
|
||||
expect(result.checks).toHaveLength(1);
|
||||
expect(result.checks[0]).toMatchObject({
|
||||
solutionStatus: "unknown",
|
||||
conclusive: false,
|
||||
truncated: true,
|
||||
limitReason: "node-cap",
|
||||
unknownReason: "per-check-node-cap",
|
||||
});
|
||||
expect(
|
||||
result.redundancy.givens.every(
|
||||
({ classification }) => classification === "unknown",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.minimality?.status).toBe("not-applicable");
|
||||
expect(result.budget.unknownReasons).toContain("per-check-node-cap");
|
||||
expect(result.budget.unknownReasons).toContain("baseline-unknown");
|
||||
});
|
||||
|
||||
it("shares an aggregate check budget across all removal checks", () => {
|
||||
const result = analyzePuzzleQuality(
|
||||
{ ...unique4, givens: solution4, solution: solution4 },
|
||||
{
|
||||
aggregateMaxChecks: 1,
|
||||
aggregateMaxNodes: 1_000,
|
||||
proveMinimality: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.solutionStatus).toBe("unique");
|
||||
expect(result.budget.checksPerformed).toBe(1);
|
||||
expect(result.budget.checksPlanned).toBe(17);
|
||||
expect(result.budget.truncated).toBe(true);
|
||||
expect(result.budget.unknownReasons).toContain("aggregate-check-cap");
|
||||
expect(
|
||||
result.redundancy.givens.every(
|
||||
({ classification, unknownReason }) =>
|
||||
classification === "unknown" &&
|
||||
unknownReason === "aggregate-check-cap",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.minimality?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("leaves contradiction-core items unknown when the shared budget ends", () => {
|
||||
const contradictory: PuzzleDefinition = {
|
||||
...unique4,
|
||||
givens: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
const result = analyzePuzzleQuality(contradictory, {
|
||||
aggregateMaxChecks: 1,
|
||||
});
|
||||
|
||||
expect(result.solutionStatus).toBe("unsatisfiable");
|
||||
expect(result.contradiction.status).toBe("incomplete");
|
||||
expect(result.contradiction.necessary).toEqual([]);
|
||||
expect(result.contradiction.unknown).toEqual(result.contradiction.core);
|
||||
expect(result.budget.checksPerformed).toBe(1);
|
||||
expect(result.budget.unknownReasons).toContain("aggregate-check-cap");
|
||||
});
|
||||
|
||||
it("supports a cheap baseline-only depth", () => {
|
||||
const result = analyzePuzzleQuality(unique4, {
|
||||
analysisDepth: "baseline",
|
||||
proveMinimality: true,
|
||||
});
|
||||
|
||||
expect(result.analysisDepth).toBe("baseline");
|
||||
expect(result.solutionStatus).toBe("unique");
|
||||
expect(result.redundancy).toEqual({ givens: [], constraints: [] });
|
||||
expect(result.criticalityHeatmap).toEqual([]);
|
||||
expect(result.contradiction.status).toBe("not-applicable");
|
||||
expect(result.minimality?.status).toBe("not-applicable");
|
||||
expect(result.budget.checksPlanned).toBe(1);
|
||||
expect(result.budget.checksPerformed).toBe(1);
|
||||
});
|
||||
|
||||
it("maps given, local and outside findings to their board cells", () => {
|
||||
const puzzle: PuzzleDefinition = {
|
||||
...unique4,
|
||||
constraints: [
|
||||
{ type: "killer-cage", cells: [4, 5], sum: 7 },
|
||||
{ type: "x-sum", side: "top", index: 2, sum: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
qualityItemCells(puzzle, { kind: "given", cell: 0, value: 1 }),
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
qualityItemCells(puzzle, {
|
||||
kind: "constraint",
|
||||
index: 0,
|
||||
constraintType: "killer-cage",
|
||||
}),
|
||||
).toEqual([4, 5]);
|
||||
expect(
|
||||
qualityItemCells(puzzle, {
|
||||
kind: "constraint",
|
||||
index: 1,
|
||||
constraintType: "x-sum",
|
||||
}),
|
||||
).toEqual([2, 6, 10, 14]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user