feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import {
|
||||
candidateCellsForValues,
|
||||
candidateUnitIndicesForCells,
|
||||
candidateValuesFromMask,
|
||||
deriveCandidateLinks,
|
||||
inspectCandidateCells,
|
||||
inspectCandidateHouse,
|
||||
} from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate inspection", () => {
|
||||
it("decodes masks and identifies the houses touching selected cells", () => {
|
||||
expect(candidateValuesFromMask(mask(1, 3, 4), 4)).toEqual([1, 3, 4]);
|
||||
expect(candidateUnitIndicesForCells(puzzle, [0])).toEqual([0, 1, 2]);
|
||||
|
||||
const cells = inspectCandidateCells(
|
||||
puzzle,
|
||||
[mask(1, 4), ...new Array<number>(15).fill(0)],
|
||||
[0],
|
||||
);
|
||||
expect(cells).toEqual([
|
||||
{
|
||||
cell: 0,
|
||||
values: [1, 4],
|
||||
houseLabels: ["Row 1", "Column 1", "Region 1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing values, candidate positions and conjugate pairs in a house", () => {
|
||||
const values = [4, 0, 0, 2, ...new Array<number>(12).fill(0)];
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[1] = mask(1, 3);
|
||||
masks[2] = mask(1, 3);
|
||||
|
||||
expect(inspectCandidateHouse(puzzle, values, masks, 0)).toMatchObject({
|
||||
label: "Row 1",
|
||||
missingValues: [1, 3],
|
||||
positions: [
|
||||
{ value: 1, cells: [1, 2], linkKind: "strong" },
|
||||
{ value: 3, cells: [1, 2], linkKind: "strong" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate link graph", () => {
|
||||
it("derives strong house and bivalue links and merges shared contexts", () => {
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[0] = mask(1, 2);
|
||||
masks[1] = mask(1, 3);
|
||||
|
||||
const links = deriveCandidateLinks(puzzle, masks);
|
||||
const houseLink = links.find(
|
||||
({ a, b }) =>
|
||||
a.cell === 0 && a.value === 1 && b.cell === 1 && b.value === 1,
|
||||
);
|
||||
expect(houseLink).toMatchObject({ kind: "strong" });
|
||||
expect(houseLink?.contexts.map(({ label }) => label)).toEqual([
|
||||
"Row 1",
|
||||
"Region 1",
|
||||
]);
|
||||
expect(links).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 1 },
|
||||
b: { cell: 0, value: 2 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps larger candidate groups weak and supports digit and house scopes", () => {
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[0] = mask(2, 4);
|
||||
masks[1] = mask(2);
|
||||
masks[2] = mask(2);
|
||||
masks[3] = mask(3, 4);
|
||||
|
||||
const links = deriveCandidateLinks(puzzle, masks, {
|
||||
unitIndices: [0],
|
||||
values: [2],
|
||||
});
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links.every(({ kind }) => kind === "weak")).toBe(true);
|
||||
expect(links.every(({ a, b }) => a.value === 2 && b.value === 2)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(candidateCellsForValues(masks, [4])).toEqual([0, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeSumLab, sumCombinationKey } from "../../src/helpers/sumLab";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("generalized sum lab", () => {
|
||||
it("enumerates deterministic, sorted combinations", () => {
|
||||
const result = analyzeSumLab({ cellCount: 2, target: 10 });
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
]);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("supports a digit range, repeats, required and excluded digits", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 3,
|
||||
target: 15,
|
||||
minimumDigit: 3,
|
||||
maximumDigit: 7,
|
||||
allowRepeats: true,
|
||||
requiredDigits: [3],
|
||||
excludedDigits: [4],
|
||||
});
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[3, 5, 7],
|
||||
[3, 6, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses per-cell candidate masks and derives positional possibilities", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 10,
|
||||
candidateMasks: [mask(1, 2), mask(8, 9)],
|
||||
});
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
]);
|
||||
expect(result.assignments).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
]);
|
||||
expect(result.possibleByCell).toEqual([
|
||||
[1, 2],
|
||||
[8, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes toggled combinations from every live deduction", () => {
|
||||
const eliminatedKeys = new Set([sumCombinationKey([1, 9])]);
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 10,
|
||||
eliminatedKeys,
|
||||
});
|
||||
|
||||
expect(result.combinations).toHaveLength(4);
|
||||
expect(result.eliminatedCombinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
]);
|
||||
expect(result.activeCombinations.map(({ digits }) => digits)).toEqual([
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
]);
|
||||
expect(result.possibleDigits).not.toContain(1);
|
||||
expect(result.possibleDigits).not.toContain(9);
|
||||
});
|
||||
|
||||
it("reports which independent safety bound stopped an analysis", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 8,
|
||||
target: 36,
|
||||
allowRepeats: true,
|
||||
maxSearchNodes: 1,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncationReason).toBe("search");
|
||||
expect(result.exploredNodes).toBe(2);
|
||||
});
|
||||
|
||||
it("validates positional candidates and contradictory digit filters", () => {
|
||||
expect(() =>
|
||||
analyzeSumLab({ cellCount: 2, target: 3, candidateMasks: [mask(1)] }),
|
||||
).toThrow(/exactly 2/u);
|
||||
expect(
|
||||
analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 3,
|
||||
requiredDigits: [1],
|
||||
excludedDigits: [1],
|
||||
}).combinations,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves combination invariants over generated inputs", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3, max: 12 }),
|
||||
fc.integer({ min: 1, max: 6 }),
|
||||
fc.integer({ min: 0, max: 72 }),
|
||||
fc.boolean(),
|
||||
(maximumDigit, cellCount, target, allowRepeats) => {
|
||||
if (target > maximumDigit * cellCount) return;
|
||||
const result = analyzeSumLab({
|
||||
cellCount,
|
||||
target,
|
||||
maximumDigit,
|
||||
allowRepeats,
|
||||
});
|
||||
const keys = new Set<string>();
|
||||
for (const combination of result.combinations) {
|
||||
expect(combination.digits).toHaveLength(cellCount);
|
||||
expect(
|
||||
combination.digits.reduce((total, digit) => total + digit, 0),
|
||||
).toBe(target);
|
||||
expect(combination.digits).toEqual(
|
||||
[...combination.digits].sort((left, right) => left - right),
|
||||
);
|
||||
if (!allowRepeats) {
|
||||
expect(new Set(combination.digits)).toHaveProperty(
|
||||
"size",
|
||||
cellCount,
|
||||
);
|
||||
}
|
||||
keys.add(combination.key);
|
||||
}
|
||||
expect(keys.size).toBe(result.combinations.length);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user