From a07c9900bd7a8c33079d7ff5f7d1488522954c4e Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 7 Jun 2026 12:01:49 +0800 Subject: [PATCH 01/14] feat: implement branch inspector for slither puzzles, enhancing inference details visualization and navigation --- src/app/WorkspacePage.test.tsx | 34 ++++ src/app/workspace.css | 184 ++++++++++++++++++ src/domain/rules/engine.ts | 1 + src/domain/rules/slither/rules.test.ts | 60 +++++- .../slither/rules/colorAssumptionInference.ts | 32 ++- .../slither/rules/sectorParityInference.ts | 44 ++++- .../rules/slither/rules/strongInference.ts | 47 ++++- src/domain/rules/slither/rules/trial.ts | 52 ++++- src/domain/rules/types.ts | 41 ++++ src/features/board/CanvasBoard.tsx | 95 ++++++++- src/features/explanation/BranchInspector.tsx | 168 ++++++++++++++++ src/features/explanation/ExplanationPanel.tsx | 17 ++ 12 files changed, 757 insertions(+), 18 deletions(-) create mode 100644 src/features/explanation/BranchInspector.tsx diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index 942b0fb..fb1b095 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -876,6 +876,40 @@ describe('WorkspacePage', () => { expect(screen.getByText('line updates: 1, line crosses: 1')).toBeInTheDocument() }) + it('opens and navigates the Slitherlink strong inference branch inspector', () => { + const url = + 'https://puzz.link/p?slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' + const store = useSolverStore.getState() + store.importFromUrl(url, 'slitherlink') + store.nextStep() + store.nextStep() + store.nextStep() + + renderWorkspace() + + const reasoningPanel = screen.getByRole('heading', { name: /reasoning steps/i }).closest('section') + if (!reasoningPanel) { + throw new Error('Expected reasoning panel') + } + expect(within(reasoningPanel).getAllByRole('button', { name: /view details/i })).toHaveLength(1) + fireEvent.click(within(reasoningPanel).getByRole('button', { name: /view details/i })) + + const dialog = screen.getByRole('dialog', { name: /branch inspector/i }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(within(dialog).getByText(/vertex-degree contradiction at V\(5, 3\)/i)).toBeInTheDocument() + expect(within(dialog).getByLabelText(/branch replay step/i)).toHaveAttribute('max', '10') + expect(within(dialog).getByLabelText(/slitherlink branch inspector canvas/i)).toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: /next/i })) + expect(within(dialog).getByText(/apply the branch assumption/i)).toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('tab', { name: /branch b unresolved/i })) + expect(within(dialog).getByLabelText(/branch replay step/i)).toHaveAttribute('max', '3') + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: /branch inspector/i })).not.toBeInTheDocument() + }) + it('keeps replay and puzzle I/O controls in the intended compact order', () => { renderWorkspace() diff --git a/src/app/workspace.css b/src/app/workspace.css index 2b4f04c..0d75127 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -1152,6 +1152,190 @@ button[data-active='true'] { font-size: 0.85rem; } +.step-details-button { + margin-top: 8px; +} + +.branch-inspector-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 20px; + background: rgb(15 23 42 / 0.48); +} + +.branch-inspector-dialog { + width: min(1180px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + border: 1px solid #cbd5e1; + border-radius: 14px; + background: #ffffff; + box-shadow: 0 24px 80px rgb(15 23 42 / 0.28); + padding: 16px; +} + +.branch-inspector-header, +.branch-inspector-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.branch-inspector-header h2, +.branch-inspector-header p, +.branch-inspector-controls p { + margin: 0; +} + +.branch-inspector-header h2 { + color: #0f172a; + font-size: 1.15rem; +} + +.branch-inspector-header p, +.branch-inspector-controls p, +.branch-inspector-muted { + margin-top: 4px; + color: #64748b; + font-size: 0.86rem; +} + +.branch-inspector-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 14px 0; +} + +.branch-inspector-tabs button { + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #334155; + padding: 7px 10px; +} + +.branch-inspector-tabs button[data-active='true'] { + border-color: #0891b2; + background: #ecfeff; + color: #0f172a; +} + +.branch-inspector-tabs span { + margin-left: 5px; + color: #64748b; + font-size: 0.76rem; +} + +.branch-inspector-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + gap: 14px; +} + +.branch-inspector-board, +.branch-inspector-details { + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + padding: 10px; +} + +.branch-inspector-board .board-scroll-shell { + max-height: min(62vh, 720px); + background: #ffffff; +} + +.branch-inspector-status { + display: flex; + justify-content: space-between; + gap: 8px; + color: #64748b; + font-size: 0.85rem; +} + +.branch-inspector-status strong[data-status='contradiction'], +.branch-inspector-contradiction { + color: #b91c1c; +} + +.branch-inspector-contradiction { + line-height: 1.45; +} + +.branch-inspector-legend { + display: grid; + gap: 8px; + margin-top: 18px; + color: #475569; + font-size: 0.82rem; +} + +.branch-inspector-legend span { + display: flex; + align-items: center; + gap: 7px; +} + +.branch-inspector-legend i { + width: 14px; + height: 14px; + border: 3px solid; + border-radius: 4px; +} + +.branch-inspector-legend i[data-kind='assumption'] { + border-color: #f97316; +} + +.branch-inspector-legend i[data-kind='step'] { + border-color: #22d3ee; +} + +.branch-inspector-legend i[data-kind='contradiction'] { + border-color: #dc2626; + background: rgb(220 38 38 / 0.2); +} + +.branch-inspector-controls { + margin-top: 14px; +} + +.branch-inspector-navigation { + display: flex; + align-items: center; + gap: 8px; +} + +.branch-inspector-navigation input { + width: min(240px, 28vw); + accent-color: #0891b2; +} + +.branch-inspector-navigation output { + min-width: 54px; + color: #475569; + font-size: 0.82rem; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 820px) { + .branch-inspector-layout { + grid-template-columns: 1fr; + } + + .branch-inspector-header, + .branch-inspector-controls { + align-items: stretch; + flex-direction: column; + } +} + .stats-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/src/domain/rules/engine.ts b/src/domain/rules/engine.ts index 2d182dd..3763260 100644 --- a/src/domain/rules/engine.ts +++ b/src/domain/rules/engine.ts @@ -206,6 +206,7 @@ export const runNextRule = ( chainDurationMs, ruleApplyMs, ruleAttempts: attempts, + inferenceDetails: result.inferenceDetails, } return { nextPuzzle: applyDiffs(puzzle, step), diff --git a/src/domain/rules/slither/rules.test.ts b/src/domain/rules/slither/rules.test.ts index e0a1525..910f0e7 100644 --- a/src/domain/rules/slither/rules.test.ts +++ b/src/domain/rules/slither/rules.test.ts @@ -22,7 +22,7 @@ import { SECTOR_MASK_ONLY_2, type PuzzleIR, } from '../../ir/types' -import { runNextRule } from '../engine' +import { applyRuleDiffs, runNextRule } from '../engine' import type { Rule } from '../types' import { deterministicSlitherRules, slitherRules } from './rules' import { createColorAssumptionInferenceRule } from './rules/colorAssumptionInference' @@ -58,6 +58,48 @@ describe('slither deterministic rule order', () => { }) }) +describe('slither inference branch details', () => { + it('records the provided strong inference branch trace without changing the formal conclusion', () => { + const url = + 'https://puzz.link/p?slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' + let puzzle = decodeSlitherFromPuzzlink(url) + let targetStep: ReturnType['step'] = null + + for (let stepNumber = 1; stepNumber <= 3; stepNumber += 1) { + const result = runNextRule(puzzle, slitherRules, stepNumber) + puzzle = result.nextPuzzle + targetStep = result.step + } + + expect(targetStep?.ruleId).toBe('strong-inference') + expect(targetStep?.diffs).toEqual([ + { kind: 'edge', edgeKey: edgeKey([1, 2], [1, 3]), from: 'unknown', to: 'blank' }, + ]) + expect(targetStep?.inferenceDetails?.defaultBranchId).toBe('a') + const failingBranch = targetStep?.inferenceDetails?.branches[0] + expect(failingBranch).toMatchObject({ + status: 'contradiction', + assumptionDiffs: [ + { kind: 'edge', edgeKey: edgeKey([1, 2], [1, 3]), from: 'unknown', to: 'line' }, + ], + }) + expect(failingBranch?.traceSteps).toHaveLength(9) + expect(failingBranch?.contradiction).toMatchObject({ + kind: 'vertex-degree', + vertices: [vertexKey(5, 3)], + }) + + let rebuilt = applyRuleDiffs( + targetStep?.inferenceDetails?.basePuzzle ?? puzzle, + failingBranch?.assumptionDiffs ?? [], + ) + for (const traceStep of failingBranch?.traceSteps ?? []) { + rebuilt = applyRuleDiffs(rebuilt, traceStep.diffs) + } + expect(findHardContradictionReason(rebuilt)?.kind).toBe('vertex-degree') + }) +}) + describe('slither contiguous 3-run boundaries rule', () => { const threeRunRule = slitherRules.find((rule) => rule.id === 'contiguous-three-run-boundaries') if (!threeRunRule) { @@ -2587,6 +2629,11 @@ describe('slither sector parity inference rule', () => { expect(result?.message).toContain('probe budget 24') expect(result?.message).toContain('line branch unresolved after 24 steps') expect(result?.message).toContain('blank branch contradicted after 1 step') + expect(result?.inferenceDetails).toMatchObject({ + kind: 'slither-sector-parity', + defaultBranchId: 'blank', + }) + expect(result?.inferenceDetails?.branches).toHaveLength(2) }) it('checks later sector parity candidates at the first probe budget', () => { @@ -2673,6 +2720,7 @@ describe('slither trial diagnostics', () => { expect(reason?.kind).toBe('vertex-degree') expect(reason?.message).toContain('V(1, 1)') + expect(reason?.vertices).toEqual([vertexKey(1, 1)]) }) it('reports cell-clue contradiction locations', () => { @@ -2684,6 +2732,7 @@ describe('slither trial diagnostics', () => { expect(reason?.kind).toBe('cell-clue') expect(reason?.message).toContain('(R1, C1)') + expect(reason?.cells).toEqual([cellKey(0, 0)]) }) it('reports sector-mask contradiction locations', () => { @@ -2695,6 +2744,7 @@ describe('slither trial diagnostics', () => { expect(reason?.kind).toBe('sector-mask') expect(reason?.message).toContain('(R1, C1, NW)') + expect(reason?.sectors).toEqual([targetSector]) }) it('reports vertex-candidates contradiction locations', () => { @@ -2718,6 +2768,7 @@ describe('slither trial diagnostics', () => { expect(reason?.kind).toBe('color-edge') expect(reason?.message).toContain('edge V(0, 1)-V(1, 1)') + expect(reason?.edges).toEqual([shared]) }) it('reports line-loop contradiction shape', () => { @@ -2844,6 +2895,11 @@ describe('slither strong inference rule', () => { expect(result?.message).toContain('0 trial steps') expect(result?.message).toContain('Searched 1 candidate') expect(result?.message).toContain('is green') + expect(result?.inferenceDetails).toMatchObject({ + kind: 'slither-color-assumption', + defaultBranchId: 'green', + }) + expect(result?.inferenceDetails?.branches).toHaveLength(2) }) it('compresses same-color candidate components before searching', () => { @@ -3085,6 +3141,8 @@ describe('slither strong inference rule', () => { ]) expect(result?.message).toContain('has two possible continuations') expect(result?.message).toContain('contradicts the puzzle') + expect(result?.inferenceDetails?.kind).toBe('slither-strong') + expect(result?.inferenceDetails?.branches).toHaveLength(2) }) it('forces an edge branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { diff --git a/src/domain/rules/slither/rules/colorAssumptionInference.ts b/src/domain/rules/slither/rules/colorAssumptionInference.ts index 61f9492..5f6b6c8 100644 --- a/src/domain/rules/slither/rules/colorAssumptionInference.ts +++ b/src/domain/rules/slither/rules/colorAssumptionInference.ts @@ -1,6 +1,6 @@ import { clonePuzzle } from '../../../ir/normalize' import { cellKey, getCellEdgeKeys, sectorKey } from '../../../ir/keys' -import type { Rule, RuleApplication } from '../../types' +import type { InferenceDetails, Rule, RuleApplication } from '../../types' import { SECTOR_MASK_ALL, SECTOR_MASK_ONLY_1, @@ -16,7 +16,7 @@ import { oppositeSlitherCellColor, type SlitherCellColor, } from './shared' -import { runTrialUntilFixpoint, type TrialResult } from './trial' +import { buildSlitherInferenceBranch, runTrialUntilFixpoint, type TrialResult } from './trial' const COLOR_ASSUMPTION_MAX_CANDIDATES = 200 const COLOR_ASSUMPTION_MAX_TRIAL_STEPS = 50 @@ -230,6 +230,33 @@ const immediateContradictionResult = (puzzle: PuzzleIR): TrialResult => ({ kind: 'color-edge', message: 'setup contradiction: the assumed color is already incompatible with the current cell state', }, + traceSteps: [], +}) + +const buildInferenceDetails = ( + puzzle: PuzzleIR, + candidate: ColorAssumptionCandidate, + greenResult: TrialResult, + yellowResult: TrialResult, +): InferenceDetails => ({ + kind: 'slither-color-assumption', + conclusion: 'opposite-branch', + basePuzzle: clonePuzzle(puzzle), + defaultBranchId: greenResult.contradiction ? 'green' : 'yellow', + branches: [ + buildSlitherInferenceBranch( + 'green', + 'Green assumption', + getCellAssumptionDiff(puzzle, candidate, 'green'), + greenResult, + ), + buildSlitherInferenceBranch( + 'yellow', + 'Yellow assumption', + getCellAssumptionDiff(puzzle, candidate, 'yellow'), + yellowResult, + ), + ], }) const formatElapsedMs = (elapsedMs: number): string => `${Math.max(0, Math.round(elapsedMs))} ms` @@ -315,6 +342,7 @@ export const createColorAssumptionInferenceRule = ( message: `Assume ${describeCandidate(candidate)} is ${failingColor}; after ${formatTrialStepCount(failingResult.stepsRun)} / ${formatElapsedMs(failingResult.elapsedMs)}, deterministic propagation reaches ${describeContradiction(failingResult)}, so ${describeCandidate(candidate)} must be ${inferredColor}. Searched ${componentsSearched} candidate ${componentsSearched === 1 ? 'component' : 'components'} from ${rawCandidateCount} candidate ${rawCandidateCount === 1 ? 'cell' : 'cells'} at probe budget ${budget}; compressed to ${componentCount} ${componentCount === 1 ? 'component' : 'components'}; ${describeProbeBranch('green', greenResult)}; ${describeProbeBranch('yellow', yellowResult)}.`, diffs, affectedCells: [candidate.cellKey], + inferenceDetails: buildInferenceDetails(puzzle, candidate, greenResult, yellowResult), } } } diff --git a/src/domain/rules/slither/rules/sectorParityInference.ts b/src/domain/rules/slither/rules/sectorParityInference.ts index 07c5269..803a28b 100644 --- a/src/domain/rules/slither/rules/sectorParityInference.ts +++ b/src/domain/rules/slither/rules/sectorParityInference.ts @@ -1,12 +1,12 @@ import { clonePuzzle } from '../../../ir/normalize' import { cellKey, getCornerEdgeKeys, parseSectorKey } from '../../../ir/keys' -import type { Rule, RuleApplication } from '../../types' +import type { InferenceDetails, Rule, RuleApplication } from '../../types' import { SECTOR_MASK_NOT_1, type EdgeMark, type PuzzleIR, } from '../../../ir/types' -import { applyEdgeAssumption, runTrialUntilFixpoint, type TrialResult } from './trial' +import { applyEdgeAssumption, buildSlitherInferenceBranch, runTrialUntilFixpoint, type TrialResult } from './trial' import { formatEdgeLabel, formatSectorKeyLabel } from './shared' const SECTOR_PARITY_MAX_CANDIDATES = 200 @@ -43,6 +43,30 @@ const immediateContradictionResult = (puzzle: PuzzleIR): TrialResult => ({ kind: 'sector-mask', message: 'setup contradiction: this parity branch is already incompatible with the current edge state', }, + traceSteps: [], +}) + +const buildInferenceDetails = ( + puzzle: PuzzleIR, + conclusion: InferenceDetails['conclusion'], + lineBranch: SectorParityBranch, + lineResult: TrialResult, + blankBranch: SectorParityBranch, + blankResult: TrialResult, +): InferenceDetails => ({ + kind: 'slither-sector-parity', + conclusion, + basePuzzle: clonePuzzle(puzzle), + defaultBranchId: + lineResult.contradiction !== blankResult.contradiction + ? lineResult.contradiction + ? 'line' + : 'blank' + : 'line', + branches: [ + buildSlitherInferenceBranch('line', 'Line parity branch', lineBranch.diffs, lineResult), + buildSlitherInferenceBranch('blank', 'Blank parity branch', blankBranch.diffs, blankResult), + ], }) const deriveProbeBudgets = (maxTrialSteps: number): number[] => { @@ -206,6 +230,14 @@ export const createSectorParityInferenceRule = ( diffs: survivingBranch.diffs, affectedCells: [cellKey(candidate.row, candidate.col)], affectedSectors: [candidate.sectorKey], + inferenceDetails: buildInferenceDetails( + puzzle, + 'opposite-branch', + lineBranch.info, + lineResult, + blankBranch.info, + blankResult, + ), } } if (lineResult.contradiction && blankResult.contradiction) { @@ -222,6 +254,14 @@ export const createSectorParityInferenceRule = ( diffs, affectedCells: [cellKey(candidate.row, candidate.col)], affectedSectors: [candidate.sectorKey], + inferenceDetails: buildInferenceDetails( + puzzle, + 'shared-consequence', + lineBranch.info, + lineResult, + blankBranch.info, + blankResult, + ), } } } diff --git a/src/domain/rules/slither/rules/strongInference.ts b/src/domain/rules/slither/rules/strongInference.ts index 63d028a..38c1596 100644 --- a/src/domain/rules/slither/rules/strongInference.ts +++ b/src/domain/rules/slither/rules/strongInference.ts @@ -1,12 +1,12 @@ import { clonePuzzle } from '../../../ir/normalize' import { cellKey, getCornerEdgeKeys, getVertexIncidentEdges, parseSectorKey } from '../../../ir/keys' -import type { Rule, RuleApplication } from '../../types' +import type { InferenceDetails, Rule, RuleApplication } from '../../types' import { SECTOR_MASK_ALL, sectorMaskSingleValue, type PuzzleIR, } from '../../../ir/types' -import { applyEdgeAssumption, runTrialUntilFixpoint, type TrialResult } from './trial' +import { applyEdgeAssumption, buildSlitherInferenceBranch, runTrialUntilFixpoint, type TrialResult } from './trial' import { formatEdgeLabel, formatSectorKeyLabel, formatVertexLabel } from './shared' // const STRONG_MAX_CANDIDATES = 1000 @@ -141,8 +141,35 @@ const immediateContradictionResult = (puzzle: PuzzleIR): TrialResult => ({ kind: 'sector-mask', message: 'setup contradiction: this branch is already incompatible with the current edge state', }, + traceSteps: [], }) +const buildInferenceDetails = ( + puzzle: PuzzleIR, + conclusion: InferenceDetails['conclusion'], + branchAInfo: StrongCandidateBranch, + branchAResult: TrialResult, + branchBInfo: StrongCandidateBranch, + branchBResult: TrialResult, +): InferenceDetails => { + const defaultBranchId = + branchAResult.contradiction !== branchBResult.contradiction + ? branchAResult.contradiction + ? 'a' + : 'b' + : 'a' + return { + kind: 'slither-strong', + conclusion, + basePuzzle: clonePuzzle(puzzle), + defaultBranchId, + branches: [ + buildSlitherInferenceBranch('a', 'Branch A', branchAInfo.diffs, branchAResult), + buildSlitherInferenceBranch('b', 'Branch B', branchBInfo.diffs, branchBResult), + ], + } +} + const deriveProbeBudgets = (maxTrialSteps: number): number[] => { const cappedMax = Math.max(1, maxTrialSteps) const budgets = [24, 96, 384, cappedMax] @@ -315,6 +342,14 @@ export const createStrongInferenceRule = ( diffs, affectedCells: candidate.kind === 'sector-only-one' ? [cellKey(candidate.row, candidate.col)] : [], affectedSectors: candidate.kind === 'sector-only-one' ? [candidate.sectorKey] : [], + inferenceDetails: buildInferenceDetails( + puzzle, + 'opposite-branch', + branchAInfo, + branchAResult, + branchBInfo, + branchBResult, + ), } } if (branchAResult.contradiction && branchBResult.contradiction) { @@ -331,6 +366,14 @@ export const createStrongInferenceRule = ( diffs, affectedCells: candidate.kind === 'sector-only-one' ? [cellKey(candidate.row, candidate.col)] : [], affectedSectors: candidate.kind === 'sector-only-one' ? [candidate.sectorKey] : [], + inferenceDetails: buildInferenceDetails( + puzzle, + 'shared-consequence', + branchAInfo, + branchAResult, + branchBInfo, + branchBResult, + ), } } } diff --git a/src/domain/rules/slither/rules/trial.ts b/src/domain/rules/slither/rules/trial.ts index ead59a4..5417cc3 100644 --- a/src/domain/rules/slither/rules/trial.ts +++ b/src/domain/rules/slither/rules/trial.ts @@ -1,6 +1,6 @@ -import { cellKey, edgeKey, getCellEdgeKeys, getCornerEdgeKeys, getVertexIncidentEdges, parseEdgeKey, parseSectorKey } from '../../../ir/keys' +import { cellKey, edgeKey, getCellEdgeKeys, getCornerEdgeKeys, getVertexIncidentEdges, parseEdgeKey, parseSectorKey, vertexKey } from '../../../ir/keys' import { runNextRule } from '../../engine' -import type { Rule } from '../../types' +import type { InferenceBranch, InferenceContradiction, Rule, TrialTraceStep } from '../../types' import { SECTOR_MASK_ALL, sectorMaskAllows, @@ -18,7 +18,7 @@ import { isSlitherCellColor, } from './shared' -export type TrialContradictionReason = { +export type TrialContradictionReason = InferenceContradiction & { kind: | 'vertex-degree' | 'cell-clue' @@ -38,8 +38,23 @@ export type TrialResult = { stepsRun: number elapsedMs: number contradictionReason?: TrialContradictionReason + traceSteps: TrialTraceStep[] } +export const buildSlitherInferenceBranch = ( + id: string, + label: string, + assumptionDiffs: InferenceBranch['assumptionDiffs'], + result: TrialResult, +): InferenceBranch => ({ + id, + label, + assumptionDiffs, + status: result.contradiction ? 'contradiction' : result.exhausted ? 'exhausted' : 'unresolved', + traceSteps: result.traceSteps, + contradiction: result.contradictionReason, +}) + export const applyEdgeAssumption = (puzzle: PuzzleIR, edgeKeyValue: string, to: EdgeMark): boolean => { const current = puzzle.edges[edgeKeyValue]?.mark ?? 'unknown' if (current !== 'unknown') { @@ -67,12 +82,14 @@ const detectVertexContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'vertex-degree', message: `vertex-degree contradiction at ${formatVertexLabel(r, c)}: ${lineCount} line edges meet there`, + vertices: [vertexKey(r, c)], } } if (unknownCount === 0 && lineCount !== 0 && lineCount !== 2) { return { kind: 'vertex-degree', message: `vertex-degree contradiction at ${formatVertexLabel(r, c)}: closed vertex has ${lineCount} line edge`, + vertices: [vertexKey(r, c)], } } } @@ -100,12 +117,14 @@ const detectCellClueContradiction = (puzzle: PuzzleIR): TrialContradictionReason return { kind: 'cell-clue', message: `cell-clue contradiction at ${formatCellLabel(r, c)}: clue ${target} already has ${lineCount} line edges`, + cells: [cellKey(r, c)], } } if (lineCount + unknownCount < target) { return { kind: 'cell-clue', message: `cell-clue contradiction at ${formatCellLabel(r, c)}: clue ${target} can reach at most ${lineCount + unknownCount} line edges`, + cells: [cellKey(r, c)], } } } @@ -120,6 +139,7 @@ const detectSectorContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'sector-mask', message: `sector-mask contradiction at ${formatSectorKeyLabel(sectorKeyValue)}: no corner line count remains allowed`, + sectors: [sectorKeyValue], } } const [row, col, corner] = parseSectorKey(sectorKeyValue) @@ -135,6 +155,7 @@ const detectSectorContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'sector-mask', message: `sector-mask contradiction at ${formatSectorKeyLabel(sectorKeyValue)}: fixed corner has ${lineCount} line edges, which the sector mask forbids`, + sectors: [sectorKeyValue], } } let hasFeasible = false @@ -148,6 +169,7 @@ const detectSectorContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'sector-mask', message: `sector-mask contradiction at ${formatSectorKeyLabel(sectorKeyValue)}: ${lineCount} fixed line edges and ${unknownCount} unknown edges leave no allowed corner count`, + sectors: [sectorKeyValue], } } } @@ -161,6 +183,7 @@ const detectVertexCandidateContradiction = (puzzle: PuzzleIR): TrialContradictio return { kind: 'vertex-candidates', message: `vertex-candidates contradiction at ${formatVertexLabel(row, col)}: no feasible degree state remains`, + vertices: [vertexKeyValue], } } } @@ -184,6 +207,8 @@ const detectColorEdgeContradiction = (puzzle: PuzzleIR): TrialContradictionReaso return { kind: 'color-edge', message: `color-edge contradiction at ${formatEdgeLabel(edgeKeyValue)}: boundary ${mark} requires ${formatCellKeyLabel(adjacentCells[0])} to be ${expected}, but it is ${color}`, + edges: [edgeKeyValue], + cells: adjacentCells, } } continue @@ -200,12 +225,16 @@ const detectColorEdgeContradiction = (puzzle: PuzzleIR): TrialContradictionReaso return { kind: 'color-edge', message: `color-edge contradiction at ${formatEdgeLabel(edgeKeyValue)}: a line edge separates equal-colored cells ${formatCellKeyLabel(adjacentCells[0])} and ${formatCellKeyLabel(adjacentCells[1])}`, + edges: [edgeKeyValue], + cells: adjacentCells, } } if (mark === 'blank' && colorA !== colorB) { return { kind: 'color-edge', message: `color-edge contradiction at ${formatEdgeLabel(edgeKeyValue)}: a blank edge connects different-colored cells ${formatCellKeyLabel(adjacentCells[0])} and ${formatCellKeyLabel(adjacentCells[1])}`, + edges: [edgeKeyValue], + cells: adjacentCells, } } } @@ -295,6 +324,7 @@ const detectLineLoopContradiction = (puzzle: PuzzleIR): TrialContradictionReason closedLoopComponents > 1 ? `line-loop contradiction: ${closedLoopComponents} separate closed loops are present` : `line-loop contradiction: a closed loop of ${closedLoopEdges} edges exists while other line edges remain outside it`, + edges: lineEdges.map(([edgeKeyValue]) => edgeKeyValue), } } return null @@ -354,6 +384,7 @@ const detectDisconnectedGreenContradiction = (puzzle: PuzzleIR): TrialContradict return { kind: 'disconnected-green', message: `disconnected-green contradiction: ${formatCellKeyLabel(disconnectedCell)} cannot connect to ${formatCellKeyLabel(greenCells[0])} through non-line edges`, + cells: [disconnectedCell, greenCells[0]], } } @@ -386,10 +417,12 @@ export const runTrialUntilFixpoint = ( stepsRun: 0, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason: initialContradictionReason, + traceSteps: [], } } let trial = puzzle + const traceSteps: TrialTraceStep[] = [] for (let stepNumber = 1; stepNumber <= maxTrialSteps; stepNumber += 1) { if (Date.now() > deadlineMs) { return { @@ -399,6 +432,7 @@ export const runTrialUntilFixpoint = ( puzzle: trial, stepsRun: stepNumber - 1, elapsedMs: Math.max(0, performance.now() - startedAt), + traceSteps, } } const { nextPuzzle, step } = runNextRule(trial, deterministicRules, stepNumber) @@ -412,9 +446,19 @@ export const runTrialUntilFixpoint = ( stepsRun: stepNumber - 1, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason: contradictionReason ?? undefined, + traceSteps, } } trial = nextPuzzle + traceSteps.push({ + ruleId: step.ruleId, + ruleName: step.ruleName, + message: step.message, + diffs: step.diffs, + affectedCells: step.affectedCells, + affectedEdges: step.affectedEdges, + affectedSectors: step.affectedSectors, + }) const contradictionReason = findHardContradictionReason(trial) if (contradictionReason) { return { @@ -425,6 +469,7 @@ export const runTrialUntilFixpoint = ( stepsRun: stepNumber, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason, + traceSteps, } } } @@ -435,5 +480,6 @@ export const runTrialUntilFixpoint = ( puzzle: trial, stepsRun: maxTrialSteps, elapsedMs: Math.max(0, performance.now() - startedAt), + traceSteps, } } diff --git a/src/domain/rules/types.ts b/src/domain/rules/types.ts index cb77346..16058b5 100644 --- a/src/domain/rules/types.ts +++ b/src/domain/rules/types.ts @@ -56,6 +56,45 @@ export type RuleDiff = | TileDiff | VertexDiff +export type InferenceFocus = { + cells?: string[] + edges?: string[] + sectors?: string[] + vertices?: string[] +} + +export type InferenceContradiction = InferenceFocus & { + kind: string + message: string +} + +export type TrialTraceStep = { + ruleId: string + ruleName: string + message: string + diffs: RuleDiff[] + affectedCells: string[] + affectedEdges: string[] + affectedSectors: string[] +} + +export type InferenceBranch = { + id: string + label: string + assumptionDiffs: RuleDiff[] + status: 'contradiction' | 'unresolved' | 'exhausted' + traceSteps: TrialTraceStep[] + contradiction?: InferenceContradiction +} + +export type InferenceDetails = { + kind: 'slither-strong' | 'slither-color-assumption' | 'slither-sector-parity' + conclusion: 'opposite-branch' | 'shared-consequence' + basePuzzle: PuzzleIR + defaultBranchId: string + branches: InferenceBranch[] +} + export type RuleStep = { id: string ruleId: string @@ -72,6 +111,7 @@ export type RuleStep = { chainDurationMs?: number ruleApplyMs?: number ruleAttempts?: RuleAttempt[] + inferenceDetails?: InferenceDetails } export type RuleApplication = { @@ -81,6 +121,7 @@ export type RuleApplication = { affectedTiles?: string[] affectedLines?: string[] affectedSectors?: string[] + inferenceDetails?: InferenceDetails } export type RuleAttempt = { diff --git a/src/features/board/CanvasBoard.tsx b/src/features/board/CanvasBoard.tsx index c93bf36..58a68d6 100644 --- a/src/features/board/CanvasBoard.tsx +++ b/src/features/board/CanvasBoard.tsx @@ -6,6 +6,7 @@ import { parseLineKey, parseSectorKey, parseTileKey, + parseVertexKey, } from '../../domain/ir/keys' import { SECTOR_MASK_ALL, @@ -14,6 +15,7 @@ import { type SectorCorner, } from '../../domain/ir/types' import type { PuzzleIR } from '../../domain/ir/types' +import type { InferenceFocus, RuleDiff } from '../../domain/rules/types' import { PuzzleStatsInfoButton } from '../puzzleStats/PuzzleStatsInfoButton' import type { DisplaySettings } from '../solver/solverStore' import { BoardDisplayButton } from './BoardDisplayButton' @@ -28,6 +30,10 @@ type Props = { highlightedColorTiles?: string[] displaySettings: DisplaySettings onSetDisplayOption: (optionId: string, enabled: boolean) => void + variant?: 'panel' | 'surface' + assumptionDiffs?: RuleDiff[] + contradictionFocus?: InferenceFocus + ariaLabel?: string } const CELL_SIZE = 52 @@ -106,6 +112,10 @@ export const CanvasBoard = ({ highlightedColorTiles = [], displaySettings, onSetDisplayOption, + variant = 'panel', + assumptionDiffs = [], + contradictionFocus, + ariaLabel, }: Props) => { const canvasRef = useRef(null) const [zoomPercent, setZoomPercent] = useState(100) @@ -398,6 +408,65 @@ export const CanvasBoard = ({ } } + if (!isMasyu && assumptionDiffs.length > 0) { + ctx.save() + ctx.strokeStyle = '#f97316' + ctx.fillStyle = 'rgba(249, 115, 22, 0.16)' + ctx.lineWidth = 4 + ctx.setLineDash([8, 5]) + for (const diff of assumptionDiffs) { + if (diff.kind === 'edge') { + const [v1, v2] = parseEdgeKey(diff.edgeKey) + ctx.beginPath() + ctx.moveTo(PADDING + v1[1] * CELL_SIZE, PADDING + v1[0] * CELL_SIZE) + ctx.lineTo(PADDING + v2[1] * CELL_SIZE, PADDING + v2[0] * CELL_SIZE) + ctx.stroke() + } else if (diff.kind === 'cell') { + const [r, c] = parseCellKey(diff.cellKey) + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + ctx.strokeRect(PADDING + c * CELL_SIZE + 3, PADDING + r * CELL_SIZE + 3, CELL_SIZE - 6, CELL_SIZE - 6) + } + } + ctx.restore() + } + + if (!isMasyu && contradictionFocus) { + ctx.save() + ctx.strokeStyle = '#dc2626' + ctx.fillStyle = 'rgba(220, 38, 38, 0.2)' + ctx.lineWidth = 5 + ctx.setLineDash([]) + for (const key of contradictionFocus.cells ?? []) { + const [r, c] = parseCellKey(key) + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + ctx.strokeRect(PADDING + c * CELL_SIZE + 2, PADDING + r * CELL_SIZE + 2, CELL_SIZE - 4, CELL_SIZE - 4) + } + for (const key of contradictionFocus.edges ?? []) { + const [v1, v2] = parseEdgeKey(key) + ctx.beginPath() + ctx.moveTo(PADDING + v1[1] * CELL_SIZE, PADDING + v1[0] * CELL_SIZE) + ctx.lineTo(PADDING + v2[1] * CELL_SIZE, PADDING + v2[0] * CELL_SIZE) + ctx.stroke() + } + for (const key of contradictionFocus.vertices ?? []) { + const [r, c] = parseVertexKey(key) + ctx.beginPath() + ctx.arc(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, 9, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + for (const key of contradictionFocus.sectors ?? []) { + const [r, c, corner] = parseSectorKey(key) + const x = PADDING + (c + (corner === 'ne' || corner === 'se' ? 1 : 0)) * CELL_SIZE + const y = PADDING + (r + (corner === 'sw' || corner === 'se' ? 1 : 0)) * CELL_SIZE + ctx.beginPath() + ctx.arc(x, y, 13, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + ctx.restore() + } + ctx.restore() }, [ displayHeight, @@ -410,6 +479,8 @@ export const CanvasBoard = ({ highlightedLines, displaySettings, puzzle, + assumptionDiffs, + contradictionFocus, width, zoom, ]) @@ -427,6 +498,21 @@ export const CanvasBoard = ({ return { lineCount, blankCount, unknownCount } }, [puzzle.edges, puzzle.lines, puzzle.puzzleType]) + const canvasSurface = ( +
+ +
+ ) + + if (variant === 'surface') { + return canvasSurface + } + return (
@@ -461,14 +547,7 @@ export const CanvasBoard = ({
-
- -
+ {canvasSurface}

Use the slider to zoom. Scroll to move around large grids. Highlight syncs with reasoning steps. diff --git a/src/features/explanation/BranchInspector.tsx b/src/features/explanation/BranchInspector.tsx new file mode 100644 index 0000000..2e82a31 --- /dev/null +++ b/src/features/explanation/BranchInspector.tsx @@ -0,0 +1,168 @@ +import { useEffect, useMemo, useState } from 'react' +import { applyRuleDiffs } from '../../domain/rules/engine' +import type { InferenceBranch, InferenceDetails } from '../../domain/rules/types' +import { CanvasBoard } from '../board/CanvasBoard' +import type { DisplaySettings } from '../solver/solverStore' + +type Props = { + details: InferenceDetails + onClose: () => void +} + +const inspectorDisplaySettings: DisplaySettings = { + showGridLabels: true, + showHighlights: true, + showCellColors: true, + showEdgeCrosses: true, + showSectorMarks: true, + showVertices: true, + showCoordinates: false, +} + +const buildBranchPuzzle = (details: InferenceDetails, branch: InferenceBranch, pointer: number) => { + let puzzle = details.basePuzzle + if (pointer >= 1) { + puzzle = applyRuleDiffs(puzzle, branch.assumptionDiffs) + } + for (let index = 0; index < pointer - 1; index += 1) { + puzzle = applyRuleDiffs(puzzle, branch.traceSteps[index].diffs) + } + return puzzle +} + +export const BranchInspector = ({ details, onClose }: Props) => { + const [branchId, setBranchId] = useState(details.defaultBranchId) + const [pointer, setPointer] = useState(0) + const branch = details.branches.find((item) => item.id === branchId) ?? details.branches[0] + const maxPointer = branch.traceSteps.length + 1 + const puzzle = useMemo( + () => buildBranchPuzzle(details, branch, pointer), + [branch, details, pointer], + ) + const activeTraceStep = pointer >= 2 ? branch.traceSteps[pointer - 2] : undefined + const showContradiction = pointer === maxPointer && branch.status === 'contradiction' + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const selectBranch = (nextBranchId: string) => { + setBranchId(nextBranchId) + setPointer(0) + } + + const stageText = + pointer === 0 + ? 'Base puzzle before the inference' + : pointer === 1 + ? 'Apply the branch assumption' + : activeTraceStep?.message ?? 'Branch result' + + return ( +

event.target === event.currentTarget && onClose()}> +
+
+
+

Branch Inspector

+

+ {details.conclusion === 'opposite-branch' + ? 'One branch contradicts the puzzle, forcing the alternative.' + : 'Both branches imply the same consequence.'} +

+
+ +
+ +
+ {details.branches.map((item) => ( + + ))} +
+ +
+
+ diff.kind === 'cell' ? [diff.cellKey] : []) ?? []} + highlightedEdges={activeTraceStep?.affectedEdges ?? []} + displaySettings={inspectorDisplaySettings} + onSetDisplayOption={() => {}} + variant="surface" + assumptionDiffs={pointer >= 1 ? branch.assumptionDiffs : []} + contradictionFocus={showContradiction ? branch.contradiction : undefined} + ariaLabel="Slitherlink branch inspector canvas" + /> +
+ + +
+ +
+
+ {pointer === 0 ? 'Base puzzle' : pointer === 1 ? 'Assumption' : activeTraceStep?.ruleName} +

{stageText}

+
+
+ + + {pointer} / {maxPointer} + +
+
+
+
+ ) +} diff --git a/src/features/explanation/ExplanationPanel.tsx b/src/features/explanation/ExplanationPanel.tsx index 8aeccef..1113429 100644 --- a/src/features/explanation/ExplanationPanel.tsx +++ b/src/features/explanation/ExplanationPanel.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import type { RuleStep } from '../../domain/rules/types' +import { BranchInspector } from './BranchInspector' type Props = { steps: RuleStep[] @@ -21,6 +22,7 @@ const buildStepMeta = (step: RuleStep): string => { export const ExplanationPanel = ({ steps }: Props) => { const [showAllSteps, setShowAllSteps] = useState(false) + const [inspectedStep, setInspectedStep] = useState(null) const visibleEntries = useMemo( () => (showAllSteps ? steps : steps.slice(-30)) @@ -66,10 +68,25 @@ export const ExplanationPanel = ({ steps }: Props) => {

{step.message}

{buildStepMeta(step)}

+ {step.inferenceDetails ? ( + + ) : null} )) )} + {inspectedStep?.inferenceDetails ? ( + setInspectedStep(null)} + /> + ) : null}
) } From c6d59bde60ff6ce0ecf7db244325dd0cc0e7b327 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 7 Jun 2026 12:20:58 +0800 Subject: [PATCH 02/14] feat: strong inference visualization and contradiction display, slitherlink-only Fixes #11 --- src/app/WorkspacePage.test.tsx | 14 ++++- src/app/workspace.css | 61 ++++++++++++++++--- src/features/explanation/BranchInspector.tsx | 8 +-- src/features/explanation/ExplanationPanel.tsx | 24 +++++--- 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index fb1b095..1354f7b 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -891,20 +891,28 @@ describe('WorkspacePage', () => { if (!reasoningPanel) { throw new Error('Expected reasoning panel') } + const strongInferenceMessage = within(reasoningPanel).getByText(/Strong inference: edge V\(1, 2\)-V\(1, 3\)/i) + expect(strongInferenceMessage.firstChild).toHaveTextContent('[View details]') expect(within(reasoningPanel).getAllByRole('button', { name: /view details/i })).toHaveLength(1) + expect(within(reasoningPanel).queryByText(/\[View details\].*Apply Vertex Flow/i)).not.toBeInTheDocument() fireEvent.click(within(reasoningPanel).getByRole('button', { name: /view details/i })) const dialog = screen.getByRole('dialog', { name: /branch inspector/i }) expect(dialog).toHaveAttribute('aria-modal', 'true') expect(within(dialog).getByText(/vertex-degree contradiction at V\(5, 3\)/i)).toBeInTheDocument() - expect(within(dialog).getByLabelText(/branch replay step/i)).toHaveAttribute('max', '10') + expect(within(dialog).getByLabelText('Branch replay step', { exact: true })).toHaveAttribute('max', '10') expect(within(dialog).getByLabelText(/slitherlink branch inspector canvas/i)).toBeInTheDocument() + const stageDetails = within(dialog).getByLabelText('Current branch replay step', { exact: true }) + expect(within(stageDetails).getByText('Base puzzle', { exact: true })).toBeInTheDocument() + const inspectorFooter = dialog.querySelector('.branch-inspector-controls') + expect(inspectorFooter).not.toBeNull() + expect(inspectorFooter).not.toHaveTextContent(/base puzzle before the inference/i) fireEvent.click(within(dialog).getByRole('button', { name: /next/i })) - expect(within(dialog).getByText(/apply the branch assumption/i)).toBeInTheDocument() + expect(within(stageDetails).getByText(/apply the branch assumption/i)).toBeInTheDocument() fireEvent.click(within(dialog).getByRole('tab', { name: /branch b unresolved/i })) - expect(within(dialog).getByLabelText(/branch replay step/i)).toHaveAttribute('max', '3') + expect(within(dialog).getByLabelText('Branch replay step', { exact: true })).toHaveAttribute('max', '3') fireEvent.keyDown(window, { key: 'Escape' }) expect(screen.queryByRole('dialog', { name: /branch inspector/i })).not.toBeInTheDocument() diff --git a/src/app/workspace.css b/src/app/workspace.css index 0d75127..e4e6dba 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -1152,8 +1152,26 @@ button[data-active='true'] { font-size: 0.85rem; } -.step-details-button { - margin-top: 8px; +.step-details-link { + appearance: none; + border: 0; + background: transparent; + color: #0284c7; + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; +} + +.step-details-link:hover { + color: #0369a1; +} + +.step-details-link:focus-visible { + border-radius: 3px; + outline: 2px solid #67e8f9; + outline-offset: 2px; } .branch-inspector-overlay { @@ -1177,8 +1195,7 @@ button[data-active='true'] { padding: 16px; } -.branch-inspector-header, -.branch-inspector-controls { +.branch-inspector-header { display: flex; justify-content: space-between; align-items: center; @@ -1186,8 +1203,7 @@ button[data-active='true'] { } .branch-inspector-header h2, -.branch-inspector-header p, -.branch-inspector-controls p { +.branch-inspector-header p { margin: 0; } @@ -1197,7 +1213,6 @@ button[data-active='true'] { } .branch-inspector-header p, -.branch-inspector-controls p, .branch-inspector-muted { margin-top: 4px; color: #64748b; @@ -1268,6 +1283,28 @@ button[data-active='true'] { line-height: 1.45; } +.branch-inspector-stage { + height: 150px; + overflow: auto; + margin-top: 18px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.branch-inspector-stage strong { + color: #0f172a; + font-size: 0.9rem; +} + +.branch-inspector-stage p { + margin: 5px 0 0; + color: #64748b; + font-size: 0.84rem; + line-height: 1.45; +} + .branch-inspector-legend { display: grid; gap: 8px; @@ -1303,6 +1340,8 @@ button[data-active='true'] { } .branch-inspector-controls { + display: flex; + justify-content: flex-end; margin-top: 14px; } @@ -1329,11 +1368,15 @@ button[data-active='true'] { grid-template-columns: 1fr; } - .branch-inspector-header, - .branch-inspector-controls { + .branch-inspector-header { align-items: stretch; flex-direction: column; } + + .branch-inspector-navigation { + width: 100%; + justify-content: flex-end; + } } .stats-summary-grid { diff --git a/src/features/explanation/BranchInspector.tsx b/src/features/explanation/BranchInspector.tsx index 2e82a31..4b0c126 100644 --- a/src/features/explanation/BranchInspector.tsx +++ b/src/features/explanation/BranchInspector.tsx @@ -128,6 +128,10 @@ export const BranchInspector = ({ details, onClose }: Props) => { ) : (

No contradiction was found within the probe budget.

)} +
+ {pointer === 0 ? 'Base puzzle' : pointer === 1 ? 'Assumption' : activeTraceStep?.ruleName} +

{stageText}

+
assumption current trial step @@ -137,10 +141,6 @@ export const BranchInspector = ({ details, onClose }: Props) => {