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