From 9137e1a20b900d0153d2c47237f43162e5934cab Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 22 May 2026 00:57:21 +0800 Subject: [PATCH 01/22] chore: docs clean --- docs/MASYU_AGENT_BRIEF.md | 31 +- docs/MASYU_ASSIST_STRATEGIES_CN.md | 553 --------------------- docs/MASYU_CHANGELOG.md | 214 --------- docs/MASYU_RULE_ABSTRACTIONS.md | 743 ----------------------------- docs/MASYU_RULE_ROADMAP.md | 213 +++++++++ docs/PROJECT_GUIDE_EN.md | 28 +- docs/techniques/masyu.md | 9 - docs/techniques/slitherlink.md | 69 ++- 8 files changed, 279 insertions(+), 1581 deletions(-) delete mode 100644 docs/MASYU_ASSIST_STRATEGIES_CN.md delete mode 100644 docs/MASYU_CHANGELOG.md delete mode 100644 docs/MASYU_RULE_ABSTRACTIONS.md create mode 100644 docs/MASYU_RULE_ROADMAP.md diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index 17a1f90..c28625e 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -5,10 +5,9 @@ This is the lightweight starting point for AI agents working on Masyu in PuzzleK ## Read-On-Demand Route - Implement a Masyu rule: read this brief, then inspect the relevant `src/domain/rules/masyu/*` files. -- Research Puzzlink Assistance strategy: also read `docs/MASYU_ASSIST_STRATEGIES_CN.md`. -- Design a new rule family: also read `docs/MASYU_RULE_ABSTRACTIONS.md`. -- Check historical context: also read `docs/MASYU_CHANGELOG.md`. +- Refactor or design Masyu rules: also read `docs/MASYU_RULE_ROADMAP.md`. - Change plugin, IR, replay, stats, or app-wide architecture: also read `docs/PROJECT_GUIDE_EN.md`. +- User-facing Masyu rules/help wording lives in `docs/techniques/masyu.md`. ## Current State @@ -92,12 +91,21 @@ Replay and rendering plumbing: ## Current Development Direction -Near-term Masyu work should focus on making tile color useful beyond connectivity coloring: +Near-term Masyu work should focus on reducing rule bloat before adding more +large deductions: -1. Add pearl-local color implications: - - migrate selected Puzzlink in/out tricks only when they can be explained as small Masyu tile parity rules. +1. Consolidate repeated rule primitives: + - line/tile decision collection; + - pearl selection and candidate modeling; + - line graph helpers; + - tile parity graph helpers. -2. Keep rule granularity small: +2. Use the shared primitives to add missing deterministic strength: + - white pearl candidate pruning; + - pearl-local tile color implications; + - candidate graph articulation improvements. + +3. Keep rule granularity small: - one reasoning idea per rule; - explicit diffs; - concise explanation message; @@ -123,18 +131,15 @@ pnpm build ## When To Read More -Read `docs/MASYU_RULE_ABSTRACTIONS.md` when designing a new rule family or checking intended rule taxonomy. - -Read `docs/MASYU_ASSIST_STRATEGIES_CN.md` only when tracing a deduction back to Puzzlink Assistance. It is research/provenance, not the implementation source of truth. - -Read `docs/MASYU_CHANGELOG.md` only when historical context matters. +Read `docs/MASYU_RULE_ROADMAP.md` when refactoring existing Masyu rules, designing +new rule families, or checking the intended candidate/color/graph direction. Read `docs/PROJECT_GUIDE_EN.md` when changing plugin contracts, IR conventions, replay, stats, or app-wide architecture. ## Maintenance Rules - Update this brief whenever rule order, canonical state, or next development direction changes. -- Keep this brief current, not historical. Move history to `docs/MASYU_CHANGELOG.md`. +- Keep this brief current, not historical. Use git history for old implementation notes. - Keep this brief short enough that it can be pasted into an AI context without drowning the actual task. - Prefer links and routing over duplicating long explanations. diff --git a/docs/MASYU_ASSIST_STRATEGIES_CN.md b/docs/MASYU_ASSIST_STRATEGIES_CN.md deleted file mode 100644 index 4ec54d6..0000000 --- a/docs/MASYU_ASSIST_STRATEGIES_CN.md +++ /dev/null @@ -1,553 +0,0 @@ -# Puzzlink Assistance 中的 Masyu 求解策略调研 - -本文调研油猴脚本 `Puzzlink_Assistance.js` 里作者对 Masyu 的自动辅助策略,重点比较 `SingleLoopInCell` 与 `SingleLoopInBorder` 这两个通用单环推理框架。 - -结论先行: - -- `SingleLoopInCell` 适合 Masyu 这类“环经过格子中心”的谜题。它把格子当作图的顶点,把相邻格子之间的 border 当作边,目标是形成一条经过指定格子的单一回路。 -- `SingleLoopInBorder` 适合 Slitherlink 这类“环画在格子边界上”的谜题。它把格点 cross 当作图的顶点,把 border 当作边,同时用格子染色表示环的内外侧。 -- 二者共同解决的是单环基础约束:不能分叉、不能死端、不能提前形成小环、所有线段最终必须属于同一个连通分量。 -- Masyu 的珠子规则不是全部塞进通用单环框架,而是在 `SingleLoopInCell({ isPass: c => c.qnum !== CQNUM.none })` 之后追加一组白珠、黑珠和局部图形模式。 - -## 脚本中的基础状态 - -脚本基于 puzz.link / pzpr 的对象模型工作。几个状态很关键: - -- `border.line === 1`:这条边已经确定为线,脚本用 `isLine(b)` 判断,用 `add_line(b)` 写入。 -- `border.qsub === BQSUB.cross`:这条边已经确定不能走线,脚本用 `isCross(b)` 判断,用 `add_cross(b)` 写入。 -- `cell.lcnt`:一个格子周围已经接入的线数。对 Masyu 的格心路径来说,它就是该格子的当前度数。 -- `cross.qsub === CRQSUB.in / out`:交叉点上的隐式内外侧标记。`SingleLoopInCell` 会把这些标记用作“线两侧”的拓扑约束。 -- `cell.qsub === CQSUB.green / yellow`:格子背景色。`SingleLoopInBorder` 用它区分 Slitherlink 环内外。 -- `cell.path`、`cross.path`、`board.linegraph.components.length`:puzz.link 已经维护好的线段连通分量信息。脚本用它判断“如果现在连上这条边,是否会提前闭合一个小环”。 - -所有规则都不是一次性求完整解,而是在 `assist()` 的循环中反复调用。每次 `add_line`、`add_cross`、`add_inout` 等写入一个确定状态后,`stepcheck()` 增加推理步数;循环继续运行,直到没有新结论、达到步数上限或超时。 - -## `SingleLoopInCell`:格心路径模型 - -`MasyuAssist()` 的第一步是: - -```js -SingleLoopInCell({ - isPass: c => c.qnum !== CQNUM.none, -}); -``` - -这里的 `isPass` 表示“这个格子最终必须被环经过”。对 Masyu 来说,所有白珠、黑珠格都必须被环经过,普通空格则可以经过也可以不经过。 - -### 图模型 - -`SingleLoopInCell` 把棋盘看作如下图: - -```text -cell --border-- cell --border-- cell -``` - -- 图的顶点是格子中心。 -- 图的边是两个相邻格子之间的 border。 -- 一条 `line` 表示环从一个格子中心走到相邻格子中心。 -- 一个 `cross` 表示这两个格子中心不能直接相连。 - -这与 Masyu 规则天然一致:Masyu 的线是穿过格子中心的正交路径,珠子对“路径在该格怎么进出、下一格怎么走”施加约束。 - -### 可配置参数的意义 - -`SingleLoopInCell` 是通用函数,不只服务 Masyu。它的参数给不同谜题留了接口: - -- `isPassable(cell)`:这个格子是否允许被路径经过。默认所有格子都可经过。 -- `isPathable(border)`:这条边是否还可能成为路径。默认只要不是 `cross` 就可走。 -- `isPass(cell)`:这个格子是否已经或必须被路径经过。Masyu 传入“有珠子的格子”。 -- `isPath(border)`:这条边当前是否是路径。默认是 `isLine`。 -- `add_notpass(cell)` / `add_pass(cell)`:推出格子不经过或经过时的写入动作。Masyu 没有显式写格子状态,所以基本不用。 -- `add_notpath(border)` / `add_path(border)`:推出边不能走或必须走时的写入动作。默认分别是 `add_cross` 和 `add_line`。 -- `Directed`:用于需要方向箭头的单环谜题。Masyu 不使用。 -- `hasCross`:用于允许路径在格内交叉的变体。Masyu 不使用。 - -### 推理流程 - -`SingleLoopInCell` 大致按以下层次工作。 - -### 1. 内外侧传播 - -在没有冰块、没有交叉路径的普通情形下,函数先在 `cross` 上维护 `CRQSUB.in / out`。直观上,格心路径的一条线会把相邻两个 cross 分在环的两侧;一条禁止线的边则不会穿过环,两侧保持同侧。 - -核心关系是: - -- 如果两个相邻 cross 同为 `in` 或同为 `out`,它们之间的 border 不能是线,应当为 `cross`。 -- 如果两个相邻 cross 一内一外,它们之间的 border 必须是线。 -- 如果 border 已经是 `cross`,两端 cross 的内外侧应相同。 -- 如果 border 已经是 `line`,两端 cross 的内外侧应相反。 - -代码最后用 DFS 从已经有 `in/out` 的 cross 出发传播这些关系: - -```js -if (cr.qsub === ncr.qsub) { add_cross(b); } -if (cr.qsub !== ncr.qsub) { add_line(b); } -if (isntLine(b)) { add_inout(ncr, cr.qsub); } -if (isLine(b)) { add_inout(ncr, cr.qsub ^ CRQSUB.inout); } -``` - -这是一种很强的拓扑推理:它不直接看珠子,而是把“单环把平面分成内外”这个事实编码到 cross 的二染色里。 - -### 2. 不能提前闭合小环 - -函数会遍历每个已有线段分量的端点。若某条候选边会连接同一个 `path` 分量的两个端点,并且棋盘上还有其他线段分量或还有必须经过但尚未经过的格子,那么这条边会形成一个提前闭合的小环,必须打叉。 - -对 Masyu 来说,这条规则非常重要。因为 Masyu 最终只能有一个环,不能先在局部珠子附近形成一个已经闭合的圈,然后让其他珠子留在圈外。 - -可以理解为: - -```text -如果 A 和 B 已经属于同一条未完成路径, -且 A--B 这条候选边会闭合它, -但还有其他必须纳入的内容, -那么 A--B = cross。 -``` - -### 3. 度数约束:不能分叉,不能死端 - -对每个格子,函数统计: - -- `linecnt`:四周已有多少条线接入。 -- `emptycnt`:四周还有多少条可走候选边。 - -随后推出: - -- 如果某格已有 2 条线接入,它在单环中度数已经满了,其他方向全部 `cross`。 -- 如果某格可走方向小于等于 1,那么它不可能成为环上的普通点,周围候选边全部 `cross`。 -- 如果某格必须经过,或者已经有 1 条线接入,而可走方向只剩 2 条,则这两条可走边必须都成为 `line`。 - -这就是单环题最基础的“每个经过点度数为 2”。 - -### 4. 连通分量阻断 - -如果一个格子尚未接线,但它周围所有可走邻居都已经属于同一个路径分量,那么从这个格子进入再出去会把同一分量提前闭合。函数会把相关候选边打叉。 - -相反,如果某个必须经过的格子周围候选边被分成两个路径分量,并且每个分量只能通过某些边进入这个格子,那么这些边会被临时标为 `lineaux`,再进一步推出必须走的边。这部分代码相当于一种“弹回 / bouncing”推理:为了避免把两个入口选择错成同一分量,必须让路径穿过这个格子把两个分量连起来。 - -### 5. 可选方向推理 - -`Directed` 分支会在边上记录箭头,用于带方向的环题。它可以推出: - -- 一个度数为 2 的格子,如果一条线方向已知,另一条线方向也能确定。 -- 两个只有一个端口的路径端点如果具有相同的入/出方向,则不能相连。 -- 根据环的整体顺逆时针方向,把箭头和 `in/out` 关系互相传播。 - -Masyu 没有传 `Directed`,所以这些不是 Masyu 当前使用的核心策略,但它说明作者把 `SingleLoopInCell` 做成了较通用的“格心单环引擎”。 - -## `SingleLoopInBorder`:边界路径模型 - -`SlitherlinkAssist()` 的第一步是: - -```js -SingleLoopInBorder(); -``` - -Slitherlink 的线不穿过格子中心,而是画在格子边界上。因此它的自然图模型与 Masyu 不同: - -```text -cross --border-- cross - | cell | -cross --border-- cross -``` - -- 图的顶点是格点 cross。 -- 图的边是 border。 -- 数字格约束的是围绕该 cell 的四条 border 有几条是线。 -- 格子本身不在路径上,而是在环的内侧或外侧。 - -### 1. 格子内外染色 - -`SingleLoopInBorder` 先用 `CellConnected` 对格子做两次连通性搜索: - -- 绿色 `green` 表示一侧区域。 -- 黄色 `yellow` 表示另一侧区域,且棋盘外部视为黄色。 -- `line` 是内外区域之间的边界,不可穿过。 -- `cross` 表示这条边不是环,两个相邻格子在同一区域,可以连通。 - -于是有两条核心规则: - -- 相邻两格颜色不同,则它们之间必须有线。 -- 相邻两格颜色相同,则它们之间必须不是线,即 `cross`。 - -反过来也成立: - -- 若一条边是 `line`,两侧格子颜色应不同。 -- 若一条边是 `cross`,两侧格子颜色应相同。 - -这与 `SingleLoopInCell` 的 cross 内外侧传播很像,但作用对象不同。Masyu 的 `in/out` 标在格点 cross 上;Slitherlink 的 `green/yellow` 标在格子 cell 上。 - -### 2. NoCheckerCell:排除棋盘格状矛盾 - -`SingleLoopInBorder` 调用了 `NoCheckerCell`,目标是避免内外染色出现棋盘格式的局部矛盾。直观上,如果四个格子围绕一个交点交替内外,那么交点附近会要求四条边全部成为边界,导致顶点度数不符合单环规则。该规则把“内外区域不能以不合法方式交错”的拓扑事实变成染色推理。 - -### 3. 顶点候选集:cross 上只允许 0 条或 2 条线 - -Slitherlink 的每个 cross 是路径顶点。单环经过一个顶点时,必须恰好有 2 条 incident border 是线;如果不经过,则 0 条是线。 - -`SingleLoopInBorder` 用 `cross.qsub` 存一个 JSON 候选集: - -- 初始候选包含 `[]`,表示这个 cross 不被路径经过。 -- 还包含所有从四条 incident border 中任选两条的组合,表示路径从其中两条边进出。 -- 已经打叉的边会从候选集中删除。 -- 已经画线的边必须出现在候选中。 -- 如果所有候选都包含某条边,则该边必为 `line`。 -- 如果所有候选都不包含某条边,则该边必为 `cross`。 - -这相当于一个很小的局部约束传播器。它比单纯的“已有两条线就打叉、已有三条叉就补线”更强,因为它能把数字格和顶点候选联动起来。 - -### 4. 顶点度数的直接推理 - -除了候选集,函数还做直接计数: - -- 如果某 cross 已有 2 条线,则其他 incident border 都必须 `cross`。 -- 如果某 cross 已有 3 条 `cross`,则剩下那条也必须 `cross`,因为不能形成度数 1 的死端。 -- 如果某 cross 已有 1 条线且已有 2 条 `cross`,则剩下那条必须 `line`,补足度数 2。 - -### 5. 禁止提前闭环 - -对每条尚未决定的 border,如果它两端 cross 已经在同一个 `path` 分量中,并且棋盘上还有其他线段分量,那么画这条线会提前闭合小环。函数把它打叉。 - -这与 `SingleLoopInCell` 的提前闭环规则同源,只是判断对象从 cell path 改成 cross path。 - -## 二者的相同点与差异 - -### 相同点 - -- 都是“单一回路”推理引擎,不负责猜测,专门推出局部必然结论。 -- 都把未知边逐步决定为 `line` 或 `cross`。 -- 都维护度数约束:路径上的节点度数必须为 2,不能出现度数 1 的死端,也不能出现度数超过 2 的分叉。 -- 都利用 puzz.link 的 `linegraph.components` / `path` 信息禁止提前形成小环。 -- 都引入内外侧思想:单环会把平面分成内外两侧,颜色或 `in/out` 可继续反推线与叉。 -- 都作为题型专用规则的底座。Masyu 在其上加珠子规则,Slitherlink 在其上加数字格规则。 - -### 差异 - -| 维度 | `SingleLoopInCell` | `SingleLoopInBorder` | -| --- | --- | --- | -| 典型题型 | Masyu | Slitherlink | -| 路径位置 | 穿过格子中心 | 画在格子边界 | -| 图顶点 | cell | cross | -| 图边 | 相邻 cell 之间的 border | 相邻 cross 之间的 border | -| 约束核心 | 哪些格子必须被经过,经过格度数为 2 | 每个顶点度数为 0 或 2,每个数字格周围线数匹配数字 | -| 内外标记 | `cross.qsub = in/out` | `cell.qsub = green/yellow` | -| 候选传播 | 主要依靠格子度数、连通分量、内外侧传播 | 额外在每个 cross 上保存 incident edge 组合候选 | -| 提前闭环判断 | 连接同一 cell path 会不会闭合小环 | 连接同一 cross path 会不会闭合小环 | -| 题型局部规则 | 白珠直行并邻格转弯,黑珠转弯并两侧直行 | 数字格周围线数,常见 2/3/3-3 模式,区域染色 | - -一个简化理解是:`SingleLoopInCell` 解的是“哪些格子连成环”;`SingleLoopInBorder` 解的是“哪些边界围成环”。两者的 border 都是最终画线的对象,但图论意义不同。 - -## MasyuAssist 的专用策略 - -`MasyuAssist()` 在通用单环推理后,定义: - -```js -let isBlack = c => !c.isnull && c.qnum === CQNUM.bcir; -let isWhite = c => !c.isnull && c.qnum === CQNUM.wcir; -let isPathable = b => !b.isnull && !isCross(b); -``` - -然后对每个 cell、每个方向 `d` 套用一组旋转对称的局部规则。下面按策略含义解释。 - -### 1. 白珠与内外侧的关系 - -白珠规则:线必须直行通过白珠,并且至少在白珠相邻的一侧转弯。 - -代码中: - -```js -if (isWhite(cell) && offset(cell, .5, .5, d).qsub !== CRQSUB.none) { - add_inout(offset(cell, -.5, -.5, d), offset(cell, .5, .5, d).qsub ^ 1); -} -``` - -这是在利用白珠的“直行”性质传播对角 cross 的内外侧。白珠直行通过时,对角上的两侧关系可以被确定:一个角的 `in/out` 已知,另一个对角也能推出。它不直接画线,但会让后续内外侧传播推出 `line` 或 `cross`。 - -### 2. 黑珠夹在两个白珠对角之间时的内外侧关系 - -黑珠规则:线必须在黑珠处转弯,并且离开黑珠后的下一段仍要直行。 - -代码检查黑珠两侧对角位置有白珠: - -```js -if (isBlack(cell) && isWhite(offset(cell, -1, -1, d)) && isWhite(offset(cell, 1, 1, d)) && - offset(cell, .5, .5, d).qsub !== CRQSUB.none) { - add_inout(offset(cell, -.5, -.5, d), offset(cell, .5, .5, d).qsub); -} -``` - -以及另一个镜像关系。含义是:黑珠必须转弯,附近两个白珠又会要求直行并在邻格转弯,这个组合会固定某些角点的内外侧同异关系。作者把它作为拓扑传播规则,而不是显式枚举所有线型。 - -### 3. 两个黑珠中间一侧被堵:另一侧不能走 - -图形注释: - -```text - +×+ +×+ -● ● -> ● ● - + + +×+ -``` - -若一个空位左右各有黑珠,并且上方边已经不可走,那么下方边也必须不可走。 - -原因是黑珠如果朝中间延伸,会要求离开黑珠后继续直行;两个黑珠同时受限时,中间路径会造成无法满足的进出方式。作者用这个模式提前打叉,减少后续搜索。 - -### 4. 白珠已有一侧线,或垂直方向被堵:强制横向直行 - -图形注释: - -```text -+ + +×+ -━○ -> ━○━ -+ + +×+ -``` - -如果白珠左侧已有线,那么它必须从右侧出去,且上下不能走。或者如果上方不可走,白珠不能选择竖直轴,只能选择水平轴。于是: - -- 左右两侧加线。 -- 上下两侧打叉。 - -这是最基础的白珠补全:白珠必须直行,不能转弯经过。 - -### 5. 白珠一侧已经连续直行两段:另一侧不能继续直行太远 - -图形注释: - -```text -+ + + + + + + + -━━━○━╸ -> ━━━○━╸× -+ + + + + + + + -``` - -白珠要求“至少一侧相邻格要转弯”。如果白珠左侧已经出现连续两段直线,说明左侧相邻格没有转弯,那么右侧相邻格必须承担转弯义务,不能继续向右直行第二段。因此右侧远端边打叉。 - -这是白珠规则中容易漏掉的二阶约束:不仅白珠本身直行,白珠两侧的下一格还要至少一边发生转弯。 - -### 6. 连续白珠或两侧都无法承担直行轴:强制改用垂直轴 - -图形注释: - -```text -+ + + + + +┃+ + -━╸ ○ ○ -> ━╸×○×○ -+ + + + + +┃+ + -``` - -代码判断白珠左右两侧是否都不适合作为直行通过方向。触发条件包括: - -- 某侧已经有远端直线,使该侧无法满足白珠邻格转弯要求。 -- 某侧相邻格也是白珠,连续白珠会互相限制直行轴。 -- 某侧上下转弯候选都不可用,或被黑珠局部规则堵住。 - -当左右轴无法满足白珠要求时,白珠只能使用上下轴: - -- 左右打叉。 -- 上下加线。 - -这个规则体现了作者的模式化思路:不是只看白珠当前四边,而是看相邻一格、两格范围内的“这条轴能否合法完成白珠条件”。 - -### 7. 黑珠一侧不能作为出口:强制从另一侧进入,并堵掉坏方向 - -图形注释把多个情形合并: - -```text -+ + + : + + + : + +┃+ : + + + : + + + + + + -━● : ●× : ● ╹ : ● ● : ● × -> ━●× -+ + + ; + + + ; + + + ; + + + ; + + + + + + -``` - -对黑珠来说,若向右作为出口不合法,则左侧必须是线,右侧必须打叉。右侧不合法的原因包括: - -- 黑珠右侧边已经不可走。 -- 从黑珠向右走后,下一格不能继续直行。 -- 右侧相邻位置有黑珠,会冲突。 -- 右侧第二段不可走。 -- 某些相邻已有线会迫使黑珠不能按规则转弯后直行。 - -这对应黑珠基本规则:黑珠必须转弯,且离开黑珠的两个方向都要至少延伸一格。若某个方向不能满足“离开后继续直行”,该方向不能作为黑珠出口。 - -### 8. 黑珠已有一侧线:同方向第二段强制为线 - -图形注释: - -```text -+ + + + + + - ●━╸ -> ●━━━ -+ + + + + + -``` - -如果黑珠右侧已有线,那么这条线离开黑珠后必须继续直行一段,所以右侧第二段强制为线。 - -这正是黑珠“转弯后两边必须直行一格”的补全规则。 - -### 9. 黑珠面向两个连续白珠:反方向强制为线 - -图形注释: - -```text -+ + + + + + + + + + - ● ○ ○ -> ━● ○ ○ -+ + + + + + + + + + -``` - -如果黑珠某方向上隔一个空格后出现两个连续白珠,那么黑珠不能朝那边延伸。因为连续白珠会要求直行与邻格转弯,和黑珠离开后继续直行的需求冲突。于是黑珠必须选择反方向的一条线。 - -这个模式已经在当前项目的 `createBlackFacingConsecutiveWhitesRule()` 中有对应实现。 - -### 10. 黑珠被两个斜向白珠夹住:朝另一侧强制为线 - -图形注释: - -```text -+ + + + + + + + - ○ ○ ○ ○ -+ + + + -> + + + + - ● ● -+ + + + + +┃+ + -``` - -如果黑珠某侧的两个斜对角都是白珠,那么黑珠朝这一侧转弯/延伸会让两个白珠的转弯义务变得不可满足。于是黑珠必须朝相反方向延伸。 - -这个模式对应当前项目里的 `createBlackDiagonalWhitePinchRule()`。 - -### 11. 已有路径形状配合白珠/黑珠避免小环 - -后面几段以四条已有线构成一个拐角路径为前提: - -```js -[[0, .5], [0, 1.5], [.5, 0], [1.5, 0]].every(([dx, dy]) => isLine(offset(cell, dx, dy, d))) -``` - -这表示局部已经有一个 `┏` 形或类似的路径框架。若附近出现特定白珠或黑珠,且当前线图还有多个连通分量,作者会强制补出某些线,避免该路径框架闭合成局部小环或让珠子规则无解。 - -这些规则更像“模式库”: - -- 拐角路径旁有两个白珠时,补出穿过白珠的直线与转弯结构。 -- 拐角路径旁有黑珠与白珠时,补出黑珠离开后的直线延伸。 -- 若不补这些线,就会导致已有路径分量只能错误闭合,或违反白/黑珠的局部形态。 - -这些规则的解释性要求比较高。迁移时不建议只照搬坐标模式,而应把它们转写成“假设不走这条边会造成提前闭环或珠子不可满足”的小型反证规则。 - -### 12. 基于连通性的黑珠强制 - -代码: - -```js -if (isBlack(cell) && cell.path !== null && cell.path === offset(cell, 2, 0, d).path && - offset(cell, 1, 0, d).path === null && board.linegraph.components.length > 1) { - add_line(offset(cell, -.5, 0, d)); -} -``` - -含义是:黑珠与同方向隔两格的 cell 已经属于同一条路径分量,而中间格还不在路径上。如果黑珠再朝这个方向走,可能会把同一分量过早接回,且黑珠还需要转弯后直行。因此反方向边被强制为线。 - -它把黑珠局部规则和全局单环连通性结合起来。 - -### 13. 基于连通性的白珠强制 - -代码: - -```js -if (isWhite(cell) && offset(cell, -1, 0, d).path !== null && offset(cell, -1, 0, d).path === offset(cell, +1, 0, d).path && - board.linegraph.components.length > 1) { - add_line(offset(cell, 0, +.5, d)); - add_line(offset(cell, 0, -.5, d)); -} -``` - -如果白珠左右两侧已经属于同一路径分量,那么让白珠横向直行会提前闭合该分量。因此白珠必须改走垂直方向,上下两边强制为线。 - -这是很典型的“白珠必须直行,但直行轴不能造成小环,所以选择另一条轴”的解释型规则。 - -## SlitherlinkAssist 在 `SingleLoopInBorder` 上追加的策略 - -为了对比,Slitherlink 的题型规则主要围绕数字格展开: - -- 若某数字格周围已有线数等于数字,其余边全部打叉。 -- 若某数字格周围未打叉的边数等于数字,所有未打叉边全部画线。 -- 利用 cross 上的候选集与数字格组合过滤,进一步收缩顶点候选。 -- 对常见局部形状加入模式,例如相邻 `3-3`、`2-3` 在特定边已叉时强制画线和打叉。 -- 基于内外染色,数字格周围相邻格的颜色数量可以反推该格是内侧还是外侧,也可以反推边是线还是叉。 - -因此 Slitherlink 的求解重心是“边界数量 + 顶点度数 + 内外区域”;Masyu 的求解重心是“格心路径 + 珠子局部几何 + 单环连通性”。 - -## 对可解释分步求解器的迁移建议 - -### 1. 把通用单环层和题型规则层拆开 - -建议将 Masyu 求解器分成两层: - -- 通用格心单环层:负责度数、死端、提前闭环、连通分量、必须经过格子的基础约束。 -- Masyu 珠子层:负责白珠直行并邻格转弯、黑珠转弯并两侧延伸,以及由珠子组合产生的模式。 - -这样每一步解释可以清楚地区分: - -- “这是所有单环题都成立的拓扑理由。” -- “这是 Masyu 珠子规则导致的局部必然。” - -### 2. 用 IR 中的 line/cell 图显式表达 `SingleLoopInCell` - -当前项目已经在 Masyu completion 和 rules 中把 line 视为相邻 cell 的连接。后续可以继续沿用: - -- 顶点:`cellKey(row, col)`。 -- 边:`lineKey([row1, col1], [row2, col2])`。 -- 度数:某 cell incident line 数。 -- 连通性:并查集维护 line 连接的 cell 分量。 -- 提前闭环:候选 line 两端已在同一分量,且还有其他 line 分量或未满足 pearl 时,候选 line 为 blank。 - -这与油猴脚本里的 `cell.path` / `linegraph.components` 思路一致,但在项目 IR 中更容易生成解释文本。 - -### 3. 每条规则都输出“局部事实 + 结论” - -例如白珠规则可以解释成: - -```text -白珠 (R3, C4) 必须直行通过。左侧已有线,因此右侧也必须为线,上下两侧不能走。 -``` - -黑珠规则可以解释成: - -```text -黑珠 (R5, C2) 已经向东连线。黑珠离开后必须继续直行一格,因此东侧第二段也必须为线。 -``` - -提前闭环规则可以解释成: - -```text -若画上 (R2, C2)-(R2, C3),会闭合同一个路径分量;但仍有其他线段或未满足珠子在环外,所以该线必须为空。 -``` - -这种解释比“匹配了某某模式”更适合分步可解释求解器。 - -### 4. 谨慎迁移坐标模式库 - -`MasyuAssist()` 中一些图形注释规则很强,但它们是以旋转坐标和已有线形写死的模式。迁移时建议分三类: - -- 基础珠子规则:优先迁移,解释清楚,风险低。 -- 珠子组合规则:例如黑珠面对连续白珠、黑珠斜向被白珠夹住,适合迁移为命名模式。 -- 复杂路径框架规则:应先写测试,最好用小型 lookahead 或反证表达,不要只照搬坐标。 - -### 5. 保留内外侧推理作为增强层 - -`SingleLoopInCell` 的 cross `in/out` 推理非常有价值,但对解释系统来说需要谨慎包装。它本质上是平面拓扑二染色: - -- 非线边两侧同色。 -- 线边两侧异色。 -- 同色推出非线。 -- 异色推出线。 - -若未来要实现,应把它作为“环内外侧标记”或“拓扑染色”规则,并在 UI 中可视化内外侧,否则用户可能难以理解为什么一个远处角点颜色能推出某条线。 - -## 最适合优先内化的策略清单 - -优先级从高到低: - -1. 格心单环基础:经过点度数为 2、不能死端、不能分叉。 -2. Masyu 基础珠子:白珠直行并至少一侧邻格转弯;黑珠转弯并两侧延伸一格。 -3. 提前闭环禁止:候选线连接同一分量且仍有其他内容未纳入时打叉。 -4. 白珠轴选择:某一轴被堵或无法满足邻格转弯时,强制另一轴。 -5. 黑珠出口可用性:某方向无法延伸一格时,禁止该方向。 -6. 黑珠面对连续白珠、黑珠被斜向白珠夹住等命名模式。 -7. 内外侧拓扑染色。 -8. 复杂路径框架 + 珠子组合的反证规则。 - -总体来看,作者的 Masyu 求解不是单纯堆局部珠子口诀,而是“通用单环拓扑引擎 + Masyu 珠子几何模式 + 少量连通性反证”的组合。这一点很适合迁移到分步可解释求解器:先让通用单环层稳定地产生基础结论,再逐步加入可命名、可测试、可解释的珠子策略。 diff --git a/docs/MASYU_CHANGELOG.md b/docs/MASYU_CHANGELOG.md deleted file mode 100644 index 2aff625..0000000 --- a/docs/MASYU_CHANGELOG.md +++ /dev/null @@ -1,214 +0,0 @@ -# Masyu Implementation Changelog - -## 2026-05-17 Deterministic Rule Increment - -This update adds the first replay-safe Masyu solving rules. The goal is still -incremental: keep each rule local, deterministic, explainable, and backed by -small fixtures before moving toward graph or coloring techniques. - -## Implemented - -- Added Masyu rule helpers in `src/domain/rules/masyu/rules/shared.ts`: - - Cardinal directions, opposite/turn checks, and direction offsets. - - Directional center-line lookup from a cell. - - Two-step line lookup for pearl extension logic. - - Line-decision collection helpers that avoid overwriting decided marks. -- Added pearl-local rules in `src/domain/rules/masyu/rules/pearls.ts`: - - `White Circle Rule`: white pearls go straight through the pearl, reject - blocked axes, blank perpendicular turn exits, and now enforce the adjacent - turn requirement when one side already runs straight for two segments. - - `Black Circle Rule`: black pearls turn, extend any known exit straight one - more cell, reject impossible exits, and blank the opposite side of a known - exit on the same axis. -- Added generic center-line completion in - `src/domain/rules/masyu/rules/completion.ts`: - - `Pearl Completion`: pearl cells get their own completion pass, separate - from ordinary cells. White pearls only complete straight-through exits; - black pearls only complete turn exits and try to extend confirmed exits one - more cell. - - Non-pearl cells with degree 2 blank every other candidate. - - Non-pearl cells with one known line and one remaining candidate force that - candidate as a line. - - Non-pearl dead-end candidates are blanked. -- Added graph and candidate look-ahead rules: - - `Prevent Premature Loop`: center-line loop components reject unknown lines - that would close a smaller loop while other confirmed lines remain outside. - - `Black Pearl Candidate Pruning`: enumerates the four black-pearl turns, - applies a shallow non-recursive feasibility check, and keeps only compatible - candidates before forcing shared exits, shared extensions, and excluded - adjacent exits. This rule is intentionally single-target per step, and its - white-pearl look-ahead validates an actual adjacent-turn overlay before - rejecting a candidate. - - `White Circle Rule` line forcing now checks both endpoints before adding a - line, so it will not push a neighboring cell above degree 2. -- Added local pattern rules in `src/domain/rules/masyu/rules/patterns.ts`: - - `Black Facing Consecutive Whites`: a black pearl facing two consecutive - white pearls two and three cells away is forced to leave the opposite way. - - `Black Diagonal White Pinch`: two diagonal white pearls on one side of a - black pearl force the black pearl away from that side. - - `Consecutive White Pearls Straight`: a run of three or more adjacent white - pearls is forced to pass perpendicular to the run. - - `Double Black Squeeze`: two black pearls with one middle cell between them - force the opposite perpendicular exit blank when the other perpendicular - exit is already blank. -- Registered the current Masyu rule order: - 1. `White Circle Rule` - 2. `Black Circle Rule` - 3. `Black Facing Consecutive Whites` - 4. `Black Diagonal White Pinch` - 5. `Consecutive White Pearls Straight` - 6. `Double Black Squeeze` - 7. `Prevent Premature Loop` - 8. `Black Pearl Candidate Pruning` - 9. `Pearl Completion` - 10. `Cell Completion` - -## Validation - -Focused tests live in `src/domain/rules/masyu/rules.test.ts` and cover: - -- White pearl straight-through, blocked-axis, and adjacent-turn deductions. -- Black pearl turn, extension, and impossible-exit deductions. -- Black-pearl local patterns. -- Consecutive white-pearl run patterns. -- Pearl-specific completion and double-black squeeze completion. -- Premature-loop prevention and black-pearl candidate pruning. -- Regression coverage for - `https://puzz.link/p?mashu/10/6/0000b6103260i0902216`, ensuring candidate - pruning does not batch multiple black pearls into one unsafe step. -- Regression coverage for a larger `mashu/49/39` puzzle where white-pearl - straight forcing previously created a degree-3 neighbor. -- Registration order and line-diff application on the sample Masyu puzzle. - -Commands run successfully: - -```bash -pnpm test:run src/domain/rules/masyu/rules.test.ts -pnpm lint -``` - -Focused result at implementation time: - -- 1 test file passed. -- 56 Masyu rule tests passed. - -## Notes For Future Agents - -- Keep using `PuzzleIR.lines` as the canonical Masyu loop state. -- New Masyu rules should continue to return explicit `LineDiff`s only unless a - future feature deliberately introduces replay support for another state field. -- Avoid contradiction masking: if a target line is already decided as the - opposite mark, skip the inference and leave invalidity reporting to a later - completion/analysis layer. -- The next useful rule families are graph-level single-loop constraints - (`premature loop prevention`, `candidate bridge`) and more white-pearl axis - elimination, before any Masyu coloring work. - -## 2026-05-16 Initial Import And Display Increment - -This update adds the first real Masyu support path to PuzzleKit Web. The goal of -this increment is intentionally narrow: import a Masyu `puzz.link` URL, preserve -the existing Slitherlink architecture, and render the imported board in the main -solver workspace. - -## Implemented - -- Added first-class Masyu IR fields: - - `PuzzleIR.lines`: canonical center-to-center loop decisions for Masyu. - - `PuzzleIR.tiles`: future vertex-centered coloring units. - - Pearl clues as `Clue { kind: "pearl"; color: "white" | "black" }`. -- Added Masyu key helpers: - - `lineKey`, `parseLineKey`, `getCellLineKeys`. - - `tileKey`, `parseTileKey`. -- Added `createMasyuPuzzle(rows, cols)`: - - Creates one unknown line for each orthogonally adjacent cell-center pair. - - Creates tiles at original grid vertex coordinates, `0..rows` and `0..cols`. - - Leaves Slitherlink-style `edges` and `sectors` empty. -- Added `decodeMasyuFromPuzzlink`: - - Accepts `masyu`, `mashu`, and `pearl`. - - Supports optional `v:` and `b` header segments. - - Decodes `number3` trits according to `docs/MASYU_ENCODE_METHOD.md`. - - Verified sample: - `https://puzz.link/p?mashu/5/5/001390360`. -- Updated `masyuPlugin`: - - Display name is now `Masyu`. - - Parser is wired to the new puzz.link decoder. - - Export intentionally throws: Masyu puzz.link export is not implemented yet. - - Rule/help text is present. - - Legend is a placeholder. - - Stats show board size and pearl distribution. -- Extended replay and stats plumbing: - - Added `LineDiff`. - - Rule engine can apply and revert line diffs. - - Rule steps may carry `affectedLines`. - - Trace stats treat Masyu line decisions as board progress. - - Slitherlink edge behavior remains unchanged. -- Added Masyu rendering in the solver board: - - Thin dashed inner grid. - - Thick solid outer border. - - Existing `R` / `C` coordinate labels. - - Centered white and black pearls. - - Center-to-center lines and crosses from `PuzzleIR.lines`. - -## Not Implemented Yet - -- Masyu solving rules. -- Masyu editor. -- Masyu dataset flow. -- Masyu-specific Live Stats labels. -- Masyu export back to puzz.link. -- Rule examples and rich legend diagrams. - -## Validation - -Use a modern local Node runtime. Debugging with local Node `v24.13.1` should be -fine. In this Codex environment, the bundled Node runtime was required because -the default shell Node was too old for the current `pnpm`. - -Commands run successfully: - -```bash -pnpm lint -pnpm build -pnpm test:run -``` - -Full test result at implementation time: - -- 16 test files passed. -- 278 tests passed. - -Focused tests added: - -- `src/domain/ir/masyu.test.ts` -- `src/domain/parsers/puzzlink/masyuPuzzlink.test.ts` -- Line diff coverage in `src/domain/rules/engine.test.ts` -- Masyu line progress coverage in `src/domain/difficulty/traceStats.test.ts` - -## Architecture Notes For Future Agents - -- `lines` is the canonical Masyu decision state. Do not reuse Slitherlink - `edges` for Masyu loop segments. -- `edges` remains Slitherlink-style vertex-to-vertex grid-edge state. -- `tiles` is reserved for future Masyu coloring over vertex-centered middle - cells. It is not currently rendered or inferred. -- Masyu line keys use cell coordinates, not vertex coordinates: - `lineKey([row, col], [neighborRow, neighborCol])`. -- `rows × cols` in the UI means the user-operated cell board size. -- Existing Slitherlink rules should not be generalized unless a Masyu feature - needs shared infrastructure. - -## Next Work Center - -The next development center should be stronger deterministic Masyu solving -rules. Use `docs/MASYU_RULE_ABSTRACTIONS.md` as the implementation-oriented -taxonomy and `docs/MASYU_ASSIST_STRATEGIES_CN.md` as provenance for the original -strategy source. - -Recommended next steps: - -- Add premature-loop prevention over Masyu `lines`. -- Add candidate-graph bridge inference over non-blank center-line candidates. -- Continue expanding local white-pearl axis elimination and optional pattern - rules with focused fixtures. -- Keep each rule small, named, deterministic, and backed by focused tests. diff --git a/docs/MASYU_RULE_ABSTRACTIONS.md b/docs/MASYU_RULE_ABSTRACTIONS.md deleted file mode 100644 index 114f07b..0000000 --- a/docs/MASYU_RULE_ABSTRACTIONS.md +++ /dev/null @@ -1,743 +0,0 @@ -# Masyu Rule Abstractions - -This document is an implementation-oriented rule taxonomy for adding deterministic Masyu solving to PuzzleKit. It abstracts the current Puzzlink Assistance strategy notes into rules that do not depend on that userscript's object model. - -The target reader is a developer or AI agent implementing `src/domain/rules/masyu/*` rules that return replay-safe `RuleDiff`s. Use `docs/MASYU_ASSIST_STRATEGIES_CN.md` only as provenance for the original strategy source; use this document as the implementation spec. - -## Current PuzzleKit Model - -Masyu currently uses a center-line model: - -- `PuzzleIR.cells`: stores pearl clues as `{ kind: "pearl", color: "white" | "black" }`. -- `PuzzleIR.lines`: stores center-to-center loop decisions using `LineState.mark`. -- `LineMark`: `unknown`, `line`, or `blank`. -- `PuzzleIR.tiles`: reserved for future region/corner coloring units. -- `PuzzleIR.edges`: remains available for Slitherlink-style vertex-to-vertex edges, but should not be the canonical Masyu loop state. - -Implementation should prefer small helpers that hide geometry details: - -- `getMasyuNeighborCells(puzzle, cellKey)`: orthogonal in-bounds neighbor cell keys. -- `getMasyuIncidentLineKeys(puzzle, cellKey)`: up to four center-line keys incident to a cell. -- `getMasyuDirectionalLine(puzzle, cellKey, direction, distance)`: the line at a directional offset, for pearl-local rules. -- `getMasyuCellDegree(puzzle, cellKey)`: count incident lines marked `line`. -- `getMasyuUnknownExits(puzzle, cellKey)`: incident lines still marked `unknown`. -- `buildMasyuLineComponents(puzzle)`: connected components of cells joined by `line` marks. -- `buildMasyuCandidateGraph(puzzle)`: graph of cells connected by lines that are not `blank`. - -Rules should produce explicit diffs: - -- Use `LineDiff` for loop decisions. -- Use `CellDiff` only if a future rule stores visible cell color/fill state. -- If Masyu coloring is added as a first-class hidden or visible state, prefer a dedicated IR field only after deciding how replay and rendering should expose it. Until then, treat coloring rules in this document as design guidance. - -## Rule Design Principles - -Each rule should be deterministic, local where possible, and replay-safe. - -- Do not mutate `PuzzleIR` while inspecting it. -- Collect all compatible updates in local maps, then return diffs. -- If a rule can infer both `line` and `blank`, reject or skip contradictory updates instead of masking them. -- Report a concise message with the first clear example and a total count. -- Keep each rule narrow enough that a failing test points to one reasoning idea. - -Recommended rule card fields: - -- Intent: what the rule proves. -- Input state: what facts it reads. -- Algorithm: implementation steps. -- Output diffs: what it can write. -- Explanation message: user-facing wording pattern. -- Tests: focused fixtures that prove the rule and replay. - -## Rule Family 1: Generic Single Loop In Cell - -These rules are puzzle-generic for loops that pass through cell centers. They should be implemented before pearl-specific rules because black and white pearl deductions depend on them. - -### Rule: Pearl Pass-Through Degree - -Intent: every pearl cell must have loop degree 2. - -Input state: - -- Pearl cells from `PuzzleIR.cells`. -- Incident center lines from `PuzzleIR.lines`. - -Algorithm: - -1. For each pearl cell, count incident `line`, `blank`, and `unknown` lines. -2. If two incident lines are already `line`, mark all remaining unknown incident lines `blank`. -3. If exactly two incident lines are not `blank`, mark both as `line`. -4. If fewer than two incident lines are available, return no inference; completion analysis should report invalidity later. - -Output diffs: `LineDiff` from `unknown` to `line` or `blank`. - -Explanation message: `Pearl (Rr, Cc) must have degree 2, so the only two available exits are lines.` - -Tests: - -- Pearl with two unknown exits and two blanks. -- Pearl with two lines and two unknown exits. -- Border/corner pearl with only two possible exits. - -### Rule: Center-Line Degree - -Intent: the loop has degree 0 or 2 at every cell center, and degree 2 at pearl cells. - -Input state: - -- All cells, not only pearls. -- Incident center lines. - -Algorithm: - -1. For each cell, count incident `line` and `unknown` lines. -2. If `lineCount === 2`, mark remaining unknown incident lines `blank`. -3. If `lineCount === 1 && unknownCount === 1`, mark the only unknown line `line`. -4. If `lineCount === 0 && unknownCount === 1` for a non-pearl cell, mark the only unknown line `blank`; using it would create a dead end. -5. For pearl cells, delegate forced two-exit logic to `Pearl Pass-Through Degree` or share a helper. - -Output diffs: `LineDiff`. - -Explanation message: `Cell (Rr, Cc) already has two loop lines, so every other exit is blank.` - -Tests: - -- A path entering a cell with only one unknown continuation. -- A completed degree-2 cell. -- A non-pearl dead-end candidate. - -### Rule: Premature Loop Prevention - -Intent: do not close a smaller loop before all required pearls are in the final loop. - -Input state: - -- Current `line` graph on cell centers. -- Unknown center lines. -- Pearl cells that still need to be included. - -Algorithm: - -1. Build a union-find over cell centers connected by existing `line` marks. -2. For each unknown line between cells `a` and `b`, check whether `a` and `b` are already in the same component. -3. If they are in the same component, adding this line closes a cycle. -4. Mark the line `blank` unless this closure would be the final valid loop containing all required pearl cells and no unresolved line component remains. A first implementation can conservatively blank same-component closures whenever any pearl has degree less than 2 or there is more than one active line component. - -Output diffs: `LineDiff` to `blank`. - -Explanation message: `Line (Rr, Cc)-(Rr2, Cc2) would close the current path before all pearls are connected, so it is blank.` - -Tests: - -- A nearly closed small loop with an outside pearl. -- A same-component candidate when multiple active components exist. -- A fully solved final loop should not be processed by this rule as a new inference. - -Implementation analogy: this is the center-line counterpart of Slitherlink's `createPreventPrematureLoopRule()`, but vertices become cell centers and `edges` become `lines`. - -### Rule: Candidate-Graph Bridge Line - -Intent: if all required loop material can stay connected only through a candidate line, that line must be used. - -Input state: - -- Candidate graph where every non-blank line is an edge. -- Source nodes: pearl cells and cells already incident to a line. -- Current known line components. - -Algorithm: - -1. Build the candidate graph from all cell centers and lines whose mark is not `blank`. -2. Treat existing line components and pearl cells as required sources. -3. Run Tarjan low-link analysis to find bridges or articulation structures that separate required sources. -4. If a candidate line is the only connection between two required-source sides, mark it `line`. -5. Keep the first version conservative: only infer when a single unknown line is the bridge between two source-containing components. - -Output diffs: `LineDiff` to `line`. - -Explanation message: `Line (Rr, Cc)-(Rr2, Cc2) is the only candidate connection between required loop regions, so it is a line.` - -Tests: - -- Two pearl groups connected by a one-cell-wide corridor. -- Existing line endpoint that can reach the rest of the puzzle through only one unknown line. -- No inference when two independent candidate corridors exist. - -Implementation analogy: this rule uses the same low-link idea as Slitherlink color connectivity cut coloring, but the graph is the Masyu candidate line graph rather than a colored-cell region graph. - -## Rule Family 2: Pearl-Local Rules - -These rules encode the Masyu clue semantics directly. They should work from directional axes rather than from copied pattern diagrams. - -Use directions `N`, `E`, `S`, `W`. An axis is an opposite pair, such as `E-W` or `N-S`. - -### Rule: Black Pearl Turn - -Intent: a black pearl must turn at the pearl cell. - -Input state: - -- Black pearl cell. -- Incident lines and blanks. -- Two axis groups: north-south and east-west. - -Algorithm: - -1. For each axis group, count incident `line` and non-blank candidate exits. -2. A valid black pearl uses exactly one exit from each axis group. -3. If one direction in an axis group is already `line`, mark the opposite direction in that axis group `blank`. -4. If an axis group has exactly one non-blank candidate and no line yet, mark that candidate `line`. -5. If an axis group has zero candidates, return no inference; completion analysis should report the contradiction. - -Output diffs: `LineDiff` to `blank` or `line`. - -Explanation message: `Black pearl (Rr, Cc) must use one vertical and one horizontal exit, so the only remaining vertical candidate is a line.` - -Tests: - -- Black pearl with an east line forces west blank. -- Black pearl with only one vertical candidate forces that vertical candidate to line. -- Black pearl with no vertical candidates produces no diff and is left for completion analysis. - -### Rule: Black Pearl Straight Extension - -Intent: after leaving a black pearl, the loop must continue straight for at least one more cell. - -Input state: - -- Black pearl cell. -- A known incident line in direction `d`. -- The next line in direction `d` from the neighboring cell. - -Algorithm: - -1. For each black pearl and direction `d`, if the incident line in `d` is `line`, find the next forward line beyond the adjacent cell. -2. If that next line is unknown, mark it `line`. -3. If that next line is already `blank`, do not infer here; invalidity belongs to completion analysis. - -Output diffs: `LineDiff` to `line`. - -Explanation message: `Black pearl (Rr, Cc) exits east, so the line must continue straight through the next cell.` - -Tests: - -- Each direction extension. -- Extension at board boundary should produce no diff. -- Existing extension line should be ignored. - -### Rule: Black Pearl Impossible Exit - -Intent: remove any black-pearl exit direction that cannot satisfy the turn-and-extension constraint. - -Input state: - -- Black pearl cell. -- Candidate incident direction `d`. -- Neighbor cell and forward extension line in direction `d`. -- Side lines through the neighbor cell. -- Nearby pearl clues. - -Algorithm: - -For each candidate direction `d`, mark the incident line `blank` if any of these are true: - -- The incident line is already unavailable. -- The forward extension line beyond the neighbor is unavailable. -- The neighbor already has a perpendicular line that would prevent straight extension. -- The neighbor is another black pearl, making the required straight continuation incompatible with that pearl's turn. -- Taking direction `d` would force the black pearl to go straight through the pearl cell. - -After blanking an impossible exit, ordinary degree rules can force the remaining exits. - -Output diffs: `LineDiff` to `blank`. - -Explanation message: `Black pearl (Rr, Cc) cannot exit east because the required straight extension is blocked.` - -Tests: - -- Blocked forward extension. -- Neighboring black pearl. -- Perpendicular line at the required extension cell. - -### Rule: White Pearl Straight Through - -Intent: a white pearl must go straight through the pearl cell. - -Input state: - -- White pearl cell. -- Incident lines and blanks. - -Algorithm: - -1. If an incident line on one side of an axis is `line`, mark the opposite side of that axis `line`. -2. Mark both perpendicular incident lines `blank`. -3. If both sides of one axis are unavailable, mark both sides of the other axis `line`. -4. If exactly one axis remains possible, force it. - -Output diffs: `LineDiff` to `line` and `blank`. - -Explanation message: `White pearl (Rr, Cc) must go straight, so the opposite exit is also a line and perpendicular exits are blank.` - -Tests: - -- Known east line forces west line and north/south blank. -- North/south blocked forces east-west line. -- One remaining possible axis. - -### Rule: White Pearl Adjacent Turn Requirement - -Intent: a white pearl must turn in at least one adjacent cell immediately before or after the pearl. - -Input state: - -- White pearl cell. -- A chosen or implied straight axis. -- Lines one and two steps away along each side of that axis. - -Algorithm: - -1. If one side of the white pearl already continues straight through the adjacent cell, then the opposite side must provide the required adjacent turn. -2. Concretely, if the line entering the pearl from west and the line west of the neighboring west cell are both `line`, mark the far east continuation line `blank`. -3. Apply symmetrically for all axes and directions. - -Output diffs: `LineDiff` to `blank`. - -Explanation message: `White pearl (Rr, Cc) already continues straight on one side, so the other adjacent cell must turn.` - -Tests: - -- Two consecutive line segments on one side of a white pearl. -- Symmetry across all four directions. - -### Rule: White Pearl Axis Elimination - -Intent: eliminate a white-pearl straight axis when either side cannot provide a valid adjacent turn condition. - -Input state: - -- White pearl cell. -- Candidate axis. -- Neighbor cells on both sides of the axis. -- Local line/blank states around those neighbors. -- Nearby pearl clues that constrain those neighbors. - -Algorithm: - -1. For each side of a candidate axis, determine whether that side can still satisfy the white pearl's adjacent-turn requirement. -2. A side cannot satisfy the requirement if the adjacent cell is forced to continue straight, is blocked from turning, or is itself a pearl whose constraints conflict with the required turn. -3. If both sides of an axis cannot satisfy the requirement, mark the two incident lines of that axis `blank` and force the perpendicular axis through `White Pearl Straight Through`. -4. Keep individual blockers as separate helper predicates so explanations stay short. - -Output diffs: `LineDiff` to `blank` and possibly `line`. - -Explanation message: `White pearl (Rr, Cc) cannot use the east-west axis because neither adjacent side can turn, so it must use the north-south axis.` - -Tests: - -- Adjacent white pearl blocks an axis. -- Existing far straight line blocks the adjacent-turn requirement. -- Both adjacent turn positions blocked. - -## Rule Family 3: Local Pattern Rules - -These are deterministic pattern rules derived from common Masyu shapes. Implement them after the core pearl-local rules, and keep each one optional and independently tested. - -### Rule: Double-Black Squeeze - -Intent: two black pearls on opposite sides of a cell can eliminate a perpendicular singleton exit. - -Input state: - -- A middle cell. -- Two black pearls on opposite sides along an axis. -- One perpendicular incident line from the middle cell already blank. - -Algorithm: - -1. For each cell and axis, check whether the two opposite neighbor cells are black pearls. -2. If one perpendicular line from the middle cell is `blank`, mark the other perpendicular line `blank`. -3. Rely on degree and black-pearl extension rules to handle any forced axis lines afterward. - -Output diffs: `LineDiff` to `blank`. - -Explanation message: `The cell between two black pearls cannot use a single perpendicular exit, so the opposite perpendicular exit is blank.` - -Tests: - -- Horizontal black pair with north blocked implies south blank. -- Vertical black pair with east blocked implies west blank. - -### Rule: Black Facing Consecutive Whites - -Intent: a black pearl may be forced away from a direction that leads into two consecutive white pearls. - -Input state: - -- Black pearl. -- Two white pearls at offsets two and three cells in direction `d`. - -Algorithm: - -1. For each black pearl and direction `d`, inspect the two cells at distance 2 and 3. -2. If both are white pearls, mark the opposite incident line from the black pearl `line`. -3. Let black pearl turn and extension rules clean up the remaining exits. - -Output diffs: `LineDiff` to `line`. - -Explanation message: `Black pearl (Rr, Cc) cannot satisfy its exit toward two consecutive white pearls, so it must extend the opposite way.` - -Tests: - -- Horizontal and vertical examples. -- No inference when only one white pearl exists. - -### Rule: Black Diagonal-White Pinch - -Intent: two diagonal white pearls on the same side of a black pearl force the black pearl away from that side. - -Input state: - -- Black pearl. -- Two white pearls at the diagonal cells on one side. - -Algorithm: - -1. For each black pearl and side `s`, inspect the two diagonal cells in front-left and front-right of that side. -2. If both are white pearls, mark the incident line opposite side `s` as `line`. -3. Use normal black-pearl rules to infer the turn partner and extension. - -Output diffs: `LineDiff` to `line`. - -Explanation message: `Two diagonal white pearls pinch black pearl (Rr, Cc), forcing it to leave away from them.` - -Tests: - -- All four orientations. -- No inference when one diagonal is empty or black. - -### Rule: White Neighbor Axis Exclusion - -Intent: neighboring white pearls and blocked turn cells can eliminate a white pearl axis. - -Input state: - -- White pearl. -- Candidate straight axis. -- Adjacent cells along that axis. -- Pearl clues and line states around those adjacent cells. - -Algorithm: - -1. For each candidate axis, evaluate both adjacent side cells. -2. Mark a side as axis-hostile when it is a white pearl in a position that would require incompatible straight-through behavior, or when both turn exits around that side are blocked. -3. If both sides are axis-hostile, blank the candidate axis and force the perpendicular axis. - -Output diffs: `LineDiff`. - -Explanation message: `White pearl (Rr, Cc) cannot use the east-west axis because both neighboring turn positions are unavailable.` - -Tests: - -- Consecutive white pearls on both sides. -- Turn exits blocked by blanks. -- Mixed blockers on the two sides. - -### Rule: L-Path Pearl Continuation - -Intent: an existing L-shaped partial path plus nearby pearl constraints can force continuation lines. - -Input state: - -- A local L shape made of four known center lines around a corner. -- Nearby white or black pearls at fixed offsets. -- More than one active line component, or an otherwise unresolved loop. - -Algorithm: - -1. Detect a local L-shaped path segment using center-line geometry. -2. Check for specific pearl configurations around the open ends: - - two white pearls near the diagonal continuation, - - a black pearl near one end and a white pearl near the other, - - a black pearl that must extend away from the L shape. -3. Add the continuation lines that are forced by pearl semantics and by avoiding a premature local loop. -4. Implement each pearl configuration as a named subrule rather than one large pattern. - -Output diffs: `LineDiff` to `line`. - -Explanation message: `The L-shaped path around (Rr, Cc) and nearby pearls force this continuation to keep the loop connected.` - -Tests: - -- One fixture per subrule. -- Regression test ensuring the rule does not fire after the loop is already complete. - -## Rule Family 4: Masyu Coloring - -Masyu can use a coloring strategy analogous to Slitherlink, but the colored objects differ. - -In Slitherlink, the loop runs on grid edges, so coloring cells as inside/outside works directly: same-colored adjacent cells imply a blank edge; opposite-colored adjacent cells imply a line edge. - -In Masyu, the loop runs between cell centers. The natural colored objects are the small regions around grid corners, not the puzzle cells themselves. This project already reserves `PuzzleIR.tiles` for future vertex-centered coloring units. A Masyu coloring implementation should decide whether `tiles` represent these corner regions. - -### Coloring State - -Use two colors: - -- `inside` -- `outside` - -For implementation parity with Slitherlink, these could be stored as tile fills such as `green` and `yellow`, but the semantic names in rule code should remain `inside` and `outside`. - -Adjacency relation: - -- Two neighboring corner regions separated by no loop crossing have the same color. -- Two neighboring corner regions separated by a Masyu center line have opposite colors. - -Line relation: - -- If adjacent regions are same color, the separating center-line candidate is `blank`. -- If adjacent regions are opposite colors, the separating center-line candidate is `line`. -- If a center line is `line`, adjacent regions become opposite colors. -- If a center line is `blank`, adjacent regions become same color. - -This is the Masyu counterpart of `createColorEdgePropagationRule()`. - -### Rule: Masyu Outside Seeding - -Intent: seed the unbounded exterior region as outside. - -Input state: - -- Tile/corner-region graph. -- Border-adjacent regions. - -Algorithm: - -1. Identify corner regions connected to the board exterior without crossing a known line. -2. Mark them `outside`. -3. Propagate through known blank separations if those are represented. - -Output diffs: future tile/color diffs, or no implementation until the IR supports them. - -Explanation message: `The exterior region is outside, so connected border regions are outside.` - -Implementation analogy: Slitherlink `createColorOutsideSeedingRule()`. - -### Rule: Masyu Line-Color Propagation - -Intent: known line and blank marks propagate region color parity. - -Input state: - -- Known line marks. -- Known region colors. -- Region adjacency separated by each center-line candidate. - -Algorithm: - -1. For each known line, require opposite colors on its adjacent regions. -2. For each known blank, require same colors on its adjacent regions. -3. Use a parity union-find if many implications are processed together. - -Output diffs: future tile/color diffs. - -Explanation message: `This line is part of the loop, so the regions on its sides have opposite colors.` - -Implementation analogy: the second half of `createColorEdgePropagationRule()`. - -### Rule: Masyu Color-Line Propagation - -Intent: known region colors decide line marks. - -Input state: - -- Adjacent region colors. -- Unknown center-line candidate separating them. - -Algorithm: - -1. If two adjacent regions have the same color, mark the separating line `blank`. -2. If two adjacent regions have opposite colors, mark the separating line `line`. - -Output diffs: `LineDiff`. - -Explanation message: `The regions on both sides have opposite colors, so the separating Masyu line is part of the loop.` - -Implementation analogy: the first half of `createColorEdgePropagationRule()`. - -### Rule: Pearl-Local Color Implications - -Intent: pearl semantics can imply region color parity even before line marks are known. - -Input state: - -- Pearl type. -- Local region colors around the pearl. -- Candidate pearl axes. - -Algorithm: - -1. White pearl straight-through behavior implies consistent parity between diagonal corner regions around its straight axis. -2. Black pearl turn behavior implies parity relations across the turn quadrant and straight-extension cells. -3. Encode each implication as a small local color rule only when the geometry is unambiguous. - -Output diffs: future tile/color diffs, or `LineDiff` if colors decide lines immediately. - -Explanation message: `White pearl (Rr, Cc) must go straight, so these two corner regions have opposite parity.` - -Note: these are the algorithmic version of the original assist script's pearl-specific `in/out` propagation. Implement them after basic coloring works. - -### Rule: Masyu Connectivity Cut Coloring - -Intent: Tarjan cut analysis can force unknown regions to become inside or outside when they are required to preserve color-region connectivity. - -Input state: - -- Region graph where known lines are barriers and known blanks are passable connections. -- Known inside or outside source regions. -- Optional exterior source for outside. - -Algorithm: - -1. Compress already-connected same-color regions with union-find. -2. Build a candidate connectivity graph between color components through non-barrier adjacencies. -3. For a target color, treat known target-colored components as sources. -4. Run Tarjan DFS with `discovery` and `low` values. -5. If a component is an articulation point separating target-color sources, color it with the target color. -6. If a component is unreachable from any target source, color it with the opposite color. -7. Run once for inside and once for outside. - -Output diffs: future tile/color diffs. - -Explanation message: `Region connectivity forces this component to be inside because it is a cut between inside sources.` - -Implementation analogy: this is the direct Masyu-region version of Slitherlink's `createColorConnectivityCutColoringRule()` and `findConnectivityColorUpdates()`. - -## Suggested Rule Order - -Start with rules that use only current IR fields: - -1. `masyu-pearl-pass-through-degree` -2. `masyu-center-line-degree` -3. `masyu-white-straight-through` -4. `masyu-black-turn` -5. `masyu-black-straight-extension` -6. `masyu-black-impossible-exit` -7. `masyu-white-adjacent-turn` -8. `masyu-white-axis-elimination` -9. `masyu-premature-loop-prevention` -10. `masyu-candidate-graph-bridge-line` -11. Selected local pattern rules - -Add coloring after the IR has an agreed representation for Masyu regions: - -1. `masyu-outside-seeding` -2. `masyu-line-color-propagation` -3. `masyu-color-line-propagation` -4. `masyu-pearl-local-color-implications` -5. `masyu-connectivity-cut-coloring` - -Keep branch-based or contradiction-based inference out of the first implementation pass. Masyu should first reach parity with deterministic local and graph rules. - -## Implementation Milestones - -Milestone 1: geometry helpers. - -- Add Masyu line-direction helpers. -- Add degree and candidate-exit helpers. -- Add line component builder. -- Add formatter helpers for cells and center lines. - -Milestone 2: generic loop rules. - -- Implement degree rules and premature loop prevention. -- Add focused unit tests for each. -- Register rules through `masyuPlugin.getRules()`. - -Milestone 3: pearl-local rules. - -- Implement black and white pearl rules as separate files or separate factory functions. -- Use small fixtures for each direction and axis. -- Keep explanation messages specific. - -Milestone 4: graph connectivity. - -- Implement candidate-graph bridge/cut inference. -- Use Tarjan low-link helpers if they can be shared cleanly with Slitherlink; otherwise keep a Masyu-local graph helper. - -Milestone 5: optional coloring. - -- Decide whether `PuzzleIR.tiles` should store Masyu region colors. -- Implement color propagation and cut coloring only after replay and rendering semantics are clear. - -## Test Strategy - -Every rule should have tests that cover: - -- One minimal positive fixture. -- One non-firing fixture with a near miss. -- Replay safety: apply the rule, undo via engine replay, and reapply deterministically where existing test harnesses support this. -- Directional symmetry for local rules. -- Conflict avoidance: a rule must not emit a diff from a non-unknown line to a different mark. - -For graph rules, include: - -- A single forced bridge. -- Two alternative corridors where no bridge is forced. -- A same-component premature loop candidate. -- A final-loop-like position that should not be incorrectly blanked. - -For coloring rules, include: - -- Same color implies blank. -- Opposite color implies line. -- Line implies opposite color. -- Blank implies same color. -- Tarjan cut component between two target-color sources. -- Unreachable component becomes the opposite color. - -## Mapping From Strategy Notes To Rule Names - -Use these names when migrating from the older strategy document: - -- `珠子必须被回路经过`: `masyu-pearl-pass-through-degree` -- `度数为二`: `masyu-center-line-degree` -- `无死端`: `masyu-center-line-degree` -- `双出口强制成线`: `masyu-pearl-pass-through-degree` -- `禁止提前闭环`: `masyu-premature-loop-prevention` -- `单环连通桥成线`: `masyu-candidate-graph-bridge-line` -- `白珠被迫直行`: `masyu-white-straight-through` -- `白珠至少一侧转弯`: `masyu-white-adjacent-turn` -- `白珠轴线排除`: `masyu-white-axis-elimination` -- `黑珠非法出口排除`: `masyu-black-impossible-exit` -- `黑珠出口延伸`: `masyu-black-straight-extension` -- `黑珠避开连续白珠`: `masyu-black-facing-consecutive-whites` -- `黑珠斜白夹逼`: `masyu-black-diagonal-white-pinch` -- `双黑夹格垂直禁线`: `masyu-double-black-squeeze` -- `白珠同路径改轴`: `masyu-white-same-component-axis-elimination` -- `黑珠同路径避闭环`: `masyu-black-same-component-exit-elimination` -- `L形路径连通补线`: `masyu-l-path-pearl-continuation` -- `内外异色成线`: `masyu-color-line-propagation` -- `内外同色禁线`: `masyu-color-line-propagation` -- `线段翻转内外`: `masyu-line-color-propagation` -- `禁线保持内外`: `masyu-line-color-propagation` - -## Notes On Reusing Slitherlink Infrastructure - -The Slitherlink color rules are useful templates, not drop-in Masyu rules. - -Reusable ideas: - -- Parity union-find for color constraints. -- Low-link Tarjan traversal for articulation/cut coloring. -- Rule factories that collect local decisions before returning diffs. -- First-example explanation messages with aggregate counts. - -Do not reuse directly without adapting geometry: - -- Slitherlink `edges` are vertex-to-vertex loop edges; Masyu loop decisions are `lines` between cell centers. -- Slitherlink cell colors represent regions separated by grid edges; Masyu region colors should represent corner/tile regions separated by center lines. -- Slitherlink vertex degree is not the same as Masyu cell-center degree. - -The desired end state is conceptual reuse with Masyu-specific helpers, not shared code that hides different geometry behind misleading names. diff --git a/docs/MASYU_RULE_ROADMAP.md b/docs/MASYU_RULE_ROADMAP.md new file mode 100644 index 0000000..e7403a5 --- /dev/null +++ b/docs/MASYU_RULE_ROADMAP.md @@ -0,0 +1,213 @@ +# Masyu Rule Roadmap + +This is the implementation roadmap for future Masyu rule work. Read +`docs/MASYU_AGENT_BRIEF.md` first for current state and file locations, then use +this document when planning refactors or new deductions. + +## Direction + +Masyu already has a working rule stack: pearl-local deductions, local patterns, +completion, premature-loop prevention, candidate graph reasoning, black-pearl +candidate pruning, bounded strong inference, and vertex-centered tile coloring. + +The next goal is not to add another large rule module. The next goal is to make +the current rule strategy smaller and easier to extend: + +- consolidate repeated helpers; +- separate rule semantics by reasoning idea; +- give white pearls the same candidate-model treatment black pearls already + have; +- make tile color useful through small pearl-local parity rules; +- keep every rule replay-safe and explainable. + +## Core Model + +- `PuzzleIR.cells` stores pearl clues. +- `PuzzleIR.lines` stores Masyu loop decisions between orthogonally adjacent + cell centers. +- `PuzzleIR.tiles` stores vertex-centered inside/outside color information. +- `PuzzleIR.edges` is Slitherlink state and must not be used for Masyu loop + deductions. + +Rule output should stay explicit: + +- line decisions use `LineDiff`; +- tile color decisions use `TileDiff`; +- rules inspect `PuzzleIR` without mutating it; +- if a candidate update conflicts with an existing decision, skip or reject the + inference rather than overwriting state. + +## Current Pain Points + +The Masyu code is capable, but too much reasoning is repeated locally: + +- pearl iteration helpers and clue filters exist in several files; +- "remember this compatible decision" helpers are repeated; +- degree and possibility checks are split between direct rules, completion, + lookahead, and trial inference; +- black-pearl feasibility exists in `lookahead.ts`, while trial inference has a + separate feasibility model; +- union-find and low-link graph logic are implemented independently for loop + closure, bridge detection, tile parity, tile connectivity, and trial + contradiction checks. + +This makes future rule work feel heavier than it should. A new rule author has +to rediscover which helper has the intended meaning of "available", "blocked", +"possible", and "contradiction". + +## Refactor Priorities + +### 1. Shared Decision Collection + +Create a small collector used by line and tile rules: + +- add compatible decisions only; +- optionally guard `line` decisions against degree overflow; +- track the first clear example for the explanation message; +- build replay-safe diffs in stable order; +- expose affected line/tile keys. + +This should replace file-local helpers that only differ in naming. + +### 2. Pearl Selectors + +Centralize pearl lookup: + +- iterate all pearls; +- iterate white pearls; +- iterate black pearls; +- read pearl color safely; +- format common pearl labels. + +This is tiny, but it removes noise from every rule file. + +### 3. Pearl Candidate Model + +Build one shared candidate model for local pearl feasibility. + +Black candidates: + +- choose one vertical exit and one horizontal exit; +- require both exit lines; +- require the one-step straight extension after each exit; +- exclude the two non-selected incident exits. + +White candidates: + +- choose one straight axis; +- require both axis exits; +- exclude perpendicular exits; +- track whether each adjacent side can still provide the required immediate + turn. + +Use this model for: + +- existing black-pearl candidate pruning; +- future white-pearl candidate pruning; +- trial feasibility checks; +- common consequence extraction across all feasible candidates. + +### 4. Line Graph Helpers + +Centralize graph operations over `PuzzleIR.lines`: + +- cell-center degree counts; +- touched cells for a set of line keys; +- connected components of known lines; +- premature-loop checks; +- candidate graph from all non-blank lines; +- required sources from pearls and existing line components; +- bridge and articulation analysis. + +`Masyu Candidate Bridge Line` and `Prevent Premature Loop` should eventually use +the same graph vocabulary. + +### 5. Tile Parity Graph + +Centralize tile inside/outside parity: + +- line means adjacent tiles have opposite colors; +- blank means adjacent tiles have the same color; +- boundary tiles are outside/yellow; +- existing tile fills are anchors; +- conflicting anchors or parity cycles can be reported to trial inference. + +Reuse this helper from color propagation and bounded trial contradiction +detection. + +## Rule Family Cleanup + +After the helpers exist, split or rename current rules so each one maps to one +deduction idea. + +Suggested pearl rule boundaries: + +- White straight axis: a known white exit forces the opposite exit and blanks + perpendicular exits. +- White adjacent turn: if one side already continues straight beyond the + adjacent cell, the opposite side cannot also continue straight. +- White axis feasibility: if one white axis cannot satisfy the adjacent-turn + condition on either side, eliminate that axis. +- Black turn pair: a black pearl must use one vertical and one horizontal exit. +- Black straight extension: a known black exit forces the next line straight + beyond the neighboring cell. +- Pearl completion: only handle degree completion and "only legal pair remains" + cases. + +Pattern rules should stay only when they are clearer or cheaper than candidate +reasoning. Do not grow a large library of copied coordinate patterns unless the +deduction has a short explanation and focused tests. + +## Missing Rule Work + +Highest leverage additions: + +1. White pearl candidate pruning. +2. Black pearl local tile-color implications. +3. White pearl candidate color implications when all feasible axes imply the + same tile relation. +4. Candidate graph articulation reasoning beyond single bridge edges. +5. A Masyu `NoChecker`-style tile parity inference only if it can be stated in + vertex-centered tile terms and returned as explicit diffs. + +Avoid broad monolithic "assistance script" ports. Convert outside research into +small line, pearl-candidate, graph, or tile-parity rules. + +## Test Plan + +Focused rule tests: + +```bash +pnpm test:run src/domain/rules/masyu/rules.test.ts +``` + +Replay safety: + +```bash +pnpm test:run src/domain/rules/engine.test.ts src/features/solver/solverStore.test.ts +``` + +Full confidence: + +```bash +pnpm build +``` + +New tests should be fixture-sized and grouped by behavior: + +- candidate model: black turn candidates, white straight-axis candidates, + blocked extensions, blocked adjacent turns; +- decision collector: duplicate compatible decisions, conflicts, degree guards; +- tile parity graph: boundary anchors, line/blank parity, conflicting anchors, + pearl-local parity; +- line graph helpers: premature closures, bridge lines, articulation cells, + required-source connectivity; +- trial inference: hard contradictions should match deterministic feasibility + semantics. + +## Working Rule + +If this roadmap disagrees with current code, trust current code and update the +roadmap. Keep this file current and compact; use git history for historical +detail. + diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index ce90f61..76c3b75 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -79,8 +79,10 @@ Current families: - Slitherlink: parser, renderer, editor, rules, stats, completion analysis, and export support are implemented. -- Masyu: puzz.link import, IR, renderer, stats, help, and replay plumbing are - implemented; solving rules and export are still planned. +- Masyu: puzz.link import, IR, renderer, stats, help, replay plumbing, + completion analysis, deterministic solving rules, bounded strong inference, + and tile-color topology support are implemented. Editor, dataset flow, and + URL export remain future work. - Nonogram: visible as a planned plugin stub. ## 5. IR And Diff Conventions @@ -94,8 +96,8 @@ Important state buckets: - `edges`: Slitherlink-style vertex-to-vertex grid-edge decisions. - `lines`: Masyu-style cell-center-to-cell-center line decisions. - `sectors`: Slitherlink corner-sector constraints. -- `tiles`: future vertex-centered coloring units, currently introduced for - Masyu. +- `tiles`: vertex-centered coloring units currently used by Masyu + inside/outside reasoning. - `vertices`: vertex candidate state for Slitherlink inference. `RuleDiff` is the replay contract. If a rule mutates a new IR bucket, add a diff @@ -115,8 +117,8 @@ technique notes instead: - `docs/techniques/masyu.md` - `docs/techniques/slitherlink.md` -That file points to the current Slitherlink rule modules, the Masyu changelog, -the Masyu URL encoding reference, and the Masyu strategy research document: +For implementation-oriented Masyu rule work, start with +`docs/MASYU_AGENT_BRIEF.md` and then `docs/MASYU_RULE_ROADMAP.md`. ## 7. Benchmark And Dataset Flow @@ -172,7 +174,10 @@ Implemented: - Masyu IR support through `lines`, `tiles`, and pearl clues. - Masyu solver-board rendering for dashed grids, pearls, center lines, and crosses. -- Replay support for both edge diffs and line diffs. +- Masyu deterministic solving rules, completion analysis, tile-color + propagation, candidate bridge reasoning, black-pearl pruning, and bounded + strong inference. +- Replay support for edge, line, and tile diffs. - Live Stats trace cache for step-prefix summaries, chart progress, and rule usage. - Public/private benchmark manifest workflow. @@ -180,8 +185,7 @@ Implemented: Planned or partial: -- Masyu deterministic solving rules. -- Masyu editor, dataset flow, completion analysis, and URL export. +- Masyu editor, dataset flow, and URL export. - Nonogram parser, renderer, editor, and rules. - Puzzle-specific Live Stats wording beyond the current shared labels. - Penpa adapter/export completeness. @@ -197,13 +201,13 @@ If you are an AI agent onboarding this repository, read in this order: 4. `src/domain/plugins/types.ts` 5. `src/domain/plugins/registry.ts` 6. `src/features/solver/solverStore.ts` -7. `docs/techniques/PUZZLE_TECHNIQUES_EN.md` for puzzle-specific rule work +7. The relevant puzzle note in `docs/techniques/` for user-facing rules/help For targeted work: - Slitherlink rules: start at `src/domain/rules/slither/rules.ts`. -- Masyu import/display: start at `docs/MASYU_CHANGELOG.md`. -- Masyu future rules: start at `docs/MASYU_ASSIST_STRATEGIES_CN.md`. +- Masyu rules: start at `docs/MASYU_AGENT_BRIEF.md`, then read + `docs/MASYU_RULE_ROADMAP.md` for refactor direction. - Editor/UI work: inspect the relevant `src/features/*` component and page test. - Benchmark work: read `src/domain/benchmark/runner.ts` and `scripts/benchmark-solve.ts`. diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index b7e72ec..55ceabb 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -2,8 +2,6 @@ Masyu is a loop puzzle with black and white pearls. The final answer is one continuous loop that passes through every pearl. -This page is a short user-facing technique note. AI agents and developers should start from `docs/MASYU_AGENT_BRIEF.md` instead. - ## Core Rules - The loop travels between cell centers and never branches. @@ -28,10 +26,3 @@ PuzzleKit represents Masyu with a center-line model: - Replay deterministic solving steps with explanations. - Analyze completion for a single valid loop and satisfied pearl constraints. - Apply local pearl rules, selected local patterns, premature-loop prevention, candidate pruning, completion rules, and Masyu tile-color propagation. - -## Developer Pointers - -- Start here for Masyu development: `docs/MASYU_AGENT_BRIEF.md` -- Current rule taxonomy: `docs/MASYU_RULE_ABSTRACTIONS.md` -- Original strategy research: `docs/MASYU_ASSIST_STRATEGIES_CN.md` -- Historical implementation notes: `docs/MASYU_CHANGELOG.md` diff --git a/docs/techniques/slitherlink.md b/docs/techniques/slitherlink.md index e444127..1d9ecb9 100644 --- a/docs/techniques/slitherlink.md +++ b/docs/techniques/slitherlink.md @@ -1,39 +1,34 @@ # Slitherlink -Current implementation location: - -- Rule aggregator: `src/domain/rules/slither/rules.ts` -- Rule modules: `src/domain/rules/slither/rules/` -- Completion analysis: `src/domain/rules/slither/completion.ts` -- Tests: `src/domain/rules/slither/rules.test.ts` - -Current rule organization: - -- `patterns.ts`: clue pattern rules, such as contiguous 3-runs and diagonal - adjacent 3s. -- `core.ts`: generic Slitherlink constraints, including clue edge counts, - vertex degree, and premature loop prevention. -- `color.ts`: cell color seeding and propagation. -- `sectorInference.ts`: corner-sector inference from local edge, vertex, and - cell evidence. -- `sectorPropagation.ts`: sector-to-sector and sector-to-edge propagation. -- `colorAssumptionInference.ts`: conservative color-branch contradiction - inference. -- `sectorParityInference.ts`: conservative sector-parity contradiction - inference. -- `strongInference.ts`: conservative branch-based contradiction inference. -- `shared.ts`: reusable geometry, clue, color, and mask helpers. - -Important Slitherlink model note: - -- Sector state is a bitmask of allowed corner line counts `{0,1,2}`. -- Sector diffs use `fromMask -> toMask`. -- Rule semantics narrow masks by intersection and then propagate strict masks. -- Do not revert sectors to old single-label semantics. - -Branch inference note: - -- Branch-based rules should not self-reference the exported `slitherRules` - array. -- Use dependency injection, for example - `createStrongInferenceRule(() => deterministicSlitherRules)`. +Slitherlink is a loop puzzle played on a grid of dots. The final answer is one +continuous loop drawn along grid edges. + +## Core Rules + +- The loop never branches or crosses itself. +- Every grid vertex touched by the loop has exactly two used edges. +- A numbered cell tells how many of its four surrounding edges are part of the + loop. +- Empty cells have no direct number clue, but they still obey the single-loop + rules. +- The final loop must be one connected loop, not several smaller loops. + +## PuzzleKit Model + +PuzzleKit represents Slitherlink with a grid-edge model: + +- `PuzzleIR.edges` stores line and cross decisions. +- `PuzzleIR.cells` stores numbered clues. +- `PuzzleIR.sectors` stores corner-sector constraints used by advanced + reasoning. +- `PuzzleIR.vertices` stores vertex candidate information. + +## Current Support + +- Import from `puzz.link` Slitherlink URLs and Penpa inputs. +- Create and edit Slitherlink puzzles in the editor. +- Render clues, lines, crosses, colors, sectors, and solver highlights. +- Replay deterministic and conservative branch-based solving steps with + explanations. +- Analyze completion for one valid loop satisfying all numbered clues. + From 3f9ed6759554f2d78fa6d0e86a159f3b4cd9b6a6 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 22 May 2026 16:09:30 +0800 Subject: [PATCH 02/22] refractor: add White Pearl Candidate Pruning rule --- docs/MASYU_AGENT_BRIEF.md | 8 +- src/domain/rules/masyu/rules.test.ts | 340 +++++++++++++++-- src/domain/rules/masyu/rules.ts | 11 +- .../masyu/rules/blackPearlStrongInference.ts | 54 ++- src/domain/rules/masyu/rules/bridges.ts | 172 +++------ src/domain/rules/masyu/rules/candidates.ts | 66 +++- src/domain/rules/masyu/rules/color.ts | 251 +++---------- src/domain/rules/masyu/rules/completion.ts | 132 ++++--- src/domain/rules/masyu/rules/connectivity.ts | 43 ++- .../rules/masyu/rules/decisionCollector.ts | 139 +++++++ src/domain/rules/masyu/rules/lineGraph.ts | 259 +++++++++++++ src/domain/rules/masyu/rules/lookahead.ts | 347 ++++++++++-------- .../rules/masyu/rules/lookaheadGeometry.ts | 95 ++++- src/domain/rules/masyu/rules/loop.ts | 127 +------ src/domain/rules/masyu/rules/patterns.ts | 169 ++++++--- .../rules/masyu/rules/pearlCandidates.ts | 165 +++++++++ .../rules/masyu/rules/pearlSelectors.ts | 55 +++ src/domain/rules/masyu/rules/pearls.ts | 227 +++++++++--- src/domain/rules/masyu/rules/shared.ts | 55 ++- src/domain/rules/masyu/rules/tileParity.ts | 226 ++++++++++++ src/domain/rules/masyu/rules/trial.ts | 288 ++++++--------- 21 files changed, 2175 insertions(+), 1054 deletions(-) create mode 100644 src/domain/rules/masyu/rules/decisionCollector.ts create mode 100644 src/domain/rules/masyu/rules/lineGraph.ts create mode 100644 src/domain/rules/masyu/rules/pearlCandidates.ts create mode 100644 src/domain/rules/masyu/rules/pearlSelectors.ts create mode 100644 src/domain/rules/masyu/rules/tileParity.ts diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index c28625e..75cc2e7 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -43,9 +43,10 @@ Registered rule order: 11. `Masyu Candidate Bridge Line` 12. `Prevent Premature Loop` 13. `Black Pearl Candidate Pruning` -14. `Pearl Completion` -15. `Cell Completion` -16. `Black Pearl Strong Inference` +14. `White Pearl Candidate Pruning` +15. `Pearl Completion` +16. `Cell Completion` +17. `Black Pearl Strong Inference` Implemented rule areas: @@ -53,6 +54,7 @@ Implemented rule areas: - Local pattern rules derived from common Masyu situations. - Premature loop prevention over `PuzzleIR.lines`. - Black pearl candidate pruning with shallow feasibility checks. +- White pearl candidate pruning with the same shared pearl-candidate model. - Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. - Completion rules for pearl and non-pearl cells. - Tile color propagation: diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index e9ccb25..05eca43 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -7,14 +7,20 @@ import type { Rule } from '../types' import { masyuPlugin } from '../../plugins/masyuPlugin' import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' import { createMasyuCandidateBridgeLineRule } from './rules/bridges' -import { createBlackPearlCandidatePruningRule } from './rules/candidates' +import { + createBlackPearlCandidatePruningRule, + createWhitePearlCandidatePruningRule, +} from './rules/candidates' import { createMasyuColorLinePropagationRule, createMasyuColorPearlPropagationRule, createMasyuTileColorPropagationRule, } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' -import { createCellCompletionRule, createPearlCompletionRule } from './rules/completion' +import { + createCellCompletionRule, + createPearlCompletionRule, +} from './rules/completion' import { createPreventPrematureLoopRule } from './rules/loop' import { createBlackDiagonalWhitePinchRule, @@ -24,12 +30,33 @@ import { } from './rules/patterns' import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' import { deterministicMasyuRules } from './rules' +import { + createMasyuLineDecisionCollector, + createMasyuTileDecisionCollector, +} from './rules/decisionCollector' +import { + getMasyuBlackPearlKeys, + getMasyuPearlColor, + getMasyuPearlKeys, + getMasyuWhitePearlKeys, +} from './rules/pearlSelectors' +import { buildMasyuTileParityGraph } from './rules/tileParity' +import { + buildMasyuCandidateGraph, + findMasyuPrematureLoopClosingLines, + getMasyuRequiredSources, +} from './rules/lineGraph' const markLine = (puzzle: PuzzleIR, key: string, mark: LineMark): void => { puzzle.lines[key] = { ...puzzle.lines[key], mark } } -const addPearl = (puzzle: PuzzleIR, row: number, col: number, color: 'white' | 'black'): void => { +const addPearl = ( + puzzle: PuzzleIR, + row: number, + col: number, + color: 'white' | 'black', +): void => { puzzle.cells[cellKey(row, col)] = { clue: { kind: 'pearl', color } } } @@ -50,12 +77,19 @@ const getLineDegree = (puzzle: PuzzleIR, row: number, col: number): number => ].filter((key) => key !== null && puzzle.lines[key]?.mark === 'line').length const expectLineDiffs = ( - diffs: NonNullable['apply']>>['diffs'] | undefined, + diffs: + | NonNullable< + ReturnType['apply']> + >['diffs'] + | undefined, expected: Record, ): void => { expect( Object.fromEntries( - (diffs ?? []).map((diff) => [diff.kind === 'line' ? diff.lineKey : '', diff.kind === 'line' ? diff.to : '']), + (diffs ?? []).map((diff) => [ + diff.kind === 'line' ? diff.lineKey : '', + diff.kind === 'line' ? diff.to : '', + ]), ), ).toEqual(expected) } @@ -70,7 +104,11 @@ describe('Masyu pearl rules', () => { const result = createWhiteCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [south]: 'blank', [east]: 'line', [west]: 'line' }) + expectLineDiffs(result?.diffs, { + [south]: 'blank', + [east]: 'line', + [west]: 'line', + }) expect(result?.affectedCells).toEqual([cellKey(0, 1)]) }) @@ -85,7 +123,11 @@ describe('Masyu pearl rules', () => { const result = createWhiteCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [west]: 'blank', [north]: 'line', [south]: 'line' }) + expectLineDiffs(result?.diffs, { + [west]: 'blank', + [north]: 'line', + [south]: 'line', + }) }) it('White Circle Rule does nothing when both straight axes are still available', () => { @@ -109,7 +151,12 @@ describe('Masyu pearl rules', () => { const result = createWhiteCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank', [east]: 'line', [west]: 'line' }) + expectLineDiffs(result?.diffs, { + [north]: 'blank', + [south]: 'blank', + [east]: 'line', + [west]: 'line', + }) }) it('White Circle Rule rejects a horizontal pass-through when both immediate turn cells are blocked', () => { @@ -126,7 +173,12 @@ describe('Masyu pearl rules', () => { const result = createWhiteCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [east]: 'blank', [west]: 'blank', [north]: 'line', [south]: 'line' }) + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [west]: 'blank', + [north]: 'line', + [south]: 'line', + }) }) it('White Circle Rule keeps an axis available when each side still has a turn candidate', () => { @@ -180,7 +232,11 @@ describe('Masyu pearl rules', () => { const result = createWhiteCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [east]: 'line', [north]: 'blank', [south]: 'blank' }) + expectLineDiffs(result?.diffs, { + [east]: 'line', + [north]: 'blank', + [south]: 'blank', + }) }) it('White Circle Rule does not force a straight line into a degree-2 neighbor', () => { @@ -352,7 +408,11 @@ describe('Masyu pearl rules', () => { const result = createBlackCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [east]: 'blank', [west]: 'line', [extension]: 'line' }) + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [west]: 'line', + [extension]: 'line', + }) }) it('Black Circle Rule rejects an exit whose second step is already blank', () => { @@ -366,7 +426,11 @@ describe('Masyu pearl rules', () => { const result = createBlackCircleRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [east]: 'blank', [west]: 'line', [westExtension]: 'line' }) + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [west]: 'line', + [westExtension]: 'line', + }) }) it('Black Circle Rule turns away from a known line and extends that exit', () => { @@ -416,6 +480,7 @@ describe('Masyu pearl rules', () => { 'Masyu Candidate Bridge Line', 'Prevent Premature Loop', 'Black Pearl Candidate Pruning', + 'White Pearl Candidate Pruning', 'Pearl Completion', 'Cell Completion', 'Black Pearl Strong Inference', @@ -505,7 +570,12 @@ describe('Masyu loop rules', () => { const result = createMasyuColorPearlPropagationRule().apply(puzzle) expect(result?.diffs).toEqual([ - { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'yellow' }, + { + kind: 'tile', + tileKey: tileKey(2, 2), + fromFill: null, + toFill: 'yellow', + }, ]) expect(result?.affectedCells).toEqual([cellKey(1, 1)]) expect(result?.affectedTiles).toEqual([tileKey(1, 1), tileKey(2, 2)]) @@ -639,7 +709,9 @@ describe('Masyu loop rules', () => { it('Masyu Tile Connectivity Cut Coloring keeps unknown line separators passable', () => { const puzzle = createMasyuPuzzle(2, 2) - expect(createMasyuTileConnectivityCutColoringRule().apply(puzzle)).toBeNull() + expect( + createMasyuTileConnectivityCutColoringRule().apply(puzzle), + ).toBeNull() }) it('Masyu Tile Connectivity Cut Coloring does not fire with only one green source component', () => { @@ -648,7 +720,9 @@ describe('Masyu loop rules', () => { puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } puzzle.tiles[tileKey(2, 2)] = {} - expect(createMasyuTileConnectivityCutColoringRule().apply(puzzle)).toBeNull() + expect( + createMasyuTileConnectivityCutColoringRule().apply(puzzle), + ).toBeNull() }) it('Masyu Candidate Bridge Line forces the only candidate bridge between two pearls', () => { @@ -720,15 +794,27 @@ describe('Masyu loop rules', () => { expect(rules).toContain('masyu-color-line-propagation') expect(rules).toContain('masyu-tile-connectivity-cut-coloring') expect(rules).toContain('masyu-candidate-bridge-line') - expect(rules.indexOf('masyu-color-pearl-propagation')).toBe(rules.indexOf('masyu-tile-color-propagation') + 1) - expect(rules.indexOf('masyu-color-line-propagation')).toBe(rules.indexOf('masyu-color-pearl-propagation') + 1) + expect(rules.indexOf('masyu-color-pearl-propagation')).toBe( + rules.indexOf('masyu-tile-color-propagation') + 1, + ) + expect(rules.indexOf('masyu-color-line-propagation')).toBe( + rules.indexOf('masyu-color-pearl-propagation') + 1, + ) expect(rules.indexOf('masyu-tile-connectivity-cut-coloring')).toBe( rules.indexOf('masyu-color-line-propagation') + 1, ) expect(rules.indexOf('masyu-candidate-bridge-line')).toBe( rules.indexOf('masyu-tile-connectivity-cut-coloring') + 1, ) - expect(rules.indexOf('masyu-candidate-bridge-line')).toBeLessThan(rules.indexOf('masyu-prevent-premature-loop')) + expect(rules.indexOf('masyu-candidate-bridge-line')).toBeLessThan( + rules.indexOf('masyu-prevent-premature-loop'), + ) + expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBe( + rules.indexOf('masyu-black-pearl-candidate-pruning') + 1, + ) + expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBeLessThan( + rules.indexOf('pearl-completion'), + ) }) it('Prevent Premature Loop blanks a line that would close a smaller loop while other lines remain outside', () => { @@ -775,7 +861,10 @@ describe('Masyu black pearl candidate pruning', () => { const result = createBlackPearlCandidatePruningRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [northExtension]: 'line', [south]: 'blank' }) + expectLineDiffs(result?.diffs, { + [northExtension]: 'line', + [south]: 'blank', + }) expect(result?.affectedCells).toEqual([cellKey(2, 2)]) }) @@ -811,8 +900,18 @@ describe('Masyu black pearl candidate pruning', () => { const result = createBlackPearlCandidatePruningRule().apply(puzzle) - expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: lineKey([1, 3], [2, 3]), from: 'unknown', to: 'line' }) - expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: east, from: 'unknown', to: 'blank' }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: lineKey([1, 3], [2, 3]), + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: east, + from: 'unknown', + to: 'blank', + }) }) it('removes candidates whose required black pearl extension is already blank', () => { @@ -825,9 +924,24 @@ describe('Masyu black pearl candidate pruning', () => { const result = createBlackPearlCandidatePruningRule().apply(puzzle) - expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: south, from: 'unknown', to: 'line' }) - expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: southExtension, from: 'unknown', to: 'line' }) - expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: north, from: 'unknown', to: 'blank' }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: south, + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: southExtension, + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: north, + from: 'unknown', + to: 'blank', + }) }) it('removes a black pearl candidate that would close a smaller loop', () => { @@ -939,9 +1053,15 @@ describe('Masyu black pearl candidate pruning', () => { }) it('prunes only one black pearl per step on the reported 10x6 regression puzzle', () => { - let puzzle = masyuPlugin.parse('https://puzz.link/p?mashu/10/6/0000b6103260i0902216') - const rules = masyuPlugin.getRules().filter((rule) => rule.id !== 'masyu-color-pearl-propagation') - let pruningStep: NonNullable['step']> | null = null + let puzzle = masyuPlugin.parse( + 'https://puzz.link/p?mashu/10/6/0000b6103260i0902216', + ) + const rules = masyuPlugin + .getRules() + .filter((rule) => rule.id !== 'masyu-color-pearl-propagation') + let pruningStep: NonNullable< + ReturnType['step'] + > | null = null for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { const result = runNextRule(puzzle, rules, stepNumber) @@ -984,6 +1104,129 @@ describe('Masyu black pearl candidate pruning', () => { }) }) +describe('Masyu white pearl candidate pruning', () => { + it('forces the only feasible white pearl straight axis', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'white') + const east = lineKey([0, 1], [0, 2]) + const west = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 1], [1, 1]) + + const result = createWhitePearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [west]: 'line', + [south]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(0, 1)]) + expect(result?.message).toContain('White pearl') + }) + + it('does not force a white candidate line into a degree-2 neighbor', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + + expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('does nothing when both white straight-axis candidates remain symmetric', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + + expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) +}) + +describe('Masyu shared helper primitives', () => { + it('collects compatible line and tile decisions without overwriting conflicts', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [1, 2]) + const lineDecisions = createMasyuLineDecisionCollector(puzzle) + + expect(lineDecisions.add(targetLine, 'line')).toBe(true) + expect(lineDecisions.add(targetLine, 'line')).toBe(true) + expect(lineDecisions.add(targetLine, 'blank')).toBe(false) + expect(lineDecisions.diffs()).toEqual([ + { kind: 'line', lineKey: targetLine, from: 'unknown', to: 'line' }, + ]) + + const targetTile = tileKey(1, 1) + const tileDecisions = createMasyuTileDecisionCollector(puzzle) + expect(tileDecisions.add(targetTile, 'green')).toBe(true) + expect(tileDecisions.add(targetTile, 'yellow')).toBe(false) + expect(tileDecisions.diffs()).toEqual([ + { kind: 'tile', tileKey: targetTile, fromFill: null, toFill: 'green' }, + ]) + }) + + it('guards line decisions that would overflow endpoint degree', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + const guarded = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + + expect(guarded.add(lineKey([1, 1], [1, 2]), 'line')).toBe(false) + expect(guarded.hasChanges()).toBe(false) + }) + + it('selects pearl keys and colors in puzzle order', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 0, 'black') + addPearl(puzzle, 1, 1, 'white') + + expect(getMasyuPearlKeys(puzzle)).toEqual([cellKey(0, 0), cellKey(1, 1)]) + expect(getMasyuBlackPearlKeys(puzzle)).toEqual([cellKey(0, 0)]) + expect(getMasyuWhitePearlKeys(puzzle)).toEqual([cellKey(1, 1)]) + expect(getMasyuPearlColor(puzzle, cellKey(1, 1))).toBe('white') + }) + + it('shares line graph vocabulary for premature closures and required sources', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([3, 2], [3, 3]), 'line') + const closingLine = lineKey([0, 0], [1, 0]) + + expect(findMasyuPrematureLoopClosingLines(puzzle)).toEqual([closingLine]) + expect( + getMasyuRequiredSources(puzzle, buildMasyuCandidateGraph(puzzle)), + ).toEqual( + new Set([ + cellKey(2, 2), + cellKey(0, 0), + cellKey(0, 1), + cellKey(1, 1), + cellKey(1, 0), + cellKey(3, 2), + cellKey(3, 3), + ]), + ) + }) + + it('builds tile parity from boundary, line, blank, and fixed color anchors', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'blank') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + const parity = buildMasyuTileParityGraph(puzzle) + + expect(parity.getInferredColor(tileKey(0, 1))).toBe('yellow') + expect(parity.getInferredColor(tileKey(1, 1))).toBe('green') + expect(parity.getInferredColor(tileKey(2, 1))).toBe('green') + expect(parity.firstConflict).toBeNull() + }) +}) + describe('Masyu black pearl strong inference', () => { it('crosses out a black pearl exit whose two-step assumption causes a degree contradiction', () => { const puzzle = createMasyuPuzzle(5, 5) @@ -1019,8 +1262,18 @@ describe('Masyu black pearl strong inference', () => { return { message: 'Force a downstream contradiction', diffs: [ - { kind: 'line', lineKey: westOfNeighbor, from: 'unknown', to: 'line' }, - { kind: 'line', lineKey: eastOfNeighbor, from: 'unknown', to: 'line' }, + { + kind: 'line', + lineKey: westOfNeighbor, + from: 'unknown', + to: 'line', + }, + { + kind: 'line', + lineKey: eastOfNeighbor, + from: 'unknown', + to: 'line', + }, ], affectedCells: [], affectedLines: [westOfNeighbor, eastOfNeighbor], @@ -1028,7 +1281,9 @@ describe('Masyu black pearl strong inference', () => { }, } - const result = createBlackPearlStrongInferenceRule(() => [downstreamRule]).apply(puzzle) + const result = createBlackPearlStrongInferenceRule(() => [ + downstreamRule, + ]).apply(puzzle) expectLineDiffs(result?.diffs, { [north]: 'blank' }) expect(result?.message).toContain('after 1 step') @@ -1051,14 +1306,18 @@ describe('Masyu black pearl strong inference', () => { } return { message: 'Harmless trial-only progress', - diffs: [{ kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }], + diffs: [ + { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, + ], affectedCells: [], affectedLines: [unrelated], } }, } - const result = createBlackPearlStrongInferenceRule(() => [harmlessRule], { maxTrialSteps: 1 }).apply(puzzle) + const result = createBlackPearlStrongInferenceRule(() => [harmlessRule], { + maxTrialSteps: 1, + }).apply(puzzle) expect(result).toBeNull() expect(puzzle.lines[unrelated]?.mark).toBe('unknown') @@ -1068,7 +1327,9 @@ describe('Masyu black pearl strong inference', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') - const result = createBlackPearlStrongInferenceRule(() => [], { maxMs: -1 }).apply(puzzle) + const result = createBlackPearlStrongInferenceRule(() => [], { + maxMs: -1, + }).apply(puzzle) expect(result).toBeNull() }) @@ -1083,16 +1344,21 @@ describe('Masyu black pearl strong inference', () => { const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) - expect(result?.diffs).not.toContainEqual({ kind: 'line', lineKey: north, from: 'line', to: 'blank' }) + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: north, + from: 'line', + to: 'blank', + }) expect(puzzle.lines[north]?.mark).toBe('line') }) it('registers strong inference after the deterministic Masyu rules', () => { const rules = masyuPlugin.getRules() - expect(rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id)).toEqual( - deterministicMasyuRules.map((rule) => rule.id), - ) + expect( + rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id), + ).toEqual(deterministicMasyuRules.map((rule) => rule.id)) expect(rules.at(-1)?.id).toBe('masyu-black-pearl-strong-inference') }) }) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index a7ded28..c862c57 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -1,14 +1,20 @@ import type { Rule } from '../types' import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' import { createMasyuCandidateBridgeLineRule } from './rules/bridges' -import { createBlackPearlCandidatePruningRule } from './rules/candidates' +import { + createBlackPearlCandidatePruningRule, + createWhitePearlCandidatePruningRule, +} from './rules/candidates' import { createMasyuColorLinePropagationRule, createMasyuColorPearlPropagationRule, createMasyuTileColorPropagationRule, } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' -import { createCellCompletionRule, createPearlCompletionRule } from './rules/completion' +import { + createCellCompletionRule, + createPearlCompletionRule, +} from './rules/completion' import { createPreventPrematureLoopRule } from './rules/loop' import { createBlackDiagonalWhitePinchRule, @@ -32,6 +38,7 @@ export const deterministicMasyuRules: Rule[] = [ createMasyuCandidateBridgeLineRule(), createPreventPrematureLoopRule(), createBlackPearlCandidatePruningRule(), + createWhitePearlCandidatePruningRule(), createPearlCompletionRule(), createCellCompletionRule(), ] diff --git a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts index e8b4f0b..20c76cc 100644 --- a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts +++ b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts @@ -6,6 +6,7 @@ import { runMasyuTrialUntilFixpoint, type MasyuTrialResult, } from './trial' +import { getMasyuBlackPearlKeys } from './pearlSelectors' import { formatMasyuCellKeyLabel, formatMasyuLineLabel, @@ -49,12 +50,17 @@ const deriveProbeBudgets = (maxTrialSteps: number): number[] => { } const isUndecidedBlackPearl = (puzzle: PuzzleIR, pearlKey: string): boolean => - Object.values(getMasyuIncidentDirectionalLines(puzzle, pearlKey)).some((line) => line?.mark === 'unknown') - -const collectBlackPearlExitCandidates = (puzzle: PuzzleIR, maxCandidates: number): BlackPearlExitCandidate[] => { + Object.values(getMasyuIncidentDirectionalLines(puzzle, pearlKey)).some( + (line) => line?.mark === 'unknown', + ) + +const collectBlackPearlExitCandidates = ( + puzzle: PuzzleIR, + maxCandidates: number, +): BlackPearlExitCandidate[] => { const candidates: BlackPearlExitCandidate[] = [] - for (const [pearlKey, cell] of Object.entries(puzzle.cells)) { - if (cell.clue?.kind !== 'pearl' || cell.clue.color !== 'black' || !isUndecidedBlackPearl(puzzle, pearlKey)) { + for (const pearlKey of getMasyuBlackPearlKeys(puzzle)) { + if (!isUndecidedBlackPearl(puzzle, pearlKey)) { continue } for (const direction of MASYU_DIRECTIONS) { @@ -62,7 +68,11 @@ const collectBlackPearlExitCandidates = (puzzle: PuzzleIR, maxCandidates: number if (!first || !second || first.mark !== 'unknown') { continue } - const opposite = getMasyuDirectionalLine(puzzle, pearlKey, oppositeMasyuDirection(direction)) + const opposite = getMasyuDirectionalLine( + puzzle, + pearlKey, + oppositeMasyuDirection(direction), + ) candidates.push({ pearlKey, direction, @@ -78,7 +88,10 @@ const collectBlackPearlExitCandidates = (puzzle: PuzzleIR, maxCandidates: number return candidates } -const buildBranch = (puzzle: PuzzleIR, candidate: BlackPearlExitCandidate): StrongBranch => { +const buildBranch = ( + puzzle: PuzzleIR, + candidate: BlackPearlExitCandidate, +): StrongBranch => { const branch = clonePuzzle(puzzle) const assumptions: Array<[lineKey: string, mark: 'line' | 'blank']> = [ [candidate.firstLine, 'line'], @@ -94,7 +107,9 @@ const buildBranch = (puzzle: PuzzleIR, candidate: BlackPearlExitCandidate): Stro } const setupDescription = assumptions - .map(([lineKeyValue, mark]) => `${formatMasyuLineLabel(lineKeyValue)} ${mark}`) + .map( + ([lineKeyValue, mark]) => `${formatMasyuLineLabel(lineKeyValue)} ${mark}`, + ) .join(', ') return { puzzle: branch, setupOk, setupDescription } @@ -109,7 +124,8 @@ const immediateContradictionResult = (puzzle: PuzzleIR): MasyuTrialResult => ({ elapsedMs: 0, contradictionReason: { kind: 'line-assumption', - message: 'line-assumption contradiction: this exit assumption conflicts with an already decided Masyu line', + message: + 'line-assumption contradiction: this exit assumption conflicts with an already decided Masyu line', }, }) @@ -133,13 +149,18 @@ export const createBlackPearlStrongInferenceRule = ( name: 'Black Pearl Strong Inference', apply: (puzzle: PuzzleIR): RuleApplication | null => { const deterministicRules = getDeterministicRules() - const candidates = collectBlackPearlExitCandidates(puzzle, options.maxCandidates ?? STRONG_MAX_CANDIDATES) + const candidates = collectBlackPearlExitCandidates( + puzzle, + options.maxCandidates ?? STRONG_MAX_CANDIDATES, + ) if (candidates.length === 0) { return null } const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) - const budgets = deriveProbeBudgets(options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS) + const budgets = deriveProbeBudgets( + options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, + ) for (const budget of budgets) { for (const candidate of candidates) { if (Date.now() > deadlineMs) { @@ -148,7 +169,12 @@ export const createBlackPearlStrongInferenceRule = ( const branch = buildBranch(puzzle, candidate) const result = branch.setupOk - ? runMasyuTrialUntilFixpoint(branch.puzzle, deterministicRules, budget, deadlineMs) + ? runMasyuTrialUntilFixpoint( + branch.puzzle, + deterministicRules, + budget, + deadlineMs, + ) : immediateContradictionResult(branch.puzzle) if (result.timedOut) { return null @@ -156,7 +182,9 @@ export const createBlackPearlStrongInferenceRule = ( if (!result.contradiction) { continue } - if ((puzzle.lines[candidate.firstLine]?.mark ?? 'unknown') !== 'unknown') { + if ( + (puzzle.lines[candidate.firstLine]?.mark ?? 'unknown') !== 'unknown' + ) { continue } diff --git a/src/domain/rules/masyu/rules/bridges.ts b/src/domain/rules/masyu/rules/bridges.ts index e7a664a..c867146 100644 --- a/src/domain/rules/masyu/rules/bridges.ts +++ b/src/domain/rules/masyu/rules/bridges.ts @@ -1,133 +1,32 @@ -import { cellKey, parseLineKey } from '../../../ir/keys' -import type { CellCoord, LineMark, PuzzleIR } from '../../../ir/types' +import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' +import { createMasyuLineDecisionCollector } from './decisionCollector' import { - buildMasyuLineDiffs, - collectMasyuLineDecisionWithoutDegreeOverflow, - formatMasyuLineLabel, -} from './shared' - -type CandidateEdge = { - lineKey: string - mark: LineMark - left: string - right: string -} - -type CandidateGraph = { - cellKeys: string[] - edges: CandidateEdge[] - adjacency: Map -} - -const getEndpointKey = ([row, col]: CellCoord): string => cellKey(row, col) - -const getOtherEndpoint = (edge: CandidateEdge, node: string): string => - edge.left === node ? edge.right : edge.left - -const buildMasyuCandidateGraph = (puzzle: PuzzleIR): CandidateGraph => { - const cellKeys: string[] = [] - const adjacency = new Map() - - for (let row = 0; row < puzzle.rows; row += 1) { - for (let col = 0; col < puzzle.cols; col += 1) { - const key = cellKey(row, col) - cellKeys.push(key) - adjacency.set(key, []) - } - } - - const edges: CandidateEdge[] = [] - for (const lineKeyValue of Object.keys(puzzle.lines)) { - const mark = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' - if (mark === 'blank') { - continue - } - const [leftCoord, rightCoord] = parseLineKey(lineKeyValue) - const edge = { - lineKey: lineKeyValue, - mark, - left: getEndpointKey(leftCoord), - right: getEndpointKey(rightCoord), - } - edges.push(edge) - adjacency.get(edge.left)?.push(edge) - adjacency.get(edge.right)?.push(edge) - } - - return { cellKeys, edges, adjacency } -} - -const getRequiredSources = (puzzle: PuzzleIR, graph: CandidateGraph): Set => { - const sources = new Set() - - for (const [key, cell] of Object.entries(puzzle.cells)) { - if (cell.clue?.kind === 'pearl') { - sources.add(key) - } - } - - for (const edge of graph.edges) { - if (edge.mark !== 'line') { - continue - } - sources.add(edge.left) - sources.add(edge.right) - } - - return sources -} - -const getSourceComponentCount = (graph: CandidateGraph, sources: ReadonlySet): number => { - const seen = new Set() - let sourceComponents = 0 - - for (const start of graph.cellKeys) { - if (seen.has(start)) { - continue - } - const stack = [start] - seen.add(start) - let hasSource = false - - while (stack.length > 0) { - const node = stack.pop() - if (node === undefined) { - continue - } - if (sources.has(node)) { - hasSource = true - } - for (const edge of graph.adjacency.get(node) ?? []) { - const neighbor = getOtherEndpoint(edge, node) - if (seen.has(neighbor)) { - continue - } - seen.add(neighbor) - stack.push(neighbor) - } - } - - if (hasSource) { - sourceComponents += 1 - } - } - - return sourceComponents -} + buildMasyuCandidateGraph, + getMasyuOtherEndpoint, + getMasyuRequiredSources, + getMasyuSourceComponentCount, + type MasyuCandidateEdge, +} from './lineGraph' +import { formatMasyuLineLabel } from './shared' export const createMasyuCandidateBridgeLineRule = (): Rule => ({ id: 'masyu-candidate-bridge-line', name: 'Masyu Candidate Bridge Line', apply: (puzzle: PuzzleIR): RuleApplication | null => { const graph = buildMasyuCandidateGraph(puzzle) - const requiredSources = getRequiredSources(puzzle, graph) + const requiredSources = getMasyuRequiredSources(puzzle, graph) - if (requiredSources.size < 2 || getSourceComponentCount(graph, requiredSources) > 1) { + if ( + requiredSources.size < 2 || + getMasyuSourceComponentCount(graph, requiredSources) > 1 + ) { return null } - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) const affectedCells = new Set() const affectedLines = new Set() const discovery = new Map() @@ -135,11 +34,19 @@ export const createMasyuCandidateBridgeLineRule = (): Rule => ({ let timestamp = 0 let firstForcedLine: string | null = null - const rememberBridge = (edge: CandidateEdge, childRequired: number, totalRequired: number): void => { - if (edge.mark !== 'unknown' || childRequired === 0 || totalRequired - childRequired === 0) { + const rememberBridge = ( + edge: MasyuCandidateEdge, + childRequired: number, + totalRequired: number, + ): void => { + if ( + edge.mark !== 'unknown' || + childRequired === 0 || + totalRequired - childRequired === 0 + ) { return } - if (!collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, edge.lineKey, 'line')) { + if (!decisions.add(edge.lineKey, 'line')) { return } affectedLines.add(edge.lineKey) @@ -150,7 +57,11 @@ export const createMasyuCandidateBridgeLineRule = (): Rule => ({ } } - const dfs = (node: string, parentEdge: string | null, totalRequired: number): number => { + const dfs = ( + node: string, + parentEdge: string | null, + totalRequired: number, + ): number => { discovery.set(node, timestamp) low.set(node, timestamp) timestamp += 1 @@ -161,7 +72,7 @@ export const createMasyuCandidateBridgeLineRule = (): Rule => ({ if (edge.lineKey === parentEdge) { continue } - const neighbor = getOtherEndpoint(edge, node) + const neighbor = getMasyuOtherEndpoint(edge, node) if (!discovery.has(neighbor)) { const childRequired = dfs(neighbor, edge.lineKey, totalRequired) low.set(node, Math.min(low.get(node) ?? 0, low.get(neighbor) ?? 0)) @@ -172,7 +83,10 @@ export const createMasyuCandidateBridgeLineRule = (): Rule => ({ } continue } - low.set(node, Math.min(low.get(node) ?? 0, discovery.get(neighbor) ?? 0)) + low.set( + node, + Math.min(low.get(node) ?? 0, discovery.get(neighbor) ?? 0), + ) } return subtreeRequired @@ -193,7 +107,7 @@ export const createMasyuCandidateBridgeLineRule = (): Rule => ({ } componentNodes.push(node) for (const edge of graph.adjacency.get(node) ?? []) { - const neighbor = getOtherEndpoint(edge, node) + const neighbor = getMasyuOtherEndpoint(edge, node) if (seen.has(neighbor)) { continue } @@ -202,15 +116,17 @@ export const createMasyuCandidateBridgeLineRule = (): Rule => ({ } } - const totalRequired = componentNodes.filter((node) => requiredSources.has(node)).length + const totalRequired = componentNodes.filter((node) => + requiredSources.has(node), + ).length dfs(start, null, totalRequired) } - if (decisions.size === 0 || firstForcedLine === null) { + if (!decisions.hasChanges() || firstForcedLine === null) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: `This candidate line is the only remaining connection between required loop regions, so it must be part of the loop: ${formatMasyuLineLabel(firstForcedLine)}` + diff --git a/src/domain/rules/masyu/rules/candidates.ts b/src/domain/rules/masyu/rules/candidates.ts index 9747a95..dd65840 100644 --- a/src/domain/rules/masyu/rules/candidates.ts +++ b/src/domain/rules/masyu/rules/candidates.ts @@ -1,6 +1,7 @@ -import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' -import { buildMasyuLineDiffs, collectMasyuLineDecision, formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' +import { createMasyuLineDecisionCollector } from './decisionCollector' +import { formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' import { createMasyuLookaheadContext } from './lookahead' export const createBlackPearlCandidatePruningRule = (): Rule => ({ @@ -10,7 +11,7 @@ export const createBlackPearlCandidatePruningRule = (): Rule => ({ const context = createMasyuLookaheadContext(puzzle) for (const pearlKey of context.getBlackPearlKeys()) { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const incident = context.getIncidentEntries(new Map(), pearlKey) const exitLineKeys = incident.map((item) => item.lineKey) const candidates = context.getFeasibleBlackPearlCandidates(pearlKey) @@ -26,17 +27,17 @@ export const createBlackPearlCandidatePruningRule = (): Rule => ({ ) for (const lineKeyValue of commonLineKeys) { - collectMasyuLineDecision(decisions, puzzle, lineKeyValue, 'line') + decisions.add(lineKeyValue, 'line') } for (const lineKeyValue of excludedExitLineKeys) { - collectMasyuLineDecision(decisions, puzzle, lineKeyValue, 'blank') + decisions.add(lineKeyValue, 'blank') } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { continue } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() const firstLine = diffs[0]?.lineKey return { message: firstLine @@ -51,3 +52,54 @@ export const createBlackPearlCandidatePruningRule = (): Rule => ({ return null }, }) + +export const createWhitePearlCandidatePruningRule = (): Rule => ({ + id: 'masyu-white-pearl-candidate-pruning', + name: 'White Pearl Candidate Pruning', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const context = createMasyuLookaheadContext(puzzle) + + for (const pearlKey of context.getWhitePearlKeys()) { + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + const incident = context.getIncidentEntries(new Map(), pearlKey) + const exitLineKeys = incident.map((item) => item.lineKey) + const candidates = context.getFeasibleWhitePearlCandidates(pearlKey) + if (candidates.length === 0) { + continue + } + + const commonLineKeys = [...candidates[0].lines].filter((lineKeyValue) => + candidates.every((candidate) => candidate.lines.has(lineKeyValue)), + ) + const excludedExitLineKeys = exitLineKeys.filter((lineKeyValue) => + candidates.every((candidate) => !candidate.exitLines.has(lineKeyValue)), + ) + + for (const lineKeyValue of commonLineKeys) { + decisions.add(lineKeyValue, 'line') + } + for (const lineKeyValue of excludedExitLineKeys) { + decisions.add(lineKeyValue, 'blank') + } + + if (!decisions.hasChanges()) { + continue + } + + const diffs = decisions.diffs() + const firstLine = diffs[0]?.lineKey + return { + message: firstLine + ? `White pearl ${formatMasyuCellKeyLabel(pearlKey)} has only compatible straight-axis candidates left, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'White pearl candidate pruning applied.', + diffs, + affectedCells: [pearlKey], + affectedLines: diffs.map((diff) => diff.lineKey), + } + } + + return null + }, +}) diff --git a/src/domain/rules/masyu/rules/color.ts b/src/domain/rules/masyu/rules/color.ts index e880a04..9c215dc 100644 --- a/src/domain/rules/masyu/rules/color.ts +++ b/src/domain/rules/masyu/rules/color.ts @@ -1,87 +1,42 @@ -import { parseCellKey, parseLineKey, parseTileKey, tileKey } from '../../../ir/keys' +import { parseCellKey, parseTileKey, tileKey } from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' -import { buildMasyuLineDiffs, formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' - -export type MasyuTileColor = 'green' | 'yellow' - -type Parity = 0 | 1 - -export const isMasyuTileColor = (fill: string | undefined): fill is MasyuTileColor => - fill === 'green' || fill === 'yellow' - -export const oppositeMasyuTileColor = (fill: MasyuTileColor): MasyuTileColor => - fill === 'green' ? 'yellow' : 'green' +import { + createMasyuLineDecisionCollector, + createMasyuTileDecisionCollector, +} from './decisionCollector' +import { formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' +import { + buildMasyuTileParityGraph, + getMasyuLineTileRelation, + isMasyuTileColor, + oppositeMasyuTileColor, +} from './tileParity' + +export { + getMasyuLineTileRelation, + isMasyuTileColor, + oppositeMasyuTileColor, + type MasyuTileColor, +} from './tileParity' export const formatMasyuTileKeyLabel = (key: string): string => { const [row, col] = parseTileKey(key) return `T(${row}, ${col})` } -const applyParity = (color: MasyuTileColor, parity: Parity): MasyuTileColor => - parity === 0 ? color : oppositeMasyuTileColor(color) - -const isBoundaryTile = (puzzle: PuzzleIR, row: number, col: number): boolean => - row === 0 || row === puzzle.rows || col === 0 || col === puzzle.cols - -const collectMasyuTileColorDecision = ( - decisions: Map, - puzzle: PuzzleIR, - key: string, - toFill: MasyuTileColor, -): boolean => { - const currentFill = puzzle.tiles[key]?.fill - if (isMasyuTileColor(currentFill)) { - return currentFill === toFill - } - const existing = decisions.get(key) - if (existing !== undefined) { - return existing === toFill - } - decisions.set(key, toFill) - return true -} - -export const getMasyuLineTileRelation = ( - puzzle: PuzzleIR, - lineKeyValue: string, -): { leftTile: string; rightTile: string } | null => { - const [left, right] = parseLineKey(lineKeyValue) - if (left[0] === right[0] && Math.abs(left[1] - right[1]) === 1) { - const row = left[0] - const col = Math.min(left[1], right[1]) - if (row < 0 || row >= puzzle.rows || col < 0 || col >= puzzle.cols - 1) { - return null - } - return { - leftTile: tileKey(row, col + 1), - rightTile: tileKey(row + 1, col + 1), - } - } - if (left[1] === right[1] && Math.abs(left[0] - right[0]) === 1) { - const row = Math.min(left[0], right[0]) - const col = left[1] - if (row < 0 || row >= puzzle.rows - 1 || col < 0 || col >= puzzle.cols) { - return null - } - return { - leftTile: tileKey(row + 1, col), - rightTile: tileKey(row + 1, col + 1), - } - } - return null -} - export const createMasyuColorLinePropagationRule = (): Rule => ({ id: 'masyu-color-line-propagation', name: 'Masyu Color-Line Propagation', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedLines = new Set() const affectedTiles = new Set() let firstReason: string | null = null - for (const [lineKeyValue, lineState] of Object.entries(puzzle.lines ?? {})) { + for (const [lineKeyValue, lineState] of Object.entries( + puzzle.lines ?? {}, + )) { if ((lineState?.mark ?? 'unknown') !== 'unknown') { continue } @@ -98,7 +53,7 @@ export const createMasyuColorLinePropagationRule = (): Rule => ({ } const to: LineMark = leftColor === rightColor ? 'blank' : 'line' - decisions.set(lineKeyValue, to) + decisions.add(lineKeyValue, to) affectedLines.add(lineKeyValue) affectedTiles.add(relation.leftTile) affectedTiles.add(relation.rightTile) @@ -108,13 +63,13 @@ export const createMasyuColorLinePropagationRule = (): Rule => ({ : `${formatMasyuTileKeyLabel(relation.leftTile)} and ${formatMasyuTileKeyLabel(relation.rightTile)} have the same color, so ${formatMasyuLineLabel(lineKeyValue)} must be crossed out` } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } return { - message: `${firstReason ?? 'Known Masyu tile colors decide separating line marks'} (${decisions.size} line update(s)).`, - diffs: buildMasyuLineDiffs(decisions, puzzle), + message: `${firstReason ?? 'Known Masyu tile colors decide separating line marks'} (${decisions.affectedLines().length} line update(s)).`, + diffs: decisions.diffs(), affectedCells: [], affectedLines: [...affectedLines], affectedTiles: [...affectedTiles], @@ -126,12 +81,16 @@ export const createMasyuColorPearlPropagationRule = (): Rule => ({ id: 'masyu-color-pearl-propagation', name: 'Masyu Color-Pearl Propagation', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decidedTileFills = new Map() + const decidedTileFills = createMasyuTileDecisionCollector(puzzle) const affectedCells = new Set() const affectedTiles = new Set() let firstReason: string | null = null - const inferOppositeDiagonal = (pearlKey: string, knownTile: string, oppositeTile: string): void => { + const inferOppositeDiagonal = ( + pearlKey: string, + knownTile: string, + oppositeTile: string, + ): void => { const knownFill = puzzle.tiles[knownTile]?.fill if (!isMasyuTileColor(knownFill)) { return @@ -142,7 +101,7 @@ export const createMasyuColorPearlPropagationRule = (): Rule => ({ } const toFill = oppositeMasyuTileColor(knownFill) - if (!collectMasyuTileColorDecision(decidedTileFills, puzzle, oppositeTile, toFill)) { + if (!decidedTileFills.add(oppositeTile, toFill)) { return } affectedCells.add(pearlKey) @@ -169,16 +128,11 @@ export const createMasyuColorPearlPropagationRule = (): Rule => ({ inferOppositeDiagonal(key, sw, ne) } - if (decidedTileFills.size === 0) { + if (!decidedTileFills.hasChanges()) { return null } - const diffs: RuleApplication['diffs'] = [...decidedTileFills.entries()].map(([key, toFill]) => ({ - kind: 'tile' as const, - tileKey: key, - fromFill: (puzzle.tiles[key]?.fill ?? null) as string | null, - toFill, - })) + const diffs = decidedTileFills.diffs() return { message: `${firstReason ?? 'White pearl diagonal tile colors force opposite colors'} (${diffs.length} tile update(s)).`, @@ -193,117 +147,8 @@ export const createMasyuTileColorPropagationRule = (): Rule => ({ id: 'masyu-tile-color-propagation', name: 'Masyu Tile Color Propagation', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const parent = new Map() - const rank = new Map() - const parityToParent = new Map() - const inconsistentRoots = new Set() - - const ensureTile = (key: string): void => { - if (parent.has(key)) { - return - } - parent.set(key, key) - rank.set(key, 0) - parityToParent.set(key, 0) - } - - const find = (key: string): { root: string; parity: Parity } => { - ensureTile(key) - const currentParent = parent.get(key) - if (currentParent === undefined || currentParent === key) { - return { root: key, parity: 0 } - } - const parentResult = find(currentParent) - const compressedParity = ((parityToParent.get(key) ?? 0) ^ parentResult.parity) as Parity - parent.set(key, parentResult.root) - parityToParent.set(key, compressedParity) - return { root: parentResult.root, parity: compressedParity } - } - - const markInconsistent = (root: string): void => { - inconsistentRoots.add(find(root).root) - } - - const union = (tileA: string, tileB: string, relation: Parity): void => { - const rootA = find(tileA) - const rootB = find(tileB) - if (rootA.root === rootB.root) { - if ((rootA.parity ^ rootB.parity) !== relation) { - markInconsistent(rootA.root) - } - return - } - - const mergedParity = (rootA.parity ^ rootB.parity ^ relation) as Parity - const rankA = rank.get(rootA.root) ?? 0 - const rankB = rank.get(rootB.root) ?? 0 - const rootAWasInconsistent = inconsistentRoots.delete(rootA.root) - const rootBWasInconsistent = inconsistentRoots.delete(rootB.root) - - if (rankA < rankB) { - parent.set(rootA.root, rootB.root) - parityToParent.set(rootA.root, mergedParity) - if (rootAWasInconsistent || rootBWasInconsistent) { - inconsistentRoots.add(rootB.root) - } - return - } - - parent.set(rootB.root, rootA.root) - parityToParent.set(rootB.root, mergedParity) - if (rankA === rankB) { - rank.set(rootA.root, rankA + 1) - } - if (rootAWasInconsistent || rootBWasInconsistent) { - inconsistentRoots.add(rootA.root) - } - } - - for (let row = 0; row <= puzzle.rows; row += 1) { - for (let col = 0; col <= puzzle.cols; col += 1) { - ensureTile(tileKey(row, col)) - } - } - - for (const [lineKeyValue, lineState] of Object.entries(puzzle.lines ?? {})) { - const mark: LineMark = lineState?.mark ?? 'unknown' - if (mark !== 'line' && mark !== 'blank') { - continue - } - const relation = getMasyuLineTileRelation(puzzle, lineKeyValue) - if (!relation) { - continue - } - union(relation.leftTile, relation.rightTile, mark === 'line' ? 1 : 0) - } - - const anchoredRootColors = new Map() - const rememberAnchor = (key: string, color: MasyuTileColor): void => { - const { root, parity } = find(key) - const rootColor = applyParity(color, parity) - const current = anchoredRootColors.get(root) - if (current !== undefined && current !== rootColor) { - markInconsistent(root) - return - } - anchoredRootColors.set(root, rootColor) - } - - for (let row = 0; row <= puzzle.rows; row += 1) { - for (let col = 0; col <= puzzle.cols; col += 1) { - if (isBoundaryTile(puzzle, row, col)) { - rememberAnchor(tileKey(row, col), 'yellow') - } - } - } - - for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { - if (isMasyuTileColor(tile.fill)) { - rememberAnchor(key, tile.fill) - } - } - - const decidedTileFills = new Map() + const parityGraph = buildMasyuTileParityGraph(puzzle) + const decidedTileFills = createMasyuTileDecisionCollector(puzzle) const affectedTiles = new Set() let firstInferredTile: string | null = null @@ -314,31 +159,21 @@ export const createMasyuTileColorPropagationRule = (): Rule => ({ if (isMasyuTileColor(currentFill)) { continue } - const { root, parity } = find(key) - if (inconsistentRoots.has(root)) { - continue - } - const rootColor = anchoredRootColors.get(root) - if (rootColor === undefined) { + const inferredColor = parityGraph.getInferredColor(key) + if (!inferredColor) { continue } - const inferredColor = applyParity(rootColor, parity) - decidedTileFills.set(key, inferredColor) + decidedTileFills.add(key, inferredColor) affectedTiles.add(key) firstInferredTile ??= key } } - if (decidedTileFills.size === 0) { + if (!decidedTileFills.hasChanges()) { return null } - const diffs: RuleApplication['diffs'] = [...decidedTileFills.entries()].map(([key, toFill]) => ({ - kind: 'tile' as const, - tileKey: key, - fromFill: (puzzle.tiles[key]?.fill ?? null) as string | null, - toFill, - })) + const diffs = decidedTileFills.diffs() return { message: `Known Masyu lines and the exterior boundary color tile regions; ${firstInferredTile ? formatMasyuTileKeyLabel(firstInferredTile) : 'matching tiles'} and related tiles are yellow/green (${diffs.length} tile update(s)).`, diff --git a/src/domain/rules/masyu/rules/completion.ts b/src/domain/rules/masyu/rules/completion.ts index 7df2e0a..b849356 100644 --- a/src/domain/rules/masyu/rules/completion.ts +++ b/src/domain/rules/masyu/rules/completion.ts @@ -1,11 +1,18 @@ import { cellKey } from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' +import { + createMasyuLineDecisionCollector, + type MasyuLineDecisionCollector, +} from './decisionCollector' +import { + getMasyuPearlColor, + getMasyuPearlKeys, + type MasyuPearlColor, +} from './pearlSelectors' import { areMasyuDirectionsOpposite, areMasyuDirectionsTurn, - buildMasyuLineDiffs, - collectMasyuLineDecision, formatMasyuCellKeyLabel, formatMasyuLineLabel, getMasyuIncidentDirectionalLines, @@ -15,26 +22,26 @@ import { type MasyuDirectionalLine, } from './shared' -type PearlColor = 'white' | 'black' - -const getPearlCellKeys = (puzzle: PuzzleIR): string[] => - Object.entries(puzzle.cells).flatMap(([key, cell]) => (cell.clue?.kind === 'pearl' ? [key] : [])) - -const getPearlColor = (puzzle: PuzzleIR, key: string): PearlColor | null => { - const clue = puzzle.cells[key]?.clue - return clue?.kind === 'pearl' ? clue.color : null -} - -const isLegalPearlPair = (color: PearlColor, left: MasyuDirection, right: MasyuDirection): boolean => - color === 'white' ? areMasyuDirectionsOpposite(left, right) : areMasyuDirectionsTurn(left, right) +const isLegalPearlPair = ( + color: MasyuPearlColor, + left: MasyuDirection, + right: MasyuDirection, +): boolean => + color === 'white' + ? areMasyuDirectionsOpposite(left, right) + : areMasyuDirectionsTurn(left, right) const getLegalPearlPairs = ( - color: PearlColor, + color: MasyuPearlColor, entries: MasyuDirectionalLine[], ): [MasyuDirectionalLine, MasyuDirectionalLine][] => { const pairs: [MasyuDirectionalLine, MasyuDirectionalLine][] = [] for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < entries.length; + rightIndex += 1 + ) { const left = entries[leftIndex] const right = entries[rightIndex] if (isLegalPearlPair(color, left.direction, right.direction)) { @@ -46,23 +53,24 @@ const getLegalPearlPairs = ( } const rememberPearlDecision = ( - decisions: Map, - puzzle: PuzzleIR, + decisions: MasyuLineDecisionCollector, lineKey: string, to: LineMark, ): boolean => { - const beforeSize = decisions.size - return collectMasyuLineDecision(decisions, puzzle, lineKey, to) && decisions.size > beforeSize + return decisions.addNew(lineKey, to) } const rememberBlackExtension = ( - decisions: Map, + decisions: MasyuLineDecisionCollector, puzzle: PuzzleIR, pearlKey: string, direction: MasyuDirection, ): string | null => { const extension = getMasyuTwoStepLine(puzzle, pearlKey, direction).second - if (!extension || !rememberPearlDecision(decisions, puzzle, extension.lineKey, 'line')) { + if ( + !extension || + !rememberPearlDecision(decisions, extension.lineKey, 'line') + ) { return null } return extension.lineKey @@ -72,14 +80,19 @@ export const createPearlCompletionRule = (): Rule => ({ id: 'pearl-completion', name: 'Pearl Completion', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstPearl: string | null = null let firstLine: string | null = null let firstReason: string | null = null - const remember = (pearlKey: string, lineKey: string, to: LineMark, reason: string): void => { - if (!rememberPearlDecision(decisions, puzzle, lineKey, to)) { + const remember = ( + pearlKey: string, + lineKey: string, + to: LineMark, + reason: string, + ): void => { + if (!rememberPearlDecision(decisions, lineKey, to)) { return } affectedCells.add(pearlKey) @@ -90,9 +103,18 @@ export const createPearlCompletionRule = (): Rule => ({ } } - const rememberBlackExitExtensions = (pearlKey: string, directions: MasyuDirection[], reason: string): void => { + const rememberBlackExitExtensions = ( + pearlKey: string, + directions: MasyuDirection[], + reason: string, + ): void => { for (const direction of directions) { - const extensionLineKey = rememberBlackExtension(decisions, puzzle, pearlKey, direction) + const extensionLineKey = rememberBlackExtension( + decisions, + puzzle, + pearlKey, + direction, + ) if (extensionLineKey) { affectedCells.add(pearlKey) if (firstPearl === null) { @@ -104,8 +126,8 @@ export const createPearlCompletionRule = (): Rule => ({ } } - for (const pearlKey of getPearlCellKeys(puzzle)) { - const color = getPearlColor(puzzle, pearlKey) + for (const pearlKey of getMasyuPearlKeys(puzzle)) { + const color = getMasyuPearlColor(puzzle, pearlKey) if (!color) { continue } @@ -123,7 +145,13 @@ export const createPearlCompletionRule = (): Rule => ({ : 'must turn and extend from each exit' if (lineEntries.length === 2) { - if (!isLegalPearlPair(color, lineEntries[0].direction, lineEntries[1].direction)) { + if ( + !isLegalPearlPair( + color, + lineEntries[0].direction, + lineEntries[1].direction, + ) + ) { continue } for (const item of unknownEntries) { @@ -148,7 +176,11 @@ export const createPearlCompletionRule = (): Rule => ({ } remember(pearlKey, legalCandidates[0].lineKey, 'line', reason) if (color === 'black') { - rememberBlackExitExtensions(pearlKey, [lineEntries[0].direction, legalCandidates[0].direction], reason) + rememberBlackExitExtensions( + pearlKey, + [lineEntries[0].direction, legalCandidates[0].direction], + reason, + ) } continue } @@ -166,19 +198,23 @@ export const createPearlCompletionRule = (): Rule => ({ remember(pearlKey, left.lineKey, 'line', reason) remember(pearlKey, right.lineKey, 'line', reason) if (color === 'black') { - rememberBlackExitExtensions(pearlKey, [left.direction, right.direction], reason) + rememberBlackExitExtensions( + pearlKey, + [left.direction, right.direction], + reason, + ) } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstPearl && firstLine && firstReason - ? `${getPearlColor(puzzle, firstPearl) === 'white' ? 'White' : 'Black'} pearl ${formatMasyuCellKeyLabel(firstPearl)} ${firstReason}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + ? `${getMasyuPearlColor(puzzle, firstPearl) === 'white' ? 'White' : 'Black'} pearl ${formatMasyuCellKeyLabel(firstPearl)} ${firstReason}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` : 'Pearl completion applied.', diffs, affectedCells: [...affectedCells], @@ -191,13 +227,18 @@ export const createCellCompletionRule = (): Rule => ({ id: 'cell-completion', name: 'Cell Completion', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstCell: string | null = null let firstReason: string | null = null - const remember = (key: string, lineKey: string, to: LineMark, reason: string): void => { - if (!collectMasyuLineDecision(decisions, puzzle, lineKey, to)) { + const remember = ( + key: string, + lineKey: string, + to: LineMark, + reason: string, + ): void => { + if (!decisions.add(lineKey, to)) { return } affectedCells.add(key) @@ -220,14 +261,21 @@ export const createCellCompletionRule = (): Rule => ({ return item ? [item] : [] }) const lineEntries = incident.filter((item) => item.mark === 'line') - const unknownEntries = incident.filter((item) => item.mark === 'unknown') + const unknownEntries = incident.filter( + (item) => item.mark === 'unknown', + ) if (unknownEntries.length === 0) { continue } if (lineEntries.length === 2) { for (const item of unknownEntries) { - remember(key, item.lineKey, 'blank', 'it already has degree 2, so every other exit is blank') + remember( + key, + item.lineKey, + 'blank', + 'it already has degree 2, so every other exit is blank', + ) } continue } @@ -255,11 +303,11 @@ export const createCellCompletionRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstCell && firstReason diff --git a/src/domain/rules/masyu/rules/connectivity.ts b/src/domain/rules/masyu/rules/connectivity.ts index e028712..9f0ccb7 100644 --- a/src/domain/rules/masyu/rules/connectivity.ts +++ b/src/domain/rules/masyu/rules/connectivity.ts @@ -41,7 +41,9 @@ const getTileAdjacencies = (puzzle: PuzzleIR): TileAdjacency[] => { for (let row = 0; row <= puzzle.rows; row += 1) { for (let col = 0; col < puzzle.cols; col += 1) { const separatorLine = - row > 0 && row < puzzle.rows ? lineKey([row - 1, col], [row, col]) : null + row > 0 && row < puzzle.rows + ? lineKey([row - 1, col], [row, col]) + : null adjacencies.push({ left: tileKey(row, col), right: tileKey(row, col + 1), @@ -53,7 +55,9 @@ const getTileAdjacencies = (puzzle: PuzzleIR): TileAdjacency[] => { for (let row = 0; row < puzzle.rows; row += 1) { for (let col = 0; col <= puzzle.cols; col += 1) { const separatorLine = - col > 0 && col < puzzle.cols ? lineKey([row, col - 1], [row, col]) : null + col > 0 && col < puzzle.cols + ? lineKey([row, col - 1], [row, col]) + : null adjacencies.push({ left: tileKey(row, col), right: tileKey(row + 1, col), @@ -65,7 +69,10 @@ const getTileAdjacencies = (puzzle: PuzzleIR): TileAdjacency[] => { return adjacencies } -const getSeparatorMark = (puzzle: PuzzleIR, adjacency: TileAdjacency): LineMark | 'permanent-blank' => { +const getSeparatorMark = ( + puzzle: PuzzleIR, + adjacency: TileAdjacency, +): LineMark | 'permanent-blank' => { if (adjacency.separatorLine === null) { return 'permanent-blank' } @@ -74,7 +81,11 @@ const getSeparatorMark = (puzzle: PuzzleIR, adjacency: TileAdjacency): LineMark const findConnectivityTileColorUpdates = ( puzzle: PuzzleIR, - { target, includeOutsideSource, getEffectiveTileColor }: ConnectivityCutPassOptions, + { + target, + includeOutsideSource, + getEffectiveTileColor, + }: ConnectivityCutPassOptions, ): ConnectivityTileColorUpdate[] => { const blocked = oppositeMasyuTileColor(target) const parent = new Map() @@ -87,7 +98,8 @@ const findConnectivityTileColorUpdates = ( } } - const isCandidateTile = (key: string): boolean => getEffectiveTileColor(key) !== blocked + const isCandidateTile = (key: string): boolean => + getEffectiveTileColor(key) !== blocked const ensureNode = (key: string): void => { if (parent.has(key)) { @@ -219,7 +231,11 @@ const findConnectivityTileColorUpdates = ( const reachableComponents = new Set() let timestamp = 0 - const dfs = (node: string, parentNode: string | null, connectedNodes: string[]): void => { + const dfs = ( + node: string, + parentNode: string | null, + connectedNodes: string[], + ): void => { discovery.set(node, timestamp) low.set(node, timestamp) timestamp += 1 @@ -237,7 +253,10 @@ const findConnectivityTileColorUpdates = ( 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)) + 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)) @@ -262,7 +281,9 @@ const findConnectivityTileColorUpdates = ( } const connectedNodes: string[] = [] dfs(node, null, connectedNodes) - const totalSources = connectedNodes.filter((component) => sourceComponents.has(component)).length + const totalSources = connectedNodes.filter((component) => + sourceComponents.has(component), + ).length if (totalSources < 2) { continue } @@ -285,7 +306,11 @@ const findConnectivityTileColorUpdates = ( } for (const key of tiles) { if (getEffectiveTileColor(key) === null) { - updates.set(key, { tileKey: key, toFill: unreachableFill, reason: 'unreachable' }) + updates.set(key, { + tileKey: key, + toFill: unreachableFill, + reason: 'unreachable', + }) } } } diff --git a/src/domain/rules/masyu/rules/decisionCollector.ts b/src/domain/rules/masyu/rules/decisionCollector.ts new file mode 100644 index 0000000..5389ca6 --- /dev/null +++ b/src/domain/rules/masyu/rules/decisionCollector.ts @@ -0,0 +1,139 @@ +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { LineDiff, TileDiff } from '../../types' +import { + canMasyuLineBeAddedWithoutDegreeOverflow, + formatMasyuLineLabel, +} from './shared' + +export type MasyuTileColorDecision = 'green' | 'yellow' + +const isMasyuTileColorDecision = ( + fill: string | undefined, +): fill is MasyuTileColorDecision => fill === 'green' || fill === 'yellow' + +export type MasyuDecisionCollectorOptions = { + guardLineDegree?: boolean +} + +export type MasyuLineDecisionCollector = { + add: (lineKey: string, to: LineMark) => boolean + addNew: (lineKey: string, to: LineMark) => boolean + hasChanges: () => boolean + diffs: () => LineDiff[] + affectedLines: () => string[] + firstLine: () => string | null + firstLineLabel: () => string | null + decisions: ReadonlyMap +} + +export type MasyuTileDecisionCollector = { + add: (tileKey: string, toFill: MasyuTileColorDecision) => boolean + addNew: (tileKey: string, toFill: MasyuTileColorDecision) => boolean + hasChanges: () => boolean + diffs: () => TileDiff[] + affectedTiles: () => string[] + firstTile: () => string | null + decisions: ReadonlyMap +} + +export const createMasyuLineDecisionCollector = ( + puzzle: PuzzleIR, + options: MasyuDecisionCollectorOptions = {}, +): MasyuLineDecisionCollector => { + const decisions = new Map() + let first: string | null = null + + const add = (lineKeyValue: string, to: LineMark): boolean => { + if ( + to === 'line' && + options.guardLineDegree && + !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, lineKeyValue, decisions) + ) { + return false + } + const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + if (current === to) { + return true + } + if (current !== 'unknown') { + return false + } + const existing = decisions.get(lineKeyValue) + if (existing !== undefined) { + return existing === to + } + decisions.set(lineKeyValue, to) + first ??= lineKeyValue + return true + } + + const addNew = (lineKeyValue: string, to: LineMark): boolean => { + const beforeSize = decisions.size + return add(lineKeyValue, to) && decisions.size > beforeSize + } + + return { + add, + addNew, + hasChanges: () => decisions.size > 0, + diffs: () => + [...decisions.entries()].map(([lineKeyValue, to]) => ({ + kind: 'line' as const, + lineKey: lineKeyValue, + from: puzzle.lines[lineKeyValue]?.mark ?? 'unknown', + to, + })), + affectedLines: () => [...decisions.keys()], + firstLine: () => first, + firstLineLabel: () => (first ? formatMasyuLineLabel(first) : null), + decisions, + } +} + +export const createMasyuTileDecisionCollector = ( + puzzle: PuzzleIR, +): MasyuTileDecisionCollector => { + const decisions = new Map() + let first: string | null = null + + const add = ( + tileKeyValue: string, + toFill: MasyuTileColorDecision, + ): boolean => { + const currentFill = puzzle.tiles[tileKeyValue]?.fill + if (isMasyuTileColorDecision(currentFill)) { + return currentFill === toFill + } + const existing = decisions.get(tileKeyValue) + if (existing !== undefined) { + return existing === toFill + } + decisions.set(tileKeyValue, toFill) + first ??= tileKeyValue + return true + } + + const addNew = ( + tileKeyValue: string, + toFill: MasyuTileColorDecision, + ): boolean => { + const beforeSize = decisions.size + return add(tileKeyValue, toFill) && decisions.size > beforeSize + } + + return { + add, + addNew, + hasChanges: () => decisions.size > 0, + diffs: () => + [...decisions.entries()].map(([tileKeyValue, toFill]) => ({ + kind: 'tile' as const, + tileKey: tileKeyValue, + fromFill: (puzzle.tiles[tileKeyValue]?.fill ?? null) as string | null, + toFill, + })), + affectedTiles: () => [...decisions.keys()], + firstTile: () => first, + decisions, + } +} diff --git a/src/domain/rules/masyu/rules/lineGraph.ts b/src/domain/rules/masyu/rules/lineGraph.ts new file mode 100644 index 0000000..31a4a5d --- /dev/null +++ b/src/domain/rules/masyu/rules/lineGraph.ts @@ -0,0 +1,259 @@ +import { cellKey, parseLineKey } from '../../../ir/keys' +import type { CellCoord, LineMark, PuzzleIR } from '../../../ir/types' +import { getMasyuPearlKeys } from './pearlSelectors' +import { getMasyuCellLineDegree } from './shared' + +export type MasyuLineOverlay = ReadonlyMap + +export type MasyuCandidateEdge = { + lineKey: string + mark: LineMark + left: string + right: string +} + +export type MasyuCandidateGraph = { + cellKeys: string[] + edges: MasyuCandidateEdge[] + adjacency: Map +} + +export type MasyuKnownLineComponent = { + root: number + edgeCount: number + vertices: Set +} + +const getOverlayLineMark = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay, + lineKeyValue: string, +): LineMark => + overlay.get(lineKeyValue) ?? puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + +export const getMasyuEndpointKey = ([row, col]: CellCoord): string => + cellKey(row, col) + +export const getMasyuOtherEndpoint = ( + edge: MasyuCandidateEdge, + node: string, +): string => (edge.left === node ? edge.right : edge.left) + +export const getMasyuTouchedCells = ( + lineKeys: Iterable, +): Set => { + const cells = new Set() + for (const lineKeyValue of lineKeys) { + const [left, right] = parseLineKey(lineKeyValue) + cells.add(getMasyuEndpointKey(left)) + cells.add(getMasyuEndpointKey(right)) + } + return cells +} + +export const hasMasyuLineDegreeOverflow = ( + puzzle: PuzzleIR, + cellKeyValue: string, + decisions: ReadonlyMap = new Map(), +): boolean => getMasyuCellLineDegree(puzzle, cellKeyValue, decisions) > 2 + +export const buildMasyuLineUnion = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +) => { + const cellCount = puzzle.rows * puzzle.cols + const parent = Array.from({ length: cellCount }, (_, idx) => idx) + const rank = new Array(cellCount).fill(0) + const toCellIndex = (row: number, col: number): number => + row * puzzle.cols + col + const find = (idx: number): number => { + if (parent[idx] !== idx) { + parent[idx] = find(parent[idx]) + } + return parent[idx] + } + const union = (a: number, b: number): void => { + const rootA = find(a) + const rootB = find(b) + if (rootA === rootB) { + return + } + if (rank[rootA] < rank[rootB]) { + parent[rootA] = rootB + } else if (rank[rootA] > rank[rootB]) { + parent[rootB] = rootA + } else { + parent[rootB] = rootA + rank[rootA] += 1 + } + } + + const lineKeys = Object.keys(puzzle.lines).filter( + (lineKeyValue) => + getOverlayLineMark(puzzle, overlay, lineKeyValue) === 'line', + ) + for (const lineKeyValue of lineKeys) { + const [left, right] = parseLineKey(lineKeyValue) + union(toCellIndex(left[0], left[1]), toCellIndex(right[0], right[1])) + } + + return { find, lineKeys, toCellIndex } +} + +export const getMasyuKnownLineComponents = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +): MasyuKnownLineComponent[] => { + const { find, lineKeys, toCellIndex } = buildMasyuLineUnion(puzzle, overlay) + const components = new Map() + + for (const lineKeyValue of lineKeys) { + const [left, right] = parseLineKey(lineKeyValue) + const leftIndex = toCellIndex(left[0], left[1]) + const rightIndex = toCellIndex(right[0], right[1]) + const root = find(leftIndex) + const component = components.get(root) ?? { + root, + edgeCount: 0, + vertices: new Set(), + } + component.edgeCount += 1 + component.vertices.add(leftIndex) + component.vertices.add(rightIndex) + components.set(root, component) + } + + return [...components.values()] +} + +export const findMasyuPrematureLoopClosingLines = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +): string[] => { + const { find, lineKeys, toCellIndex } = buildMasyuLineUnion(puzzle, overlay) + const lineComponentRoots = new Set( + lineKeys.map((lineKeyValue) => { + const [left] = parseLineKey(lineKeyValue) + return find(toCellIndex(left[0], left[1])) + }), + ) + const closingLines: string[] = [] + + for (const lineKeyValue of Object.keys(puzzle.lines)) { + if (getOverlayLineMark(puzzle, overlay, lineKeyValue) !== 'unknown') { + continue + } + const [left, right] = parseLineKey(lineKeyValue) + const leftRoot = find(toCellIndex(left[0], left[1])) + const rightRoot = find(toCellIndex(right[0], right[1])) + if ( + leftRoot === rightRoot && + [...lineComponentRoots].some((root) => root !== leftRoot) + ) { + closingLines.push(lineKeyValue) + } + } + + return closingLines +} + +export const hasMasyuPrematureLoop = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +): boolean => { + const lineCount = buildMasyuLineUnion(puzzle, overlay).lineKeys.length + return getMasyuKnownLineComponents(puzzle, overlay).some( + (component) => + component.edgeCount >= component.vertices.size && + lineCount > component.edgeCount, + ) +} + +export const buildMasyuCandidateGraph = ( + puzzle: PuzzleIR, +): MasyuCandidateGraph => { + const cellKeys: string[] = [] + const adjacency = new Map() + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + cellKeys.push(key) + adjacency.set(key, []) + } + } + + const edges: MasyuCandidateEdge[] = [] + for (const lineKeyValue of Object.keys(puzzle.lines)) { + const mark = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + if (mark === 'blank') { + continue + } + const [leftCoord, rightCoord] = parseLineKey(lineKeyValue) + const edge = { + lineKey: lineKeyValue, + mark, + left: getMasyuEndpointKey(leftCoord), + right: getMasyuEndpointKey(rightCoord), + } + edges.push(edge) + adjacency.get(edge.left)?.push(edge) + adjacency.get(edge.right)?.push(edge) + } + + return { cellKeys, edges, adjacency } +} + +export const getMasyuRequiredSources = ( + puzzle: PuzzleIR, + graph: MasyuCandidateGraph, +): Set => { + const sources = new Set(getMasyuPearlKeys(puzzle)) + + for (const edge of graph.edges) { + if (edge.mark === 'line') { + sources.add(edge.left) + sources.add(edge.right) + } + } + + return sources +} + +export const getMasyuSourceComponentCount = ( + graph: MasyuCandidateGraph, + sources: ReadonlySet, +): number => { + const seen = new Set() + let sourceComponents = 0 + + for (const start of graph.cellKeys) { + if (seen.has(start)) { + continue + } + const stack = [start] + seen.add(start) + let hasSource = false + + while (stack.length > 0) { + const node = stack.pop() + if (node === undefined) { + continue + } + hasSource ||= sources.has(node) + for (const edge of graph.adjacency.get(node) ?? []) { + const neighbor = getMasyuOtherEndpoint(edge, node) + if (!seen.has(neighbor)) { + seen.add(neighbor) + stack.push(neighbor) + } + } + } + + if (hasSource) { + sourceComponents += 1 + } + } + + return sourceComponents +} diff --git a/src/domain/rules/masyu/rules/lookahead.ts b/src/domain/rules/masyu/rules/lookahead.ts index a17aba7..58b7a8e 100644 --- a/src/domain/rules/masyu/rules/lookahead.ts +++ b/src/domain/rules/masyu/rules/lookahead.ts @@ -2,77 +2,42 @@ import type { LineMark, PuzzleIR } from '../../../ir/types' import { areMasyuDirectionsOpposite, areMasyuDirectionsTurn, - MASYU_DIRECTIONS, type MasyuDirection, type MasyuDirectionalLine, } from './shared' -import type { MasyuLineOverlay } from './loop' +import type { MasyuLineOverlay } from './lineGraph' import { createMasyuLookaheadGeometry } from './lookaheadGeometry' - -export type BlackPearlCandidate = { - exits: [MasyuDirection, MasyuDirection] - lines: Set - exitLines: Set - extensionLines: Set - blanks: Set -} +import { + addMasyuOverlayDecision, + BLACK_CANDIDATE_EXIT_PAIRS, + buildMasyuBlackPearlCandidate, + buildMasyuWhitePearlCandidate, + masyuPearlCandidateToOverlay, + mergeMasyuLineOverlay, + WHITE_CANDIDATE_AXES, + type BlackPearlCandidate, + type WhitePearlCandidate, +} from './pearlCandidates' export type MasyuLookaheadContext = { getBlackPearlKeys: () => string[] - getIncidentEntries: (overlay: MasyuLineOverlay, key: string) => MasyuDirectionalLine[] + getWhitePearlKeys: () => string[] + getIncidentEntries: ( + overlay: MasyuLineOverlay, + key: string, + ) => MasyuDirectionalLine[] getFeasibleBlackPearlCandidates: (pearlKey: string) => BlackPearlCandidate[] + getFeasibleWhitePearlCandidates: (pearlKey: string) => WhitePearlCandidate[] } -const BLACK_CANDIDATE_EXIT_PAIRS: [MasyuDirection, MasyuDirection][] = [ - ['N', 'E'], - ['N', 'W'], - ['S', 'E'], - ['S', 'W'], -] - -const WHITE_CANDIDATE_AXES: [MasyuDirection, MasyuDirection][] = [ - ['N', 'S'], - ['E', 'W'], -] - -const addOverlayDecision = (overlay: Map, lineKey: string, mark: LineMark): boolean => { - const existing = overlay.get(lineKey) - if (existing !== undefined) { - return existing === mark - } - overlay.set(lineKey, mark) - return true -} - -const candidateToOverlay = (candidate: BlackPearlCandidate): Map | null => { - const overlay = new Map() - for (const lineKeyValue of candidate.lines) { - if (!addOverlayDecision(overlay, lineKeyValue, 'line')) { - return null - } - } - for (const lineKeyValue of candidate.blanks) { - if (!addOverlayDecision(overlay, lineKeyValue, 'blank')) { - return null - } - } - return overlay -} - -const mergeOverlay = (base: MasyuLineOverlay, next: MasyuLineOverlay): Map | null => { - const merged = new Map(base) - for (const [lineKeyValue, mark] of next.entries()) { - if (!addOverlayDecision(merged, lineKeyValue, mark)) { - return null - } - } - return merged -} - -export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadContext => { +export const createMasyuLookaheadContext = ( + puzzle: PuzzleIR, +): MasyuLookaheadContext => { const geometry = createMasyuLookaheadGeometry(puzzle) - const isOverlayConsistentWithPuzzle = (overlay: MasyuLineOverlay): boolean => { + const isOverlayConsistentWithPuzzle = ( + overlay: MasyuLineOverlay, + ): boolean => { for (const [lineKeyValue, mark] of overlay.entries()) { const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' if (current !== 'unknown' && current !== mark) { @@ -83,17 +48,24 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon } const isCellDegreeValid = (overlay: MasyuLineOverlay, key: string): boolean => - geometry.getIncidentEntries(overlay, key).filter((item) => item.mark === 'line').length <= 2 + geometry + .getIncidentEntries(overlay, key) + .filter((item) => item.mark === 'line').length <= 2 - const canApplyLocalDecisions = (overlay: MasyuLineOverlay, decisions: MasyuLineOverlay): boolean => { - const merged = mergeOverlay(overlay, decisions) + const canApplyLocalDecisions = ( + overlay: MasyuLineOverlay, + decisions: MasyuLineOverlay, + ): boolean => { + const merged = mergeMasyuLineOverlay(overlay, decisions) if (!merged || !isOverlayConsistentWithPuzzle(merged)) { return false } for (const key of geometry.getTouchedCells(decisions.keys())) { if ( !isCellDegreeValid(merged, key) || - !isPearlShapeStillPossible(merged, key, { checkWhiteAdjacentTurn: false }) + !isPearlShapeStillPossible(merged, key, { + checkWhiteAdjacentTurn: false, + }) ) { return false } @@ -113,19 +85,24 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon if (second && geometry.getLineMark(overlay, second.lineKey) === 'line') { return false } - return geometry.getTurnCandidates(first.neighborKey, direction).some((line) => { - if (geometry.getLineMark(overlay, line.lineKey) === 'blank') { - return false - } - const turnOverlay = new Map() - if (!addOverlayDecision(turnOverlay, line.lineKey, 'line')) { - return false - } - if (second && !addOverlayDecision(turnOverlay, second.lineKey, 'blank')) { - return false - } - return canApplyLocalDecisions(overlay, turnOverlay) - }) + return geometry + .getTurnCandidates(first.neighborKey, direction) + .some((line) => { + if (geometry.getLineMark(overlay, line.lineKey) === 'blank') { + return false + } + const turnOverlay = new Map() + if (!addMasyuOverlayDecision(turnOverlay, line.lineKey, 'line')) { + return false + } + if ( + second && + !addMasyuOverlayDecision(turnOverlay, second.lineKey, 'blank') + ) { + return false + } + return canApplyLocalDecisions(overlay, turnOverlay) + }) } const isPearlShapeStillPossible = ( @@ -137,7 +114,9 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon if (!color) { return true } - const lineEntries = geometry.getIncidentEntries(overlay, key).filter((item) => item.mark === 'line') + const lineEntries = geometry + .getIncidentEntries(overlay, key) + .filter((item) => item.mark === 'line') if (lineEntries.length > 2) { return false } @@ -151,7 +130,10 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon } return [left.direction, right.direction].every((direction) => { const extension = geometry.getTwoStep(key, direction).second - return extension !== null && geometry.getLineMark(overlay, extension.lineKey) !== 'blank' + return ( + extension !== null && + geometry.getLineMark(overlay, extension.lineKey) !== 'blank' + ) }) } if (!areMasyuDirectionsOpposite(left.direction, right.direction)) { @@ -160,92 +142,61 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon if (options.checkWhiteAdjacentTurn === false) { return true } - return [left.direction, right.direction].some((direction) => canWhiteSideTurn(overlay, key, direction)) - } - - const buildBlackPearlCandidate = ( - pearlKey: string, - exits: [MasyuDirection, MasyuDirection], - ): BlackPearlCandidate | null => { - const candidate: BlackPearlCandidate = { - exits, - lines: new Set(), - exitLines: new Set(), - extensionLines: new Set(), - blanks: new Set(), - } - - for (const direction of exits) { - const { first, second } = geometry.getTwoStep(pearlKey, direction) - if (!first || !second) { - return null - } - candidate.lines.add(first.lineKey) - candidate.lines.add(second.lineKey) - candidate.exitLines.add(first.lineKey) - candidate.extensionLines.add(second.lineKey) - } - - const selected = new Set(exits) - const incident = geometry.getIncident(pearlKey) - for (const direction of MASYU_DIRECTIONS) { - if (selected.has(direction)) { - continue - } - const line = incident[direction] - if (line) { - candidate.blanks.add(line.lineKey) - } - } - - return candidate + return [left.direction, right.direction].some((direction) => + canWhiteSideTurn(overlay, key, direction), + ) } - const hasAnyBlackPearlCandidate = (overlay: MasyuLineOverlay, key: string): boolean => + const hasAnyBlackPearlCandidate = ( + overlay: MasyuLineOverlay, + key: string, + ): boolean => BLACK_CANDIDATE_EXIT_PAIRS.some((exits) => { - const candidate = buildBlackPearlCandidate(key, exits) - const candidateOverlay = candidate ? candidateToOverlay(candidate) : null - return candidateOverlay !== null && canApplyLocalDecisions(overlay, candidateOverlay) + const candidate = buildMasyuBlackPearlCandidate( + exits, + (direction) => geometry.getTwoStep(key, direction), + () => geometry.getIncident(key), + ) + const candidateOverlay = candidate + ? masyuPearlCandidateToOverlay(candidate) + : null + return ( + candidateOverlay !== null && + canApplyLocalDecisions(overlay, candidateOverlay) + ) }) - const buildWhiteAxisOverlay = ( + const hasAnyWhitePearlCandidate = ( + overlay: MasyuLineOverlay, key: string, - axis: [MasyuDirection, MasyuDirection], - ): Map | null => { - const overlay = new Map() - const selected = new Set(axis) - const incident = geometry.getIncident(key) - for (const direction of axis) { - const line = incident[direction] - if (!line || !addOverlayDecision(overlay, line.lineKey, 'line')) { - return null - } - } - for (const direction of MASYU_DIRECTIONS) { - if (selected.has(direction)) { - continue - } - const line = incident[direction] - if (line && !addOverlayDecision(overlay, line.lineKey, 'blank')) { - return null - } - } - return overlay - } - - const hasAnyWhitePearlCandidate = (overlay: MasyuLineOverlay, key: string): boolean => + ): boolean => WHITE_CANDIDATE_AXES.some((axis) => { - const axisOverlay = buildWhiteAxisOverlay(key, axis) - const merged = axisOverlay ? mergeOverlay(overlay, axisOverlay) : null + const candidate = buildMasyuWhitePearlCandidate( + axis, + () => geometry.getIncident(key), + (direction, axisOverlay) => + canWhiteSideTurn(axisOverlay, key, direction), + ) + const axisOverlay = candidate + ? masyuPearlCandidateToOverlay(candidate) + : null + const merged = axisOverlay + ? mergeMasyuLineOverlay(overlay, axisOverlay) + : null return ( axisOverlay !== null && merged !== null && canApplyLocalDecisions(overlay, axisOverlay) && + candidate !== null && + candidate.turnableSides.size > 0 && axis.some((direction) => canWhiteSideTurn(merged, key, direction)) ) }) - const areAffectedPearlsStillPossible = (overlay: MasyuLineOverlay, centerKey: string): boolean => { + const areAffectedPearlsStillPossible = ( + overlay: MasyuLineOverlay, + centerKey: string, + ): boolean => { const affected = geometry.getAffectedPearls(overlay) affected.add(centerKey) for (const key of affected) { @@ -254,7 +205,9 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon continue } const possible = - color === 'black' ? hasAnyBlackPearlCandidate(overlay, key) : hasAnyWhitePearlCandidate(overlay, key) + color === 'black' + ? hasAnyBlackPearlCandidate(overlay, key) + : hasAnyWhitePearlCandidate(overlay, key) if (!possible) { return false } @@ -262,7 +215,9 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon return true } - const wouldCreatePrematureLoop = (assumedLineEndpoints: Iterable<[left: number, right: number]>): boolean => { + const wouldCreatePrematureLoop = ( + assumedLineEndpoints: Iterable<[left: number, right: number]>, + ): boolean => { const parent = new Map() const lineCounts = new Map() const find = (root: number): number => { @@ -286,7 +241,10 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon return } parent.set(right, left) - lineCounts.set(left, (lineCounts.get(left) ?? 0) + (lineCounts.get(right) ?? 0)) + lineCounts.set( + left, + (lineCounts.get(left) ?? 0) + (lineCounts.get(right) ?? 0), + ) } for (const [left, right] of assumedLineEndpoints) { @@ -300,7 +258,9 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon return false } - const hasPrematureLoopFromCandidate = (candidate: BlackPearlCandidate): boolean => { + const hasPrematureLoopFromCandidate = ( + candidate: BlackPearlCandidate, + ): boolean => { const assumedLineEndpoints: Array<[left: number, right: number]> = [] for (const lineKeyValue of candidate.lines) { if ((puzzle.lines[lineKeyValue]?.mark ?? 'unknown') === 'line') { @@ -308,16 +268,28 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon } assumedLineEndpoints.push(geometry.lineEndpoints(lineKeyValue)) } - return assumedLineEndpoints.length > 0 && wouldCreatePrematureLoop(assumedLineEndpoints) + return ( + assumedLineEndpoints.length > 0 && + wouldCreatePrematureLoop(assumedLineEndpoints) + ) } - const isBlackPearlCandidateFeasible = (pearlKey: string, candidate: BlackPearlCandidate): boolean => { - const overlay = candidateToOverlay(candidate) + const isBlackPearlCandidateFeasible = ( + pearlKey: string, + candidate: BlackPearlCandidate, + ): boolean => { + const overlay = masyuPearlCandidateToOverlay(candidate) if (!overlay || !isOverlayConsistentWithPuzzle(overlay)) { return false } - for (const key of geometry.getTouchedCells([...candidate.lines, ...candidate.blanks])) { - if (!isCellDegreeValid(overlay, key) || !isPearlShapeStillPossible(overlay, key)) { + for (const key of geometry.getTouchedCells([ + ...candidate.lines, + ...candidate.blanks, + ])) { + if ( + !isCellDegreeValid(overlay, key) || + !isPearlShapeStillPossible(overlay, key) + ) { return false } } @@ -327,20 +299,71 @@ export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadCon return areAffectedPearlsStillPossible(overlay, pearlKey) } - const getFeasibleBlackPearlCandidates = (pearlKey: string): BlackPearlCandidate[] => { - const incidentLines = geometry.getIncidentEntries(new Map(), pearlKey).filter((item) => item.mark === 'line') + const getFeasibleBlackPearlCandidates = ( + pearlKey: string, + ): BlackPearlCandidate[] => { + const incidentLines = geometry + .getIncidentEntries(new Map(), pearlKey) + .filter((item) => item.mark === 'line') if (incidentLines.length >= 2) { return [] } return BLACK_CANDIDATE_EXIT_PAIRS.flatMap((exits) => { - const candidate = buildBlackPearlCandidate(pearlKey, exits) - return candidate && isBlackPearlCandidateFeasible(pearlKey, candidate) ? [candidate] : [] + const candidate = buildMasyuBlackPearlCandidate( + exits, + (direction) => geometry.getTwoStep(pearlKey, direction), + () => geometry.getIncident(pearlKey), + ) + return candidate && isBlackPearlCandidateFeasible(pearlKey, candidate) + ? [candidate] + : [] + }) + } + + const isWhitePearlCandidateFeasible = ( + pearlKey: string, + candidate: WhitePearlCandidate, + ): boolean => { + const overlay = masyuPearlCandidateToOverlay(candidate) + if (!overlay || !isOverlayConsistentWithPuzzle(overlay)) { + return false + } + if ( + !canApplyLocalDecisions(new Map(), overlay) || + candidate.turnableSides.size === 0 + ) { + return false + } + return areAffectedPearlsStillPossible(overlay, pearlKey) + } + + const getFeasibleWhitePearlCandidates = ( + pearlKey: string, + ): WhitePearlCandidate[] => { + const incidentLines = geometry + .getIncidentEntries(new Map(), pearlKey) + .filter((item) => item.mark === 'line') + if (incidentLines.length >= 2) { + return [] + } + return WHITE_CANDIDATE_AXES.flatMap((axis) => { + const candidate = buildMasyuWhitePearlCandidate( + axis, + () => geometry.getIncident(pearlKey), + (direction, axisOverlay) => + canWhiteSideTurn(axisOverlay, pearlKey, direction), + ) + return candidate && isWhitePearlCandidateFeasible(pearlKey, candidate) + ? [candidate] + : [] }) } return { getBlackPearlKeys: () => geometry.blackPearlKeys, + getWhitePearlKeys: () => geometry.whitePearlKeys, getIncidentEntries: geometry.getIncidentEntries, getFeasibleBlackPearlCandidates, + getFeasibleWhitePearlCandidates, } } diff --git a/src/domain/rules/masyu/rules/lookaheadGeometry.ts b/src/domain/rules/masyu/rules/lookaheadGeometry.ts index 718f7e9..c5f848f 100644 --- a/src/domain/rules/masyu/rules/lookaheadGeometry.ts +++ b/src/domain/rules/masyu/rules/lookaheadGeometry.ts @@ -9,32 +9,53 @@ import { type MasyuDirectionalLine, type MasyuTwoStepLine, } from './shared' -import type { MasyuLineOverlay } from './loop' +import type { MasyuLineOverlay } from './lineGraph' type PearlColor = 'white' | 'black' export type MasyuLookaheadGeometry = { blackPearlKeys: string[] + whitePearlKeys: string[] pearlColors: Map baseLineCounts: Map totalBaseLineCount: number findBase: (idx: number) => number lineEndpoints: (lineKeyValue: string) => [left: number, right: number] - getIncident: (key: string) => Record + getIncident: ( + key: string, + ) => Record getTwoStep: (key: string, direction: MasyuDirection) => MasyuTwoStepLine - getTurnCandidates: (key: string, throughDirection: MasyuDirection) => MasyuDirectionalLine[] + getTurnCandidates: ( + key: string, + throughDirection: MasyuDirection, + ) => MasyuDirectionalLine[] getLineMark: (overlay: MasyuLineOverlay, key: string) => LineMark - getIncidentEntries: (overlay: MasyuLineOverlay, key: string) => MasyuDirectionalLine[] + getIncidentEntries: ( + overlay: MasyuLineOverlay, + key: string, + ) => MasyuDirectionalLine[] getTouchedCells: (lineKeys: Iterable) => Set getAffectedPearls: (overlay: MasyuLineOverlay) => Set } -export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGeometry => { +export const createMasyuLookaheadGeometry = ( + puzzle: PuzzleIR, +): MasyuLookaheadGeometry => { const blackPearlKeys: string[] = [] + const whitePearlKeys: string[] = [] const pearlColors = new Map() - const incidentCache = new Map>() - const twoStepCache = new Map>() - const turnCandidateCache = new Map>() + const incidentCache = new Map< + string, + Record + >() + const twoStepCache = new Map< + string, + Record + >() + const turnCandidateCache = new Map< + string, + Record + >() const lineEndpointCache = new Map() const lineCellCache = new Map() const dependencyCellPearls = new Map>() @@ -45,7 +66,8 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe const baseLineCounts = new Map() let totalBaseLineCount = 0 - const toCellIndex = (row: number, col: number): number => row * puzzle.cols + col + const toCellIndex = (row: number, col: number): number => + row * puzzle.cols + col const findBase = (idx: number): number => { if (baseParent[idx] !== idx) { @@ -70,15 +92,23 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe } } - const lineEndpoints = (lineKeyValue: string): [left: number, right: number] => { + const lineEndpoints = ( + lineKeyValue: string, + ): [left: number, right: number] => { const cached = lineEndpointCache.get(lineKeyValue) if (cached) { return cached } const [left, right] = parseLineKey(lineKeyValue) - const endpoints: [number, number] = [toCellIndex(left[0], left[1]), toCellIndex(right[0], right[1])] + const endpoints: [number, number] = [ + toCellIndex(left[0], left[1]), + toCellIndex(right[0], right[1]), + ] lineEndpointCache.set(lineKeyValue, endpoints) - lineCellCache.set(lineKeyValue, [cellKey(left[0], left[1]), cellKey(right[0], right[1])]) + lineCellCache.set(lineKeyValue, [ + cellKey(left[0], left[1]), + cellKey(right[0], right[1]), + ]) return endpoints } @@ -95,13 +125,19 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe return cells } - const addDependency = (index: Map>, key: string, pearlKey: string): void => { + const addDependency = ( + index: Map>, + key: string, + pearlKey: string, + ): void => { const set = index.get(key) ?? new Set() set.add(pearlKey) index.set(key, set) } - const getIncident = (key: string): Record => { + const getIncident = ( + key: string, + ): Record => { const cached = incidentCache.get(key) if (cached) { return cached @@ -111,7 +147,10 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe return incident } - const getTwoStep = (key: string, direction: MasyuDirection): MasyuTwoStepLine => { + const getTwoStep = ( + key: string, + direction: MasyuDirection, + ): MasyuTwoStepLine => { const cached = twoStepCache.get(key) if (cached) { return cached[direction] @@ -126,7 +165,10 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe return twoStep[direction] } - const getTurnCandidates = (key: string, throughDirection: MasyuDirection): MasyuDirectionalLine[] => { + const getTurnCandidates = ( + key: string, + throughDirection: MasyuDirection, + ): MasyuDirectionalLine[] => { const cached = turnCandidateCache.get(key) if (cached) { return cached[throughDirection] @@ -142,8 +184,10 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe } const registerPearlDependencies = (pearlKey: string): void => { - const addCell = (key: string): void => addDependency(dependencyCellPearls, key, pearlKey) - const addLine = (key: string): void => addDependency(dependencyLinePearls, key, pearlKey) + const addCell = (key: string): void => + addDependency(dependencyCellPearls, key, pearlKey) + const addLine = (key: string): void => + addDependency(dependencyLinePearls, key, pearlKey) addCell(pearlKey) for (const direction of MASYU_DIRECTIONS) { const { first, second } = getTwoStep(pearlKey, direction) @@ -195,6 +239,8 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe pearlColors.set(key, clue.color) if (clue.color === 'black') { blackPearlKeys.push(key) + } else { + whitePearlKeys.push(key) } registerPearlDependencies(key) } @@ -202,7 +248,10 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe const getLineMark = (overlay: MasyuLineOverlay, key: string): LineMark => overlay.get(key) ?? puzzle.lines[key]?.mark ?? 'unknown' - const getIncidentEntries = (overlay: MasyuLineOverlay, key: string): MasyuDirectionalLine[] => { + const getIncidentEntries = ( + overlay: MasyuLineOverlay, + key: string, + ): MasyuDirectionalLine[] => { const incident = getIncident(key) return MASYU_DIRECTIONS.flatMap((direction) => { const item = incident[direction] @@ -231,7 +280,12 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe [row, col + 1], ] for (const [cellRow, cellCol] of cells) { - if (cellRow < 0 || cellRow >= puzzle.rows || cellCol < 0 || cellCol >= puzzle.cols) { + if ( + cellRow < 0 || + cellRow >= puzzle.rows || + cellCol < 0 || + cellCol >= puzzle.cols + ) { continue } const pearlKey = cellKey(cellRow, cellCol) @@ -263,6 +317,7 @@ export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGe return { blackPearlKeys, + whitePearlKeys, pearlColors, baseLineCounts, totalBaseLineCount, diff --git a/src/domain/rules/masyu/rules/loop.ts b/src/domain/rules/masyu/rules/loop.ts index 94cf64a..ad639e3 100644 --- a/src/domain/rules/masyu/rules/loop.ts +++ b/src/domain/rules/masyu/rules/loop.ts @@ -1,128 +1,31 @@ -import { parseLineKey } from '../../../ir/keys' -import type { LineMark, PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' -import { buildMasyuLineDiffs, formatMasyuLineLabel } from './shared' +import { createMasyuLineDecisionCollector } from './decisionCollector' +import { + findMasyuPrematureLoopClosingLines, + hasMasyuPrematureLoop, + type MasyuLineOverlay, +} from './lineGraph' -export type MasyuLineOverlay = ReadonlyMap +export type { MasyuLineOverlay } -const getOverlayLineMark = (puzzle: PuzzleIR, overlay: MasyuLineOverlay, lineKey: string): LineMark => - overlay.get(lineKey) ?? puzzle.lines[lineKey]?.mark ?? 'unknown' - -const buildMasyuLineUnion = (puzzle: PuzzleIR, overlay: MasyuLineOverlay = new Map()) => { - const cellCount = puzzle.rows * puzzle.cols - const parent = Array.from({ length: cellCount }, (_, idx) => idx) - const rank = new Array(cellCount).fill(0) - const toCellIndex = (row: number, col: number): number => row * puzzle.cols + col - const find = (idx: number): number => { - if (parent[idx] !== idx) { - parent[idx] = find(parent[idx]) - } - return parent[idx] - } - const union = (a: number, b: number): void => { - const rootA = find(a) - const rootB = find(b) - if (rootA === rootB) { - return - } - if (rank[rootA] < rank[rootB]) { - parent[rootA] = rootB - } else if (rank[rootA] > rank[rootB]) { - parent[rootB] = rootA - } else { - parent[rootB] = rootA - rank[rootA] += 1 - } - } - - const lineKeys = Object.keys(puzzle.lines).filter((lineKeyValue) => getOverlayLineMark(puzzle, overlay, lineKeyValue) === 'line') - for (const lineKeyValue of lineKeys) { - const [left, right] = parseLineKey(lineKeyValue) - union(toCellIndex(left[0], left[1]), toCellIndex(right[0], right[1])) - } - - return { find, lineKeys, toCellIndex } -} - -export const findMasyuPrematureLoopClosingLines = ( - puzzle: PuzzleIR, - overlay: MasyuLineOverlay = new Map(), -): string[] => { - const { find, lineKeys, toCellIndex } = buildMasyuLineUnion(puzzle, overlay) - const lineComponentRoots = new Set( - lineKeys.map((lineKeyValue) => { - const [left] = parseLineKey(lineKeyValue) - return find(toCellIndex(left[0], left[1])) - }), - ) - const closingLines: string[] = [] - - for (const lineKeyValue of Object.keys(puzzle.lines)) { - if (getOverlayLineMark(puzzle, overlay, lineKeyValue) !== 'unknown') { - continue - } - const [left, right] = parseLineKey(lineKeyValue) - const leftRoot = find(toCellIndex(left[0], left[1])) - const rightRoot = find(toCellIndex(right[0], right[1])) - if (leftRoot !== rightRoot) { - continue - } - if (![...lineComponentRoots].some((root) => root !== leftRoot)) { - continue - } - closingLines.push(lineKeyValue) - } - - return closingLines -} - -export const hasMasyuPrematureLoop = ( - puzzle: PuzzleIR, - overlay: MasyuLineOverlay = new Map(), -): boolean => { - const { find, lineKeys, toCellIndex } = buildMasyuLineUnion(puzzle, overlay) - const components = new Map }>() - - for (const lineKeyValue of lineKeys) { - const [left, right] = parseLineKey(lineKeyValue) - const leftIndex = toCellIndex(left[0], left[1]) - const rightIndex = toCellIndex(right[0], right[1]) - const root = find(leftIndex) - const component = components.get(root) ?? { edgeCount: 0, vertices: new Set() } - component.edgeCount += 1 - component.vertices.add(leftIndex) - component.vertices.add(rightIndex) - components.set(root, component) - } - - for (const component of components.values()) { - if (component.edgeCount >= component.vertices.size && lineKeys.length > component.edgeCount) { - return true - } - } - - return false -} +export { findMasyuPrematureLoopClosingLines, hasMasyuPrematureLoop } export const createPreventPrematureLoopRule = (): Rule => ({ id: 'masyu-prevent-premature-loop', name: 'Prevent Premature Loop', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() - let firstExample: string | null = null + apply: (puzzle): RuleApplication | null => { + const decisions = createMasyuLineDecisionCollector(puzzle) for (const lineKeyValue of findMasyuPrematureLoopClosingLines(puzzle)) { - decisions.set(lineKeyValue, 'blank') - if (firstExample === null) { - firstExample = formatMasyuLineLabel(lineKeyValue) - } + decisions.add(lineKeyValue, 'blank') } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() + const firstExample = decisions.firstLineLabel() return { message: firstExample !== null @@ -130,7 +33,7 @@ export const createPreventPrematureLoopRule = (): Rule => ({ : 'Lines that would close a smaller loop while other lines remain outside it are blank.', diffs, affectedCells: [], - affectedLines: diffs.map((diff) => diff.lineKey), + affectedLines: decisions.affectedLines(), } }, }) diff --git a/src/domain/rules/masyu/rules/patterns.ts b/src/domain/rules/masyu/rules/patterns.ts index 204f0ac..80f2524 100644 --- a/src/domain/rules/masyu/rules/patterns.ts +++ b/src/domain/rules/masyu/rules/patterns.ts @@ -1,10 +1,13 @@ import { cellKey, parseCellKey } from '../../../ir/keys' -import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' +import { + createMasyuLineDecisionCollector, + type MasyuLineDecisionCollector, +} from './decisionCollector' +import { getMasyuBlackPearlKeys, isMasyuPearl } from './pearlSelectors' import { MASYU_DIRECTIONS, - buildMasyuLineDiffs, - collectMasyuLineDecision, formatMasyuCellKeyLabel, formatMasyuLineLabel, getMasyuDirectionOffset, @@ -26,48 +29,52 @@ const offsetCellKey = ( const [row, col] = parseCellKey(originKey) const targetRow = row + rowDelta * distance const targetCol = col + colDelta * distance - return isInBounds(puzzle, targetRow, targetCol) ? cellKey(targetRow, targetCol) : null -} - -const getPearlCellKeys = (puzzle: PuzzleIR, color: 'white' | 'black'): string[] => - Object.entries(puzzle.cells).flatMap(([key, cell]) => - cell.clue?.kind === 'pearl' && cell.clue.color === color ? [key] : [], - ) - -const isPearl = (puzzle: PuzzleIR, key: string | null, color: 'white' | 'black'): boolean => - key !== null && puzzle.cells[key]?.clue?.kind === 'pearl' && puzzle.cells[key]?.clue?.color === color - -const rememberLine = ( - decisions: Map, - puzzle: PuzzleIR, - lineKey: string, - mark: LineMark, -): boolean => { - const beforeSize = decisions.size - return collectMasyuLineDecision(decisions, puzzle, lineKey, mark) && decisions.size > beforeSize + return isInBounds(puzzle, targetRow, targetCol) + ? cellKey(targetRow, targetCol) + : null } export const createBlackFacingConsecutiveWhitesRule = (): Rule => ({ id: 'masyu-black-facing-consecutive-whites', name: 'Black Facing Consecutive Whites', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstPearl: string | null = null let firstLine: string | null = null - for (const pearlKey of getPearlCellKeys(puzzle, 'black')) { + for (const pearlKey of getMasyuBlackPearlKeys(puzzle)) { for (const direction of MASYU_DIRECTIONS) { const [rowDelta, colDelta] = getMasyuDirectionOffset(direction) const gap = offsetCellKey(puzzle, pearlKey, rowDelta, colDelta) - const firstWhite = offsetCellKey(puzzle, pearlKey, rowDelta, colDelta, 2) - const secondWhite = offsetCellKey(puzzle, pearlKey, rowDelta, colDelta, 3) - if (!gap || !isPearl(puzzle, firstWhite, 'white') || !isPearl(puzzle, secondWhite, 'white')) { + const firstWhite = offsetCellKey( + puzzle, + pearlKey, + rowDelta, + colDelta, + 2, + ) + const secondWhite = offsetCellKey( + puzzle, + pearlKey, + rowDelta, + colDelta, + 3, + ) + if ( + !gap || + !isMasyuPearl(puzzle, firstWhite, 'white') || + !isMasyuPearl(puzzle, secondWhite, 'white') + ) { continue } - const forced = getMasyuDirectionalLine(puzzle, pearlKey, oppositeMasyuDirection(direction)) - if (!forced || !rememberLine(decisions, puzzle, forced.lineKey, 'line')) { + const forced = getMasyuDirectionalLine( + puzzle, + pearlKey, + oppositeMasyuDirection(direction), + ) + if (!forced || !decisions.addNew(forced.lineKey, 'line')) { continue } affectedCells.add(pearlKey) @@ -78,11 +85,11 @@ export const createBlackFacingConsecutiveWhitesRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstPearl && firstLine @@ -112,7 +119,12 @@ const getSideDiagonalKeys = ( [1, 0], ] return perpendicularOffsets.map(([rowDelta, colDelta]) => - offsetCellKey(puzzle, originKey, sideRowDelta + rowDelta, sideColDelta + colDelta), + offsetCellKey( + puzzle, + originKey, + sideRowDelta + rowDelta, + sideColDelta + colDelta, + ), ) as [string | null, string | null] } @@ -120,20 +132,31 @@ export const createBlackDiagonalWhitePinchRule = (): Rule => ({ id: 'masyu-black-diagonal-white-pinch', name: 'Black Diagonal White Pinch', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstPearl: string | null = null let firstLine: string | null = null - for (const pearlKey of getPearlCellKeys(puzzle, 'black')) { + for (const pearlKey of getMasyuBlackPearlKeys(puzzle)) { for (const side of MASYU_DIRECTIONS) { - const [leftDiagonal, rightDiagonal] = getSideDiagonalKeys(puzzle, pearlKey, side) - if (!isPearl(puzzle, leftDiagonal, 'white') || !isPearl(puzzle, rightDiagonal, 'white')) { + const [leftDiagonal, rightDiagonal] = getSideDiagonalKeys( + puzzle, + pearlKey, + side, + ) + if ( + !isMasyuPearl(puzzle, leftDiagonal, 'white') || + !isMasyuPearl(puzzle, rightDiagonal, 'white') + ) { continue } - const forced = getMasyuDirectionalLine(puzzle, pearlKey, oppositeMasyuDirection(side)) - if (!forced || !rememberLine(decisions, puzzle, forced.lineKey, 'line')) { + const forced = getMasyuDirectionalLine( + puzzle, + pearlKey, + oppositeMasyuDirection(side), + ) + if (!forced || !decisions.addNew(forced.lineKey, 'line')) { continue } affectedCells.add(pearlKey) @@ -144,11 +167,11 @@ export const createBlackDiagonalWhitePinchRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstPearl && firstLine @@ -173,7 +196,7 @@ const collectWhitePearlRun = ( let col = startCol while (isInBounds(puzzle, row, col)) { const key = cellKey(row, col) - if (!isPearl(puzzle, key, 'white')) { + if (!isMasyuPearl(puzzle, key, 'white')) { break } run.push(key) @@ -185,7 +208,7 @@ const collectWhitePearlRun = ( const forceWhitePearlRunLines = ( puzzle: PuzzleIR, - decisions: Map, + decisions: MasyuLineDecisionCollector, affectedCells: Set, run: string[], forcedDirections: [MasyuDirection, MasyuDirection], @@ -195,7 +218,7 @@ const forceWhitePearlRunLines = ( let addedForCell = false for (const direction of forcedDirections) { const line = getMasyuDirectionalLine(puzzle, pearlKey, direction) - if (!line || !rememberLine(decisions, puzzle, line.lineKey, 'line')) { + if (!line || !decisions.addNew(line.lineKey, 'line')) { continue } addedForCell = true @@ -214,7 +237,7 @@ export const createConsecutiveWhitePearlsStraightRule = (): Rule => ({ id: 'masyu-consecutive-white-pearls-straight', name: 'Consecutive White Pearls Straight', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstPearl: string | null = null let firstLine: string | null = null @@ -224,7 +247,13 @@ export const createConsecutiveWhitePearlsStraightRule = (): Rule => ({ while (col < puzzle.cols) { const run = collectWhitePearlRun(puzzle, row, col, 0, 1) if (run.length >= 3) { - const line = forceWhitePearlRunLines(puzzle, decisions, affectedCells, run, ['N', 'S']) + const line = forceWhitePearlRunLines( + puzzle, + decisions, + affectedCells, + run, + ['N', 'S'], + ) if (firstPearl === null && line) { firstPearl = run[0] firstLine = line @@ -239,7 +268,13 @@ export const createConsecutiveWhitePearlsStraightRule = (): Rule => ({ while (row < puzzle.rows) { const run = collectWhitePearlRun(puzzle, row, col, 1, 0) if (run.length >= 3) { - const line = forceWhitePearlRunLines(puzzle, decisions, affectedCells, run, ['E', 'W']) + const line = forceWhitePearlRunLines( + puzzle, + decisions, + affectedCells, + run, + ['E', 'W'], + ) if (firstPearl === null && line) { firstPearl = run[0] firstLine = line @@ -249,11 +284,11 @@ export const createConsecutiveWhitePearlsStraightRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstPearl && firstLine @@ -282,20 +317,28 @@ const squeezeAxes: { const collectDoubleBlackSqueeze = ( puzzle: PuzzleIR, - decisions: Map, + decisions: MasyuLineDecisionCollector, middleKey: string, perpendicularDirections: [MasyuDirection, MasyuDirection], ): string | null => { - const first = getMasyuDirectionalLine(puzzle, middleKey, perpendicularDirections[0]) - const second = getMasyuDirectionalLine(puzzle, middleKey, perpendicularDirections[1]) + const first = getMasyuDirectionalLine( + puzzle, + middleKey, + perpendicularDirections[0], + ) + const second = getMasyuDirectionalLine( + puzzle, + middleKey, + perpendicularDirections[1], + ) if (!first || !second) { return null } if (first.mark === 'blank') { - return rememberLine(decisions, puzzle, second.lineKey, 'blank') ? second.lineKey : null + return decisions.addNew(second.lineKey, 'blank') ? second.lineKey : null } if (second.mark === 'blank') { - return rememberLine(decisions, puzzle, first.lineKey, 'blank') ? first.lineKey : null + return decisions.addNew(first.lineKey, 'blank') ? first.lineKey : null } return null } @@ -304,7 +347,7 @@ export const createDoubleBlackSqueezeRule = (): Rule => ({ id: 'masyu-double-black-squeeze', name: 'Double Black Squeeze', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstMiddle: string | null = null let firstLine: string | null = null @@ -312,15 +355,23 @@ export const createDoubleBlackSqueezeRule = (): Rule => ({ for (let row = 0; row < puzzle.rows; row += 1) { for (let col = 0; col < puzzle.cols; col += 1) { const middleKey = cellKey(row, col) - for (const { blackDirections, perpendicularDirections } of squeezeAxes) { + for (const { + blackDirections, + perpendicularDirections, + } of squeezeAxes) { const blackCells = blackDirections.map((direction) => { const [rowDelta, colDelta] = getMasyuDirectionOffset(direction) return offsetCellKey(puzzle, middleKey, rowDelta, colDelta) }) - if (!blackCells.every((key) => isPearl(puzzle, key, 'black'))) { + if (!blackCells.every((key) => isMasyuPearl(puzzle, key, 'black'))) { continue } - const line = collectDoubleBlackSqueeze(puzzle, decisions, middleKey, perpendicularDirections) + const line = collectDoubleBlackSqueeze( + puzzle, + decisions, + middleKey, + perpendicularDirections, + ) if (!line) { continue } @@ -333,11 +384,11 @@ export const createDoubleBlackSqueezeRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstMiddle && firstLine diff --git a/src/domain/rules/masyu/rules/pearlCandidates.ts b/src/domain/rules/masyu/rules/pearlCandidates.ts new file mode 100644 index 0000000..1a3855a --- /dev/null +++ b/src/domain/rules/masyu/rules/pearlCandidates.ts @@ -0,0 +1,165 @@ +import type { LineMark } from '../../../ir/types' +import type { MasyuLineOverlay } from './lineGraph' +import { + MASYU_DIRECTIONS, + type MasyuDirection, + type MasyuDirectionalLine, + type MasyuTwoStepLine, +} from './shared' + +export type BlackPearlCandidate = { + color: 'black' + exits: [MasyuDirection, MasyuDirection] + lines: Set + exitLines: Set + extensionLines: Set + blanks: Set +} + +export type WhitePearlCandidate = { + color: 'white' + axis: [MasyuDirection, MasyuDirection] + lines: Set + exitLines: Set + blanks: Set + turnableSides: Set +} + +export type PearlCandidate = BlackPearlCandidate | WhitePearlCandidate + +export const BLACK_CANDIDATE_EXIT_PAIRS: [MasyuDirection, MasyuDirection][] = [ + ['N', 'E'], + ['N', 'W'], + ['S', 'E'], + ['S', 'W'], +] + +export const WHITE_CANDIDATE_AXES: [MasyuDirection, MasyuDirection][] = [ + ['N', 'S'], + ['E', 'W'], +] + +export const addMasyuOverlayDecision = ( + overlay: Map, + lineKeyValue: string, + mark: LineMark, +): boolean => { + const existing = overlay.get(lineKeyValue) + if (existing !== undefined) { + return existing === mark + } + overlay.set(lineKeyValue, mark) + return true +} + +export const mergeMasyuLineOverlay = ( + base: MasyuLineOverlay, + next: MasyuLineOverlay, +): Map | null => { + const merged = new Map(base) + for (const [lineKeyValue, mark] of next.entries()) { + if (!addMasyuOverlayDecision(merged, lineKeyValue, mark)) { + return null + } + } + return merged +} + +export const masyuPearlCandidateToOverlay = ( + candidate: PearlCandidate, +): Map | null => { + const overlay = new Map() + for (const lineKeyValue of candidate.lines) { + if (!addMasyuOverlayDecision(overlay, lineKeyValue, 'line')) { + return null + } + } + for (const lineKeyValue of candidate.blanks) { + if (!addMasyuOverlayDecision(overlay, lineKeyValue, 'blank')) { + return null + } + } + return overlay +} + +export const buildMasyuBlackPearlCandidate = ( + exits: [MasyuDirection, MasyuDirection], + getTwoStep: (direction: MasyuDirection) => MasyuTwoStepLine, + getIncident: () => Record, +): BlackPearlCandidate | null => { + const candidate: BlackPearlCandidate = { + color: 'black', + exits, + lines: new Set(), + exitLines: new Set(), + extensionLines: new Set(), + blanks: new Set(), + } + + for (const direction of exits) { + const { first, second } = getTwoStep(direction) + if (!first || !second) { + return null + } + candidate.lines.add(first.lineKey) + candidate.lines.add(second.lineKey) + candidate.exitLines.add(first.lineKey) + candidate.extensionLines.add(second.lineKey) + } + + const selected = new Set(exits) + const incident = getIncident() + for (const direction of MASYU_DIRECTIONS) { + if (!selected.has(direction) && incident[direction]) { + candidate.blanks.add(incident[direction].lineKey) + } + } + + return candidate +} + +export const buildMasyuWhitePearlCandidate = ( + axis: [MasyuDirection, MasyuDirection], + getIncident: () => Record, + canSideTurn: ( + direction: MasyuDirection, + axisOverlay: MasyuLineOverlay, + ) => boolean, +): WhitePearlCandidate | null => { + const candidate: WhitePearlCandidate = { + color: 'white', + axis, + lines: new Set(), + exitLines: new Set(), + blanks: new Set(), + turnableSides: new Set(), + } + const selected = new Set(axis) + const incident = getIncident() + + for (const direction of axis) { + const line = incident[direction] + if (!line) { + return null + } + candidate.lines.add(line.lineKey) + candidate.exitLines.add(line.lineKey) + } + for (const direction of MASYU_DIRECTIONS) { + if (!selected.has(direction) && incident[direction]) { + candidate.blanks.add(incident[direction].lineKey) + } + } + + const overlay = masyuPearlCandidateToOverlay(candidate) + if (!overlay) { + return null + } + for (const direction of axis) { + if (canSideTurn(direction, overlay)) { + candidate.turnableSides.add(direction) + } + } + + return candidate +} diff --git a/src/domain/rules/masyu/rules/pearlSelectors.ts b/src/domain/rules/masyu/rules/pearlSelectors.ts new file mode 100644 index 0000000..6608fb9 --- /dev/null +++ b/src/domain/rules/masyu/rules/pearlSelectors.ts @@ -0,0 +1,55 @@ +import type { PuzzleIR } from '../../../ir/types' +import { formatMasyuCellKeyLabel } from './shared' + +export type MasyuPearlColor = 'white' | 'black' + +export type MasyuPearlEntry = { + key: string + color: MasyuPearlColor +} + +export const getMasyuPearlColor = ( + puzzle: PuzzleIR, + key: string, +): MasyuPearlColor | null => { + const clue = puzzle.cells[key]?.clue + return clue?.kind === 'pearl' ? clue.color : null +} + +export const getMasyuPearls = (puzzle: PuzzleIR): MasyuPearlEntry[] => + Object.entries(puzzle.cells).flatMap(([key, cell]) => + cell.clue?.kind === 'pearl' ? [{ key, color: cell.clue.color }] : [], + ) + +export const getMasyuPearlKeys = ( + puzzle: PuzzleIR, + color?: MasyuPearlColor, +): string[] => + getMasyuPearls(puzzle).flatMap((pearl) => + color === undefined || pearl.color === color ? [pearl.key] : [], + ) + +export const getMasyuWhitePearlKeys = (puzzle: PuzzleIR): string[] => + getMasyuPearlKeys(puzzle, 'white') + +export const getMasyuBlackPearlKeys = (puzzle: PuzzleIR): string[] => + getMasyuPearlKeys(puzzle, 'black') + +export const isMasyuPearl = ( + puzzle: PuzzleIR, + key: string | null, + color?: MasyuPearlColor, +): boolean => + key !== null && + getMasyuPearlColor(puzzle, key) !== null && + (color === undefined || getMasyuPearlColor(puzzle, key) === color) + +export const formatMasyuPearlLabel = ( + puzzle: PuzzleIR, + key: string, +): string => { + const color = getMasyuPearlColor(puzzle, key) + const colorLabel = + color === 'white' ? 'White' : color === 'black' ? 'Black' : 'Masyu' + return `${colorLabel} pearl ${formatMasyuCellKeyLabel(key)}` +} diff --git a/src/domain/rules/masyu/rules/pearls.ts b/src/domain/rules/masyu/rules/pearls.ts index f58c98c..be5e598 100644 --- a/src/domain/rules/masyu/rules/pearls.ts +++ b/src/domain/rules/masyu/rules/pearls.ts @@ -1,10 +1,17 @@ import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' +import { + createMasyuLineDecisionCollector, + type MasyuLineDecisionCollector, +} from './decisionCollector' +import { + getMasyuBlackPearlKeys, + getMasyuWhitePearlKeys, +} from './pearlSelectors' import { MASYU_DIRECTIONS, areMasyuDirectionsOpposite, areMasyuDirectionsTurn, - buildMasyuLineDiffs, canMasyuLineBeAddedWithoutDegreeOverflow, collectMasyuLineDecision, collectMasyuLineDecisionWithoutDegreeOverflow, @@ -26,77 +33,109 @@ const PEARL_AXES: Axis[] = [ ['E', 'W'], ] -const getOppositeAxis = (axis: Axis): Axis => (axis[0] === 'N' ? ['E', 'W'] : ['N', 'S']) +const getOppositeAxis = (axis: Axis): Axis => + axis[0] === 'N' ? ['E', 'W'] : ['N', 'S'] -const getPearlCellKeys = (puzzle: PuzzleIR, color: 'white' | 'black'): string[] => - Object.entries(puzzle.cells).flatMap(([key, cell]) => - cell.clue?.kind === 'pearl' && cell.clue.color === color ? [key] : [], - ) - -const isWhiteAxisBlocked = (puzzle: PuzzleIR, pearlKey: string, axis: Axis): boolean => +const isWhiteAxisBlocked = ( + puzzle: PuzzleIR, + pearlKey: string, + axis: Axis, +): boolean => axis .map((direction) => getMasyuDirectionalLine(puzzle, pearlKey, direction)) - .some((item) => !item || !isMasyuLineAvailable(item) || !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, item.lineKey)) + .some( + (item) => + !item || + !isMasyuLineAvailable(item) || + !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, item.lineKey), + ) -const canWhiteAxisSideTurn = (puzzle: PuzzleIR, pearlKey: string, direction: MasyuDirection): boolean => { +const canWhiteAxisSideTurn = ( + puzzle: PuzzleIR, + pearlKey: string, + direction: MasyuDirection, +): boolean => { const { first, second } = getMasyuTwoStepLine(puzzle, pearlKey, direction) if (!isMasyuLineAvailable(first) || !first || second?.mark === 'line') { return false } - return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some((candidate) => { - if (!isMasyuLineAvailable(candidate)) { - return false - } - const decisions = new Map() - if (!collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, first.lineKey, 'line')) { - return false - } - if (second && !collectMasyuLineDecision(decisions, puzzle, second.lineKey, 'blank')) { - return false - } - return collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, candidate.lineKey, 'line') - }) + return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some( + (candidate) => { + if (!isMasyuLineAvailable(candidate)) { + return false + } + const decisions = new Map() + if ( + !collectMasyuLineDecisionWithoutDegreeOverflow( + decisions, + puzzle, + first.lineKey, + 'line', + ) + ) { + return false + } + if ( + second && + !collectMasyuLineDecision(decisions, puzzle, second.lineKey, 'blank') + ) { + return false + } + return collectMasyuLineDecisionWithoutDegreeOverflow( + decisions, + puzzle, + candidate.lineKey, + 'line', + ) + }, + ) } -const isWhiteAxisTurnBlocked = (puzzle: PuzzleIR, pearlKey: string, axis: Axis): boolean => +const isWhiteAxisTurnBlocked = ( + puzzle: PuzzleIR, + pearlKey: string, + axis: Axis, +): boolean => axis.every((direction) => !canWhiteAxisSideTurn(puzzle, pearlKey, direction)) -const isBlackExitAvailable = (puzzle: PuzzleIR, pearlKey: string, direction: MasyuDirection): boolean => { +const isBlackExitAvailable = ( + puzzle: PuzzleIR, + pearlKey: string, + direction: MasyuDirection, +): boolean => { const { first, second } = getMasyuTwoStepLine(puzzle, pearlKey, direction) return isMasyuLineAvailable(first) && isMasyuLineAvailable(second) } const collectNewMasyuLineDecision = ( - decisions: Map, - puzzle: PuzzleIR, + decisions: MasyuLineDecisionCollector, key: string, to: 'line' | 'blank', ): boolean => { - const beforeSize = decisions.size - return collectMasyuLineDecision(decisions, puzzle, key, to) && decisions.size > beforeSize + return decisions.addNew(key, to) } const collectNewMasyuLineDecisionWithoutDegreeOverflow = ( - decisions: Map, - puzzle: PuzzleIR, + decisions: MasyuLineDecisionCollector, key: string, to: 'line' | 'blank', ): boolean => { - const beforeSize = decisions.size - return collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, key, to) && decisions.size > beforeSize + return to === 'line' ? decisions.addNew(key, to) : decisions.addNew(key, to) } export const createWhiteCircleRule = (): Rule => ({ id: 'white-circle-rule', name: 'White Circle Rule', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) const affectedCells = new Set() let firstPearl: string | null = null let firstLine: string | null = null let firstReason: string | null = null - for (const pearlKey of getPearlCellKeys(puzzle, 'white')) { + for (const pearlKey of getMasyuWhitePearlKeys(puzzle)) { const incident = getMasyuIncidentDirectionalLines(puzzle, pearlKey) const lineEntries = MASYU_DIRECTIONS.flatMap((direction) => { const item = incident[direction] @@ -110,12 +149,26 @@ export const createWhiteCircleRule = (): Rule => ({ } for (const straightSide of axis) { const turnSide = oppositeMasyuDirection(straightSide) - const straightExtension = getMasyuTwoStepLine(puzzle, pearlKey, straightSide).second - const turnExtension = getMasyuTwoStepLine(puzzle, pearlKey, turnSide).second + const straightExtension = getMasyuTwoStepLine( + puzzle, + pearlKey, + straightSide, + ).second + const turnExtension = getMasyuTwoStepLine( + puzzle, + pearlKey, + turnSide, + ).second if (straightExtension?.mark !== 'line' || !turnExtension) { continue } - if (collectNewMasyuLineDecision(decisions, puzzle, turnExtension.lineKey, 'blank')) { + if ( + collectNewMasyuLineDecision( + decisions, + turnExtension.lineKey, + 'blank', + ) + ) { affectedCells.add(pearlKey) if (firstPearl === null) { firstPearl = pearlKey @@ -128,7 +181,9 @@ export const createWhiteCircleRule = (): Rule => ({ } if (lineEntries.length === 1) { - const straightDirection = oppositeMasyuDirection(lineEntries[0].direction) + const straightDirection = oppositeMasyuDirection( + lineEntries[0].direction, + ) let addedAny = false for (const direction of MASYU_DIRECTIONS) { const item = incident[direction] @@ -136,7 +191,13 @@ export const createWhiteCircleRule = (): Rule => ({ continue } const mark = direction === straightDirection ? 'line' : 'blank' - if (collectNewMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, item.lineKey, mark)) { + if ( + collectNewMasyuLineDecisionWithoutDegreeOverflow( + decisions, + item.lineKey, + mark, + ) + ) { addedAny = true if (firstLine === null) { firstLine = item.lineKey @@ -163,7 +224,10 @@ export const createWhiteCircleRule = (): Rule => ({ continue } const item = incident[direction] - if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'blank')) { + if ( + item && + collectNewMasyuLineDecision(decisions, item.lineKey, 'blank') + ) { addedAny = true if (firstLine === null) { firstLine = item.lineKey @@ -174,14 +238,17 @@ export const createWhiteCircleRule = (): Rule => ({ affectedCells.add(pearlKey) if (firstPearl === null) { firstPearl = pearlKey - firstReason = 'must go straight through the pearl, so turn candidates are blank' + firstReason = + 'must go straight through the pearl, so turn candidates are blank' } } continue } const unavailableAxes = PEARL_AXES.filter( - (axis) => isWhiteAxisBlocked(puzzle, pearlKey, axis) || isWhiteAxisTurnBlocked(puzzle, pearlKey, axis), + (axis) => + isWhiteAxisBlocked(puzzle, pearlKey, axis) || + isWhiteAxisTurnBlocked(puzzle, pearlKey, axis), ) if (unavailableAxes.length !== 1) { continue @@ -193,7 +260,10 @@ export const createWhiteCircleRule = (): Rule => ({ for (const direction of blockedAxis) { const item = getMasyuDirectionalLine(puzzle, pearlKey, direction) - if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'blank')) { + if ( + item && + collectNewMasyuLineDecision(decisions, item.lineKey, 'blank') + ) { addedAny = true if (firstLine === null) { firstLine = item.lineKey @@ -202,7 +272,14 @@ export const createWhiteCircleRule = (): Rule => ({ } for (const direction of straightAxis) { const item = getMasyuDirectionalLine(puzzle, pearlKey, direction) - if (item && collectNewMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, item.lineKey, 'line')) { + if ( + item && + collectNewMasyuLineDecisionWithoutDegreeOverflow( + decisions, + item.lineKey, + 'line', + ) + ) { addedAny = true if (firstLine === null) { firstLine = item.lineKey @@ -218,11 +295,11 @@ export const createWhiteCircleRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstPearl && firstLine @@ -239,12 +316,12 @@ export const createBlackCircleRule = (): Rule => ({ id: 'black-circle-rule', name: 'Black Circle Rule', apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = new Map() + const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstPearl: string | null = null let firstLine: string | null = null - for (const pearlKey of getPearlCellKeys(puzzle, 'black')) { + for (const pearlKey of getMasyuBlackPearlKeys(puzzle)) { const incident = getMasyuIncidentDirectionalLines(puzzle, pearlKey) const lineEntries = MASYU_DIRECTIONS.flatMap((direction) => { const item = incident[direction] @@ -256,14 +333,24 @@ export const createBlackCircleRule = (): Rule => ({ const lineDirection = lineEntries[0].direction const opposite = incident[oppositeMasyuDirection(lineDirection)] let addedAny = false - if (opposite && collectNewMasyuLineDecision(decisions, puzzle, opposite.lineKey, 'blank')) { + if ( + opposite && + collectNewMasyuLineDecision(decisions, opposite.lineKey, 'blank') + ) { addedAny = true if (firstLine === null) { firstLine = opposite.lineKey } } - const extension = getMasyuTwoStepLine(puzzle, pearlKey, lineDirection).second - if (extension && collectNewMasyuLineDecision(decisions, puzzle, extension.lineKey, 'line')) { + const extension = getMasyuTwoStepLine( + puzzle, + pearlKey, + lineDirection, + ).second + if ( + extension && + collectNewMasyuLineDecision(decisions, extension.lineKey, 'line') + ) { addedAny = true if (firstLine === null) { firstLine = extension.lineKey @@ -288,7 +375,10 @@ export const createBlackCircleRule = (): Rule => ({ continue } const item = incident[direction] - if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'blank')) { + if ( + item && + collectNewMasyuLineDecision(decisions, item.lineKey, 'blank') + ) { addedAny = true if (firstLine === null) { firstLine = item.lineKey @@ -296,8 +386,15 @@ export const createBlackCircleRule = (): Rule => ({ } } for (const direction of lineDirections) { - const extension = getMasyuTwoStepLine(puzzle, pearlKey, direction).second - if (extension && collectNewMasyuLineDecision(decisions, puzzle, extension.lineKey, 'line')) { + const extension = getMasyuTwoStepLine( + puzzle, + pearlKey, + direction, + ).second + if ( + extension && + collectNewMasyuLineDecision(decisions, extension.lineKey, 'line') + ) { addedAny = true if (firstLine === null) { firstLine = extension.lineKey @@ -324,16 +421,26 @@ export const createBlackCircleRule = (): Rule => ({ let addedAny = false const blocked = getMasyuDirectionalLine(puzzle, pearlKey, direction) - if (blocked && collectNewMasyuLineDecision(decisions, puzzle, blocked.lineKey, 'blank')) { + if ( + blocked && + collectNewMasyuLineDecision(decisions, blocked.lineKey, 'blank') + ) { addedAny = true if (firstLine === null) { firstLine = blocked.lineKey } } - const opposite = getMasyuTwoStepLine(puzzle, pearlKey, oppositeDirection) + const opposite = getMasyuTwoStepLine( + puzzle, + pearlKey, + oppositeDirection, + ) for (const item of [opposite.first, opposite.second]) { - if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'line')) { + if ( + item && + collectNewMasyuLineDecision(decisions, item.lineKey, 'line') + ) { addedAny = true if (firstLine === null) { firstLine = item.lineKey @@ -350,11 +457,11 @@ export const createBlackCircleRule = (): Rule => ({ } } - if (decisions.size === 0) { + if (!decisions.hasChanges()) { return null } - const diffs = buildMasyuLineDiffs(decisions, puzzle) + const diffs = decisions.diffs() return { message: firstPearl && firstLine diff --git a/src/domain/rules/masyu/rules/shared.ts b/src/domain/rules/masyu/rules/shared.ts index 70073c8..a0eeadc 100644 --- a/src/domain/rules/masyu/rules/shared.ts +++ b/src/domain/rules/masyu/rules/shared.ts @@ -1,4 +1,10 @@ -import { cellKey, getCellLineKeys, lineKey, parseCellKey, parseLineKey } from '../../../ir/keys' +import { + cellKey, + getCellLineKeys, + lineKey, + parseCellKey, + parseLineKey, +} from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' import type { LineDiff } from '../../types' @@ -6,7 +12,9 @@ export type MasyuDirection = 'N' | 'E' | 'S' | 'W' export const MASYU_DIRECTIONS: MasyuDirection[] = ['N', 'E', 'S', 'W'] -export const oppositeMasyuDirection = (direction: MasyuDirection): MasyuDirection => { +export const oppositeMasyuDirection = ( + direction: MasyuDirection, +): MasyuDirection => { if (direction === 'N') return 'S' if (direction === 'S') return 'N' if (direction === 'E') return 'W' @@ -23,7 +31,10 @@ export const areMasyuDirectionsTurn = ( right: MasyuDirection, ): boolean => left !== right && !areMasyuDirectionsOpposite(left, right) -const directionOffsets: Record = { +const directionOffsets: Record< + MasyuDirection, + [rowDelta: number, colDelta: number] +> = { N: [-1, 0], E: [0, 1], S: [1, 0], @@ -34,7 +45,8 @@ export const getMasyuDirectionOffset = ( direction: MasyuDirection, ): [rowDelta: number, colDelta: number] => directionOffsets[direction] -export const formatMasyuCellLabel = (row: number, col: number): string => `(R${row + 1}, C${col + 1})` +export const formatMasyuCellLabel = (row: number, col: number): string => + `(R${row + 1}, C${col + 1})` export const formatMasyuCellKeyLabel = (key: string): string => { const [row, col] = parseCellKey(key) @@ -84,7 +96,12 @@ export const getMasyuDirectionalLine = ( const [rowDelta, colDelta] = directionOffsets[direction] const neighborRow = row + rowDelta const neighborCol = col + colDelta - if (neighborRow < 0 || neighborRow >= puzzle.rows || neighborCol < 0 || neighborCol >= puzzle.cols) { + if ( + neighborRow < 0 || + neighborRow >= puzzle.rows || + neighborCol < 0 || + neighborCol >= puzzle.cols + ) { return null } const neighborKey = cellKey(neighborRow, neighborCol) @@ -132,12 +149,15 @@ export const getMasyuTwoStepLine = ( const first = getMasyuDirectionalLine(puzzle, key, direction) return { first, - second: first ? getMasyuDirectionalLine(puzzle, first.neighborKey, direction) : null, + second: first + ? getMasyuDirectionalLine(puzzle, first.neighborKey, direction) + : null, } } -export const isMasyuLineAvailable = (line: MasyuDirectionalLine | null): boolean => - line !== null && line.mark !== 'blank' +export const isMasyuLineAvailable = ( + line: MasyuDirectionalLine | null, +): boolean => line !== null && line.mark !== 'blank' export const getMasyuTurnCandidateLines = ( puzzle: PuzzleIR, @@ -145,7 +165,9 @@ export const getMasyuTurnCandidateLines = ( throughDirection: MasyuDirection, ): MasyuDirectionalLine[] => { const turnDirections: MasyuDirection[] = - throughDirection === 'N' || throughDirection === 'S' ? ['E', 'W'] : ['N', 'S'] + throughDirection === 'N' || throughDirection === 'S' + ? ['E', 'W'] + : ['N', 'S'] return turnDirections.flatMap((direction) => { const item = getMasyuDirectionalLine(puzzle, key, direction) return item ? [item] : [] @@ -180,7 +202,10 @@ export const getMasyuCellLineDegree = ( ): number => { const [row, col] = parseCellKey(key) return getCellLineKeys(row, col, puzzle.rows, puzzle.cols).filter( - (lineKeyValue) => (decisions.get(lineKeyValue) ?? puzzle.lines[lineKeyValue]?.mark ?? 'unknown') === 'line', + (lineKeyValue) => + (decisions.get(lineKeyValue) ?? + puzzle.lines[lineKeyValue]?.mark ?? + 'unknown') === 'line', ).length } @@ -197,7 +222,10 @@ export const canMasyuLineBeAddedWithoutDegreeOverflow = ( return false } const [left, right] = parseLineKey(key) - return [left, right].every(([row, col]) => getMasyuCellLineDegree(puzzle, cellKey(row, col), decisions) < 2) + return [left, right].every( + ([row, col]) => + getMasyuCellLineDegree(puzzle, cellKey(row, col), decisions) < 2, + ) } export const collectMasyuLineDecisionWithoutDegreeOverflow = ( @@ -206,7 +234,10 @@ export const collectMasyuLineDecisionWithoutDegreeOverflow = ( key: string, to: LineMark, ): boolean => { - if (to === 'line' && !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, key, decisions)) { + if ( + to === 'line' && + !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, key, decisions) + ) { return false } return collectMasyuLineDecision(decisions, puzzle, key, to) diff --git a/src/domain/rules/masyu/rules/tileParity.ts b/src/domain/rules/masyu/rules/tileParity.ts new file mode 100644 index 0000000..424ab93 --- /dev/null +++ b/src/domain/rules/masyu/rules/tileParity.ts @@ -0,0 +1,226 @@ +import { parseLineKey, tileKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' + +export type MasyuTileColor = 'green' | 'yellow' +export type MasyuTileParity = 0 | 1 + +export type MasyuTileParityConflict = { + kind: 'relation' | 'anchor' + source: string + message: string +} + +export type MasyuTileParityGraph = { + find: (key: string) => { root: string; parity: MasyuTileParity } + anchorColors: Map + inconsistentRoots: Set + firstConflict: MasyuTileParityConflict | null + getInferredColor: (key: string) => MasyuTileColor | null +} + +export const isMasyuTileColor = ( + fill: string | undefined, +): fill is MasyuTileColor => fill === 'green' || fill === 'yellow' + +export const oppositeMasyuTileColor = (fill: MasyuTileColor): MasyuTileColor => + fill === 'green' ? 'yellow' : 'green' + +export const applyMasyuTileParity = ( + color: MasyuTileColor, + parity: MasyuTileParity, +): MasyuTileColor => (parity === 0 ? color : oppositeMasyuTileColor(color)) + +export const isMasyuBoundaryTile = ( + puzzle: PuzzleIR, + row: number, + col: number, +): boolean => + row === 0 || row === puzzle.rows || col === 0 || col === puzzle.cols + +export const getMasyuLineTileRelation = ( + puzzle: PuzzleIR, + lineKeyValue: string, +): { leftTile: string; rightTile: string } | null => { + const [left, right] = parseLineKey(lineKeyValue) + if (left[0] === right[0] && Math.abs(left[1] - right[1]) === 1) { + const row = left[0] + const col = Math.min(left[1], right[1]) + if (row < 0 || row >= puzzle.rows || col < 0 || col >= puzzle.cols - 1) { + return null + } + return { + leftTile: tileKey(row, col + 1), + rightTile: tileKey(row + 1, col + 1), + } + } + if (left[1] === right[1] && Math.abs(left[0] - right[0]) === 1) { + const row = Math.min(left[0], right[0]) + const col = left[1] + if (row < 0 || row >= puzzle.rows - 1 || col < 0 || col >= puzzle.cols) { + return null + } + return { + leftTile: tileKey(row + 1, col), + rightTile: tileKey(row + 1, col + 1), + } + } + return null +} + +export const buildMasyuTileParityGraph = ( + puzzle: PuzzleIR, +): MasyuTileParityGraph => { + const parent = new Map() + const rank = new Map() + const parityToParent = new Map() + const inconsistentRoots = new Set() + const anchorColors = new Map() + let firstConflict: MasyuTileParityConflict | null = null + + const ensure = (key: string): void => { + if (parent.has(key)) { + return + } + parent.set(key, key) + rank.set(key, 0) + parityToParent.set(key, 0) + } + + const find = (key: string): { root: string; parity: MasyuTileParity } => { + ensure(key) + const currentParent = parent.get(key) + if (currentParent === undefined || currentParent === key) { + return { root: key, parity: 0 } + } + const found = find(currentParent) + const parity = ((parityToParent.get(key) ?? 0) ^ + found.parity) as MasyuTileParity + parent.set(key, found.root) + parityToParent.set(key, parity) + return { root: found.root, parity } + } + + const markInconsistent = ( + root: string, + conflict: MasyuTileParityConflict, + ): void => { + inconsistentRoots.add(find(root).root) + firstConflict ??= conflict + } + + const union = ( + left: string, + right: string, + relation: MasyuTileParity, + source: string, + ): void => { + const leftRoot = find(left) + const rightRoot = find(right) + if (leftRoot.root === rightRoot.root) { + if ((leftRoot.parity ^ rightRoot.parity) !== relation) { + markInconsistent(leftRoot.root, { + kind: 'relation', + source, + message: 'line/tile parity requirements conflict', + }) + } + return + } + + const mergedParity = (leftRoot.parity ^ + rightRoot.parity ^ + relation) as MasyuTileParity + const leftRank = rank.get(leftRoot.root) ?? 0 + const rightRank = rank.get(rightRoot.root) ?? 0 + const leftInconsistent = inconsistentRoots.delete(leftRoot.root) + const rightInconsistent = inconsistentRoots.delete(rightRoot.root) + + if (leftRank < rightRank) { + parent.set(leftRoot.root, rightRoot.root) + parityToParent.set(leftRoot.root, mergedParity) + if (leftInconsistent || rightInconsistent) { + inconsistentRoots.add(rightRoot.root) + } + return + } + + parent.set(rightRoot.root, leftRoot.root) + parityToParent.set(rightRoot.root, mergedParity) + if (leftRank === rightRank) { + rank.set(leftRoot.root, leftRank + 1) + } + if (leftInconsistent || rightInconsistent) { + inconsistentRoots.add(leftRoot.root) + } + } + + const rememberAnchor = (key: string, color: MasyuTileColor): void => { + const { root, parity } = find(key) + const rootColor = applyMasyuTileParity(color, parity) + const current = anchorColors.get(root) + if (current !== undefined && current !== rootColor) { + markInconsistent(root, { + kind: 'anchor', + source: key, + message: `fixed Masyu tile colors require both ${current} and ${rootColor}`, + }) + return + } + anchorColors.set(root, rootColor) + } + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + ensure(tileKey(row, col)) + } + } + + for (const [lineKeyValue, line] of Object.entries(puzzle.lines)) { + const mark: LineMark = line?.mark ?? 'unknown' + if (mark !== 'line' && mark !== 'blank') { + continue + } + const relation = getMasyuLineTileRelation(puzzle, lineKeyValue) + if (relation) { + union( + relation.leftTile, + relation.rightTile, + mark === 'line' ? 1 : 0, + lineKeyValue, + ) + } + } + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + if (isMasyuBoundaryTile(puzzle, row, col)) { + rememberAnchor(tileKey(row, col), 'yellow') + } + } + } + + for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { + if (isMasyuTileColor(tile.fill)) { + rememberAnchor(key, tile.fill) + } + } + + return { + find, + anchorColors, + inconsistentRoots, + get firstConflict() { + return firstConflict + }, + getInferredColor: (key: string): MasyuTileColor | null => { + const { root, parity } = find(key) + if (inconsistentRoots.has(root)) { + return null + } + const rootColor = anchorColors.get(root) + return rootColor === undefined + ? null + : applyMasyuTileParity(rootColor, parity) + }, + } +} diff --git a/src/domain/rules/masyu/rules/trial.ts b/src/domain/rules/masyu/rules/trial.ts index 9c639d7..5cd5581 100644 --- a/src/domain/rules/masyu/rules/trial.ts +++ b/src/domain/rules/masyu/rules/trial.ts @@ -1,8 +1,9 @@ -import { cellKey, parseLineKey, tileKey } from '../../../ir/keys' +import { cellKey, parseLineKey } from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' import { runNextRule } from '../../engine' import type { Rule } from '../../types' -import { getMasyuLineTileRelation, isMasyuTileColor } from './color' +import { getMasyuKnownLineComponents } from './lineGraph' +import { buildMasyuTileParityGraph } from './tileParity' import { areMasyuDirectionsOpposite, areMasyuDirectionsTurn, @@ -36,9 +37,11 @@ export type MasyuTrialResult = { contradictionReason?: MasyuTrialContradictionReason } -type Parity = 0 | 1 - -export const applyMasyuLineAssumption = (puzzle: PuzzleIR, lineKeyValue: string, to: LineMark): boolean => { +export const applyMasyuLineAssumption = ( + puzzle: PuzzleIR, + lineKeyValue: string, + to: LineMark, +): boolean => { const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' if (current !== 'unknown') { return current === to @@ -47,15 +50,23 @@ export const applyMasyuLineAssumption = (puzzle: PuzzleIR, lineKeyValue: string, return true } -const getLineDirectionsAtCell = (puzzle: PuzzleIR, key: string): MasyuDirection[] => - Object.values(getMasyuIncidentDirectionalLines(puzzle, key)).flatMap((item) => - item && item.mark === 'line' ? [item.direction] : [], +const getLineDirectionsAtCell = ( + puzzle: PuzzleIR, + key: string, +): MasyuDirection[] => + Object.values(getMasyuIncidentDirectionalLines(puzzle, key)).flatMap( + (item) => (item && item.mark === 'line' ? [item.direction] : []), ) -const getIncidentCounts = (puzzle: PuzzleIR, key: string): { lineCount: number; unknownCount: number } => { +const getIncidentCounts = ( + puzzle: PuzzleIR, + key: string, +): { lineCount: number; unknownCount: number } => { let lineCount = 0 let unknownCount = 0 - for (const item of Object.values(getMasyuIncidentDirectionalLines(puzzle, key))) { + for (const item of Object.values( + getMasyuIncidentDirectionalLines(puzzle, key), + )) { if (!item) { continue } @@ -68,7 +79,9 @@ const getIncidentCounts = (puzzle: PuzzleIR, key: string): { lineCount: number; return { lineCount, unknownCount } } -const detectCellDegreeContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { +const detectCellDegreeContradiction = ( + puzzle: PuzzleIR, +): MasyuTrialContradictionReason | null => { for (let row = 0; row < puzzle.rows; row += 1) { for (let col = 0; col < puzzle.cols; col += 1) { const key = cellKey(row, col) @@ -90,7 +103,10 @@ const detectCellDegreeContradiction = (puzzle: PuzzleIR): MasyuTrialContradictio return null } -const canLineStillBeLine = (puzzle: PuzzleIR, lineKeyValue: string): boolean => { +const canLineStillBeLine = ( + puzzle: PuzzleIR, + lineKeyValue: string, +): boolean => { const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' if (current === 'blank') { return false @@ -99,7 +115,9 @@ const canLineStillBeLine = (puzzle: PuzzleIR, lineKeyValue: string): boolean => return true } const [left, right] = parseLineKey(lineKeyValue) - return [left, right].every(([row, col]) => getIncidentCounts(puzzle, cellKey(row, col)).lineCount < 2) + return [left, right].every( + ([row, col]) => getIncidentCounts(puzzle, cellKey(row, col)).lineCount < 2, + ) } const canBlackPearlStillWork = (puzzle: PuzzleIR, key: string): boolean => { @@ -124,7 +142,12 @@ const canBlackPearlStillWork = (puzzle: PuzzleIR, key: string): boolean => { } for (const direction of [leftDirection, rightDirection]) { const { first, second } = getMasyuTwoStepLine(puzzle, key, direction) - if (!first || !second || !canLineStillBeLine(puzzle, first.lineKey) || !canLineStillBeLine(puzzle, second.lineKey)) { + if ( + !first || + !second || + !canLineStillBeLine(puzzle, first.lineKey) || + !canLineStillBeLine(puzzle, second.lineKey) + ) { return false } } @@ -141,17 +164,25 @@ const canBlackPearlStillWork = (puzzle: PuzzleIR, key: string): boolean => { }) } -const canWhiteSideStillTurn = (puzzle: PuzzleIR, key: string, direction: MasyuDirection): boolean => { +const canWhiteSideStillTurn = ( + puzzle: PuzzleIR, + key: string, + direction: MasyuDirection, +): boolean => { const first = getMasyuDirectionalLine(puzzle, key, direction) if (!first || !canLineStillBeLine(puzzle, first.lineKey)) { return false } - const straightContinuation = getMasyuDirectionalLine(puzzle, first.neighborKey, direction) + const straightContinuation = getMasyuDirectionalLine( + puzzle, + first.neighborKey, + direction, + ) if (straightContinuation?.mark === 'line') { return false } - return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some((turn) => - canLineStillBeLine(puzzle, turn.lineKey), + return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some( + (turn) => canLineStillBeLine(puzzle, turn.lineKey), ) } @@ -187,11 +218,16 @@ const canWhitePearlStillWork = (puzzle: PuzzleIR, key: string): boolean => { return false } } - return canWhiteSideStillTurn(puzzle, key, leftDirection) || canWhiteSideStillTurn(puzzle, key, rightDirection) + return ( + canWhiteSideStillTurn(puzzle, key, leftDirection) || + canWhiteSideStillTurn(puzzle, key, rightDirection) + ) }) } -const detectPearlContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { +const detectPearlContradiction = ( + puzzle: PuzzleIR, +): MasyuTrialContradictionReason | null => { for (const [key, cell] of Object.entries(puzzle.cells)) { if (cell.clue?.kind !== 'pearl') { continue @@ -201,14 +237,21 @@ const detectPearlContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReas if ( cell.clue.color === 'black' && (!areMasyuDirectionsTurn(lineDirections[0], lineDirections[1]) || - !lineDirections.every((direction) => getMasyuTwoStepLine(puzzle, key, direction).second?.mark !== 'blank')) + !lineDirections.every( + (direction) => + getMasyuTwoStepLine(puzzle, key, direction).second?.mark !== + 'blank', + )) ) { return { kind: 'pearl-shape', message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: a black pearl must turn and continue straight after both exits`, } } - if (cell.clue.color === 'white' && !areMasyuDirectionsOpposite(lineDirections[0], lineDirections[1])) { + if ( + cell.clue.color === 'white' && + !areMasyuDirectionsOpposite(lineDirections[0], lineDirections[1]) + ) { return { kind: 'pearl-shape', message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: a white pearl must go straight through`, @@ -217,7 +260,9 @@ const detectPearlContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReas } const possible = - cell.clue.color === 'black' ? canBlackPearlStillWork(puzzle, key) : canWhitePearlStillWork(puzzle, key) + cell.clue.color === 'black' + ? canBlackPearlStillWork(puzzle, key) + : canWhitePearlStillWork(puzzle, key) if (!possible) { return { kind: 'pearl-shape', @@ -228,71 +273,40 @@ const detectPearlContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReas return null } -const detectLineLoopContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { - const lineEntries = Object.entries(puzzle.lines).filter(([, line]) => (line?.mark ?? 'unknown') === 'line') - if (lineEntries.length === 0) { +const detectLineLoopContradiction = ( + puzzle: PuzzleIR, +): MasyuTrialContradictionReason | null => { + const lineCount = Object.values(puzzle.lines).filter( + (line) => (line?.mark ?? 'unknown') === 'line', + ).length + if (lineCount === 0) { return null } - const cellCount = puzzle.rows * puzzle.cols - const parent = Array.from({ length: cellCount }, (_, idx) => idx) - const rank = new Array(cellCount).fill(0) const degree = new Map() - const toCellIndex = (row: number, col: number): number => row * puzzle.cols + col - const find = (idx: number): number => { - if (parent[idx] !== idx) { - parent[idx] = find(parent[idx]) - } - return parent[idx] - } - const union = (left: number, right: number): void => { - const rootLeft = find(left) - const rootRight = find(right) - if (rootLeft === rootRight) { - return - } - if (rank[rootLeft] < rank[rootRight]) { - parent[rootLeft] = rootRight - } else if (rank[rootLeft] > rank[rootRight]) { - parent[rootRight] = rootLeft - } else { - parent[rootRight] = rootLeft - rank[rootLeft] += 1 - } - } + const toCellIndex = (row: number, col: number): number => + row * puzzle.cols + col - for (const [lineKeyValue] of lineEntries) { + for (const [lineKeyValue, line] of Object.entries(puzzle.lines)) { + if ((line?.mark ?? 'unknown') !== 'line') { + continue + } const [left, right] = parseLineKey(lineKeyValue) const leftIdx = toCellIndex(left[0], left[1]) const rightIdx = toCellIndex(right[0], right[1]) - union(leftIdx, rightIdx) degree.set(leftIdx, (degree.get(leftIdx) ?? 0) + 1) degree.set(rightIdx, (degree.get(rightIdx) ?? 0) + 1) } - const componentEdgeCount = new Map() - const componentCells = new Map>() - for (const [lineKeyValue] of lineEntries) { - const [left, right] = parseLineKey(lineKeyValue) - const leftIdx = toCellIndex(left[0], left[1]) - const rightIdx = toCellIndex(right[0], right[1]) - const root = find(leftIdx) - componentEdgeCount.set(root, (componentEdgeCount.get(root) ?? 0) + 1) - const cells = componentCells.get(root) ?? new Set() - cells.add(leftIdx) - cells.add(rightIdx) - componentCells.set(root, cells) - } - let closedLoopCount = 0 let closedLoopLines = 0 - for (const [root, cells] of componentCells.entries()) { - const edgeCount = componentEdgeCount.get(root) ?? 0 - if (edgeCount !== cells.size) { + for (const component of getMasyuKnownLineComponents(puzzle)) { + const { edgeCount, vertices } = component + if (edgeCount !== vertices.size) { continue } let allDegreeTwo = true - for (const cell of cells) { + for (const cell of vertices) { if ((degree.get(cell) ?? 0) !== 2) { allDegreeTwo = false break @@ -305,7 +319,10 @@ const detectLineLoopContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionR closedLoopLines += edgeCount } - if (closedLoopCount > 1 || (closedLoopCount === 1 && closedLoopLines < lineEntries.length)) { + if ( + closedLoopCount > 1 || + (closedLoopCount === 1 && closedLoopLines < lineCount) + ) { return { kind: 'line-loop', message: @@ -317,118 +334,25 @@ const detectLineLoopContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionR return null } -const detectTileColorContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { - const parent = new Map() - const rank = new Map() - const parityToParent = new Map() - - const ensure = (key: string): void => { - if (parent.has(key)) { - return - } - parent.set(key, key) - rank.set(key, 0) - parityToParent.set(key, 0) - } - const find = (key: string): { root: string; parity: Parity } => { - ensure(key) - const currentParent = parent.get(key) - if (currentParent === undefined || currentParent === key) { - return { root: key, parity: 0 } - } - const found = find(currentParent) - const parity = ((parityToParent.get(key) ?? 0) ^ found.parity) as Parity - parent.set(key, found.root) - parityToParent.set(key, parity) - return { root: found.root, parity } - } - const union = (left: string, right: string, relation: Parity, source: string): MasyuTrialContradictionReason | null => { - const leftRoot = find(left) - const rightRoot = find(right) - if (leftRoot.root === rightRoot.root) { - if ((leftRoot.parity ^ rightRoot.parity) !== relation) { - return { - kind: 'tile-color', - message: `tile-color contradiction at ${formatMasyuLineLabel(source)}: line/tile parity requirements conflict`, - } - } - return null - } - const mergedParity = (leftRoot.parity ^ rightRoot.parity ^ relation) as Parity - const leftRank = rank.get(leftRoot.root) ?? 0 - const rightRank = rank.get(rightRoot.root) ?? 0 - if (leftRank < rightRank) { - parent.set(leftRoot.root, rightRoot.root) - parityToParent.set(leftRoot.root, mergedParity) - } else { - parent.set(rightRoot.root, leftRoot.root) - parityToParent.set(rightRoot.root, mergedParity) - if (leftRank === rightRank) { - rank.set(leftRoot.root, leftRank + 1) - } - } - return null - } - - for (let row = 0; row <= puzzle.rows; row += 1) { - for (let col = 0; col <= puzzle.cols; col += 1) { - ensure(tileKey(row, col)) - } - } - - for (const [lineKeyValue, line] of Object.entries(puzzle.lines)) { - const mark = line?.mark ?? 'unknown' - if (mark !== 'line' && mark !== 'blank') { - continue - } - const relation = getMasyuLineTileRelation(puzzle, lineKeyValue) - if (!relation) { - continue - } - const contradiction = union(relation.leftTile, relation.rightTile, mark === 'line' ? 1 : 0, lineKeyValue) - if (contradiction) { - return contradiction - } - } - - const anchoredRootColors = new Map() - const rememberAnchor = (key: string, color: 'green' | 'yellow'): MasyuTrialContradictionReason | null => { - const { root, parity } = find(key) - const rootColor = (parity === 0 ? color : color === 'green' ? 'yellow' : 'green') as 'green' | 'yellow' - const current = anchoredRootColors.get(root) - if (current !== undefined && current !== rootColor) { - return { - kind: 'tile-color', - message: `tile-color contradiction at ${key}: fixed Masyu tile colors require both ${current} and ${rootColor}`, - } - } - anchoredRootColors.set(root, rootColor) +const detectTileColorContradiction = ( + puzzle: PuzzleIR, +): MasyuTrialContradictionReason | null => { + const conflict = buildMasyuTileParityGraph(puzzle).firstConflict + if (!conflict) { return null } - - for (let row = 0; row <= puzzle.rows; row += 1) { - for (let col = 0; col <= puzzle.cols; col += 1) { - if (row === 0 || row === puzzle.rows || col === 0 || col === puzzle.cols) { - const contradiction = rememberAnchor(tileKey(row, col), 'yellow') - if (contradiction) { - return contradiction - } - } - } - } - for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { - if (!isMasyuTileColor(tile.fill)) { - continue - } - const contradiction = rememberAnchor(key, tile.fill) - if (contradiction) { - return contradiction - } + return { + kind: 'tile-color', + message: + conflict.kind === 'relation' + ? `tile-color contradiction at ${formatMasyuLineLabel(conflict.source)}: ${conflict.message}` + : `tile-color contradiction at ${conflict.source}: ${conflict.message}`, } - return null } -export const findMasyuHardContradictionReason = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => +export const findMasyuHardContradictionReason = ( + puzzle: PuzzleIR, +): MasyuTrialContradictionReason | null => detectCellDegreeContradiction(puzzle) ?? detectPearlContradiction(puzzle) ?? detectLineLoopContradiction(puzzle) ?? @@ -466,7 +390,11 @@ export const runMasyuTrialUntilFixpoint = ( elapsedMs: Math.max(0, performance.now() - startedAt), } } - const { nextPuzzle, step } = runNextRule(trial, deterministicRules, stepNumber) + const { nextPuzzle, step } = runNextRule( + trial, + deterministicRules, + stepNumber, + ) if (!step) { const contradictionReason = findMasyuHardContradictionReason(trial) return { From b92263b738a4eccd8f23246d95fef4f9a0ca65fa Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 22 May 2026 16:38:58 +0800 Subject: [PATCH 03/22] feat: add Adjacent White Pearls LookAhead rule to enhance puzzle solving --- docs/MASYU_AGENT_BRIEF.md | 16 +- docs/MASYU_RULE_ROADMAP.md | 61 +++++-- src/domain/rules/masyu/rules.test.ts | 113 ++++++++++++- src/domain/rules/masyu/rules.ts | 2 + src/domain/rules/masyu/rules/candidates.ts | 179 ++++++++++++++++++++- src/domain/rules/masyu/rules/lookahead.ts | 53 +++++- 6 files changed, 405 insertions(+), 19 deletions(-) diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index 75cc2e7..53ffe73 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -44,9 +44,10 @@ Registered rule order: 12. `Prevent Premature Loop` 13. `Black Pearl Candidate Pruning` 14. `White Pearl Candidate Pruning` -15. `Pearl Completion` -16. `Cell Completion` -17. `Black Pearl Strong Inference` +15. `Adjacent White Pearls LookAhead` +16. `Pearl Completion` +17. `Cell Completion` +18. `Black Pearl Strong Inference` Implemented rule areas: @@ -55,6 +56,7 @@ Implemented rule areas: - Premature loop prevention over `PuzzleIR.lines`. - Black pearl candidate pruning with shallow feasibility checks. - White pearl candidate pruning with the same shared pearl-candidate model. +- Adjacent white pearl lookahead pruning for the two local joint traversal modes. - Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. - Completion rules for pearl and non-pearl cells. - Tile color propagation: @@ -104,10 +106,16 @@ large deductions: 2. Use the shared primitives to add missing deterministic strength: - white pearl candidate pruning; + - structured assumption inference for white pearl axes and named white-pearl patterns; - pearl-local tile color implications; - candidate graph articulation improvements. -3. Keep rule granularity small: +3. Keep assumption inference explainable: + - prefer named candidate sources such as a pearl axis, adjacent white pair, or tile-color component; + - avoid unbounded tree search in the default solver; + - use contradiction or common-conclusion explanations for branch-based steps. + +4. Keep rule granularity small: - one reasoning idea per rule; - explicit diffs; - concise explanation message; diff --git a/docs/MASYU_RULE_ROADMAP.md b/docs/MASYU_RULE_ROADMAP.md index e7403a5..262deab 100644 --- a/docs/MASYU_RULE_ROADMAP.md +++ b/docs/MASYU_RULE_ROADMAP.md @@ -17,6 +17,8 @@ the current rule strategy smaller and easier to extend: - separate rule semantics by reasoning idea; - give white pearls the same candidate-model treatment black pearls already have; +- generalize strong inference so it can test named white-pearl and color + candidates, not only black-pearl exits; - make tile color useful through small pearl-local parity rules; - keep every rule replay-safe and explainable. @@ -107,7 +109,31 @@ Use this model for: - trial feasibility checks; - common consequence extraction across all feasible candidates. -### 4. Line Graph Helpers +### 4. Structured Assumption Inference + +Generalize the current black-pearl strong inference into a small assumption +runner. Candidate sources should be typed and explainable rather than arbitrary +unknown lines. + +Good candidate sources: + +- a black-pearl exit; +- a single white pearl axis; +- a named local white-pearl pattern, such as adjacent white pearls; +- a tile-color component with two possible colors. + +Supported outcomes: + +- contradiction: one branch fails, so its assumption is rejected; +- forced alternative: one branch fails and the opposite branch gives a direct + line or tile decision; +- common conclusion: every surviving branch produces the same unknown line or + tile decision. + +Default strong inference should stay bounded by candidate count, trial steps, +and wall-clock time. Avoid unbounded tree search in the normal rule stack. + +### 5. Line Graph Helpers Centralize graph operations over `PuzzleIR.lines`: @@ -122,7 +148,7 @@ Centralize graph operations over `PuzzleIR.lines`: `Masyu Candidate Bridge Line` and `Prevent Premature Loop` should eventually use the same graph vocabulary. -### 5. Tile Parity Graph +### 6. Tile Parity Graph Centralize tile inside/outside parity: @@ -162,17 +188,33 @@ deduction has a short explanation and focused tests. Highest leverage additions: -1. White pearl candidate pruning. -2. Black pearl local tile-color implications. -3. White pearl candidate color implications when all feasible axes imply the +1. White pearl axis candidate pruning. +2. White pearl strong inference using the structured assumption runner. +3. Named white-pearl pattern inference, starting with adjacent white pairs. +4. Black pearl local tile-color implications. +5. White pearl candidate color implications when all feasible axes imply the same tile relation. -4. Candidate graph articulation reasoning beyond single bridge edges. -5. A Masyu `NoChecker`-style tile parity inference only if it can be stated in +6. Candidate graph articulation reasoning beyond single bridge edges. +7. A Masyu `NoChecker`-style tile parity inference only if it can be stated in vertex-centered tile terms and returned as explicit diffs. Avoid broad monolithic "assistance script" ports. Convert outside research into small line, pearl-candidate, graph, or tile-parity rules. +## Search Boundary + +PuzzleKit should not become a default backtracking solver. The acceptable middle +ground is bounded, typed, explainable search: + +- do not guess arbitrary unknown lines in the default rule stack; +- do generate small candidate sets from a named structure; +- do explain branch results as contradiction, forced alternative, or common + conclusion; +- do keep budgets low enough that replay remains responsive and deterministic. + +If a puzzle still stalls, prefer adding a new named candidate source or +deterministic technique before adding deeper generic search. + ## Test Plan Focused rule tests: @@ -203,11 +245,12 @@ New tests should be fixture-sized and grouped by behavior: - line graph helpers: premature closures, bridge lines, articulation cells, required-source connectivity; - trial inference: hard contradictions should match deterministic feasibility - semantics. + semantics; +- structured assumptions: black exits, white axes, adjacent white-pair patterns, + and common-conclusion branches. ## Working Rule If this roadmap disagrees with current code, trust current code and update the roadmap. Keep this file current and compact; use git history for historical detail. - diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index 05eca43..f5f7fe0 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -8,6 +8,7 @@ import { masyuPlugin } from '../../plugins/masyuPlugin' import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' import { createMasyuCandidateBridgeLineRule } from './rules/bridges' import { + createAdjacentWhitePearlsLookaheadRule, createBlackPearlCandidatePruningRule, createWhitePearlCandidatePruningRule, } from './rules/candidates' @@ -481,6 +482,7 @@ describe('Masyu pearl rules', () => { 'Prevent Premature Loop', 'Black Pearl Candidate Pruning', 'White Pearl Candidate Pruning', + 'Adjacent White Pearls LookAhead', 'Pearl Completion', 'Cell Completion', 'Black Pearl Strong Inference', @@ -812,7 +814,10 @@ describe('Masyu loop rules', () => { expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBe( rules.indexOf('masyu-black-pearl-candidate-pruning') + 1, ) - expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBeLessThan( + expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBe( + rules.indexOf('masyu-white-pearl-candidate-pruning') + 1, + ) + expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBeLessThan( rules.indexOf('pearl-completion'), ) }) @@ -1142,6 +1147,112 @@ describe('Masyu white pearl candidate pruning', () => { }) }) +describe('Masyu adjacent white pearls lookahead', () => { + it('keeps parallel paths for horizontal white pearls when through is impossible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 3], [2, 3])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + [lineKey([2, 2], [2, 3])]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(2, 2), cellKey(2, 3)]) + expect(result?.message).toContain('parallel') + }) + + it('keeps one horizontal through-line when parallel is impossible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 2], [2, 3])]: 'line', + [lineKey([2, 1], [2, 2])]: 'line', + [lineKey([2, 3], [2, 4])]: 'line', + [lineKey([2, 2], [3, 2])]: 'blank', + [lineKey([2, 3], [3, 3])]: 'blank', + }) + expect(result?.message).toContain('one straight line') + }) + + it('keeps parallel paths for vertical white pearls when through is impossible', () => { + const puzzle = createMasyuPuzzle(6, 5) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 3, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 1], [2, 2])]: 'line', + [lineKey([2, 2], [2, 3])]: 'line', + [lineKey([3, 1], [3, 2])]: 'line', + [lineKey([3, 2], [3, 3])]: 'line', + [lineKey([2, 2], [3, 2])]: 'blank', + }) + }) + + it('keeps one vertical through-line when parallel is impossible', () => { + const puzzle = createMasyuPuzzle(6, 5) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 3, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([3, 1], [3, 2]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([3, 2], [4, 2])]: 'line', + [lineKey([2, 2], [2, 3])]: 'blank', + [lineKey([3, 2], [3, 3])]: 'blank', + }) + }) + + it('does nothing when both adjacent-white patterns remain feasible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + + expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() + }) + + it('does nothing when both adjacent-white patterns are impossible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + + expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() + }) + + it('does not force the remaining adjacent-white pattern through a degree-2 cell', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 1], [3, 1]), 'line') + + expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() + }) +}) + describe('Masyu shared helper primitives', () => { it('collects compatible line and tile decisions without overwriting conflicts', () => { const puzzle = createMasyuPuzzle(3, 3) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index c862c57..e9d280d 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -2,6 +2,7 @@ import type { Rule } from '../types' import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' import { createMasyuCandidateBridgeLineRule } from './rules/bridges' import { + createAdjacentWhitePearlsLookaheadRule, createBlackPearlCandidatePruningRule, createWhitePearlCandidatePruningRule, } from './rules/candidates' @@ -39,6 +40,7 @@ export const deterministicMasyuRules: Rule[] = [ createPreventPrematureLoopRule(), createBlackPearlCandidatePruningRule(), createWhitePearlCandidatePruningRule(), + createAdjacentWhitePearlsLookaheadRule(), createPearlCompletionRule(), createCellCompletionRule(), ] diff --git a/src/domain/rules/masyu/rules/candidates.ts b/src/domain/rules/masyu/rules/candidates.ts index dd65840..bf0a168 100644 --- a/src/domain/rules/masyu/rules/candidates.ts +++ b/src/domain/rules/masyu/rules/candidates.ts @@ -1,7 +1,16 @@ +import { cellKey, parseCellKey } from '../../../ir/keys' import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' import { createMasyuLineDecisionCollector } from './decisionCollector' -import { formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' +import type { MasyuLineOverlay } from './lineGraph' +import { addMasyuOverlayDecision } from './pearlCandidates' +import { + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuDirectionalLine, + oppositeMasyuDirection, + type MasyuDirection, +} from './shared' import { createMasyuLookaheadContext } from './lookahead' export const createBlackPearlCandidatePruningRule = (): Rule => ({ @@ -103,3 +112,171 @@ export const createWhitePearlCandidatePruningRule = (): Rule => ({ return null }, }) + +type AdjacentWhitePearlPatternKind = 'parallel' | 'through' + +type AdjacentWhitePearlPattern = { + kind: AdjacentWhitePearlPatternKind + overlay: Map +} + +type AdjacentWhitePearlPatternResult = { + kind: AdjacentWhitePearlPatternKind + overlay: MasyuLineOverlay | null +} + +type AdjacentWhitePearlPair = { + first: string + second: string + direction: MasyuDirection +} + +const perpendicularDirections = ( + direction: MasyuDirection, +): [MasyuDirection, MasyuDirection] => + direction === 'N' || direction === 'S' ? ['E', 'W'] : ['N', 'S'] + +const addRequiredLine = ( + puzzle: PuzzleIR, + overlay: Map, + pearlKey: string, + direction: MasyuDirection, +): boolean => { + const line = getMasyuDirectionalLine(puzzle, pearlKey, direction) + return line !== null && addMasyuOverlayDecision(overlay, line.lineKey, 'line') +} + +const addOptionalBlank = ( + puzzle: PuzzleIR, + overlay: Map, + pearlKey: string, + direction: MasyuDirection, +): boolean => { + const line = getMasyuDirectionalLine(puzzle, pearlKey, direction) + return ( + line === null || addMasyuOverlayDecision(overlay, line.lineKey, 'blank') + ) +} + +const buildAdjacentWhitePearlPattern = ( + puzzle: PuzzleIR, + pair: AdjacentWhitePearlPair, + kind: AdjacentWhitePearlPatternKind, +): AdjacentWhitePearlPattern | null => { + const overlay = new Map() + const throughDirections: [MasyuDirection, MasyuDirection] = [ + pair.direction, + oppositeMasyuDirection(pair.direction), + ] + const parallelDirections = perpendicularDirections(pair.direction) + + const lineDirections = + kind === 'through' ? throughDirections : parallelDirections + const blankDirections = + kind === 'through' ? parallelDirections : throughDirections + + for (const pearlKey of [pair.first, pair.second]) { + for (const direction of lineDirections) { + if (!addRequiredLine(puzzle, overlay, pearlKey, direction)) { + return null + } + } + for (const direction of blankDirections) { + if (!addOptionalBlank(puzzle, overlay, pearlKey, direction)) { + return null + } + } + } + + return { kind, overlay } +} + +const getAdjacentWhitePearlPairs = ( + puzzle: PuzzleIR, +): AdjacentWhitePearlPair[] => { + const pairs: AdjacentWhitePearlPair[] = [] + const whitePearls = new Set( + Object.entries(puzzle.cells).flatMap(([key, cell]) => + cell.clue?.kind === 'pearl' && cell.clue.color === 'white' ? [key] : [], + ), + ) + + for (const first of whitePearls) { + const [row, col] = parseCellKey(first) + const candidates: Array<[MasyuDirection, string]> = [ + ['E', cellKey(row, col + 1)], + ['S', cellKey(row + 1, col)], + ] + for (const [direction, second] of candidates) { + if (whitePearls.has(second)) { + pairs.push({ first, second, direction }) + } + } + } + + return pairs +} + +export const createAdjacentWhitePearlsLookaheadRule = (): Rule => ({ + id: 'masyu-adjacent-white-pearls-lookahead', + name: 'Adjacent White Pearls LookAhead', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const context = createMasyuLookaheadContext(puzzle) + + for (const pair of getAdjacentWhitePearlPairs(puzzle)) { + const patterns: AdjacentWhitePearlPatternResult[] = ( + ['parallel', 'through'] as const + ).map((kind) => { + const pattern = buildAdjacentWhitePearlPattern(puzzle, pair, kind) + return pattern ?? { kind, overlay: null } + }) + const feasiblePatterns = patterns.filter( + ( + pattern, + ): pattern is { + kind: AdjacentWhitePearlPatternKind + overlay: MasyuLineOverlay + } => + pattern.overlay !== null && + context.isOverlayLocallyFeasible( + [pair.first, pair.second], + pattern.overlay, + ), + ) + + if (feasiblePatterns.length !== 1) { + continue + } + + const selected = feasiblePatterns[0] + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + for (const [lineKeyValue, mark] of selected.overlay.entries()) { + decisions.add(lineKeyValue, mark) + } + + if (!decisions.hasChanges()) { + continue + } + + const diffs = decisions.diffs() + const firstLine = diffs[0]?.lineKey + const pairLabel = `${formatMasyuCellKeyLabel(pair.first)} and ${formatMasyuCellKeyLabel(pair.second)}` + const patternLabel = + selected.kind === 'parallel' + ? 'parallel straight-through paths' + : 'one straight line through both pearls' + return { + message: firstLine + ? `Adjacent white pearls ${pairLabel} have only ${patternLabel} left, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Adjacent white pearls lookahead applied.', + diffs, + affectedCells: [pair.first, pair.second], + affectedLines: diffs.map((diff) => diff.lineKey), + } + } + + return null + }, +}) diff --git a/src/domain/rules/masyu/rules/lookahead.ts b/src/domain/rules/masyu/rules/lookahead.ts index 58b7a8e..5a388b4 100644 --- a/src/domain/rules/masyu/rules/lookahead.ts +++ b/src/domain/rules/masyu/rules/lookahead.ts @@ -26,6 +26,10 @@ export type MasyuLookaheadContext = { overlay: MasyuLineOverlay, key: string, ) => MasyuDirectionalLine[] + isOverlayLocallyFeasible: ( + centerKeys: Iterable, + overlay: MasyuLineOverlay, + ) => boolean getFeasibleBlackPearlCandidates: (pearlKey: string) => BlackPearlCandidate[] getFeasibleWhitePearlCandidates: (pearlKey: string) => WhitePearlCandidate[] } @@ -195,10 +199,12 @@ export const createMasyuLookaheadContext = ( const areAffectedPearlsStillPossible = ( overlay: MasyuLineOverlay, - centerKey: string, + centerKeys: Iterable, ): boolean => { const affected = geometry.getAffectedPearls(overlay) - affected.add(centerKey) + for (const centerKey of centerKeys) { + affected.add(centerKey) + } for (const key of affected) { const color = geometry.pearlColors.get(key) if (!color) { @@ -274,6 +280,44 @@ export const createMasyuLookaheadContext = ( ) } + const hasPrematureLoopFromOverlay = (overlay: MasyuLineOverlay): boolean => { + const assumedLineEndpoints: Array<[left: number, right: number]> = [] + for (const [lineKeyValue, mark] of overlay.entries()) { + if (mark !== 'line') { + continue + } + if ((puzzle.lines[lineKeyValue]?.mark ?? 'unknown') === 'line') { + continue + } + assumedLineEndpoints.push(geometry.lineEndpoints(lineKeyValue)) + } + return ( + assumedLineEndpoints.length > 0 && + wouldCreatePrematureLoop(assumedLineEndpoints) + ) + } + + const isOverlayLocallyFeasible = ( + centerKeys: Iterable, + overlay: MasyuLineOverlay, + ): boolean => { + if (!isOverlayConsistentWithPuzzle(overlay)) { + return false + } + for (const key of geometry.getTouchedCells(overlay.keys())) { + if ( + !isCellDegreeValid(overlay, key) || + !isPearlShapeStillPossible(overlay, key) + ) { + return false + } + } + if (hasPrematureLoopFromOverlay(overlay)) { + return false + } + return areAffectedPearlsStillPossible(overlay, centerKeys) + } + const isBlackPearlCandidateFeasible = ( pearlKey: string, candidate: BlackPearlCandidate, @@ -296,7 +340,7 @@ export const createMasyuLookaheadContext = ( if (hasPrematureLoopFromCandidate(candidate)) { return false } - return areAffectedPearlsStillPossible(overlay, pearlKey) + return areAffectedPearlsStillPossible(overlay, [pearlKey]) } const getFeasibleBlackPearlCandidates = ( @@ -334,7 +378,7 @@ export const createMasyuLookaheadContext = ( ) { return false } - return areAffectedPearlsStillPossible(overlay, pearlKey) + return areAffectedPearlsStillPossible(overlay, [pearlKey]) } const getFeasibleWhitePearlCandidates = ( @@ -363,6 +407,7 @@ export const createMasyuLookaheadContext = ( getBlackPearlKeys: () => geometry.blackPearlKeys, getWhitePearlKeys: () => geometry.whitePearlKeys, getIncidentEntries: geometry.getIncidentEntries, + isOverlayLocallyFeasible, getFeasibleBlackPearlCandidates, getFeasibleWhitePearlCandidates, } From 027a5072b763780584e176477e3418be58fffb0d Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 22 May 2026 17:00:51 +0800 Subject: [PATCH 04/22] feat: white pearl strong inference --- src/domain/rules/masyu/rules.test.ts | 170 ++++++++++++- src/domain/rules/masyu/rules.ts | 2 + .../masyu/rules/blackPearlStrongInference.ts | 90 ++----- .../rules/masyu/rules/strongInference.ts | 83 +++++++ .../masyu/rules/whitePearlStrongInference.ts | 225 ++++++++++++++++++ 5 files changed, 497 insertions(+), 73 deletions(-) create mode 100644 src/domain/rules/masyu/rules/strongInference.ts create mode 100644 src/domain/rules/masyu/rules/whitePearlStrongInference.ts diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index f5f7fe0..fdbd551 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -31,6 +31,7 @@ import { } from './rules/patterns' import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' import { deterministicMasyuRules } from './rules' +import { createWhitePearlStrongInferenceRule } from './rules/whitePearlStrongInference' import { createMasyuLineDecisionCollector, createMasyuTileDecisionCollector, @@ -486,6 +487,7 @@ describe('Masyu pearl rules', () => { 'Pearl Completion', 'Cell Completion', 'Black Pearl Strong Inference', + 'White Pearl Strong Inference', ]) }) @@ -1470,7 +1472,173 @@ describe('Masyu black pearl strong inference', () => { expect( rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id), ).toEqual(deterministicMasyuRules.map((rule) => rule.id)) - expect(rules.at(-1)?.id).toBe('masyu-black-pearl-strong-inference') + expect(rules.at(-2)?.id).toBe('masyu-black-pearl-strong-inference') + expect(rules.at(-1)?.id).toBe('masyu-white-pearl-strong-inference') + }) +}) + +describe('Masyu white pearl strong inference', () => { + it('forces the opposite white pearl axis when one axis causes a tile-color contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + puzzle.tiles[tileKey(2, 3)] = { fill: 'yellow' } + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const east = lineKey([2, 2], [2, 3]) + const west = lineKey([2, 1], [2, 2]) + + const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [west]: 'line', + [north]: 'blank', + [south]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(2, 2)]) + expect(result?.affectedLines).toEqual([east, west, north, south]) + expect(result?.message).toContain('tile-color contradiction') + expect(result?.message).toContain('must go horizontal') + }) + + it('uses deterministic downstream rules to find a white-axis contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const east = lineKey([2, 2], [2, 3]) + const west = lineKey([2, 1], [2, 2]) + const westOfNeighbor = lineKey([1, 1], [1, 2]) + const eastOfNeighbor = lineKey([1, 2], [1, 3]) + const downstreamRule: Rule = { + id: 'test-white-downstream-degree', + name: 'Test White Downstream Degree', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[westOfNeighbor]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Force a downstream contradiction', + diffs: [ + { + kind: 'line', + lineKey: westOfNeighbor, + from: 'unknown', + to: 'line', + }, + { + kind: 'line', + lineKey: eastOfNeighbor, + from: 'unknown', + to: 'line', + }, + ], + affectedCells: [], + affectedLines: [westOfNeighbor, eastOfNeighbor], + } + }, + } + + const result = createWhitePearlStrongInferenceRule(() => [ + downstreamRule, + ]).apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [west]: 'line', + [north]: 'blank', + [south]: 'blank', + }) + expect(result?.message).toContain('after 1 step') + }) + + it('does not copy white trial progress back into the real puzzle', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + const north = lineKey([1, 2], [2, 2]) + const unrelated = lineKey([4, 3], [4, 4]) + const harmlessRule: Rule = { + id: 'test-white-harmless-trial-progress', + name: 'Test White Harmless Trial Progress', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Harmless trial-only progress', + diffs: [ + { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, + ], + affectedCells: [], + affectedLines: [unrelated], + } + }, + } + + const result = createWhitePearlStrongInferenceRule(() => [harmlessRule], { + maxTrialSteps: 1, + }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[unrelated]?.mark).toBe('unknown') + }) + + it('returns null when the white trial budget times out', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + + const result = createWhitePearlStrongInferenceRule(() => [], { + maxMs: -1, + }).apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not overwrite decided lines when forcing the opposite white axis', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + puzzle.tiles[tileKey(2, 3)] = { fill: 'yellow' } + const east = lineKey([2, 2], [2, 3]) + markLine(puzzle, east, 'blank') + + const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[east]?.mark).toBe('blank') + }) + + it('tries white pearls with denser nearby pearl context first', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 1)] = { fill: 'yellow' } + puzzle.tiles[tileKey(1, 2)] = { fill: 'yellow' } + addPearl(puzzle, 4, 4, 'white') + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 3, 4, 'white') + puzzle.tiles[tileKey(4, 4)] = { fill: 'yellow' } + puzzle.tiles[tileKey(4, 5)] = { fill: 'yellow' } + const prioritizedEast = lineKey([4, 4], [4, 5]) + const prioritizedWest = lineKey([4, 3], [4, 4]) + const prioritizedNorth = lineKey([3, 4], [4, 4]) + const prioritizedSouth = lineKey([4, 4], [5, 4]) + + const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) + + expect(result?.affectedCells).toEqual([cellKey(4, 4)]) + expectLineDiffs(result?.diffs, { + [prioritizedEast]: 'line', + [prioritizedWest]: 'line', + [prioritizedNorth]: 'blank', + [prioritizedSouth]: 'blank', + }) }) }) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index e9d280d..51748aa 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -24,6 +24,7 @@ import { createDoubleBlackSqueezeRule, } from './rules/patterns' import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' +import { createWhitePearlStrongInferenceRule } from './rules/whitePearlStrongInference' export const deterministicMasyuRules: Rule[] = [ createWhiteCircleRule(), @@ -48,4 +49,5 @@ export const deterministicMasyuRules: Rule[] = [ export const masyuRules: Rule[] = [ ...deterministicMasyuRules, createBlackPearlStrongInferenceRule(() => deterministicMasyuRules), + createWhitePearlStrongInferenceRule(() => deterministicMasyuRules), ] diff --git a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts index 20c76cc..55782e1 100644 --- a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts +++ b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts @@ -1,10 +1,7 @@ -import { clonePuzzle } from '../../../ir/normalize' import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' import { - applyMasyuLineAssumption, runMasyuTrialUntilFixpoint, - type MasyuTrialResult, } from './trial' import { getMasyuBlackPearlKeys } from './pearlSelectors' import { @@ -17,16 +14,17 @@ import { oppositeMasyuDirection, type MasyuDirection, } from './shared' - -const STRONG_MAX_CANDIDATES = 200 -const STRONG_MAX_TRIAL_STEPS = 60 -const STRONG_MAX_MS = 4000 - -type BlackPearlStrongInferenceOptions = { - maxCandidates?: number - maxTrialSteps?: number - maxMs?: number -} +import { + buildMasyuStrongBranch, + deriveMasyuStrongProbeBudgets, + describeMasyuStrongTrialResult, + immediateMasyuStrongContradictionResult, + STRONG_MAX_CANDIDATES, + STRONG_MAX_MS, + STRONG_MAX_TRIAL_STEPS, + type MasyuLineAssumption, + type MasyuStrongInferenceOptions, +} from './strongInference' type BlackPearlExitCandidate = { pearlKey: string @@ -36,19 +34,6 @@ type BlackPearlExitCandidate = { oppositeLine: string | null } -type StrongBranch = { - puzzle: PuzzleIR - setupOk: boolean - setupDescription: string -} - -const deriveProbeBudgets = (maxTrialSteps: number): number[] => { - const cappedMax = Math.max(1, maxTrialSteps) - return [12, 36, cappedMax] - .map((budget) => Math.min(budget, cappedMax)) - .filter((budget, index, arr) => arr.indexOf(budget) === index) -} - const isUndecidedBlackPearl = (puzzle: PuzzleIR, pearlKey: string): boolean => Object.values(getMasyuIncidentDirectionalLines(puzzle, pearlKey)).some( (line) => line?.mark === 'unknown', @@ -91,59 +76,20 @@ const collectBlackPearlExitCandidates = ( const buildBranch = ( puzzle: PuzzleIR, candidate: BlackPearlExitCandidate, -): StrongBranch => { - const branch = clonePuzzle(puzzle) - const assumptions: Array<[lineKey: string, mark: 'line' | 'blank']> = [ +): ReturnType => { + const assumptions: MasyuLineAssumption[] = [ [candidate.firstLine, 'line'], [candidate.secondLine, 'line'], ] if (candidate.oppositeLine) { assumptions.push([candidate.oppositeLine, 'blank']) } - - let setupOk = true - for (const [lineKeyValue, mark] of assumptions) { - setupOk = applyMasyuLineAssumption(branch, lineKeyValue, mark) && setupOk - } - - const setupDescription = assumptions - .map( - ([lineKeyValue, mark]) => `${formatMasyuLineLabel(lineKeyValue)} ${mark}`, - ) - .join(', ') - - return { puzzle: branch, setupOk, setupDescription } -} - -const immediateContradictionResult = (puzzle: PuzzleIR): MasyuTrialResult => ({ - contradiction: true, - timedOut: false, - exhausted: false, - puzzle, - stepsRun: 0, - elapsedMs: 0, - contradictionReason: { - kind: 'line-assumption', - message: - 'line-assumption contradiction: this exit assumption conflicts with an already decided Masyu line', - }, -}) - -const describeTrialResult = (result: MasyuTrialResult): string => { - if (result.contradiction) { - return `${result.contradictionReason?.message ?? 'a contradiction'} after ${result.stepsRun} ${ - result.stepsRun === 1 ? 'step' : 'steps' - }` - } - if (result.exhausted) { - return `no contradiction within ${result.stepsRun} trial steps` - } - return `no contradiction after ${result.stepsRun} ${result.stepsRun === 1 ? 'step' : 'steps'}` + return buildMasyuStrongBranch(puzzle, assumptions) } export const createBlackPearlStrongInferenceRule = ( getDeterministicRules: () => Rule[], - options: BlackPearlStrongInferenceOptions = {}, + options: MasyuStrongInferenceOptions = {}, ): Rule => ({ id: 'masyu-black-pearl-strong-inference', name: 'Black Pearl Strong Inference', @@ -158,7 +104,7 @@ export const createBlackPearlStrongInferenceRule = ( } const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) - const budgets = deriveProbeBudgets( + const budgets = deriveMasyuStrongProbeBudgets( options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, ) for (const budget of budgets) { @@ -175,7 +121,7 @@ export const createBlackPearlStrongInferenceRule = ( budget, deadlineMs, ) - : immediateContradictionResult(branch.puzzle) + : immediateMasyuStrongContradictionResult(branch.puzzle) if (result.timedOut) { return null } @@ -191,7 +137,7 @@ export const createBlackPearlStrongInferenceRule = ( return { message: `Black Pearl Strong Inference: assuming ${formatMasyuCellKeyLabel(candidate.pearlKey)} exits ${candidate.direction} ` + - `(${branch.setupDescription}) leads to ${describeTrialResult(result)}, so ${formatMasyuLineLabel( + `(${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult(result)}, so ${formatMasyuLineLabel( candidate.firstLine, )} is crossed out.`, diffs: [ diff --git a/src/domain/rules/masyu/rules/strongInference.ts b/src/domain/rules/masyu/rules/strongInference.ts new file mode 100644 index 0000000..857d8fe --- /dev/null +++ b/src/domain/rules/masyu/rules/strongInference.ts @@ -0,0 +1,83 @@ +import { clonePuzzle } from '../../../ir/normalize' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import { + applyMasyuLineAssumption, + type MasyuTrialResult, +} from './trial' +import { formatMasyuLineLabel } from './shared' + +export type MasyuStrongInferenceOptions = { + maxCandidates?: number + maxTrialSteps?: number + maxMs?: number +} + +export type MasyuStrongBranch = { + puzzle: PuzzleIR + setupOk: boolean + setupDescription: string +} + +export type MasyuLineAssumption = [lineKey: string, mark: LineMark] + +export const STRONG_MAX_CANDIDATES = 200 +export const STRONG_MAX_TRIAL_STEPS = 100 +export const STRONG_MAX_MS = 10000 + +export const deriveMasyuStrongProbeBudgets = ( + maxTrialSteps: number, +): number[] => { + const cappedMax = Math.max(1, maxTrialSteps) + return [12, 36, cappedMax] + .map((budget) => Math.min(budget, cappedMax)) + .filter((budget, index, arr) => arr.indexOf(budget) === index) +} + +export const buildMasyuStrongBranch = ( + puzzle: PuzzleIR, + assumptions: MasyuLineAssumption[], +): MasyuStrongBranch => { + const branch = clonePuzzle(puzzle) + let setupOk = true + for (const [lineKeyValue, mark] of assumptions) { + setupOk = applyMasyuLineAssumption(branch, lineKeyValue, mark) && setupOk + } + + const setupDescription = assumptions + .map( + ([lineKeyValue, mark]) => `${formatMasyuLineLabel(lineKeyValue)} ${mark}`, + ) + .join(', ') + + return { puzzle: branch, setupOk, setupDescription } +} + +export const immediateMasyuStrongContradictionResult = ( + puzzle: PuzzleIR, +): MasyuTrialResult => ({ + contradiction: true, + timedOut: false, + exhausted: false, + puzzle, + stepsRun: 0, + elapsedMs: 0, + contradictionReason: { + kind: 'line-assumption', + message: + 'line-assumption contradiction: this assumption conflicts with an already decided Masyu line', + }, +}) + +export const describeMasyuStrongTrialResult = ( + result: MasyuTrialResult, +): string => { + if (result.contradiction) { + return `${result.contradictionReason?.message ?? 'a contradiction'} after ${result.stepsRun} ${ + result.stepsRun === 1 ? 'step' : 'steps' + }` + } + if (result.exhausted) { + return `no contradiction within ${result.stepsRun} trial steps` + } + return `no contradiction after ${result.stepsRun} ${result.stepsRun === 1 ? 'step' : 'steps'}` +} diff --git a/src/domain/rules/masyu/rules/whitePearlStrongInference.ts b/src/domain/rules/masyu/rules/whitePearlStrongInference.ts new file mode 100644 index 0000000..e581954 --- /dev/null +++ b/src/domain/rules/masyu/rules/whitePearlStrongInference.ts @@ -0,0 +1,225 @@ +import { cellKey, parseCellKey } from '../../../ir/keys' +import type { PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { createMasyuLineDecisionCollector } from './decisionCollector' +import { createMasyuLookaheadContext } from './lookahead' +import type { WhitePearlCandidate } from './pearlCandidates' +import { masyuPearlCandidateToOverlay } from './pearlCandidates' +import { + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuDirectionalLine, + getMasyuIncidentDirectionalLines, + MASYU_DIRECTIONS, + type MasyuDirection, +} from './shared' +import { + buildMasyuStrongBranch, + deriveMasyuStrongProbeBudgets, + describeMasyuStrongTrialResult, + immediateMasyuStrongContradictionResult, + STRONG_MAX_CANDIDATES, + STRONG_MAX_MS, + STRONG_MAX_TRIAL_STEPS, + type MasyuLineAssumption, + type MasyuStrongInferenceOptions, +} from './strongInference' +import { runMasyuTrialUntilFixpoint } from './trial' + +type WhitePearlAxisCandidate = { + pearlKey: string + candidates: [WhitePearlCandidate, WhitePearlCandidate] + pearlNeighborCount: number + decidedLocalLineCount: number + order: number +} + +const axisLabel = (axis: [MasyuDirection, MasyuDirection]): string => + axis[0] === 'N' || axis[0] === 'S' ? 'vertical' : 'horizontal' + +const getNeighborPearlCount = (puzzle: PuzzleIR, pearlKey: string): number => { + const [row, col] = parseCellKey(pearlKey) + let count = 0 + for (let rowDelta = -1; rowDelta <= 1; rowDelta += 1) { + for (let colDelta = -1; colDelta <= 1; colDelta += 1) { + if (rowDelta === 0 && colDelta === 0) { + continue + } + const nextRow = row + rowDelta + const nextCol = col + colDelta + if ( + nextRow < 0 || + nextRow >= puzzle.rows || + nextCol < 0 || + nextCol >= puzzle.cols + ) { + continue + } + const clue = puzzle.cells[cellKey(nextRow, nextCol)]?.clue + if (clue?.kind === 'pearl') { + count += 1 + } + } + } + return count +} + +const getDecidedLocalLineCount = ( + puzzle: PuzzleIR, + pearlKey: string, +): number => { + const localCells = new Set([pearlKey]) + for (const direction of MASYU_DIRECTIONS) { + const line = getMasyuDirectionalLine(puzzle, pearlKey, direction) + if (line) { + localCells.add(line.neighborKey) + } + } + + const localLines = new Set() + for (const key of localCells) { + for (const line of Object.values(getMasyuIncidentDirectionalLines(puzzle, key))) { + if (line) { + localLines.add(line.lineKey) + } + } + } + + return [...localLines].filter( + (lineKeyValue) => + (puzzle.lines[lineKeyValue]?.mark ?? 'unknown') !== 'unknown', + ).length +} + +const collectWhitePearlAxisCandidates = ( + puzzle: PuzzleIR, + maxCandidates: number, +): WhitePearlAxisCandidate[] => { + const context = createMasyuLookaheadContext(puzzle) + return context + .getWhitePearlKeys() + .flatMap((pearlKey, order): WhitePearlAxisCandidate[] => { + const candidates = context.getFeasibleWhitePearlCandidates(pearlKey) + if (candidates.length !== 2) { + return [] + } + return [ + { + pearlKey, + candidates: [candidates[0], candidates[1]], + pearlNeighborCount: getNeighborPearlCount(puzzle, pearlKey), + decidedLocalLineCount: getDecidedLocalLineCount(puzzle, pearlKey), + order, + }, + ] + }) + .sort( + (left, right) => + right.pearlNeighborCount - left.pearlNeighborCount || + right.decidedLocalLineCount - left.decidedLocalLineCount || + left.order - right.order, + ) + .slice(0, maxCandidates) +} + +const candidateAssumptions = ( + candidate: WhitePearlCandidate, +): MasyuLineAssumption[] => { + const overlay = masyuPearlCandidateToOverlay(candidate) + return overlay ? [...overlay.entries()] : [] +} + +const collectCandidateDiffs = ( + puzzle: PuzzleIR, + candidate: WhitePearlCandidate, +): ReturnType['diffs']> | null => { + const overlay = masyuPearlCandidateToOverlay(candidate) + if (!overlay) { + return null + } + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + for (const [lineKeyValue, mark] of overlay.entries()) { + if (!decisions.add(lineKeyValue, mark)) { + return null + } + } + return decisions.hasChanges() ? decisions.diffs() : null +} + +export const createWhitePearlStrongInferenceRule = ( + getDeterministicRules: () => Rule[], + options: MasyuStrongInferenceOptions = {}, +): Rule => ({ + id: 'masyu-white-pearl-strong-inference', + name: 'White Pearl Strong Inference', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const deterministicRules = getDeterministicRules() + const candidates = collectWhitePearlAxisCandidates( + puzzle, + options.maxCandidates ?? STRONG_MAX_CANDIDATES, + ) + if (candidates.length === 0) { + return null + } + + const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) + const budgets = deriveMasyuStrongProbeBudgets( + options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, + ) + + for (const budget of budgets) { + for (const pearlCandidate of candidates) { + if (Date.now() > deadlineMs) { + return null + } + + for (const assumedIndex of [0, 1] as const) { + const assumed = pearlCandidate.candidates[assumedIndex] + const forced = pearlCandidate.candidates[assumedIndex === 0 ? 1 : 0] + const assumptions = candidateAssumptions(assumed) + if (assumptions.length === 0) { + continue + } + const branch = buildMasyuStrongBranch(puzzle, assumptions) + const result = branch.setupOk + ? runMasyuTrialUntilFixpoint( + branch.puzzle, + deterministicRules, + budget, + deadlineMs, + ) + : immediateMasyuStrongContradictionResult(branch.puzzle) + if (result.timedOut) { + return null + } + if (!result.contradiction) { + continue + } + + const diffs = collectCandidateDiffs(puzzle, forced) + if (!diffs || diffs.length === 0) { + continue + } + const firstLine = diffs[0]?.lineKey + return { + message: + `White Pearl Strong Inference: assuming ${formatMasyuCellKeyLabel(pearlCandidate.pearlKey)} goes ${axisLabel( + assumed.axis, + )} (${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult( + result, + )}, so it must go ${axisLabel(forced.axis)} through ${formatMasyuLineLabel( + firstLine, + )}${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.`, + diffs, + affectedCells: [pearlCandidate.pearlKey], + affectedLines: diffs.map((diff) => diff.lineKey), + } + } + } + } + + return null + }, +}) From 0df18156372f6455340d456483a94691d46fea2b Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 28 May 2026 11:11:08 +0800 Subject: [PATCH 05/22] chore: docs sync --- docs/Masyu_Rule_Gap_And_Integration_Plan.md | 209 +++++++++++ docs/Puzzlink_Assistance_Masyu_Analysis.md | 208 +++++++++++ docs/techniques/masyu.md | 374 +++++++++++++++++++- 3 files changed, 777 insertions(+), 14 deletions(-) create mode 100644 docs/Masyu_Rule_Gap_And_Integration_Plan.md create mode 100644 docs/Puzzlink_Assistance_Masyu_Analysis.md diff --git a/docs/Masyu_Rule_Gap_And_Integration_Plan.md b/docs/Masyu_Rule_Gap_And_Integration_Plan.md new file mode 100644 index 0000000..bb2f3d4 --- /dev/null +++ b/docs/Masyu_Rule_Gap_And_Integration_Plan.md @@ -0,0 +1,209 @@ +# Masyu 规则差距与融合路线 + +这份文档把 `Puzzlink_Assistance.js` 的 Masyu 思路和当前 PuzzleKit Masyu 规则栈放在一起比较。目标不是把作者脚本原样搬进来,而是判断哪些技巧适合拆成 replay-safe、step-wise、可解释的规则。 + +## 当前两套实现的定位差异 + +作者脚本的定位是“在 puzz.link 页面上快速辅助推导”。它直接写 puzz.link board 状态,规则之间没有清晰边界,只要能推出线、叉或顶点颜色,就立刻写入并继续下一轮。 + +PuzzleKit 的定位是“纯前端、分步、可解释的推理工具”。当前 Masyu 规则通过 `RuleApplication` 返回 diff,每一步需要说清楚为什么改了哪些线或 tile。这让规则更容易回放、测试和解释,但也意味着不能简单复制作者那种大而混杂的 assist 循环。 + +这个差异决定了推荐路线:**吸收作者的推理思想,不吸收作者的 monolithic 结构**。 + +## 当前 PuzzleKit 的优势 + +PuzzleKit 已经有几块作者脚本没有的能力: + +- **可解释回放**:每条规则返回明确的 `LineDiff` / `TileDiff`,有 message、affected cells/lines/tiles。 +- **结构化候选模型**:黑珠、白珠都有候选 pruning 的基础设施,不只是硬编码 pattern。 +- **bounded strong inference**:已有黑珠和白珠强推断,能用有限 trial propagation 排除导致矛盾的局部假设。 +- **相邻白珠 lookahead**:`Adjacent White Pearls LookAhead` 把相邻白珠的两类穿法作为结构化候选,而不是只写坐标 pattern。 +- **tile parity 基础设施**:`PuzzleIR.tiles` 已经把 Masyu 的 inside/outside 思想落成 vertex-centered tile,可以承接作者的 in/out 推理。 +- **line graph 基础设施**:已有 premature loop、known line components、candidate graph、bridge line 等基础工具。 + +因此,后续不需要把精力放在“补一个通用搜索器”上。更高收益的是补齐作者脚本里那些便宜、确定、可解释的 topology 和 pattern 结论。 + +## 当前缺口 + +### 1. SingleLoopInCell 的 loop-graph 细节还没有完全等价物 + +PuzzleKit 已有 `Prevent Premature Loop`,也有 `Candidate Bridge Line`,但作者 `SingleLoopInCell()` 里还有不少更细的单环推理: + +- 必须经过格只剩两条候选边时的强制; +- 当前路径分量会在某格自连时的排除; +- 某个必须经过格是连接两个候选路径组的唯一入口时的强制; +- 使用 `lineaux` 临时可能线判断候选路径组; +- 长路径分量与局部空格之间的“包围/反弹”推理。 + +其中有些可能已经被 `Cell Completion`、`Pearl Completion` 或候选 pruning 间接覆盖,但还没有形成一套清晰的“单环通用图规则”。 + +### 2. 连通性框架拆得更可解释,但能力不如作者集中 + +当前 PuzzleKit 把连通性拆成两类: + +- `Candidate Bridge Line`:在候选 line graph 中找桥边,强制连接 required sources。 +- `Tile Connectivity Cut Coloring`:在 tile color graph 中找 cut / unreachable 区域,推出 inside/outside 颜色。 + +作者的 `CellConnected()` 则是一个更泛化的 low-link 框架,可以根据配置同时做 cut vertex、bridge、不可达、强制连接。PuzzleKit 的拆分更符合解释性,但目前少了几个能力: + +- required-source articulation:某个候选 cell/component 是连接多个必达源的割点; +- candidate cut vertex 周围的线决策:不仅知道“这里必须通过”,还要转换成哪些边必须线/叉; +- bridge 与 pearl-local 约束结合:桥边不是孤立判断,而是会触发黑珠/白珠的出口选择。 + +### 3. in/out 颜色传播还可以更主动 + +作者把顶点 in/out 颜色和线/叉判断放得很近: + +- 同色顶点之间是叉; +- 异色顶点之间是线; +- 白珠对角顶点异色; +- 部分黑白组合直接推出顶点颜色关系; +- 颜色一旦传播,又马上反推线/叉。 + +PuzzleKit 已经有 `Tile Color Propagation`、`Color-Pearl Propagation`、`Color-Line Propagation`,但仍偏“基础 parity”。缺少的主要是: + +- 更多 pearl-local tile color implication; +- linegraph 或 candidate graph 触发的 tile color implication; +- 把作者某些 cross qsub 结论稳定翻译成 `PuzzleIR.tiles` 的规则。 + +### 4. 部分高收益 pattern 尚未覆盖 + +当前已有 `Black Facing Consecutive Whites`、`Black Diagonal White Pinch`、`Consecutive White Pearls Straight`、`Double Black Squeeze`、相邻白珠 lookahead 等规则。 + +作者脚本中仍有一些值得补的 pattern: + +- 白珠某轴两侧都无法满足相邻转弯时,强制走另一轴; +- 双黑夹迫的更宽条件,不只是一条垂直边已 blank; +- 黑珠出口被第二段、邻近黑珠、边界或白珠形态排除时的即时结论; +- 长走廊 linegraph + 两个白珠 / 黑白组合导致的强制延伸; +- 已知路径分量触发的 “connectivity and black” / “connectivity and white”。 + +这些最好不要一次性堆成一个 `patterns.ts` 巨块,而是按“解释能否一句话说清楚”拆成小规则。 + +## 候选规则清单 + +| 作者技巧 | 当前状态 | 建议 | +| --- | --- | --- | +| 白珠已有一侧线后强制直行、垂直打叉 | 已覆盖主干 | 保持在白珠基础规则中 | +| 白珠一侧已连续直行两段,另一侧不能继续直行 | 部分覆盖 | 用白珠候选模型校准后保留/补测试 | +| 白珠某轴两侧都不能完成相邻转弯,强制另一轴 | 部分覆盖 | 优先补成 `White Pearl Axis Feasibility` 类规则 | +| 黑珠已有出口后强制第二段直行 | 已覆盖主干 | 保持在黑珠基础/候选规则中 | +| 黑珠某出口因第二段不可走或邻近珍珠形态不可用而排除 | 部分覆盖 | 优先进入黑珠候选 pruning,而不是硬编码多个 if | +| 双黑夹迫 | 已有窄版 | 扩展条件,保留清晰 fixture | +| 黑珠面对两个连续白珠 | 已覆盖 | 检查作者远端/变体是否还缺 | +| 黑珠被两个斜向白珠 pinch | 已覆盖 | 保持现状,补充必要测试即可 | +| 连续三白珠强制垂直于 run | 已覆盖 | 保持现状 | +| 相邻白珠两种穿法只剩一种 | 已有 lookahead | 当前实现比作者更结构化,继续沿用 | +| 同一路径分量提前闭环打叉 | 已覆盖主干 | 扩展到更多局部自连形态 | +| 必须连通源之间的桥边强制线 | 已覆盖桥边 | 扩展到 articulation/cut vertex | +| 顶点 in/out 同色叉、异色线 | 已覆盖基础 parity | 增加更多触发颜色的来源 | +| 白珠对角顶点异色 | 已覆盖 | 保持在 color-pearl propagation | +| 黑白组合触发顶点颜色传播 | 未充分覆盖 | 作为 tile color 小规则补充 | +| linegraph 触发的黑珠/白珠方向选择 | 未充分覆盖 | 高优先级,适合单独成规则 | +| 作者的通用深度搜索/强推断 | 作者没有 | 不作为移植目标,保留 PuzzleKit 现有强推断 | + +## 推荐实现路径 + +### 第一步:先整理成规则族,不直接移植 + +先把作者脚本中的结论分成三类: + +- 已有规则覆盖,只需要补测试或改说明; +- 当前规则部分覆盖,可以用候选模型或 graph helper 扩展; +- 真正缺失,需要新增小规则。 + +这一步的关键是避免把 `MasyuAssist()` 重新写成一个新的大函数。每条规则都应有一个简短、可复述的理由。 + +### 第二步:优先增强 line graph 层 + +建议先扩展 `lineGraph.ts` 的表达能力,而不是继续堆坐标 pattern: + +- required sources 继续由珍珠和已知线分量提供; +- candidate graph 继续由所有非 blank 线构成; +- 在 bridge edge 之外,增加 articulation / cut vertex 分析; +- 提供“某个候选 cell/component 必须被通过”的结果,再由上层规则转换成 line decisions; +- 让 `bridges.ts` 从单纯桥边规则扩展成“候选连接强制规则族”。 + +这能吸收 `CellConnected()` 的核心收益,同时保持 PuzzleKit 的解释粒度。 + +### 第三步:补齐 SingleLoopInCell 中可命名的通用单环规则 + +推荐拆成几条稳定规则: + +- **Masyu Required Cell Degree**:必须经过的珍珠格只剩两条候选边时画线。 +- **Masyu Same Component Closure**:候选边会把已知路径分量提前自连时打叉。 +- **Masyu Required Source Connector**:某格或某边是连接 required sources 的唯一通路时强制。 +- **Masyu Candidate Path Bounce**:作者 `lineaux` 对应的局部候选路径组推理;只有在解释足够清楚时再做。 + +其中前两条最稳,第三条最有性能价值,第四条需要谨慎,因为它最容易变成难解释的隐式 lookahead。 + +### 第四步:补高收益 Masyu pattern + +pattern 的原则是:先选“短、确定、容易测试”的,而不是把作者所有坐标条件照搬。 + +优先顺序建议: + +1. 白珠轴可行性:某轴两侧都无法转弯,强制另一轴。 +2. linegraph + 白珠:白珠两侧同属一个路径分量时,排除该轴,强制垂直轴。 +3. linegraph + 黑珠:黑珠某方向会连接回同一路径分量时,强制反向出口。 +4. 双黑夹迫扩展:把作者更宽的不可行条件纳入现有规则。 +5. 黑白长走廊 pattern:只挑能用一句话解释的情况。 + +这些规则可以先落在 `patterns.ts` 或拆出新的 graph-pattern 文件;如果实现时发现共用大量 linegraph 查询,再回收进 helper。 + +### 第五步:把作者 in/out 思想转写到 tile parity + +不要引入 puzz.link 的 `CRQSUB` 语义。PuzzleKit 应继续使用: + +- `PuzzleIR.tiles` 表示顶点中心区域颜色; +- `line` 表示相邻 tiles 异色; +- `blank` 表示相邻 tiles 同色; +- 边界 tiles 作为 outside/yellow anchor。 + +可新增的方向: + +- 黑珠局部 tile color implication; +- 黑白组合触发的 tile color relation; +- 已知路径方向触发的 tile parity relation; +- tile color contradiction 作为 strong inference 的可解释失败原因。 + +每个颜色结论仍输出 `TileDiff`,每个线结论仍输出 `LineDiff`。 + +### 第六步:最后再整合强推断 + +作者脚本没有黑/白强推断,也没有当前这种结构化 lookahead。因此强推断不是移植重点。 + +推荐保持当前方向: + +- deterministic rules 先吃掉作者的便宜结论; +- 黑/白 strong inference 继续作为后置 fallback; +- 后续如果要增强 strong inference,应基于 typed candidate source,例如黑珠出口、白珠轴、相邻白珠穿法、tile color component; +- 不把任意未知线作为默认猜测对象,避免求解过程退化成黑箱搜索。 + +## 测试建议 + +文档阶段不需要运行测试。后续实现时建议按规则族建立 fixture: + +- `pnpm test:run src/domain/rules/masyu/rules.test.ts` +- graph bridge/articulation:桥边、割点、多个 required sources、无 required source 时不触发; +- 白珠组合:轴不可行、相邻白珠、linegraph 同分量排除; +- 黑珠组合:出口第二段不可行、双黑夹迫扩展、linegraph 同分量触发; +- tile color:白珠对角、黑白组合、同色叉/异色线、边界 outside anchor。 + +每条新增规则至少需要三个场景: + +- 一个最小触发局面; +- 一个已有线/叉冲突或候选不足时不触发的局面; +- 一个验证 affected cells/lines/tiles 与 message 合理的回放断言。 + +## 推荐优先级 + +短期优先做 graph 层和 linegraph + 珍珠混合规则,因为这最可能解释作者在大题上的性能优势: + +1. 扩展 line graph bridge/articulation helper。 +2. 补 `connectivity and white` / `connectivity and black` 的可解释版本。 +3. 补白珠轴可行性规则。 +4. 扩展双黑夹迫和黑珠出口排除。 +5. 再补 tile color 的黑白组合 implication。 + +长期来看,PuzzleKit 不应该追求复刻作者脚本的所有 `if`。更好的路线是:用作者脚本作为 pattern 和 topology 灵感来源,把其中高收益结论转成小而清楚的规则,让求解性能和解释性一起增长。 diff --git a/docs/Puzzlink_Assistance_Masyu_Analysis.md b/docs/Puzzlink_Assistance_Masyu_Analysis.md new file mode 100644 index 0000000..26e7466 --- /dev/null +++ b/docs/Puzzlink_Assistance_Masyu_Analysis.md @@ -0,0 +1,208 @@ +# Puzzlink Assistance 的 Masyu 求解流程分析 + +这份文档记录对 `Puzzlink_Assistance.js` 中 Masyu 部分的阅读结论。它不是移植说明,而是帮助我们理解作者脚本为什么在一些大题上表现不错,以及哪些思想值得拆解后吸收到 PuzzleKit 的可解释规则框架里。 + +## 总体流程 + +作者脚本是一个直接嵌入 puzz.link 页面的油猴脚本。它并不维护一套独立的 puzzle IR,而是直接读取和修改 puzz.link 的 `ui.puzzle.board`: + +- `assist()` 初始化本轮求解状态,设置 `board = ui.puzzle.board`,然后循环调用具体题型的 assist 函数。 +- 对 Masyu 来说,实际入口是 `MasyuAssist()`。 +- 每轮推导结束后,如果还有新增结论,脚本会用 `setTimeout(..., 0)` 继续下一轮,直到没有变化、超过 `MAXLOOP`、超时,或者 `Assist Step` 模式已经产生一步有效修改。 +- `stepcheck()` 是全局推进器:每当 `add_line`、`add_cross`、`add_inout` 等函数真的写入了状态,就增加计数;如果当前是 step 模式,会通过抛出 `"Step finished."` 立刻中止。 + +所以作者的求解不是“一个规则返回一个解释步骤”,而是“一个大循环不断把所有可用结论直接写回棋盘”。这对性能和覆盖面很有利,因为各种结论会在同一轮或下一轮马上互相喂给对方;代价是很难拆出清晰、稳定、可回放的推理说明。 + +## MasyuAssist 的结构 + +`MasyuAssist()` 很短,但它调用的通用函数很重: + +```js +function MasyuAssist() { + SingleLoopInCell({ + isPass: c => c.qnum !== CQNUM.none, + }); + ... +} +``` + +这句的含义是:把所有珍珠格都视为“必须被环经过”的格子,然后先运行一套通用单环推理。之后,作者再在 `forEachCell(cell => { for (let d = 0; d < 4; d++) { ... } })` 中枚举每个格子和四个旋转方向,执行 Masyu 专用 pattern。 + +可以把作者的 Masyu 求解分成两层: + +1. **通用单环层**:`SingleLoopInCell()` 负责所有单环谜题都可能用到的度数、死路、连通性、内外侧颜色传播。 +2. **Masyu 专用层**:`MasyuAssist()` 里硬编码白珠、黑珠、黑白组合、长走廊连接等局部 pattern。 + +这也是作者脚本强的地方:它不是只有 Masyu 局部规则,而是把 Masyu 珍珠规则放进了一个比较完整的 loop/topology 框架里反复迭代。 + +## 通用单环层:SingleLoopInCell + +`SingleLoopInCell()` 是作者 Masyu 性能的核心之一。它的输入是一组谓词和写入函数,Masyu 只特别指定了 `isPass: c => c.qnum !== CQNUM.none`,也就是珍珠必须经过。 + +它主要做这些事。 + +### 1. 单环基础约束 + +它扫描每个 cell 的四条边,维护: + +- `linecnt`:当前已知线数量; +- `emptycnt`:仍可走的候选边数量; +- `isPass(cell)`:这个格子是否必须被环经过; +- `isPathable(border)`:这条边是否还可能成为线。 + +典型结论包括: + +- 不可经过的格子周围全部打叉; +- 如果一个必须经过的格子只剩两条可行边,就把两条都画线; +- 如果一个格子已经有两条线,就把其他方向打叉; +- 如果某格没有足够出口,就打叉并标记为不可经过。 + +这些是单环谜题的“度数”和“死路”基础推理。 + +### 2. 防止 premature loop + +作者使用 puzz.link 的 `board.linegraph.components` 和每个 cell 的 `path` 信息判断已知线段是否属于同一个连通分量。 + +当某条候选边会把同一条路径提前闭成小环,而棋盘上还有其他必须经过或已经画出的部分没有连接进来时,脚本会把这条边打叉。这里对应 PuzzleKit 里的 `Prevent Premature Loop`,但作者还把它和更多局部路径形态混在一起使用。 + +### 3. 已知路径分量的连通性推理 + +`SingleLoopInCell()` 不只禁止小环,还会利用“多个路径分量最终必须成为一个环”的事实做强制: + +- 某个未经过的格子如果四周候选邻居都属于同一路径分量,进入它可能导致局部自连,于是相关边被排除。 +- 某个必须经过的格子如果只有一种方式可以连接两个不同候选路径组,就强制对应边。 +- `lineaux` 被用作临时“可能线”标记,帮助判断候选路径组的连通关系;它不是正式答案线,但能参与局部连接分析。 + +这部分是 PuzzleKit 当前最值得吸收的内容之一。作者并没有把它写成独立的“桥边规则”或“割点规则”,而是嵌在单环通用函数里,导致很多 topology 结论自然出现。 + +### 4. 顶点 in/out 颜色传播 + +在没有 ice/cross 这类特殊机制时,作者会给 grid cross 使用 `CRQSUB.in` / `CRQSUB.out` 的隐式标记: + +- 已知线两侧的顶点颜色不同; +- 已知叉或非线通道两侧颜色相同; +- 如果两个相邻顶点颜色相同,就打叉; +- 如果颜色不同,就画线; +- 白珠会强制对角顶点颜色相反; +- 某些黑白组合也会传播顶点颜色关系。 + +这和 PuzzleKit 当前的 `PuzzleIR.tiles` 很接近:我们把顶点中心的区域颜色存成 tile fill,线和叉对应 tile parity 的异同关系。区别是作者把颜色传播和线判断放在同一个大函数里持续迭代,而 PuzzleKit 为了可解释性拆成了 `Tile Color Propagation`、`Color-Pearl Propagation`、`Color-Line Propagation` 和 `Tile Connectivity Cut Coloring`。 + +## 通用连通框架:CellConnected + +`CellConnected()` 是作者脚本里另一个重要基础设施。它不是 Masyu 专用,而是一个可配置的 Tarjan 连通性工具。 + +它接收: + +- 什么算 shaded / unshaded; +- 发现必须 shaded 时怎么写入; +- 发现必须 unshaded 时怎么写入; +- 哪些边可通行、哪些边已连接; +- 是否把棋盘外侧当成 shaded; +- 是否要找 bridge,并把 bridge 转成线或 link。 + +内部做法是: + +- 用 DFS/Tarjan 维护 `ord` 和 `low`; +- 统计每个 DFS 子树里有多少目标对象; +- 如果某个点一旦移除会把必须连通的对象分开,就把它加入 `shadelist`; +- 如果某条边是连接必须连通对象的唯一桥,就把它加入 `bridgelist`; +- 最后统一调用 `add_shaded`、`add_unshaded` 或 `add_line` / `add_link`。 + +在 `SingleLoopInCell()` 中,它主要被用于 cross 顶点的 in/out 连通与传播;在其他谜题中也可用于黑格、白格、区域连通等推理。对 PuzzleKit 来说,它的价值不在于代码形式,而在于“同一个 low-link 框架可以同时服务割点、桥边、不可达区域、强制连接”这个抽象。 + +## Masyu 专用推理策略 + +作者在 `MasyuAssist()` 中写了一组旋转对称的局部判断。下面按推理家族归类,而不是逐条 `if` 机械编号。 + +### 1. 白珠直行与相邻转弯 + +白珠必须直行穿过,但至少一侧相邻格必须转弯。作者覆盖了几类常见情况: + +- 如果白珠某一侧已经有线,或垂直方向已不可走,就强制白珠沿该轴直行,并把垂直方向打叉。 +- 如果白珠一侧已经连续直行两段,那么另一侧不能也继续直行,于是远端候选边被打叉。 +- 如果白珠左右两侧都因为线、相邻白珠、黑珠、或不可行边而无法满足横向穿过后的转弯条件,就打叉横向、强制纵向。 + +其中第三类是比较有价值的 pattern:它不是简单的“白珠已有一条线”,而是在判断某个轴是否还能满足相邻转弯条件。PuzzleKit 现在已有白珠候选 pruning,思想上接近,但作者在局部 pattern 中覆盖了一些更具体的触发形态。 + +### 2. 黑珠转弯与两步直行延伸 + +黑珠必须转弯,并且从黑珠出去后的下一格必须继续直行一段。作者写了: + +- 如果黑珠某方向已经有线,则继续强制同方向第二段; +- 如果某个出口不可能合法使用,就打叉该出口,并常常强制对侧出口; +- 如果黑珠面对黑珠、白珠组合、边界或不可行边,会排除某些出口。 + +PuzzleKit 当前的黑珠规则和黑珠候选 pruning 已覆盖主干逻辑,但作者有一些局部 pattern 可以更早产生结论,尤其是“出口被未来第二段或邻近珍珠形态排除”的情况。 + +### 3. 双黑夹迫 + +作者有一个典型 pattern: + +```text +++ +● · ● +++ +``` + +如果两个黑珠夹着一个中间格,而中间格某个垂直方向已经不能走,那么另一个垂直方向也被打叉。直觉是:中间格不能只给一个黑珠提供不对称的转弯路径,否则会破坏黑珠的转弯/延伸要求。 + +PuzzleKit 已有 `Double Black Squeeze`,但当前实现偏窄:只在中间格某一条垂直候选已 blank 时推出另一条 blank。作者的条件族更宽,会结合 `isPathable` 和周边局部形态判断。 + +### 4. 连续与相邻白珠 pattern + +作者覆盖了连续白珠带来的方向排除和强制: + +- 连续白珠会限制它们只能以某种轴穿过; +- 相邻白珠如果某些侧向转弯不可行,会强制另一种穿法; +- 白珠组合还会参与 in/out 顶点颜色传播。 + +PuzzleKit 当前有 `Consecutive White Pearls Straight` 和 `Adjacent White Pearls LookAhead`。后者比作者脚本更结构化,但作者脚本中仍有一些“不是完整 lookahead、但很便宜”的白珠组合 pattern 值得拆出来。 + +### 5. 黑白组合 pattern + +作者有几类黑珠与白珠组合: + +- 黑珠面对两个连续白珠时,黑珠远离白珠的一侧被强制画线。 +- 黑珠斜向两侧有白珠时,会强制黑珠向另一侧延伸。 +- 某些黑白组合会让顶点 in/out 颜色相同或相反,从而继续推出线/叉。 + +PuzzleKit 里已经有 `Black Facing Consecutive Whites` 和 `Black Diagonal White Pinch`,但作者还写了更长距离、更依赖已有 linegraph 的黑白组合。 + +### 6. 基于 linegraph 的长走廊连通 pattern + +`MasyuAssist()` 末尾有几条明显依赖 `board.linegraph.components.length > 1` 的规则。它们的共同前提是:当前已有线形成了一个长走廊或局部路径,如果不在某处继续连接,就会过早闭合或无法把其他珍珠接入同一环。 + +这些规则看起来像硬编码 pattern,但本质更接近 graph 连通性推理。它们在大题上可能很有效,因为大题中长路径分量多,局部形状很容易触发“唯一连接方式”。 + +### 7. 连通性与珍珠局部规则的混合 + +作者还专门写了: + +- connectivity and black:如果黑珠与某个远端格已经在同一路径分量中,而中间还没有连接,则强制黑珠向反方向出线。 +- connectivity and white:如果白珠两侧相邻格已经在同一路径分量中,为避免白珠沿该轴造成自连,强制它改走垂直方向。 + +这两条很值得注意。它们不是普通 pearl-local 规则,而是把“珍珠形状约束”和“已知路径分量不能提前自连”结合起来。PuzzleKit 当前有 premature loop 和候选 pruning,但这类“linegraph 状态触发的珍珠 pattern”还可以更明确地补。 + +## 作者实现了几种策略? + +如果按可移植的推理家族看,作者至少实现了以下 8 类: + +1. **单环度数与死路规则**:二度补全、三度/溢出排除、必须经过格只剩两出口则画线。 +2. **premature loop 防止**:同一路径分量提前闭环时打叉。 +3. **路径分量连通性规则**:利用 linegraph、候选边和 lineaux 判断必须连接或禁止自连。 +4. **Tarjan 割点/桥边规则**:`CellConnected()` 对必须连通对象做 cut vertex / bridge 推理。 +5. **顶点 in/out 颜色传播**:同色打叉、异色画线,并从线/叉继续传播颜色。 +6. **Masyu 珍珠基础规则**:白珠直行转弯、黑珠转弯延伸。 +7. **Masyu 局部 pattern**:双黑、连续白、黑白组合、长走廊组合。 +8. **珍珠约束与连通性的混合规则**:根据当前路径分量状态触发黑珠/白珠方向选择。 + +作者没有实现 PuzzleKit 当前已有的黑珠强推断、白珠强推断,也没有把相邻白珠做成结构化 lookahead。作者的优势更多来自“便宜、全局、持续迭代”的 deterministic/topology 组合,而不是深搜索。 + +## 对 PuzzleKit 的启发 + +直接移植 `MasyuAssist()` 不合适,因为它的结论是批量、隐式、直接写回 board 的;但它给出了三个很清楚的方向: + +- **graph 层优先**:先把 `SingleLoopInCell()` 中那些非坐标 pattern 的 loop graph 逻辑抽象出来。 +- **颜色推理更贴近线判断**:现有 tile parity 很适合承接作者的 in/out 思想,但需要更多 pearl-local 和 connectivity 触发点。 +- **pattern 只挑高收益的**:作者脚本里有不少坐标硬编码,值得移植的是解释短、测试小、能明显补强大题性能的那部分。 diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 55ceabb..fa5525b 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -1,28 +1,374 @@ # Masyu -Masyu is a loop puzzle with black and white pearls. The final answer is one continuous loop that passes through every pearl. +Masyu is a loop puzzle with black and white pearls. The final answer is one +continuous loop that passes through every pearl. -## Core Rules +This page describes the Masyu solving rules currently implemented in PuzzleKit. +It is organized by reasoning family rather than by the exact execution order in +the solver. + +## Core Puzzle Rules - The loop travels between cell centers and never branches. - Every pearl is visited by the loop. -- A white pearl is passed through straight, and the loop must turn in at least one adjacent cell before or after it. -- A black pearl is turned on, and the loop must go straight for at least one cell before and after the turn. -- The final loop must be a single loop, not several disconnected loops or an early small loop. +- A white pearl is passed through straight, and the loop must turn in at least + one adjacent cell before or after it. +- A black pearl is turned on, and the loop must go straight for at least one + cell before and after the turn. +- The final loop must be a single loop, not several disconnected loops or an + early small loop. -## PuzzleKit Model +## PuzzleKit Model And Notation PuzzleKit represents Masyu with a center-line model: -- `PuzzleIR.lines` stores the Masyu loop decisions. +- `PuzzleIR.lines` stores Masyu loop decisions. These are center-to-center + segments between orthogonally adjacent cells. - `PuzzleIR.cells` stores pearl clues. -- `PuzzleIR.tiles` stores vertex-centered inside/outside colors used by Masyu coloring rules. +- `PuzzleIR.tiles` stores vertex-centered inside/outside colors used by Masyu + coloring rules. - `PuzzleIR.edges` is Slitherlink state and is not the Masyu loop model. -## Current Support +Notation used below: + +- `W` = white pearl. +- `B` = black pearl. +- `?` = unknown line segment. +- `=` or `|` = confirmed loop line. +- `x` = crossed-out line segment. +- `Y` = outside/yellow tile. +- `G` = inside/green tile. + +Line diagrams show decisions between cell centers. Tile-color diagrams describe +the vertex-centered tile colors that sit around those lines. + +## Core Pearl Rules + +### White Circle Rule + +A white pearl must be passed through straight, and at least one adjacent cell on +the path must turn. + +This rule can decide line and crossed-out line segments around a white pearl: + +- If one incident side is already a line, the opposite side is forced as a line + and the turn-direction exits are crossed out where possible. +- If both straight exits are already known, the perpendicular exits are crossed + out. +- If one straight axis is blocked, or if that axis cannot still satisfy the + required adjacent turn, the other straight axis is forced. +- If one side already continues straight for two segments, the opposite side's + straight continuation is crossed out so the white pearl can still turn near + the pearl. + +Example: a white pearl with one horizontal exit already used must continue +horizontally through the pearl. + +```text +? x ? + | +== W == forces the opposite horizontal line and rejects vertical exits + | +? x ? +``` + +### Black Circle Rule + +A black pearl must turn on the pearl, and both exits from the pearl must continue +straight for one more segment. + +This rule can decide lines and crossed-out lines near a black pearl: + +- If one exit is already known, the opposite exit is crossed out and the next + segment in the known exit direction is forced. +- If two turning exits are already known, all remaining exits are crossed out + and both straight extensions are forced. +- If an exit direction cannot provide the required two-step path, that exit is + crossed out. +- When only the opposite direction remains viable for a blocked direction pair, + the first and second segments in the viable direction are forced. + +Example: once a black pearl exits upward, it cannot also exit downward, and the +upward path must continue straight for one more segment. + +```text + | + | + B + x +``` + +## Local Pearl Patterns + +### Black Facing Consecutive Whites + +If a black pearl faces two consecutive white pearls with one gap between the +black pearl and the first white pearl, the black pearl cannot exit toward that +white pair. It is forced to exit away from the pair. + +This rule determines a confirmed line segment from the black pearl in the +opposite direction. + +```text +B ? . W W forces the black pearl away from the whites +``` + +### Black Diagonal White Pinch + +If two white pearls sit diagonally on the same side of a black pearl, they pinch +that side: the black pearl cannot use exits toward them while still satisfying +the local pearl constraints. + +This rule determines a confirmed line segment from the black pearl away from the +pinched side. + +```text +W W + B forces B away from the two diagonal whites +``` + +### Consecutive White Pearls Straight + +A run of three or more consecutive white pearls forces each pearl in the run to +pass perpendicular to the run. + +This rule determines confirmed line segments through the pearls on the +perpendicular axis. + +```text +W ? W ? W horizontal run + +| | | +W W W each pearl is forced vertically +| | | +``` + +The same logic applies to vertical runs, forcing horizontal passage through the +white pearls. + +### Double Black Squeeze + +If a non-pearl cell sits between two black pearls, that middle cell cannot keep +exactly one perpendicular exit. If one perpendicular exit is already crossed +out, the other perpendicular exit is crossed out too. + +This rule determines crossed-out line segments at the middle cell. + +```text + ? +B ? . ? B + x + +If one vertical exit of the middle cell is x, the other vertical exit is x too. +``` + +## Completion Rules + +### Pearl Completion + +Pearl Completion applies local pearl shape completion once enough exits are +known or eliminated. + +This rule can determine confirmed lines and crossed-out lines: + +- If a pearl already has its legal two exits, all other incident unknown exits + are crossed out. +- If a pearl has one known exit and only one legal matching exit remains, that + matching exit is forced. +- If no exit is known and only one complete legal pearl shape remains, both + exits of that shape are forced. +- For black pearls, once an exit is selected, the required straight extension + after that exit is also forced. + +### Cell Completion + +Cell Completion applies ordinary loop-degree logic to non-pearl cells. + +This rule can determine confirmed lines and crossed-out lines: + +- If a non-pearl cell already has degree 2, all other incident unknown exits are + crossed out. +- If a non-pearl cell has degree 1 and exactly one unknown exit remains, that + unknown exit is forced so the loop can continue. +- If a non-pearl cell has degree 0 and exactly one unknown exit remains, that + unknown exit is crossed out because using it would create a dead end. + +```text + | +-- . ? degree 2 already reached, so the remaining ? becomes x +``` + +## Loop And Connectivity Rules + +### Prevent Premature Loop + +The final answer must be one continuous loop. An unknown segment is crossed out +if adding it would close a smaller loop while other confirmed line segments +remain outside that loop. + +This rule determines crossed-out line segments. It only blocks premature loop +closures; it does not complete the final loop by itself. + +### Masyu Candidate Bridge Line + +The solver builds a candidate graph from all confirmed and still-unknown Masyu +lines. Pearls and existing confirmed line components are treated as required +regions that must eventually belong to one loop. + +If an unknown candidate line is the only remaining bridge between required loop +regions, that line is forced. + +This rule determines confirmed line segments. + +```text +required region == ? == required region + +If the ? segment is the only candidate bridge, it becomes a line. +``` + +## Candidate And Lookahead Rules + +### Black Pearl Candidate Pruning + +For each black pearl, the solver enumerates feasible local turn candidates. A +black candidate consists of two turning exits, their required straight +extensions, and the crossed-out non-exits. + +This rule determines: + +- confirmed lines that appear in every feasible black pearl candidate; +- crossed-out exits that appear in no feasible black pearl candidate. + +Candidate feasibility checks local line consistency, cell degree, affected pearl +possibility, and premature-loop creation. + +### White Pearl Candidate Pruning + +For each white pearl, the solver enumerates feasible straight-axis candidates. A +white candidate consists of one straight axis, crossed-out perpendicular exits, +and at least one side where the required adjacent turn can still happen. + +This rule determines: + +- confirmed lines that appear in every feasible white pearl candidate; +- crossed-out exits that appear in no feasible white pearl candidate. + +Candidate feasibility checks local line consistency, cell degree, affected pearl +possibility, and whether the white pearl can still satisfy its adjacent-turn +requirement. + +### Adjacent White Pearls LookAhead + +For adjacent white pearls, the solver compares two local modes: + +- both pearls pass straight through on parallel paths; +- one straight line passes through both pearls. + +If only one of those modes remains locally feasible, the rule applies that +mode's line and crossed-out-line decisions. + +```text +W ? W + +Mode 1: one line passes through both pearls. +Mode 2: both pearls use parallel perpendicular paths. +Only the feasible mode is kept. +``` + +## Tile Color Rules + +Tile colors are vertex-centered region markers used for inside/outside +reasoning. In the current UI, yellow represents outside and green represents +inside. + +### Masyu Tile Color Propagation + +Known Masyu lines and crosses create parity constraints between neighboring +tiles: + +- boundary tiles are yellow; +- a confirmed line separates opposite tile colors; +- a crossed-out line connects same-colored tiles. + +This rule determines tile colors when those parity constraints imply a unique +green or yellow fill. + +```text +Y x Y same color across a crossed-out line +G | Y opposite colors across a confirmed line +``` + +### Masyu Color-Pearl Propagation + +A white pearl is passed through straight, so opposite diagonal tiles around that +white pearl must have opposite colors. + +If one diagonal tile color is known, this rule determines the opposite diagonal +tile color. + +```text +G ? + W +? Y opposite diagonals around W must differ +``` + +### Masyu Color-Line Propagation + +Known neighboring tile colors can be converted back into line decisions: + +- same-colored neighboring tiles force the separating Masyu line to be crossed + out; +- different-colored neighboring tiles force the separating Masyu line to be a + confirmed loop segment. + +This rule determines line and crossed-out-line segments from tile colors. + +### Masyu Tile Connectivity Cut Coloring + +The inside and outside tile regions must remain connected through passages that +are not blocked by confirmed loop lines. + +This rule determines tile colors in two cases: + +- a tile component is an articulation or cut region needed to keep known inside + regions connected, or needed to keep known outside regions connected; +- a tile component is unreachable from the relevant source, so it is colored as + the opposite region. + +The rule can color both green and yellow tiles. + +## Bounded Strong Inference + +Strong inference rules run bounded trial branches. They temporarily assume a +local choice, apply the deterministic Masyu rules to a fixpoint or limit, and +reject the assumption if it reaches a hard contradiction. + +The trial is capped by candidate count, trial steps, and elapsed time. Hard +contradictions include: + +- cell degree contradictions; +- pearl shape contradictions; +- premature or multiple-loop contradictions; +- tile-color parity conflicts; +- assumptions that conflict with already decided lines. + +### Black Pearl Strong Inference + +For a black pearl, the solver assumes one possible exit direction together with +that exit's required straight extension and the opposite exit crossed out. + +If deterministic propagation reaches a contradiction, the assumed first exit is +crossed out in the real puzzle. + +This rule determines crossed-out line segments. + +### White Pearl Strong Inference + +For a white pearl with exactly two feasible straight-axis candidates, the solver +assumes one axis. + +If deterministic propagation reaches a contradiction, the opposite axis is +forced in the real puzzle. -- Import from Masyu-family `puzz.link` URLs. -- Render pearls, center-to-center loop segments, crosses, and tile colors. -- Replay deterministic solving steps with explanations. -- Analyze completion for a single valid loop and satisfied pearl constraints. -- Apply local pearl rules, selected local patterns, premature-loop prevention, candidate pruning, completion rules, and Masyu tile-color propagation. +This rule determines the confirmed lines and crossed-out lines belonging to the +forced white-pearl axis. From a80c0f624429492b6f933fa3dbd1a6acf3214661 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 28 May 2026 12:11:30 +0800 Subject: [PATCH 06/22] chore: bundle rules, remove duplicated cell completion part. --- docs/MASYU_AGENT_BRIEF.md | 13 +- docs/Masyu_Rule_Gap_And_Integration_Plan.md | 2 +- docs/techniques/masyu.md | 40 +-- src/domain/plugins/masyuPlugin.ts | 6 +- src/domain/rules/masyu/rules.test.ts | 193 ++++++------ src/domain/rules/masyu/rules.ts | 14 +- src/domain/rules/masyu/rules/completion.ts | 316 ++++++++------------ src/domain/rules/masyu/rules/pearls.ts | 16 +- src/features/solver/solverStore.test.ts | 2 +- 9 files changed, 247 insertions(+), 355 deletions(-) diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index 53ffe73..f134f74 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -30,8 +30,8 @@ Important coordinate convention: Registered rule order: -1. `White Circle Rule` -2. `Black Circle Rule` +1. `White Pearl Rule` +2. `Black Pearl Rule` 3. `Black Facing Consecutive Whites` 4. `Black Diagonal White Pinch` 5. `Consecutive White Pearls Straight` @@ -45,9 +45,9 @@ Registered rule order: 13. `Black Pearl Candidate Pruning` 14. `White Pearl Candidate Pruning` 15. `Adjacent White Pearls LookAhead` -16. `Pearl Completion` -17. `Cell Completion` -18. `Black Pearl Strong Inference` +16. `Cell Exit Completion` +17. `Black Pearl Strong Inference` +18. `White Pearl Strong Inference` Implemented rule areas: @@ -58,7 +58,8 @@ Implemented rule areas: - White pearl candidate pruning with the same shared pearl-candidate model. - Adjacent white pearl lookahead pruning for the two local joint traversal modes. - Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. -- Completion rules for pearl and non-pearl cells. +- Unified cell-exit completion for ordinary cells, white pearls, and black + pearls. - Tile color propagation: - boundary tiles are `yellow` / outside; - known `blank` lines imply same-color adjacent tiles; diff --git a/docs/Masyu_Rule_Gap_And_Integration_Plan.md b/docs/Masyu_Rule_Gap_And_Integration_Plan.md index bb2f3d4..749b7e0 100644 --- a/docs/Masyu_Rule_Gap_And_Integration_Plan.md +++ b/docs/Masyu_Rule_Gap_And_Integration_Plan.md @@ -35,7 +35,7 @@ PuzzleKit 已有 `Prevent Premature Loop`,也有 `Candidate Bridge Line`,但 - 使用 `lineaux` 临时可能线判断候选路径组; - 长路径分量与局部空格之间的“包围/反弹”推理。 -其中有些可能已经被 `Cell Completion`、`Pearl Completion` 或候选 pruning 间接覆盖,但还没有形成一套清晰的“单环通用图规则”。 +其中有些可能已经被 `Cell Exit Completion` 或候选 pruning 间接覆盖,但还没有形成一套清晰的“单环通用图规则”。 ### 2. 连通性框架拆得更可解释,但能力不如作者集中 diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index fa5525b..64c649e 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -44,7 +44,7 @@ the vertex-centered tile colors that sit around those lines. ## Core Pearl Rules -### White Circle Rule +### White Pearl Rule A white pearl must be passed through straight, and at least one adjacent cell on the path must turn. @@ -72,7 +72,7 @@ horizontally through the pearl. ? x ? ``` -### Black Circle Rule +### Black Pearl Rule A black pearl must turn on the pearl, and both exits from the pearl must continue straight for one more segment. @@ -164,34 +164,22 @@ If one vertical exit of the middle cell is x, the other vertical exit is x too. ## Completion Rules -### Pearl Completion +### Cell Exit Completion -Pearl Completion applies local pearl shape completion once enough exits are -known or eliminated. +Cell Exit Completion applies local exit completion to every cell. It uses +ordinary loop-degree logic for empty cells and pearl-shape logic for pearl cells. This rule can determine confirmed lines and crossed-out lines: -- If a pearl already has its legal two exits, all other incident unknown exits - are crossed out. -- If a pearl has one known exit and only one legal matching exit remains, that - matching exit is forced. -- If no exit is known and only one complete legal pearl shape remains, both - exits of that shape are forced. -- For black pearls, once an exit is selected, the required straight extension - after that exit is also forced. - -### Cell Completion - -Cell Completion applies ordinary loop-degree logic to non-pearl cells. - -This rule can determine confirmed lines and crossed-out lines: - -- If a non-pearl cell already has degree 2, all other incident unknown exits are - crossed out. -- If a non-pearl cell has degree 1 and exactly one unknown exit remains, that - unknown exit is forced so the loop can continue. -- If a non-pearl cell has degree 0 and exactly one unknown exit remains, that - unknown exit is crossed out because using it would create a dead end. +- For ordinary cells, degree 2 crosses out every other unknown exit; degree 1 + with one unknown exit forces continuation; degree 0 with one unknown exit + crosses out that dead-end candidate. +- For white pearls, exits must be opposite. A known exit keeps only its opposite + exit, and a single remaining straight axis is completed. +- For black pearls, exits must be adjacent. A known exit crosses out its + opposite exit, and a single remaining turn pair is completed. +- Black pearl straight extensions are handled by Black Pearl Rule, not by this + completion rule. ```text | diff --git a/src/domain/plugins/masyuPlugin.ts b/src/domain/plugins/masyuPlugin.ts index 612c311..cdcc5a2 100644 --- a/src/domain/plugins/masyuPlugin.ts +++ b/src/domain/plugins/masyuPlugin.ts @@ -13,11 +13,11 @@ const formatPercent = (count: number, total: number): string => { const masyuHelp: PuzzleHelpContent = { title: 'Masyu Rules', - summary: 'Draw lines through orthogonally adjacent cells to form a loop that goes through every circle.', + summary: 'Draw lines through orthogonally adjacent cells to form a loop that goes through every pearl.', rules: [ 'The loop cannot branch off or cross itself.', - 'The loop must turn on black circles and travel straight through the cells before and after the circle.', - 'The loop must go straight through white circles, and turn in at least one of the cells on either side.', + 'The loop must turn on black pearls and travel straight through the cells before and after the pearl.', + 'The loop must go straight through white pearls, and turn in at least one of the cells on either side.', ], notes: ['Rule examples are planned for a later Masyu update.'], } diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index fdbd551..458bc22 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -18,10 +18,7 @@ import { createMasyuTileColorPropagationRule, } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' -import { - createCellCompletionRule, - createPearlCompletionRule, -} from './rules/completion' +import { createCellExitCompletionRule } from './rules/completion' import { createPreventPrematureLoopRule } from './rules/loop' import { createBlackDiagonalWhitePinchRule, @@ -29,7 +26,7 @@ import { createConsecutiveWhitePearlsStraightRule, createDoubleBlackSqueezeRule, } from './rules/patterns' -import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' +import { createBlackPearlRule, createWhitePearlRule } from './rules/pearls' import { deterministicMasyuRules } from './rules' import { createWhitePearlStrongInferenceRule } from './rules/whitePearlStrongInference' import { @@ -81,7 +78,7 @@ const getLineDegree = (puzzle: PuzzleIR, row: number, col: number): number => const expectLineDiffs = ( diffs: | NonNullable< - ReturnType['apply']> + ReturnType['apply']> >['diffs'] | undefined, expected: Record, @@ -97,14 +94,14 @@ const expectLineDiffs = ( } describe('Masyu pearl rules', () => { - it('White Circle Rule blanks a blocked axis and forces the other straight axis', () => { + it('White Pearl Rule blanks a blocked axis and forces the other straight axis', () => { const puzzle = createMasyuPuzzle(3, 3) addPearl(puzzle, 0, 1, 'white') const south = lineKey([0, 1], [1, 1]) const east = lineKey([0, 1], [0, 2]) const west = lineKey([0, 1], [0, 0]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [south]: 'blank', @@ -114,7 +111,7 @@ describe('Masyu pearl rules', () => { expect(result?.affectedCells).toEqual([cellKey(0, 1)]) }) - it('White Circle Rule uses an existing blank to force the crossing axis', () => { + it('White Pearl Rule uses an existing blank to force the crossing axis', () => { const puzzle = createMasyuPuzzle(3, 3) addPearl(puzzle, 1, 1, 'white') const east = lineKey([1, 1], [1, 2]) @@ -123,7 +120,7 @@ describe('Masyu pearl rules', () => { const south = lineKey([1, 1], [2, 1]) markLine(puzzle, east, 'blank') - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [west]: 'blank', @@ -132,14 +129,14 @@ describe('Masyu pearl rules', () => { }) }) - it('White Circle Rule does nothing when both straight axes are still available', () => { + it('White Pearl Rule does nothing when both straight axes are still available', () => { const puzzle = createMasyuPuzzle(3, 3) addPearl(puzzle, 1, 1, 'white') - expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + expect(createWhitePearlRule().apply(puzzle)).toBeNull() }) - it('White Circle Rule rejects a vertical pass-through when both immediate turn cells are blocked', () => { + it('White Pearl Rule rejects a vertical pass-through when both immediate turn cells are blocked', () => { const puzzle = createMasyuPuzzle(6, 6) addPearl(puzzle, 3, 3, 'white') markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') @@ -151,7 +148,7 @@ describe('Masyu pearl rules', () => { const east = lineKey([3, 3], [3, 4]) const west = lineKey([3, 2], [3, 3]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [north]: 'blank', @@ -161,7 +158,7 @@ describe('Masyu pearl rules', () => { }) }) - it('White Circle Rule rejects a horizontal pass-through when both immediate turn cells are blocked', () => { + it('White Pearl Rule rejects a horizontal pass-through when both immediate turn cells are blocked', () => { const puzzle = createMasyuPuzzle(6, 6) addPearl(puzzle, 3, 3, 'white') markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') @@ -173,7 +170,7 @@ describe('Masyu pearl rules', () => { const north = lineKey([2, 3], [3, 3]) const south = lineKey([3, 3], [4, 3]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'blank', @@ -183,32 +180,32 @@ describe('Masyu pearl rules', () => { }) }) - it('White Circle Rule keeps an axis available when each side still has a turn candidate', () => { + it('White Pearl Rule keeps an axis available when each side still has a turn candidate', () => { const puzzle = createMasyuPuzzle(6, 6) addPearl(puzzle, 3, 3, 'white') markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') - expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + expect(createWhitePearlRule().apply(puzzle)).toBeNull() }) - it('White Circle Rule keeps an axis available when only one side can turn', () => { + it('White Pearl Rule keeps an axis available when only one side can turn', () => { const puzzle = createMasyuPuzzle(6, 6) addPearl(puzzle, 3, 3, 'white') markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') - expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + expect(createWhitePearlRule().apply(puzzle)).toBeNull() }) - it('White Circle Rule does not force the second white pearl vertical when the shared gap can still turn', () => { + it('White Pearl Rule does not force the second white pearl vertical when the shared gap can still turn', () => { const puzzle = createMasyuPuzzle(9, 10) addPearl(puzzle, 4, 5, 'white') addPearl(puzzle, 4, 7, 'white') markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') markLine(puzzle, lineKey([4, 5], [4, 6]), 'line') markLine(puzzle, lineKey([4, 6], [5, 6]), 'blank') - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expect(result?.diffs).not.toContainEqual({ kind: 'line', @@ -224,7 +221,7 @@ describe('Masyu pearl rules', () => { }) }) - it('White Circle Rule continues a known line straight and blanks turn candidates', () => { + it('White Pearl Rule continues a known line straight and blanks turn candidates', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') @@ -232,7 +229,7 @@ describe('Masyu pearl rules', () => { const north = lineKey([1, 2], [2, 2]) const south = lineKey([2, 2], [3, 2]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'line', @@ -241,14 +238,14 @@ describe('Masyu pearl rules', () => { }) }) - it('White Circle Rule does not force a straight line into a degree-2 neighbor', () => { + it('White Pearl Rule does not force a straight line into a degree-2 neighbor', () => { const puzzle = createMasyuPuzzle(4, 4) addPearl(puzzle, 1, 2, 'white') markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expect(result?.diffs).not.toContainEqual({ kind: 'line', @@ -258,7 +255,7 @@ describe('Masyu pearl rules', () => { }) }) - it('White Circle Rule blanks turn candidates when a white pearl already has a straight pair', () => { + it('White Pearl Rule blanks turn candidates when a white pearl already has a straight pair', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') @@ -266,12 +263,12 @@ describe('Masyu pearl rules', () => { const north = lineKey([1, 2], [2, 2]) const south = lineKey([2, 2], [3, 2]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) }) - it('White Circle Rule only highlights pearls that create new line decisions', () => { + it('White Pearl Rule only highlights pearls that create new line decisions', () => { const puzzle = createMasyuPuzzle(3, 4) const unchangedPearl = cellKey(1, 1) const changedPearl = cellKey(1, 2) @@ -285,7 +282,7 @@ describe('Masyu pearl rules', () => { markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') const changedLine = lineKey([1, 2], [1, 3]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expect(result?.affectedCells).toEqual([changedPearl]) expect(result?.affectedCells).not.toContain(unchangedPearl) @@ -293,7 +290,7 @@ describe('Masyu pearl rules', () => { expectLineDiffs(result?.diffs, { [changedLine]: 'blank' }) }) - it('White Circle Rule blocks the short side from continuing when the other side already runs straight south', () => { + it('White Pearl Rule blocks the short side from continuing when the other side already runs straight south', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') @@ -303,13 +300,13 @@ describe('Masyu pearl rules', () => { markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') const northExtension = lineKey([0, 2], [1, 2]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [northExtension]: 'blank' }) expect(result?.message).toContain('must turn in an adjacent cell') }) - it('White Circle Rule blocks the short side from continuing when the other side already runs straight east', () => { + it('White Pearl Rule blocks the short side from continuing when the other side already runs straight east', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') @@ -319,12 +316,12 @@ describe('Masyu pearl rules', () => { markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') const westExtension = lineKey([2, 0], [2, 1]) - const result = createWhiteCircleRule().apply(puzzle) + const result = createWhitePearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [westExtension]: 'blank' }) }) - it('White Circle Rule does not block either side when both sides only reach the pearl', () => { + it('White Pearl Rule does not block either side when both sides only reach the pearl', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') @@ -332,10 +329,10 @@ describe('Masyu pearl rules', () => { markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + expect(createWhitePearlRule().apply(puzzle)).toBeNull() }) - it('White Circle Rule ignores adjacent-turn continuation when the short-side extension leaves the board', () => { + it('White Pearl Rule ignores adjacent-turn continuation when the short-side extension leaves the board', () => { const puzzle = createMasyuPuzzle(4, 4) addPearl(puzzle, 1, 2, 'white') markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') @@ -344,22 +341,22 @@ describe('Masyu pearl rules', () => { markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') markLine(puzzle, lineKey([1, 2], [1, 3]), 'blank') - expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + expect(createWhitePearlRule().apply(puzzle)).toBeNull() }) - it('Black Circle Rule forces the opposite line and its straight extension at the border', () => { + it('Black Pearl Rule forces the opposite line and its straight extension at the border', () => { const puzzle = createMasyuPuzzle(3, 3) addPearl(puzzle, 0, 1, 'black') const south = lineKey([0, 1], [1, 1]) const extension = lineKey([1, 1], [2, 1]) - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [south]: 'line', [extension]: 'line' }) expect(result?.affectedCells).toEqual([cellKey(0, 1)]) }) - it('Black Circle Rule forces the opposite line and extension when one side is already blank', () => { + it('Black Pearl Rule forces the opposite line and extension when one side is already blank', () => { const puzzle = createMasyuPuzzle(3, 4) addPearl(puzzle, 1, 1, 'black') const west = lineKey([1, 1], [1, 0]) @@ -367,21 +364,21 @@ describe('Masyu pearl rules', () => { const extension = lineKey([1, 2], [1, 3]) markLine(puzzle, west, 'blank') - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'line', [extension]: 'line' }) }) - it('Black Circle Rule does not overwrite already-decided opposite line or extension', () => { + it('Black Pearl Rule does not overwrite already-decided opposite line or extension', () => { const puzzle = createMasyuPuzzle(3, 3) addPearl(puzzle, 0, 1, 'black') markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - expect(createBlackCircleRule().apply(puzzle)).toBeNull() + expect(createBlackPearlRule().apply(puzzle)).toBeNull() }) - it('Black Circle Rule only highlights pearls that create new line decisions', () => { + it('Black Pearl Rule only highlights pearls that create new line decisions', () => { const puzzle = createMasyuPuzzle(5, 5) const unchangedPearl = cellKey(2, 2) const changedPearl = cellKey(0, 1) @@ -393,7 +390,7 @@ describe('Masyu pearl rules', () => { markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') const changedLine = lineKey([1, 1], [2, 1]) - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expect(result?.affectedCells).toEqual([changedPearl]) expect(result?.affectedCells).not.toContain(unchangedPearl) @@ -401,14 +398,14 @@ describe('Masyu pearl rules', () => { expectLineDiffs(result?.diffs, { [changedLine]: 'line' }) }) - it('Black Circle Rule rejects an exit whose second step would leave the board', () => { + it('Black Pearl Rule rejects an exit whose second step would leave the board', () => { const puzzle = createMasyuPuzzle(6, 6) addPearl(puzzle, 3, 4, 'black') const east = lineKey([3, 4], [3, 5]) const west = lineKey([3, 3], [3, 4]) const extension = lineKey([3, 2], [3, 3]) - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'blank', @@ -417,7 +414,7 @@ describe('Masyu pearl rules', () => { }) }) - it('Black Circle Rule rejects an exit whose second step is already blank', () => { + it('Black Pearl Rule rejects an exit whose second step is already blank', () => { const puzzle = createMasyuPuzzle(6, 6) addPearl(puzzle, 3, 3, 'black') const east = lineKey([3, 3], [3, 4]) @@ -426,7 +423,7 @@ describe('Masyu pearl rules', () => { const westExtension = lineKey([3, 1], [3, 2]) markLine(puzzle, eastExtension, 'blank') - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'blank', @@ -435,19 +432,19 @@ describe('Masyu pearl rules', () => { }) }) - it('Black Circle Rule turns away from a known line and extends that exit', () => { + it('Black Pearl Rule turns away from a known line and extends that exit', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') const extension = lineKey([2, 0], [2, 1]) const east = lineKey([2, 2], [2, 3]) - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'blank', [extension]: 'line' }) }) - it('Black Circle Rule blanks remaining exits and extends both sides of a known turn', () => { + it('Black Pearl Rule blanks remaining exits and extends both sides of a known turn', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') @@ -457,7 +454,7 @@ describe('Masyu pearl rules', () => { const westExtension = lineKey([2, 0], [2, 1]) const northExtension = lineKey([0, 2], [1, 2]) - const result = createBlackCircleRule().apply(puzzle) + const result = createBlackPearlRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'blank', @@ -469,8 +466,8 @@ describe('Masyu pearl rules', () => { it('registers Masyu rules in pearl-then-completion order', () => { expect(masyuPlugin.getRules().map((rule) => rule.name)).toEqual([ - 'White Circle Rule', - 'Black Circle Rule', + 'White Pearl Rule', + 'Black Pearl Rule', 'Black Facing Consecutive Whites', 'Black Diagonal White Pinch', 'Consecutive White Pearls Straight', @@ -484,8 +481,7 @@ describe('Masyu pearl rules', () => { 'Black Pearl Candidate Pruning', 'White Pearl Candidate Pruning', 'Adjacent White Pearls LookAhead', - 'Pearl Completion', - 'Cell Completion', + 'Cell Exit Completion', 'Black Pearl Strong Inference', 'White Pearl Strong Inference', ]) @@ -495,7 +491,7 @@ describe('Masyu pearl rules', () => { const puzzle = masyuPlugin.parse('https://puzz.link/p?mashu/5/5/001390360') const { step } = runNextRule(puzzle, masyuPlugin.getRules(), 1) - expect(step?.ruleName).toBe('White Circle Rule') + expect(step?.ruleName).toBe('White Pearl Rule') expect(step?.diffs.some((diff) => diff.kind === 'line')).toBe(true) }) }) @@ -820,7 +816,7 @@ describe('Masyu loop rules', () => { rules.indexOf('masyu-white-pearl-candidate-pruning') + 1, ) expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBeLessThan( - rules.indexOf('pearl-completion'), + rules.indexOf('cell-exit-completion'), ) }) @@ -1088,7 +1084,7 @@ describe('Masyu black pearl candidate pruning', () => { }) }) - it('does not let White Circle Rule push the reported long puzzle into a degree-3 cell', () => { + it('does not let White Pearl Rule push the reported long puzzle into a degree-3 cell', () => { const url = 'https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090' let puzzle = masyuPlugin.parse(url) @@ -1904,7 +1900,7 @@ describe('Masyu pattern rules', () => { }) }) -describe('Masyu Pearl Completion', () => { +describe('Masyu Cell Exit Completion', () => { it('forces the opposite white pearl exit when only the straight continuation remains', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') @@ -1913,7 +1909,7 @@ describe('Masyu Pearl Completion', () => { markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') const south = lineKey([2, 2], [3, 2]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [south]: 'line' }) }) @@ -1926,7 +1922,7 @@ describe('Masyu Pearl Completion', () => { const north = lineKey([1, 2], [2, 2]) const south = lineKey([2, 2], [3, 2]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [north]: 'line', [south]: 'line' }) }) @@ -1939,7 +1935,7 @@ describe('Masyu Pearl Completion', () => { const west = lineKey([2, 1], [2, 2]) const east = lineKey([2, 2], [2, 3]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [west]: 'blank', [east]: 'blank' }) }) @@ -1950,65 +1946,51 @@ describe('Masyu Pearl Completion', () => { markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - expect(createPearlCompletionRule().apply(puzzle)).toBeNull() + expect(createCellExitCompletionRule().apply(puzzle)).toBeNull() }) - it('forces a black pearl turn continuation and extends both exits', () => { + it('forces a black pearl turn continuation without extending exits', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') const south = lineKey([2, 2], [3, 2]) - const westExtension = lineKey([2, 0], [2, 1]) - const southExtension = lineKey([3, 2], [4, 2]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) - expectLineDiffs(result?.diffs, { - [south]: 'line', - [westExtension]: 'line', - [southExtension]: 'line', - }) + expectLineDiffs(result?.diffs, { [south]: 'line' }) }) - it('forces the only available turning pair on a black pearl and extends it', () => { + it('forces the only available turning pair on a black pearl without extending it', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') const west = lineKey([2, 1], [2, 2]) const south = lineKey([2, 2], [3, 2]) - const westExtension = lineKey([2, 0], [2, 1]) - const southExtension = lineKey([3, 2], [4, 2]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [west]: 'line', [south]: 'line', - [westExtension]: 'line', - [southExtension]: 'line', }) }) - it('blanks remaining exits and extends a completed black pearl turn', () => { + it('blanks remaining exits after a completed black pearl turn', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') const east = lineKey([2, 2], [2, 3]) const south = lineKey([2, 2], [3, 2]) - const westExtension = lineKey([2, 0], [2, 1]) - const northExtension = lineKey([0, 2], [1, 2]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank', - [westExtension]: 'line', - [northExtension]: 'line', }) }) @@ -2018,10 +2000,10 @@ describe('Masyu Pearl Completion', () => { markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - expect(createPearlCompletionRule().apply(puzzle)).toBeNull() + expect(createCellExitCompletionRule().apply(puzzle)).toBeNull() }) - it('does not overwrite a blank black pearl extension', () => { + it('does not inspect black pearl extensions', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') @@ -2029,15 +2011,14 @@ describe('Masyu Pearl Completion', () => { markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') const south = lineKey([2, 2], [3, 2]) - const westExtension = lineKey([2, 0], [2, 1]) - const result = createPearlCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) - expectLineDiffs(result?.diffs, { [south]: 'line', [westExtension]: 'line' }) + expectLineDiffs(result?.diffs, { [south]: 'line' }) }) }) -describe('Masyu Cell Completion', () => { +describe('Masyu Cell Exit Completion', () => { it('connects the only remaining candidate when a regular cell has one line', () => { const puzzle = createMasyuPuzzle(3, 3) const west = lineKey([1, 1], [1, 0]) @@ -2046,7 +2027,7 @@ describe('Masyu Cell Completion', () => { markLine(puzzle, lineKey([1, 1], [0, 1]), 'blank') markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') - const result = createCellCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [east]: 'line' }) }) @@ -2058,7 +2039,7 @@ describe('Masyu Cell Completion', () => { markLine(puzzle, lineKey([1, 1], [1, 0]), 'line') markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') - const result = createCellCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) }) @@ -2067,28 +2048,36 @@ describe('Masyu Cell Completion', () => { const puzzle = createMasyuPuzzle(1, 2) const onlyLine = lineKey([0, 0], [0, 1]) - const result = createCellCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) expectLineDiffs(result?.diffs, { [onlyLine]: 'blank' }) }) - it('does not apply pearl-specific completion to a white pearl', () => { + it('uses white pearl completion when traversing all cells', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const north = lineKey([1, 2], [2, 2]) + const east = lineKey([2, 2], [2, 3]) + const south = lineKey([2, 2], [3, 2]) - const result = createCellCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) - expect(result).toBeNull() + expectLineDiffs(result?.diffs, { + [north]: 'blank', + [east]: 'line', + [south]: 'blank', + }) }) - it('does not apply pearl-specific completion to a black pearl', () => { + it('uses black pearl completion when traversing all cells', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) - const result = createCellCompletionRule().apply(puzzle) + const result = createCellExitCompletionRule().apply(puzzle) - expect(result).toBeNull() + expectLineDiffs(result?.diffs, { [east]: 'blank' }) }) }) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index 51748aa..bc691db 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -12,10 +12,7 @@ import { createMasyuTileColorPropagationRule, } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' -import { - createCellCompletionRule, - createPearlCompletionRule, -} from './rules/completion' +import { createCellExitCompletionRule } from './rules/completion' import { createPreventPrematureLoopRule } from './rules/loop' import { createBlackDiagonalWhitePinchRule, @@ -23,12 +20,12 @@ import { createConsecutiveWhitePearlsStraightRule, createDoubleBlackSqueezeRule, } from './rules/patterns' -import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' +import { createBlackPearlRule, createWhitePearlRule } from './rules/pearls' import { createWhitePearlStrongInferenceRule } from './rules/whitePearlStrongInference' export const deterministicMasyuRules: Rule[] = [ - createWhiteCircleRule(), - createBlackCircleRule(), + createWhitePearlRule(), + createBlackPearlRule(), createBlackFacingConsecutiveWhitesRule(), createBlackDiagonalWhitePinchRule(), createConsecutiveWhitePearlsStraightRule(), @@ -42,8 +39,7 @@ export const deterministicMasyuRules: Rule[] = [ createBlackPearlCandidatePruningRule(), createWhitePearlCandidatePruningRule(), createAdjacentWhitePearlsLookaheadRule(), - createPearlCompletionRule(), - createCellCompletionRule(), + createCellExitCompletionRule(), ] export const masyuRules: Rule[] = [ diff --git a/src/domain/rules/masyu/rules/completion.ts b/src/domain/rules/masyu/rules/completion.ts index b849356..d765837 100644 --- a/src/domain/rules/masyu/rules/completion.ts +++ b/src/domain/rules/masyu/rules/completion.ts @@ -1,23 +1,16 @@ import { cellKey } from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' -import { - createMasyuLineDecisionCollector, - type MasyuLineDecisionCollector, -} from './decisionCollector' -import { - getMasyuPearlColor, - getMasyuPearlKeys, - type MasyuPearlColor, -} from './pearlSelectors' +import { createMasyuLineDecisionCollector } from './decisionCollector' +import { getMasyuPearlColor, type MasyuPearlColor } from './pearlSelectors' import { areMasyuDirectionsOpposite, areMasyuDirectionsTurn, formatMasyuCellKeyLabel, formatMasyuLineLabel, getMasyuIncidentDirectionalLines, - getMasyuTwoStepLine, MASYU_DIRECTIONS, + oppositeMasyuDirection, type MasyuDirection, type MasyuDirectionalLine, } from './shared' @@ -52,184 +45,19 @@ const getLegalPearlPairs = ( return pairs } -const rememberPearlDecision = ( - decisions: MasyuLineDecisionCollector, - lineKey: string, - to: LineMark, -): boolean => { - return decisions.addNew(lineKey, to) -} - -const rememberBlackExtension = ( - decisions: MasyuLineDecisionCollector, - puzzle: PuzzleIR, - pearlKey: string, +const directionsInclude = ( + directions: readonly MasyuDirection[], direction: MasyuDirection, -): string | null => { - const extension = getMasyuTwoStepLine(puzzle, pearlKey, direction).second - if ( - !extension || - !rememberPearlDecision(decisions, extension.lineKey, 'line') - ) { - return null - } - return extension.lineKey -} - -export const createPearlCompletionRule = (): Rule => ({ - id: 'pearl-completion', - name: 'Pearl Completion', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const decisions = createMasyuLineDecisionCollector(puzzle) - const affectedCells = new Set() - let firstPearl: string | null = null - let firstLine: string | null = null - let firstReason: string | null = null - - const remember = ( - pearlKey: string, - lineKey: string, - to: LineMark, - reason: string, - ): void => { - if (!rememberPearlDecision(decisions, lineKey, to)) { - return - } - affectedCells.add(pearlKey) - if (firstPearl === null) { - firstPearl = pearlKey - firstLine = lineKey - firstReason = reason - } - } - - const rememberBlackExitExtensions = ( - pearlKey: string, - directions: MasyuDirection[], - reason: string, - ): void => { - for (const direction of directions) { - const extensionLineKey = rememberBlackExtension( - decisions, - puzzle, - pearlKey, - direction, - ) - if (extensionLineKey) { - affectedCells.add(pearlKey) - if (firstPearl === null) { - firstPearl = pearlKey - firstLine = extensionLineKey - firstReason = reason - } - } - } - } - - for (const pearlKey of getMasyuPearlKeys(puzzle)) { - const color = getMasyuPearlColor(puzzle, pearlKey) - if (!color) { - continue - } - const directional = getMasyuIncidentDirectionalLines(puzzle, pearlKey) - const incident = MASYU_DIRECTIONS.flatMap((direction) => { - const item = directional[direction] - return item ? [item] : [] - }) - const lineEntries = incident.filter((item) => item.mark === 'line') - const unknownEntries = incident.filter((item) => item.mark === 'unknown') - const availableEntries = incident.filter((item) => item.mark !== 'blank') - const reason = - color === 'white' - ? 'must have two straight-through exits' - : 'must turn and extend from each exit' - - if (lineEntries.length === 2) { - if ( - !isLegalPearlPair( - color, - lineEntries[0].direction, - lineEntries[1].direction, - ) - ) { - continue - } - for (const item of unknownEntries) { - remember(pearlKey, item.lineKey, 'blank', reason) - } - if (color === 'black') { - rememberBlackExitExtensions( - pearlKey, - lineEntries.map((item) => item.direction), - reason, - ) - } - continue - } - - if (lineEntries.length === 1) { - const legalCandidates = unknownEntries.filter((item) => - isLegalPearlPair(color, lineEntries[0].direction, item.direction), - ) - if (legalCandidates.length !== 1) { - continue - } - remember(pearlKey, legalCandidates[0].lineKey, 'line', reason) - if (color === 'black') { - rememberBlackExitExtensions( - pearlKey, - [lineEntries[0].direction, legalCandidates[0].direction], - reason, - ) - } - continue - } - - if (lineEntries.length !== 0) { - continue - } - - const legalPairs = getLegalPearlPairs(color, availableEntries) - if (legalPairs.length !== 1) { - continue - } - - const [left, right] = legalPairs[0] - remember(pearlKey, left.lineKey, 'line', reason) - remember(pearlKey, right.lineKey, 'line', reason) - if (color === 'black') { - rememberBlackExitExtensions( - pearlKey, - [left.direction, right.direction], - reason, - ) - } - } +): boolean => directions.includes(direction) - if (!decisions.hasChanges()) { - return null - } - - const diffs = decisions.diffs() - return { - message: - firstPearl && firstLine && firstReason - ? `${getMasyuPearlColor(puzzle, firstPearl) === 'white' ? 'White' : 'Black'} pearl ${formatMasyuCellKeyLabel(firstPearl)} ${firstReason}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` - : 'Pearl completion applied.', - diffs, - affectedCells: [...affectedCells], - affectedLines: diffs.map((diff) => diff.lineKey), - } - }, -}) - -export const createCellCompletionRule = (): Rule => ({ - id: 'cell-completion', - name: 'Cell Completion', +export const createCellExitCompletionRule = (): Rule => ({ + id: 'cell-exit-completion', + name: 'Cell Exit Completion', apply: (puzzle: PuzzleIR): RuleApplication | null => { const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() let firstCell: string | null = null + let firstLine: string | null = null let firstReason: string | null = null const remember = ( @@ -238,12 +66,13 @@ export const createCellCompletionRule = (): Rule => ({ to: LineMark, reason: string, ): void => { - if (!decisions.add(lineKey, to)) { + if (!decisions.addNew(lineKey, to)) { return } affectedCells.add(key) if (firstCell === null) { firstCell = key + firstLine = lineKey firstReason = reason } } @@ -251,10 +80,7 @@ export const createCellCompletionRule = (): Rule => ({ for (let row = 0; row < puzzle.rows; row += 1) { for (let col = 0; col < puzzle.cols; col += 1) { const key = cellKey(row, col) - const clue = puzzle.cells[key]?.clue - if (clue?.kind === 'pearl') { - continue - } + const color = getMasyuPearlColor(puzzle, key) const directional = getMasyuIncidentDirectionalLines(puzzle, key) const incident = MASYU_DIRECTIONS.flatMap((direction) => { const item = directional[direction] @@ -268,36 +94,128 @@ export const createCellCompletionRule = (): Rule => ({ continue } + if (!color) { + if (lineEntries.length === 2) { + for (const item of unknownEntries) { + remember( + key, + item.lineKey, + 'blank', + 'it already has degree 2, so every other exit is blank', + ) + } + continue + } + + if (lineEntries.length === 1 && unknownEntries.length === 1) { + remember( + key, + unknownEntries[0].lineKey, + 'line', + 'one line must continue through the only remaining candidate', + ) + continue + } + + if (lineEntries.length === 0 && unknownEntries.length === 1) { + remember( + key, + unknownEntries[0].lineKey, + 'blank', + 'using the only remaining candidate would create a dead end', + ) + } + continue + } + if (lineEntries.length === 2) { + if ( + !isLegalPearlPair( + color, + lineEntries[0].direction, + lineEntries[1].direction, + ) + ) { + continue + } + const lineDirections = lineEntries.map((item) => item.direction) for (const item of unknownEntries) { + if (directionsInclude(lineDirections, item.direction)) { + continue + } remember( key, item.lineKey, 'blank', - 'it already has degree 2, so every other exit is blank', + color === 'white' + ? 'it already has its straight-through exits, so every other exit is blank' + : 'it already has its turning exits, so every other exit is blank', ) } continue } - if (lineEntries.length !== 1) { - if (lineEntries.length === 0 && unknownEntries.length === 1) { + if (lineEntries.length === 1) { + const knownDirection = lineEntries[0].direction + if (color === 'white') { + const opposite = oppositeMasyuDirection(knownDirection) + for (const item of unknownEntries) { + remember( + key, + item.lineKey, + item.direction === opposite ? 'line' : 'blank', + 'a white pearl must continue straight through the opposite exit', + ) + } + continue + } + + const opposite = oppositeMasyuDirection(knownDirection) + const legalCandidates = unknownEntries.filter((item) => + areMasyuDirectionsTurn(knownDirection, item.direction), + ) + for (const item of unknownEntries) { + if (item.direction !== opposite) { + continue + } remember( key, - unknownEntries[0].lineKey, + item.lineKey, 'blank', - 'using the only remaining candidate would create a dead end', + 'a black pearl must turn, so the opposite exit is blank', + ) + } + if (legalCandidates.length === 1) { + remember( + key, + legalCandidates[0].lineKey, + 'line', + 'a black pearl has only one turning exit left', ) } continue } - if (unknownEntries.length === 1) { + if (lineEntries.length !== 0) { + continue + } + + const availableEntries = incident.filter((item) => item.mark !== 'blank') + const legalPairs = getLegalPearlPairs(color, availableEntries) + if (legalPairs.length !== 1) { + continue + } + + const [left, right] = legalPairs[0] + const pairDirections = [left.direction, right.direction] as const + for (const item of unknownEntries) { remember( key, - unknownEntries[0].lineKey, - 'line', - 'one line must continue through the only remaining candidate', + item.lineKey, + directionsInclude(pairDirections, item.direction) ? 'line' : 'blank', + color === 'white' + ? 'only one straight-through axis remains' + : 'only one turning pair remains', ) } } @@ -310,9 +228,9 @@ export const createCellCompletionRule = (): Rule => ({ const diffs = decisions.diffs() return { message: - firstCell && firstReason - ? `Cell ${formatMasyuCellKeyLabel(firstCell)}: ${firstReason}${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` - : 'Cell completion applied.', + firstCell && firstLine && firstReason + ? `Cell ${formatMasyuCellKeyLabel(firstCell)}: ${firstReason}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Cell exit completion applied.', diffs, affectedCells: [...affectedCells], affectedLines: diffs.map((diff) => diff.lineKey), diff --git a/src/domain/rules/masyu/rules/pearls.ts b/src/domain/rules/masyu/rules/pearls.ts index be5e598..8f0de10 100644 --- a/src/domain/rules/masyu/rules/pearls.ts +++ b/src/domain/rules/masyu/rules/pearls.ts @@ -123,9 +123,9 @@ const collectNewMasyuLineDecisionWithoutDegreeOverflow = ( return to === 'line' ? decisions.addNew(key, to) : decisions.addNew(key, to) } -export const createWhiteCircleRule = (): Rule => ({ - id: 'white-circle-rule', - name: 'White Circle Rule', +export const createWhitePearlRule = (): Rule => ({ + id: 'white-pearl-rule', + name: 'White Pearl Rule', apply: (puzzle: PuzzleIR): RuleApplication | null => { const decisions = createMasyuLineDecisionCollector(puzzle, { guardLineDegree: true, @@ -304,7 +304,7 @@ export const createWhiteCircleRule = (): Rule => ({ message: firstPearl && firstLine ? `White pearl ${formatMasyuCellKeyLabel(firstPearl)} ${firstReason ?? 'forces a local decision'}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` - : 'White circle rule applied.', + : 'White pearl rule applied.', diffs, affectedCells: [...affectedCells], affectedLines: diffs.map((diff) => diff.lineKey), @@ -312,9 +312,9 @@ export const createWhiteCircleRule = (): Rule => ({ }, }) -export const createBlackCircleRule = (): Rule => ({ - id: 'black-circle-rule', - name: 'Black Circle Rule', +export const createBlackPearlRule = (): Rule => ({ + id: 'black-pearl-rule', + name: 'Black Pearl Rule', apply: (puzzle: PuzzleIR): RuleApplication | null => { const decisions = createMasyuLineDecisionCollector(puzzle) const affectedCells = new Set() @@ -466,7 +466,7 @@ export const createBlackCircleRule = (): Rule => ({ message: firstPearl && firstLine ? `Black pearl ${formatMasyuCellKeyLabel(firstPearl)} must turn, so ${formatMasyuLineLabel(firstLine)} is a line${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` - : 'Black circle rule applied.', + : 'Black pearl rule applied.', diffs, affectedCells: [...affectedCells], affectedLines: diffs.map((diff) => diff.lineKey), diff --git a/src/features/solver/solverStore.test.ts b/src/features/solver/solverStore.test.ts index 818ee5e..2b4e4e6 100644 --- a/src/features/solver/solverStore.test.ts +++ b/src/features/solver/solverStore.test.ts @@ -440,7 +440,7 @@ describe('solver puzzle loading', () => { useSolverStore.getState().nextStep() const afterStep = useSolverStore.getState() - expect(afterStep.steps[0]?.ruleName).toBe('White Circle Rule') + expect(afterStep.steps[0]?.ruleName).toBe('White Pearl Rule') expect(afterStep.highlightedLines.length).toBeGreaterThan(0) expect(afterStep.steps[0]?.diffs.some((diff) => diff.kind === 'line')).toBe(true) }) From 99e998e5f8801f0e4fa2f3b00f289b8196d0cb3d Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 28 May 2026 12:51:56 +0800 Subject: [PATCH 07/22] feat: add Empty Cell Candidate Pruning rule to enhance puzzle solving by determining confirmed lines in non-pearl cells --- docs/MASYU_AGENT_BRIEF.md | 9 +- docs/techniques/masyu.md | 17 +++ src/domain/rules/masyu/rules.test.ts | 71 ++++++++- src/domain/rules/masyu/rules.ts | 2 + src/domain/rules/masyu/rules/candidates.ts | 160 ++++++++++++++++++++- 5 files changed, 254 insertions(+), 5 deletions(-) diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index f134f74..2ca2b91 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -45,9 +45,10 @@ Registered rule order: 13. `Black Pearl Candidate Pruning` 14. `White Pearl Candidate Pruning` 15. `Adjacent White Pearls LookAhead` -16. `Cell Exit Completion` -17. `Black Pearl Strong Inference` -18. `White Pearl Strong Inference` +16. `Empty Cell Candidate Pruning` +17. `Cell Exit Completion` +18. `Black Pearl Strong Inference` +19. `White Pearl Strong Inference` Implemented rule areas: @@ -57,6 +58,8 @@ Implemented rule areas: - Black pearl candidate pruning with shallow feasibility checks. - White pearl candidate pruning with the same shared pearl-candidate model. - Adjacent white pearl lookahead pruning for the two local joint traversal modes. +- Empty cell candidate pruning for selected non-pearl cells, using bounded + degree-0/degree-2 candidates without deterministic trial propagation. - Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. - Unified cell-exit completion for ordinary cells, white pearls, and black pearls. diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 64c649e..4bfe38c 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -263,6 +263,23 @@ Mode 2: both pearls use parallel perpendicular paths. Only the feasible mode is kept. ``` +### Empty Cell Candidate Pruning + +Empty Cell Candidate Pruning checks selected non-pearl cells that are already +locally constrained. It compares the bounded local candidates where the loop +either skips the cell completely or passes through it with exactly two exits. + +This rule can determine confirmed lines and crossed-out lines: + +- It considers only active empty cells, such as cells with one confirmed line, + few unknown exits, or nearby confirmed line structure. +- It filters degree-0 and degree-2 candidates with the same local feasibility + checks used by pearl candidate pruning. +- It does not run deterministic rule propagation or strong inference inside a + candidate assumption. +- If every locally feasible candidate agrees on an incident line, that line is + decided. + ## Tile Color Rules Tile colors are vertex-centered region markers used for inside/outside diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index 458bc22..d25beb5 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -10,6 +10,7 @@ import { createMasyuCandidateBridgeLineRule } from './rules/bridges' import { createAdjacentWhitePearlsLookaheadRule, createBlackPearlCandidatePruningRule, + createEmptyCellCandidatePruningRule, createWhitePearlCandidatePruningRule, } from './rules/candidates' import { @@ -481,6 +482,7 @@ describe('Masyu pearl rules', () => { 'Black Pearl Candidate Pruning', 'White Pearl Candidate Pruning', 'Adjacent White Pearls LookAhead', + 'Empty Cell Candidate Pruning', 'Cell Exit Completion', 'Black Pearl Strong Inference', 'White Pearl Strong Inference', @@ -815,7 +817,10 @@ describe('Masyu loop rules', () => { expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBe( rules.indexOf('masyu-white-pearl-candidate-pruning') + 1, ) - expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBeLessThan( + expect(rules.indexOf('masyu-empty-cell-candidate-pruning')).toBe( + rules.indexOf('masyu-adjacent-white-pearls-lookahead') + 1, + ) + expect(rules.indexOf('masyu-empty-cell-candidate-pruning')).toBeLessThan( rules.indexOf('cell-exit-completion'), ) }) @@ -1251,6 +1256,70 @@ describe('Masyu adjacent white pearls lookahead', () => { }) }) +describe('Masyu empty cell candidate pruning', () => { + it('skips an unconstrained center empty cell with four unknown exits', () => { + const puzzle = createMasyuPuzzle(3, 3) + + expect(createEmptyCellCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('forces the only corner continuation from a degree-1 empty cell', () => { + const puzzle = createMasyuPuzzle(2, 2) + const south = lineKey([0, 0], [1, 0]) + const east = lineKey([0, 0], [0, 1]) + markLine(puzzle, south, 'line') + + const result = createEmptyCellCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(0, 0)]) + expect(result?.message).toContain('Empty cell') + }) + + it('rejects candidates that would overflow a touched cell degree', () => { + const puzzle = createMasyuPuzzle(2, 4) + const target = cellKey(0, 1) + const east = lineKey([0, 1], [0, 2]) + const west = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 1], [1, 1]) + markLine(puzzle, west, 'blank') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + + const result = createEmptyCellCandidatePruningRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank' }) + }) + + it('rejects candidates that make a nearby white pearl locally impossible', () => { + const puzzle = createMasyuPuzzle(2, 4) + addPearl(puzzle, 0, 2, 'white') + const westOfEmpty = lineKey([0, 0], [0, 1]) + const toWhite = lineKey([0, 1], [0, 2]) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + + const result = createEmptyCellCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [westOfEmpty]: 'line', + [toWhite]: 'line', + }) + }) + + it('does not rely on downstream deterministic propagation inside candidates', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'unknown') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'unknown') + + expect(createEmptyCellCandidatePruningRule().apply(puzzle)).toBeNull() + }) +}) + describe('Masyu shared helper primitives', () => { it('collects compatible line and tile decisions without overwriting conflicts', () => { const puzzle = createMasyuPuzzle(3, 3) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index bc691db..10df47b 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -4,6 +4,7 @@ import { createMasyuCandidateBridgeLineRule } from './rules/bridges' import { createAdjacentWhitePearlsLookaheadRule, createBlackPearlCandidatePruningRule, + createEmptyCellCandidatePruningRule, createWhitePearlCandidatePruningRule, } from './rules/candidates' import { @@ -39,6 +40,7 @@ export const deterministicMasyuRules: Rule[] = [ createBlackPearlCandidatePruningRule(), createWhitePearlCandidatePruningRule(), createAdjacentWhitePearlsLookaheadRule(), + createEmptyCellCandidatePruningRule(), createCellExitCompletionRule(), ] diff --git a/src/domain/rules/masyu/rules/candidates.ts b/src/domain/rules/masyu/rules/candidates.ts index bf0a168..f00324d 100644 --- a/src/domain/rules/masyu/rules/candidates.ts +++ b/src/domain/rules/masyu/rules/candidates.ts @@ -1,5 +1,5 @@ import { cellKey, parseCellKey } from '../../../ir/keys' -import type { PuzzleIR } from '../../../ir/types' +import type { LineMark, PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' import { createMasyuLineDecisionCollector } from './decisionCollector' import type { MasyuLineOverlay } from './lineGraph' @@ -9,10 +9,16 @@ import { formatMasyuLineLabel, getMasyuDirectionalLine, oppositeMasyuDirection, + MASYU_DIRECTIONS, type MasyuDirection, + type MasyuDirectionalLine, } from './shared' import { createMasyuLookaheadContext } from './lookahead' +type EmptyCellCandidate = { + overlay: MasyuLineOverlay +} + export const createBlackPearlCandidatePruningRule = (): Rule => ({ id: 'masyu-black-pearl-candidate-pruning', name: 'Black Pearl Candidate Pruning', @@ -280,3 +286,155 @@ export const createAdjacentWhitePearlsLookaheadRule = (): Rule => ({ return null }, }) + +const isEmptyCellCandidateActive = ( + puzzle: PuzzleIR, + key: string, + incident: MasyuDirectionalLine[], +): boolean => { + if (puzzle.cells[key]?.clue?.kind === 'pearl') { + return false + } + const unknownCount = incident.filter((item) => item.mark === 'unknown').length + if (unknownCount === 0) { + return false + } + const lineCount = incident.filter((item) => item.mark === 'line').length + if (lineCount === 1 || unknownCount <= 3) { + return true + } + return incident.some((item) => + getMasyuIncidentLineCount(puzzle, item.neighborKey) > 0, + ) +} + +const getMasyuIncidentLineCount = (puzzle: PuzzleIR, key: string): number => + MASYU_DIRECTIONS.flatMap((direction) => { + const item = getMasyuDirectionalLine(puzzle, key, direction) + return item ? [item] : [] + }).filter((item) => item.mark === 'line').length + +const buildEmptyCellCandidateOverlay = ( + incident: MasyuDirectionalLine[], + selectedLineKeys: ReadonlySet, +): MasyuLineOverlay | null => { + const overlay = new Map() + for (const item of incident) { + if ( + !addMasyuOverlayDecision( + overlay, + item.lineKey, + selectedLineKeys.has(item.lineKey) ? 'line' : 'blank', + ) + ) { + return null + } + } + return overlay +} + +const buildEmptyCellCandidates = ( + incident: MasyuDirectionalLine[], +): EmptyCellCandidate[] => { + const candidates: EmptyCellCandidate[] = [] + const degreeZero = buildEmptyCellCandidateOverlay(incident, new Set()) + if (degreeZero) { + candidates.push({ overlay: degreeZero }) + } + + for (let leftIndex = 0; leftIndex < incident.length; leftIndex += 1) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < incident.length; + rightIndex += 1 + ) { + const overlay = buildEmptyCellCandidateOverlay( + incident, + new Set([ + incident[leftIndex].lineKey, + incident[rightIndex].lineKey, + ]), + ) + if (overlay) { + candidates.push({ overlay }) + } + } + } + + return candidates +} + +const getOverlayCellDegree = ( + incident: MasyuDirectionalLine[], + overlay: MasyuLineOverlay, +): number => + incident.filter((item) => overlay.get(item.lineKey) === 'line').length + +const getCommonOverlayDecisions = ( + overlays: MasyuLineOverlay[], +): Array<[string, LineMark]> => { + const [first] = overlays + if (!first) { + return [] + } + return [...first.entries()].filter(([lineKeyValue, mark]) => + overlays.every((overlay) => overlay.get(lineKeyValue) === mark), + ) +} + +export const createEmptyCellCandidatePruningRule = (): Rule => ({ + id: 'masyu-empty-cell-candidate-pruning', + name: 'Empty Cell Candidate Pruning', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const context = createMasyuLookaheadContext(puzzle) + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + const incident = context.getIncidentEntries(new Map(), key) + if (!isEmptyCellCandidateActive(puzzle, key, incident)) { + continue + } + + const feasibleCandidates = buildEmptyCellCandidates(incident).filter( + (candidate) => { + const degree = getOverlayCellDegree(incident, candidate.overlay) + return ( + (degree === 0 || degree === 2) && + context.isOverlayLocallyFeasible([key], candidate.overlay) + ) + }, + ) + if (feasibleCandidates.length === 0) { + continue + } + + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + for (const [lineKeyValue, mark] of getCommonOverlayDecisions( + feasibleCandidates.map((candidate) => candidate.overlay), + )) { + decisions.add(lineKeyValue, mark) + } + + if (!decisions.hasChanges()) { + continue + } + + const diffs = decisions.diffs() + const firstLine = diffs[0]?.lineKey + return { + message: firstLine + ? `Empty cell ${formatMasyuCellKeyLabel(key)} has only compatible degree-0/degree-2 candidates left, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Empty cell candidate pruning applied.', + diffs, + affectedCells: [key], + affectedLines: diffs.map((diff) => diff.lineKey), + } + } + } + + return null + }, +}) From ae0b8e7553550632e523fa93c4e803b09857a2a1 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 28 May 2026 13:09:38 +0800 Subject: [PATCH 08/22] chore: update stats logic --- src/domain/difficulty/traceStats.test.ts | 71 +++++++++-- src/domain/difficulty/traceStats.ts | 135 +++++++++++++++------ src/domain/rules/engine.test.ts | 67 ++++++++-- src/domain/rules/engine.ts | 66 ++++++++-- src/domain/rules/masyu/rules/candidates.ts | 127 +++++++++++-------- src/domain/rules/masyu/rules/lookahead.ts | 19 +++ src/domain/rules/types.ts | 35 +++++- 7 files changed, 400 insertions(+), 120 deletions(-) diff --git a/src/domain/difficulty/traceStats.test.ts b/src/domain/difficulty/traceStats.test.ts index 0c298d4..e0360cd 100644 --- a/src/domain/difficulty/traceStats.test.ts +++ b/src/domain/difficulty/traceStats.test.ts @@ -26,8 +26,12 @@ const makeStep = ( message: `step ${index}`, diffs, affectedCells: [], - affectedEdges: diffs.flatMap((diff) => (diff.kind === 'edge' ? [diff.edgeKey] : [])), - affectedSectors: diffs.flatMap((diff) => (diff.kind === 'sector' ? [diff.sectorKey] : [])), + affectedEdges: diffs.flatMap((diff) => + diff.kind === 'edge' ? [diff.edgeKey] : [], + ), + affectedSectors: diffs.flatMap((diff) => + diff.kind === 'sector' ? [diff.sectorKey] : [], + ), timestamp: index, durationMs, }) @@ -51,7 +55,14 @@ describe('buildRuleTraceStats', () => { expect(stats.ruleUsage).toEqual({ 'rule-a': 2, 'rule-b': 1 }) expect(stats.ruleSteps).toEqual({ 'rule-a': [1, 3], 'rule-b': [2] }) expect(stats.totalDurationMs).toBe(10) - expect(stats.diffCounts).toEqual({ edge: 1, line: 0, sector: 1, cell: 1, tile: 0, vertex: 0 }) + expect(stats.diffCounts).toEqual({ + edge: 1, + line: 0, + sector: 1, + cell: 1, + tile: 0, + vertex: 0, + }) }) it('keeps all full-trace rules visible when the active prefix has not used them yet', () => { @@ -78,6 +89,21 @@ describe('buildRuleTraceStats', () => { expect(buildRuleTraceStats(steps, 99).pointer).toBe(2) expect(buildRuleTraceStats(steps, 1).traceProgressRatio).toBe(0.5) }) + + it('uses rule apply time for per-rule timing and chain time for total timing', () => { + const steps: RuleStep[] = [ + { + ...makeStep(1, 'late-rule', 'Late Rule', 50, []), + chainDurationMs: 50, + ruleApplyMs: 7, + }, + ] + + const stats = buildRuleTraceStats(steps, 1) + + expect(stats.totalDurationMs).toBe(50) + expect(stats.rules[0].durationMs).toBe(7) + }) }) describe('buildTraceChartStats', () => { @@ -108,7 +134,9 @@ describe('buildTraceChartStats', () => { const stats = buildTraceChartStats(puzzle, steps, 2) - expect(stats.points.map((point) => point.edgeCoverageRatio)).toEqual([0, 0.25, 0.5]) + expect(stats.points.map((point) => point.edgeCoverageRatio)).toEqual([ + 0, 0.25, 0.5, + ]) expect(stats.current.boardProgressRatio).toBe(0.5) }) @@ -132,7 +160,12 @@ describe('buildTraceChartStats', () => { const puzzle = createSlitherPuzzle(2, 2) const steps: RuleStep[] = [ makeStep(1, 'cell-rule', 'Cell Rule', 1, [ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + { + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'green', + }, ]), ] @@ -192,8 +225,18 @@ describe('incremental trace stats cache', () => { const targetVertex = vertexKey(0, 0) const initialCandidates = puzzle.vertices[targetVertex].candidateEdgeSets const step = makeStep(1, 'mixed-rule', 'Mixed Rule', 4, [ - { kind: 'edge', edgeKey: edgeKey([0, 0], [0, 1]), from: 'unknown', to: 'line' }, - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'yellow' }, + { + kind: 'edge', + edgeKey: edgeKey([0, 0], [0, 1]), + from: 'unknown', + to: 'line', + }, + { + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'yellow', + }, { kind: 'vertex', vertexKey: targetVertex, @@ -233,10 +276,20 @@ describe('incremental trace stats cache', () => { it('truncates a future branch and rebuilds prefix totals', () => { const puzzle = createSlitherPuzzle(1, 1) const first = makeStep(1, 'rule-a', 'Rule A', 2, [ - { kind: 'edge', edgeKey: edgeKey([0, 0], [0, 1]), from: 'unknown', to: 'line' }, + { + kind: 'edge', + edgeKey: edgeKey([0, 0], [0, 1]), + from: 'unknown', + to: 'line', + }, ]) const second = makeStep(2, 'rule-b', 'Rule B', 3, [ - { kind: 'edge', edgeKey: edgeKey([1, 0], [1, 1]), from: 'unknown', to: 'blank' }, + { + kind: 'edge', + edgeKey: edgeKey([1, 0], [1, 1]), + from: 'unknown', + to: 'blank', + }, ]) const cache = rebuildTraceStatsCache(puzzle, [first, second]) diff --git a/src/domain/difficulty/traceStats.ts b/src/domain/difficulty/traceStats.ts index 665bc17..0260a64 100644 --- a/src/domain/difficulty/traceStats.ts +++ b/src/domain/difficulty/traceStats.ts @@ -91,6 +91,12 @@ export const addRuleUsage = ( ruleSteps[step.ruleId] = [...(ruleSteps[step.ruleId] ?? []), stepNumber] } +const getStepChainDurationMs = (step: RuleStep): number => + step.chainDurationMs ?? step.durationMs ?? 0 + +const getStepRuleApplyMs = (step: RuleStep): number => + step.ruleApplyMs ?? step.durationMs ?? 0 + const clampPointer = (pointer: number, totalSteps: number): number => { if (!Number.isFinite(pointer)) { return 0 @@ -98,7 +104,10 @@ const clampPointer = (pointer: number, totalSteps: number): number => { return Math.min(totalSteps, Math.max(0, Math.floor(pointer))) } -export const buildRuleTraceStats = (steps: RuleStep[], pointer: number): RuleTraceStats => { +export const buildRuleTraceStats = ( + steps: RuleStep[], + pointer: number, +): RuleTraceStats => { const currentPointer = clampPointer(pointer, steps.length) const activeSteps = steps.slice(0, currentPointer) const ruleOrder: string[] = [] @@ -120,9 +129,9 @@ export const buildRuleTraceStats = (steps: RuleStep[], pointer: number): RuleTra activeSteps.forEach((step, index) => { const stepNumber = index + 1 addRuleUsage(ruleUsage, ruleSteps, step, stepNumber) - const durationMs = step.durationMs ?? 0 - ruleDurations[step.ruleId] = (ruleDurations[step.ruleId] ?? 0) + durationMs - totalDurationMs += durationMs + const ruleApplyMs = getStepRuleApplyMs(step) + ruleDurations[step.ruleId] = (ruleDurations[step.ruleId] ?? 0) + ruleApplyMs + totalDurationMs += getStepChainDurationMs(step) for (const diff of step.diffs) { diffCounts[diff.kind] += 1 @@ -157,13 +166,16 @@ export const buildRuleTraceStats = (steps: RuleStep[], pointer: number): RuleTra } } -const ratio = (count: number, total: number): number => (total <= 0 ? 0 : count / total) +const ratio = (count: number, total: number): number => + total <= 0 ? 0 : count / total const vertexSignature = (candidates: VertexCandidate[] | undefined): string => JSON.stringify( (candidates ?? []) .map((candidate) => [...candidate].sort()) - .sort((a, b) => a.length - b.length || a.join('|').localeCompare(b.join('|'))), + .sort( + (a, b) => a.length - b.length || a.join('|').localeCompare(b.join('|')), + ), ) const makeChartPoint = ( @@ -186,12 +198,14 @@ const makeChartPoint = ( }) const countInitialFilledCells = (puzzle: PuzzleIR): number => - Object.values(puzzle.cells).filter((cell) => cell.fill !== undefined && cell.fill !== null).length + Object.values(puzzle.cells).filter( + (cell) => cell.fill !== undefined && cell.fill !== null, + ).length const countInitialDecidedEdges = (puzzle: PuzzleIR): number => - Object.values(puzzle.puzzleType === 'masyu' ? puzzle.lines ?? {} : puzzle.edges).filter( - (edge) => (edge?.mark ?? 'unknown') !== 'unknown', - ).length + Object.values( + puzzle.puzzleType === 'masyu' ? (puzzle.lines ?? {}) : puzzle.edges, + ).filter((edge) => (edge?.mark ?? 'unknown') !== 'unknown').length const upperBound = (values: number[], target: number): number => { let low = 0 @@ -207,8 +221,13 @@ const upperBound = (values: number[], target: number): number => { return low } -export const createTraceStatsCache = (initialPuzzle: PuzzleIR): TraceStatsCache => { - const decisionMarks = initialPuzzle.puzzleType === 'masyu' ? initialPuzzle.lines ?? {} : initialPuzzle.edges +export const createTraceStatsCache = ( + initialPuzzle: PuzzleIR, +): TraceStatsCache => { + const decisionMarks = + initialPuzzle.puzzleType === 'masyu' + ? (initialPuzzle.lines ?? {}) + : initialPuzzle.edges const totalEdges = Object.keys(decisionMarks).length const totalCells = initialPuzzle.rows * initialPuzzle.cols const totalVertices = Object.keys(initialPuzzle.vertices).length @@ -267,7 +286,10 @@ export const createTraceStatsCache = (initialPuzzle: PuzzleIR): TraceStatsCache } } -export const appendTraceStatsStep = (cache: TraceStatsCache, step: RuleStep): TraceStatsCache => { +export const appendTraceStatsStep = ( + cache: TraceStatsCache, + step: RuleStep, +): TraceStatsCache => { const stepNumber = cache.points.length const next: TraceStatsCache = { ...cache, @@ -313,10 +335,14 @@ export const appendTraceStatsStep = (cache: TraceStatsCache, step: RuleStep): Tr } else if (diff.kind === 'vertex') { vertexDiffs += 1 const signature = vertexSignature(diff.toCandidates) - const initialCount = next.initialVertexCandidateCounts[diff.vertexKey] ?? 0 - const initialSignature = next.initialVertexCandidateSignatures[diff.vertexKey] ?? '[]' + const initialCount = + next.initialVertexCandidateCounts[diff.vertexKey] ?? 0 + const initialSignature = + next.initialVertexCandidateSignatures[diff.vertexKey] ?? '[]' const wasNarrowed = next.narrowedVertexKeys[diff.vertexKey] ?? false - const isNarrowed = diff.toCandidates.length < initialCount || signature !== initialSignature + const isNarrowed = + diff.toCandidates.length < initialCount || + signature !== initialSignature if (!wasNarrowed && isNarrowed) { next.narrowedVertexCount += 1 } else if (wasNarrowed && !isNarrowed) { @@ -341,21 +367,42 @@ export const appendTraceStatsStep = (cache: TraceStatsCache, step: RuleStep): Tr const occurrence = next.ruleOccurrences[step.ruleId] occurrence.steps.push(stepNumber) occurrence.durationPrefixMs.push( - occurrence.durationPrefixMs[occurrence.durationPrefixMs.length - 1] + (step.durationMs ?? 0), + occurrence.durationPrefixMs[occurrence.durationPrefixMs.length - 1] + + getStepRuleApplyMs(step), ) next.totalDurationPrefixMs.push( - next.totalDurationPrefixMs[next.totalDurationPrefixMs.length - 1] + (step.durationMs ?? 0), + next.totalDurationPrefixMs[next.totalDurationPrefixMs.length - 1] + + getStepChainDurationMs(step), ) next.totalDiffPrefixCounts.push( - next.totalDiffPrefixCounts[next.totalDiffPrefixCounts.length - 1] + step.diffs.length, + next.totalDiffPrefixCounts[next.totalDiffPrefixCounts.length - 1] + + step.diffs.length, + ) + next.diffPrefixCounts.edge.push( + next.diffPrefixCounts.edge[next.diffPrefixCounts.edge.length - 1] + + edgeDiffs, + ) + next.diffPrefixCounts.line.push( + next.diffPrefixCounts.line[next.diffPrefixCounts.line.length - 1] + + lineDiffs, + ) + next.diffPrefixCounts.sector.push( + next.diffPrefixCounts.sector[next.diffPrefixCounts.sector.length - 1] + + sectorDiffs, + ) + next.diffPrefixCounts.cell.push( + next.diffPrefixCounts.cell[next.diffPrefixCounts.cell.length - 1] + + cellDiffs, + ) + next.diffPrefixCounts.tile.push( + next.diffPrefixCounts.tile[next.diffPrefixCounts.tile.length - 1] + + tileDiffs, + ) + next.diffPrefixCounts.vertex.push( + next.diffPrefixCounts.vertex[next.diffPrefixCounts.vertex.length - 1] + + vertexDiffs, ) - next.diffPrefixCounts.edge.push(next.diffPrefixCounts.edge[next.diffPrefixCounts.edge.length - 1] + edgeDiffs) - next.diffPrefixCounts.line.push(next.diffPrefixCounts.line[next.diffPrefixCounts.line.length - 1] + lineDiffs) - next.diffPrefixCounts.sector.push(next.diffPrefixCounts.sector[next.diffPrefixCounts.sector.length - 1] + sectorDiffs) - next.diffPrefixCounts.cell.push(next.diffPrefixCounts.cell[next.diffPrefixCounts.cell.length - 1] + cellDiffs) - next.diffPrefixCounts.tile.push(next.diffPrefixCounts.tile[next.diffPrefixCounts.tile.length - 1] + tileDiffs) - next.diffPrefixCounts.vertex.push(next.diffPrefixCounts.vertex[next.diffPrefixCounts.vertex.length - 1] + vertexDiffs) next.points.push(makeChartPoint(stepNumber, next)) return { ...next } @@ -364,7 +411,8 @@ export const appendTraceStatsStep = (cache: TraceStatsCache, step: RuleStep): Tr export const rebuildTraceStatsCache = ( initialPuzzle: PuzzleIR, steps: RuleStep[] = [], -): TraceStatsCache => steps.reduce(appendTraceStatsStep, createTraceStatsCache(initialPuzzle)) +): TraceStatsCache => + steps.reduce(appendTraceStatsStep, createTraceStatsCache(initialPuzzle)) export const truncateTraceStatsCache = ( initialPuzzle: PuzzleIR, @@ -373,7 +421,10 @@ export const truncateTraceStatsCache = ( pointer: number, ): TraceStatsCache => { const clampedPointer = clampPointer(pointer, steps.length) - if (clampedPointer === steps.length && cache.points.length === steps.length + 1) { + if ( + clampedPointer === steps.length && + cache.points.length === steps.length + 1 + ) { return cache } return rebuildTraceStatsCache(initialPuzzle, steps.slice(0, clampedPointer)) @@ -441,7 +492,10 @@ export const buildTraceChartStats = ( pointer: number, ): TraceChartStats => { const currentPointer = clampPointer(pointer, steps.length) - const decisionMarks = initialPuzzle.puzzleType === 'masyu' ? initialPuzzle.lines ?? {} : initialPuzzle.edges + const decisionMarks = + initialPuzzle.puzzleType === 'masyu' + ? (initialPuzzle.lines ?? {}) + : initialPuzzle.edges const totalEdges = Object.keys(decisionMarks).length const totalCells = initialPuzzle.rows * initialPuzzle.cols const totalVertices = Object.keys(initialPuzzle.vertices).length @@ -466,12 +520,21 @@ export const buildTraceChartStats = ( } const makePoint = (step: number): TraceChartPoint => { - const decidedEdges = Object.values(edgeMarks).filter((mark) => mark !== 'unknown').length - const filledCells = Object.values(cellFills).filter((fill) => fill !== null).length - const narrowedVertices = Object.entries(vertexCandidates).filter(([key, candidates]) => { - const initialCount = initialVertexCandidateCounts[key] ?? 0 - return candidates.length < initialCount || vertexSignature(candidates) !== initialVertexSignatures[key] - }).length + const decidedEdges = Object.values(edgeMarks).filter( + (mark) => mark !== 'unknown', + ).length + const filledCells = Object.values(cellFills).filter( + (fill) => fill !== null, + ).length + const narrowedVertices = Object.entries(vertexCandidates).filter( + ([key, candidates]) => { + const initialCount = initialVertexCandidateCounts[key] ?? 0 + return ( + candidates.length < initialCount || + vertexSignature(candidates) !== initialVertexSignatures[key] + ) + }, + ).length return { step, @@ -492,7 +555,9 @@ export const buildTraceChartStats = ( } else if (diff.kind === 'tile') { continue } else if (diff.kind === 'vertex') { - vertexCandidates[diff.vertexKey] = diff.toCandidates.map((candidate) => [...candidate]) + vertexCandidates[diff.vertexKey] = diff.toCandidates.map( + (candidate) => [...candidate], + ) } } points.push(makePoint(index + 1)) diff --git a/src/domain/rules/engine.test.ts b/src/domain/rules/engine.test.ts index 1e5d759..29710e5 100644 --- a/src/domain/rules/engine.test.ts +++ b/src/domain/rules/engine.test.ts @@ -4,19 +4,64 @@ import { tileKey, vertexKey } from '../ir/keys' import { createMasyuPuzzle } from '../ir/masyu' import { applyRuleDiffs, revertRuleDiffs, runNextRule } from './engine' import { slitherRules } from './slither/rules' -import type { RuleDiff } from './types' +import type { Rule, RuleDiff } from './types' describe('rule engine', () => { it('finds at least one step for simple zero clue puzzle', () => { - const puzzle = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/3/3/g0h') + const puzzle = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/3/3/g0h', + ) const result = runNextRule(puzzle, slitherRules, 1) expect(result.step).not.toBeNull() expect(result.step?.diffs.length).toBeGreaterThan(0) expect(result.step?.durationMs).toBeGreaterThanOrEqual(0) + expect(result.step?.chainDurationMs).toBe(result.step?.durationMs) + expect(result.step?.ruleApplyMs).toBeGreaterThanOrEqual(0) + expect(result.step?.ruleAttempts?.at(-1)?.hit).toBe(true) + }) + + it('shares one runtime context across attempted rules in a solver step', () => { + const puzzle = createMasyuPuzzle(1, 2) + const line = Object.keys(puzzle.lines)[0] + const rules: Rule[] = [ + { + id: 'miss', + name: 'Miss', + apply: (_puzzle, runtimeContext) => { + runtimeContext?.cache.set('shared-value', 41) + return null + }, + }, + { + id: 'hit', + name: 'Hit', + apply: (_puzzle, runtimeContext) => { + const value = runtimeContext?.cache.get('shared-value') + return { + message: `shared ${String(value)}`, + diffs: [ + { kind: 'line', lineKey: line, from: 'unknown', to: 'line' }, + ], + affectedCells: [], + } + }, + }, + ] + + const result = runNextRule(puzzle, rules, 1) + + expect(result.step?.message).toBe('shared 41') + expect(result.step?.ruleAttempts).toHaveLength(2) + expect(result.step?.ruleAttempts?.map((attempt) => attempt.hit)).toEqual([ + false, + true, + ]) }) it('applies and reverts diffs without mutating input puzzle', () => { - const puzzle = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/3/3/g0h') + 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) @@ -53,16 +98,24 @@ describe('rule engine', () => { 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(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) + 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.sectors[sectorKey].constraintsMask).toBe( + puzzle.sectors[sectorKey].constraintsMask, + ) expect(rewound.cells['0,0']?.fill).toBeUndefined() - expect(rewound.vertices[centerVertexKey].candidateEdgeSets).toEqual(fromCandidates) + expect(rewound.vertices[centerVertexKey].candidateEdgeSets).toEqual( + fromCandidates, + ) }) it('applies and reverts line diffs without mutating input puzzle', () => { diff --git a/src/domain/rules/engine.ts b/src/domain/rules/engine.ts index 5caf121..2d182dd 100644 --- a/src/domain/rules/engine.ts +++ b/src/domain/rules/engine.ts @@ -1,5 +1,11 @@ import type { PuzzleIR } from '../ir/types' -import type { Rule, RuleDiff, RuleStep } from './types' +import type { + Rule, + RuleAttempt, + RuleDiff, + RuleRuntimeContext, + RuleStep, +} from './types' type WritableBuckets = { cells: PuzzleIR['cells'] | null @@ -43,11 +49,14 @@ const applyDiffEntry = ( next.sectors = writable.sectors } const prev = writable.sectors[diff.sectorKey] - writable.sectors[diff.sectorKey] = prev ? { ...prev, constraintsMask } : { constraintsMask } + writable.sectors[diff.sectorKey] = prev + ? { ...prev, constraintsMask } + : { constraintsMask } return } if (diff.kind === 'vertex') { - const candidateEdgeSets = mode === 'forward' ? diff.toCandidates : diff.fromCandidates + const candidateEdgeSets = + mode === 'forward' ? diff.toCandidates : diff.fromCandidates if (!writable.vertices) { writable.vertices = { ...(next.vertices ?? {}) } next.vertices = writable.vertices @@ -117,12 +126,19 @@ const applyRuleDiffsInternal = ( export const applyRuleDiffs = (puzzle: PuzzleIR, diffs: RuleDiff[]): PuzzleIR => applyRuleDiffsInternal(puzzle, diffs, 'forward') -export const revertRuleDiffs = (puzzle: PuzzleIR, diffs: RuleDiff[]): PuzzleIR => - applyRuleDiffsInternal(puzzle, diffs, 'backward') +export const revertRuleDiffs = ( + puzzle: PuzzleIR, + diffs: RuleDiff[], +): PuzzleIR => applyRuleDiffsInternal(puzzle, diffs, 'backward') -const applyDiffs = (puzzle: PuzzleIR, step: RuleStep): PuzzleIR => applyRuleDiffs(puzzle, step.diffs) +const applyDiffs = (puzzle: PuzzleIR, step: RuleStep): PuzzleIR => + applyRuleDiffs(puzzle, step.diffs) -export const buildPuzzleFromSteps = (initialPuzzle: PuzzleIR, steps: RuleStep[], pointer: number): PuzzleIR => { +export const buildPuzzleFromSteps = ( + initialPuzzle: PuzzleIR, + steps: RuleStep[], + pointer: number, +): PuzzleIR => { const clamped = Math.max(0, Math.min(pointer, steps.length)) let next = initialPuzzle for (let i = 0; i < clamped; i += 1) { @@ -131,7 +147,10 @@ export const buildPuzzleFromSteps = (initialPuzzle: PuzzleIR, steps: RuleStep[], return next } -export const rewindPuzzleByStep = (puzzle: PuzzleIR, step: RuleStep | undefined): PuzzleIR => { +export const rewindPuzzleByStep = ( + puzzle: PuzzleIR, + step: RuleStep | undefined, +): PuzzleIR => { if (!step) { return puzzle } @@ -144,11 +163,25 @@ export const runNextRule = ( stepNumber: number, ): { nextPuzzle: PuzzleIR; step: RuleStep | null } => { const startedAt = performance.now() + const runtimeContext: RuleRuntimeContext = { + cache: new Map(), + } + const attempts: RuleAttempt[] = [] for (const rule of rules) { - const result = rule.apply(puzzle) + const ruleStartedAt = performance.now() + const result = rule.apply(puzzle, runtimeContext) + const ruleApplyMs = Math.max(0, performance.now() - ruleStartedAt) + const hit = Boolean(result && result.diffs.length > 0) + attempts.push({ + ruleId: rule.id, + ruleName: rule.name, + durationMs: ruleApplyMs, + hit, + }) if (!result || result.diffs.length === 0) { continue } + const chainDurationMs = Math.max(0, performance.now() - startedAt) const step: RuleStep = { id: `step-${stepNumber}`, ruleId: rule.id, @@ -157,15 +190,22 @@ export const runNextRule = ( diffs: result.diffs, affectedCells: result.affectedCells, affectedTiles: - result.affectedTiles ?? result.diffs.flatMap((d) => (d.kind === 'tile' ? [d.tileKey] : [])), - affectedEdges: result.diffs.flatMap((d) => (d.kind === 'edge' ? [d.edgeKey] : [])), + result.affectedTiles ?? + result.diffs.flatMap((d) => (d.kind === 'tile' ? [d.tileKey] : [])), + affectedEdges: result.diffs.flatMap((d) => + d.kind === 'edge' ? [d.edgeKey] : [], + ), affectedLines: - result.affectedLines ?? result.diffs.flatMap((d) => (d.kind === 'line' ? [d.lineKey] : [])), + result.affectedLines ?? + result.diffs.flatMap((d) => (d.kind === 'line' ? [d.lineKey] : [])), affectedSectors: result.affectedSectors ?? result.diffs.flatMap((d) => (d.kind === 'sector' ? [d.sectorKey] : [])), timestamp: Date.now(), - durationMs: Math.max(0, performance.now() - startedAt), + durationMs: chainDurationMs, + chainDurationMs, + ruleApplyMs, + ruleAttempts: attempts, } return { nextPuzzle: applyDiffs(puzzle, step), diff --git a/src/domain/rules/masyu/rules/candidates.ts b/src/domain/rules/masyu/rules/candidates.ts index f00324d..28cbe4a 100644 --- a/src/domain/rules/masyu/rules/candidates.ts +++ b/src/domain/rules/masyu/rules/candidates.ts @@ -1,6 +1,6 @@ import { cellKey, parseCellKey } from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' -import type { Rule, RuleApplication } from '../../types' +import type { Rule, RuleApplication, RuleRuntimeContext } from '../../types' import { createMasyuLineDecisionCollector } from './decisionCollector' import type { MasyuLineOverlay } from './lineGraph' import { addMasyuOverlayDecision } from './pearlCandidates' @@ -13,17 +13,58 @@ import { type MasyuDirection, type MasyuDirectionalLine, } from './shared' -import { createMasyuLookaheadContext } from './lookahead' +import { getMasyuLookaheadContext } from './lookahead' type EmptyCellCandidate = { overlay: MasyuLineOverlay } +type PearlCandidateWithExits = { + lines: ReadonlySet + exitLines: ReadonlySet +} + +const getCommonLineDecisions = ( + overlays: MasyuLineOverlay[], +): Array<[string, LineMark]> => { + const [first] = overlays + if (!first) { + return [] + } + return [...first.entries()].filter(([lineKeyValue, mark]) => + overlays.every((overlay) => overlay.get(lineKeyValue) === mark), + ) +} + +const getCommonPearlCandidateDecisions = ( + candidates: PearlCandidateWithExits[], + exitLineKeys: string[], +): Array<[string, LineMark]> => { + const [first] = candidates + if (!first) { + return [] + } + const commonLines = [...first.lines] + .filter((lineKeyValue) => + candidates.every((candidate) => candidate.lines.has(lineKeyValue)), + ) + .map((lineKeyValue): [string, LineMark] => [lineKeyValue, 'line']) + const excludedExits = exitLineKeys + .filter((lineKeyValue) => + candidates.every((candidate) => !candidate.exitLines.has(lineKeyValue)), + ) + .map((lineKeyValue): [string, LineMark] => [lineKeyValue, 'blank']) + return [...commonLines, ...excludedExits] +} + export const createBlackPearlCandidatePruningRule = (): Rule => ({ id: 'masyu-black-pearl-candidate-pruning', name: 'Black Pearl Candidate Pruning', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const context = createMasyuLookaheadContext(puzzle) + apply: ( + puzzle: PuzzleIR, + runtimeContext?: RuleRuntimeContext, + ): RuleApplication | null => { + const context = getMasyuLookaheadContext(puzzle, runtimeContext) for (const pearlKey of context.getBlackPearlKeys()) { const decisions = createMasyuLineDecisionCollector(puzzle) @@ -34,18 +75,11 @@ export const createBlackPearlCandidatePruningRule = (): Rule => ({ continue } - const commonLineKeys = [...candidates[0].lines].filter((lineKeyValue) => - candidates.every((candidate) => candidate.lines.has(lineKeyValue)), - ) - const excludedExitLineKeys = exitLineKeys.filter((lineKeyValue) => - candidates.every((candidate) => !candidate.exitLines.has(lineKeyValue)), - ) - - for (const lineKeyValue of commonLineKeys) { - decisions.add(lineKeyValue, 'line') - } - for (const lineKeyValue of excludedExitLineKeys) { - decisions.add(lineKeyValue, 'blank') + for (const [lineKeyValue, mark] of getCommonPearlCandidateDecisions( + candidates, + exitLineKeys, + )) { + decisions.add(lineKeyValue, mark) } if (!decisions.hasChanges()) { @@ -71,8 +105,11 @@ export const createBlackPearlCandidatePruningRule = (): Rule => ({ export const createWhitePearlCandidatePruningRule = (): Rule => ({ id: 'masyu-white-pearl-candidate-pruning', name: 'White Pearl Candidate Pruning', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const context = createMasyuLookaheadContext(puzzle) + apply: ( + puzzle: PuzzleIR, + runtimeContext?: RuleRuntimeContext, + ): RuleApplication | null => { + const context = getMasyuLookaheadContext(puzzle, runtimeContext) for (const pearlKey of context.getWhitePearlKeys()) { const decisions = createMasyuLineDecisionCollector(puzzle, { @@ -85,18 +122,11 @@ export const createWhitePearlCandidatePruningRule = (): Rule => ({ continue } - const commonLineKeys = [...candidates[0].lines].filter((lineKeyValue) => - candidates.every((candidate) => candidate.lines.has(lineKeyValue)), - ) - const excludedExitLineKeys = exitLineKeys.filter((lineKeyValue) => - candidates.every((candidate) => !candidate.exitLines.has(lineKeyValue)), - ) - - for (const lineKeyValue of commonLineKeys) { - decisions.add(lineKeyValue, 'line') - } - for (const lineKeyValue of excludedExitLineKeys) { - decisions.add(lineKeyValue, 'blank') + for (const [lineKeyValue, mark] of getCommonPearlCandidateDecisions( + candidates, + exitLineKeys, + )) { + decisions.add(lineKeyValue, mark) } if (!decisions.hasChanges()) { @@ -226,8 +256,11 @@ const getAdjacentWhitePearlPairs = ( export const createAdjacentWhitePearlsLookaheadRule = (): Rule => ({ id: 'masyu-adjacent-white-pearls-lookahead', name: 'Adjacent White Pearls LookAhead', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const context = createMasyuLookaheadContext(puzzle) + apply: ( + puzzle: PuzzleIR, + runtimeContext?: RuleRuntimeContext, + ): RuleApplication | null => { + const context = getMasyuLookaheadContext(puzzle, runtimeContext) for (const pair of getAdjacentWhitePearlPairs(puzzle)) { const patterns: AdjacentWhitePearlPatternResult[] = ( @@ -303,8 +336,8 @@ const isEmptyCellCandidateActive = ( if (lineCount === 1 || unknownCount <= 3) { return true } - return incident.some((item) => - getMasyuIncidentLineCount(puzzle, item.neighborKey) > 0, + return incident.some( + (item) => getMasyuIncidentLineCount(puzzle, item.neighborKey) > 0, ) } @@ -350,10 +383,7 @@ const buildEmptyCellCandidates = ( ) { const overlay = buildEmptyCellCandidateOverlay( incident, - new Set([ - incident[leftIndex].lineKey, - incident[rightIndex].lineKey, - ]), + new Set([incident[leftIndex].lineKey, incident[rightIndex].lineKey]), ) if (overlay) { candidates.push({ overlay }) @@ -370,23 +400,14 @@ const getOverlayCellDegree = ( ): number => incident.filter((item) => overlay.get(item.lineKey) === 'line').length -const getCommonOverlayDecisions = ( - overlays: MasyuLineOverlay[], -): Array<[string, LineMark]> => { - const [first] = overlays - if (!first) { - return [] - } - return [...first.entries()].filter(([lineKeyValue, mark]) => - overlays.every((overlay) => overlay.get(lineKeyValue) === mark), - ) -} - export const createEmptyCellCandidatePruningRule = (): Rule => ({ id: 'masyu-empty-cell-candidate-pruning', name: 'Empty Cell Candidate Pruning', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const context = createMasyuLookaheadContext(puzzle) + apply: ( + puzzle: PuzzleIR, + runtimeContext?: RuleRuntimeContext, + ): RuleApplication | null => { + const context = getMasyuLookaheadContext(puzzle, runtimeContext) for (let row = 0; row < puzzle.rows; row += 1) { for (let col = 0; col < puzzle.cols; col += 1) { @@ -412,7 +433,7 @@ export const createEmptyCellCandidatePruningRule = (): Rule => ({ const decisions = createMasyuLineDecisionCollector(puzzle, { guardLineDegree: true, }) - for (const [lineKeyValue, mark] of getCommonOverlayDecisions( + for (const [lineKeyValue, mark] of getCommonLineDecisions( feasibleCandidates.map((candidate) => candidate.overlay), )) { decisions.add(lineKeyValue, mark) diff --git a/src/domain/rules/masyu/rules/lookahead.ts b/src/domain/rules/masyu/rules/lookahead.ts index 5a388b4..9c7e4af 100644 --- a/src/domain/rules/masyu/rules/lookahead.ts +++ b/src/domain/rules/masyu/rules/lookahead.ts @@ -1,4 +1,5 @@ import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { RuleRuntimeContext } from '../../types' import { areMasyuDirectionsOpposite, areMasyuDirectionsTurn, @@ -34,6 +35,8 @@ export type MasyuLookaheadContext = { getFeasibleWhitePearlCandidates: (pearlKey: string) => WhitePearlCandidate[] } +const MASYU_LOOKAHEAD_CONTEXT_CACHE_KEY = 'masyu.lookaheadContext' + export const createMasyuLookaheadContext = ( puzzle: PuzzleIR, ): MasyuLookaheadContext => { @@ -412,3 +415,19 @@ export const createMasyuLookaheadContext = ( getFeasibleWhitePearlCandidates, } } + +export const getMasyuLookaheadContext = ( + puzzle: PuzzleIR, + runtimeContext?: RuleRuntimeContext, +): MasyuLookaheadContext => { + if (!runtimeContext) { + return createMasyuLookaheadContext(puzzle) + } + const cached = runtimeContext.cache.get(MASYU_LOOKAHEAD_CONTEXT_CACHE_KEY) + if (cached) { + return cached as MasyuLookaheadContext + } + const context = createMasyuLookaheadContext(puzzle) + runtimeContext.cache.set(MASYU_LOOKAHEAD_CONTEXT_CACHE_KEY, context) + return context +} diff --git a/src/domain/rules/types.ts b/src/domain/rules/types.ts index 61afab5..cb77346 100644 --- a/src/domain/rules/types.ts +++ b/src/domain/rules/types.ts @@ -1,4 +1,10 @@ -import type { EdgeMark, LineMark, PuzzleIR, SectorConstraintMask, VertexCandidate } from '../ir/types' +import type { + EdgeMark, + LineMark, + PuzzleIR, + SectorConstraintMask, + VertexCandidate, +} from '../ir/types' export type EdgeDiff = { kind: 'edge' @@ -42,7 +48,13 @@ export type VertexDiff = { toCandidates: VertexCandidate[] } -export type RuleDiff = EdgeDiff | LineDiff | SectorDiff | CellDiff | TileDiff | VertexDiff +export type RuleDiff = + | EdgeDiff + | LineDiff + | SectorDiff + | CellDiff + | TileDiff + | VertexDiff export type RuleStep = { id: string @@ -57,6 +69,9 @@ export type RuleStep = { affectedSectors: string[] timestamp: number durationMs: number + chainDurationMs?: number + ruleApplyMs?: number + ruleAttempts?: RuleAttempt[] } export type RuleApplication = { @@ -68,8 +83,22 @@ export type RuleApplication = { affectedSectors?: string[] } +export type RuleAttempt = { + ruleId: string + ruleName: string + durationMs: number + hit: boolean +} + +export type RuleRuntimeContext = { + cache: Map +} + export type Rule = { id: string name: string - apply: (puzzle: PuzzleIR) => RuleApplication | null + apply: ( + puzzle: PuzzleIR, + runtimeContext?: RuleRuntimeContext, + ) => RuleApplication | null } From 46f03abfe9fe023707dd61de7360f34a43f30c5f Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 28 May 2026 21:29:19 +0800 Subject: [PATCH 09/22] refactor: remove Empty Cell Candidate Pruning rule --- docs/MASYU_AGENT_BRIEF.md | 9 +++------ docs/techniques/masyu.md | 17 ----------------- src/domain/rules/masyu/rules.test.ts | 6 +----- src/domain/rules/masyu/rules.ts | 2 -- 4 files changed, 4 insertions(+), 30 deletions(-) diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index 2ca2b91..f134f74 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -45,10 +45,9 @@ Registered rule order: 13. `Black Pearl Candidate Pruning` 14. `White Pearl Candidate Pruning` 15. `Adjacent White Pearls LookAhead` -16. `Empty Cell Candidate Pruning` -17. `Cell Exit Completion` -18. `Black Pearl Strong Inference` -19. `White Pearl Strong Inference` +16. `Cell Exit Completion` +17. `Black Pearl Strong Inference` +18. `White Pearl Strong Inference` Implemented rule areas: @@ -58,8 +57,6 @@ Implemented rule areas: - Black pearl candidate pruning with shallow feasibility checks. - White pearl candidate pruning with the same shared pearl-candidate model. - Adjacent white pearl lookahead pruning for the two local joint traversal modes. -- Empty cell candidate pruning for selected non-pearl cells, using bounded - degree-0/degree-2 candidates without deterministic trial propagation. - Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. - Unified cell-exit completion for ordinary cells, white pearls, and black pearls. diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 4bfe38c..64c649e 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -263,23 +263,6 @@ Mode 2: both pearls use parallel perpendicular paths. Only the feasible mode is kept. ``` -### Empty Cell Candidate Pruning - -Empty Cell Candidate Pruning checks selected non-pearl cells that are already -locally constrained. It compares the bounded local candidates where the loop -either skips the cell completely or passes through it with exactly two exits. - -This rule can determine confirmed lines and crossed-out lines: - -- It considers only active empty cells, such as cells with one confirmed line, - few unknown exits, or nearby confirmed line structure. -- It filters degree-0 and degree-2 candidates with the same local feasibility - checks used by pearl candidate pruning. -- It does not run deterministic rule propagation or strong inference inside a - candidate assumption. -- If every locally feasible candidate agrees on an incident line, that line is - decided. - ## Tile Color Rules Tile colors are vertex-centered region markers used for inside/outside diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index d25beb5..c4c4526 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -482,7 +482,6 @@ describe('Masyu pearl rules', () => { 'Black Pearl Candidate Pruning', 'White Pearl Candidate Pruning', 'Adjacent White Pearls LookAhead', - 'Empty Cell Candidate Pruning', 'Cell Exit Completion', 'Black Pearl Strong Inference', 'White Pearl Strong Inference', @@ -817,10 +816,7 @@ describe('Masyu loop rules', () => { expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBe( rules.indexOf('masyu-white-pearl-candidate-pruning') + 1, ) - expect(rules.indexOf('masyu-empty-cell-candidate-pruning')).toBe( - rules.indexOf('masyu-adjacent-white-pearls-lookahead') + 1, - ) - expect(rules.indexOf('masyu-empty-cell-candidate-pruning')).toBeLessThan( + expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBeLessThan( rules.indexOf('cell-exit-completion'), ) }) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index 10df47b..bc691db 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -4,7 +4,6 @@ import { createMasyuCandidateBridgeLineRule } from './rules/bridges' import { createAdjacentWhitePearlsLookaheadRule, createBlackPearlCandidatePruningRule, - createEmptyCellCandidatePruningRule, createWhitePearlCandidatePruningRule, } from './rules/candidates' import { @@ -40,7 +39,6 @@ export const deterministicMasyuRules: Rule[] = [ createBlackPearlCandidatePruningRule(), createWhitePearlCandidatePruningRule(), createAdjacentWhitePearlsLookaheadRule(), - createEmptyCellCandidatePruningRule(), createCellExitCompletionRule(), ] From ce18cb9e0155c4106812eeee0fa3c4dd43b33ce3 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 29 May 2026 00:19:00 +0800 Subject: [PATCH 10/22] chore: black squeeze enhance --- docs/techniques/masyu.md | 5 +- src/domain/rules/masyu/rules.test.ts | 81 ++++++++++++++++++++++++ src/domain/rules/masyu/rules/patterns.ts | 14 +++- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 64c649e..3375901 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -150,7 +150,7 @@ white pearls. If a non-pearl cell sits between two black pearls, that middle cell cannot keep exactly one perpendicular exit. If one perpendicular exit is already crossed -out, the other perpendicular exit is crossed out too. +out or points off-board, the other perpendicular exit is crossed out too. This rule determines crossed-out line segments at the middle cell. @@ -159,7 +159,8 @@ This rule determines crossed-out line segments at the middle cell. B ? . ? B x -If one vertical exit of the middle cell is x, the other vertical exit is x too. +If one vertical exit of the middle cell is x or off-board, the other vertical +exit is x too. ``` ## Completion Rules diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index c4c4526..98bcfab 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -1936,6 +1936,87 @@ describe('Masyu pattern rules', () => { expectLineDiffs(result?.diffs, { [east]: 'blank' }) }) + it('Double Black Squeeze treats the bottom edge as a crossed-out perpendicular exit', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 9, 5, 'black') + addPearl(puzzle, 9, 7, 'black') + const north = lineKey([8, 6], [9, 6]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(9, 6)]) + }) + + it('Double Black Squeeze treats the top edge as a crossed-out perpendicular exit', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 0, 2, 'black') + addPearl(puzzle, 0, 4, 'black') + const south = lineKey([0, 3], [1, 3]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(0, 3)]) + }) + + it('Double Black Squeeze treats side edges as crossed-out perpendicular exits', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 2, 0, 'black') + addPearl(puzzle, 4, 0, 'black') + addPearl(puzzle, 5, 9, 'black') + addPearl(puzzle, 7, 9, 'black') + const rightFromLeftEdge = lineKey([3, 0], [3, 1]) + const leftFromRightEdge = lineKey([6, 8], [6, 9]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [rightFromLeftEdge]: 'blank', + [leftFromRightEdge]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(3, 0), cellKey(6, 9)]) + }) + + it('Double Black Squeeze does not overwrite a border perpendicular exit that is already a line', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 9, 5, 'black') + addPearl(puzzle, 9, 7, 'black') + markLine(puzzle, lineKey([8, 6], [9, 6]), 'line') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze handles the supplied bottom-edge puzzle without strong inference', () => { + let puzzle = masyuPlugin.parse( + 'https://puzz.link/p?mashu/10/10/0000000000000000000000000300000260', + ) + const rules = deterministicMasyuRules + const expectedLines = [ + lineKey([7, 5], [8, 5]), + lineKey([8, 5], [9, 5]), + lineKey([7, 7], [8, 7]), + lineKey([8, 7], [9, 7]), + ] + const squeezed = lineKey([8, 6], [9, 6]) + let sawDoubleBlackSqueeze = false + + for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { + const result = runNextRule(puzzle, rules, stepNumber) + if (!result.step) { + break + } + sawDoubleBlackSqueeze ||= result.step.ruleName === 'Double Black Squeeze' + puzzle = result.nextPuzzle + } + + expect(sawDoubleBlackSqueeze).toBe(true) + expect(puzzle.lines[squeezed]?.mark).toBe('blank') + for (const key of expectedLines) { + expect(puzzle.lines[key]?.mark).toBe('line') + } + }) + it('Double Black Squeeze does not fire unless both opposite cells are black pearls', () => { const puzzle = createMasyuPuzzle(5, 6) addPearl(puzzle, 2, 2, 'black') diff --git a/src/domain/rules/masyu/rules/patterns.ts b/src/domain/rules/masyu/rules/patterns.ts index 80f2524..944a501 100644 --- a/src/domain/rules/masyu/rules/patterns.ts +++ b/src/domain/rules/masyu/rules/patterns.ts @@ -331,9 +331,21 @@ const collectDoubleBlackSqueeze = ( middleKey, perpendicularDirections[1], ) - if (!first || !second) { + if (!first && !second) { return null } + if (!first) { + const onlyExit = second + return onlyExit?.mark === 'unknown' && + decisions.addNew(onlyExit.lineKey, 'blank') + ? onlyExit.lineKey + : null + } + if (!second) { + return first.mark === 'unknown' && decisions.addNew(first.lineKey, 'blank') + ? first.lineKey + : null + } if (first.mark === 'blank') { return decisions.addNew(second.lineKey, 'blank') ? second.lineKey : null } From f452debf7f07e8691c718f920915a7ae104c21a0 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 29 May 2026 00:38:49 +0800 Subject: [PATCH 11/22] feat: implement Empty Cell Strong Inference rule to enhance Masyu puzzle solving by determining forced line configurations in non-pearl cells --- README.md | 1 + docs/MASYU_AGENT_BRIEF.md | 3 + docs/techniques/masyu.md | 13 + src/domain/rules/masyu/rules.test.ts | 192 +++++++++++++- src/domain/rules/masyu/rules.ts | 2 + .../masyu/rules/emptyCellStrongInference.ts | 236 ++++++++++++++++++ .../rules/masyu/rules/strongInference.ts | 6 +- 7 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 src/domain/rules/masyu/rules/emptyCellStrongInference.ts diff --git a/README.md b/README.md index fb679c7..434c619 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Current focus: - Pure web-based solving experience - Rule-driven, explainable, replayable deduction flow - Modular Slitherlink rule architecture (including strong-inference fallback) +- Masyu rule stack with pearl, color, loop, completion, and bounded strong-inference deductions - Practical interoperability with common puzzle URL formats (currently centered on puzz.link) --- diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index f134f74..e7fe938 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -48,6 +48,7 @@ Registered rule order: 16. `Cell Exit Completion` 17. `Black Pearl Strong Inference` 18. `White Pearl Strong Inference` +19. `Empty Cell Strong Inference` Implemented rule areas: @@ -58,6 +59,8 @@ Implemented rule areas: - White pearl candidate pruning with the same shared pearl-candidate model. - Adjacent white pearl lookahead pruning for the two local joint traversal modes. - Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. +- Empty cell strong inference for bounded degree-1 continuations and degree-0 + two-exit branches on non-pearl cells. - Unified cell-exit completion for ordinary cells, white pearls, and black pearls. - Tile color propagation: diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 3375901..eb4b1b4 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -361,3 +361,16 @@ forced in the real puzzle. This rule determines the confirmed lines and crossed-out lines belonging to the forced white-pearl axis. + +### Empty Cell Strong Inference + +For a non-pearl cell, the solver can test bounded degree choices when the local +state has either one known line with exactly two unknown exits, or no known lines +with exactly two unknown exits. + +In the degree-1 case, it assumes each possible continuation in turn. If one +continuation reaches a contradiction, the other continuation is forced. + +In the degree-0 case, it tests the two local modes: both exits are used, or both +exits are crossed out. If one mode reaches a contradiction, the other mode is +forced in the real puzzle. diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index 98bcfab..1fd3617 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -34,6 +34,7 @@ import { createMasyuLineDecisionCollector, createMasyuTileDecisionCollector, } from './rules/decisionCollector' +import { createEmptyCellStrongInferenceRule } from './rules/emptyCellStrongInference' import { getMasyuBlackPearlKeys, getMasyuPearlColor, @@ -485,6 +486,7 @@ describe('Masyu pearl rules', () => { 'Cell Exit Completion', 'Black Pearl Strong Inference', 'White Pearl Strong Inference', + 'Empty Cell Strong Inference', ]) }) @@ -1533,8 +1535,9 @@ describe('Masyu black pearl strong inference', () => { expect( rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id), ).toEqual(deterministicMasyuRules.map((rule) => rule.id)) - expect(rules.at(-2)?.id).toBe('masyu-black-pearl-strong-inference') - expect(rules.at(-1)?.id).toBe('masyu-white-pearl-strong-inference') + expect(rules.at(-3)?.id).toBe('masyu-black-pearl-strong-inference') + expect(rules.at(-2)?.id).toBe('masyu-white-pearl-strong-inference') + expect(rules.at(-1)?.id).toBe('masyu-empty-cell-strong-inference') }) }) @@ -1703,6 +1706,191 @@ describe('Masyu white pearl strong inference', () => { }) }) +describe('Masyu empty cell strong inference', () => { + it('forces the opposite continuation when one degree-1 branch causes a degree contradiction', () => { + const puzzle = createMasyuPuzzle(3, 4) + const target = cellKey(0, 1) + const west = lineKey([0, 0], [0, 1]) + const east = lineKey([0, 1], [0, 2]) + const south = lineKey([0, 1], [1, 1]) + markLine(puzzle, west, 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + + const result = createEmptyCellStrongInferenceRule(() => [], { + maxCandidates: 1, + }).apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { + [south]: 'line', + [east]: 'blank', + }) + expect(result?.message).toContain('cell-degree contradiction') + expect(result?.message).toContain('must be the continuation') + }) + + it('crosses out both remaining exits when using both causes a contradiction', () => { + const puzzle = createMasyuPuzzle(2, 3) + const target = cellKey(0, 0) + const east = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 0], [1, 0]) + markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + + const result = createEmptyCellStrongInferenceRule(() => [], { + maxCandidates: 1, + }).apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [south]: 'blank', + }) + expect(result?.message).toContain('using both remaining exits') + expect(result?.message).toContain('both remaining exits are crossed out') + }) + + it('forces both remaining exits when using neither causes a degree contradiction', () => { + const puzzle = createMasyuPuzzle(2, 3) + const target = cellKey(0, 0) + const east = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 0], [1, 0]) + markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + + const result = createEmptyCellStrongInferenceRule(() => []).apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { + [east]: 'line', + [south]: 'line', + }) + expect(result?.message).toContain('using neither remaining exit') + expect(result?.message).toContain('both remaining exits must be lines') + }) + + it('uses deterministic downstream rules to find an empty-cell contradiction', () => { + const puzzle = createMasyuPuzzle(3, 4) + const target = cellKey(0, 1) + const west = lineKey([0, 0], [0, 1]) + const east = lineKey([0, 1], [0, 2]) + const south = lineKey([0, 1], [1, 1]) + const eastOfEast = lineKey([0, 2], [0, 3]) + const southOfEast = lineKey([0, 2], [1, 2]) + markLine(puzzle, west, 'line') + const downstreamRule: Rule = { + id: 'test-empty-cell-downstream-degree', + name: 'Test Empty Cell Downstream Degree', + apply: (trial) => { + if ((trial.lines[east]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[eastOfEast]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Force downstream empty-cell contradiction', + diffs: [ + { + kind: 'line', + lineKey: eastOfEast, + from: 'unknown', + to: 'line', + }, + { + kind: 'line', + lineKey: southOfEast, + from: 'unknown', + to: 'line', + }, + ], + affectedCells: [], + affectedLines: [eastOfEast, southOfEast], + } + }, + } + + const result = createEmptyCellStrongInferenceRule(() => [ + downstreamRule, + ]).apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { + [south]: 'line', + [east]: 'blank', + }) + expect(result?.message).toContain('after 1 step') + }) + + it('does not copy empty-cell trial progress back into the real puzzle', () => { + const puzzle = createMasyuPuzzle(3, 4) + const west = lineKey([0, 0], [0, 1]) + const east = lineKey([0, 1], [0, 2]) + const unrelated = lineKey([2, 2], [2, 3]) + markLine(puzzle, west, 'line') + const harmlessRule: Rule = { + id: 'test-empty-cell-harmless-trial-progress', + name: 'Test Empty Cell Harmless Trial Progress', + apply: (trial) => { + if ((trial.lines[east]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Harmless empty-cell trial-only progress', + diffs: [ + { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, + ], + affectedCells: [], + affectedLines: [unrelated], + } + }, + } + + const result = createEmptyCellStrongInferenceRule(() => [harmlessRule], { + maxTrialSteps: 1, + }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[unrelated]?.mark).toBe('unknown') + }) + + it('returns null when the empty-cell trial budget times out', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + + const result = createEmptyCellStrongInferenceRule(() => [], { + maxMs: -1, + }).apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not force an opposite empty-cell branch through a degree-2 neighbor', () => { + const puzzle = createMasyuPuzzle(3, 4) + const west = lineKey([0, 0], [0, 1]) + const east = lineKey([0, 1], [0, 2]) + const south = lineKey([0, 1], [1, 1]) + markLine(puzzle, west, 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + + const result = createEmptyCellStrongInferenceRule(() => [], { + maxCandidates: 1, + }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[east]?.mark).toBe('unknown') + expect(puzzle.lines[south]?.mark).toBe('unknown') + }) +}) + describe('Masyu pattern rules', () => { it('Black Facing Consecutive Whites forces away from horizontal consecutive white pearls', () => { const puzzle = createMasyuPuzzle(5, 6) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index bc691db..a685ffe 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -13,6 +13,7 @@ import { } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' import { createCellExitCompletionRule } from './rules/completion' +import { createEmptyCellStrongInferenceRule } from './rules/emptyCellStrongInference' import { createPreventPrematureLoopRule } from './rules/loop' import { createBlackDiagonalWhitePinchRule, @@ -46,4 +47,5 @@ export const masyuRules: Rule[] = [ ...deterministicMasyuRules, createBlackPearlStrongInferenceRule(() => deterministicMasyuRules), createWhitePearlStrongInferenceRule(() => deterministicMasyuRules), + createEmptyCellStrongInferenceRule(() => deterministicMasyuRules), ] diff --git a/src/domain/rules/masyu/rules/emptyCellStrongInference.ts b/src/domain/rules/masyu/rules/emptyCellStrongInference.ts new file mode 100644 index 0000000..238efe2 --- /dev/null +++ b/src/domain/rules/masyu/rules/emptyCellStrongInference.ts @@ -0,0 +1,236 @@ +import { cellKey } from '../../../ir/keys' +import type { PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { createMasyuLineDecisionCollector } from './decisionCollector' +import { + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuIncidentDirectionalLines, + MASYU_DIRECTIONS, + type MasyuDirectionalLine, +} from './shared' +import { + buildMasyuStrongBranch, + deriveMasyuStrongProbeBudgets, + describeMasyuStrongTrialResult, + immediateMasyuStrongContradictionResult, + STRONG_MAX_CANDIDATES, + STRONG_MAX_MS, + STRONG_MAX_TRIAL_STEPS, + type MasyuLineAssumption, + type MasyuStrongInferenceOptions, +} from './strongInference' +import { runMasyuTrialUntilFixpoint } from './trial' + +type EmptyCellStrongCandidate = { + cell: string + mode: 'continuation' | 'degree-zero' + unknowns: [MasyuDirectionalLine, MasyuDirectionalLine] +} + +type EmptyCellStrongBranch = { + label: string + assumptions: MasyuLineAssumption[] + forced: MasyuLineAssumption[] +} + +const collectEmptyCellStrongCandidates = ( + puzzle: PuzzleIR, + maxCandidates: number, +): EmptyCellStrongCandidate[] => { + const candidates: EmptyCellStrongCandidate[] = [] + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + if (puzzle.cells[key]?.clue?.kind === 'pearl') { + continue + } + + const incidentByDirection = getMasyuIncidentDirectionalLines(puzzle, key) + const incident = MASYU_DIRECTIONS.flatMap((direction) => { + const item = incidentByDirection[direction] + return item ? [item] : [] + }) + const lineCount = incident.filter((item) => item.mark === 'line').length + const unknowns = incident.filter((item) => item.mark === 'unknown') + if (unknowns.length !== 2 || (lineCount !== 0 && lineCount !== 1)) { + continue + } + + candidates.push({ + cell: key, + mode: lineCount === 1 ? 'continuation' : 'degree-zero', + unknowns: [unknowns[0], unknowns[1]], + }) + if (candidates.length >= maxCandidates) { + return candidates + } + } + } + return candidates +} + +const buildBranches = ( + candidate: EmptyCellStrongCandidate, +): EmptyCellStrongBranch[] => { + const [first, second] = candidate.unknowns + if (candidate.mode === 'continuation') { + return [ + { + label: `${formatMasyuLineLabel(first.lineKey)} as the continuation`, + assumptions: [ + [first.lineKey, 'line'], + [second.lineKey, 'blank'], + ], + forced: [ + [second.lineKey, 'line'], + [first.lineKey, 'blank'], + ], + }, + { + label: `${formatMasyuLineLabel(second.lineKey)} as the continuation`, + assumptions: [ + [second.lineKey, 'line'], + [first.lineKey, 'blank'], + ], + forced: [ + [first.lineKey, 'line'], + [second.lineKey, 'blank'], + ], + }, + ] + } + + return [ + { + label: 'using both remaining exits', + assumptions: [ + [first.lineKey, 'line'], + [second.lineKey, 'line'], + ], + forced: [ + [first.lineKey, 'blank'], + [second.lineKey, 'blank'], + ], + }, + { + label: 'using neither remaining exit', + assumptions: [ + [first.lineKey, 'blank'], + [second.lineKey, 'blank'], + ], + forced: [ + [first.lineKey, 'line'], + [second.lineKey, 'line'], + ], + }, + ] +} + +const collectForcedDiffs = ( + puzzle: PuzzleIR, + forced: MasyuLineAssumption[], +): ReturnType['diffs']> | null => { + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + for (const [lineKeyValue, mark] of forced) { + if (!decisions.add(lineKeyValue, mark)) { + return null + } + } + return decisions.hasChanges() ? decisions.diffs() : null +} + +const describeForced = (forced: MasyuLineAssumption[]): string => { + const lineCount = forced.filter(([, mark]) => mark === 'line').length + const blankCount = forced.filter(([, mark]) => mark === 'blank').length + if (lineCount === 2) { + return 'both remaining exits must be lines' + } + if (blankCount === 2) { + return 'both remaining exits are crossed out' + } + const line = forced.find(([, mark]) => mark === 'line')?.[0] + return line + ? `${formatMasyuLineLabel(line)} must be the continuation` + : forced + .map( + ([lineKeyValue, mark]) => + `${formatMasyuLineLabel(lineKeyValue)} ${mark}`, + ) + .join(', ') +} + +export const createEmptyCellStrongInferenceRule = ( + getDeterministicRules: () => Rule[], + options: MasyuStrongInferenceOptions = {}, +): Rule => ({ + id: 'masyu-empty-cell-strong-inference', + name: 'Empty Cell Strong Inference', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const deterministicRules = getDeterministicRules() + const candidates = collectEmptyCellStrongCandidates( + puzzle, + options.maxCandidates ?? STRONG_MAX_CANDIDATES, + ) + if (candidates.length === 0) { + return null + } + + const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) + const budgets = deriveMasyuStrongProbeBudgets( + options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, + ) + + for (const budget of budgets) { + for (const candidate of candidates) { + if (Date.now() > deadlineMs) { + return null + } + + for (const trialBranch of buildBranches(candidate)) { + const branch = buildMasyuStrongBranch( + puzzle, + trialBranch.assumptions, + ) + const result = branch.setupOk + ? runMasyuTrialUntilFixpoint( + branch.puzzle, + deterministicRules, + budget, + deadlineMs, + ) + : immediateMasyuStrongContradictionResult(branch.puzzle) + if (result.timedOut) { + return null + } + if (!result.contradiction) { + continue + } + + const diffs = collectForcedDiffs(puzzle, trialBranch.forced) + if (!diffs || diffs.length === 0) { + continue + } + const firstLine = diffs[0]?.lineKey + return { + message: + `Empty Cell Strong Inference: assuming ${formatMasyuCellKeyLabel( + candidate.cell, + )} is ${trialBranch.label} (${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult( + result, + )}, so ${describeForced(trialBranch.forced)} through ${formatMasyuLineLabel( + firstLine, + )}${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.`, + diffs, + affectedCells: [candidate.cell], + affectedLines: diffs.map((diff) => diff.lineKey), + } + } + } + } + + return null + }, +}) diff --git a/src/domain/rules/masyu/rules/strongInference.ts b/src/domain/rules/masyu/rules/strongInference.ts index 857d8fe..72d53da 100644 --- a/src/domain/rules/masyu/rules/strongInference.ts +++ b/src/domain/rules/masyu/rules/strongInference.ts @@ -20,9 +20,9 @@ export type MasyuStrongBranch = { export type MasyuLineAssumption = [lineKey: string, mark: LineMark] -export const STRONG_MAX_CANDIDATES = 200 -export const STRONG_MAX_TRIAL_STEPS = 100 -export const STRONG_MAX_MS = 10000 +export const STRONG_MAX_CANDIDATES = 300 +export const STRONG_MAX_TRIAL_STEPS = 80 +export const STRONG_MAX_MS = 5000 export const deriveMasyuStrongProbeBudgets = ( maxTrialSteps: number, From a3fb774893223674c6efc0d99667b39dcda76f42 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 29 May 2026 13:18:29 +0800 Subject: [PATCH 12/22] feat: enhance Masyu puzzle solving with new rules for black and white pearl candidate pruning, hard contradiction detection, and improved lookahead context --- src/domain/rules/masyu/rules.test.ts | 77 ++++++++++++++++++- src/domain/rules/masyu/rules/lookahead.ts | 54 ++++++++++++- .../rules/masyu/rules/lookaheadGeometry.ts | 2 + .../rules/masyu/rules/strongInference.ts | 2 +- src/domain/rules/masyu/rules/trial.ts | 11 ++- 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index 1fd3617..368eab5 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -47,6 +47,8 @@ import { findMasyuPrematureLoopClosingLines, getMasyuRequiredSources, } from './rules/lineGraph' +import { createMasyuLookaheadContext } from './rules/lookahead' +import { findMasyuHardContradictionReason } from './rules/trial' const markLine = (puzzle: PuzzleIR, key: string, mark: LineMark): void => { puzzle.lines[key] = { ...puzzle.lines[key], mark } @@ -1051,6 +1053,19 @@ describe('Masyu black pearl candidate pruning', () => { expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() }) + it('rejects a black pearl candidate that leaves an extension endpoint with no second exit', () => { + const puzzle = createMasyuPuzzle(4, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 3], [0, 4]), 'line') + markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') + + expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + it('does nothing when all black pearl candidates remain symmetric', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'black') @@ -1140,6 +1155,21 @@ describe('Masyu white pearl candidate pruning', () => { expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() }) + it('rejects a white pearl axis that leaves a neighboring empty cell with no second exit', () => { + const puzzle = createMasyuPuzzle(4, 5) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'line') + markLine(puzzle, lineKey([1, 0], [2, 0]), 'line') + markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + + expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + it('does nothing when both white straight-axis candidates remain symmetric', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') @@ -1252,6 +1282,32 @@ describe('Masyu adjacent white pearls lookahead', () => { expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() }) + + it('rejects the reported adjacent-white horizontal assumption when it creates a single-exit empty cell', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 6, 6, 'black') + addPearl(puzzle, 4, 8, 'white') + addPearl(puzzle, 5, 8, 'white') + markLine(puzzle, lineKey([4, 6], [5, 6]), 'line') + markLine(puzzle, lineKey([5, 6], [6, 6]), 'line') + markLine(puzzle, lineKey([4, 6], [4, 7]), 'line') + markLine(puzzle, lineKey([6, 6], [6, 7]), 'line') + markLine(puzzle, lineKey([6, 7], [6, 8]), 'line') + + const horizontalThroughOverlay = new Map([ + [lineKey([5, 7], [5, 8]), 'line'], + [lineKey([5, 8], [5, 9]), 'line'], + [lineKey([4, 7], [4, 8]), 'line'], + [lineKey([4, 8], [4, 9]), 'line'], + ]) + + expect( + createMasyuLookaheadContext(puzzle).isOverlayLocallyFeasible( + [cellKey(4, 8), cellKey(5, 8)], + horizontalThroughOverlay, + ), + ).toBe(false) + }) }) describe('Masyu empty cell candidate pruning', () => { @@ -1295,7 +1351,6 @@ describe('Masyu empty cell candidate pruning', () => { const puzzle = createMasyuPuzzle(2, 4) addPearl(puzzle, 0, 2, 'white') const westOfEmpty = lineKey([0, 0], [0, 1]) - const toWhite = lineKey([0, 1], [0, 2]) markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') @@ -1303,7 +1358,7 @@ describe('Masyu empty cell candidate pruning', () => { expectLineDiffs(result?.diffs, { [westOfEmpty]: 'line', - [toWhite]: 'line', + [lineKey([0, 0], [1, 0])]: 'line', }) }) @@ -1403,6 +1458,24 @@ describe('Masyu shared helper primitives', () => { }) }) +describe('Masyu hard contradiction detection', () => { + it('treats a degree-1 empty cell as closed when every unknown exit would overflow its other endpoint', () => { + const puzzle = createMasyuPuzzle(3, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [2, 0]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 0]), 'line') + + const reason = findMasyuHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('cell-degree') + expect(reason?.message).toContain('only one line segment') + }) +}) + describe('Masyu black pearl strong inference', () => { it('crosses out a black pearl exit whose two-step assumption causes a degree contradiction', () => { const puzzle = createMasyuPuzzle(5, 5) diff --git a/src/domain/rules/masyu/rules/lookahead.ts b/src/domain/rules/masyu/rules/lookahead.ts index 9c7e4af..70ae7b9 100644 --- a/src/domain/rules/masyu/rules/lookahead.ts +++ b/src/domain/rules/masyu/rules/lookahead.ts @@ -59,6 +59,54 @@ export const createMasyuLookaheadContext = ( .getIncidentEntries(overlay, key) .filter((item) => item.mark === 'line').length <= 2 + const canOverlayLineStillBecomeLine = ( + overlay: MasyuLineOverlay, + lineKeyValue: string, + ): boolean => { + const mark = geometry.getLineMark(overlay, lineKeyValue) + if (mark === 'blank') { + return false + } + if (mark === 'line') { + return true + } + const [left, right] = geometry.lineCells(lineKeyValue) + return [left, right].every( + (key) => + geometry + .getIncidentEntries(overlay, key) + .filter((item) => item.mark === 'line').length < 2, + ) + } + + const isEmptyCellDegreeStillPossible = ( + overlay: MasyuLineOverlay, + key: string, + ): boolean => { + if (geometry.pearlColors.has(key)) { + return true + } + const incident = geometry.getIncidentEntries(overlay, key) + const lineEntries = incident.filter((item) => item.mark === 'line') + if (lineEntries.length > 2) { + return false + } + if (lineEntries.length !== 1) { + return true + } + return incident.some( + (item) => + item.mark === 'unknown' && + canOverlayLineStillBecomeLine(overlay, item.lineKey), + ) + } + + const isCellLocallyFeasible = ( + overlay: MasyuLineOverlay, + key: string, + ): boolean => + isCellDegreeValid(overlay, key) && isEmptyCellDegreeStillPossible(overlay, key) + const canApplyLocalDecisions = ( overlay: MasyuLineOverlay, decisions: MasyuLineOverlay, @@ -69,7 +117,7 @@ export const createMasyuLookaheadContext = ( } for (const key of geometry.getTouchedCells(decisions.keys())) { if ( - !isCellDegreeValid(merged, key) || + !isCellLocallyFeasible(merged, key) || !isPearlShapeStillPossible(merged, key, { checkWhiteAdjacentTurn: false, }) @@ -309,7 +357,7 @@ export const createMasyuLookaheadContext = ( } for (const key of geometry.getTouchedCells(overlay.keys())) { if ( - !isCellDegreeValid(overlay, key) || + !isCellLocallyFeasible(overlay, key) || !isPearlShapeStillPossible(overlay, key) ) { return false @@ -334,7 +382,7 @@ export const createMasyuLookaheadContext = ( ...candidate.blanks, ])) { if ( - !isCellDegreeValid(overlay, key) || + !isCellLocallyFeasible(overlay, key) || !isPearlShapeStillPossible(overlay, key) ) { return false diff --git a/src/domain/rules/masyu/rules/lookaheadGeometry.ts b/src/domain/rules/masyu/rules/lookaheadGeometry.ts index c5f848f..8497b59 100644 --- a/src/domain/rules/masyu/rules/lookaheadGeometry.ts +++ b/src/domain/rules/masyu/rules/lookaheadGeometry.ts @@ -21,6 +21,7 @@ export type MasyuLookaheadGeometry = { totalBaseLineCount: number findBase: (idx: number) => number lineEndpoints: (lineKeyValue: string) => [left: number, right: number] + lineCells: (lineKeyValue: string) => [left: string, right: string] getIncident: ( key: string, ) => Record @@ -323,6 +324,7 @@ export const createMasyuLookaheadGeometry = ( totalBaseLineCount, findBase, lineEndpoints, + lineCells, getIncident, getTwoStep, getTurnCandidates, diff --git a/src/domain/rules/masyu/rules/strongInference.ts b/src/domain/rules/masyu/rules/strongInference.ts index 72d53da..c77fd05 100644 --- a/src/domain/rules/masyu/rules/strongInference.ts +++ b/src/domain/rules/masyu/rules/strongInference.ts @@ -22,7 +22,7 @@ export type MasyuLineAssumption = [lineKey: string, mark: LineMark] export const STRONG_MAX_CANDIDATES = 300 export const STRONG_MAX_TRIAL_STEPS = 80 -export const STRONG_MAX_MS = 5000 +export const STRONG_MAX_MS = 10000 export const deriveMasyuStrongProbeBudgets = ( maxTrialSteps: number, diff --git a/src/domain/rules/masyu/rules/trial.ts b/src/domain/rules/masyu/rules/trial.ts index 5cd5581..2c0dce6 100644 --- a/src/domain/rules/masyu/rules/trial.ts +++ b/src/domain/rules/masyu/rules/trial.ts @@ -92,7 +92,16 @@ const detectCellDegreeContradiction = ( message: `cell-degree contradiction at ${formatMasyuCellKeyLabel(key)}: ${lineCount} line segments meet there`, } } - if (lineCount === 1 && unknownCount === 0) { + const addableUnknownCount = + lineCount === 1 + ? Object.values(getMasyuIncidentDirectionalLines(puzzle, key)).filter( + (item) => + item !== null && + item.mark === 'unknown' && + canLineStillBeLine(puzzle, item.lineKey), + ).length + : unknownCount + if (lineCount === 1 && addableUnknownCount === 0) { return { kind: 'cell-degree', message: `cell-degree contradiction at ${formatMasyuCellKeyLabel(key)}: a closed cell has only one line segment`, From 404968f39dbb93a59eb5055849789abec5488265 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sat, 30 May 2026 21:25:28 +0800 Subject: [PATCH 13/22] chore: update diagnosis code and instruction --- .gitignore | 5 ++ docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md | 93 ++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md diff --git a/.gitignore b/.gitignore index bee95a6..eaeb8f7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ test-results dataset/private/ *.local +# Local-only Masyu assist diagnostics +AUX_Puzzlink_Assistance.js +scripts/compare-masyu-assist.ts +src/domain/diagnostics/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md b/docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md new file mode 100644 index 0000000..45b40ed --- /dev/null +++ b/docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md @@ -0,0 +1,93 @@ +# Masyu Assist Diagnostic Workflow + +This document records the local-only workflow for comparing PuzzleKit's Masyu +solver against the local `Puzzlink_Assistance.js` helper script. The diagnostic +tool is intentionally not part of the product code path and should not be +committed. + +## Local Files + +The workflow expects these local files: + +- `Puzzlink_Assistance.js`: gitignored local copy of the assist script. For + diagnostic runs, this can be the instrumented version copied from + `AUX_Puzzlink_Assistance.js` or regenerated by a local agent. +- `scripts/compare-masyu-assist.ts`: gitignored local CLI harness. +- `src/domain/diagnostics/*`: gitignored local comparison and test helpers. +- `benchmark-results/*.json`: gitignored output reports. + +`AUX_Puzzlink_Assistance.js` is deprecated as a standalone file. Keep any +instrumented assist logic in the ignored `Puzzlink_Assistance.js` or in the +local harness. + +## Run Command + +Use this command shape: + +```bash +pnpm exec tsx scripts/compare-masyu-assist.ts \ + --url "" \ + --out benchmark-results/masyu-assist-gap.json +``` + +The harness opens the puzzle in Playwright, injects/uses the local assist +script, runs PuzzleKit's Masyu solver, then writes a JSON comparison report. + +## Report Fields + +Focus on these fields first: + +- `summary.assistOnly`: decisions the assist script made and PuzzleKit did not. +- `summary.puzzlekitOnly`: decisions PuzzleKit made and the assist script did not. +- `summary.conflicts`: opposite line/blank decisions; this should stay `0`. +- `firstAssistLead`: the earliest assist-only decision. +- `assistLeadByRule`: assist-only decisions grouped by inferred assist strategy. + +The intended development loop is: + +1. Inspect `firstAssistLead`. +2. Implement or tune one small explainable rule for that earliest gap. +3. Add a focused fixture test. +4. Rerun the diagnostic report. +5. Repeat from the new `firstAssistLead`. + +This loop is better than trying to port every final `assistOnly` decision at +once, because one early deterministic rule can remove many later gaps through +normal rule cascading. + +## 49x39 Baseline + +Reference puzzle: + +```text +https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090 +``` + +Current diagnostic baseline: + +- PuzzleKit: `253` steps, `548 line`, `708 blank`, `2478 unknown`. +- Assist: `2255` traced deductions, `740 line`, `973 blank`, `2021 unknown`. +- `assistOnly=584`. +- `puzzlekitOnly=127`. +- `conflicts=0`. +- First assist-only gap: `18,32-18,33 -> blank`. +- First assist strategy: `Masyu: white axis has no adjacent turn`. + +Top assist-only strategy groups observed in this run: + +- `SingleLoopInCell: degree and required-cell completion`: `202`. +- `Masyu: white straight axis forced`: `139`. +- `Masyu: black exit rejected so opposite forced`: `84`. +- `Masyu: black pearl straight extension`: `41`. +- `Masyu: white axis has no adjacent turn`: `35`. + +## Commit Hygiene + +Only documentation and ignore-rule updates should be committed for this +workflow. Do not commit: + +- `Puzzlink_Assistance.js` +- `AUX_Puzzlink_Assistance.js` +- `scripts/compare-masyu-assist.ts` +- `src/domain/diagnostics/` +- `benchmark-results/` From 269dc661bbba17a3ba2a46a3ad9f76ab50ea1054 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sat, 30 May 2026 22:43:07 +0800 Subject: [PATCH 14/22] feat: add tests for White Pearl Rule to validate behavior with adjacent pearls and line availability --- src/domain/rules/masyu/rules.test.ts | 60 ++++++++++++++++++++++++++ src/domain/rules/masyu/rules/pearls.ts | 9 +++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index 368eab5..e3db1d0 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -225,6 +225,66 @@ describe('Masyu pearl rules', () => { }) }) + it('White Pearl Rule rejects a horizontal pass-through when both side turns break adjacent pearls', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 5, 3, 'black') + addPearl(puzzle, 4, 4, 'white') + addPearl(puzzle, 4, 5, 'white') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([4, 3], [4, 4])]: 'blank', + [lineKey([4, 4], [4, 5])]: 'blank', + [lineKey([3, 4], [4, 4])]: 'line', + [lineKey([4, 4], [5, 4])]: 'line', + }) + expect(result?.affectedCells).toEqual([cellKey(4, 4)]) + }) + + it('White Pearl Rule treats an adjacent white pearl as a blocked turn side', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 3], [3, 3])]: 'blank', + [lineKey([3, 3], [4, 3])]: 'blank', + [lineKey([3, 3], [3, 4])]: 'line', + [lineKey([3, 2], [3, 3])]: 'line', + }) + }) + + it('White Pearl Rule keeps an axis available when a nearby black pearl still has candidates', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 2, 'white') + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 2], [3, 2])]: 'blank', + [lineKey([3, 2], [3, 3])]: 'line', + [lineKey([3, 1], [3, 2])]: 'line', + }) + }) + + it('White Pearl Rule keeps a turn side available when only one target is black', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + it('White Pearl Rule continues a known line straight and blanks turn candidates', () => { const puzzle = createMasyuPuzzle(5, 5) addPearl(puzzle, 2, 2, 'white') diff --git a/src/domain/rules/masyu/rules/pearls.ts b/src/domain/rules/masyu/rules/pearls.ts index 8f0de10..8bf6376 100644 --- a/src/domain/rules/masyu/rules/pearls.ts +++ b/src/domain/rules/masyu/rules/pearls.ts @@ -7,6 +7,7 @@ import { import { getMasyuBlackPearlKeys, getMasyuWhitePearlKeys, + isMasyuPearl, } from './pearlSelectors' import { MASYU_DIRECTIONS, @@ -59,9 +60,15 @@ const canWhiteAxisSideTurn = ( if (!isMasyuLineAvailable(first) || !first || second?.mark === 'line') { return false } + if (isMasyuPearl(puzzle, first.neighborKey, 'white')) { + return false + } return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some( (candidate) => { - if (!isMasyuLineAvailable(candidate)) { + if ( + !isMasyuLineAvailable(candidate) || + isMasyuPearl(puzzle, candidate.neighborKey, 'black') + ) { return false } const decisions = new Map() From d33022ceb95c99aa534290ca02090233efd30001 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 31 May 2026 01:15:10 +0800 Subject: [PATCH 15/22] feat: introduce White Corridor rule to enforce line paths through adjacent white pearls in L-shaped corridors --- docs/techniques/masyu.md | 19 ++ src/domain/rules/masyu/rules.test.ts | 73 +++++++ src/domain/rules/masyu/rules.ts | 2 + src/domain/rules/masyu/rules/patterns.ts | 198 +++++++++++++++++- .../rules/masyu/rules/strongInference.ts | 4 +- 5 files changed, 293 insertions(+), 3 deletions(-) diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index eb4b1b4..2714fda 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -146,6 +146,25 @@ W W W each pearl is forced vertically The same logic applies to vertical runs, forcing horizontal passage through the white pearls. +### White Corridor + +If an existing L-shaped corridor has two white pearls tucked just inside the +corner, and there are still multiple known line components, the corridor cannot +locally close or turn away from both pearls. The two white pearls are forced to +go straight through the corner-facing axes. + +This rule determines four confirmed line segments: the first white pearl passes +parallel to one corridor arm, and the second white pearl passes parallel to the +other arm. + +```text +--. + | + W forces the two nearby whites straight through +. W + | +``` + ### Double Black Squeeze If a non-pearl cell sits between two black pearls, that middle cell cannot keep diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index e3db1d0..0a13d8c 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -26,6 +26,7 @@ import { createBlackFacingConsecutiveWhitesRule, createConsecutiveWhitePearlsStraightRule, createDoubleBlackSqueezeRule, + createWhiteCorridorRule, } from './rules/patterns' import { createBlackPearlRule, createWhitePearlRule } from './rules/pearls' import { deterministicMasyuRules } from './rules' @@ -535,6 +536,7 @@ describe('Masyu pearl rules', () => { 'Black Facing Consecutive Whites', 'Black Diagonal White Pinch', 'Consecutive White Pearls Straight', + 'White Corridor', 'Double Black Squeeze', 'Masyu Tile Color Propagation', 'Masyu Color-Pearl Propagation', @@ -2220,6 +2222,77 @@ describe('Masyu pattern rules', () => { }) }) + it('White Corridor forces the two inner white pearls through the L-shaped corridor', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 5, 4, 'white') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') + markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const result = createWhiteCorridorRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([4, 4], [4, 5])]: 'line', + [lineKey([4, 5], [4, 6])]: 'line', + [lineKey([4, 4], [5, 4])]: 'line', + [lineKey([5, 4], [6, 4])]: 'line', + }) + expect(result?.affectedCells).toEqual([ + cellKey(3, 3), + cellKey(4, 5), + cellKey(5, 4), + ]) + }) + + it('White Corridor works after rotation', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 5, 2, 'white') + addPearl(puzzle, 4, 1, 'white') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + markLine(puzzle, lineKey([3, 3], [3, 2]), 'line') + markLine(puzzle, lineKey([3, 2], [3, 1]), 'line') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const result = createWhiteCorridorRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([4, 2], [5, 2])]: 'line', + [lineKey([5, 2], [6, 2])]: 'line', + [lineKey([4, 1], [4, 2])]: 'line', + [lineKey([4, 0], [4, 1])]: 'line', + }) + }) + + it('White Corridor does not fire before there are multiple known line components', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 5, 4, 'white') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') + markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + + expect(createWhiteCorridorRule().apply(puzzle)).toBeNull() + }) + + it('White Corridor does not overwrite a blank forced line', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 5, 4, 'white') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') + markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([4, 4], [4, 5]), 'blank') + + expect(createWhiteCorridorRule().apply(puzzle)).toBeNull() + }) + it('Double Black Squeeze blanks the opposite vertical exit between horizontal black pearls', () => { const puzzle = createMasyuPuzzle(5, 6) addPearl(puzzle, 2, 2, 'black') diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index a685ffe..ac7a7ec 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -20,6 +20,7 @@ import { createBlackFacingConsecutiveWhitesRule, createConsecutiveWhitePearlsStraightRule, createDoubleBlackSqueezeRule, + createWhiteCorridorRule, } from './rules/patterns' import { createBlackPearlRule, createWhitePearlRule } from './rules/pearls' import { createWhitePearlStrongInferenceRule } from './rules/whitePearlStrongInference' @@ -30,6 +31,7 @@ export const deterministicMasyuRules: Rule[] = [ createBlackFacingConsecutiveWhitesRule(), createBlackDiagonalWhitePinchRule(), createConsecutiveWhitePearlsStraightRule(), + createWhiteCorridorRule(), createDoubleBlackSqueezeRule(), createMasyuTileColorPropagationRule(), createMasyuColorPearlPropagationRule(), diff --git a/src/domain/rules/masyu/rules/patterns.ts b/src/domain/rules/masyu/rules/patterns.ts index 944a501..a55e43f 100644 --- a/src/domain/rules/masyu/rules/patterns.ts +++ b/src/domain/rules/masyu/rules/patterns.ts @@ -1,4 +1,4 @@ -import { cellKey, parseCellKey } from '../../../ir/keys' +import { cellKey, lineKey, parseCellKey } from '../../../ir/keys' import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' import { @@ -6,8 +6,10 @@ import { type MasyuLineDecisionCollector, } from './decisionCollector' import { getMasyuBlackPearlKeys, isMasyuPearl } from './pearlSelectors' +import { getMasyuKnownLineComponents } from './lineGraph' import { MASYU_DIRECTIONS, + canMasyuLineBeAddedWithoutDegreeOverflow, formatMasyuCellKeyLabel, formatMasyuLineLabel, getMasyuDirectionOffset, @@ -184,6 +186,200 @@ export const createBlackDiagonalWhitePinchRule = (): Rule => ({ }, }) +type MasyuLocalBasis = { + right: [rowDelta: number, colDelta: number] + down: [rowDelta: number, colDelta: number] +} + +const whiteCorridorBases: MasyuLocalBasis[] = [ + { right: [0, 1], down: [1, 0] }, + { right: [1, 0], down: [0, -1] }, + { right: [0, -1], down: [-1, 0] }, + { right: [-1, 0], down: [0, 1] }, +] + +const offsetLocalPoint = ( + row: number, + col: number, + x: number, + y: number, + basis: MasyuLocalBasis, +): [row: number, col: number] => [ + row + basis.right[0] * x + basis.down[0] * y, + col + basis.right[1] * x + basis.down[1] * y, +] + +const offsetLocalCellKey = ( + puzzle: PuzzleIR, + originKey: string, + x: number, + y: number, + basis: MasyuLocalBasis, +): string | null => { + const [row, col] = parseCellKey(originKey) + const [targetRow, targetCol] = offsetLocalPoint(row, col, x, y, basis) + if (!Number.isInteger(targetRow) || !Number.isInteger(targetCol)) { + return null + } + return isInBounds(puzzle, targetRow, targetCol) + ? cellKey(targetRow, targetCol) + : null +} + +const offsetLocalLineKey = ( + puzzle: PuzzleIR, + originKey: string, + x: number, + y: number, + basis: MasyuLocalBasis, +): string | null => { + const hasHalfX = !Number.isInteger(x) + const hasHalfY = !Number.isInteger(y) + if (hasHalfX === hasHalfY) { + return null + } + + const first = hasHalfX + ? offsetLocalCellKey(puzzle, originKey, x - 0.5, y, basis) + : offsetLocalCellKey(puzzle, originKey, x, y - 0.5, basis) + const second = hasHalfX + ? offsetLocalCellKey(puzzle, originKey, x + 0.5, y, basis) + : offsetLocalCellKey(puzzle, originKey, x, y + 0.5, basis) + if (!first || !second) { + return null + } + + return lineKey(parseCellKey(first), parseCellKey(second)) +} + +const canAddMasyuLineBatch = ( + puzzle: PuzzleIR, + lineKeys: string[], +): boolean => { + const decisions = new Map() + for (const key of lineKeys) { + const current = puzzle.lines[key]?.mark ?? 'unknown' + if (current === 'blank') { + return false + } + if ( + current === 'unknown' && + !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, key, decisions) + ) { + return false + } + decisions.set(key, 'line') + } + return true +} + +export const createWhiteCorridorRule = (): Rule => ({ + id: 'masyu-white-corridor', + name: 'White Corridor', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + if (getMasyuKnownLineComponents(puzzle).length <= 1) { + return null + } + + const decisions = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + const affectedCells = new Set() + let firstAnchor: string | null = null + let firstWhite: string | null = null + let firstLine: string | null = null + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const anchorKey = cellKey(row, col) + for (const basis of whiteCorridorBases) { + const corridorLines = [ + offsetLocalLineKey(puzzle, anchorKey, 0, 0.5, basis), + offsetLocalLineKey(puzzle, anchorKey, 0, 1.5, basis), + offsetLocalLineKey(puzzle, anchorKey, 0.5, 0, basis), + offsetLocalLineKey(puzzle, anchorKey, 1.5, 0, basis), + ] + if ( + corridorLines.some( + (key) => !key || puzzle.lines[key]?.mark !== 'line', + ) + ) { + continue + } + + const firstWhiteKey = offsetLocalCellKey( + puzzle, + anchorKey, + 2, + 1, + basis, + ) + const secondWhiteKey = offsetLocalCellKey( + puzzle, + anchorKey, + 1, + 2, + basis, + ) + if ( + !firstWhiteKey || + !secondWhiteKey || + !isMasyuPearl(puzzle, firstWhiteKey, 'white') || + !isMasyuPearl(puzzle, secondWhiteKey, 'white') + ) { + continue + } + + const forcedLines = [ + offsetLocalLineKey(puzzle, anchorKey, 1.5, 1, basis), + offsetLocalLineKey(puzzle, anchorKey, 2.5, 1, basis), + offsetLocalLineKey(puzzle, anchorKey, 1, 1.5, basis), + offsetLocalLineKey(puzzle, anchorKey, 1, 2.5, basis), + ] + if ( + forcedLines.some((key): key is null => key === null) || + !canAddMasyuLineBatch(puzzle, forcedLines as string[]) + ) { + continue + } + + const beforeSize = decisions.decisions.size + for (const key of forcedLines as string[]) { + decisions.add(key, 'line') + } + if (decisions.decisions.size === beforeSize) { + continue + } + + affectedCells.add(anchorKey) + affectedCells.add(firstWhiteKey) + affectedCells.add(secondWhiteKey) + if (firstAnchor === null) { + firstAnchor = anchorKey + firstWhite = firstWhiteKey + firstLine = decisions.firstLine() + } + } + } + } + + if (!decisions.hasChanges()) { + return null + } + + const diffs = decisions.diffs() + return { + message: + firstAnchor && firstWhite && firstLine + ? `An L-shaped corridor at ${formatMasyuCellKeyLabel(firstAnchor)} forces nearby white pearl ${formatMasyuCellKeyLabel(firstWhite)} to go straight, so ${formatMasyuLineLabel(firstLine)} is a line${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'White corridor pattern applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) + const collectWhitePearlRun = ( puzzle: PuzzleIR, startRow: number, diff --git a/src/domain/rules/masyu/rules/strongInference.ts b/src/domain/rules/masyu/rules/strongInference.ts index c77fd05..5b0a1f2 100644 --- a/src/domain/rules/masyu/rules/strongInference.ts +++ b/src/domain/rules/masyu/rules/strongInference.ts @@ -21,8 +21,8 @@ export type MasyuStrongBranch = { export type MasyuLineAssumption = [lineKey: string, mark: LineMark] export const STRONG_MAX_CANDIDATES = 300 -export const STRONG_MAX_TRIAL_STEPS = 80 -export const STRONG_MAX_MS = 10000 +export const STRONG_MAX_TRIAL_STEPS = 100 +export const STRONG_MAX_MS = 15000 export const deriveMasyuStrongProbeBudgets = ( maxTrialSteps: number, From 0b76dd22542063d12335276ab7d47343d5396f28 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 31 May 2026 02:21:13 +0800 Subject: [PATCH 16/22] feat: add Masyu Empty Cell Premature Loop rule to prevent premature closures in empty cells, docs clean --- .gitignore | 1 + docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md | 93 ------- docs/MASYU_RULE_ROADMAP.md | 256 ------------------ docs/Masyu_Rule_Gap_And_Integration_Plan.md | 209 --------------- docs/Puzzlink_Assistance_Masyu_Analysis.md | 208 --------------- docs/techniques/masyu.md | 5 + src/domain/rules/masyu/rules.test.ts | 60 ++++- src/domain/rules/masyu/rules.ts | 6 +- src/domain/rules/masyu/rules/loop.ts | 280 ++++++++++++++++++++ 9 files changed, 350 insertions(+), 768 deletions(-) delete mode 100644 docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md delete mode 100644 docs/MASYU_RULE_ROADMAP.md delete mode 100644 docs/Masyu_Rule_Gap_And_Integration_Plan.md delete mode 100644 docs/Puzzlink_Assistance_Masyu_Analysis.md diff --git a/.gitignore b/.gitignore index eaeb8f7..dc85509 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ src/domain/diagnostics/ *.sln *.sw? Puzzlink_Assistance.js +docs/legacy/* \ No newline at end of file diff --git a/docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md b/docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md deleted file mode 100644 index 45b40ed..0000000 --- a/docs/MASYU_ASSIST_DIAGNOSTIC_WORKFLOW.md +++ /dev/null @@ -1,93 +0,0 @@ -# Masyu Assist Diagnostic Workflow - -This document records the local-only workflow for comparing PuzzleKit's Masyu -solver against the local `Puzzlink_Assistance.js` helper script. The diagnostic -tool is intentionally not part of the product code path and should not be -committed. - -## Local Files - -The workflow expects these local files: - -- `Puzzlink_Assistance.js`: gitignored local copy of the assist script. For - diagnostic runs, this can be the instrumented version copied from - `AUX_Puzzlink_Assistance.js` or regenerated by a local agent. -- `scripts/compare-masyu-assist.ts`: gitignored local CLI harness. -- `src/domain/diagnostics/*`: gitignored local comparison and test helpers. -- `benchmark-results/*.json`: gitignored output reports. - -`AUX_Puzzlink_Assistance.js` is deprecated as a standalone file. Keep any -instrumented assist logic in the ignored `Puzzlink_Assistance.js` or in the -local harness. - -## Run Command - -Use this command shape: - -```bash -pnpm exec tsx scripts/compare-masyu-assist.ts \ - --url "" \ - --out benchmark-results/masyu-assist-gap.json -``` - -The harness opens the puzzle in Playwright, injects/uses the local assist -script, runs PuzzleKit's Masyu solver, then writes a JSON comparison report. - -## Report Fields - -Focus on these fields first: - -- `summary.assistOnly`: decisions the assist script made and PuzzleKit did not. -- `summary.puzzlekitOnly`: decisions PuzzleKit made and the assist script did not. -- `summary.conflicts`: opposite line/blank decisions; this should stay `0`. -- `firstAssistLead`: the earliest assist-only decision. -- `assistLeadByRule`: assist-only decisions grouped by inferred assist strategy. - -The intended development loop is: - -1. Inspect `firstAssistLead`. -2. Implement or tune one small explainable rule for that earliest gap. -3. Add a focused fixture test. -4. Rerun the diagnostic report. -5. Repeat from the new `firstAssistLead`. - -This loop is better than trying to port every final `assistOnly` decision at -once, because one early deterministic rule can remove many later gaps through -normal rule cascading. - -## 49x39 Baseline - -Reference puzzle: - -```text -https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090 -``` - -Current diagnostic baseline: - -- PuzzleKit: `253` steps, `548 line`, `708 blank`, `2478 unknown`. -- Assist: `2255` traced deductions, `740 line`, `973 blank`, `2021 unknown`. -- `assistOnly=584`. -- `puzzlekitOnly=127`. -- `conflicts=0`. -- First assist-only gap: `18,32-18,33 -> blank`. -- First assist strategy: `Masyu: white axis has no adjacent turn`. - -Top assist-only strategy groups observed in this run: - -- `SingleLoopInCell: degree and required-cell completion`: `202`. -- `Masyu: white straight axis forced`: `139`. -- `Masyu: black exit rejected so opposite forced`: `84`. -- `Masyu: black pearl straight extension`: `41`. -- `Masyu: white axis has no adjacent turn`: `35`. - -## Commit Hygiene - -Only documentation and ignore-rule updates should be committed for this -workflow. Do not commit: - -- `Puzzlink_Assistance.js` -- `AUX_Puzzlink_Assistance.js` -- `scripts/compare-masyu-assist.ts` -- `src/domain/diagnostics/` -- `benchmark-results/` diff --git a/docs/MASYU_RULE_ROADMAP.md b/docs/MASYU_RULE_ROADMAP.md deleted file mode 100644 index 262deab..0000000 --- a/docs/MASYU_RULE_ROADMAP.md +++ /dev/null @@ -1,256 +0,0 @@ -# Masyu Rule Roadmap - -This is the implementation roadmap for future Masyu rule work. Read -`docs/MASYU_AGENT_BRIEF.md` first for current state and file locations, then use -this document when planning refactors or new deductions. - -## Direction - -Masyu already has a working rule stack: pearl-local deductions, local patterns, -completion, premature-loop prevention, candidate graph reasoning, black-pearl -candidate pruning, bounded strong inference, and vertex-centered tile coloring. - -The next goal is not to add another large rule module. The next goal is to make -the current rule strategy smaller and easier to extend: - -- consolidate repeated helpers; -- separate rule semantics by reasoning idea; -- give white pearls the same candidate-model treatment black pearls already - have; -- generalize strong inference so it can test named white-pearl and color - candidates, not only black-pearl exits; -- make tile color useful through small pearl-local parity rules; -- keep every rule replay-safe and explainable. - -## Core Model - -- `PuzzleIR.cells` stores pearl clues. -- `PuzzleIR.lines` stores Masyu loop decisions between orthogonally adjacent - cell centers. -- `PuzzleIR.tiles` stores vertex-centered inside/outside color information. -- `PuzzleIR.edges` is Slitherlink state and must not be used for Masyu loop - deductions. - -Rule output should stay explicit: - -- line decisions use `LineDiff`; -- tile color decisions use `TileDiff`; -- rules inspect `PuzzleIR` without mutating it; -- if a candidate update conflicts with an existing decision, skip or reject the - inference rather than overwriting state. - -## Current Pain Points - -The Masyu code is capable, but too much reasoning is repeated locally: - -- pearl iteration helpers and clue filters exist in several files; -- "remember this compatible decision" helpers are repeated; -- degree and possibility checks are split between direct rules, completion, - lookahead, and trial inference; -- black-pearl feasibility exists in `lookahead.ts`, while trial inference has a - separate feasibility model; -- union-find and low-link graph logic are implemented independently for loop - closure, bridge detection, tile parity, tile connectivity, and trial - contradiction checks. - -This makes future rule work feel heavier than it should. A new rule author has -to rediscover which helper has the intended meaning of "available", "blocked", -"possible", and "contradiction". - -## Refactor Priorities - -### 1. Shared Decision Collection - -Create a small collector used by line and tile rules: - -- add compatible decisions only; -- optionally guard `line` decisions against degree overflow; -- track the first clear example for the explanation message; -- build replay-safe diffs in stable order; -- expose affected line/tile keys. - -This should replace file-local helpers that only differ in naming. - -### 2. Pearl Selectors - -Centralize pearl lookup: - -- iterate all pearls; -- iterate white pearls; -- iterate black pearls; -- read pearl color safely; -- format common pearl labels. - -This is tiny, but it removes noise from every rule file. - -### 3. Pearl Candidate Model - -Build one shared candidate model for local pearl feasibility. - -Black candidates: - -- choose one vertical exit and one horizontal exit; -- require both exit lines; -- require the one-step straight extension after each exit; -- exclude the two non-selected incident exits. - -White candidates: - -- choose one straight axis; -- require both axis exits; -- exclude perpendicular exits; -- track whether each adjacent side can still provide the required immediate - turn. - -Use this model for: - -- existing black-pearl candidate pruning; -- future white-pearl candidate pruning; -- trial feasibility checks; -- common consequence extraction across all feasible candidates. - -### 4. Structured Assumption Inference - -Generalize the current black-pearl strong inference into a small assumption -runner. Candidate sources should be typed and explainable rather than arbitrary -unknown lines. - -Good candidate sources: - -- a black-pearl exit; -- a single white pearl axis; -- a named local white-pearl pattern, such as adjacent white pearls; -- a tile-color component with two possible colors. - -Supported outcomes: - -- contradiction: one branch fails, so its assumption is rejected; -- forced alternative: one branch fails and the opposite branch gives a direct - line or tile decision; -- common conclusion: every surviving branch produces the same unknown line or - tile decision. - -Default strong inference should stay bounded by candidate count, trial steps, -and wall-clock time. Avoid unbounded tree search in the normal rule stack. - -### 5. Line Graph Helpers - -Centralize graph operations over `PuzzleIR.lines`: - -- cell-center degree counts; -- touched cells for a set of line keys; -- connected components of known lines; -- premature-loop checks; -- candidate graph from all non-blank lines; -- required sources from pearls and existing line components; -- bridge and articulation analysis. - -`Masyu Candidate Bridge Line` and `Prevent Premature Loop` should eventually use -the same graph vocabulary. - -### 6. Tile Parity Graph - -Centralize tile inside/outside parity: - -- line means adjacent tiles have opposite colors; -- blank means adjacent tiles have the same color; -- boundary tiles are outside/yellow; -- existing tile fills are anchors; -- conflicting anchors or parity cycles can be reported to trial inference. - -Reuse this helper from color propagation and bounded trial contradiction -detection. - -## Rule Family Cleanup - -After the helpers exist, split or rename current rules so each one maps to one -deduction idea. - -Suggested pearl rule boundaries: - -- White straight axis: a known white exit forces the opposite exit and blanks - perpendicular exits. -- White adjacent turn: if one side already continues straight beyond the - adjacent cell, the opposite side cannot also continue straight. -- White axis feasibility: if one white axis cannot satisfy the adjacent-turn - condition on either side, eliminate that axis. -- Black turn pair: a black pearl must use one vertical and one horizontal exit. -- Black straight extension: a known black exit forces the next line straight - beyond the neighboring cell. -- Pearl completion: only handle degree completion and "only legal pair remains" - cases. - -Pattern rules should stay only when they are clearer or cheaper than candidate -reasoning. Do not grow a large library of copied coordinate patterns unless the -deduction has a short explanation and focused tests. - -## Missing Rule Work - -Highest leverage additions: - -1. White pearl axis candidate pruning. -2. White pearl strong inference using the structured assumption runner. -3. Named white-pearl pattern inference, starting with adjacent white pairs. -4. Black pearl local tile-color implications. -5. White pearl candidate color implications when all feasible axes imply the - same tile relation. -6. Candidate graph articulation reasoning beyond single bridge edges. -7. A Masyu `NoChecker`-style tile parity inference only if it can be stated in - vertex-centered tile terms and returned as explicit diffs. - -Avoid broad monolithic "assistance script" ports. Convert outside research into -small line, pearl-candidate, graph, or tile-parity rules. - -## Search Boundary - -PuzzleKit should not become a default backtracking solver. The acceptable middle -ground is bounded, typed, explainable search: - -- do not guess arbitrary unknown lines in the default rule stack; -- do generate small candidate sets from a named structure; -- do explain branch results as contradiction, forced alternative, or common - conclusion; -- do keep budgets low enough that replay remains responsive and deterministic. - -If a puzzle still stalls, prefer adding a new named candidate source or -deterministic technique before adding deeper generic search. - -## Test Plan - -Focused rule tests: - -```bash -pnpm test:run src/domain/rules/masyu/rules.test.ts -``` - -Replay safety: - -```bash -pnpm test:run src/domain/rules/engine.test.ts src/features/solver/solverStore.test.ts -``` - -Full confidence: - -```bash -pnpm build -``` - -New tests should be fixture-sized and grouped by behavior: - -- candidate model: black turn candidates, white straight-axis candidates, - blocked extensions, blocked adjacent turns; -- decision collector: duplicate compatible decisions, conflicts, degree guards; -- tile parity graph: boundary anchors, line/blank parity, conflicting anchors, - pearl-local parity; -- line graph helpers: premature closures, bridge lines, articulation cells, - required-source connectivity; -- trial inference: hard contradictions should match deterministic feasibility - semantics; -- structured assumptions: black exits, white axes, adjacent white-pair patterns, - and common-conclusion branches. - -## Working Rule - -If this roadmap disagrees with current code, trust current code and update the -roadmap. Keep this file current and compact; use git history for historical -detail. diff --git a/docs/Masyu_Rule_Gap_And_Integration_Plan.md b/docs/Masyu_Rule_Gap_And_Integration_Plan.md deleted file mode 100644 index 749b7e0..0000000 --- a/docs/Masyu_Rule_Gap_And_Integration_Plan.md +++ /dev/null @@ -1,209 +0,0 @@ -# Masyu 规则差距与融合路线 - -这份文档把 `Puzzlink_Assistance.js` 的 Masyu 思路和当前 PuzzleKit Masyu 规则栈放在一起比较。目标不是把作者脚本原样搬进来,而是判断哪些技巧适合拆成 replay-safe、step-wise、可解释的规则。 - -## 当前两套实现的定位差异 - -作者脚本的定位是“在 puzz.link 页面上快速辅助推导”。它直接写 puzz.link board 状态,规则之间没有清晰边界,只要能推出线、叉或顶点颜色,就立刻写入并继续下一轮。 - -PuzzleKit 的定位是“纯前端、分步、可解释的推理工具”。当前 Masyu 规则通过 `RuleApplication` 返回 diff,每一步需要说清楚为什么改了哪些线或 tile。这让规则更容易回放、测试和解释,但也意味着不能简单复制作者那种大而混杂的 assist 循环。 - -这个差异决定了推荐路线:**吸收作者的推理思想,不吸收作者的 monolithic 结构**。 - -## 当前 PuzzleKit 的优势 - -PuzzleKit 已经有几块作者脚本没有的能力: - -- **可解释回放**:每条规则返回明确的 `LineDiff` / `TileDiff`,有 message、affected cells/lines/tiles。 -- **结构化候选模型**:黑珠、白珠都有候选 pruning 的基础设施,不只是硬编码 pattern。 -- **bounded strong inference**:已有黑珠和白珠强推断,能用有限 trial propagation 排除导致矛盾的局部假设。 -- **相邻白珠 lookahead**:`Adjacent White Pearls LookAhead` 把相邻白珠的两类穿法作为结构化候选,而不是只写坐标 pattern。 -- **tile parity 基础设施**:`PuzzleIR.tiles` 已经把 Masyu 的 inside/outside 思想落成 vertex-centered tile,可以承接作者的 in/out 推理。 -- **line graph 基础设施**:已有 premature loop、known line components、candidate graph、bridge line 等基础工具。 - -因此,后续不需要把精力放在“补一个通用搜索器”上。更高收益的是补齐作者脚本里那些便宜、确定、可解释的 topology 和 pattern 结论。 - -## 当前缺口 - -### 1. SingleLoopInCell 的 loop-graph 细节还没有完全等价物 - -PuzzleKit 已有 `Prevent Premature Loop`,也有 `Candidate Bridge Line`,但作者 `SingleLoopInCell()` 里还有不少更细的单环推理: - -- 必须经过格只剩两条候选边时的强制; -- 当前路径分量会在某格自连时的排除; -- 某个必须经过格是连接两个候选路径组的唯一入口时的强制; -- 使用 `lineaux` 临时可能线判断候选路径组; -- 长路径分量与局部空格之间的“包围/反弹”推理。 - -其中有些可能已经被 `Cell Exit Completion` 或候选 pruning 间接覆盖,但还没有形成一套清晰的“单环通用图规则”。 - -### 2. 连通性框架拆得更可解释,但能力不如作者集中 - -当前 PuzzleKit 把连通性拆成两类: - -- `Candidate Bridge Line`:在候选 line graph 中找桥边,强制连接 required sources。 -- `Tile Connectivity Cut Coloring`:在 tile color graph 中找 cut / unreachable 区域,推出 inside/outside 颜色。 - -作者的 `CellConnected()` 则是一个更泛化的 low-link 框架,可以根据配置同时做 cut vertex、bridge、不可达、强制连接。PuzzleKit 的拆分更符合解释性,但目前少了几个能力: - -- required-source articulation:某个候选 cell/component 是连接多个必达源的割点; -- candidate cut vertex 周围的线决策:不仅知道“这里必须通过”,还要转换成哪些边必须线/叉; -- bridge 与 pearl-local 约束结合:桥边不是孤立判断,而是会触发黑珠/白珠的出口选择。 - -### 3. in/out 颜色传播还可以更主动 - -作者把顶点 in/out 颜色和线/叉判断放得很近: - -- 同色顶点之间是叉; -- 异色顶点之间是线; -- 白珠对角顶点异色; -- 部分黑白组合直接推出顶点颜色关系; -- 颜色一旦传播,又马上反推线/叉。 - -PuzzleKit 已经有 `Tile Color Propagation`、`Color-Pearl Propagation`、`Color-Line Propagation`,但仍偏“基础 parity”。缺少的主要是: - -- 更多 pearl-local tile color implication; -- linegraph 或 candidate graph 触发的 tile color implication; -- 把作者某些 cross qsub 结论稳定翻译成 `PuzzleIR.tiles` 的规则。 - -### 4. 部分高收益 pattern 尚未覆盖 - -当前已有 `Black Facing Consecutive Whites`、`Black Diagonal White Pinch`、`Consecutive White Pearls Straight`、`Double Black Squeeze`、相邻白珠 lookahead 等规则。 - -作者脚本中仍有一些值得补的 pattern: - -- 白珠某轴两侧都无法满足相邻转弯时,强制走另一轴; -- 双黑夹迫的更宽条件,不只是一条垂直边已 blank; -- 黑珠出口被第二段、邻近黑珠、边界或白珠形态排除时的即时结论; -- 长走廊 linegraph + 两个白珠 / 黑白组合导致的强制延伸; -- 已知路径分量触发的 “connectivity and black” / “connectivity and white”。 - -这些最好不要一次性堆成一个 `patterns.ts` 巨块,而是按“解释能否一句话说清楚”拆成小规则。 - -## 候选规则清单 - -| 作者技巧 | 当前状态 | 建议 | -| --- | --- | --- | -| 白珠已有一侧线后强制直行、垂直打叉 | 已覆盖主干 | 保持在白珠基础规则中 | -| 白珠一侧已连续直行两段,另一侧不能继续直行 | 部分覆盖 | 用白珠候选模型校准后保留/补测试 | -| 白珠某轴两侧都不能完成相邻转弯,强制另一轴 | 部分覆盖 | 优先补成 `White Pearl Axis Feasibility` 类规则 | -| 黑珠已有出口后强制第二段直行 | 已覆盖主干 | 保持在黑珠基础/候选规则中 | -| 黑珠某出口因第二段不可走或邻近珍珠形态不可用而排除 | 部分覆盖 | 优先进入黑珠候选 pruning,而不是硬编码多个 if | -| 双黑夹迫 | 已有窄版 | 扩展条件,保留清晰 fixture | -| 黑珠面对两个连续白珠 | 已覆盖 | 检查作者远端/变体是否还缺 | -| 黑珠被两个斜向白珠 pinch | 已覆盖 | 保持现状,补充必要测试即可 | -| 连续三白珠强制垂直于 run | 已覆盖 | 保持现状 | -| 相邻白珠两种穿法只剩一种 | 已有 lookahead | 当前实现比作者更结构化,继续沿用 | -| 同一路径分量提前闭环打叉 | 已覆盖主干 | 扩展到更多局部自连形态 | -| 必须连通源之间的桥边强制线 | 已覆盖桥边 | 扩展到 articulation/cut vertex | -| 顶点 in/out 同色叉、异色线 | 已覆盖基础 parity | 增加更多触发颜色的来源 | -| 白珠对角顶点异色 | 已覆盖 | 保持在 color-pearl propagation | -| 黑白组合触发顶点颜色传播 | 未充分覆盖 | 作为 tile color 小规则补充 | -| linegraph 触发的黑珠/白珠方向选择 | 未充分覆盖 | 高优先级,适合单独成规则 | -| 作者的通用深度搜索/强推断 | 作者没有 | 不作为移植目标,保留 PuzzleKit 现有强推断 | - -## 推荐实现路径 - -### 第一步:先整理成规则族,不直接移植 - -先把作者脚本中的结论分成三类: - -- 已有规则覆盖,只需要补测试或改说明; -- 当前规则部分覆盖,可以用候选模型或 graph helper 扩展; -- 真正缺失,需要新增小规则。 - -这一步的关键是避免把 `MasyuAssist()` 重新写成一个新的大函数。每条规则都应有一个简短、可复述的理由。 - -### 第二步:优先增强 line graph 层 - -建议先扩展 `lineGraph.ts` 的表达能力,而不是继续堆坐标 pattern: - -- required sources 继续由珍珠和已知线分量提供; -- candidate graph 继续由所有非 blank 线构成; -- 在 bridge edge 之外,增加 articulation / cut vertex 分析; -- 提供“某个候选 cell/component 必须被通过”的结果,再由上层规则转换成 line decisions; -- 让 `bridges.ts` 从单纯桥边规则扩展成“候选连接强制规则族”。 - -这能吸收 `CellConnected()` 的核心收益,同时保持 PuzzleKit 的解释粒度。 - -### 第三步:补齐 SingleLoopInCell 中可命名的通用单环规则 - -推荐拆成几条稳定规则: - -- **Masyu Required Cell Degree**:必须经过的珍珠格只剩两条候选边时画线。 -- **Masyu Same Component Closure**:候选边会把已知路径分量提前自连时打叉。 -- **Masyu Required Source Connector**:某格或某边是连接 required sources 的唯一通路时强制。 -- **Masyu Candidate Path Bounce**:作者 `lineaux` 对应的局部候选路径组推理;只有在解释足够清楚时再做。 - -其中前两条最稳,第三条最有性能价值,第四条需要谨慎,因为它最容易变成难解释的隐式 lookahead。 - -### 第四步:补高收益 Masyu pattern - -pattern 的原则是:先选“短、确定、容易测试”的,而不是把作者所有坐标条件照搬。 - -优先顺序建议: - -1. 白珠轴可行性:某轴两侧都无法转弯,强制另一轴。 -2. linegraph + 白珠:白珠两侧同属一个路径分量时,排除该轴,强制垂直轴。 -3. linegraph + 黑珠:黑珠某方向会连接回同一路径分量时,强制反向出口。 -4. 双黑夹迫扩展:把作者更宽的不可行条件纳入现有规则。 -5. 黑白长走廊 pattern:只挑能用一句话解释的情况。 - -这些规则可以先落在 `patterns.ts` 或拆出新的 graph-pattern 文件;如果实现时发现共用大量 linegraph 查询,再回收进 helper。 - -### 第五步:把作者 in/out 思想转写到 tile parity - -不要引入 puzz.link 的 `CRQSUB` 语义。PuzzleKit 应继续使用: - -- `PuzzleIR.tiles` 表示顶点中心区域颜色; -- `line` 表示相邻 tiles 异色; -- `blank` 表示相邻 tiles 同色; -- 边界 tiles 作为 outside/yellow anchor。 - -可新增的方向: - -- 黑珠局部 tile color implication; -- 黑白组合触发的 tile color relation; -- 已知路径方向触发的 tile parity relation; -- tile color contradiction 作为 strong inference 的可解释失败原因。 - -每个颜色结论仍输出 `TileDiff`,每个线结论仍输出 `LineDiff`。 - -### 第六步:最后再整合强推断 - -作者脚本没有黑/白强推断,也没有当前这种结构化 lookahead。因此强推断不是移植重点。 - -推荐保持当前方向: - -- deterministic rules 先吃掉作者的便宜结论; -- 黑/白 strong inference 继续作为后置 fallback; -- 后续如果要增强 strong inference,应基于 typed candidate source,例如黑珠出口、白珠轴、相邻白珠穿法、tile color component; -- 不把任意未知线作为默认猜测对象,避免求解过程退化成黑箱搜索。 - -## 测试建议 - -文档阶段不需要运行测试。后续实现时建议按规则族建立 fixture: - -- `pnpm test:run src/domain/rules/masyu/rules.test.ts` -- graph bridge/articulation:桥边、割点、多个 required sources、无 required source 时不触发; -- 白珠组合:轴不可行、相邻白珠、linegraph 同分量排除; -- 黑珠组合:出口第二段不可行、双黑夹迫扩展、linegraph 同分量触发; -- tile color:白珠对角、黑白组合、同色叉/异色线、边界 outside anchor。 - -每条新增规则至少需要三个场景: - -- 一个最小触发局面; -- 一个已有线/叉冲突或候选不足时不触发的局面; -- 一个验证 affected cells/lines/tiles 与 message 合理的回放断言。 - -## 推荐优先级 - -短期优先做 graph 层和 linegraph + 珍珠混合规则,因为这最可能解释作者在大题上的性能优势: - -1. 扩展 line graph bridge/articulation helper。 -2. 补 `connectivity and white` / `connectivity and black` 的可解释版本。 -3. 补白珠轴可行性规则。 -4. 扩展双黑夹迫和黑珠出口排除。 -5. 再补 tile color 的黑白组合 implication。 - -长期来看,PuzzleKit 不应该追求复刻作者脚本的所有 `if`。更好的路线是:用作者脚本作为 pattern 和 topology 灵感来源,把其中高收益结论转成小而清楚的规则,让求解性能和解释性一起增长。 diff --git a/docs/Puzzlink_Assistance_Masyu_Analysis.md b/docs/Puzzlink_Assistance_Masyu_Analysis.md deleted file mode 100644 index 26e7466..0000000 --- a/docs/Puzzlink_Assistance_Masyu_Analysis.md +++ /dev/null @@ -1,208 +0,0 @@ -# Puzzlink Assistance 的 Masyu 求解流程分析 - -这份文档记录对 `Puzzlink_Assistance.js` 中 Masyu 部分的阅读结论。它不是移植说明,而是帮助我们理解作者脚本为什么在一些大题上表现不错,以及哪些思想值得拆解后吸收到 PuzzleKit 的可解释规则框架里。 - -## 总体流程 - -作者脚本是一个直接嵌入 puzz.link 页面的油猴脚本。它并不维护一套独立的 puzzle IR,而是直接读取和修改 puzz.link 的 `ui.puzzle.board`: - -- `assist()` 初始化本轮求解状态,设置 `board = ui.puzzle.board`,然后循环调用具体题型的 assist 函数。 -- 对 Masyu 来说,实际入口是 `MasyuAssist()`。 -- 每轮推导结束后,如果还有新增结论,脚本会用 `setTimeout(..., 0)` 继续下一轮,直到没有变化、超过 `MAXLOOP`、超时,或者 `Assist Step` 模式已经产生一步有效修改。 -- `stepcheck()` 是全局推进器:每当 `add_line`、`add_cross`、`add_inout` 等函数真的写入了状态,就增加计数;如果当前是 step 模式,会通过抛出 `"Step finished."` 立刻中止。 - -所以作者的求解不是“一个规则返回一个解释步骤”,而是“一个大循环不断把所有可用结论直接写回棋盘”。这对性能和覆盖面很有利,因为各种结论会在同一轮或下一轮马上互相喂给对方;代价是很难拆出清晰、稳定、可回放的推理说明。 - -## MasyuAssist 的结构 - -`MasyuAssist()` 很短,但它调用的通用函数很重: - -```js -function MasyuAssist() { - SingleLoopInCell({ - isPass: c => c.qnum !== CQNUM.none, - }); - ... -} -``` - -这句的含义是:把所有珍珠格都视为“必须被环经过”的格子,然后先运行一套通用单环推理。之后,作者再在 `forEachCell(cell => { for (let d = 0; d < 4; d++) { ... } })` 中枚举每个格子和四个旋转方向,执行 Masyu 专用 pattern。 - -可以把作者的 Masyu 求解分成两层: - -1. **通用单环层**:`SingleLoopInCell()` 负责所有单环谜题都可能用到的度数、死路、连通性、内外侧颜色传播。 -2. **Masyu 专用层**:`MasyuAssist()` 里硬编码白珠、黑珠、黑白组合、长走廊连接等局部 pattern。 - -这也是作者脚本强的地方:它不是只有 Masyu 局部规则,而是把 Masyu 珍珠规则放进了一个比较完整的 loop/topology 框架里反复迭代。 - -## 通用单环层:SingleLoopInCell - -`SingleLoopInCell()` 是作者 Masyu 性能的核心之一。它的输入是一组谓词和写入函数,Masyu 只特别指定了 `isPass: c => c.qnum !== CQNUM.none`,也就是珍珠必须经过。 - -它主要做这些事。 - -### 1. 单环基础约束 - -它扫描每个 cell 的四条边,维护: - -- `linecnt`:当前已知线数量; -- `emptycnt`:仍可走的候选边数量; -- `isPass(cell)`:这个格子是否必须被环经过; -- `isPathable(border)`:这条边是否还可能成为线。 - -典型结论包括: - -- 不可经过的格子周围全部打叉; -- 如果一个必须经过的格子只剩两条可行边,就把两条都画线; -- 如果一个格子已经有两条线,就把其他方向打叉; -- 如果某格没有足够出口,就打叉并标记为不可经过。 - -这些是单环谜题的“度数”和“死路”基础推理。 - -### 2. 防止 premature loop - -作者使用 puzz.link 的 `board.linegraph.components` 和每个 cell 的 `path` 信息判断已知线段是否属于同一个连通分量。 - -当某条候选边会把同一条路径提前闭成小环,而棋盘上还有其他必须经过或已经画出的部分没有连接进来时,脚本会把这条边打叉。这里对应 PuzzleKit 里的 `Prevent Premature Loop`,但作者还把它和更多局部路径形态混在一起使用。 - -### 3. 已知路径分量的连通性推理 - -`SingleLoopInCell()` 不只禁止小环,还会利用“多个路径分量最终必须成为一个环”的事实做强制: - -- 某个未经过的格子如果四周候选邻居都属于同一路径分量,进入它可能导致局部自连,于是相关边被排除。 -- 某个必须经过的格子如果只有一种方式可以连接两个不同候选路径组,就强制对应边。 -- `lineaux` 被用作临时“可能线”标记,帮助判断候选路径组的连通关系;它不是正式答案线,但能参与局部连接分析。 - -这部分是 PuzzleKit 当前最值得吸收的内容之一。作者并没有把它写成独立的“桥边规则”或“割点规则”,而是嵌在单环通用函数里,导致很多 topology 结论自然出现。 - -### 4. 顶点 in/out 颜色传播 - -在没有 ice/cross 这类特殊机制时,作者会给 grid cross 使用 `CRQSUB.in` / `CRQSUB.out` 的隐式标记: - -- 已知线两侧的顶点颜色不同; -- 已知叉或非线通道两侧颜色相同; -- 如果两个相邻顶点颜色相同,就打叉; -- 如果颜色不同,就画线; -- 白珠会强制对角顶点颜色相反; -- 某些黑白组合也会传播顶点颜色关系。 - -这和 PuzzleKit 当前的 `PuzzleIR.tiles` 很接近:我们把顶点中心的区域颜色存成 tile fill,线和叉对应 tile parity 的异同关系。区别是作者把颜色传播和线判断放在同一个大函数里持续迭代,而 PuzzleKit 为了可解释性拆成了 `Tile Color Propagation`、`Color-Pearl Propagation`、`Color-Line Propagation` 和 `Tile Connectivity Cut Coloring`。 - -## 通用连通框架:CellConnected - -`CellConnected()` 是作者脚本里另一个重要基础设施。它不是 Masyu 专用,而是一个可配置的 Tarjan 连通性工具。 - -它接收: - -- 什么算 shaded / unshaded; -- 发现必须 shaded 时怎么写入; -- 发现必须 unshaded 时怎么写入; -- 哪些边可通行、哪些边已连接; -- 是否把棋盘外侧当成 shaded; -- 是否要找 bridge,并把 bridge 转成线或 link。 - -内部做法是: - -- 用 DFS/Tarjan 维护 `ord` 和 `low`; -- 统计每个 DFS 子树里有多少目标对象; -- 如果某个点一旦移除会把必须连通的对象分开,就把它加入 `shadelist`; -- 如果某条边是连接必须连通对象的唯一桥,就把它加入 `bridgelist`; -- 最后统一调用 `add_shaded`、`add_unshaded` 或 `add_line` / `add_link`。 - -在 `SingleLoopInCell()` 中,它主要被用于 cross 顶点的 in/out 连通与传播;在其他谜题中也可用于黑格、白格、区域连通等推理。对 PuzzleKit 来说,它的价值不在于代码形式,而在于“同一个 low-link 框架可以同时服务割点、桥边、不可达区域、强制连接”这个抽象。 - -## Masyu 专用推理策略 - -作者在 `MasyuAssist()` 中写了一组旋转对称的局部判断。下面按推理家族归类,而不是逐条 `if` 机械编号。 - -### 1. 白珠直行与相邻转弯 - -白珠必须直行穿过,但至少一侧相邻格必须转弯。作者覆盖了几类常见情况: - -- 如果白珠某一侧已经有线,或垂直方向已不可走,就强制白珠沿该轴直行,并把垂直方向打叉。 -- 如果白珠一侧已经连续直行两段,那么另一侧不能也继续直行,于是远端候选边被打叉。 -- 如果白珠左右两侧都因为线、相邻白珠、黑珠、或不可行边而无法满足横向穿过后的转弯条件,就打叉横向、强制纵向。 - -其中第三类是比较有价值的 pattern:它不是简单的“白珠已有一条线”,而是在判断某个轴是否还能满足相邻转弯条件。PuzzleKit 现在已有白珠候选 pruning,思想上接近,但作者在局部 pattern 中覆盖了一些更具体的触发形态。 - -### 2. 黑珠转弯与两步直行延伸 - -黑珠必须转弯,并且从黑珠出去后的下一格必须继续直行一段。作者写了: - -- 如果黑珠某方向已经有线,则继续强制同方向第二段; -- 如果某个出口不可能合法使用,就打叉该出口,并常常强制对侧出口; -- 如果黑珠面对黑珠、白珠组合、边界或不可行边,会排除某些出口。 - -PuzzleKit 当前的黑珠规则和黑珠候选 pruning 已覆盖主干逻辑,但作者有一些局部 pattern 可以更早产生结论,尤其是“出口被未来第二段或邻近珍珠形态排除”的情况。 - -### 3. 双黑夹迫 - -作者有一个典型 pattern: - -```text -++ -● · ● -++ -``` - -如果两个黑珠夹着一个中间格,而中间格某个垂直方向已经不能走,那么另一个垂直方向也被打叉。直觉是:中间格不能只给一个黑珠提供不对称的转弯路径,否则会破坏黑珠的转弯/延伸要求。 - -PuzzleKit 已有 `Double Black Squeeze`,但当前实现偏窄:只在中间格某一条垂直候选已 blank 时推出另一条 blank。作者的条件族更宽,会结合 `isPathable` 和周边局部形态判断。 - -### 4. 连续与相邻白珠 pattern - -作者覆盖了连续白珠带来的方向排除和强制: - -- 连续白珠会限制它们只能以某种轴穿过; -- 相邻白珠如果某些侧向转弯不可行,会强制另一种穿法; -- 白珠组合还会参与 in/out 顶点颜色传播。 - -PuzzleKit 当前有 `Consecutive White Pearls Straight` 和 `Adjacent White Pearls LookAhead`。后者比作者脚本更结构化,但作者脚本中仍有一些“不是完整 lookahead、但很便宜”的白珠组合 pattern 值得拆出来。 - -### 5. 黑白组合 pattern - -作者有几类黑珠与白珠组合: - -- 黑珠面对两个连续白珠时,黑珠远离白珠的一侧被强制画线。 -- 黑珠斜向两侧有白珠时,会强制黑珠向另一侧延伸。 -- 某些黑白组合会让顶点 in/out 颜色相同或相反,从而继续推出线/叉。 - -PuzzleKit 里已经有 `Black Facing Consecutive Whites` 和 `Black Diagonal White Pinch`,但作者还写了更长距离、更依赖已有 linegraph 的黑白组合。 - -### 6. 基于 linegraph 的长走廊连通 pattern - -`MasyuAssist()` 末尾有几条明显依赖 `board.linegraph.components.length > 1` 的规则。它们的共同前提是:当前已有线形成了一个长走廊或局部路径,如果不在某处继续连接,就会过早闭合或无法把其他珍珠接入同一环。 - -这些规则看起来像硬编码 pattern,但本质更接近 graph 连通性推理。它们在大题上可能很有效,因为大题中长路径分量多,局部形状很容易触发“唯一连接方式”。 - -### 7. 连通性与珍珠局部规则的混合 - -作者还专门写了: - -- connectivity and black:如果黑珠与某个远端格已经在同一路径分量中,而中间还没有连接,则强制黑珠向反方向出线。 -- connectivity and white:如果白珠两侧相邻格已经在同一路径分量中,为避免白珠沿该轴造成自连,强制它改走垂直方向。 - -这两条很值得注意。它们不是普通 pearl-local 规则,而是把“珍珠形状约束”和“已知路径分量不能提前自连”结合起来。PuzzleKit 当前有 premature loop 和候选 pruning,但这类“linegraph 状态触发的珍珠 pattern”还可以更明确地补。 - -## 作者实现了几种策略? - -如果按可移植的推理家族看,作者至少实现了以下 8 类: - -1. **单环度数与死路规则**:二度补全、三度/溢出排除、必须经过格只剩两出口则画线。 -2. **premature loop 防止**:同一路径分量提前闭环时打叉。 -3. **路径分量连通性规则**:利用 linegraph、候选边和 lineaux 判断必须连接或禁止自连。 -4. **Tarjan 割点/桥边规则**:`CellConnected()` 对必须连通对象做 cut vertex / bridge 推理。 -5. **顶点 in/out 颜色传播**:同色打叉、异色画线,并从线/叉继续传播颜色。 -6. **Masyu 珍珠基础规则**:白珠直行转弯、黑珠转弯延伸。 -7. **Masyu 局部 pattern**:双黑、连续白、黑白组合、长走廊组合。 -8. **珍珠约束与连通性的混合规则**:根据当前路径分量状态触发黑珠/白珠方向选择。 - -作者没有实现 PuzzleKit 当前已有的黑珠强推断、白珠强推断,也没有把相邻白珠做成结构化 lookahead。作者的优势更多来自“便宜、全局、持续迭代”的 deterministic/topology 组合,而不是深搜索。 - -## 对 PuzzleKit 的启发 - -直接移植 `MasyuAssist()` 不合适,因为它的结论是批量、隐式、直接写回 board 的;但它给出了三个很清楚的方向: - -- **graph 层优先**:先把 `SingleLoopInCell()` 中那些非坐标 pattern 的 loop graph 逻辑抽象出来。 -- **颜色推理更贴近线判断**:现有 tile parity 很适合承接作者的 in/out 思想,但需要更多 pearl-local 和 connectivity 触发点。 -- **pattern 只挑高收益的**:作者脚本里有不少坐标硬编码,值得移植的是解释短、测试小、能明显补强大题性能的那部分。 diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 2714fda..3b76969 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -217,6 +217,11 @@ remain outside that loop. This rule determines crossed-out line segments. It only blocks premature loop closures; it does not complete the final loop by itself. +The same premature-loop check also applies to ordinary empty cells with exactly +two remaining unknown exits and no confirmed exits. If using both exits would +close a smaller loop while other confirmed line segments remain outside it, both +exits are crossed out. + ### Masyu Candidate Bridge Line The solver builds a candidate graph from all confirmed and still-unknown Masyu diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts index 0a13d8c..b0c58bd 100644 --- a/src/domain/rules/masyu/rules.test.ts +++ b/src/domain/rules/masyu/rules.test.ts @@ -20,7 +20,10 @@ import { } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' import { createCellExitCompletionRule } from './rules/completion' -import { createPreventPrematureLoopRule } from './rules/loop' +import { + createMasyuEmptyCellPrematureLoopRule, + createPreventPrematureLoopRule, +} from './rules/loop' import { createBlackDiagonalWhitePinchRule, createBlackFacingConsecutiveWhitesRule, @@ -544,6 +547,7 @@ describe('Masyu pearl rules', () => { 'Masyu Tile Connectivity Cut Coloring', 'Masyu Candidate Bridge Line', 'Prevent Premature Loop', + 'Masyu Empty Cell Premature Loop', 'Black Pearl Candidate Pruning', 'White Pearl Candidate Pruning', 'Adjacent White Pearls LookAhead', @@ -876,6 +880,12 @@ describe('Masyu loop rules', () => { expect(rules.indexOf('masyu-candidate-bridge-line')).toBeLessThan( rules.indexOf('masyu-prevent-premature-loop'), ) + expect(rules.indexOf('masyu-empty-cell-premature-loop')).toBe( + rules.indexOf('masyu-prevent-premature-loop') + 1, + ) + expect(rules.indexOf('masyu-empty-cell-premature-loop')).toBeLessThan( + rules.indexOf('masyu-black-pearl-candidate-pruning'), + ) expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBe( rules.indexOf('masyu-black-pearl-candidate-pruning') + 1, ) @@ -919,6 +929,54 @@ describe('Masyu loop rules', () => { expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() }) + + it('Masyu Empty Cell Premature Loop blanks both exits when using both would close a smaller loop', () => { + const puzzle = createMasyuPuzzle(4, 4) + const target = cellKey(0, 0) + const east = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 0], [1, 0]) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + const result = createMasyuEmptyCellPrematureLoopRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank' }) + expect(result?.affectedLines).toEqual([east, south]) + expect(result?.message).toContain('both remaining exits') + expect(result?.message).toContain('smaller loop') + }) + + it('Masyu Empty Cell Premature Loop skips cells that already have a line', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Empty Cell Premature Loop skips pearl cells', () => { + for (const color of ['black', 'white'] as const) { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 0, 0, color) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() + } + }) + + it('Masyu Empty Cell Premature Loop allows both exits when no other confirmed lines remain outside', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + + expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() + }) }) describe('Masyu black pearl candidate pruning', () => { diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index ac7a7ec..c0fde5e 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -14,7 +14,10 @@ import { import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' import { createCellExitCompletionRule } from './rules/completion' import { createEmptyCellStrongInferenceRule } from './rules/emptyCellStrongInference' -import { createPreventPrematureLoopRule } from './rules/loop' +import { + createMasyuEmptyCellPrematureLoopRule, + createPreventPrematureLoopRule, +} from './rules/loop' import { createBlackDiagonalWhitePinchRule, createBlackFacingConsecutiveWhitesRule, @@ -39,6 +42,7 @@ export const deterministicMasyuRules: Rule[] = [ createMasyuTileConnectivityCutColoringRule(), createMasyuCandidateBridgeLineRule(), createPreventPrematureLoopRule(), + createMasyuEmptyCellPrematureLoopRule(), createBlackPearlCandidatePruningRule(), createWhitePearlCandidatePruningRule(), createAdjacentWhitePearlsLookaheadRule(), diff --git a/src/domain/rules/masyu/rules/loop.ts b/src/domain/rules/masyu/rules/loop.ts index ad639e3..e7aede6 100644 --- a/src/domain/rules/masyu/rules/loop.ts +++ b/src/domain/rules/masyu/rules/loop.ts @@ -1,3 +1,5 @@ +import { cellKey, parseLineKey } from '../../../ir/keys' +import type { PuzzleIR } from '../../../ir/types' import type { Rule, RuleApplication } from '../../types' import { createMasyuLineDecisionCollector } from './decisionCollector' import { @@ -5,11 +7,255 @@ import { hasMasyuPrematureLoop, type MasyuLineOverlay, } from './lineGraph' +import { + formatMasyuCellKeyLabel, + getMasyuIncidentDirectionalLines, + MASYU_DIRECTIONS, + type MasyuDirectionalLine, +} from './shared' export type { MasyuLineOverlay } export { findMasyuPrematureLoopClosingLines, hasMasyuPrematureLoop } +export type MasyuEmptyCellPrematureLoopCandidate = { + cellKey: string + exits: [MasyuDirectionalLine, MasyuDirectionalLine] +} + +type MasyuEmptyCellLoopSnapshot = { + wouldCreatePrematureLoopWithLines: (lineKeys: [string, string]) => boolean +} + +type ComponentStats = { + edgeCount: number + vertexCount: number +} + +const createMasyuEmptyCellLoopSnapshot = ( + puzzle: PuzzleIR, +): MasyuEmptyCellLoopSnapshot => { + const cellCount = puzzle.rows * puzzle.cols + const parent = Array.from({ length: cellCount }, (_, idx) => idx) + const rank = new Array(cellCount).fill(0) + const lineEndpoints = new Map() + + const toCellIndex = (row: number, col: number): number => + row * puzzle.cols + col + + const getLineEndpoints = ( + lineKeyValue: string, + ): [left: number, right: number] => { + const cached = lineEndpoints.get(lineKeyValue) + if (cached) { + return cached + } + const [left, right] = parseLineKey(lineKeyValue) + const endpoints: [number, number] = [ + toCellIndex(left[0], left[1]), + toCellIndex(right[0], right[1]), + ] + lineEndpoints.set(lineKeyValue, endpoints) + return endpoints + } + + const find = (idx: number): number => { + if (parent[idx] !== idx) { + parent[idx] = find(parent[idx]) + } + return parent[idx] + } + + const union = (left: number, right: number): void => { + const rootA = find(left) + const rootB = find(right) + if (rootA === rootB) { + return + } + if (rank[rootA] < rank[rootB]) { + parent[rootA] = rootB + } else if (rank[rootA] > rank[rootB]) { + parent[rootB] = rootA + } else { + parent[rootB] = rootA + rank[rootA] += 1 + } + } + + const lineKeys = Object.keys(puzzle.lines) + const knownLineKeys = lineKeys.filter( + (lineKeyValue) => (puzzle.lines[lineKeyValue]?.mark ?? 'unknown') === 'line', + ) + + for (const lineKeyValue of knownLineKeys) { + const [left, right] = getLineEndpoints(lineKeyValue) + union(left, right) + } + + const baseStats = new Map() + const baseVertices = new Map>() + for (const lineKeyValue of knownLineKeys) { + const [left, right] = getLineEndpoints(lineKeyValue) + const root = find(left) + const vertices = baseVertices.get(root) ?? new Set() + vertices.add(left) + vertices.add(right) + baseVertices.set(root, vertices) + const stats = baseStats.get(root) ?? { edgeCount: 0, vertexCount: 0 } + stats.edgeCount += 1 + baseStats.set(root, stats) + } + for (const [root, vertices] of baseVertices) { + const stats = baseStats.get(root) + if (stats) { + stats.vertexCount = vertices.size + } + } + + const totalBaseLineCount = knownLineKeys.length + + const wouldCreatePrematureLoopWithLines = ( + assumedLineKeys: [string, string], + ): boolean => { + const localParent = new Map() + const localStats = new Map() + const countedIsolatedRoots = new Set() + + const ensureRoot = (root: number): void => { + if (!localParent.has(root)) { + localParent.set(root, root) + const stats = baseStats.get(root) + localStats.set(root, { + edgeCount: stats?.edgeCount ?? 0, + vertexCount: stats?.vertexCount ?? 0, + }) + } + } + + const findLocal = (root: number): number => { + ensureRoot(root) + const current = localParent.get(root) + if (current === undefined || current === root) { + return root + } + const next = findLocal(current) + localParent.set(root, next) + return next + } + + const countTouchedEndpoint = (root: number): void => { + ensureRoot(root) + if (baseStats.has(root) || countedIsolatedRoots.has(root)) { + return + } + countedIsolatedRoots.add(root) + const localRoot = findLocal(root) + const stats = localStats.get(localRoot) + if (stats) { + stats.vertexCount += 1 + } + } + + const addAssumedLine = (lineKeyValue: string): void => { + const [left, right] = getLineEndpoints(lineKeyValue) + const leftRoot = find(left) + const rightRoot = find(right) + countTouchedEndpoint(leftRoot) + countTouchedEndpoint(rightRoot) + + const localLeft = findLocal(leftRoot) + const localRight = findLocal(rightRoot) + if (localLeft === localRight) { + const stats = localStats.get(localLeft) + if (stats) { + stats.edgeCount += 1 + } + return + } + + const leftStats = localStats.get(localLeft) ?? { + edgeCount: 0, + vertexCount: 0, + } + const rightStats = localStats.get(localRight) ?? { + edgeCount: 0, + vertexCount: 0, + } + localParent.set(localRight, localLeft) + localStats.set(localLeft, { + edgeCount: leftStats.edgeCount + rightStats.edgeCount + 1, + vertexCount: leftStats.vertexCount + rightStats.vertexCount, + }) + localStats.delete(localRight) + } + + for (const lineKeyValue of assumedLineKeys) { + addAssumedLine(lineKeyValue) + } + + const totalLineCount = totalBaseLineCount + assumedLineKeys.length + return [...localParent.keys()].some((root) => { + if (findLocal(root) !== root) { + return false + } + const stats = localStats.get(root) + return ( + stats !== undefined && + stats.edgeCount >= stats.vertexCount && + totalLineCount > stats.edgeCount + ) + }) + } + + return { wouldCreatePrematureLoopWithLines } +} + +export const findMasyuEmptyCellPrematureLoopCandidates = ( + puzzle: PuzzleIR, +): MasyuEmptyCellPrematureLoopCandidate[] => { + const candidates: MasyuEmptyCellPrematureLoopCandidate[] = [] + const snapshot = createMasyuEmptyCellLoopSnapshot(puzzle) + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + if (puzzle.cells[key]?.clue?.kind === 'pearl') { + continue + } + + const incidentByDirection = getMasyuIncidentDirectionalLines(puzzle, key) + const incident = MASYU_DIRECTIONS.flatMap((direction) => { + const item = incidentByDirection[direction] + return item ? [item] : [] + }) + const lineCount = incident.filter((item) => item.mark === 'line').length + const available = incident.filter((item) => item.mark !== 'blank') + const unknowns = available.filter((item) => item.mark === 'unknown') + if ( + lineCount !== 0 || + available.length !== 2 || + unknowns.length !== 2 + ) { + continue + } + + if ( + snapshot.wouldCreatePrematureLoopWithLines([ + unknowns[0].lineKey, + unknowns[1].lineKey, + ]) + ) { + candidates.push({ + cellKey: key, + exits: [unknowns[0], unknowns[1]], + }) + } + } + } + + return candidates +} + export const createPreventPrematureLoopRule = (): Rule => ({ id: 'masyu-prevent-premature-loop', name: 'Prevent Premature Loop', @@ -37,3 +283,37 @@ export const createPreventPrematureLoopRule = (): Rule => ({ } }, }) + +export const createMasyuEmptyCellPrematureLoopRule = (): Rule => ({ + id: 'masyu-empty-cell-premature-loop', + name: 'Masyu Empty Cell Premature Loop', + apply: (puzzle): RuleApplication | null => { + const decisions = createMasyuLineDecisionCollector(puzzle) + const affectedCells: string[] = [] + + for (const candidate of findMasyuEmptyCellPrematureLoopCandidates(puzzle)) { + const beforeSize = decisions.decisions.size + for (const exit of candidate.exits) { + decisions.add(exit.lineKey, 'blank') + } + if (decisions.decisions.size > beforeSize) { + affectedCells.push(candidate.cellKey) + } + } + + if (!decisions.hasChanges()) { + return null + } + + const diffs = decisions.diffs() + const firstCell = affectedCells[0] + return { + message: firstCell + ? `Empty cell ${formatMasyuCellKeyLabel(firstCell)} cannot use both remaining exits because that would close a smaller loop, so both exits are blank.` + : 'Empty cells that would close a smaller loop by using both remaining exits have those exits crossed out.', + diffs, + affectedCells, + affectedLines: decisions.affectedLines(), + } + }, +}) From a6edd1b1c428bbaa8bc88f7777de8ff8f1e1267e Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Wed, 3 Jun 2026 15:15:57 +0800 Subject: [PATCH 17/22] feat: enhance puzzle URL export functionality by supporting additional formats and updating encoding logic for Masyu puzzles --- src/domain/exporters/index.test.ts | 30 +++++++ src/domain/exporters/index.ts | 56 +++++++++++-- src/domain/exporters/types.ts | 3 +- .../parsers/puzzlink/masyuPuzzlink.test.ts | 78 ++++++++++++++++++- src/domain/parsers/puzzlink/masyuPuzzlink.ts | 51 ++++++++++-- .../parsers/puzzlink/slitherPuzzlink.test.ts | 15 +++- .../parsers/puzzlink/slitherPuzzlink.ts | 6 +- src/domain/plugins/slitherPlugin.ts | 2 +- src/features/solver/ControlPanel.tsx | 11 ++- 9 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 src/domain/exporters/index.test.ts diff --git a/src/domain/exporters/index.test.ts b/src/domain/exporters/index.test.ts new file mode 100644 index 0000000..15056b8 --- /dev/null +++ b/src/domain/exporters/index.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { cellKey } from '../ir/keys' +import { createMasyuPuzzle } from '../ir/masyu' +import { decodeSlitherFromPuzzlink } from '../parsers/puzzlink' +import { exportPuzzle } from './index' + +describe('URL exporters', () => { + it('exports Slitherlink puzzles to supported puzz.link-compatible URL variants', () => { + const puzzle = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/3/3/g0h') + const context = { puzzle, pluginId: 'slitherlink' } + + expect(exportPuzzle(context, 'puzzlink')).toBe('https://puzz.link/p?slither/3/3/gak') + expect(exportPuzzle(context, 'pzplus')).toBe('https://pzplus.tck.mn/p.html?slither/3/3/gak') + expect(exportPuzzle(context, 'pzprxs')).toBe('https://pzprxs.vercel.app/p?slither/3/3/gak') + }) + + it('exports Masyu puzzles to supported puzz.link-compatible URL variants', () => { + const puzzle = createMasyuPuzzle(6, 6) + puzzle.cells[cellKey(1, 1)] = { clue: { kind: 'pearl', color: 'white' } } + puzzle.cells[cellKey(4, 3)] = { clue: { kind: 'pearl', color: 'white' } } + puzzle.cells[cellKey(2, 2)] = { clue: { kind: 'pearl', color: 'black' } } + puzzle.cells[cellKey(2, 3)] = { clue: { kind: 'pearl', color: 'black' } } + puzzle.cells[cellKey(0, 5)] = { clue: { kind: 'pearl', color: 'black' } } + const context = { puzzle, pluginId: 'masyu' } + + expect(exportPuzzle(context, 'puzzlink')).toBe('https://puzz.link/p?mashu/6/6/02302i000900') + expect(exportPuzzle(context, 'pzplus')).toBe('https://pzplus.tck.mn/p.html?mashu/6/6/02302i000900') + expect(exportPuzzle(context, 'pzprxs')).toBe('https://pzprxs.vercel.app/p?mashu/6/6/02302i000900') + }) +}) diff --git a/src/domain/exporters/index.ts b/src/domain/exporters/index.ts index 29d206a..98df1fb 100644 --- a/src/domain/exporters/index.ts +++ b/src/domain/exporters/index.ts @@ -1,22 +1,64 @@ import { normalizePuzzle } from '../ir/normalize' import { puzzleRegistry } from '../plugins/registry' -import type { ExportContext, ExportFormat, Exporter, PuzzlinkEncodeResult } from './types' +import type { + ExportContext, + ExportFormat, + Exporter, + PuzzleUrlExportFormat, + PuzzlinkEncodeResult, +} from './types' -export const tryEncodePuzzlink = (context: ExportContext): PuzzlinkEncodeResult => { +export const isPuzzleUrlExportFormat = (format: ExportFormat): format is PuzzleUrlExportFormat => + format === 'puzzlink' || format === 'pzplus' || format === 'pzprxs' + +const getPuzzlinkPath = (url: string): string => { + if (!url.includes('://')) { + return url.replace(/^p\?/, '').split('&')[0] ?? '' + } + const parsed = new URL(url) + const path = decodeURIComponent(parsed.search.replace(/^\?/, '')).split('&')[0] ?? '' + if (path.length > 0) { + return path + } + const pathTokens = parsed.pathname.split('/').filter(Boolean) + if (pathTokens[0] === 'p') { + return pathTokens.slice(1).join('/') + } + throw new Error('Could not extract puzz.link puzzle data from encoded URL.') +} + +const formatPuzzleUrl = (puzzlinkUrl: string, format: PuzzleUrlExportFormat): string => { + const path = getPuzzlinkPath(puzzlinkUrl) + if (format === 'pzplus') { + return `https://pzplus.tck.mn/p.html?${path}` + } + if (format === 'pzprxs') { + return `https://pzprxs.vercel.app/p?${path}` + } + return `https://puzz.link/p?${path}` +} + +export const tryEncodePuzzleUrl = ( + context: ExportContext, + format: PuzzleUrlExportFormat, +): PuzzlinkEncodeResult => { const plugin = puzzleRegistry.get(context.pluginId) if (!plugin) { return { ok: false, message: `Puzzle plugin "${context.pluginId}" not found.` } } try { - return { ok: true, url: plugin.encode(context.puzzle) } + return { ok: true, url: formatPuzzleUrl(plugin.encode(context.puzzle), format) } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { ok: false, message } } } -const exportPuzzlink = (context: ExportContext): string => { - const result = tryEncodePuzzlink(context) +export const tryEncodePuzzlink = (context: ExportContext): PuzzlinkEncodeResult => + tryEncodePuzzleUrl(context, 'puzzlink') + +const exportPuzzleUrl = (format: PuzzleUrlExportFormat) => (context: ExportContext): string => { + const result = tryEncodePuzzleUrl(context, format) if (!result.ok) { throw new Error(result.message) } @@ -30,7 +72,9 @@ const exportJson = ({ puzzle }: ExportContext): string => JSON.stringify(normalizePuzzle(puzzle), null, 2) export const exporters: Exporter[] = [ - { format: 'puzzlink', label: 'puzz.link URL', export: exportPuzzlink }, + { format: 'puzzlink', label: 'puzz.link URL', export: exportPuzzleUrl('puzzlink') }, + { format: 'pzplus', label: 'pzplus URL', export: exportPuzzleUrl('pzplus') }, + { format: 'pzprxs', label: 'pzprxs URL', export: exportPuzzleUrl('pzprxs') }, { format: 'penpa', label: 'Penpa URL', export: exportPenpa }, { format: 'json', label: 'Custom JSON', export: exportJson }, ] diff --git a/src/domain/exporters/types.ts b/src/domain/exporters/types.ts index 1b576eb..287a73c 100644 --- a/src/domain/exporters/types.ts +++ b/src/domain/exporters/types.ts @@ -1,6 +1,7 @@ import type { PuzzleIR } from '../ir/types' -export type ExportFormat = 'puzzlink' | 'penpa' | 'json' +export type PuzzleUrlExportFormat = 'puzzlink' | 'pzplus' | 'pzprxs' +export type ExportFormat = PuzzleUrlExportFormat | 'penpa' | 'json' export type ExportContext = { puzzle: PuzzleIR diff --git a/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts b/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts index d55de17..0c0e1f7 100644 --- a/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts +++ b/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts @@ -1,9 +1,25 @@ import { describe, expect, it } from 'vitest' import { cellKey } from '../../ir/keys' -import { decodeMasyuFromPuzzlink, number3Decode } from './masyuPuzzlink' +import { createMasyuPuzzle } from '../../ir/masyu' +import { createSlitherPuzzle } from '../../ir/slither' +import { + decodeMasyuFromPuzzlink, + encodeMasyuToPuzzlink, + number3Decode, + number3Encode, +} from './masyuPuzzlink' const SAMPLE_URL = 'https://puzz.link/p?mashu/5/5/001390360' +const addPearl = ( + puzzle: ReturnType, + row: number, + col: number, + color: 'white' | 'black', +) => { + puzzle.cells[cellKey(row, col)] = { clue: { kind: 'pearl', color } } +} + describe('number3Decode', () => { it('unpacks each base-36 character into three trits', () => { expect(number3Decode('9')).toEqual([1, 0, 0]) @@ -11,6 +27,17 @@ describe('number3Decode', () => { }) }) +describe('number3Encode', () => { + it('packs three trits into one base-36 character', () => { + expect(number3Encode([1, 0, 0], 3)).toBe('9') + expect(number3Encode([0, 2, 2], 3)).toBe('8') + }) + + it('pads the tail with zeroes when the length is not divisible by three', () => { + expect(number3Encode([2], 1)).toBe('i') + }) +}) + describe('decodeMasyuFromPuzzlink', () => { it('imports the sample Masyu puzzle with expected pearl coordinates', () => { const puzzle = decodeMasyuFromPuzzlink(SAMPLE_URL) @@ -36,4 +63,53 @@ describe('decodeMasyuFromPuzzlink', () => { color: 'black', }) }) + + it('decodes pzplus.tck.mn and pzprxs.vercel.app urls with the same data as puzz.link', () => { + const fromPuzzlink = decodeMasyuFromPuzzlink(SAMPLE_URL) + const fromPzplus = decodeMasyuFromPuzzlink( + SAMPLE_URL.replace('https://puzz.link/p?', 'https://pzplus.tck.mn/p.html?'), + ) + const fromPzprxs = decodeMasyuFromPuzzlink( + SAMPLE_URL.replace('https://puzz.link', 'https://pzprxs.vercel.app'), + ) + + expect(fromPzplus.cells).toEqual(fromPuzzlink.cells) + expect(fromPzprxs.cells).toEqual(fromPuzzlink.cells) + }) +}) + +describe('encodeMasyuToPuzzlink', () => { + it('exports the supplied 6x6 Masyu puzzle as a mashu puzz.link URL', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 4, 3, 'white') + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 3, 'black') + addPearl(puzzle, 0, 5, 'black') + + expect(encodeMasyuToPuzzlink(puzzle)).toBe('https://puzz.link/p?mashu/6/6/02302i000900') + }) + + it('exports the supplied 5x6 Masyu puzzle as a mashu puzz.link URL', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 0, 0, 'black') + addPearl(puzzle, 4, 5, 'black') + addPearl(puzzle, 1, 4, 'white') + addPearl(puzzle, 4, 2, 'white') + + expect(encodeMasyuToPuzzlink(puzzle)).toBe('https://puzz.link/p?mashu/6/5/i003000012') + }) + + it('round-trips decoded pearl layouts through the encoder', () => { + const encoded = encodeMasyuToPuzzlink(decodeMasyuFromPuzzlink(SAMPLE_URL)) + const roundTripped = decodeMasyuFromPuzzlink(encoded) + + expect(roundTripped.cells).toEqual(decodeMasyuFromPuzzlink(SAMPLE_URL).cells) + }) + + it('rejects non-Masyu puzzles', () => { + expect(() => encodeMasyuToPuzzlink(createSlitherPuzzle(3, 3))).toThrow( + 'puzz.link export only supports Masyu puzzles.', + ) + }) }) diff --git a/src/domain/parsers/puzzlink/masyuPuzzlink.ts b/src/domain/parsers/puzzlink/masyuPuzzlink.ts index d39bb66..f8ddd2d 100644 --- a/src/domain/parsers/puzzlink/masyuPuzzlink.ts +++ b/src/domain/parsers/puzzlink/masyuPuzzlink.ts @@ -1,10 +1,10 @@ import { z } from 'zod' -import { cellKey } from '../../ir/keys' +import { cellKey, parseCellKey } from '../../ir/keys' import { createMasyuPuzzle } from '../../ir/masyu' import type { PuzzleIR } from '../../ir/types' import type { PuzzleFormatAdapter } from '../types' -const PUZZLINK_HOSTS = new Set(['puzz.link', 'pzplus.tck.mn', 'pzv.jp']) +const PUZZLINK_HOSTS = new Set(['puzz.link', 'pzplus.tck.mn', 'pzprxs.vercel.app', 'pzv.jp']) const typeAlias: Record = { masyu: 'masyu', mashu: 'masyu', @@ -22,7 +22,9 @@ const parsePuzzlinkPath = (input: string) => { if (input.includes('://')) { const url = new URL(input) if (!PUZZLINK_HOSTS.has(url.hostname.toLowerCase())) { - throw new Error('Only puzz.link, pzplus.tck.mn, and pzv.jp URLs are supported in this adapter.') + throw new Error( + 'Only puzz.link, pzplus.tck.mn, pzprxs.vercel.app, and pzv.jp URLs are supported in this adapter.', + ) } const q = decodeURIComponent(url.search.replace(/^\?/, '')).split('&')[0] ?? '' if (q.length > 0) { @@ -70,6 +72,20 @@ export const number3Decode = (body: string): number[] => { return result } +export const number3Encode = (values: number[], totalCells: number): string => { + let result = '' + for (let idx = 0; idx < totalCells; idx += 3) { + const a = values[idx] ?? 0 + const b = values[idx + 1] ?? 0 + const c = values[idx + 2] ?? 0 + if (a < 0 || a > 2 || b < 0 || b > 2 || c < 0 || c > 2) { + throw new Error('number3 values must be trits between 0 and 2.') + } + result += (a * 9 + b * 3 + c).toString(36) + } + return result +} + export const decodeMasyuFromPuzzlink = (input: string): PuzzleIR => { const path = parsePuzzlinkPath(input) const header = parseHeader(path) @@ -103,8 +119,33 @@ export const decodeMasyuFromPuzzlink = (input: string): PuzzleIR => { return puzzle } -export const encodeMasyuToPuzzlink = (): string => { - throw new Error('Masyu puzz.link export is not implemented yet.') +export const encodeMasyuToPuzzlink = (puzzle: PuzzleIR): string => { + if (puzzle.puzzleType !== 'masyu') { + throw new Error('puzz.link export only supports Masyu puzzles.') + } + + const rows = puzzle.rows - puzzle.margins[0] - puzzle.margins[1] + const cols = puzzle.cols - puzzle.margins[2] - puzzle.margins[3] + const totalCells = rows * cols + const values = Array(totalCells).fill(0) + + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'pearl') { + continue + } + const [row, col] = parseCellKey(key) + if (!Number.isInteger(row) || !Number.isInteger(col)) { + continue + } + const rr = row - puzzle.margins[0] + const cc = col - puzzle.margins[2] + if (rr < 0 || rr >= rows || cc < 0 || cc >= cols) { + continue + } + values[rr * cols + cc] = cell.clue.color === 'white' ? 1 : 2 + } + + return `https://puzz.link/p?mashu/${cols}/${rows}/${number3Encode(values, totalCells)}` } export const masyuPuzzlinkAdapter: PuzzleFormatAdapter = { diff --git a/src/domain/parsers/puzzlink/slitherPuzzlink.test.ts b/src/domain/parsers/puzzlink/slitherPuzzlink.test.ts index 19b108c..1589455 100644 --- a/src/domain/parsers/puzzlink/slitherPuzzlink.test.ts +++ b/src/domain/parsers/puzzlink/slitherPuzzlink.test.ts @@ -35,7 +35,7 @@ describe('slither puzzlink parser', () => { it('decodes pzplus.tck.mn urls with the same data as puzz.link', () => { const fromPuzzlink = decodeSlitherFromPuzzlink(complexSlitherUrl) const fromPzplus = decodeSlitherFromPuzzlink( - complexSlitherUrl.replace('https://puzz.link', 'https://pzplus.tck.mn'), + complexSlitherUrl.replace('https://puzz.link/p?', 'https://pzplus.tck.mn/p.html?'), ) expect(fromPzplus.rows).toBe(fromPuzzlink.rows) @@ -43,9 +43,20 @@ describe('slither puzzlink parser', () => { expect(fromPzplus.cells).toEqual(fromPuzzlink.cells) }) + it('decodes pzprxs.vercel.app urls with the same data as puzz.link', () => { + const fromPuzzlink = decodeSlitherFromPuzzlink(complexSlitherUrl) + const fromPzprxs = decodeSlitherFromPuzzlink( + complexSlitherUrl.replace('https://puzz.link', 'https://pzprxs.vercel.app'), + ) + + expect(fromPzprxs.rows).toBe(fromPuzzlink.rows) + expect(fromPzprxs.cols).toBe(fromPuzzlink.cols) + expect(fromPzprxs.cells).toEqual(fromPuzzlink.cells) + }) + it('rejects unsupported url hosts', () => { expect(() => decodeSlitherFromPuzzlink('https://example.com/p?slither/3/3/g0h')).toThrow( - /Only puzz\.link, pzplus\.tck\.mn, and pzv\.jp URLs are supported/, + /Only puzz\.link, pzplus\.tck\.mn, pzprxs\.vercel\.app, and pzv\.jp URLs are supported/, ) }) diff --git a/src/domain/parsers/puzzlink/slitherPuzzlink.ts b/src/domain/parsers/puzzlink/slitherPuzzlink.ts index f285b8f..f00a643 100644 --- a/src/domain/parsers/puzzlink/slitherPuzzlink.ts +++ b/src/domain/parsers/puzzlink/slitherPuzzlink.ts @@ -8,7 +8,7 @@ import { import type { NumberClueValue, PuzzleIR } from '../../ir/types' import type { PuzzleFormatAdapter } from '../types' -const PUZZLINK_HOSTS = new Set(['puzz.link', 'pzplus.tck.mn', 'pzv.jp']) +const PUZZLINK_HOSTS = new Set(['puzz.link', 'pzplus.tck.mn', 'pzprxs.vercel.app', 'pzv.jp']) const typeAlias: Record = { slither: 'slitherlink', slitherlink: 'slitherlink', @@ -112,7 +112,9 @@ const parsePuzzlinkPath = (input: string) => { if (input.includes('://')) { const url = new URL(input) if (!PUZZLINK_HOSTS.has(url.hostname.toLowerCase())) { - throw new Error('Only puzz.link, pzplus.tck.mn, and pzv.jp URLs are supported in this adapter.') + throw new Error( + 'Only puzz.link, pzplus.tck.mn, pzprxs.vercel.app, and pzv.jp URLs are supported in this adapter.', + ) } const q = decodeURIComponent(url.search.replace(/^\?/, '')) if (q.length > 0) { diff --git a/src/domain/plugins/slitherPlugin.ts b/src/domain/plugins/slitherPlugin.ts index 6526e4e..f391abd 100644 --- a/src/domain/plugins/slitherPlugin.ts +++ b/src/domain/plugins/slitherPlugin.ts @@ -20,7 +20,7 @@ const parseSlitherInput = (input: string) => { puzzlinkError instanceof Error ? puzzlinkError.message : String(puzzlinkError) const penpaMessage = penpaError instanceof Error ? penpaError.message : String(penpaError) throw new Error( - `Unsupported Slitherlink URL. Paste a puzz.link, pzplus.tck.mn, pzv.jp, or Penpa+ Slitherlink URL. puzz.link-compatible: ${puzzlinkMessage} Penpa+: ${penpaMessage}`, + `Unsupported Slitherlink URL. Paste a puzz.link, pzplus.tck.mn, pzprxs.vercel.app, pzv.jp, or Penpa+ Slitherlink URL. puzz.link-compatible: ${puzzlinkMessage} Penpa+: ${penpaMessage}`, ) } } diff --git a/src/features/solver/ControlPanel.tsx b/src/features/solver/ControlPanel.tsx index 56f80b4..cb56a3f 100644 --- a/src/features/solver/ControlPanel.tsx +++ b/src/features/solver/ControlPanel.tsx @@ -1,5 +1,10 @@ import { useEffect, useMemo, useState } from 'react' -import { exportPuzzle, exporters, tryEncodePuzzlink } from '../../domain/exporters' +import { + exportPuzzle, + exporters, + isPuzzleUrlExportFormat, + tryEncodePuzzleUrl, +} from '../../domain/exporters' import type { ExportFormat } from '../../domain/exporters/types' import { puzzleRegistry } from '../../domain/plugins/registry' import { BoardLegendButton } from '../board/BoardLegendButton' @@ -359,8 +364,8 @@ export const ControlPanel = () => { + {isOpen ? ( +
+
+

Display

+ +
+
+ {options.map((option) => { + const checked = displaySettings[option.id] ?? option.enabledByDefault + return ( + + ) + })} +
+
+ ) : null} + + ) +} diff --git a/src/features/board/BoardLegendButton.tsx b/src/features/board/BoardLegendButton.tsx index d548f8b..0693b03 100644 --- a/src/features/board/BoardLegendButton.tsx +++ b/src/features/board/BoardLegendButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { puzzleRegistry } from '../../domain/plugins/registry' import type { PuzzleHelpExampleEdge, @@ -8,6 +8,9 @@ import type { type Props = { pluginId: string + isOpen: boolean + onToggle: () => void + onClose: () => void } const LEGEND_WIDTH = 132 @@ -172,11 +175,9 @@ const BoardLegendCanvas = ({ example, label }: { example: PuzzleLegendExample; l return } -export const BoardLegendButton = ({ pluginId }: Props) => { - const [openPluginId, setOpenPluginId] = useState(null) +export const BoardLegendButton = ({ pluginId, isOpen, onToggle, onClose }: Props) => { const plugin = puzzleRegistry.get(pluginId) const legend = plugin?.legend - const isOpen = openPluginId === pluginId const titleId = `${pluginId}-board-legend-title` useEffect(() => { @@ -185,12 +186,12 @@ export const BoardLegendButton = ({ pluginId }: Props) => { } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setOpenPluginId(null) + onClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [isOpen]) + }, [isOpen, onClose]) if (!plugin || !legend) { return null @@ -204,7 +205,7 @@ export const BoardLegendButton = ({ pluginId }: Props) => { aria-label={`Show ${plugin.displayName} legend`} aria-expanded={isOpen} data-active={isOpen} - onClick={() => setOpenPluginId((current) => (current === pluginId ? null : pluginId))} + onClick={onToggle} > i @@ -221,7 +222,7 @@ export const BoardLegendButton = ({ pluginId }: Props) => { type="button" className="panel-icon-close" aria-label={`Close ${plugin.displayName} legend`} - onClick={() => setOpenPluginId(null)} + onClick={onClose} > × diff --git a/src/features/board/CanvasBoard.tsx b/src/features/board/CanvasBoard.tsx index 136125d..c93bf36 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 { - getCellEdgeKeys, getCornerEdgeKeys, parseCellKey, parseEdgeKey, @@ -16,6 +15,8 @@ import { } from '../../domain/ir/types' import type { PuzzleIR } from '../../domain/ir/types' import { PuzzleStatsInfoButton } from '../puzzleStats/PuzzleStatsInfoButton' +import type { DisplaySettings } from '../solver/solverStore' +import { BoardDisplayButton } from './BoardDisplayButton' type Props = { puzzle: PuzzleIR @@ -25,7 +26,8 @@ type Props = { highlightedCells: string[] highlightedColorCells: string[] highlightedColorTiles?: string[] - showVertexNumbers: boolean + displaySettings: DisplaySettings + onSetDisplayOption: (optionId: string, enabled: boolean) => void } const CELL_SIZE = 52 @@ -60,6 +62,7 @@ const drawDecisionMark = ( y1: number, x2: number, y2: number, + showBlankCross: boolean, ): void => { if (mark === 'line') { ctx.strokeStyle = highlighted ? '#22d3ee' : '#38bdf8' @@ -70,7 +73,7 @@ const drawDecisionMark = ( ctx.stroke() return } - if (mark === 'blank') { + if (mark === 'blank' && showBlankCross) { const [mx, my] = midpoint([x1, y1], [x2, y2]) ctx.strokeStyle = highlighted ? '#f472b6' : '#94a3b8' ctx.lineWidth = 2 @@ -101,7 +104,8 @@ export const CanvasBoard = ({ highlightedCells, highlightedColorCells, highlightedColorTiles = [], - showVertexNumbers, + displaySettings, + onSetDisplayOption, }: Props) => { const canvasRef = useRef(null) const [zoomPercent, setZoomPercent] = useState(100) @@ -131,21 +135,33 @@ export const CanvasBoard = ({ ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, width, height) const isMasyu = puzzle.puzzleType === 'masyu' + const showGridLabels = displaySettings.showGridLabels ?? true + const showHighlights = displaySettings.showHighlights ?? true + const showCellColors = displaySettings.showCellColors ?? true + const showTiles = displaySettings.showTiles ?? true + const showEdgeCrosses = displaySettings.showEdgeCrosses ?? true + const showLineCrosses = displaySettings.showLineCrosses ?? true + const showSectorMarks = displaySettings.showSectorMarks ?? true + const showVertices = displaySettings.showVertices ?? true + const showCoordinates = displaySettings.showCoordinates ?? false + const showGrid = displaySettings.showGrid ?? true - 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) + if (showGridLabels) { + 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) + } } - if (!isMasyu) { + if (!isMasyu && showCellColors) { for (const [key, cell] of Object.entries(puzzle.cells)) { const fill = cell.fill const fillStyle = getColorFillStyle(fill, 0.24) @@ -156,7 +172,7 @@ export const CanvasBoard = ({ ctx.fillStyle = fillStyle ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) } - } else { + } else if (isMasyu && showTiles) { const tileSize = CELL_SIZE for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { const fillStyle = getColorFillStyle(tile.fill, 0.24) @@ -174,20 +190,22 @@ export const CanvasBoard = ({ } } - for (const cell of highlightedCells) { - const [r, c] = parseCellKey(cell) - ctx.fillStyle = 'rgba(99, 102, 241, 0.25)' - ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + if (showHighlights) { + for (const cell of highlightedCells) { + const [r, c] = parseCellKey(cell) + ctx.fillStyle = 'rgba(99, 102, 241, 0.25)' + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + } } - if (!isMasyu) { + if (!isMasyu && showHighlights && showCellColors) { for (const cell of highlightedColorCells) { const fill = puzzle.cells[cell]?.fill const [r, c] = parseCellKey(cell) ctx.fillStyle = getColorFillStyle(fill, 0.44) ?? 'rgba(99, 102, 241, 0.2)' ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) } - } else { + } else if (isMasyu && showHighlights && showTiles) { const tileSize = CELL_SIZE for (const tile of highlightedColorTiles) { const fill = puzzle.tiles[tile]?.fill @@ -202,22 +220,24 @@ export const CanvasBoard = ({ } } - ctx.strokeStyle = isMasyu ? '#94a3b8' : '#cbd5e1' - ctx.lineWidth = 1 - ctx.setLineDash(isMasyu ? [4, 4] : []) - for (let r = 0; r <= puzzle.rows; r += 1) { - ctx.beginPath() - ctx.moveTo(PADDING, PADDING + r * CELL_SIZE) - ctx.lineTo(PADDING + puzzle.cols * CELL_SIZE, PADDING + r * CELL_SIZE) - ctx.stroke() - } - for (let c = 0; c <= puzzle.cols; c += 1) { - ctx.beginPath() - ctx.moveTo(PADDING + c * CELL_SIZE, PADDING) - ctx.lineTo(PADDING + c * CELL_SIZE, PADDING + puzzle.rows * CELL_SIZE) - ctx.stroke() + if (!isMasyu || showGrid) { + ctx.strokeStyle = isMasyu ? '#94a3b8' : '#cbd5e1' + ctx.lineWidth = 1 + ctx.setLineDash(isMasyu ? [4, 4] : []) + for (let r = 0; r <= puzzle.rows; r += 1) { + ctx.beginPath() + ctx.moveTo(PADDING, PADDING + r * CELL_SIZE) + ctx.lineTo(PADDING + puzzle.cols * CELL_SIZE, PADDING + r * CELL_SIZE) + ctx.stroke() + } + for (let c = 0; c <= puzzle.cols; c += 1) { + ctx.beginPath() + ctx.moveTo(PADDING + c * CELL_SIZE, PADDING) + ctx.lineTo(PADDING + c * CELL_SIZE, PADDING + puzzle.rows * CELL_SIZE) + ctx.stroke() + } + ctx.setLineDash([]) } - ctx.setLineDash([]) if (isMasyu) { ctx.strokeStyle = '#111827' @@ -253,62 +273,62 @@ export const CanvasBoard = ({ } } - if (!isMasyu) { - const sectorRadii = { - notZero: CELL_SIZE * 0.19, - notOne: CELL_SIZE * 0.24, - notTwo: CELL_SIZE * 0.29, - single: CELL_SIZE * 0.34, - } - for (const [key, sector] of Object.entries(puzzle.sectors)) { - const mask = sector.constraintsMask ?? SECTOR_MASK_ALL - if (mask === SECTOR_MASK_ALL) { - continue - } - const [r, c, corner] = parseSectorKey(key) - const cornerEdges = getCornerEdgeKeys(r, c, corner) - const isCornerResolved = cornerEdges.every( - (edge) => (puzzle.edges[edge]?.mark ?? 'unknown') !== 'unknown', - ) - if (isCornerResolved) { - continue + if (!isMasyu && showSectorMarks) { + const sectorRadii = { + notZero: CELL_SIZE * 0.19, + notOne: CELL_SIZE * 0.24, + notTwo: CELL_SIZE * 0.29, + single: CELL_SIZE * 0.34, } - const baseX = PADDING + c * CELL_SIZE - const baseY = PADDING + r * CELL_SIZE - const cornerX = corner === 'ne' || corner === 'se' ? baseX + CELL_SIZE : baseX - const cornerY = corner === 'sw' || corner === 'se' ? baseY + CELL_SIZE : baseY - const [start, end] = getSectorArcAngles(corner) + for (const [key, sector] of Object.entries(puzzle.sectors)) { + const mask = sector.constraintsMask ?? SECTOR_MASK_ALL + if (mask === SECTOR_MASK_ALL) { + continue + } + const [r, c, corner] = parseSectorKey(key) + const cornerEdges = getCornerEdgeKeys(r, c, corner) + const isCornerResolved = cornerEdges.every( + (edge) => (puzzle.edges[edge]?.mark ?? 'unknown') !== 'unknown', + ) + if (isCornerResolved) { + continue + } + const baseX = PADDING + c * CELL_SIZE + const baseY = PADDING + r * CELL_SIZE + const cornerX = corner === 'ne' || corner === 'se' ? baseX + CELL_SIZE : baseX + const cornerY = corner === 'sw' || corner === 'se' ? baseY + CELL_SIZE : baseY + const [start, end] = getSectorArcAngles(corner) - ctx.save() - const drawArc = ( - radius: number, - strokeStyle: string, - lineWidth: number, - lineDash: number[] = [], - ): void => { - ctx.strokeStyle = strokeStyle - ctx.lineWidth = lineWidth - ctx.setLineDash(lineDash) - ctx.beginPath() - ctx.arc(cornerX, cornerY, radius, start, end) - ctx.stroke() - } + ctx.save() + const drawArc = ( + radius: number, + strokeStyle: string, + lineWidth: number, + lineDash: number[] = [], + ): void => { + ctx.strokeStyle = strokeStyle + ctx.lineWidth = lineWidth + ctx.setLineDash(lineDash) + ctx.beginPath() + ctx.arc(cornerX, cornerY, radius, start, end) + ctx.stroke() + } - if (!sectorMaskAllows(mask, 0)) { - drawArc(sectorRadii.notZero, '#22c55e', 1.8, [4, 3]) - } - if (!sectorMaskAllows(mask, 1)) { - drawArc(sectorRadii.notOne, '#3b82f6', 1.8) - } - if (!sectorMaskAllows(mask, 2)) { - drawArc(sectorRadii.notTwo, '#f59e0b', 1.8, [4, 3]) - } + if (!sectorMaskAllows(mask, 0)) { + drawArc(sectorRadii.notZero, '#22c55e', 1.8, [4, 3]) + } + if (!sectorMaskAllows(mask, 1)) { + drawArc(sectorRadii.notOne, '#3b82f6', 1.8) + } + if (!sectorMaskAllows(mask, 2)) { + drawArc(sectorRadii.notTwo, '#f59e0b', 1.8, [4, 3]) + } - if (mask === SECTOR_MASK_ONLY_1) { - drawArc(sectorRadii.single, '#ef4444', 2.4) + if (mask === SECTOR_MASK_ONLY_1) { + drawArc(sectorRadii.single, '#ef4444', 2.4) + } + ctx.restore() } - ctx.restore() - } } if (isMasyu) { @@ -318,7 +338,16 @@ export const CanvasBoard = ({ const y1 = PADDING + v1[0] * CELL_SIZE + CELL_SIZE / 2 const x2 = PADDING + v2[1] * CELL_SIZE + CELL_SIZE / 2 const y2 = PADDING + v2[0] * CELL_SIZE + CELL_SIZE / 2 - drawDecisionMark(ctx, state.mark, highlightedLines.includes(line), x1, y1, x2, y2) + drawDecisionMark( + ctx, + state.mark, + showHighlights && highlightedLines.includes(line), + x1, + y1, + x2, + y2, + showLineCrosses, + ) } } else { for (const [edge, state] of Object.entries(puzzle.edges)) { @@ -327,24 +356,33 @@ export const CanvasBoard = ({ const y1 = PADDING + v1[0] * CELL_SIZE const x2 = PADDING + v2[1] * CELL_SIZE const y2 = PADDING + v2[0] * CELL_SIZE - drawDecisionMark(ctx, state.mark, highlightedEdges.includes(edge), x1, y1, x2, y2) + drawDecisionMark( + ctx, + state.mark, + showHighlights && highlightedEdges.includes(edge), + x1, + y1, + x2, + y2, + showEdgeCrosses, + ) } } - if (!isMasyu) { - 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 - const vertY = PADDING + r * CELL_SIZE - ctx.beginPath() - ctx.arc(vertex, vertY, 2.3, 0, Math.PI * 2) - ctx.fill() + if (!isMasyu && showVertices) { + 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 + const vertY = PADDING + r * CELL_SIZE + ctx.beginPath() + ctx.arc(vertex, vertY, 2.3, 0, Math.PI * 2) + ctx.fill() + } } } - } - if (showVertexNumbers && !isMasyu) { + if (showCoordinates && !isMasyu) { ctx.fillStyle = '#64748b' ctx.font = '12px ui-monospace, monospace' ctx.textAlign = 'left' @@ -370,8 +408,8 @@ export const CanvasBoard = ({ highlightedColorTiles, highlightedEdges, highlightedLines, + displaySettings, puzzle, - showVertexNumbers, width, zoom, ]) @@ -398,6 +436,11 @@ export const CanvasBoard = ({ {puzzle.rows} × {puzzle.cols} +
@@ -430,20 +473,6 @@ export const CanvasBoard = ({ Use the slider to zoom. Scroll to move around large grids. Highlight syncs with reasoning steps.

- {puzzle.puzzleType !== 'masyu' ? ( -
- Cell to edge mapping helper -
-            {Object.keys(puzzle.cells)
-              .slice(0, 5)
-              .map((key) => {
-                const [r, c] = parseCellKey(key)
-                return `${key} -> ${getCellEdgeKeys(r, c).join(' | ')}`
-              })
-              .join('\n')}
-          
-
- ) : null} ) } diff --git a/src/features/puzzleInfo/PuzzleInfoButton.tsx b/src/features/puzzleInfo/PuzzleInfoButton.tsx index 7568818..8fe767a 100644 --- a/src/features/puzzleInfo/PuzzleInfoButton.tsx +++ b/src/features/puzzleInfo/PuzzleInfoButton.tsx @@ -1,9 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { puzzleRegistry } from '../../domain/plugins/registry' import type { PuzzleHelpExample, PuzzleHelpExampleEdge } from '../../domain/plugins/types' type Props = { pluginId: string + isOpen: boolean + onToggle: () => void + onClose: () => void } const EXAMPLE_WIDTH = 142 @@ -129,12 +132,10 @@ const PuzzleInfoExampleCanvas = ({ example }: { example: PuzzleHelpExample }) => ) } -export const PuzzleInfoButton = ({ pluginId }: Props) => { - const [openPluginId, setOpenPluginId] = useState(null) +export const PuzzleInfoButton = ({ pluginId, isOpen, onToggle, onClose }: Props) => { const plugin = puzzleRegistry.get(pluginId) const help = plugin?.help const titleId = `${pluginId}-puzzle-info-title` - const isOpen = openPluginId === pluginId useEffect(() => { if (!isOpen) { @@ -142,12 +143,12 @@ export const PuzzleInfoButton = ({ pluginId }: Props) => { } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setOpenPluginId(null) + onClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [isOpen]) + }, [isOpen, onClose]) if (!plugin || !help) { return null @@ -161,7 +162,7 @@ export const PuzzleInfoButton = ({ pluginId }: Props) => { aria-label={`Show ${plugin.displayName} rules`} aria-expanded={isOpen} data-active={isOpen} - onClick={() => setOpenPluginId((current) => (current === pluginId ? null : pluginId))} + onClick={onToggle} > ? @@ -178,7 +179,7 @@ export const PuzzleInfoButton = ({ pluginId }: Props) => { type="button" className="panel-icon-close" aria-label={`Close ${plugin.displayName} rules`} - onClick={() => setOpenPluginId(null)} + onClick={onClose} > × diff --git a/src/features/solver/ControlPanel.tsx b/src/features/solver/ControlPanel.tsx index cb56a3f..c70ca03 100644 --- a/src/features/solver/ControlPanel.tsx +++ b/src/features/solver/ControlPanel.tsx @@ -17,6 +17,8 @@ import { useSolverStore, } from './solverStore' +type ActivePuzzlePopover = 'rules' | 'legend' | null + export const ControlPanel = () => { const { pluginId, @@ -32,8 +34,6 @@ export const ControlPanel = () => { resetTimeline, solveChunkSize, setSolveChunkSize, - includeVertexNumbers, - setIncludeVertexNumbers, isRunning, currentPuzzle, terminalReport, @@ -49,6 +49,7 @@ export const ControlPanel = () => { const [showImportErrorDialog, setShowImportErrorDialog] = useState(false) const [showTerminalReport, setShowTerminalReport] = useState(false) const [timelinePreviewStep, setTimelinePreviewStep] = useState(null) + const [activePuzzlePopover, setActivePuzzlePopover] = useState(null) const activeSteps = useMemo(() => steps.slice(0, pointer), [steps, pointer]) const difficulty = useMemo(() => buildDifficultySnapshot(activeSteps), [activeSteps]) const ruleUsageEntries = useMemo( @@ -92,6 +93,7 @@ export const ControlPanel = () => { value={pluginId} onChange={(event) => { const nextPluginId = event.target.value + setActivePuzzlePopover(null) if (nextPluginId === 'masyu') { setLocalUrl(DEFAULT_MASYU_SAMPLE_URL) importFromUrl(DEFAULT_MASYU_SAMPLE_URL, nextPluginId) @@ -111,12 +113,44 @@ export const ControlPanel = () => { ))} - - + + setActivePuzzlePopover((current) => (current === 'rules' ? null : 'rules')) + } + onClose={() => setActivePuzzlePopover(null)} + /> + + setActivePuzzlePopover((current) => (current === 'legend' ? null : 'legend')) + } + onClose={() => setActivePuzzlePopover(null)} + />