feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+311
View File
@@ -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] }],
});
});
});
+297
View File
@@ -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);
});
});
+110 -1
View File
@@ -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,
+267
View File
@@ -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]);
});
});