(null)
const visibleEntries = useMemo(
@@ -64,7 +67,14 @@ export const ExplanationPanel = ({ steps }: Props) => {
data-active={index === 0}
>
- {sequence}. {step.ruleName}
+ {sequence}.{' '}
+
+ {step.ruleName}
+
{step.inferenceDetails ? (
diff --git a/src/mdx.d.ts b/src/mdx.d.ts
new file mode 100644
index 0000000..c9665f3
--- /dev/null
+++ b/src/mdx.d.ts
@@ -0,0 +1,6 @@
+declare module '*.mdx' {
+ import type { ComponentType } from 'react'
+
+ const MDXContent: ComponentType
+ export default MDXContent
+}
diff --git a/vite.config.ts b/vite.config.ts
index 7f70d68..3466dae 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,10 +1,11 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
+import mdx from '@mdx-js/rollup'
// https://vite.dev/config/
export default defineConfig({
base: '/puzzlekit-web/',
- plugins: [react()],
+ plugins: [mdx(), react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
From 59167089a00c3400272ea5a64422c1087ba39672 Mon Sep 17 00:00:00 2001
From: xiaoxiao
Date: Thu, 11 Jun 2026 02:06:06 +0800
Subject: [PATCH 13/14] feat: enhance DocsPage layout with header alignment and
add e2e tests for consistent header sizing
---
src/app/DocsPage.tsx | 14 +++++++++-----
src/app/workspace.css | 5 +++++
tests/e2e/workspace.spec.ts | 26 ++++++++++++++++++++++++++
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/src/app/DocsPage.tsx b/src/app/DocsPage.tsx
index d0ad929..1bc1ce4 100644
--- a/src/app/DocsPage.tsx
+++ b/src/app/DocsPage.tsx
@@ -23,11 +23,15 @@ const RuleDocsRoute = () => {
export const DocsPage = () => (
-
+
} />
} />
diff --git a/src/app/workspace.css b/src/app/workspace.css
index 1f540c1..8380b83 100644
--- a/src/app/workspace.css
+++ b/src/app/workspace.css
@@ -67,9 +67,14 @@
.docs-workspace {
display: grid;
+ align-content: start;
gap: 16px;
}
+.docs-header-grid {
+ width: 100%;
+}
+
.docs-index {
display: grid;
gap: 16px;
diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts
index 8bf34ab..68bba91 100644
--- a/tests/e2e/workspace.spec.ts
+++ b/tests/e2e/workspace.spec.ts
@@ -46,3 +46,29 @@ test('falls back and reports an invalid deep link', async ({ page }) => {
)
await expect(page).toHaveURL(/p=slither\/10\/10\/dsew%3F/)
})
+
+test('keeps the docs header aligned and compact across docs routes', async ({ page }) => {
+ await page.goto('/puzzlekit-web/')
+ const solverHeader = page.locator('.workspace-title')
+ const solverHeaderWidth = await solverHeader.evaluate(
+ (element) => element.getBoundingClientRect().width,
+ )
+
+ await page.goto('/puzzlekit-web/docs')
+ const docsHeader = page.locator('.workspace-title')
+ const docsIndexHeaderSize = await docsHeader.evaluate((element) => {
+ const rect = element.getBoundingClientRect()
+ return { height: rect.height, width: rect.width }
+ })
+
+ expect(docsIndexHeaderSize.width).toBeCloseTo(solverHeaderWidth, 0)
+
+ await page.goto(
+ '/puzzlekit-web/docs/slitherlink/rules/diagonal-adjacent-three-outer-corners',
+ )
+ const ruleHeaderHeight = await page
+ .locator('.workspace-title')
+ .evaluate((element) => element.getBoundingClientRect().height)
+
+ expect(ruleHeaderHeight).toBeCloseTo(docsIndexHeaderSize.height, 0)
+})
From 28438ca92e84ab896eb268530df13bf51f1fcaee Mon Sep 17 00:00:00 2001
From: xiaoxiao
Date: Thu, 11 Jun 2026 15:06:40 +0800
Subject: [PATCH 14/14] feat: implement Masyu Line Component Endpoint Strong
Inference rule and remove Empty Cell Strong Inference rule
---
docs/MASYU_AGENT_BRIEF.md | 3 +-
docs/techniques/masyu.md | 29 ++-
src/app/WorkspacePage.test.tsx | 4 +-
src/domain/rules/masyu/rules.ts | 4 +-
.../masyu/rules/emptyCellStrongInference.ts | 244 ------------------
.../lineComponentEndpointStrongInference.ts | 163 ++++++++++++
src/domain/rules/masyu/rules/lineGraph.ts | 47 ++++
.../rules/masyu/rules/strongInference.ts | 2 +-
.../tests/blackPearlStrongInference.test.ts | 6 +-
.../tests/emptyCellStrongInference.test.ts | 199 --------------
...neComponentEndpointStrongInference.test.ts | 228 ++++++++++++++++
.../rules/masyu/tests/registration.test.ts | 10 +-
12 files changed, 474 insertions(+), 465 deletions(-)
delete mode 100644 src/domain/rules/masyu/rules/emptyCellStrongInference.ts
create mode 100644 src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts
delete mode 100644 src/domain/rules/masyu/tests/emptyCellStrongInference.test.ts
create mode 100644 src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts
diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md
index b9b0148..32864d4 100644
--- a/docs/MASYU_AGENT_BRIEF.md
+++ b/docs/MASYU_AGENT_BRIEF.md
@@ -40,7 +40,8 @@ Implemented rule areas include:
- Candidate bridge reasoning and pearl candidate pruning.
- Adjacent white pearl lookahead.
- Tile color propagation, color-line implications, color-pearl implications, and connectivity cut coloring.
-- Bounded strong inference for black pearls, white pearls, and empty cells.
+- Bounded strong inference for black pearls, confirmed-line component endpoints,
+ and white pearls.
Default expectation: deterministic rules run first, then bounded strong-inference rules reuse the deterministic rule list for local propagation.
diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md
index caab61c..8db2bf5 100644
--- a/docs/techniques/masyu.md
+++ b/docs/techniques/masyu.md
@@ -383,6 +383,22 @@ crossed out in the real puzzle.
This rule determines crossed-out line segments.
+### Line Component Endpoint Strong Inference
+
+For each open component of confirmed lines, the solver examines its two
+degree-1 endpoints. Endpoints with fewer remaining directions are tested first,
+followed by endpoints belonging to longer components.
+
+For one candidate direction, the solver assumes that the component continues in
+that direction and that the endpoint's other unknown exits are crossed out. If
+deterministic propagation reaches a contradiction, that candidate direction is
+crossed out in the real puzzle. Pearl cells may be component endpoints, but the
+dedicated black-pearl strong-inference rule runs first.
+
+Because component endpoints are already high-value candidates, this rule uses a
+tighter default candidate and elapsed-time cap than the broader strong-inference
+rules.
+
### White Pearl Strong Inference
For a white pearl with exactly two feasible straight-axis candidates, the solver
@@ -393,16 +409,3 @@ forced in the real puzzle.
This rule determines the confirmed lines and crossed-out lines belonging to the
forced white-pearl axis.
-
-### Empty Cell Strong Inference
-
-For a non-pearl cell, the solver can test bounded degree choices when the local
-state has either one known line with exactly two unknown exits, or no known lines
-with exactly two unknown exits.
-
-In the degree-1 case, it assumes each possible continuation in turn. If one
-continuation reaches a contradiction, the other continuation is forced.
-
-In the degree-0 case, it tests the two local modes: both exits are used, or both
-exits are crossed out. If one mode reaches a contradiction, the other mode is
-forced in the real puzzle.
diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx
index 895caf8..588f1db 100644
--- a/src/app/WorkspacePage.test.tsx
+++ b/src/app/WorkspacePage.test.tsx
@@ -1000,8 +1000,8 @@ describe('WorkspacePage', () => {
const steps: RuleStep[] = [
{
id: 'step-1',
- ruleId: 'masyu-empty-cell-strong-inference',
- ruleName: 'Empty Cell Strong Inference',
+ ruleId: 'masyu-line-component-endpoint-strong-inference',
+ ruleName: 'Masyu Line Component Endpoint Strong Inference',
message: 'The assumption contradicts the puzzle, so the alternative is forced.',
diffs: [{ kind: 'line', lineKey: forced, from: 'unknown', to: 'line' }],
affectedCells: [cellKey(1, 1)],
diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts
index c0fde5e..df8c452 100644
--- a/src/domain/rules/masyu/rules.ts
+++ b/src/domain/rules/masyu/rules.ts
@@ -13,7 +13,7 @@ import {
} from './rules/color'
import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity'
import { createCellExitCompletionRule } from './rules/completion'
-import { createEmptyCellStrongInferenceRule } from './rules/emptyCellStrongInference'
+import { createLineComponentEndpointStrongInferenceRule } from './rules/lineComponentEndpointStrongInference'
import {
createMasyuEmptyCellPrematureLoopRule,
createPreventPrematureLoopRule,
@@ -52,6 +52,6 @@ export const deterministicMasyuRules: Rule[] = [
export const masyuRules: Rule[] = [
...deterministicMasyuRules,
createBlackPearlStrongInferenceRule(() => deterministicMasyuRules),
+ createLineComponentEndpointStrongInferenceRule(() => deterministicMasyuRules),
createWhitePearlStrongInferenceRule(() => deterministicMasyuRules),
- createEmptyCellStrongInferenceRule(() => deterministicMasyuRules),
]
diff --git a/src/domain/rules/masyu/rules/emptyCellStrongInference.ts b/src/domain/rules/masyu/rules/emptyCellStrongInference.ts
deleted file mode 100644
index 0f6fa51..0000000
--- a/src/domain/rules/masyu/rules/emptyCellStrongInference.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import { cellKey } from '../../../ir/keys'
-import type { PuzzleIR } from '../../../ir/types'
-import type { Rule, RuleApplication } from '../../types'
-import { createMasyuLineDecisionCollector } from './decisionCollector'
-import {
- formatMasyuCellKeyLabel,
- formatMasyuLineLabel,
- getMasyuIncidentDirectionalLines,
- MASYU_DIRECTIONS,
- type MasyuDirectionalLine,
-} from './shared'
-import {
- buildMasyuStrongBranch,
- buildMasyuInferenceDetails,
- deriveMasyuStrongProbeBudgets,
- describeMasyuStrongTrialResult,
- immediateMasyuStrongContradictionResult,
- STRONG_MAX_CANDIDATES,
- STRONG_MAX_MS,
- STRONG_MAX_TRIAL_STEPS,
- type MasyuLineAssumption,
- type MasyuStrongInferenceOptions,
-} from './strongInference'
-import { runMasyuTrialUntilFixpoint } from './trial'
-
-type EmptyCellStrongCandidate = {
- cell: string
- mode: 'continuation' | 'degree-zero'
- unknowns: [MasyuDirectionalLine, MasyuDirectionalLine]
-}
-
-type EmptyCellStrongBranch = {
- label: string
- assumptions: MasyuLineAssumption[]
- forced: MasyuLineAssumption[]
-}
-
-const collectEmptyCellStrongCandidates = (
- puzzle: PuzzleIR,
- maxCandidates: number,
-): EmptyCellStrongCandidate[] => {
- const candidates: EmptyCellStrongCandidate[] = []
- for (let row = 0; row < puzzle.rows; row += 1) {
- for (let col = 0; col < puzzle.cols; col += 1) {
- const key = cellKey(row, col)
- if (puzzle.cells[key]?.clue?.kind === 'pearl') {
- continue
- }
-
- const incidentByDirection = getMasyuIncidentDirectionalLines(puzzle, key)
- const incident = MASYU_DIRECTIONS.flatMap((direction) => {
- const item = incidentByDirection[direction]
- return item ? [item] : []
- })
- const lineCount = incident.filter((item) => item.mark === 'line').length
- const unknowns = incident.filter((item) => item.mark === 'unknown')
- if (unknowns.length !== 2 || (lineCount !== 0 && lineCount !== 1)) {
- continue
- }
-
- candidates.push({
- cell: key,
- mode: lineCount === 1 ? 'continuation' : 'degree-zero',
- unknowns: [unknowns[0], unknowns[1]],
- })
- if (candidates.length >= maxCandidates) {
- return candidates
- }
- }
- }
- return candidates
-}
-
-const buildBranches = (
- candidate: EmptyCellStrongCandidate,
-): EmptyCellStrongBranch[] => {
- const [first, second] = candidate.unknowns
- if (candidate.mode === 'continuation') {
- return [
- {
- label: `${formatMasyuLineLabel(first.lineKey)} as the continuation`,
- assumptions: [
- [first.lineKey, 'line'],
- [second.lineKey, 'blank'],
- ],
- forced: [
- [second.lineKey, 'line'],
- [first.lineKey, 'blank'],
- ],
- },
- {
- label: `${formatMasyuLineLabel(second.lineKey)} as the continuation`,
- assumptions: [
- [second.lineKey, 'line'],
- [first.lineKey, 'blank'],
- ],
- forced: [
- [first.lineKey, 'line'],
- [second.lineKey, 'blank'],
- ],
- },
- ]
- }
-
- return [
- {
- label: 'using both remaining exits',
- assumptions: [
- [first.lineKey, 'line'],
- [second.lineKey, 'line'],
- ],
- forced: [
- [first.lineKey, 'blank'],
- [second.lineKey, 'blank'],
- ],
- },
- {
- label: 'using neither remaining exit',
- assumptions: [
- [first.lineKey, 'blank'],
- [second.lineKey, 'blank'],
- ],
- forced: [
- [first.lineKey, 'line'],
- [second.lineKey, 'line'],
- ],
- },
- ]
-}
-
-const collectForcedDiffs = (
- puzzle: PuzzleIR,
- forced: MasyuLineAssumption[],
-): ReturnType['diffs']> | null => {
- const decisions = createMasyuLineDecisionCollector(puzzle, {
- guardLineDegree: true,
- })
- for (const [lineKeyValue, mark] of forced) {
- if (!decisions.add(lineKeyValue, mark)) {
- return null
- }
- }
- return decisions.hasChanges() ? decisions.diffs() : null
-}
-
-const describeForced = (forced: MasyuLineAssumption[]): string => {
- const lineCount = forced.filter(([, mark]) => mark === 'line').length
- const blankCount = forced.filter(([, mark]) => mark === 'blank').length
- if (lineCount === 2) {
- return 'both remaining exits must be lines'
- }
- if (blankCount === 2) {
- return 'both remaining exits are crossed out'
- }
- const line = forced.find(([, mark]) => mark === 'line')?.[0]
- return line
- ? `${formatMasyuLineLabel(line)} must be the continuation`
- : forced
- .map(
- ([lineKeyValue, mark]) =>
- `${formatMasyuLineLabel(lineKeyValue)} ${mark}`,
- )
- .join(', ')
-}
-
-export const createEmptyCellStrongInferenceRule = (
- getDeterministicRules: () => Rule[],
- options: MasyuStrongInferenceOptions = {},
-): Rule => ({
- id: 'masyu-empty-cell-strong-inference',
- name: 'Empty Cell Strong Inference',
- apply: (puzzle: PuzzleIR): RuleApplication | null => {
- const deterministicRules = getDeterministicRules()
- const candidates = collectEmptyCellStrongCandidates(
- puzzle,
- options.maxCandidates ?? STRONG_MAX_CANDIDATES,
- )
- if (candidates.length === 0) {
- return null
- }
-
- const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS)
- const budgets = deriveMasyuStrongProbeBudgets(
- options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS,
- )
-
- for (const budget of budgets) {
- for (const candidate of candidates) {
- if (Date.now() > deadlineMs) {
- return null
- }
-
- for (const trialBranch of buildBranches(candidate)) {
- const branch = buildMasyuStrongBranch(
- puzzle,
- trialBranch.assumptions,
- )
- const result = branch.setupOk
- ? runMasyuTrialUntilFixpoint(
- branch.puzzle,
- deterministicRules,
- budget,
- deadlineMs,
- )
- : immediateMasyuStrongContradictionResult(branch.puzzle)
- if (result.timedOut) {
- return null
- }
- if (!result.contradiction) {
- continue
- }
-
- const diffs = collectForcedDiffs(puzzle, trialBranch.forced)
- if (!diffs || diffs.length === 0) {
- continue
- }
- const firstLine = diffs[0]?.lineKey
- return {
- message:
- `Empty Cell Strong Inference: assuming ${formatMasyuCellKeyLabel(
- candidate.cell,
- )} is ${trialBranch.label} (${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult(
- result,
- )}, so ${describeForced(trialBranch.forced)} through ${formatMasyuLineLabel(
- firstLine,
- )}${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.`,
- diffs,
- affectedCells: [candidate.cell],
- affectedLines: diffs.map((diff) => diff.lineKey),
- inferenceDetails: buildMasyuInferenceDetails(
- puzzle,
- `Assume ${formatMasyuCellKeyLabel(candidate.cell)} is ${trialBranch.label}`,
- branch,
- result,
- diffs,
- ),
- }
- }
- }
- }
-
- return null
- },
-})
diff --git a/src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts b/src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts
new file mode 100644
index 0000000..5ca74de
--- /dev/null
+++ b/src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts
@@ -0,0 +1,163 @@
+import { parseCellKey } from '../../../ir/keys'
+import type { PuzzleIR } from '../../../ir/types'
+import type { Rule, RuleApplication } from '../../types'
+import { getMasyuOpenLineComponents } from './lineGraph'
+import {
+ formatMasyuCellKeyLabel,
+ formatMasyuLineLabel,
+ getMasyuIncidentDirectionalLines,
+ MASYU_DIRECTIONS,
+ type MasyuDirectionalLine,
+} from './shared'
+import {
+ buildMasyuInferenceDetails,
+ buildMasyuStrongBranch,
+ deriveMasyuStrongProbeBudgets,
+ describeMasyuStrongTrialResult,
+ immediateMasyuStrongContradictionResult,
+ STRONG_MAX_CANDIDATES,
+ STRONG_MAX_MS,
+ STRONG_MAX_TRIAL_STEPS,
+ type MasyuLineAssumption,
+ type MasyuStrongInferenceOptions,
+} from './strongInference'
+import { runMasyuTrialUntilFixpoint } from './trial'
+
+const ENDPOINT_STRONG_MAX_CANDIDATES = 60
+const ENDPOINT_STRONG_MAX_MS = 3000
+
+type LineComponentEndpointCandidate = {
+ endpointKey: string
+ endpointOrder: number
+ componentEdgeCount: number
+ unknowns: MasyuDirectionalLine[]
+ assumed: MasyuDirectionalLine
+}
+
+const collectLineComponentEndpointCandidates = (
+ puzzle: PuzzleIR,
+ maxCandidates: number,
+): LineComponentEndpointCandidate[] =>
+ getMasyuOpenLineComponents(puzzle)
+ .flatMap((component) =>
+ component.endpointKeys.flatMap((endpointKey) => {
+ const [row, col] = parseCellKey(endpointKey)
+ const unknowns = MASYU_DIRECTIONS.flatMap((direction) => {
+ const line = getMasyuIncidentDirectionalLines(puzzle, endpointKey)[
+ direction
+ ]
+ return line?.mark === 'unknown' ? [line] : []
+ })
+ if (unknowns.length < 2) {
+ return []
+ }
+ return unknowns.map((assumed) => ({
+ endpointKey,
+ endpointOrder: row * puzzle.cols + col,
+ componentEdgeCount: component.edgeCount,
+ unknowns,
+ assumed,
+ }))
+ }),
+ )
+ .sort(
+ (left, right) =>
+ left.unknowns.length - right.unknowns.length ||
+ right.componentEdgeCount - left.componentEdgeCount ||
+ left.endpointOrder - right.endpointOrder ||
+ MASYU_DIRECTIONS.indexOf(left.assumed.direction) -
+ MASYU_DIRECTIONS.indexOf(right.assumed.direction),
+ )
+ .slice(0, maxCandidates)
+
+const buildCandidateAssumptions = (
+ candidate: LineComponentEndpointCandidate,
+): MasyuLineAssumption[] => [
+ [candidate.assumed.lineKey, 'line'],
+ ...candidate.unknowns
+ .filter((line) => line.lineKey !== candidate.assumed.lineKey)
+ .map((line): MasyuLineAssumption => [line.lineKey, 'blank']),
+]
+
+export const createLineComponentEndpointStrongInferenceRule = (
+ getDeterministicRules: () => Rule[],
+ options: MasyuStrongInferenceOptions = {},
+): Rule => ({
+ id: 'masyu-line-component-endpoint-strong-inference',
+ name: 'Masyu Line Component Endpoint Strong Inference',
+ apply: (puzzle: PuzzleIR): RuleApplication | null => {
+ const candidates = collectLineComponentEndpointCandidates(
+ puzzle,
+ options.maxCandidates ??
+ Math.min(ENDPOINT_STRONG_MAX_CANDIDATES, STRONG_MAX_CANDIDATES),
+ )
+ if (candidates.length === 0) {
+ return null
+ }
+
+ const deterministicRules = getDeterministicRules()
+ const deadlineMs =
+ Date.now() +
+ (options.maxMs ?? Math.min(ENDPOINT_STRONG_MAX_MS, STRONG_MAX_MS))
+ const budgets = deriveMasyuStrongProbeBudgets(
+ options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS,
+ )
+
+ for (const budget of budgets) {
+ for (const candidate of candidates) {
+ if (Date.now() > deadlineMs) {
+ return null
+ }
+ const branch = buildMasyuStrongBranch(
+ puzzle,
+ buildCandidateAssumptions(candidate),
+ )
+ const result = branch.setupOk
+ ? runMasyuTrialUntilFixpoint(
+ branch.puzzle,
+ deterministicRules,
+ budget,
+ deadlineMs,
+ )
+ : immediateMasyuStrongContradictionResult(branch.puzzle)
+ if (result.timedOut) {
+ return null
+ }
+ if (!result.contradiction) {
+ continue
+ }
+
+ const diffs: RuleApplication['diffs'] = [
+ {
+ kind: 'line',
+ lineKey: candidate.assumed.lineKey,
+ from: 'unknown',
+ to: 'blank',
+ },
+ ]
+ return {
+ message:
+ `Masyu Line Component Endpoint Strong Inference: assuming the ${candidate.componentEdgeCount}-segment component at ${formatMasyuCellKeyLabel(
+ candidate.endpointKey,
+ )} continues ${candidate.assumed.direction} among ${candidate.unknowns.length} candidate directions (${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult(
+ result,
+ )}, so ${formatMasyuLineLabel(candidate.assumed.lineKey)} is crossed out.`,
+ diffs,
+ affectedCells: [candidate.endpointKey],
+ affectedLines: [candidate.assumed.lineKey],
+ inferenceDetails: buildMasyuInferenceDetails(
+ puzzle,
+ `Assume the ${candidate.componentEdgeCount}-segment component at ${formatMasyuCellKeyLabel(
+ candidate.endpointKey,
+ )} continues ${candidate.assumed.direction}`,
+ branch,
+ result,
+ diffs,
+ ),
+ }
+ }
+ }
+
+ return null
+ },
+})
diff --git a/src/domain/rules/masyu/rules/lineGraph.ts b/src/domain/rules/masyu/rules/lineGraph.ts
index 31a4a5d..72e32c1 100644
--- a/src/domain/rules/masyu/rules/lineGraph.ts
+++ b/src/domain/rules/masyu/rules/lineGraph.ts
@@ -24,6 +24,10 @@ export type MasyuKnownLineComponent = {
vertices: Set
}
+export type MasyuOpenLineComponent = MasyuKnownLineComponent & {
+ endpointKeys: [string, string]
+}
+
const getOverlayLineMark = (
puzzle: PuzzleIR,
overlay: MasyuLineOverlay,
@@ -126,6 +130,49 @@ export const getMasyuKnownLineComponents = (
return [...components.values()]
}
+export const getMasyuOpenLineComponents = (
+ puzzle: PuzzleIR,
+ overlay: MasyuLineOverlay = new Map(),
+): MasyuOpenLineComponent[] => {
+ const degree = new Map()
+ const { toCellIndex } = buildMasyuLineUnion(puzzle, overlay)
+
+ for (const lineKeyValue of Object.keys(puzzle.lines)) {
+ if (getOverlayLineMark(puzzle, overlay, lineKeyValue) !== 'line') {
+ continue
+ }
+ const [left, right] = parseLineKey(lineKeyValue)
+ const leftIndex = toCellIndex(left[0], left[1])
+ const rightIndex = toCellIndex(right[0], right[1])
+ degree.set(leftIndex, (degree.get(leftIndex) ?? 0) + 1)
+ degree.set(rightIndex, (degree.get(rightIndex) ?? 0) + 1)
+ }
+
+ return getMasyuKnownLineComponents(puzzle, overlay).flatMap((component) => {
+ if (component.edgeCount !== component.vertices.size - 1) {
+ return []
+ }
+ const endpoints = [...component.vertices].filter(
+ (vertex) => degree.get(vertex) === 1,
+ )
+ if (
+ endpoints.length !== 2 ||
+ [...component.vertices].some((vertex) => {
+ const vertexDegree = degree.get(vertex) ?? 0
+ return vertexDegree !== 1 && vertexDegree !== 2
+ })
+ ) {
+ return []
+ }
+ const endpointKeys = endpoints
+ .sort((left, right) => left - right)
+ .map((vertex) =>
+ cellKey(Math.floor(vertex / puzzle.cols), vertex % puzzle.cols),
+ ) as [string, string]
+ return [{ ...component, endpointKeys }]
+ })
+}
+
export const findMasyuPrematureLoopClosingLines = (
puzzle: PuzzleIR,
overlay: MasyuLineOverlay = new Map(),
diff --git a/src/domain/rules/masyu/rules/strongInference.ts b/src/domain/rules/masyu/rules/strongInference.ts
index 4ddb342..e7c9d3f 100644
--- a/src/domain/rules/masyu/rules/strongInference.ts
+++ b/src/domain/rules/masyu/rules/strongInference.ts
@@ -24,7 +24,7 @@ export type MasyuLineAssumption = [lineKey: string, mark: LineMark]
export const STRONG_MAX_CANDIDATES = 300
export const STRONG_MAX_TRIAL_STEPS = 100
-export const STRONG_MAX_MS = 15000
+export const STRONG_MAX_MS = 30000
export const deriveMasyuStrongProbeBudgets = (
maxTrialSteps: number,
diff --git a/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts b/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts
index 5e18cdd..d8f478c 100644
--- a/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts
+++ b/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts
@@ -162,7 +162,9 @@ describe('Masyu black pearl strong inference', () => {
rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id),
).toEqual(deterministicMasyuRules.map((rule) => rule.id))
expect(rules.at(-3)?.id).toBe('masyu-black-pearl-strong-inference')
- expect(rules.at(-2)?.id).toBe('masyu-white-pearl-strong-inference')
- expect(rules.at(-1)?.id).toBe('masyu-empty-cell-strong-inference')
+ expect(rules.at(-2)?.id).toBe(
+ 'masyu-line-component-endpoint-strong-inference',
+ )
+ expect(rules.at(-1)?.id).toBe('masyu-white-pearl-strong-inference')
})
})
diff --git a/src/domain/rules/masyu/tests/emptyCellStrongInference.test.ts b/src/domain/rules/masyu/tests/emptyCellStrongInference.test.ts
deleted file mode 100644
index 2c96968..0000000
--- a/src/domain/rules/masyu/tests/emptyCellStrongInference.test.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { cellKey, lineKey } from '../../../ir/keys'
-import { createMasyuPuzzle } from '../../../ir/masyu'
-import type { Rule } from '../../types'
-import { createEmptyCellStrongInferenceRule } from '../rules/emptyCellStrongInference'
-import { markLine, expectLineDiffs } from './testUtils'
-
-describe('Masyu empty cell strong inference', () => {
- it('forces the opposite continuation when one degree-1 branch causes a degree contradiction', () => {
- const puzzle = createMasyuPuzzle(3, 4)
- const target = cellKey(0, 1)
- const west = lineKey([0, 0], [0, 1])
- const east = lineKey([0, 1], [0, 2])
- const south = lineKey([0, 1], [1, 1])
- markLine(puzzle, west, 'line')
- markLine(puzzle, lineKey([0, 2], [0, 3]), 'line')
- markLine(puzzle, lineKey([0, 2], [1, 2]), 'line')
-
- const result = createEmptyCellStrongInferenceRule(() => [], {
- maxCandidates: 1,
- }).apply(puzzle)
-
- expect(result?.affectedCells).toEqual([target])
- expectLineDiffs(result?.diffs, {
- [south]: 'line',
- [east]: 'blank',
- })
- expect(result?.message).toContain('cell-degree contradiction')
- expect(result?.message).toContain('must be the continuation')
- expect(result?.inferenceDetails?.branches[0]).toMatchObject({
- status: 'contradiction',
- contradiction: { kind: 'cell-degree', cells: [cellKey(0, 2)] },
- })
- expect(result?.inferenceDetails?.branches[1]).toMatchObject({
- status: 'forced',
- initialDiffs: result?.diffs,
- })
- })
-
- it('crosses out both remaining exits when using both causes a contradiction', () => {
- const puzzle = createMasyuPuzzle(2, 3)
- const target = cellKey(0, 0)
- const east = lineKey([0, 0], [0, 1])
- const south = lineKey([0, 0], [1, 0])
- markLine(puzzle, lineKey([0, 1], [0, 2]), 'line')
- markLine(puzzle, lineKey([0, 1], [1, 1]), 'line')
-
- const result = createEmptyCellStrongInferenceRule(() => [], {
- maxCandidates: 1,
- }).apply(puzzle)
-
- expect(result?.affectedCells).toEqual([target])
- expectLineDiffs(result?.diffs, {
- [east]: 'blank',
- [south]: 'blank',
- })
- expect(result?.message).toContain('using both remaining exits')
- expect(result?.message).toContain('both remaining exits are crossed out')
- })
-
- it('forces both remaining exits when using neither causes a degree contradiction', () => {
- const puzzle = createMasyuPuzzle(2, 3)
- const target = cellKey(0, 0)
- const east = lineKey([0, 0], [0, 1])
- const south = lineKey([0, 0], [1, 0])
- markLine(puzzle, lineKey([0, 1], [0, 2]), 'line')
- markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank')
-
- const result = createEmptyCellStrongInferenceRule(() => []).apply(puzzle)
-
- expect(result?.affectedCells).toEqual([target])
- expectLineDiffs(result?.diffs, {
- [east]: 'line',
- [south]: 'line',
- })
- expect(result?.message).toContain('using neither remaining exit')
- expect(result?.message).toContain('both remaining exits must be lines')
- })
-
- it('uses deterministic downstream rules to find an empty-cell contradiction', () => {
- const puzzle = createMasyuPuzzle(3, 4)
- const target = cellKey(0, 1)
- const west = lineKey([0, 0], [0, 1])
- const east = lineKey([0, 1], [0, 2])
- const south = lineKey([0, 1], [1, 1])
- const eastOfEast = lineKey([0, 2], [0, 3])
- const southOfEast = lineKey([0, 2], [1, 2])
- markLine(puzzle, west, 'line')
- const downstreamRule: Rule = {
- id: 'test-empty-cell-downstream-degree',
- name: 'Test Empty Cell Downstream Degree',
- apply: (trial) => {
- if ((trial.lines[east]?.mark ?? 'unknown') !== 'line') {
- return null
- }
- if ((trial.lines[eastOfEast]?.mark ?? 'unknown') !== 'unknown') {
- return null
- }
- return {
- message: 'Force downstream empty-cell contradiction',
- diffs: [
- {
- kind: 'line',
- lineKey: eastOfEast,
- from: 'unknown',
- to: 'line',
- },
- {
- kind: 'line',
- lineKey: southOfEast,
- from: 'unknown',
- to: 'line',
- },
- ],
- affectedCells: [],
- affectedLines: [eastOfEast, southOfEast],
- }
- },
- }
-
- const result = createEmptyCellStrongInferenceRule(() => [
- downstreamRule,
- ]).apply(puzzle)
-
- expect(result?.affectedCells).toEqual([target])
- expectLineDiffs(result?.diffs, {
- [south]: 'line',
- [east]: 'blank',
- })
- expect(result?.message).toContain('after 1 step')
- })
-
- it('does not copy empty-cell trial progress back into the real puzzle', () => {
- const puzzle = createMasyuPuzzle(3, 4)
- const west = lineKey([0, 0], [0, 1])
- const east = lineKey([0, 1], [0, 2])
- const unrelated = lineKey([2, 2], [2, 3])
- markLine(puzzle, west, 'line')
- const harmlessRule: Rule = {
- id: 'test-empty-cell-harmless-trial-progress',
- name: 'Test Empty Cell Harmless Trial Progress',
- apply: (trial) => {
- if ((trial.lines[east]?.mark ?? 'unknown') !== 'line') {
- return null
- }
- if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') {
- return null
- }
- return {
- message: 'Harmless empty-cell trial-only progress',
- diffs: [
- { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' },
- ],
- affectedCells: [],
- affectedLines: [unrelated],
- }
- },
- }
-
- const result = createEmptyCellStrongInferenceRule(() => [harmlessRule], {
- maxTrialSteps: 1,
- }).apply(puzzle)
-
- expect(result).toBeNull()
- expect(puzzle.lines[unrelated]?.mark).toBe('unknown')
- })
-
- it('returns null when the empty-cell trial budget times out', () => {
- const puzzle = createMasyuPuzzle(3, 3)
- markLine(puzzle, lineKey([1, 0], [1, 1]), 'line')
- markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank')
-
- const result = createEmptyCellStrongInferenceRule(() => [], {
- maxMs: -1,
- }).apply(puzzle)
-
- expect(result).toBeNull()
- })
-
- it('does not force an opposite empty-cell branch through a degree-2 neighbor', () => {
- const puzzle = createMasyuPuzzle(3, 4)
- const west = lineKey([0, 0], [0, 1])
- const east = lineKey([0, 1], [0, 2])
- const south = lineKey([0, 1], [1, 1])
- markLine(puzzle, west, 'line')
- markLine(puzzle, lineKey([0, 2], [0, 3]), 'line')
- markLine(puzzle, lineKey([0, 2], [1, 2]), 'line')
- markLine(puzzle, lineKey([1, 1], [1, 2]), 'line')
- markLine(puzzle, lineKey([1, 0], [1, 1]), 'line')
-
- const result = createEmptyCellStrongInferenceRule(() => [], {
- maxCandidates: 1,
- }).apply(puzzle)
-
- expect(result).toBeNull()
- expect(puzzle.lines[east]?.mark).toBe('unknown')
- expect(puzzle.lines[south]?.mark).toBe('unknown')
- })
-})
diff --git a/src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts b/src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts
new file mode 100644
index 0000000..b7e9b54
--- /dev/null
+++ b/src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from 'vitest'
+import { cellKey, lineKey } from '../../../ir/keys'
+import { createMasyuPuzzle } from '../../../ir/masyu'
+import type { Rule } from '../../types'
+import { createLineComponentEndpointStrongInferenceRule } from '../rules/lineComponentEndpointStrongInference'
+import { addPearl, expectLineDiffs, markLine } from './testUtils'
+
+describe('Masyu line component endpoint strong inference', () => {
+ it('crosses out a two-direction endpoint continuation that causes a contradiction', () => {
+ const puzzle = createMasyuPuzzle(3, 4)
+ const endpoint = cellKey(0, 1)
+ const west = lineKey([0, 0], [0, 1])
+ const east = lineKey([0, 1], [0, 2])
+ markLine(puzzle, west, 'line')
+ markLine(puzzle, lineKey([0, 2], [0, 3]), 'line')
+ markLine(puzzle, lineKey([0, 2], [1, 2]), 'line')
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => []).apply(
+ puzzle,
+ )
+
+ expect(result?.affectedCells).toEqual([endpoint])
+ expectLineDiffs(result?.diffs, { [east]: 'blank' })
+ expect(result?.message).toContain('1-segment component')
+ expect(result?.message).toContain('among 2 candidate directions')
+ expect(result?.inferenceDetails?.branches[0]).toMatchObject({
+ status: 'contradiction',
+ contradiction: { kind: 'cell-degree', cells: [cellKey(0, 2)] },
+ })
+ expect(result?.inferenceDetails?.branches[1]).toMatchObject({
+ status: 'forced',
+ initialDiffs: result?.diffs,
+ })
+ })
+
+ it('tests a three-direction endpoint and only crosses out the failing direction', () => {
+ const puzzle = createMasyuPuzzle(4, 4)
+ const west = lineKey([1, 0], [1, 1])
+ const north = lineKey([0, 1], [1, 1])
+ const east = lineKey([1, 1], [1, 2])
+ const south = lineKey([1, 1], [2, 1])
+ markLine(puzzle, west, 'line')
+ markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank')
+ markLine(puzzle, lineKey([1, 0], [2, 0]), 'blank')
+ markLine(puzzle, lineKey([0, 0], [0, 1]), 'line')
+ markLine(puzzle, lineKey([0, 1], [0, 2]), 'line')
+ markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank')
+ markLine(puzzle, lineKey([0, 2], [0, 3]), 'blank')
+ markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank')
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => []).apply(
+ puzzle,
+ )
+
+ expectLineDiffs(result?.diffs, { [north]: 'blank' })
+ expect(result?.message).toContain('among 3 candidate directions')
+ expect(puzzle.lines[east]?.mark).toBe('unknown')
+ expect(puzzle.lines[south]?.mark).toBe('unknown')
+ })
+
+ it('prioritizes endpoints with fewer candidate directions', () => {
+ const puzzle = createMasyuPuzzle(5, 5)
+ const constrainedEndpoint = cellKey(0, 1)
+ const constrainedEast = lineKey([0, 1], [0, 2])
+ markLine(puzzle, lineKey([0, 0], [0, 1]), 'line')
+ markLine(puzzle, lineKey([0, 2], [0, 3]), 'line')
+ markLine(puzzle, lineKey([0, 2], [1, 2]), 'line')
+
+ markLine(puzzle, lineKey([3, 2], [3, 3]), 'line')
+ markLine(puzzle, lineKey([3, 1], [3, 2]), 'blank')
+ markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank')
+ markLine(puzzle, lineKey([3, 3], [3, 4]), 'blank')
+ markLine(puzzle, lineKey([2, 3], [3, 3]), 'blank')
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => []).apply(
+ puzzle,
+ )
+
+ expect(result?.affectedCells).toEqual([constrainedEndpoint])
+ expectLineDiffs(result?.diffs, { [constrainedEast]: 'blank' })
+ })
+
+ it('prioritizes longer components when endpoint candidate counts match', () => {
+ const puzzle = createMasyuPuzzle(6, 6)
+ const shortEast = lineKey([0, 1], [0, 2])
+ markLine(puzzle, lineKey([0, 0], [0, 1]), 'line')
+ markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank')
+
+ const longNorth = lineKey([3, 3], [4, 3])
+ markLine(puzzle, lineKey([4, 0], [4, 1]), 'line')
+ markLine(puzzle, lineKey([4, 1], [4, 2]), 'line')
+ markLine(puzzle, lineKey([4, 2], [4, 3]), 'line')
+ markLine(puzzle, lineKey([3, 0], [4, 0]), 'blank')
+ markLine(puzzle, lineKey([4, 3], [5, 3]), 'blank')
+
+ const centerLines = [
+ lineKey([2, 1], [2, 2]),
+ lineKey([2, 2], [2, 3]),
+ lineKey([1, 2], [2, 2]),
+ ]
+ const contradictionRule: Rule = {
+ id: 'test-long-component-priority',
+ name: 'Test Long Component Priority',
+ apply: (trial) =>
+ trial.lines[longNorth]?.mark === 'line' ||
+ trial.lines[shortEast]?.mark === 'line'
+ ? {
+ message: 'Force a priority-test contradiction',
+ diffs: centerLines.map((lineKeyValue) => ({
+ kind: 'line' as const,
+ lineKey: lineKeyValue,
+ from: 'unknown' as const,
+ to: 'line' as const,
+ })),
+ affectedCells: [],
+ affectedLines: centerLines,
+ }
+ : null,
+ }
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => [
+ contradictionRule,
+ ]).apply(puzzle)
+
+ expectLineDiffs(result?.diffs, { [longNorth]: 'blank' })
+ expect(result?.message).toContain('3-segment component')
+ })
+
+ it('can inspect a pearl that is also a component endpoint', () => {
+ const puzzle = createMasyuPuzzle(5, 5)
+ const endpoint = cellKey(2, 2)
+ const north = lineKey([1, 2], [2, 2])
+ addPearl(puzzle, 2, 2, 'black')
+ markLine(puzzle, lineKey([2, 0], [2, 1]), 'line')
+ markLine(puzzle, lineKey([2, 1], [2, 2]), 'line')
+ markLine(puzzle, lineKey([1, 0], [2, 0]), 'blank')
+ markLine(puzzle, lineKey([2, 0], [3, 0]), 'blank')
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => []).apply(
+ puzzle,
+ )
+
+ expect(result?.affectedCells).toEqual([endpoint])
+ expectLineDiffs(result?.diffs, { [north]: 'blank' })
+ expect(result?.message).toContain('component at (R3, C3)')
+ })
+
+ it('ignores branched line components', () => {
+ const puzzle = createMasyuPuzzle(3, 3)
+ markLine(puzzle, lineKey([1, 0], [1, 1]), 'line')
+ markLine(puzzle, lineKey([0, 1], [1, 1]), 'line')
+ markLine(puzzle, lineKey([1, 1], [1, 2]), 'line')
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => []).apply(
+ puzzle,
+ )
+
+ expect(result).toBeNull()
+ })
+
+ it('ignores closed components and endpoints with only one unknown exit', () => {
+ const closed = createMasyuPuzzle(2, 2)
+ markLine(closed, lineKey([0, 0], [0, 1]), 'line')
+ markLine(closed, lineKey([0, 1], [1, 1]), 'line')
+ markLine(closed, lineKey([1, 0], [1, 1]), 'line')
+ markLine(closed, lineKey([0, 0], [1, 0]), 'line')
+
+ const singleExit = createMasyuPuzzle(1, 3)
+ markLine(singleExit, lineKey([0, 0], [0, 1]), 'line')
+
+ const rule = createLineComponentEndpointStrongInferenceRule(() => [])
+ expect(rule.apply(closed)).toBeNull()
+ expect(rule.apply(singleExit)).toBeNull()
+ })
+
+ it('does not copy harmless trial progress back into the real puzzle', () => {
+ const puzzle = createMasyuPuzzle(3, 4)
+ const east = lineKey([0, 1], [0, 2])
+ const unrelated = lineKey([2, 2], [2, 3])
+ markLine(puzzle, lineKey([0, 0], [0, 1]), 'line')
+ const harmlessRule: Rule = {
+ id: 'test-endpoint-harmless-progress',
+ name: 'Test Endpoint Harmless Progress',
+ apply: (trial) =>
+ trial.lines[east]?.mark === 'line' &&
+ trial.lines[unrelated]?.mark === 'unknown'
+ ? {
+ message: 'Harmless endpoint trial progress',
+ diffs: [
+ {
+ kind: 'line',
+ lineKey: unrelated,
+ from: 'unknown',
+ to: 'line',
+ },
+ ],
+ affectedCells: [],
+ affectedLines: [unrelated],
+ }
+ : null,
+ }
+
+ const result = createLineComponentEndpointStrongInferenceRule(() => [
+ harmlessRule,
+ ], { maxTrialSteps: 1 }).apply(puzzle)
+
+ expect(result).toBeNull()
+ expect(puzzle.lines[unrelated]?.mark).toBe('unknown')
+ })
+
+ it('honors timeout and candidate limits', () => {
+ const puzzle = createMasyuPuzzle(3, 4)
+ markLine(puzzle, lineKey([0, 0], [0, 1]), 'line')
+ markLine(puzzle, lineKey([0, 2], [0, 3]), 'line')
+ markLine(puzzle, lineKey([0, 2], [1, 2]), 'line')
+
+ expect(
+ createLineComponentEndpointStrongInferenceRule(() => [], {
+ maxMs: -1,
+ }).apply(puzzle),
+ ).toBeNull()
+ expect(
+ createLineComponentEndpointStrongInferenceRule(() => [], {
+ maxCandidates: 1,
+ }).apply(puzzle),
+ ).not.toBeNull()
+ })
+})
diff --git a/src/domain/rules/masyu/tests/registration.test.ts b/src/domain/rules/masyu/tests/registration.test.ts
index b6f5626..0bb523c 100644
--- a/src/domain/rules/masyu/tests/registration.test.ts
+++ b/src/domain/rules/masyu/tests/registration.test.ts
@@ -24,8 +24,8 @@ describe('Masyu rule registration', () => {
'Adjacent White Pearls LookAhead',
'Cell Exit Completion',
'Black Pearl Strong Inference',
+ 'Masyu Line Component Endpoint Strong Inference',
'White Pearl Strong Inference',
- 'Empty Cell Strong Inference',
])
})
@@ -76,4 +76,12 @@ describe('Masyu rule registration', () => {
rules.indexOf('cell-exit-completion'),
)
})
+
+ it('registers bounded strong inference in black-endpoint-white order', () => {
+ expect(masyuPlugin.getRules().slice(-3).map((rule) => rule.id)).toEqual([
+ 'masyu-black-pearl-strong-inference',
+ 'masyu-line-component-endpoint-strong-inference',
+ 'masyu-white-pearl-strong-inference',
+ ])
+ })
})