Files
sudoku-tools/tests/solver/generatorV2.test.ts
T

298 lines
9.1 KiB
TypeScript

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);
});
});