diff --git a/src/app/workspace.css b/src/app/workspace.css
index 3182dde..43868b3 100644
--- a/src/app/workspace.css
+++ b/src/app/workspace.css
@@ -1,32 +1,61 @@
.workspace {
min-height: 100vh;
padding: 16px;
- background: radial-gradient(circle at top, #111827, #020617 65%);
+ background: #ffffff;
}
.workspace-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-width: 0;
margin-bottom: 0;
- border: 1px solid #1e293b;
+ border: 1px solid #e5e7eb;
border-radius: 12px;
- background: #0b1222;
+ background: #ffffff;
padding: 12px;
}
.workspace-title h1 {
margin: 0;
font-size: 1.25rem;
- color: #f8fafc;
+ color: #0f172a;
}
.workspace-title p {
margin: 4px 0 0;
- color: #cbd5e1;
+ color: #4b5563;
+}
+
+.workspace-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.workspace-nav a {
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ background: #f8fafc;
+ color: #1f2937;
+ padding: 7px 10px;
+ text-decoration: none;
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+
+.workspace-nav a[aria-current='page'] {
+ border-color: #22d3ee;
+ background: #e0f2fe;
+ color: #0f172a;
}
.workspace-grid {
display: grid;
- grid-template-columns: 2fr 1fr;
+ grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
gap: 16px;
+ min-width: 0;
}
.left-column,
@@ -34,13 +63,15 @@
display: flex;
flex-direction: column;
gap: 16px;
+ min-width: 0;
}
.panel-card,
.board-card {
- border: 1px solid #1e293b;
+ min-width: 0;
+ border: 1px solid #e5e7eb;
border-radius: 12px;
- background: #0b1222;
+ background: #ffffff;
padding: 12px;
}
@@ -58,7 +89,7 @@
.panel-header h2 {
margin: 0;
font-size: 1rem;
- color: #f8fafc;
+ color: #0f172a;
}
.board-panel-header h2 {
@@ -68,10 +99,38 @@
gap: 6px;
}
+.board-header-tools {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 10px;
+}
+
+.board-zoom-control {
+ display: grid;
+ grid-template-columns: auto minmax(110px, 160px) minmax(44px, auto);
+ align-items: center;
+ gap: 8px;
+ color: #4b5563;
+ font-size: 0.84rem;
+}
+
+.board-zoom-control input {
+ width: 100%;
+ accent-color: #0891b2;
+}
+
+.board-zoom-control output {
+ color: #0f172a;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
.board-dimensions {
font-size: 0.88rem;
font-weight: 600;
- color: #94a3b8;
+ color: #6b7280;
}
.board-focus-shell {
@@ -79,29 +138,56 @@
}
.board-focus-shell:focus-visible {
- box-shadow: 0 0 0 2px #334155;
+ box-shadow: 0 0 0 2px #cbd5e1;
border-radius: 12px;
}
.panel-header small {
- color: #94a3b8;
+ color: #6b7280;
+}
+
+.explanation-panel-header {
+ gap: 10px;
+}
+
+.explanation-header-tools {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 8px;
}
.board-canvas {
width: 100%;
max-width: 100%;
- border: 1px solid #1e293b;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
- cursor: grab;
+ cursor: default;
}
-.board-canvas:active {
- cursor: grabbing;
+.board-scroll-shell {
+ width: 100%;
+ min-width: 0;
+ max-height: min(72vh, 720px);
+ overflow: auto;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #f8fafc;
+}
+
+.scroll-board-canvas,
+.editor-board-canvas {
+ display: block;
+ max-width: none;
+ border: 0;
+ border-radius: 0;
+ cursor: default;
}
.board-hint {
margin: 8px 0;
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.9rem;
}
@@ -119,18 +205,23 @@
button,
select,
textarea,
-input[type='number'] {
- border: 1px solid #334155;
+input[type='number'],
+input[type='search'] {
+ border: 1px solid #cbd5e1;
border-radius: 8px;
- background: #0f172a;
- color: #e2e8f0;
+ background: #f8fafc;
+ color: #1f2937;
padding: 7px 8px;
font: inherit;
font-size: 0.92rem;
}
+input[type='range'] {
+ font: inherit;
+}
+
button:hover {
- border-color: #64748b;
+ border-color: #94a3b8;
}
button:disabled {
@@ -139,14 +230,14 @@ button:disabled {
button[data-active='true'] {
border-color: #22d3ee;
- background: #0b1e34;
+ background: #e0f2fe;
}
.label-row {
display: flex;
flex-direction: column;
gap: 6px;
- color: #cbd5e1;
+ color: #4b5563;
margin-bottom: 6px;
}
@@ -161,7 +252,7 @@ button[data-active='true'] {
.type-row-label {
display: block;
- color: #cbd5e1;
+ color: #4b5563;
margin-bottom: 6px;
}
@@ -194,10 +285,10 @@ button[data-active='true'] {
}
.control-group {
- border: 1px solid #1e293b;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 7px;
- background: #08111f;
+ background: #f9fafb;
}
.compact-control-group {
@@ -207,7 +298,7 @@ button[data-active='true'] {
.control-group-title {
display: block;
margin-bottom: 6px;
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
@@ -244,7 +335,7 @@ button[data-active='true'] {
display: flex;
flex-direction: column;
gap: 4px;
- color: #cbd5e1;
+ color: #4b5563;
font-size: 0.86rem;
}
@@ -262,13 +353,13 @@ button[data-active='true'] {
align-items: baseline;
justify-content: space-between;
gap: 8px;
- color: #cbd5e1;
+ color: #4b5563;
font-size: 0.86rem;
}
.timeline-header span {
flex: none;
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.8rem;
}
@@ -292,13 +383,13 @@ button[data-active='true'] {
transform: translateX(-50%);
pointer-events: none;
white-space: nowrap;
- border: 1px solid #334155;
+ border: 1px solid #cbd5e1;
border-radius: 6px;
- background: #020617;
- color: #e2e8f0;
+ background: #ffffff;
+ color: #1f2937;
padding: 2px 6px;
font-size: 0.76rem;
- box-shadow: 0 8px 20px rgb(0 0 0 / 0.32);
+ box-shadow: 0 8px 20px rgb(15 23 42 / 0.12);
}
.sr-only {
@@ -339,16 +430,16 @@ button[data-active='true'] {
min-width: 220px;
padding: 12px;
border-radius: 10px;
- border: 1px solid #334155;
- background: #0f172a;
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
+ border: 1px solid #cbd5e1;
+ background: #f8fafc;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
}
.custom-grid-popover-title {
margin: 0 0 10px;
font-size: 0.92rem;
font-weight: 600;
- color: #f8fafc;
+ color: #0f172a;
}
.custom-grid-field {
@@ -357,7 +448,7 @@ button[data-active='true'] {
gap: 4px;
margin-bottom: 8px;
font-size: 0.86rem;
- color: #cbd5e1;
+ color: #4b5563;
}
.custom-grid-field input {
@@ -374,7 +465,7 @@ button[data-active='true'] {
.custom-grid-hint {
margin: 10px 0 0;
font-size: 0.78rem;
- color: #94a3b8;
+ color: #6b7280;
line-height: 1.35;
}
@@ -383,7 +474,12 @@ button[data-active='true'] {
display: flex;
align-items: center;
gap: 6px;
- color: #cbd5e1;
+ color: #4b5563;
+}
+
+.solver-check-row {
+ margin-bottom: 0;
+ font-size: 0.86rem;
}
.custom-grid-check-row {
@@ -405,19 +501,19 @@ button[data-active='true'] {
display: grid;
place-items: center;
padding: 16px;
- background: rgb(2 6 23 / 0.62);
+ background: rgb(15 23 42 / 0.28);
}
.import-error-dialog {
width: min(520px, 100%);
max-height: min(620px, calc(100vh - 32px));
overflow: auto;
- border: 1px solid #7f1d1d;
+ border: 1px solid #fecaca;
border-radius: 8px;
- background: #120b12;
- box-shadow: 0 14px 36px rgb(0 0 0 / 0.35);
+ background: #fff1f2;
+ box-shadow: 0 14px 36px rgb(15 23 42 / 0.16);
padding: 10px;
- color: #fecdd3;
+ color: #9f1239;
}
.import-error-header {
@@ -430,18 +526,18 @@ button[data-active='true'] {
.import-error-header h3 {
margin: 0;
- color: #fff1f2;
+ color: #881337;
font-size: 0.98rem;
}
.import-error-summary {
margin: 0 0 8px;
- color: #fecdd3;
+ color: #9f1239;
line-height: 1.4;
}
.import-error-details summary {
- color: #fda4af;
+ color: #be123c;
}
.import-error-details pre {
@@ -449,15 +545,15 @@ button[data-active='true'] {
overflow: auto;
margin: 8px 0 0;
padding: 8px;
- border: 1px solid #3f1d2a;
+ border: 1px solid #fecdd3;
border-radius: 8px;
- background: #070a13;
- color: #fecdd3;
+ background: #ffffff;
+ color: #9f1239;
}
.divider {
border: 0;
- border-top: 1px solid #1e293b;
+ border-top: 1px solid #e5e7eb;
margin: 10px 0;
}
@@ -474,12 +570,12 @@ button[data-active='true'] {
right: 12px;
z-index: 35;
width: min(360px, calc(100% - 24px));
- border: 1px solid #334155;
+ border: 1px solid #cbd5e1;
border-radius: 8px;
- background: #08111f;
- box-shadow: 0 18px 48px rgb(0 0 0 / 0.38);
+ background: #f9fafb;
+ box-shadow: 0 18px 48px rgb(15 23 42 / 0.16);
padding: 12px;
- color: #cbd5e1;
+ color: #4b5563;
}
.export-panel-header {
@@ -492,13 +588,13 @@ button[data-active='true'] {
.export-panel-header h2 {
margin: 0;
- color: #f8fafc;
+ color: #0f172a;
font-size: 1rem;
}
.copy-feedback {
margin: 6px 0;
- color: #7dd3fc;
+ color: #0284c7;
font-size: 0.86rem;
}
@@ -509,37 +605,37 @@ button[data-active='true'] {
display: grid;
place-items: center;
padding: 16px;
- background: rgb(2 6 23 / 0.62);
+ background: rgb(15 23 42 / 0.28);
}
.solve-progress-modal {
width: min(360px, 100%);
- border: 1px solid #334155;
+ border: 1px solid #cbd5e1;
border-radius: 8px;
- background: #08111f;
- box-shadow: 0 18px 48px rgb(0 0 0 / 0.35);
+ background: #f9fafb;
+ box-shadow: 0 18px 48px rgb(15 23 42 / 0.16);
padding: 16px;
- color: #cbd5e1;
+ color: #4b5563;
}
.solve-progress-modal h2 {
margin: 0;
- color: #f8fafc;
+ color: #0f172a;
font-size: 1rem;
}
.solve-progress-count {
margin: 10px 0 8px;
- color: #e2e8f0;
+ color: #1f2937;
font-weight: 600;
}
.solve-progress-bar {
height: 10px;
overflow: hidden;
- border: 1px solid #334155;
+ border: 1px solid #cbd5e1;
border-radius: 999px;
- background: #020617;
+ background: #ffffff;
}
.solve-progress-bar span {
@@ -550,17 +646,17 @@ button[data-active='true'] {
.solve-progress-message {
margin: 8px 0 0;
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.9rem;
}
.solve-report-dialog {
margin-top: 10px;
- border: 1px solid #334155;
+ border: 1px solid #cbd5e1;
border-radius: 8px;
- background: #08111f;
+ background: #f9fafb;
padding: 10px;
- color: #cbd5e1;
+ color: #4b5563;
}
.solve-report-header {
@@ -574,7 +670,7 @@ button[data-active='true'] {
.solve-report-header h3,
.solve-report-section h4 {
margin: 0;
- color: #f8fafc;
+ color: #0f172a;
}
.solve-report-header h3 {
@@ -593,7 +689,7 @@ button[data-active='true'] {
}
.solve-report-grid > div {
- border: 1px solid #1e293b;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px;
display: flex;
@@ -602,12 +698,12 @@ button[data-active='true'] {
}
.solve-report-grid span {
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.82rem;
}
.solve-report-grid strong {
- color: #f8fafc;
+ color: #0f172a;
}
.solve-report-section {
@@ -635,10 +731,10 @@ button[data-active='true'] {
}
.step-item {
- border: 1px solid #1e293b;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px;
- background: #0f172a;
+ background: #f8fafc;
}
.step-item.active {
@@ -646,23 +742,23 @@ button[data-active='true'] {
}
.step-item.muted {
- color: #94a3b8;
+ color: #6b7280;
}
.step-title {
margin: 0;
- color: #e2e8f0;
+ color: #1f2937;
font-weight: 600;
}
.step-message {
margin: 4px 0 0;
- color: #cbd5e1;
+ color: #4b5563;
}
.step-meta {
margin: 4px 0 0;
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.85rem;
}
@@ -673,7 +769,7 @@ button[data-active='true'] {
}
.stats-grid > div {
- border: 1px solid #1e293b;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px;
display: flex;
@@ -682,27 +778,247 @@ button[data-active='true'] {
}
.stats-grid span {
- color: #94a3b8;
+ color: #6b7280;
font-size: 0.85rem;
}
.stats-grid strong {
- color: #f8fafc;
+ color: #0f172a;
}
.rule-usage {
margin: 8px 0 0;
padding-left: 18px;
- color: #cbd5e1;
+ color: #4b5563;
+}
+
+.editor-board-card {
+ min-height: 0;
+}
+
+.editor-size-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 8px;
+}
+
+.editor-size-row button {
+ min-width: 96px;
+}
+
+.editor-url-field {
+ margin-top: 8px;
+}
+
+.primary-action {
+ border-color: #22d3ee;
+ background: #e0f2fe;
+ color: #0f172a;
+ font-weight: 700;
+}
+
+.preset-card-meta,
+.preset-description {
+ color: #6b7280;
+ font-size: 0.82rem;
+ line-height: 1.35;
+}
+
+.preset-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.preset-tags span {
+ border: 1px solid #cbd5e1;
+ border-radius: 999px;
+ padding: 2px 6px;
+ color: #4b5563;
+ font-size: 0.74rem;
+}
+
+.preset-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgb(15 23 42 / 0.36);
+}
+
+.preset-modal {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ width: min(1120px, 94vw);
+ height: min(900px, 94vh);
+ min-height: 0;
+ overflow: hidden;
+ border: 1px solid #cbd5e1;
+ border-radius: 12px;
+ background: #ffffff;
+ padding: 16px;
+ box-shadow: 0 24px 80px rgb(15 23 42 / 0.22);
+}
+
+.preset-modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.preset-modal-header h2 {
+ margin: 0;
+ color: #0f172a;
+ font-size: 1.2rem;
+}
+
+.preset-modal-header p {
+ margin: 4px 0 0;
+ color: #4b5563;
+}
+
+.preset-modal-close {
+ flex: 0 0 auto;
+}
+
+.preset-modal-tools {
+ display: grid;
+ grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
+ align-items: end;
+ gap: 12px;
+}
+
+.preset-search-field input {
+ min-height: 38px;
+}
+
+.preset-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.preset-filter-row button {
+ min-height: 32px;
+}
+
+.preset-filter-row button[data-active='true'] {
+ border-color: #22d3ee;
+ background: #e0f2fe;
+ color: #0f172a;
+ font-weight: 700;
+}
+
+.preset-action-error {
+ flex: 0 0 auto;
+ margin: 0;
+}
+
+.preset-grid-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.preset-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
+ gap: 12px;
+}
+
+.preset-library-card {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 10px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #ffffff;
+ padding: 10px;
+}
+
+.preset-library-card[data-active='true'] {
+ border-color: #22d3ee;
+ box-shadow: 0 0 0 2px #cffafe;
+}
+
+.preset-preview {
+ display: grid;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background:
+ linear-gradient(#e5e7eb 1px, transparent 1px),
+ linear-gradient(90deg, #e5e7eb 1px, transparent 1px),
+ #f8fafc;
+ background-size: 24px 24px;
+ color: #475569;
+ font-weight: 700;
+}
+
+.preset-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.preset-preview-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.preset-card-body {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.preset-card-body h3 {
+ margin: 0;
+ color: #0f172a;
+ font-size: 1rem;
+}
+
+.preset-card-body .preset-description {
+ margin: 0;
+}
+
+.preset-card-actions {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.preset-card-actions button {
+ min-width: 0;
+ padding-right: 6px;
+ padding-left: 6px;
+}
+
+.preset-empty {
+ margin: 0;
+ color: #6b7280;
}
details summary {
cursor: pointer;
- color: #cbd5e1;
+ color: #4b5563;
}
details pre {
- color: #cbd5e1;
+ color: #4b5563;
font-size: 12px;
white-space: pre-wrap;
}
@@ -714,6 +1030,11 @@ details pre {
}
@media (max-width: 520px) {
+ .workspace-title {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
.io-action-row {
grid-template-columns: 1fr;
}
@@ -727,4 +1048,29 @@ details pre {
font-size: 0.82rem;
padding-inline: 6px;
}
+
+ .editor-size-row {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .preset-modal-backdrop {
+ padding: 12px;
+ }
+
+ .preset-modal {
+ width: 100%;
+ height: 94vh;
+ }
+
+ .preset-modal-header {
+ flex-direction: column;
+ }
+
+ .preset-modal-tools {
+ grid-template-columns: 1fr;
+ }
+
+ .preset-modal-close {
+ width: 100%;
+ }
}
diff --git a/src/domain/ir/keys.ts b/src/domain/ir/keys.ts
index fa304da..c4405a2 100644
--- a/src/domain/ir/keys.ts
+++ b/src/domain/ir/keys.ts
@@ -2,11 +2,18 @@ import type { CellCoord, SectorCorner, Vertex } from './types'
export const cellKey = (row: number, col: number): string => `${row},${col}`
+export const vertexKey = (row: number, col: number): string => `${row},${col}`
+
export const parseCellKey = (key: string): CellCoord => {
const [r, c] = key.split(',').map(Number)
return [r, c]
}
+export const parseVertexKey = (key: string): Vertex => {
+ const [r, c] = key.split(',').map(Number)
+ return [r, c]
+}
+
export const sectorKey = (row: number, col: number, corner: SectorCorner): string =>
`${row},${col}:${corner}`
diff --git a/src/domain/ir/normalize.ts b/src/domain/ir/normalize.ts
index e7f6ea6..af66cae 100644
--- a/src/domain/ir/normalize.ts
+++ b/src/domain/ir/normalize.ts
@@ -1,4 +1,4 @@
-import { cellKey, edgeKey, parseSectorKey } from './keys'
+import { cellKey, edgeKey, parseSectorKey, parseVertexKey } from './keys'
import type { PuzzleIR } from './types'
const compareCoord = (a: string, b: string): number => {
@@ -57,6 +57,19 @@ export const normalizePuzzle = (puzzle: PuzzleIR): Record
=> {
return acc
}, {})
+ const vertices = Object.entries(puzzle.vertices ?? {})
+ .sort(([a], [b]) => {
+ const [ar, ac] = parseVertexKey(a)
+ const [br, bc] = parseVertexKey(b)
+ return ar === br ? ac - bc : ar - br
+ })
+ .reduce>((acc, [key, state]) => {
+ acc[key] = {
+ candidateEdgeSets: state.candidateEdgeSets.map((candidate) => [...candidate]),
+ }
+ return acc
+ }, {})
+
return {
gridType: puzzle.gridType,
puzzleType: puzzle.puzzleType,
@@ -67,6 +80,7 @@ export const normalizePuzzle = (puzzle: PuzzleIR): Record => {
cells,
edges,
sectors,
+ vertices,
}
}
diff --git a/src/domain/ir/slither.ts b/src/domain/ir/slither.ts
index 5ce5e14..f73c0ba 100644
--- a/src/domain/ir/slither.ts
+++ b/src/domain/ir/slither.ts
@@ -1,10 +1,28 @@
-import { edgeKey, sectorKey } from './keys'
-import { defaultPuzzleIR, SECTOR_MASK_ALL, type PuzzleIR } from './types'
+import { edgeKey, getVertexIncidentEdges, sectorKey, vertexKey } from './keys'
+import { defaultPuzzleIR, SECTOR_MASK_ALL, type PuzzleIR, type VertexCandidate } from './types'
/** Inclusive bounds for custom grid and puzz.link export validation. */
export const SLITHER_CUSTOM_GRID_MIN = 3
export const SLITHER_CUSTOM_GRID_MAX = 100
+const createInitialVertexCandidates = (
+ row: number,
+ col: number,
+ rows: number,
+ cols: number,
+): VertexCandidate[] => {
+ const incident = getVertexIncidentEdges(row, col, rows, cols)
+ const candidates: VertexCandidate[] = [[]]
+ for (let i = 0; i < incident.length; i += 1) {
+ for (let j = i + 1; j < incident.length; j += 1) {
+ candidates.push([incident[i], incident[j]])
+ }
+ }
+ return candidates
+ .map((candidate) => [...candidate].sort())
+ .sort((a, b) => a.length - b.length || a.join('|').localeCompare(b.join('|')))
+}
+
export const createSlitherPuzzle = (rows: number, cols: number): PuzzleIR => {
const puzzle = defaultPuzzleIR()
puzzle.puzzleType = 'slitherlink'
@@ -31,5 +49,12 @@ export const createSlitherPuzzle = (rows: number, cols: number): PuzzleIR => {
puzzle.sectors[sectorKey(r, c, 'se')] = { constraintsMask: SECTOR_MASK_ALL }
}
}
+ for (let r = 0; r <= rows; r += 1) {
+ for (let c = 0; c <= cols; c += 1) {
+ puzzle.vertices[vertexKey(r, c)] = {
+ candidateEdgeSets: createInitialVertexCandidates(r, c, rows, cols),
+ }
+ }
+ }
return puzzle
}
diff --git a/src/domain/ir/types.ts b/src/domain/ir/types.ts
index efca88d..33cde43 100644
--- a/src/domain/ir/types.ts
+++ b/src/domain/ir/types.ts
@@ -28,6 +28,7 @@ export type EdgeMark = 'unknown' | 'line' | 'blank'
export type SectorCorner = 'nw' | 'ne' | 'sw' | 'se'
export type SectorLineCount = 0 | 1 | 2
export type SectorConstraintMask = number
+export type VertexCandidate = string[]
export const SECTOR_ALLOW_0: SectorConstraintMask = 1 << 0
export const SECTOR_ALLOW_1: SectorConstraintMask = 1 << 1
@@ -78,6 +79,10 @@ export type SectorState = {
constraintsMask: SectorConstraintMask
}
+export type VertexState = {
+ candidateEdgeSets: VertexCandidate[]
+}
+
export interface PuzzleIR {
gridType: GridType
puzzleType: PuzzleKind
@@ -91,6 +96,7 @@ export interface PuzzleIR {
cells: Record
edges: Record
sectors: Record
+ vertices: Record
metadata: Record
}
@@ -107,5 +113,6 @@ export const defaultPuzzleIR = (): PuzzleIR => ({
cells: {},
edges: {},
sectors: {},
+ vertices: {},
metadata: {},
})
diff --git a/src/domain/rules/engine.bench.ts b/src/domain/rules/engine.bench.ts
index d3916f8..b90aa45 100644
--- a/src/domain/rules/engine.bench.ts
+++ b/src/domain/rules/engine.bench.ts
@@ -57,6 +57,14 @@ const applyDiffsWithJsonClone = () => {
}
continue
}
+ if (diff.kind === 'vertex') {
+ if (!next.vertices[diff.vertexKey]) {
+ next.vertices[diff.vertexKey] = { candidateEdgeSets: diff.toCandidates }
+ } else {
+ next.vertices[diff.vertexKey].candidateEdgeSets = diff.toCandidates
+ }
+ continue
+ }
if (!next.cells[diff.cellKey]) {
next.cells[diff.cellKey] = {}
}
diff --git a/src/domain/rules/engine.test.ts b/src/domain/rules/engine.test.ts
index b4abb1a..43d1d5a 100644
--- a/src/domain/rules/engine.test.ts
+++ b/src/domain/rules/engine.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { decodeSlitherFromPuzzlink } from '../parsers/puzzlink'
+import { vertexKey } from '../ir/keys'
import { applyRuleDiffs, revertRuleDiffs, runNextRule } from './engine'
import { slitherRules } from './slither/rules'
import type { RuleDiff } from './types'
@@ -17,6 +18,9 @@ describe('rule engine', () => {
const puzzle = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/3/3/g0h')
const edgeKey = Object.keys(puzzle.edges)[0]
const sectorKey = Object.keys(puzzle.sectors)[0]
+ const centerVertexKey = vertexKey(1, 1)
+ const fromCandidates = puzzle.vertices[centerVertexKey].candidateEdgeSets
+ const toCandidates = fromCandidates.slice(0, 2)
const diffs: RuleDiff[] = [
{
kind: 'edge',
@@ -36,18 +40,27 @@ describe('rule engine', () => {
fromFill: null,
toFill: 'green',
},
+ {
+ kind: 'vertex',
+ vertexKey: centerVertexKey,
+ fromCandidates,
+ toCandidates,
+ },
]
const next = applyRuleDiffs(puzzle, diffs)
expect(next.edges[edgeKey].mark).toBe('line')
expect(next.sectors[sectorKey].constraintsMask).toBe(1)
expect(next.cells['0,0']?.fill).toBe('green')
+ expect(next.vertices[centerVertexKey].candidateEdgeSets).toEqual(toCandidates)
expect(puzzle.edges[edgeKey].mark).toBe('unknown')
expect(puzzle.cells['0,0']?.fill).toBeUndefined()
+ expect(puzzle.vertices[centerVertexKey].candidateEdgeSets).toEqual(fromCandidates)
const rewound = revertRuleDiffs(next, diffs)
expect(rewound.edges[edgeKey].mark).toBe('unknown')
expect(rewound.sectors[sectorKey].constraintsMask).toBe(puzzle.sectors[sectorKey].constraintsMask)
expect(rewound.cells['0,0']?.fill).toBeUndefined()
+ expect(rewound.vertices[centerVertexKey].candidateEdgeSets).toEqual(fromCandidates)
})
})
diff --git a/src/domain/rules/engine.ts b/src/domain/rules/engine.ts
index 1e6784b..1b58ec8 100644
--- a/src/domain/rules/engine.ts
+++ b/src/domain/rules/engine.ts
@@ -5,6 +5,7 @@ type WritableBuckets = {
cells: PuzzleIR['cells'] | null
edges: PuzzleIR['edges'] | null
sectors: PuzzleIR['sectors'] | null
+ vertices: PuzzleIR['vertices'] | null
}
const applyDiffEntry = (
@@ -33,6 +34,17 @@ const applyDiffEntry = (
writable.sectors[diff.sectorKey] = prev ? { ...prev, constraintsMask } : { constraintsMask }
return
}
+ if (diff.kind === 'vertex') {
+ const candidateEdgeSets = mode === 'forward' ? diff.toCandidates : diff.fromCandidates
+ if (!writable.vertices) {
+ writable.vertices = { ...(next.vertices ?? {}) }
+ next.vertices = writable.vertices
+ }
+ writable.vertices[diff.vertexKey] = {
+ candidateEdgeSets: candidateEdgeSets.map((candidate) => [...candidate]),
+ }
+ return
+ }
const toFill = mode === 'forward' ? diff.toFill : diff.fromFill
if (!writable.cells) {
writable.cells = { ...next.cells }
@@ -58,6 +70,7 @@ const applyRuleDiffsInternal = (
cells: null,
edges: null,
sectors: null,
+ vertices: null,
}
if (mode === 'forward') {
for (const diff of diffs) {
diff --git a/src/domain/rules/slither/rules.test.ts b/src/domain/rules/slither/rules.test.ts
index 6f3160e..8eb539d 100644
--- a/src/domain/rules/slither/rules.test.ts
+++ b/src/domain/rules/slither/rules.test.ts
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest'
import { decodeSlitherFromPuzzlink } from '../../parsers/puzzlink'
-import { cellKey, edgeKey, getCellEdgeKeys, getCornerEdgeKeys, parseEdgeKey, sectorKey } from '../../ir/keys'
+import {
+ cellKey,
+ edgeKey,
+ getCellEdgeKeys,
+ getCornerEdgeKeys,
+ getVertexIncidentEdges,
+ parseEdgeKey,
+ sectorKey,
+ vertexKey,
+} from '../../ir/keys'
import { clonePuzzle } from '../../ir/normalize'
import { createSlitherPuzzle } from '../../ir/slither'
import {
@@ -17,6 +26,7 @@ import { runNextRule } from '../engine'
import type { Rule } from '../types'
import { slitherRules } from './rules'
import { createColorAssumptionInferenceRule } from './rules/colorAssumptionInference'
+import { createSectorParityInferenceRule } from './rules/sectorParityInference'
import { createStrongInferenceRule } from './rules/strongInference'
import { runTrialUntilFixpoint } from './rules/trial'
@@ -360,6 +370,67 @@ describe('slither color-edge propagation rule', () => {
})
})
+ it('marks top boundary edge line when the boundary cell is green', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.cells[cellKey(0, 1)] = { fill: 'green' }
+ const top = edgeKey([0, 1], [0, 2])
+
+ const result = colorRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toContainEqual({
+ kind: 'edge',
+ edgeKey: top,
+ from: 'unknown',
+ to: 'line',
+ })
+ })
+
+ it('marks both outer boundary edges line when a corner cell is green', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+ const top = edgeKey([0, 0], [0, 1])
+ const left = edgeKey([0, 0], [1, 0])
+
+ const result = colorRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual(
+ expect.arrayContaining([
+ { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' },
+ { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' },
+ ]),
+ )
+ })
+
+ it('marks boundary edges blank when the boundary cell is yellow', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' }
+ const top = edgeKey([0, 0], [0, 1])
+ const left = edgeKey([0, 0], [1, 0])
+
+ const result = colorRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual(
+ expect.arrayContaining([
+ { kind: 'edge', edgeKey: top, from: 'unknown', to: 'blank' },
+ { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' },
+ ]),
+ )
+ })
+
+ it('does not emit a phantom cell diff across an already decided boundary edge', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ const top = edgeKey([0, 0], [0, 1])
+ puzzle.edges[top].mark = 'line'
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+
+ const result = colorRule.apply(puzzle)
+
+ expect(result?.diffs.some((diff) => diff.kind === 'cell' && diff.cellKey === undefined)).not.toBe(true)
+ })
+
it('infers opposite color across a line edge', () => {
const puzzle = createSlitherPuzzle(2, 2)
const between = edgeKey([0, 1], [1, 1])
@@ -765,6 +836,206 @@ describe('slither inside reachability coloring rule', () => {
})
})
+describe('slither outside reachability coloring rule', () => {
+ const outsideReachabilityRule = slitherRules.find((rule) => rule.id === 'outside-reachability-coloring')
+ if (!outsideReachabilityRule) {
+ throw new Error('Expected outside-reachability-coloring rule')
+ }
+
+ it('colors a fully line-enclosed unknown cell green', () => {
+ const puzzle = createSlitherPuzzle(1, 1)
+ puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line'
+ puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line'
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+ puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line'
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' },
+ ])
+ })
+
+ it('does not color a boundary cell reachable through an unknown outside edge', () => {
+ const puzzle = createSlitherPuzzle(1, 1)
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+
+ it('does not color a boundary cell reachable through a blank outside edge', () => {
+ const puzzle = createSlitherPuzzle(1, 1)
+ puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank'
+ puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line'
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+ puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line'
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+
+ it('traverses unknown and blank edges but does not cross a line edge', () => {
+ const puzzle = createSlitherPuzzle(1, 4)
+ for (let col = 0; col < 3; col += 1) {
+ puzzle.edges[edgeKey([0, col], [0, col + 1])].mark = 'line'
+ puzzle.edges[edgeKey([1, col], [1, col + 1])].mark = 'line'
+ }
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+ puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank'
+ puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line'
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' },
+ { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' },
+ { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' },
+ ])
+ })
+
+ it('treats existing green cells as traversal blockers without overwriting them', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ puzzle.cells[cellKey(0, 1)] = { fill: 'green' }
+ puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line'
+ puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line'
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' },
+ ])
+ expect(result?.diffs).not.toContainEqual({
+ kind: 'cell',
+ cellKey: cellKey(0, 1),
+ fromFill: 'green',
+ toFill: 'green',
+ })
+ })
+
+ it('does not traverse into clue-3 cells and does not color clue-3 cells green', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ setClue(puzzle, 0, 1, 3)
+ puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line'
+ puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line'
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' },
+ ])
+ expect(result?.diffs).not.toContainEqual({
+ kind: 'cell',
+ cellKey: cellKey(0, 1),
+ fromFill: null,
+ toFill: 'green',
+ })
+ })
+
+ it('uses existing yellow cells as outside reachability sources', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' }
+ for (let col = 0; col < 3; col += 1) {
+ puzzle.edges[edgeKey([0, col], [0, col + 1])].mark = 'line'
+ puzzle.edges[edgeKey([1, col], [1, col + 1])].mark = 'line'
+ }
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+ puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line'
+
+ const result = outsideReachabilityRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+})
+
+describe('slither color connectivity cut coloring rule', () => {
+ const cutColorRule = slitherRules.find((rule) => rule.id === 'color-connectivity-cut-coloring')
+ if (!cutColorRule) {
+ throw new Error('Expected color-connectivity-cut-coloring rule')
+ }
+
+ it('colors an unknown articulation cell green between two green components', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+ puzzle.cells[cellKey(0, 2)] = { fill: 'green' }
+
+ const result = cutColorRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' },
+ ])
+ })
+
+ it('does not apply when there is only one green source component', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+
+ const result = cutColorRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+
+ it('does not color across a line-separated green component', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+ puzzle.cells[cellKey(0, 2)] = { fill: 'green' }
+ puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line'
+
+ const result = cutColorRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+
+ it('does not traverse through an unknown clue-3 cell', () => {
+ const puzzle = createSlitherPuzzle(1, 3)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+ puzzle.cells[cellKey(0, 2)] = { fill: 'green' }
+ setClue(puzzle, 0, 1, 3)
+
+ const result = cutColorRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+
+ it('colors every unknown cell inside a blank-compressed green bottleneck', () => {
+ const puzzle = createSlitherPuzzle(1, 4)
+ puzzle.cells[cellKey(0, 0)] = { fill: 'green' }
+ puzzle.cells[cellKey(0, 3)] = { fill: 'green' }
+ puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank'
+
+ const result = cutColorRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' },
+ { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' },
+ ])
+ })
+
+ it('colors an outside-to-yellow bottleneck yellow', () => {
+ const puzzle = createSlitherPuzzle(3, 3)
+ puzzle.cells[cellKey(1, 1)] = { fill: 'yellow' }
+ puzzle.cells[cellKey(1, 0)] = { fill: 'green' }
+ puzzle.cells[cellKey(1, 2)] = { fill: 'green' }
+ puzzle.cells[cellKey(2, 1)] = { fill: 'green' }
+
+ const result = cutColorRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' },
+ ])
+ })
+})
+
describe('slither color sector-mask propagation rule', () => {
const sectorColorRule = slitherRules.find((rule) => rule.id === 'color-sector-mask-propagation')
if (!sectorColorRule) {
@@ -896,6 +1167,8 @@ describe('slither prevent premature loop rule', () => {
(rule) => rule.id === 'color-orthogonal-consensus-propagation',
)
const reachabilityRuleIdx = slitherRules.findIndex((rule) => rule.id === 'inside-reachability-coloring')
+ const outsideReachabilityRuleIdx = slitherRules.findIndex((rule) => rule.id === 'outside-reachability-coloring')
+ const cutColorRuleIdx = slitherRules.findIndex((rule) => rule.id === 'color-connectivity-cut-coloring')
const antiLoopRuleIdx = slitherRules.findIndex((rule) => rule.id === 'prevent-premature-loop')
expect(vertexRuleIdx).toBeGreaterThanOrEqual(0)
expect(outsideRuleIdx).toBe(vertexRuleIdx + 1)
@@ -904,7 +1177,9 @@ describe('slither prevent premature loop rule', () => {
expect(sectorColorRuleIdx).toBe(clueRuleIdx + 1)
expect(orthogonalConsensusRuleIdx).toBe(sectorColorRuleIdx + 1)
expect(reachabilityRuleIdx).toBe(orthogonalConsensusRuleIdx + 1)
- expect(antiLoopRuleIdx).toBe(reachabilityRuleIdx + 1)
+ expect(outsideReachabilityRuleIdx).toBe(reachabilityRuleIdx + 1)
+ expect(cutColorRuleIdx).toBe(outsideReachabilityRuleIdx + 1)
+ expect(antiLoopRuleIdx).toBe(cutColorRuleIdx + 1)
})
it('marks an unknown edge blank when it would close a loop', () => {
@@ -1037,7 +1312,7 @@ describe('slither sector notOne clue-2 propagation rule', () => {
}
if (
step.ruleId === 'sector-not-one-clue-two-propagation' ||
- step.ruleId === 'sector-clue-two-combination-feasibility'
+ step.ruleId === 'clue-vertex-candidate-combination-pruning'
) {
triggered = true
break
@@ -1140,11 +1415,12 @@ describe('slither sector diagonal shared-vertex propagation rule', () => {
})
it('appears during stepwise solving for the provided 8x8 puzzle', () => {
+ const rulesWithoutCutColoring = slitherRules.filter((rule) => rule.id !== 'color-connectivity-cut-coloring')
let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/8/8/gdg1dddbdid26d72ccicadc3cgc')
let triggered = false
for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) {
- const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber)
+ const { nextPuzzle, step } = runNextRule(current, rulesWithoutCutColoring, stepNumber)
if (!step) {
break
}
@@ -1180,81 +1456,76 @@ describe('slither sector diagonal shared-vertex propagation rule', () => {
})
})
-describe('slither sector clue-2 combination feasibility rule', () => {
- const combinationRule = slitherRules.find((rule) => rule.id === 'sector-clue-two-combination-feasibility')
+describe('slither vertex candidate edge pruning rule', () => {
+ const vertexRule = slitherRules.find((rule) => rule.id === 'vertex-candidate-edge-pruning')
+ if (!vertexRule) {
+ throw new Error('Expected vertex-candidate-edge-pruning rule')
+ }
+
+ it('prunes vertex candidates from known line and blank edges and forces the remaining continuation', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ const [up, down, left, right] = getVertexIncidentEdges(1, 1, puzzle.rows, puzzle.cols)
+ puzzle.edges[up].mark = 'line'
+ puzzle.edges[down].mark = 'blank'
+ puzzle.edges[left].mark = 'blank'
+
+ const result = vertexRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toContainEqual({
+ kind: 'vertex',
+ vertexKey: vertexKey(1, 1),
+ fromCandidates: puzzle.vertices[vertexKey(1, 1)].candidateEdgeSets,
+ toCandidates: [[right, up].sort()],
+ })
+ expect(result?.diffs).toContainEqual({ kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' })
+ })
+})
+
+describe('slither clue vertex-candidate combination pruning rule', () => {
+ const combinationRule = slitherRules.find((rule) => rule.id === 'clue-vertex-candidate-combination-pruning')
if (!combinationRule) {
- throw new Error('Expected sector-clue-two-combination-feasibility rule')
+ throw new Error('Expected clue-vertex-candidate-combination-pruning rule')
}
- it('at (0,0) with clue=2 prunes impossible patterns and tightens sectors to notOne/onlyOne', () => {
+ it('prunes clue-0 corners to onlyZero sector masks', () => {
const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 0, 0, 2)
+ setClue(puzzle, 1, 1, 0)
const result = combinationRule.apply(puzzle)
expect(result).not.toBeNull()
- expect(result?.diffs).toEqual(
- expect.arrayContaining([
- {
- kind: 'sector',
- sectorKey: sectorKey(0, 0, 'nw'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_1,
- },
- {
- kind: 'sector',
- sectorKey: sectorKey(0, 0, 'se'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_1,
- },
- {
- kind: 'sector',
- sectorKey: sectorKey(0, 0, 'ne'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_1,
- },
- {
- kind: 'sector',
- sectorKey: sectorKey(0, 0, 'sw'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_1,
- },
- ]),
- )
- expect(result?.diffs).toHaveLength(4)
+ for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
+ expect(result?.diffs).toContainEqual({
+ kind: 'sector',
+ sectorKey: sectorKey(1, 1, corner),
+ fromMask: SECTOR_MASK_ALL,
+ toMask: SECTOR_MASK_ONLY_0,
+ })
+ }
+ expect(result?.diffs.some((diff) => diff.kind === 'vertex')).toBe(true)
})
- it('when one edge is pre-marked, keeps only feasible combos and can force exact sector masks', () => {
+ it('prunes clue-1 corners to notTwo sector masks', () => {
const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 0, 0, 2)
- const [topEdge] = getCellEdgeKeys(0, 0)
- puzzle.edges[topEdge].mark = 'line'
+ setClue(puzzle, 1, 1, 1)
const result = combinationRule.apply(puzzle)
expect(result).not.toBeNull()
- expect(result?.diffs).toEqual(
- expect.arrayContaining([
- {
- kind: 'sector',
- sectorKey: sectorKey(0, 0, 'nw'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_2,
- },
- {
- kind: 'sector',
- sectorKey: sectorKey(0, 0, 'se'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_0,
- },
- ]),
- )
+ for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
+ expect(result?.diffs).toContainEqual({
+ kind: 'sector',
+ sectorKey: sectorKey(1, 1, corner),
+ fromMask: SECTOR_MASK_ALL,
+ toMask: SECTOR_MASK_NOT_2,
+ })
+ }
})
- it('uses sector prior masks to filter combos before projecting to all corners', () => {
- const puzzle = createSlitherPuzzle(4, 4)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_1
+ it('prunes clue-2 boundary corners using the full four-corner candidate check', () => {
+ const puzzle = createSlitherPuzzle(3, 3)
+ setClue(puzzle, 0, 0, 2)
const result = combinationRule.apply(puzzle)
@@ -1263,193 +1534,65 @@ describe('slither sector clue-2 combination feasibility rule', () => {
expect.arrayContaining([
{
kind: 'sector',
- sectorKey: sectorKey(1, 1, 'ne'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_1,
- },
- {
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'sw'),
+ sectorKey: sectorKey(0, 0, 'nw'),
fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_1,
+ toMask: SECTOR_MASK_NOT_1,
},
- ]),
- )
- })
-
- it('can become single-combo from sector constraints and force stronger ONLY masks', () => {
- const puzzle = createSlitherPuzzle(4, 4)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_2
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).not.toBeNull()
- expect(result?.diffs).toEqual(
- expect.arrayContaining([
{
kind: 'sector',
- sectorKey: sectorKey(1, 1, 'ne'),
+ sectorKey: sectorKey(0, 0, 'ne'),
fromMask: SECTOR_MASK_ALL,
toMask: SECTOR_MASK_ONLY_1,
},
{
kind: 'sector',
- sectorKey: sectorKey(1, 1, 'sw'),
+ sectorKey: sectorKey(0, 0, 'sw'),
fromMask: SECTOR_MASK_ALL,
toMask: SECTOR_MASK_ONLY_1,
},
{
kind: 'sector',
- sectorKey: sectorKey(1, 1, 'se'),
+ sectorKey: sectorKey(0, 0, 'se'),
fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_0,
+ toMask: SECTOR_MASK_NOT_1,
},
]),
)
})
- it('returns null when sector priors remove all clue-2 combinations (strategy B)', () => {
- const puzzle = createSlitherPuzzle(4, 4)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_2
- puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_2
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).toBeNull()
- })
-
- it('returns null when clue=2 combinations do not tighten any sector', () => {
+ it('prunes clue-3 corners to notZero sector masks', () => {
const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).toBeNull()
- })
-
- it('with notTwo on one corner, projects to opposite notZero (former intra-cell notTwo case)', () => {
- const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_2
+ setClue(puzzle, 1, 1, 3)
const result = combinationRule.apply(puzzle)
expect(result).not.toBeNull()
- expect(result?.diffs).toEqual([
- {
+ for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
+ expect(result?.diffs).toContainEqual({
kind: 'sector',
- sectorKey: sectorKey(1, 1, 'se'),
+ sectorKey: sectorKey(1, 1, corner),
fromMask: SECTOR_MASK_ALL,
toMask: SECTOR_MASK_NOT_0,
- },
- ])
- })
-
- it('with notZero on one corner, projects to opposite notTwo (former intra-cell notZero case)', () => {
- const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_0
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).not.toBeNull()
- expect(result?.diffs).toEqual([
- {
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'se'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_2,
- },
- ])
- })
-
- it('with onlyOne on ne, projects to diagonally opposite onlyOne (former intra-cell onlyOne pair)', () => {
- const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).not.toBeNull()
- expect(result?.diffs).toContainEqual({
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'sw'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_ONLY_1,
- })
+ })
+ }
})
- it('with exactly one line edge in an interior cell, projects non-overlapping corners to notTwo (and tightens the line-adjacent corners)', () => {
+ it('removes a vertex candidate that is locally legal but unsupported by a neighboring clue', () => {
const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- const [topEdge] = getCellEdgeKeys(1, 1)
- puzzle.edges[topEdge].mark = 'line'
+ setClue(puzzle, 0, 0, 0)
+ const [down, right] = getVertexIncidentEdges(0, 0, puzzle.rows, puzzle.cols)
const result = combinationRule.apply(puzzle)
expect(result).not.toBeNull()
- expect(result?.diffs).toHaveLength(4)
- expect(result?.diffs).toContainEqual({
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'sw'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_2,
- })
expect(result?.diffs).toContainEqual({
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'se'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_2,
+ kind: 'vertex',
+ vertexKey: vertexKey(0, 0),
+ fromCandidates: [[], [down, right].sort()],
+ toCandidates: [[]],
})
})
- it('with exactly one blank edge in an interior cell, projects non-overlapping corners to notZero', () => {
- const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- const [, , leftEdge] = getCellEdgeKeys(1, 1)
- puzzle.edges[leftEdge].mark = 'blank'
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).not.toBeNull()
- expect(result?.diffs).toContainEqual({
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'ne'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_0,
- })
- expect(result?.diffs).toContainEqual({
- kind: 'sector',
- sectorKey: sectorKey(1, 1, 'se'),
- fromMask: SECTOR_MASK_ALL,
- toMask: SECTOR_MASK_NOT_0,
- })
- })
-
- it('is idempotent when opposite corners are already as tight as the projection (former intra idempotent case)', () => {
- const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_0
- puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_2
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).toBeNull()
- })
-
- it('skips a corner when prior masks conflict with the projection (former intra conflict case)', () => {
- const puzzle = createSlitherPuzzle(3, 3)
- setClue(puzzle, 1, 1, 2)
- puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_0
- puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_2
-
- const result = combinationRule.apply(puzzle)
-
- expect(result).toBeNull()
- })
-
it('appears during stepwise solving for the provided 5x5 line-case puzzle', () => {
let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/5/5/hdhdhcp')
let triggered = false
@@ -1459,26 +1602,7 @@ describe('slither sector clue-2 combination feasibility rule', () => {
if (!step) {
break
}
- if (step.ruleId === 'sector-clue-two-combination-feasibility') {
- triggered = true
- break
- }
- current = nextPuzzle
- }
-
- expect(triggered).toBe(true)
- })
-
- it('appears during stepwise solving for the provided 5x5 blank-case puzzle', () => {
- let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/5/5/mahcp')
- let triggered = false
-
- for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) {
- const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber)
- if (!step) {
- break
- }
- if (step.ruleId === 'sector-clue-two-combination-feasibility') {
+ if (step.ruleId === 'clue-vertex-candidate-combination-pruning') {
triggered = true
break
}
@@ -1534,6 +1658,42 @@ describe('slither sector constraint edge propagation rule', () => {
expect(result).not.toBeNull()
expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }])
})
+
+ it('forces the last unknown corner edge to line when notOne already has one line', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1
+ const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw')
+ puzzle.edges[nwTop].mark = 'line'
+
+ const result = edgePropagationRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }])
+ })
+
+ it('forces the last unknown corner edge to blank when notOne already has one blank', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1
+ const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw')
+ puzzle.edges[nwTop].mark = 'blank'
+
+ const result = edgePropagationRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }])
+ })
+
+ it('does not emit a notOne propagation diff when both corner edges are already decided', () => {
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1
+ const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw')
+ puzzle.edges[nwTop].mark = 'line'
+ puzzle.edges[nwLeft].mark = 'blank'
+
+ const result = edgePropagationRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
})
describe('slither sector clue-1/3 onlyOne opposite edges rule', () => {
@@ -1667,9 +1827,9 @@ describe('slither vertex onlyOne non-sector balance rule', () => {
expect(result).toBeNull()
})
- it('appears during stepwise solving for the provided 5x5 cgcx puzzle with boundary line diff', () => {
+ it('leaves the provided 5x5 cgcx boundary line to color-edge propagation during stepwise solving', () => {
let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/5/5/cgcx')
- let triggered = false
+ let colorEdgeTriggered = false
let sawBoundaryLine = false
for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) {
@@ -1677,8 +1837,7 @@ describe('slither vertex onlyOne non-sector balance rule', () => {
if (!step) {
break
}
- if (step.ruleId === 'vertex-onlyone-non-sector-balance') {
- triggered = true
+ if (step.ruleId === 'color-edge-propagation') {
for (const diff of step.diffs) {
if (diff.kind !== 'edge' || diff.to !== 'line') {
continue
@@ -1695,13 +1854,14 @@ describe('slither vertex onlyOne non-sector balance rule', () => {
}
}
if (sawBoundaryLine) {
+ colorEdgeTriggered = true
break
}
}
current = nextPuzzle
}
- expect(triggered).toBe(true)
+ expect(colorEdgeTriggered).toBe(true)
expect(sawBoundaryLine).toBe(true)
})
})
@@ -1894,24 +2054,113 @@ describe('slither apply sectors rule', () => {
})
})
+describe('slither sector parity inference rule', () => {
+ const sectorParityRule = slitherRules.find((rule) => rule.id === 'sector-parity-inference')
+ if (!sectorParityRule) {
+ throw new Error('Expected sector-parity-inference rule')
+ }
+
+ it('places sector parity inference after color assumption and before strong inference', () => {
+ const colorAssumptionIdx = slitherRules.findIndex((rule) => rule.id === 'color-assumption-inference')
+ const sectorParityIdx = slitherRules.findIndex((rule) => rule.id === 'sector-parity-inference')
+ const strongIdx = slitherRules.findIndex((rule) => rule.id === 'strong-inference')
+
+ expect(colorAssumptionIdx).toBeGreaterThanOrEqual(0)
+ expect(sectorParityIdx).toBe(colorAssumptionIdx + 1)
+ expect(strongIdx).toBe(sectorParityIdx + 1)
+ })
+
+ it('forces both notOne sector edges blank when the both-line branch creates a closed subloop', () => {
+ const directSectorParityRule = createSectorParityInferenceRule(() => [])
+ const puzzle = createSlitherPuzzle(2, 2)
+ const targetSector = sectorKey(0, 0, 'se')
+ puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1
+ const [bottom, right] = getCornerEdgeKeys(0, 0, 'se')
+ puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line'
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line'
+ puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line'
+
+ const result = directSectorParityRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' },
+ { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' },
+ ])
+ expect(result?.affectedSectors).toEqual([targetSector])
+ expect(result?.message).toContain('candidate=sector-not-one')
+ expect(result?.message).toContain('result=contradiction')
+ })
+
+ it('forces both notOne sector edges line when the both-blank branch violates a clue', () => {
+ const directSectorParityRule = createSectorParityInferenceRule(() => [])
+ const puzzle = createSlitherPuzzle(2, 2)
+ setClue(puzzle, 0, 0, 2)
+ const targetSector = sectorKey(0, 0, 'se')
+ puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1
+ const [bottom, right] = getCornerEdgeKeys(0, 0, 'se')
+ puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank'
+ puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'blank'
+
+ const result = directSectorParityRule.apply(puzzle)
+
+ expect(result).not.toBeNull()
+ expect(result?.diffs).toEqual([
+ { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' },
+ { kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' },
+ ])
+ expect(result?.affectedSectors).toEqual([targetSector])
+ expect(result?.message).toContain('result=contradiction')
+ })
+
+ it('returns null when both notOne parity branches remain feasible', () => {
+ const directSectorParityRule = createSectorParityInferenceRule(() => [])
+ const puzzle = createSlitherPuzzle(2, 2)
+ puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_NOT_1
+
+ const result = directSectorParityRule.apply(puzzle)
+
+ expect(result).toBeNull()
+ })
+})
+
describe('slither strong inference rule', () => {
const colorAssumptionRule = slitherRules.find((rule) => rule.id === 'color-assumption-inference')
if (!colorAssumptionRule) {
throw new Error('Expected color-assumption-inference rule')
}
const unboundedColorAssumptionRule = createColorAssumptionInferenceRule(
- () => slitherRules.filter((rule) => rule.id !== 'color-assumption-inference' && rule.id !== 'strong-inference'),
+ () =>
+ slitherRules.filter(
+ (rule) =>
+ rule.id !== 'color-assumption-inference' &&
+ rule.id !== 'sector-parity-inference' &&
+ rule.id !== 'strong-inference',
+ ),
{ maxMs: Number.POSITIVE_INFINITY },
)
const strongRule = slitherRules.find((rule) => rule.id === 'strong-inference')
if (!strongRule) {
throw new Error('Expected strong-inference rule')
}
+ const unboundedStrongRule = createStrongInferenceRule(
+ () =>
+ slitherRules.filter(
+ (rule) =>
+ rule.id !== 'color-assumption-inference' &&
+ rule.id !== 'sector-parity-inference' &&
+ rule.id !== 'strong-inference',
+ ),
+ { maxMs: Number.POSITIVE_INFINITY },
+ )
it('places color assumption inference before strong inference', () => {
const colorAssumptionIdx = slitherRules.findIndex((rule) => rule.id === 'color-assumption-inference')
+ const sectorParityIdx = slitherRules.findIndex((rule) => rule.id === 'sector-parity-inference')
const strongIdx = slitherRules.findIndex((rule) => rule.id === 'strong-inference')
- expect(colorAssumptionIdx).toBe(strongIdx - 1)
+ expect(colorAssumptionIdx).toBeGreaterThanOrEqual(0)
+ expect(sectorParityIdx).toBe(colorAssumptionIdx + 1)
+ expect(strongIdx).toBe(sectorParityIdx + 1)
})
it('is placed at the end of slitherRules', () => {
@@ -2095,14 +2344,17 @@ describe('slither strong inference rule', () => {
current = nextPuzzle
}
- expect(() => strongRule.apply(current)).not.toThrow()
- const result = strongRule.apply(current)
+ expect(() => unboundedStrongRule.apply(current)).not.toThrow()
+ const result = unboundedStrongRule.apply(current)
expect(result === null || result.diffs.length > 0).toBe(true)
})
- it('can color the provided 18x10 stuck puzzle after deterministic stabilization', () => {
+ it('lets deterministic cut coloring advance the provided 18x10 stuck puzzle', () => {
const rulesBeforeColorAssumption = slitherRules.filter(
- (rule) => rule.id !== 'color-assumption-inference' && rule.id !== 'strong-inference',
+ (rule) =>
+ rule.id !== 'color-assumption-inference' &&
+ rule.id !== 'sector-parity-inference' &&
+ rule.id !== 'strong-inference',
)
let current = decodeSlitherFromPuzzlink(
'https://puzz.link/p?slither/18/10/l12cg261b353didb1bbg112dgb2bbci161b3dgbhapchcg3c161dicb2bbg111cga2bbbi271c161bg31cj',
@@ -2117,8 +2369,9 @@ describe('slither strong inference rule', () => {
}
const result = unboundedColorAssumptionRule.apply(current)
- expect(result).not.toBeNull()
- expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(2, 12), fromFill: null, toFill: 'green' }])
+ expect(result).toBeNull()
+ expect(current.cells[cellKey(2, 12)]?.fill).toBe('green')
+ expect(current.cells[cellKey(7, 0)]?.fill).toBe('green')
const targetBranch = clonePuzzle(current)
targetBranch.cells[cellKey(7, 0)] = {
diff --git a/src/domain/rules/slither/rules.ts b/src/domain/rules/slither/rules.ts
index 43129e7..d77447a 100644
--- a/src/domain/rules/slither/rules.ts
+++ b/src/domain/rules/slither/rules.ts
@@ -1,11 +1,13 @@
import type { Rule } from '../types'
import {
createColorCluePropagationRule,
+ createColorConnectivityCutColoringRule,
createColorEdgePropagationRule,
createInsideReachabilityColoringRule,
createColorOrthogonalConsensusPropagationRule,
createColorOutsideSeedingRule,
createColorSectorMaskPropagationRule,
+ createOutsideReachabilityColoringRule,
} from './rules/color'
import { createColorAssumptionInferenceRule } from './rules/colorAssumptionInference'
import { createCellCountRule, createPreventPrematureLoopRule, createVertexDegreeRule } from './rules/core'
@@ -13,13 +15,15 @@ import {
createContiguousThreeRunBoundariesRule,
createDiagonalAdjacentThreeOuterCornersRule,
} from './rules/patterns'
+import { createSectorParityInferenceRule } from './rules/sectorParityInference'
import { createApplySectorsInference } from './rules/sectorInference'
import {
+ createClueVertexCandidateCombinationPruningRule,
createSectorClueOneThreeIntraCellPropagationRule,
- createSectorClueTwoCombinationFeasibilityRule,
createSectorConstraintEdgePropagationRule,
createSectorDiagonalSharedVertexPropagationRule,
createSectorNotOneClueTwoPropagationRule,
+ createVertexCandidateEdgePruningRule,
createVertexOnlyOneNonSectorBalanceRule,
} from './rules/sectorPropagation'
import { createStrongInferenceRule } from './rules/strongInference'
@@ -35,10 +39,13 @@ export const deterministicSlitherRules: Rule[] = [
createColorSectorMaskPropagationRule(),
createColorOrthogonalConsensusPropagationRule(),
createInsideReachabilityColoringRule(),
+ createOutsideReachabilityColoringRule(),
+ createColorConnectivityCutColoringRule(),
createPreventPrematureLoopRule(),
createApplySectorsInference(),
createSectorDiagonalSharedVertexPropagationRule(),
- createSectorClueTwoCombinationFeasibilityRule(),
+ createVertexCandidateEdgePruningRule(),
+ createClueVertexCandidateCombinationPruningRule(),
createSectorClueOneThreeIntraCellPropagationRule(),
createSectorConstraintEdgePropagationRule(),
createVertexOnlyOneNonSectorBalanceRule(),
@@ -48,5 +55,6 @@ export const deterministicSlitherRules: Rule[] = [
export const slitherRules: Rule[] = [
...deterministicSlitherRules,
createColorAssumptionInferenceRule(() => deterministicSlitherRules),
+ createSectorParityInferenceRule(() => deterministicSlitherRules),
createStrongInferenceRule(() => deterministicSlitherRules),
]
diff --git a/src/domain/rules/slither/rules/color.ts b/src/domain/rules/slither/rules/color.ts
index 0b664b8..b3d306e 100644
--- a/src/domain/rules/slither/rules/color.ts
+++ b/src/domain/rules/slither/rules/color.ts
@@ -58,13 +58,16 @@ export const createColorEdgePropagationRule = (): Rule => ({
}
const edgeKeys = Object.keys(puzzle.edges)
- const adjacentCellsByEdge = new Map()
+ const adjacentCellsByEdge = new Map()
for (const edgeKeyValue of edgeKeys) {
const adjacentCells = getEdgeAdjacentCellKeys(puzzle, edgeKeyValue)
- if (adjacentCells.length !== 2) {
+ if (adjacentCells.length !== 1 && adjacentCells.length !== 2) {
continue
}
- adjacentCellsByEdge.set(edgeKeyValue, [adjacentCells[0], adjacentCells[1]])
+ adjacentCellsByEdge.set(
+ edgeKeyValue,
+ adjacentCells.length === 1 ? [adjacentCells[0]] : [adjacentCells[0], adjacentCells[1]],
+ )
}
for (const edgeKeyValue of edgeKeys) {
@@ -72,6 +75,21 @@ export const createColorEdgePropagationRule = (): Rule => ({
if (!adjacentCells) {
continue
}
+ if (adjacentCells.length === 1) {
+ const [cell] = adjacentCells
+ const color = getEffectiveCellColor(cell)
+ if (color === null) {
+ continue
+ }
+ const toMark: EdgeMark = color === 'green' ? 'line' : 'blank'
+ if (!rememberEdge(edgeKeyValue, toMark)) {
+ continue
+ }
+ if (decidedEdges.get(edgeKeyValue) === toMark) {
+ affectedCells.add(cell)
+ }
+ continue
+ }
const [cellA, cellB] = adjacentCells
const colorA = getEffectiveCellColor(cellA)
const colorB = getEffectiveCellColor(cellB)
@@ -93,6 +111,9 @@ export const createColorEdgePropagationRule = (): Rule => ({
if (!adjacentCells) {
continue
}
+ if (adjacentCells.length !== 2) {
+ continue
+ }
const [cellA, cellB] = adjacentCells
const effectiveMark = decidedEdges.get(edgeKeyValue) ?? (puzzle.edges[edgeKeyValue]?.mark ?? 'unknown')
if (effectiveMark !== 'line' && effectiveMark !== 'blank') {
@@ -588,6 +609,398 @@ export const createInsideReachabilityColoringRule = (): Rule => ({
},
})
+export const createOutsideReachabilityColoringRule = (): Rule => ({
+ id: 'outside-reachability-coloring',
+ name: 'Outside Reachability Coloring',
+ apply: (puzzle: PuzzleIR): RuleApplication | null => {
+ const reachable = new Set()
+ const queue: string[] = []
+
+ const inBounds = (row: number, col: number): boolean =>
+ row >= 0 && row < puzzle.rows && col >= 0 && col < puzzle.cols
+
+ const enqueue = (key: string): void => {
+ if (reachable.has(key)) {
+ return
+ }
+ reachable.add(key)
+ queue.push(key)
+ }
+
+ const canReachOutsideFromBoundary = (row: number, col: number): boolean => {
+ const boundaryEdges: string[] = []
+ if (row === 0) {
+ boundaryEdges.push(edgeKey([0, col], [0, col + 1]))
+ }
+ if (row === puzzle.rows - 1) {
+ boundaryEdges.push(edgeKey([puzzle.rows, col], [puzzle.rows, col + 1]))
+ }
+ if (col === 0) {
+ boundaryEdges.push(edgeKey([row, 0], [row + 1, 0]))
+ }
+ if (col === puzzle.cols - 1) {
+ boundaryEdges.push(edgeKey([row, puzzle.cols], [row + 1, puzzle.cols]))
+ }
+ return boundaryEdges.some((key) => (puzzle.edges[key]?.mark ?? 'unknown') !== 'line')
+ }
+
+ for (let row = 0; row < puzzle.rows; row += 1) {
+ for (let col = 0; col < puzzle.cols; col += 1) {
+ const key = cellKey(row, col)
+ const currentFill = puzzle.cells[key]?.fill
+ if (currentFill === 'green' || isNumberClueThree(puzzle, key)) {
+ continue
+ }
+ if (currentFill === 'yellow' || canReachOutsideFromBoundary(row, col)) {
+ enqueue(key)
+ }
+ }
+ }
+
+ const neighborSpecs: Array<{ dr: number; dc: number; edge: (row: number, col: number) => string }> = [
+ { dr: -1, dc: 0, edge: (row, col) => edgeKey([row, col], [row, col + 1]) },
+ { dr: 1, dc: 0, edge: (row, col) => edgeKey([row + 1, col], [row + 1, col + 1]) },
+ { dr: 0, dc: -1, edge: (row, col) => edgeKey([row, col], [row + 1, col]) },
+ { dr: 0, dc: 1, edge: (row, col) => edgeKey([row, col + 1], [row + 1, col + 1]) },
+ ]
+
+ for (let idx = 0; idx < queue.length; idx += 1) {
+ const [row, col] = queue[idx].split(',').map(Number)
+ for (const spec of neighborSpecs) {
+ const neighborRow = row + spec.dr
+ const neighborCol = col + spec.dc
+ if (!inBounds(neighborRow, neighborCol)) {
+ continue
+ }
+ const sharedEdge = spec.edge(row, col)
+ if ((puzzle.edges[sharedEdge]?.mark ?? 'unknown') === 'line') {
+ continue
+ }
+
+ const neighborKey = cellKey(neighborRow, neighborCol)
+ const neighborFill = puzzle.cells[neighborKey]?.fill
+ if (neighborFill === 'green' || isNumberClueThree(puzzle, neighborKey)) {
+ continue
+ }
+ enqueue(neighborKey)
+ }
+ }
+
+ const decidedCellFills = new Map()
+ const affectedCells = new Set()
+ for (let row = 0; row < puzzle.rows; row += 1) {
+ for (let col = 0; col < puzzle.cols; col += 1) {
+ const key = cellKey(row, col)
+ if (reachable.has(key) || isNumberClueThree(puzzle, key)) {
+ continue
+ }
+ const currentFill = puzzle.cells[key]?.fill
+ if (isSlitherCellColor(currentFill)) {
+ continue
+ }
+ decidedCellFills.set(key, 'green')
+ affectedCells.add(key)
+ }
+ }
+
+ if (decidedCellFills.size === 0) {
+ return null
+ }
+
+ return {
+ message: `Outside reachability coloring applied (${decidedCellFills.size} color update(s)).`,
+ diffs: [...decidedCellFills.entries()].map(([k, toFill]) => ({
+ kind: 'cell' as const,
+ cellKey: k,
+ fromFill: (puzzle.cells[k]?.fill ?? null) as string | null,
+ toFill,
+ })),
+ affectedCells: [...affectedCells],
+ }
+ },
+})
+
+const OUTSIDE_COMPONENT = '__outside__'
+
+type ConnectivityCutPassOptions = {
+ target: SlitherCellColor
+ includeOutsideSource: boolean
+ getEffectiveCellColor: (key: string) => SlitherCellColor | null
+}
+
+const findConnectivityCutCells = (
+ puzzle: PuzzleIR,
+ { target, includeOutsideSource, getEffectiveCellColor }: ConnectivityCutPassOptions,
+): Set => {
+ const blocked = oppositeSlitherCellColor(target)
+ const parent = new Map()
+ const rank = new Map()
+
+ const inBoundsCellKeys: string[] = []
+ for (let row = 0; row < puzzle.rows; row += 1) {
+ for (let col = 0; col < puzzle.cols; col += 1) {
+ inBoundsCellKeys.push(cellKey(row, col))
+ }
+ }
+
+ const isCandidateCell = (key: string): boolean =>
+ !isNumberClueThree(puzzle, key) && getEffectiveCellColor(key) !== blocked
+
+ const ensureNode = (key: string): void => {
+ if (parent.has(key)) {
+ return
+ }
+ parent.set(key, key)
+ rank.set(key, 0)
+ }
+
+ const find = (key: string): string => {
+ ensureNode(key)
+ const currentParent = parent.get(key)
+ if (currentParent === undefined || currentParent === key) {
+ return key
+ }
+ const root = find(currentParent)
+ parent.set(key, root)
+ return root
+ }
+
+ const union = (a: string, b: string): void => {
+ const rootA = find(a)
+ const rootB = find(b)
+ if (rootA === rootB) {
+ return
+ }
+ const rankA = rank.get(rootA) ?? 0
+ const rankB = rank.get(rootB) ?? 0
+ if (rankA < rankB) {
+ parent.set(rootA, rootB)
+ return
+ }
+ parent.set(rootB, rootA)
+ if (rankA === rankB) {
+ rank.set(rootA, rankA + 1)
+ }
+ }
+
+ for (const key of inBoundsCellKeys) {
+ if (isCandidateCell(key)) {
+ ensureNode(key)
+ }
+ }
+ if (includeOutsideSource) {
+ ensureNode(OUTSIDE_COMPONENT)
+ }
+
+ for (const [edgeKeyValue, edgeState] of Object.entries(puzzle.edges)) {
+ if ((edgeState?.mark ?? 'unknown') !== 'blank') {
+ continue
+ }
+ const adjacentCells = getEdgeAdjacentCellKeys(puzzle, edgeKeyValue)
+ if (adjacentCells.length !== 2 || !adjacentCells.every(isCandidateCell)) {
+ continue
+ }
+ union(adjacentCells[0], adjacentCells[1])
+ }
+
+ const componentCells = new Map()
+ const sourceComponents = new Set()
+ for (const key of inBoundsCellKeys) {
+ if (!isCandidateCell(key)) {
+ continue
+ }
+ const root = find(key)
+ const cells = componentCells.get(root) ?? []
+ cells.push(key)
+ componentCells.set(root, cells)
+ if (getEffectiveCellColor(key) === target) {
+ sourceComponents.add(root)
+ }
+ }
+ if (includeOutsideSource) {
+ sourceComponents.add(find(OUTSIDE_COMPONENT))
+ }
+ if (sourceComponents.size < 2) {
+ return new Set()
+ }
+
+ const graph = new Map>()
+ const addGraphNode = (node: string): void => {
+ if (!graph.has(node)) {
+ graph.set(node, new Set())
+ }
+ }
+ const addGraphEdge = (a: string, b: string): void => {
+ if (a === b) {
+ addGraphNode(a)
+ return
+ }
+ addGraphNode(a)
+ addGraphNode(b)
+ graph.get(a)?.add(b)
+ graph.get(b)?.add(a)
+ }
+
+ for (const root of componentCells.keys()) {
+ addGraphNode(root)
+ }
+ if (includeOutsideSource) {
+ addGraphNode(find(OUTSIDE_COMPONENT))
+ }
+
+ for (const [edgeKeyValue, edgeState] of Object.entries(puzzle.edges)) {
+ if ((edgeState?.mark ?? 'unknown') === 'line') {
+ continue
+ }
+ const adjacentCells = getEdgeAdjacentCellKeys(puzzle, edgeKeyValue)
+ if (adjacentCells.length === 2) {
+ if (!adjacentCells.every(isCandidateCell)) {
+ continue
+ }
+ addGraphEdge(find(adjacentCells[0]), find(adjacentCells[1]))
+ continue
+ }
+ if (includeOutsideSource && adjacentCells.length === 1 && isCandidateCell(adjacentCells[0])) {
+ addGraphEdge(find(OUTSIDE_COMPONENT), find(adjacentCells[0]))
+ }
+ }
+
+ const discovery = new Map()
+ const low = new Map()
+ const subtreeSources = new Map()
+ const treeChildren = new Map()
+ const cutComponents = new Set()
+ let timestamp = 0
+
+ const dfs = (node: string, parentNode: string | null, connectedNodes: string[]): void => {
+ discovery.set(node, timestamp)
+ low.set(node, timestamp)
+ timestamp += 1
+ subtreeSources.set(node, sourceComponents.has(node) ? 1 : 0)
+ connectedNodes.push(node)
+
+ for (const neighbor of graph.get(node) ?? []) {
+ if (neighbor === parentNode) {
+ continue
+ }
+ if (!discovery.has(neighbor)) {
+ const children = treeChildren.get(node) ?? []
+ children.push(neighbor)
+ treeChildren.set(node, children)
+ dfs(neighbor, node, connectedNodes)
+ low.set(node, Math.min(low.get(node) ?? 0, low.get(neighbor) ?? 0))
+ subtreeSources.set(node, (subtreeSources.get(node) ?? 0) + (subtreeSources.get(neighbor) ?? 0))
+ continue
+ }
+ low.set(node, Math.min(low.get(node) ?? 0, discovery.get(neighbor) ?? 0))
+ }
+ }
+
+ const evaluateCuts = (node: string, totalSources: number): void => {
+ for (const neighbor of treeChildren.get(node) ?? []) {
+ if ((low.get(neighbor) ?? 0) >= (discovery.get(node) ?? 0)) {
+ const childSources = subtreeSources.get(neighbor) ?? 0
+ if (childSources > 0 && totalSources - childSources > 0) {
+ cutComponents.add(node)
+ }
+ }
+ evaluateCuts(neighbor, totalSources)
+ }
+ }
+
+ for (const node of graph.keys()) {
+ if (discovery.has(node)) {
+ continue
+ }
+ const connectedNodes: string[] = []
+ dfs(node, null, connectedNodes)
+ const totalSources = connectedNodes.filter((component) => sourceComponents.has(component)).length
+ if (totalSources < 2) {
+ continue
+ }
+ evaluateCuts(node, totalSources)
+ }
+
+ const cutCells = new Set()
+ for (const component of cutComponents) {
+ for (const key of componentCells.get(component) ?? []) {
+ if (getEffectiveCellColor(key) === null) {
+ cutCells.add(key)
+ }
+ }
+ }
+ return cutCells
+}
+
+export const createColorConnectivityCutColoringRule = (): Rule => ({
+ id: 'color-connectivity-cut-coloring',
+ name: 'Color Connectivity Cut Coloring',
+ apply: (puzzle: PuzzleIR): RuleApplication | null => {
+ const decidedCellFills = new Map()
+ const affectedCells = new Set()
+
+ const getEffectiveCellColor = (key: string): SlitherCellColor | null => {
+ const decided = decidedCellFills.get(key)
+ if (decided) {
+ return decided
+ }
+ const current = puzzle.cells[key]?.fill
+ return isSlitherCellColor(current) ? current : null
+ }
+
+ const rememberCellFill = (key: string, to: SlitherCellColor): void => {
+ if (getEffectiveCellColor(key) !== null) {
+ return
+ }
+ decidedCellFills.set(key, to)
+ affectedCells.add(key)
+ }
+
+ for (const key of findConnectivityCutCells(puzzle, {
+ target: 'green',
+ includeOutsideSource: false,
+ getEffectiveCellColor,
+ })) {
+ rememberCellFill(key, 'green')
+ }
+
+ for (const key of findConnectivityCutCells(puzzle, {
+ target: 'yellow',
+ includeOutsideSource: true,
+ getEffectiveCellColor,
+ })) {
+ rememberCellFill(key, 'yellow')
+ }
+
+ if (decidedCellFills.size === 0) {
+ return null
+ }
+
+ const diffs: RuleApplication['diffs'] = []
+ for (let row = 0; row < puzzle.rows; row += 1) {
+ for (let col = 0; col < puzzle.cols; col += 1) {
+ const key = cellKey(row, col)
+ const toFill = decidedCellFills.get(key)
+ if (!toFill) {
+ continue
+ }
+ diffs.push({
+ kind: 'cell',
+ cellKey: key,
+ fromFill: (puzzle.cells[key]?.fill ?? null) as string | null,
+ toFill,
+ })
+ }
+ }
+
+ return {
+ message: `Color connectivity cut coloring applied (${decidedCellFills.size} color update(s)).`,
+ diffs,
+ affectedCells: [...affectedCells],
+ }
+ },
+})
+
type CornerNeighbor = { row: number; col: number }
const getCornerOutsideNeighbors = (row: number, col: number, corner: SectorCorner): [CornerNeighbor, CornerNeighbor] => {
diff --git a/src/domain/rules/slither/rules/sectorParityInference.ts b/src/domain/rules/slither/rules/sectorParityInference.ts
new file mode 100644
index 0000000..c9141a9
--- /dev/null
+++ b/src/domain/rules/slither/rules/sectorParityInference.ts
@@ -0,0 +1,198 @@
+import { clonePuzzle } from '../../../ir/normalize'
+import { cellKey, getCornerEdgeKeys, parseSectorKey } from '../../../ir/keys'
+import type { Rule, RuleApplication } from '../../types'
+import {
+ SECTOR_MASK_NOT_1,
+ type EdgeMark,
+ type PuzzleIR,
+} from '../../../ir/types'
+import { applyEdgeAssumption, runTrialUntilFixpoint } from './trial'
+
+const SECTOR_PARITY_MAX_CANDIDATES = 160
+const SECTOR_PARITY_MAX_TRIAL_STEPS = 120
+const SECTOR_PARITY_MAX_MS = 2000
+
+type SectorParityInferenceOptions = {
+ maxCandidates?: number
+ maxTrialSteps?: number
+ maxMs?: number
+}
+
+type SectorParityCandidate = {
+ sectorKey: string
+ row: number
+ col: number
+ edgeA: string
+ edgeB: string
+}
+
+type SectorParityBranch = {
+ setupOk: boolean
+ diffs: RuleApplication['diffs']
+}
+
+const collectSectorParityCandidates = (puzzle: PuzzleIR, maxCandidates: number): SectorParityCandidate[] => {
+ const candidates: SectorParityCandidate[] = []
+
+ for (const [sectorKeyValue, sectorState] of Object.entries(puzzle.sectors)) {
+ if ((sectorState?.constraintsMask ?? 0) !== SECTOR_MASK_NOT_1) {
+ continue
+ }
+ const [row, col, corner] = parseSectorKey(sectorKeyValue)
+ const [edgeA, edgeB] = getCornerEdgeKeys(row, col, corner)
+ if ((puzzle.edges[edgeA]?.mark ?? 'unknown') !== 'unknown') {
+ continue
+ }
+ if ((puzzle.edges[edgeB]?.mark ?? 'unknown') !== 'unknown') {
+ continue
+ }
+ candidates.push({
+ sectorKey: sectorKeyValue,
+ row,
+ col,
+ edgeA,
+ edgeB,
+ })
+ }
+
+ return candidates.slice(0, maxCandidates)
+}
+
+const buildParityBranch = (
+ puzzle: PuzzleIR,
+ edgeA: string,
+ edgeB: string,
+ to: EdgeMark,
+): { branch: PuzzleIR; info: SectorParityBranch } => {
+ const branch = clonePuzzle(puzzle)
+ const setupOk = applyEdgeAssumption(branch, edgeA, to) && applyEdgeAssumption(branch, edgeB, to)
+
+ return {
+ branch,
+ info: {
+ setupOk,
+ diffs: [
+ { kind: 'edge', edgeKey: edgeA, from: 'unknown', to },
+ { kind: 'edge', edgeKey: edgeB, from: 'unknown', to },
+ ],
+ },
+ }
+}
+
+const collectSharedEdgeDiffs = (basePuzzle: PuzzleIR, branchA: PuzzleIR, branchB: PuzzleIR): RuleApplication['diffs'] => {
+ const diffs: RuleApplication['diffs'] = []
+ for (const [edgeKeyValue, edgeState] of Object.entries(basePuzzle.edges)) {
+ if ((edgeState?.mark ?? 'unknown') !== 'unknown') {
+ continue
+ }
+ const branchAMark = branchA.edges[edgeKeyValue]?.mark ?? 'unknown'
+ const branchBMark = branchB.edges[edgeKeyValue]?.mark ?? 'unknown'
+ if (branchAMark === 'unknown' || branchAMark !== branchBMark) {
+ continue
+ }
+ diffs.push({
+ kind: 'edge',
+ edgeKey: edgeKeyValue,
+ from: 'unknown',
+ to: branchAMark,
+ })
+ }
+ return diffs
+}
+
+const describeBranch = (diffs: RuleApplication['diffs']): string =>
+ diffs
+ .filter((diff): diff is Extract<(typeof diffs)[number], { kind: 'edge' }> => diff.kind === 'edge')
+ .map((diff) => `${diff.edgeKey}=${diff.to}`)
+ .join(', ')
+
+const summarizeFixedDiffs = (diffs: RuleApplication['diffs']): string => {
+ const edgeDiffs = diffs.filter((diff): diff is Extract<(typeof diffs)[number], { kind: 'edge' }> => diff.kind === 'edge')
+ if (edgeDiffs.length <= 3) {
+ return `fixed ${edgeDiffs.map((diff) => `${diff.edgeKey}=${diff.to}`).join(', ')}`
+ }
+ const preview = edgeDiffs
+ .slice(0, 3)
+ .map((diff) => `${diff.edgeKey}=${diff.to}`)
+ .join(', ')
+ return `fixed ${edgeDiffs.length} edges (${preview}, ...)`
+}
+
+export const createSectorParityInferenceRule = (
+ getDeterministicRules: () => Rule[],
+ options: SectorParityInferenceOptions = {},
+): Rule => ({
+ id: 'sector-parity-inference',
+ name: 'Sector Parity Inference',
+ apply: (puzzle: PuzzleIR): RuleApplication | null => {
+ const deterministicRules = getDeterministicRules()
+ const candidates = collectSectorParityCandidates(
+ puzzle,
+ options.maxCandidates ?? SECTOR_PARITY_MAX_CANDIDATES,
+ )
+ if (candidates.length === 0) {
+ return null
+ }
+
+ const deadlineMs = Date.now() + (options.maxMs ?? SECTOR_PARITY_MAX_MS)
+ for (const candidate of candidates) {
+ if (Date.now() > deadlineMs) {
+ break
+ }
+
+ const lineBranch = buildParityBranch(puzzle, candidate.edgeA, candidate.edgeB, 'line')
+ const blankBranch = buildParityBranch(puzzle, candidate.edgeA, candidate.edgeB, 'blank')
+
+ const lineResult = lineBranch.info.setupOk
+ ? runTrialUntilFixpoint(
+ lineBranch.branch,
+ deterministicRules,
+ options.maxTrialSteps ?? SECTOR_PARITY_MAX_TRIAL_STEPS,
+ deadlineMs,
+ )
+ : { contradiction: true, timedOut: false, exhausted: false, puzzle: lineBranch.branch }
+ const blankResult = blankBranch.info.setupOk
+ ? runTrialUntilFixpoint(
+ blankBranch.branch,
+ deterministicRules,
+ options.maxTrialSteps ?? SECTOR_PARITY_MAX_TRIAL_STEPS,
+ deadlineMs,
+ )
+ : { contradiction: true, timedOut: false, exhausted: false, puzzle: blankBranch.branch }
+
+ if (lineResult.timedOut || blankResult.timedOut) {
+ break
+ }
+ if (lineResult.exhausted || blankResult.exhausted) {
+ continue
+ }
+
+ const candidateLabel = `candidate=sector-not-one(${candidate.sectorKey})`
+ if (lineResult.contradiction !== blankResult.contradiction) {
+ const contradictionBranch = lineResult.contradiction ? lineBranch.info : blankBranch.info
+ const survivingBranch = lineResult.contradiction ? blankBranch.info : lineBranch.info
+
+ return {
+ message: `Sector parity inference ${candidateLabel} result=contradiction: branch ${describeBranch(contradictionBranch.diffs)} fails, so ${summarizeFixedDiffs(survivingBranch.diffs)}.`,
+ diffs: survivingBranch.diffs,
+ affectedCells: [cellKey(candidate.row, candidate.col)],
+ affectedSectors: [candidate.sectorKey],
+ }
+ }
+
+ const diffs = collectSharedEdgeDiffs(puzzle, lineResult.puzzle, blankResult.puzzle)
+ if (diffs.length === 0) {
+ continue
+ }
+
+ return {
+ message: `Sector parity inference ${candidateLabel} result=shared-consequence: both branches agree and ${summarizeFixedDiffs(diffs)}.`,
+ diffs,
+ affectedCells: [cellKey(candidate.row, candidate.col)],
+ affectedSectors: [candidate.sectorKey],
+ }
+ }
+
+ return null
+ },
+})
diff --git a/src/domain/rules/slither/rules/sectorPropagation.ts b/src/domain/rules/slither/rules/sectorPropagation.ts
index 9a9a050..b9af542 100644
--- a/src/domain/rules/slither/rules/sectorPropagation.ts
+++ b/src/domain/rules/slither/rules/sectorPropagation.ts
@@ -1,4 +1,4 @@
-import { cellKey, getCellEdgeKeys, getCornerEdgeKeys, getVertexIncidentEdges, sectorKey } from '../../../ir/keys'
+import { cellKey, getCellEdgeKeys, getCornerEdgeKeys, getVertexIncidentEdges, sectorKey, vertexKey } from '../../../ir/keys'
import {
SECTOR_ALLOW_0,
SECTOR_ALLOW_1,
@@ -18,115 +18,334 @@ import {
type SectorLineCount,
type SectorConstraintMask,
type SectorCorner,
+ type VertexCandidate,
} from '../../../ir/types'
import type { Rule, RuleApplication } from '../../types'
-type ClueTwoCombo = {
- id: 'ns' | 'we' | 'nw' | 'ne' | 'sw' | 'se'
- marks: [EdgeMark, EdgeMark, EdgeMark, EdgeMark]
-}
+const CORNERS: SectorCorner[] = ['nw', 'ne', 'sw', 'se']
-const CLUE_TWO_COMBINATIONS: ClueTwoCombo[] = [
- { id: 'ns', marks: ['line', 'line', 'blank', 'blank'] },
- { id: 'we', marks: ['blank', 'blank', 'line', 'line'] },
- { id: 'nw', marks: ['line', 'blank', 'line', 'blank'] },
- { id: 'ne', marks: ['line', 'blank', 'blank', 'line'] },
- { id: 'sw', marks: ['blank', 'line', 'line', 'blank'] },
- { id: 'se', marks: ['blank', 'line', 'blank', 'line'] },
+const cornerVertices = (row: number, col: number): Array<{ corner: SectorCorner; vertexKey: string }> => [
+ { corner: 'nw', vertexKey: vertexKey(row, col) },
+ { corner: 'ne', vertexKey: vertexKey(row, col + 1) },
+ { corner: 'sw', vertexKey: vertexKey(row + 1, col) },
+ { corner: 'se', vertexKey: vertexKey(row + 1, col + 1) },
]
-const cornerVertices = (row: number, col: number): Array<{ corner: SectorCorner; vr: number; vc: number }> => [
- { corner: 'nw', vr: row, vc: col },
- { corner: 'ne', vr: row, vc: col + 1 },
- { corner: 'sw', vr: row + 1, vc: col },
- { corner: 'se', vr: row + 1, vc: col + 1 },
-]
+const maskForAllowedCounts = (counts: Set): SectorConstraintMask => {
+ let mask: SectorConstraintMask = 0
+ if (counts.has(0)) mask |= SECTOR_ALLOW_0
+ if (counts.has(1)) mask |= SECTOR_ALLOW_1
+ if (counts.has(2)) mask |= SECTOR_ALLOW_2
+ return mask
+}
-const isComboConsistentWithKnownEdges = (
- puzzle: PuzzleIR,
- cellEdges: [string, string, string, string],
- combo: ClueTwoCombo,
-): boolean => {
- for (let i = 0; i < cellEdges.length; i += 1) {
- const current = puzzle.edges[cellEdges[i]]?.mark ?? 'unknown'
- if (current === 'unknown') continue
- if (current !== combo.marks[i]) return false
+const candidateId = (candidate: VertexCandidate): string => candidate.join('|')
+
+const normalizeCandidate = (candidate: VertexCandidate): VertexCandidate => [...candidate].sort()
+
+const normalizeCandidates = (candidates: VertexCandidate[]): VertexCandidate[] => {
+ const unique = new Map()
+ for (const candidate of candidates) {
+ const normalized = normalizeCandidate(candidate)
+ unique.set(candidateId(normalized), normalized)
}
- return true
+ return [...unique.values()].sort((a, b) => a.length - b.length || candidateId(a).localeCompare(candidateId(b)))
}
-const isComboVertexFeasible = (
+const createInitialVertexCandidates = (
+ row: number,
+ col: number,
+ rows: number,
+ cols: number,
+): VertexCandidate[] => {
+ const incident = getVertexIncidentEdges(row, col, rows, cols)
+ const candidates: VertexCandidate[] = [[]]
+ for (let i = 0; i < incident.length; i += 1) {
+ for (let j = i + 1; j < incident.length; j += 1) {
+ candidates.push([incident[i], incident[j]])
+ }
+ }
+ return normalizeCandidates(candidates)
+}
+
+const getVertexCandidates = (
puzzle: PuzzleIR,
+ key: string,
row: number,
col: number,
- cellEdges: [string, string, string, string],
- combo: ClueTwoCombo,
-): boolean => {
- const overrides = new Map()
- for (let i = 0; i < cellEdges.length; i += 1) {
- overrides.set(cellEdges[i], combo.marks[i])
+): VertexCandidate[] => {
+ const stored = puzzle.vertices?.[key]?.candidateEdgeSets
+ if (stored) {
+ return normalizeCandidates(stored)
+ }
+ return createInitialVertexCandidates(row, col, puzzle.rows, puzzle.cols)
+}
+
+const sameCandidates = (a: VertexCandidate[], b: VertexCandidate[]): boolean =>
+ JSON.stringify(normalizeCandidates(a)) === JSON.stringify(normalizeCandidates(b))
+
+const candidateContains = (candidate: VertexCandidate, edgeKeyValue: string): boolean =>
+ candidate.includes(edgeKeyValue)
+
+const addEdgeDecision = (
+ decidedEdges: Map,
+ puzzle: PuzzleIR,
+ edgeKeyValue: string,
+ toMark: EdgeMark,
+): void => {
+ const current = puzzle.edges[edgeKeyValue]?.mark ?? 'unknown'
+ if (current !== 'unknown' || decidedEdges.has(edgeKeyValue)) {
+ return
+ }
+ decidedEdges.set(edgeKeyValue, toMark)
+}
+
+const addVertexDiff = (
+ diffs: RuleApplication['diffs'],
+ key: string,
+ fromCandidates: VertexCandidate[],
+ toCandidates: VertexCandidate[],
+): void => {
+ if (sameCandidates(fromCandidates, toCandidates)) {
+ return
}
+ diffs.push({
+ kind: 'vertex',
+ vertexKey: key,
+ fromCandidates: normalizeCandidates(fromCandidates),
+ toCandidates: normalizeCandidates(toCandidates),
+ })
+}
+
+export const createVertexCandidateEdgePruningRule = (): Rule => ({
+ id: 'vertex-candidate-edge-pruning',
+ name: 'Vertex Candidate Edge Pruning',
+ apply: (puzzle: PuzzleIR): RuleApplication | null => {
+ const diffs: RuleApplication['diffs'] = []
+ const decidedEdges = new Map()
+
+ for (let row = 0; row <= puzzle.rows; row += 1) {
+ for (let col = 0; col <= puzzle.cols; col += 1) {
+ const key = vertexKey(row, col)
+ const incident = getVertexIncidentEdges(row, col, puzzle.rows, puzzle.cols)
+ const currentCandidates = getVertexCandidates(puzzle, key, row, col)
+ let nextCandidates = currentCandidates.filter((candidate) =>
+ candidate.every((edgeKeyValue) => (puzzle.edges[edgeKeyValue]?.mark ?? 'unknown') !== 'blank'),
+ )
+
+ for (const edgeKeyValue of incident) {
+ if ((puzzle.edges[edgeKeyValue]?.mark ?? 'unknown') === 'line') {
+ nextCandidates = nextCandidates.filter((candidate) => candidateContains(candidate, edgeKeyValue))
+ }
+ }
- const getEffectiveMark = (edgeKeyValue: string): EdgeMark =>
- overrides.get(edgeKeyValue) ?? (puzzle.edges[edgeKeyValue]?.mark ?? 'unknown')
+ addVertexDiff(diffs, key, currentCandidates, nextCandidates)
- for (const vertex of cornerVertices(row, col)) {
- const incident = getVertexIncidentEdges(vertex.vr, vertex.vc, puzzle.rows, puzzle.cols)
- const marks = incident.map(getEffectiveMark)
- const lineCount = marks.filter((mark) => mark === 'line').length
- const unknownCount = marks.filter((mark) => mark === 'unknown').length
+ if (nextCandidates.length === 0) {
+ continue
+ }
- if (lineCount > 2) {
- return false
+ for (const edgeKeyValue of incident) {
+ if (nextCandidates.every((candidate) => candidateContains(candidate, edgeKeyValue))) {
+ addEdgeDecision(decidedEdges, puzzle, edgeKeyValue, 'line')
+ }
+ if (nextCandidates.every((candidate) => !candidateContains(candidate, edgeKeyValue))) {
+ addEdgeDecision(decidedEdges, puzzle, edgeKeyValue, 'blank')
+ }
+ }
+ }
}
- // A vertex already activated with one line must still have an available continuation.
- if (lineCount === 1 && unknownCount === 0) {
- return false
+ for (const [edgeKeyValue, to] of decidedEdges.entries()) {
+ diffs.push({ kind: 'edge', edgeKey: edgeKeyValue, from: 'unknown', to })
}
- }
- return true
-}
+ if (diffs.length === 0) {
+ return null
+ }
+
+ return {
+ message: 'Vertex candidate pruning removed impossible vertex states and propagated forced edges.',
+ diffs,
+ affectedCells: [],
+ }
+ },
+})
+
+type CornerCandidateTuple = [VertexCandidate, VertexCandidate, VertexCandidate, VertexCandidate]
+
+const candidateForCorner = (
+ candidatesByCorner: Record,
+ corner: SectorCorner,
+): VertexCandidate[] => candidatesByCorner[corner]
+
+const cellEdgeLineCount = (
+ cellEdges: string[],
+ candidates: CornerCandidateTuple,
+): number => cellEdges.filter((edgeKeyValue) => candidates.some((candidate) => candidateContains(candidate, edgeKeyValue))).length
-const comboCornerLineCount = (
- cellEdges: [string, string, string, string],
- combo: ClueTwoCombo,
+const isCellEdgeConsistent = (
+ edgeKeyValue: string,
+ candidateA: VertexCandidate,
+ candidateB: VertexCandidate,
+): boolean => candidateContains(candidateA, edgeKeyValue) === candidateContains(candidateB, edgeKeyValue)
+
+const cornerLineCountFromCandidates = (
cornerEdges: [string, string],
-): SectorLineCount => {
- const edgeToMark = new Map()
- for (let i = 0; i < cellEdges.length; i += 1) {
- edgeToMark.set(cellEdges[i], combo.marks[i])
- }
- return cornerEdges.filter((edgeKeyValue) => edgeToMark.get(edgeKeyValue) === 'line').length as SectorLineCount
-}
+ candidate: VertexCandidate,
+): SectorLineCount =>
+ cornerEdges.filter((edgeKeyValue) => candidateContains(candidate, edgeKeyValue)).length as SectorLineCount
-const isComboConsistentWithSectorMasks = (
- puzzle: PuzzleIR,
- row: number,
- col: number,
- cellEdges: [string, string, string, string],
- combo: ClueTwoCombo,
-): boolean => {
- const corners: SectorCorner[] = ['nw', 'ne', 'sw', 'se']
- for (const corner of corners) {
- const cornerEdges = getCornerEdgeKeys(row, col, corner)
- const lineCount = comboCornerLineCount(cellEdges, combo, cornerEdges)
- const currentMask = puzzle.sectors[sectorKey(row, col, corner)]?.constraintsMask ?? SECTOR_MASK_ALL
- if (!sectorMaskAllows(currentMask, lineCount)) {
- return false
+export const createClueVertexCandidateCombinationPruningRule = (): Rule => ({
+ id: 'clue-vertex-candidate-combination-pruning',
+ name: 'Clue Vertex Candidate Combination Pruning',
+ apply: (puzzle: PuzzleIR): RuleApplication | null => {
+ const diffs: RuleApplication['diffs'] = []
+ const nextVertexCandidates = new Map()
+ const nextSectorMasks = new Map()
+ const affectedCells = new Set()
+ const affectedSectors = new Set()
+
+ for (let row = 0; row < puzzle.rows; row += 1) {
+ for (let col = 0; col < puzzle.cols; col += 1) {
+ const clue = puzzle.cells[cellKey(row, col)]?.clue
+ if (clue?.kind !== 'number' || clue.value === '?') {
+ continue
+ }
+
+ const targetLineCount = Number(clue.value)
+ const [topEdge, bottomEdge, leftEdge, rightEdge] = getCellEdgeKeys(row, col)
+ const cellEdges = [topEdge, bottomEdge, leftEdge, rightEdge]
+ const vertices = cornerVertices(row, col)
+ const candidatesByCorner = vertices.reduce>((acc, vertex) => {
+ const [vertexRow, vertexCol] = vertex.vertexKey.split(',').map(Number)
+ acc[vertex.corner] =
+ nextVertexCandidates.get(vertex.vertexKey) ??
+ getVertexCandidates(puzzle, vertex.vertexKey, vertexRow, vertexCol)
+ return acc
+ }, {} as Record)
+
+ const supportedByCorner: Record> = {
+ nw: new Map(),
+ ne: new Map(),
+ sw: new Map(),
+ se: new Map(),
+ }
+ const sectorCounts: Record> = {
+ nw: new Set(),
+ ne: new Set(),
+ sw: new Set(),
+ se: new Set(),
+ }
+ let survivingCombinations = 0
+
+ for (const nw of candidateForCorner(candidatesByCorner, 'nw')) {
+ const partialNwCount = cellEdges.filter((edgeKeyValue) => candidateContains(nw, edgeKeyValue)).length
+ if (partialNwCount > targetLineCount || 2 - partialNwCount > 4 - targetLineCount) continue
+
+ for (const ne of candidateForCorner(candidatesByCorner, 'ne')) {
+ if (!isCellEdgeConsistent(topEdge, nw, ne)) continue
+ const partialTopCount = cellEdges.filter((edgeKeyValue) =>
+ [nw, ne].some((candidate) => candidateContains(candidate, edgeKeyValue)),
+ ).length
+ if (partialTopCount > targetLineCount || 3 - partialTopCount > 4 - targetLineCount) continue
+
+ for (const sw of candidateForCorner(candidatesByCorner, 'sw')) {
+ if (!isCellEdgeConsistent(leftEdge, nw, sw)) continue
+
+ for (const se of candidateForCorner(candidatesByCorner, 'se')) {
+ if (!isCellEdgeConsistent(bottomEdge, sw, se)) continue
+ if (!isCellEdgeConsistent(rightEdge, ne, se)) continue
+
+ const tuple: CornerCandidateTuple = [nw, ne, sw, se]
+ if (cellEdgeLineCount(cellEdges, tuple) !== targetLineCount) continue
+
+ const candidatesByName: Record = { nw, ne, sw, se }
+ let sectorMasksAllowCombination = true
+ for (const corner of CORNERS) {
+ const lineCount = cornerLineCountFromCandidates(getCornerEdgeKeys(row, col, corner), candidatesByName[corner])
+ const currentMask = puzzle.sectors[sectorKey(row, col, corner)]?.constraintsMask ?? SECTOR_MASK_ALL
+ if (!sectorMaskAllows(currentMask, lineCount)) {
+ sectorMasksAllowCombination = false
+ break
+ }
+ }
+ if (!sectorMasksAllowCombination) continue
+
+ survivingCombinations += 1
+ for (const corner of CORNERS) {
+ const candidate = candidatesByName[corner]
+ supportedByCorner[corner].set(candidateId(candidate), candidate)
+ sectorCounts[corner].add(
+ cornerLineCountFromCandidates(getCornerEdgeKeys(row, col, corner), candidate),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (survivingCombinations === 0) {
+ continue
+ }
+
+ for (const vertex of vertices) {
+ const currentCandidates = candidatesByCorner[vertex.corner]
+ const supportedCandidates = normalizeCandidates([...supportedByCorner[vertex.corner].values()])
+ const existing = nextVertexCandidates.get(vertex.vertexKey) ?? currentCandidates
+ const narrowed = normalizeCandidates(
+ existing.filter((candidate) => supportedByCorner[vertex.corner].has(candidateId(candidate))),
+ )
+ if (!sameCandidates(existing, narrowed)) {
+ nextVertexCandidates.set(vertex.vertexKey, narrowed)
+ affectedCells.add(cellKey(row, col))
+ } else if (!sameCandidates(currentCandidates, supportedCandidates)) {
+ affectedCells.add(cellKey(row, col))
+ }
+ }
+
+ for (const corner of CORNERS) {
+ const counts = sectorCounts[corner]
+ if (counts.size === 0) {
+ continue
+ }
+ const key = sectorKey(row, col, corner)
+ const currentMask = nextSectorMasks.get(key) ?? (puzzle.sectors[key]?.constraintsMask ?? SECTOR_MASK_ALL)
+ const narrowedMask = sectorMaskIntersect(currentMask, maskForAllowedCounts(counts))
+ if (narrowedMask === 0 || narrowedMask === currentMask) {
+ continue
+ }
+ nextSectorMasks.set(key, narrowedMask)
+ affectedCells.add(cellKey(row, col))
+ affectedSectors.add(key)
+ }
+ }
}
- }
- return true
-}
-const maskForAllowedCounts = (counts: Set): SectorConstraintMask => {
- let mask: SectorConstraintMask = 0
- if (counts.has(0)) mask |= SECTOR_ALLOW_0
- if (counts.has(1)) mask |= SECTOR_ALLOW_1
- if (counts.has(2)) mask |= SECTOR_ALLOW_2
- return mask
-}
+ for (const [key, toCandidates] of nextVertexCandidates.entries()) {
+ const [row, col] = key.split(',').map(Number)
+ const fromCandidates = getVertexCandidates(puzzle, key, row, col)
+ addVertexDiff(diffs, key, fromCandidates, toCandidates)
+ }
+
+ for (const [key, toMask] of nextSectorMasks.entries()) {
+ const fromMask = puzzle.sectors[key]?.constraintsMask ?? SECTOR_MASK_ALL
+ if (fromMask === toMask) continue
+ diffs.push({ kind: 'sector', sectorKey: key, fromMask, toMask })
+ }
+
+ if (diffs.length === 0) {
+ return null
+ }
+
+ return {
+ message: 'Clue vertex-candidate combinations pruned impossible corner states.',
+ diffs,
+ affectedCells: [...affectedCells],
+ affectedSectors: [...affectedSectors],
+ }
+ },
+})
export const createSectorDiagonalSharedVertexPropagationRule = (): Rule => ({
id: 'sector-diagonal-shared-vertex-propagation',
@@ -207,90 +426,6 @@ export const createSectorDiagonalSharedVertexPropagationRule = (): Rule => ({
},
})
-export const createSectorClueTwoCombinationFeasibilityRule = (): Rule => ({
- id: 'sector-clue-two-combination-feasibility',
- name: 'Sector Clue-2 Combination Feasibility',
- apply: (puzzle: PuzzleIR): RuleApplication | null => {
- const nextMasks = new Map()
- const affectedCells = new Set()
- const affectedSectors = new Set()
- const corners: SectorCorner[] = ['nw', 'ne', 'sw', 'se']
-
- for (let r = 0; r < puzzle.rows; r += 1) {
- for (let c = 0; c < puzzle.cols; c += 1) {
- const clue = puzzle.cells[cellKey(r, c)]?.clue
- if (clue?.kind !== 'number' || clue.value !== 2) {
- continue
- }
-
- const [topEdge, bottomEdge, leftEdge, rightEdge] = getCellEdgeKeys(r, c)
- const cellEdges: [string, string, string, string] = [topEdge, bottomEdge, leftEdge, rightEdge]
-
- const feasibleCombos = CLUE_TWO_COMBINATIONS.filter((combo) => {
- if (!isComboConsistentWithKnownEdges(puzzle, cellEdges, combo)) {
- return false
- }
- if (!isComboVertexFeasible(puzzle, r, c, cellEdges, combo)) {
- return false
- }
- return isComboConsistentWithSectorMasks(puzzle, r, c, cellEdges, combo)
- })
-
- if (feasibleCombos.length === 0) {
- continue
- }
-
- for (const corner of corners) {
- const cornerEdges = getCornerEdgeKeys(r, c, corner)
- const allowedCounts = new Set()
-
- for (const combo of feasibleCombos) {
- const lineCount = comboCornerLineCount(cellEdges, combo, cornerEdges)
- allowedCounts.add(lineCount)
- }
-
- const impliedMask = maskForAllowedCounts(allowedCounts)
- const key = sectorKey(r, c, corner)
- const currentMask = nextMasks.get(key) ?? (puzzle.sectors[key]?.constraintsMask ?? SECTOR_MASK_ALL)
- const narrowedMask = sectorMaskIntersect(currentMask, impliedMask)
- if (narrowedMask === 0 || narrowedMask === currentMask) {
- continue
- }
-
- nextMasks.set(key, narrowedMask)
- affectedCells.add(cellKey(r, c))
- affectedSectors.add(key)
- }
- }
- }
-
- const diffs: RuleApplication['diffs'] = []
- for (const [key, toMask] of nextMasks.entries()) {
- const fromMask = puzzle.sectors[key]?.constraintsMask ?? SECTOR_MASK_ALL
- if (fromMask === toMask) {
- continue
- }
- diffs.push({
- kind: 'sector',
- sectorKey: key,
- fromMask,
- toMask,
- })
- }
-
- if (diffs.length === 0) {
- return null
- }
-
- return {
- message: 'Clue-2 combination feasibility pruned invalid edge patterns and tightened sector masks.',
- diffs,
- affectedCells: [...affectedCells],
- affectedSectors: [...affectedSectors],
- }
- },
-})
-
export const createSectorClueOneThreeIntraCellPropagationRule = (): Rule => ({
id: 'sector-clue-one-three-intra-cell-propagation',
name: 'Sector Clue-1/3 onlyOne Opposite Edges',
@@ -504,6 +639,14 @@ export const createSectorConstraintEdgePropagationRule = (): Rule => ({
toMark = 'line'
edgesToDecide = [unknownEdges[0]]
}
+ } else if (mask === SECTOR_MASK_NOT_1) {
+ if (lineCount === 1 && blankCount === 0 && unknownEdges.length === 1) {
+ toMark = 'line'
+ edgesToDecide = [unknownEdges[0]]
+ } else if (blankCount === 1 && lineCount === 0 && unknownEdges.length === 1) {
+ toMark = 'blank'
+ edgesToDecide = [unknownEdges[0]]
+ }
}
if (toMark === null || edgesToDecide.length === 0) continue
diff --git a/src/domain/rules/slither/rules/strongInference.ts b/src/domain/rules/slither/rules/strongInference.ts
index 201a4e8..8ee226a 100644
--- a/src/domain/rules/slither/rules/strongInference.ts
+++ b/src/domain/rules/slither/rules/strongInference.ts
@@ -11,9 +11,15 @@ import { applyEdgeAssumption, runTrialUntilFixpoint } from './trial'
// const STRONG_MAX_CANDIDATES = 1000
// const STRONG_MAX_TRIAL_STEPS = 2000
// const STRONG_MAX_MS = 1000
-const STRONG_MAX_CANDIDATES = 200
+const STRONG_MAX_CANDIDATES = 400
const STRONG_MAX_TRIAL_STEPS = 120
-const STRONG_MAX_MS = 2000
+const STRONG_MAX_MS = 4000
+
+type StrongInferenceOptions = {
+ maxCandidates?: number
+ maxTrialSteps?: number
+ maxMs?: number
+}
type StrongCandidate =
| {
@@ -206,17 +212,20 @@ const summarizeFixedDiffs = (diffs: RuleApplication['diffs']): string => {
return `fixed ${edgeDiffs.length} edges (${preview}, ...)`
}
-export const createStrongInferenceRule = (getDeterministicRules: () => Rule[]): Rule => ({
+export const createStrongInferenceRule = (
+ getDeterministicRules: () => Rule[],
+ options: StrongInferenceOptions = {},
+): Rule => ({
id: 'strong-inference',
name: 'Strong Inference (Conservative)',
apply: (puzzle: PuzzleIR): RuleApplication | null => {
const deterministicRules = getDeterministicRules()
- const candidates = collectStrongCandidates(puzzle, STRONG_MAX_CANDIDATES)
+ const candidates = collectStrongCandidates(puzzle, options.maxCandidates ?? STRONG_MAX_CANDIDATES)
if (candidates.length === 0) {
return null
}
- const deadlineMs = Date.now() + STRONG_MAX_MS
+ const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS)
for (const candidate of candidates) {
if (Date.now() > deadlineMs) {
break
@@ -247,10 +256,10 @@ export const createStrongInferenceRule = (getDeterministicRules: () => Rule[]):
}
const branchAResult = branchAInfo.setupOk
- ? runTrialUntilFixpoint(branchA, deterministicRules, STRONG_MAX_TRIAL_STEPS, deadlineMs)
+ ? runTrialUntilFixpoint(branchA, deterministicRules, options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, deadlineMs)
: { contradiction: true, timedOut: false, exhausted: false, puzzle: branchA }
const branchBResult = branchBInfo.setupOk
- ? runTrialUntilFixpoint(branchB, deterministicRules, STRONG_MAX_TRIAL_STEPS, deadlineMs)
+ ? runTrialUntilFixpoint(branchB, deterministicRules, options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, deadlineMs)
: { contradiction: true, timedOut: false, exhausted: false, puzzle: branchB }
if (branchAResult.timedOut || branchBResult.timedOut) {
diff --git a/src/domain/rules/slither/rules/trial.ts b/src/domain/rules/slither/rules/trial.ts
index 64ef089..cd46323 100644
--- a/src/domain/rules/slither/rules/trial.ts
+++ b/src/domain/rules/slither/rules/trial.ts
@@ -107,6 +107,15 @@ const detectSectorContradiction = (puzzle: PuzzleIR): boolean => {
return false
}
+const detectVertexCandidateContradiction = (puzzle: PuzzleIR): boolean => {
+ for (const vertexState of Object.values(puzzle.vertices ?? {})) {
+ if (vertexState.candidateEdgeSets.length === 0) {
+ return true
+ }
+ }
+ return false
+}
+
const detectColorEdgeContradiction = (puzzle: PuzzleIR): boolean => {
for (const [edgeKeyValue, edgeState] of Object.entries(puzzle.edges)) {
const mark = edgeState?.mark ?? 'unknown'
@@ -282,6 +291,7 @@ export const detectHardContradiction = (puzzle: PuzzleIR): boolean =>
detectVertexContradiction(puzzle) ||
detectCellClueContradiction(puzzle) ||
detectSectorContradiction(puzzle) ||
+ detectVertexCandidateContradiction(puzzle) ||
detectColorEdgeContradiction(puzzle) ||
detectLineLoopContradiction(puzzle) ||
detectDisconnectedGreenContradiction(puzzle)
diff --git a/src/domain/rules/types.ts b/src/domain/rules/types.ts
index c212fa3..9babda2 100644
--- a/src/domain/rules/types.ts
+++ b/src/domain/rules/types.ts
@@ -1,4 +1,4 @@
-import type { EdgeMark, PuzzleIR, SectorConstraintMask } from '../ir/types'
+import type { EdgeMark, PuzzleIR, SectorConstraintMask, VertexCandidate } from '../ir/types'
export type EdgeDiff = {
kind: 'edge'
@@ -21,7 +21,14 @@ export type CellDiff = {
toFill: string | null
}
-export type RuleDiff = EdgeDiff | SectorDiff | CellDiff
+export type VertexDiff = {
+ kind: 'vertex'
+ vertexKey: string
+ fromCandidates: VertexCandidate[]
+ toCandidates: VertexCandidate[]
+}
+
+export type RuleDiff = EdgeDiff | SectorDiff | CellDiff | VertexDiff
export type RuleStep = {
id: string
diff --git a/src/features/board/CanvasBoard.tsx b/src/features/board/CanvasBoard.tsx
index b7e1ad4..376d923 100644
--- a/src/features/board/CanvasBoard.tsx
+++ b/src/features/board/CanvasBoard.tsx
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
- cellKey,
getCellEdgeKeys,
getCornerEdgeKeys,
parseCellKey,
@@ -21,12 +20,13 @@ type Props = {
highlightedCells: string[]
highlightedColorCells: string[]
showVertexNumbers: boolean
- selectedCellKey?: string | null
- onCellSelect?: (key: string | null) => void
}
-const CELL_SIZE = 54
+const CELL_SIZE = 52
const PADDING = 48
+const MIN_ZOOM = 20
+const MAX_ZOOM = 200
+const ZOOM_STEP = 5
const midpoint = (a: [number, number], b: [number, number]): [number, number] => [
(a[0] + b[0]) / 2,
@@ -52,30 +52,23 @@ export const CanvasBoard = ({
highlightedCells,
highlightedColorCells,
showVertexNumbers,
- selectedCellKey = null,
- onCellSelect,
}: Props) => {
const canvasRef = useRef(null)
- const [scale, setScale] = useState(1)
- const [offset, setOffset] = useState({ x: 0, y: 0 })
- const dragRef = useRef<{
- startClientX: number
- startClientY: number
- isPan: boolean
- } | null>(null)
- const panOffsetStart = useRef({ x: 0, y: 0 })
- const panMouseStart = useRef({ x: 0, y: 0 })
+ const [zoomPercent, setZoomPercent] = useState(100)
const width = useMemo(() => puzzle.cols * CELL_SIZE + PADDING * 2, [puzzle.cols])
const height = useMemo(() => puzzle.rows * CELL_SIZE + PADDING * 2, [puzzle.rows])
+ const zoom = zoomPercent / 100
+ const displayWidth = Math.round(width * zoom)
+ const displayHeight = Math.round(height * zoom)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) {
return
}
- canvas.width = width
- canvas.height = height
+ canvas.width = displayWidth
+ canvas.height = displayHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
return
@@ -83,12 +76,24 @@ export const CanvasBoard = ({
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
- ctx.translate(offset.x, offset.y)
- ctx.scale(scale, scale)
+ ctx.scale(zoom, zoom)
- ctx.fillStyle = '#0f172a'
+ ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
+ ctx.fillStyle = '#64748b'
+ ctx.font = '600 12px Inter, sans-serif'
+ ctx.textAlign = 'right'
+ ctx.textBaseline = 'middle'
+ for (let r = 0; r < puzzle.rows; r += 1) {
+ ctx.fillText(`R${r + 1}`, PADDING - 12, PADDING + r * CELL_SIZE + CELL_SIZE / 2)
+ }
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'alphabetic'
+ for (let c = 0; c < puzzle.cols; c += 1) {
+ ctx.fillText(`C${c + 1}`, PADDING + c * CELL_SIZE + CELL_SIZE / 2, PADDING - 14)
+ }
+
for (const [key, cell] of Object.entries(puzzle.cells)) {
const fill = cell.fill
if (fill !== 'green' && fill !== 'yellow') {
@@ -118,22 +123,7 @@ export const CanvasBoard = ({
ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE)
}
- if (selectedCellKey) {
- const [sr, sc] = parseCellKey(selectedCellKey)
- if (sr >= 0 && sc >= 0 && sr < puzzle.rows && sc < puzzle.cols) {
- ctx.strokeStyle = '#fbbf24'
- ctx.lineWidth = 2.5
- ctx.setLineDash([])
- ctx.strokeRect(
- PADDING + sc * CELL_SIZE + 2,
- PADDING + sr * CELL_SIZE + 2,
- CELL_SIZE - 4,
- CELL_SIZE - 4,
- )
- }
- }
-
- ctx.strokeStyle = '#334155'
+ ctx.strokeStyle = '#cbd5e1'
ctx.lineWidth = 1
for (let r = 0; r <= puzzle.rows; r += 1) {
ctx.beginPath()
@@ -153,8 +143,8 @@ export const CanvasBoard = ({
continue
}
const [r, c] = parseCellKey(key)
- ctx.fillStyle = '#f8fafc'
- ctx.font = 'bold 22px Inter, sans-serif'
+ ctx.fillStyle = '#111827'
+ ctx.font = 'bold 26px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(
@@ -253,7 +243,7 @@ export const CanvasBoard = ({
}
}
- ctx.fillStyle = '#f8fafc'
+ ctx.fillStyle = '#111827'
for (let r = 0; r <= puzzle.rows; r += 1) {
for (let c = 0; c <= puzzle.cols; c += 1) {
const vertex = PADDING + c * CELL_SIZE
@@ -266,7 +256,7 @@ export const CanvasBoard = ({
if (showVertexNumbers) {
ctx.fillStyle = '#64748b'
- ctx.font = '10px ui-monospace, monospace'
+ ctx.font = '12px ui-monospace, monospace'
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
for (let r = 0; r <= puzzle.rows; r += 1) {
@@ -282,39 +272,18 @@ export const CanvasBoard = ({
ctx.restore()
}, [
+ displayHeight,
+ displayWidth,
height,
highlightedCells,
highlightedColorCells,
highlightedEdges,
- offset.x,
- offset.y,
puzzle,
- scale,
- selectedCellKey,
showVertexNumbers,
width,
+ zoom,
])
- const pickCellAtClient = (clientX: number, clientY: number): string | null => {
- const canvas = canvasRef.current
- if (!canvas || !onCellSelect) {
- return null
- }
- const rect = canvas.getBoundingClientRect()
- const scaleX = canvas.width / rect.width
- const scaleY = canvas.height / rect.height
- const mx = (clientX - rect.left) * scaleX
- const my = (clientY - rect.top) * scaleY
- const gx = (mx - offset.x) / scale
- const gy = (my - offset.y) / scale
- const col = Math.floor((gx - PADDING) / CELL_SIZE)
- const row = Math.floor((gy - PADDING) / CELL_SIZE)
- if (row < 0 || col < 0 || row >= puzzle.rows || col >= puzzle.cols) {
- return null
- }
- return cellKey(row, col)
- }
-
const status = useMemo(() => {
let lineCount = 0
let blankCount = 0
@@ -336,64 +305,36 @@ export const CanvasBoard = ({
{puzzle.rows} × {puzzle.cols}
-
- line {status.lineCount} / blank {status.blankCount} / unknown {status.unknownCount}
-
+
+
+ line {status.lineCount} / blank {status.blankCount} / unknown {status.unknownCount}
+
+
+
-