298 lines
9.1 KiB
TypeScript
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);
|
|
});
|
|
});
|