feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user