diff --git a/README.md b/README.md index 1b823699..643d1c2a 100644 --- a/README.md +++ b/README.md @@ -228,10 +228,9 @@ Currently it will take ~30 min to solve all 30k+ instances available. ## Reference -- [ortools Official](https://developers.google.cn/optimization?hl=zh-cn). +- [OR-tools Official](https://developers.google.cn/optimization?hl=zh-cn). - [Hakank's ORtools tutorials](http://www.hakank.org/google_or_tools/). -- [PySCIPOpt's tutorials](https://pyscipopt.readthedocs.io/en/latest/tutorials/). - Puzzle data source: [Raetsel's Janko](https://www.janko.at/Raetsel/index.htm), [Puzzle](https://www.puzzle-loop.com). -- Related repos like [puzzle_solver](https://github.com/Ar-Kareem/puzzle_solver) and [Puzzles-Solver](https://github.com/newtomsoft/Puzzles-Solver). -- [puzz.link](https://puzz.link) and [pzprjs](https://github.com/robx/pzprjs) and . +- Related repos like [puzzle_solver](https://github.com/Ar-Kareem/puzzle_solver), [Puzzles-Solver](https://github.com/newtomsoft/Puzzles-Solver) and [Nikoli puzzle solver](https://github.com/kevinychen/nikoli-puzzle-solver). +- [puzz.link](https://puzz.link) and [pzprjs](https://github.com/robx/pzprjs). - [Nonogram solver](https://rosettacode.org/wiki/Nonogram_solver#Python). \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index bfb55d21..3268a4d8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,12 +3,14 @@ site_url: https://github.com/SmilingWayne/PuzzleSolver site_description: PuzzleKit documentation site_dir: site +# ==== BEGIN NAV CONFIGURATION ==== nav: - Intro: - Intro: index.md - Puzzles: - abc_end_view: puzzles/abc_end_view.md - akari: puzzles/akari.md + - aqre: puzzles/aqre.md - balance_loop: puzzles/balance_loop.md - battleship: puzzles/battleship.md - binairo: puzzles/binairo.md @@ -16,6 +18,8 @@ nav: - bricks: puzzles/bricks.md - buraitoraito: puzzles/buraitoraito.md - butterfly_sudoku: puzzles/butterfly_sudoku.md + - canal_view: puzzles/canal_view.md + - castle_wall: puzzles/castle_wall.md - cave: puzzles/cave.md - clueless_1_sudoku: puzzles/clueless_1_sudoku.md - clueless_2_sudoku: puzzles/clueless_2_sudoku.md @@ -49,12 +53,15 @@ nav: - koburin: puzzles/koburin.md - kuromasu: puzzles/kuromasu.md - kuroshuto: puzzles/kuroshuto.md + - kurotto: puzzles/kurotto.md - linesweeper: puzzles/linesweeper.md - lits: puzzles/lits.md - magnetic: puzzles/magnetic.md - makaro: puzzles/makaro.md - masyu: puzzles/masyu.md - mathrax: puzzles/mathrax.md + - mejilink: puzzles/mejilink.md + - mid_loop: puzzles/mid_loop.md - minesweeper: puzzles/minesweeper.md - moon_sun: puzzles/moon_sun.md - mosaic: puzzles/mosaic.md @@ -64,6 +71,7 @@ nav: - nonogram: puzzles/nonogram.md - norinori: puzzles/norinori.md - number_cross: puzzles/number_cross.md + - nurimisaki: puzzles/nurimisaki.md - one_to_x: puzzles/one_to_x.md - paint_area: puzzles/paint_area.md - patchwork: puzzles/patchwork.md @@ -79,6 +87,7 @@ nav: - simple_loop: puzzles/simple_loop.md - skyscraper: puzzles/skyscraper.md - slitherlink: puzzles/slitherlink.md + - slitherlink_duality: puzzles/slitherlink_duality.md - snake: puzzles/snake.md - sohei_sudoku: puzzles/sohei_sudoku.md - square_o: puzzles/square_o.md @@ -99,6 +108,7 @@ nav: - yajilin: puzzles/yajilin.md - yin_yang: puzzles/yin_yang.md +# ==== END NAV CONFIGURATION ==== site_author: SmilingWayne repo_name: PuzzleKit diff --git a/pyproject.toml b/pyproject.toml index 09566cf0..f3a87614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "puzzlekit" -version = "0.3.0" -description = "A comprehensive logic puzzle solver (70+) based on Google OR-Tools. e.g., solvers for Nonogram, Slitherlink, Akari, Yajilin, Hitori and Sudoku-variants." +version = "0.3.1" +description = "A comprehensive logic puzzle solver (90+) based on Google OR-Tools. e.g., solvers for Nonogram, Slitherlink, Akari, Yajilin, Hitori and Sudoku-variants." readme = "README.md" requires-python = ">=3.10" authors = [{name = "SmilingWayne", email = "xiaoxiaowayne@gmail.com"}] diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 19c84b57..93b0a5fc 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -1,3 +1,10 @@ +""" +PuzzleKit Benchmark Tool - + +Updated for _dataset.json format +Compatible with new unified data format while preserving all original logic. +""" + import os import sys import json @@ -100,8 +107,8 @@ def run_single_benchmark(puzzle_type: str, pid: str, problem_str: str, solution_ record["status"] = result_data.get("status", "Unknown") record["total_time"] = toc - tic - # Verification - if record["status"] in ["Optimal", "Feasible"] and solution_str: + # Verification (only if solution exists) + if record["status"] in ["Optimal", "Feasible"] and solution_str.strip(): res_grid = result_data.get("solution_grid", []) sol_grid = parse_simple_solution_string(solution_str) try: @@ -109,6 +116,7 @@ def run_single_benchmark(puzzle_type: str, pid: str, problem_str: str, solution_ record["is_correct"] = str(is_correct) except Exception as ve: record["is_correct"] = "Error" + record["error_msg"] = f"Verification error: {ve}" except Exception as e: record["status"] = "Error" record["error_msg"] = str(e) @@ -116,7 +124,7 @@ def run_single_benchmark(puzzle_type: str, pid: str, problem_str: str, solution_ return record def parse_args(): - parser = argparse.ArgumentParser(description="PuzzleKit Benchmark Tool.") + parser = argparse.ArgumentParser(description="PuzzleKit Benchmark Tool (supports _dataset.json format).") # either all or one puzzle, mutually exclusive group = parser.add_mutually_exclusive_group() @@ -132,13 +140,10 @@ def main(): tic = time.perf_counter() args = parse_args() - # if no parameter is specified, print help information and exit, or default to previous behavior (currently set to default all, explicit usage of --all is required) - # but for convenience, if no parameter is specified, default to running all (compatibility with original script behavior) - # here I set it to: if no parameter is specified, default to running all (compatibility with original script behavior) + # Default behavior: run all if no arguments specified target_puzzle = args.puzzle - run_all = args.all + run_all = args.all or (not target_puzzle and not run_all) - # default behavior: if no parameter is specified, default to running all (compatibility with original script behavior) if not target_puzzle and not run_all: run_all = True @@ -155,9 +160,7 @@ def main(): sorted_assets = sorted(asset_folders) if target_puzzle: - # Normalize input to lower case for comparison target_lower = target_puzzle.lower() - # Filter matching folders filtered_assets = [f for f in sorted_assets if f.lower() == target_lower] if not filtered_assets: @@ -172,7 +175,7 @@ def main(): # --- Stats Containers --- table_rows = [] total_problems_global = 0 - total_solutions_global = 0 + total_solutions_global = 0 # Now equals total_problems_global (all puzzles have solution slots) csv_headers = ["puzzle_type", "pid", "status", "is_correct", "total_time", "error_msg"] csv_file = open(OUTPUT_CSV, 'w', newline='', encoding='utf-8') @@ -181,78 +184,90 @@ def main(): print(f"Results will be saved to: {OUTPUT_CSV}") - # Iterate + # Iterate over puzzle types for idx, folder_name in enumerate(sorted_assets, 1): + # Match solver class name puzzle_type = None - # Heuristic matching for pt in all_puzzle_types: if infer_class_name(pt) == folder_name: puzzle_type = pt break - prob_path = os.path.join(ASSETS_DIR, folder_name, "problems", f"{folder_name}_puzzles.json") - sol_path = os.path.join(ASSETS_DIR, folder_name, "solutions", f"{folder_name}_solutions.json") - - prob_data = load_json_file(prob_path) - sol_data = load_json_file(sol_path) + # === KEY CHANGE: Load unified _dataset.json instead of separate files === + dataset_path = os.path.join(ASSETS_DIR, folder_name, f"{folder_name}_dataset.json") + + if not os.path.exists(dataset_path): + print(f" ⚠️ Skipping {folder_name}: _dataset.json not found at {dataset_path}") + continue + + dataset_data = load_json_file(dataset_path) + puzzles_dict = dataset_data.get("data", {}) - puzzles = prob_data.get("puzzles", {}) - solutions_map = sol_data.get("solutions", {}) + # Get counts from dataset metadata (fallback to dict length if missing) + num_pbl = dataset_data.get("count", len(puzzles_dict)) + num_sol = dataset_data.get("count_sol", len(puzzles_dict)) # In new format, all puzzles have solution slots (may be empty strings) - num_pbl = len(puzzles) - num_sol = len(solutions_map) - max_size = get_max_size_str(puzzles) + # Calculate max size from problem data + max_size = get_max_size_str(puzzles_dict) total_problems_global += num_pbl - total_solutions_global += num_sol + total_solutions_global += num_sol # Same as num_pbl in new format + # Check solver availability solver_status = "❌" - avg_time = "-" - max_time = "-" - correct_cnt = "-" - has_solver_impl = False - try: - if puzzle_type: + if puzzle_type: + try: get_solver_class(puzzle_type) has_solver_impl = True solver_status = "✅" - except ValueError: - pass + except (ValueError, AttributeError): + pass + # Run benchmarks if solver exists and data available if has_solver_impl and num_pbl > 0: print(f"[{idx}/{len(sorted_assets)}] Benchmarking {folder_name} ({num_pbl} instances)...") times = [] corrects = 0 - for pid, p_data in puzzles.items(): - # Loop through instances + for pid, p_data in puzzles_dict.items(): problem_str = p_data.get("problem", "") - solution_str = solutions_map.get(pid, {}).get("solution", "") - + solution_str = p_data.get("solution", "") # May be empty string + + # Run benchmark for this instance res = run_single_benchmark(puzzle_type, pid, problem_str, solution_str) writer.writerow(res) - if res['status'] != "Error": + # Collect timing stats for non-error runs + if res['status'] not in ["Error", "NotStarted"]: times.append(res['total_time']) + # Count correct solutions (only when verification was performed) if res['is_correct'] == 'True': corrects += 1 + # Calculate timing statistics if times: avg_time = f"{statistics.mean(times):.3f}" max_time = f"{max(times):.3f}" + else: + avg_time = "-" + max_time = "-" correct_cnt = str(corrects) else: - print(f"[{idx}/{len(sorted_assets)}] Skipping {folder_name} (No solver or no data)") + print(f"[{idx}/{len(sorted_assets)}] Skipping {folder_name} (No solver implementation or no data)") + avg_time = "-" + max_time = "-" + correct_cnt = "-" + # Generate markdown table row folder_link = f"[{folder_name}](./assets/data/{folder_name})" table_rows.append([ str(idx), folder_link, str(num_pbl), - str(num_sol), + str(num_sol), # Now equals num_pbl (all puzzles have solution slots) max_size, solver_status, avg_time, @@ -262,7 +277,7 @@ def main(): csv_file.close() - # --- Generate Markdown --- + # --- Generate Markdown Report --- print("\n" + "="*50) print("GENERATING MARKDOWN REPORT") print("="*50 + "\n") @@ -300,6 +315,7 @@ def main(): print(f"\nMarkdown saved to: {md_path}") print(f"Full CSV data saved to: {OUTPUT_CSV}") toc = time.perf_counter() - print(f"Time taken: {toc - tic:.3f} seconds") + print(f"Total benchmark time: {toc - tic:.3f} seconds") + if __name__ == "__main__": main() \ No newline at end of file diff --git a/scripts/data_cleaner.py b/scripts/data_cleaner.py deleted file mode 100644 index 8c61eb02..00000000 --- a/scripts/data_cleaner.py +++ /dev/null @@ -1,259 +0,0 @@ -import json -import os -import argparse -from typing import Callable, Dict, Any, Optional -from pathlib import Path - - -def find_puzzle_file(puzzle_name: str) -> Optional[str]: - base_dir = Path(__file__).parent.parent - possible_paths = [ - base_dir / "assets" / "data" / puzzle_name / "problems" / f"{puzzle_name}_puzzles.json", - base_dir / "assets" / "data" / puzzle_name / f"{puzzle_name}_puzzles.json", - ] - - for path in possible_paths: - if path.exists(): - return str(path) - return None - - -def load_puzzle_data(file_path: str) -> Dict[str, Any]: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - return data - - -def save_puzzle_data(file_path: str, data: Dict[str, Any]) -> None: - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - -def clean_puzzle_data( - puzzle_name_or_path: str, - transform_func: Callable[[str, str], str], - output_path: Optional[str] = None, - dry_run: bool = False -) -> Dict[str, Any]: - if os.path.exists(puzzle_name_or_path): - file_path = puzzle_name_or_path - else: - file_path = find_puzzle_file(puzzle_name_or_path) - if file_path is None: - raise FileNotFoundError( - f"Unable to get puzzle folder: {puzzle_name_or_path}\n" - f"check if the puzzle type name is correct" - ) - - print(f"Loading file: {file_path}") - - data = load_puzzle_data(file_path) - - if "puzzles" not in data: - raise ValueError("Data file format error: missing 'puzzles' field") - - puzzles = data["puzzles"] - total = len(puzzles) - processed = 0 - errors = [] - - print(f"Found {total} puzzles, starting processing...") - - for puzzle_id, puzzle_data in puzzles.items(): - try: - if "problem" not in puzzle_data: - print(f"Warning: {puzzle_id} missing 'problem' field, skipping") - continue - - original_problem = puzzle_data["problem"] - new_problem = transform_func(original_problem, puzzle_id) - - if new_problem != original_problem: - puzzle_data["problem"] = new_problem - processed += 1 - if processed % 10 == 0: - print(f"Processed {processed}/{total} puzzles...") - except Exception as e: - error_msg = f"Error processing {puzzle_id}: {e}" - errors.append(error_msg) - print(f"Error: {error_msg}") - - print(f"\nProcessing completed!") - print(f"Total: {total} puzzles") - print(f"Modified: {processed} puzzles") - if errors: - print(f"Errors: {len(errors)}") - for error in errors: - print(f" - {error}") - if "count" in data: - data["count"] = len(puzzles) - - if not dry_run: - output_file = output_path or file_path - save_puzzle_data(output_file, data) - print(f"\nResults saved to: {output_file}") - else: - print("\n(Dry run mode, no files saved)") - return data - -def pad_default_grid(problem_str: str, puzzle_id: str) -> str: - """Pad default grid with '-'""" - lines = problem_str.strip().split('\n') - raw_matrix = [line.strip().split(" ") for line in lines[1:]] - num_rows, num_cols = map(int, lines[0].split(" ")) - default_grid = [['-' for _ in range(num_cols + 2)] for _ in range(num_rows + 2)] - - for i in range(num_rows): - for j in range(num_cols): - default_grid[i + 1][j + 1] = raw_matrix[i][j] - pad_str = "\n".join([" ".join(row) for row in default_grid]) - return f"{num_rows + 2} {num_cols + 2}\n{pad_str}" - -def remove_extra_spaces(problem_str: str, puzzle_id: str) -> str: - """Remove extra spaces""" - lines = problem_str.split('\n') - cleaned_lines = [' '.join(line.split()) for line in lines] - return '\n'.join(cleaned_lines) - -def add_header_if_missing(problem_str: str, puzzle_id: str) -> str: - """If header is missing, add it automatically""" - lines = problem_str.strip().split('\n') - if not lines: - return problem_str - - first_line = lines[0].strip() - # If the first line is not in "rows cols" format, try to infer from data - if not first_line.replace(' ', '').isdigit() or len(first_line.split()) != 2: - data_lines = [line for line in lines if line.strip()] - if data_lines: - num_rows = len(data_lines) - num_cols = len(data_lines[0].split()) if data_lines else 0 - return f"{num_rows} {num_cols}\n" + problem_str - - return problem_str - - -def replace_values(problem_str: str, puzzle_id: str) -> str: - """Replace specific values""" - lines = problem_str.split("\n") - first_line = lines[0].strip().split(" ") - m = int(first_line[0]) - content_lines = "\n".join(lines[5:]) - replacements = { - "-": "0" - } - first_five_lines = "\n".join(lines[:5]) - for old_val, new_val in replacements.items(): - content_lines = content_lines.replace(old_val, new_val) - return f"{first_five_lines}\n{content_lines}" - -def remove_padding(problem_str: str, puzzle_id: str) -> str: - lines = problem_str.strip().split('\n') - if not lines: - return problem_str - - first_line = lines[0].strip() - m, n = map(int, first_line.split(" ")) - # If the first line is not in "rows cols" format, try to infer from data - raw_matrix = [line.strip().split(" ") for line in lines[1:]] - cols = raw_matrix[0][1:] - rows = [line[0] for line in raw_matrix[1:]] - new_matrix = [[raw_matrix[i][j] for j in range(1, n + 1)] for i in range(1, m + 1)] - new_matrix_str = "\n".join([" ".join(row) for row in new_matrix]) - new_cols_str = " ".join(cols) - new_rows_str = " ".join(rows) - - return f"{first_line}\n{new_cols_str}\n{new_rows_str}\n{new_matrix_str}" - - -def main(): - parser = argparse.ArgumentParser( - description="Data cleaning script - batch process puzzle data files", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" - Examples: - python scripts/data_cleaner.py --file assets/data/DoubleBack/problems/DoubleBack_puzzles.json - - # Use puzzle type name - python scripts/data_cleaner.py --puzzle DoubleBack - - # Dry run (no files saved) - python scripts/data_cleaner.py --puzzle DoubleBack --dry-run - """ - ) - - parser.add_argument( - '--file', '-f', - type=str, - help='puzzle data file path' - ) - - parser.add_argument( - '--puzzle', '-p', - type=str, - help='puzzle type name (e.g. DoubleBack, EntryExit)' - ) - - parser.add_argument( - '--output', '-o', - type=str, - help='output file path (default overwrite original file)' - ) - - parser.add_argument( - '--dry-run', - action='store_true', - help='Dry run mode, no files saved' - ) - - parser.add_argument( - '--transform', - type=str, - choices=['remove-spaces', 'add-header', 'pad-default-grid', 'replace-puzzle-content', 'remove-padding'], - help='Use predefined transformation functions' - ) - - args = parser.parse_args() - - # Determine input - if args.file: - puzzle_name_or_path = args.file - elif args.puzzle: - puzzle_name_or_path = args.puzzle - else: - parser.error("Must specify --file or --puzzle") - - # Select transformation function - if args.transform == 'remove-spaces': - transform_func = remove_extra_spaces - elif args.transform == 'add-header': - transform_func = add_header_if_missing - elif args.transform == 'pad-default-grid': - transform_func = pad_default_grid - elif args.transform == 'replace-puzzle-content': - transform_func = replace_values - elif args.transform == 'remove-padding': - transform_func = remove_padding - else: - # Default to remove extra spaces - print("Warning: No transformation function specified, using default remove_extra_spaces") - transform_func = remove_extra_spaces - - # Execute cleaning - try: - clean_puzzle_data( - puzzle_name_or_path, - transform_func, - output_path=args.output, - dry_run=args.dry_run - ) - except Exception as e: - print(f"Error: {e}") - return 1 - - return 0 - - -if __name__ == '__main__': - exit(main()) - diff --git a/scripts/gen_mkdocs_nav.py b/scripts/gen_mkdocs_nav.py new file mode 100644 index 00000000..aa61c64f --- /dev/null +++ b/scripts/gen_mkdocs_nav.py @@ -0,0 +1,204 @@ +""" +Generate and inject MkDocs navigation configuration for puzzle documentation. +Automatically replaces content between BEGIN/END markers in mkdocs.yml. + +# Preview the nav configuration without modifying mkdocs.yml +python scripts/gen_mkdocs_nav.py --preview + +# Print the nav configuration to stdout +python scripts/gen_mkdocs_nav.py --stdout + +# Inject the nav configuration into mkdocs.yml (need to confirm) +python scripts/gen_mkdocs_nav.py +""" + +import sys +from pathlib import Path +import argparse +import re +from typing import List + +# ========================================== +# Configuration +# ========================================== +PROJECT_ROOT = Path(__file__).parent.parent +DOCS_PUZZLES_DIR = PROJECT_ROOT / "docs" / "puzzles" +MKDOCS_YML = PROJECT_ROOT / "mkdocs.yml" + +# Navigation markers +BEGIN_MARKER = "# ==== BEGIN NAV CONFIGURATION ====" +END_MARKER = "# ==== END NAV CONFIGURATION ====" + +# ========================================== +# Helper Functions +# ========================================== + +def scan_puzzle_docs(docs_dir: Path) -> List[str]: + """Scan all .md files in puzzles directory and return sorted list of filenames (without extension).""" + if not docs_dir.exists(): + print(f"⚠️ Warning: Directory not found: {docs_dir}", file=sys.stderr) + return [] + + md_files = [f.stem for f in docs_dir.glob("*.md") if f.is_file()] + return sorted(md_files, key=str.lower) + +def format_puzzle_name(filename: str) -> str: + """Convert filename to human-readable puzzle name.""" + # Replace underscores with spaces and capitalize appropriately + name = filename.replace("_", " ").title() + return name + +def generate_nav_yaml(puzzle_names: List[str]) -> str: + """Generate MkDocs nav configuration as YAML string.""" + lines = [] + lines.append("nav:") + lines.append(" - Intro:") + lines.append(" - Intro: index.md") + lines.append(" - Puzzles:") + + for name in puzzle_names: + display_name = name + lines.append(f" - {display_name}: puzzles/{name}.md") + + return "\n".join(lines) + +def inject_nav_to_mkdocs(mkdocs_file: Path, nav_content: str) -> bool: + """ + Inject nav configuration into mkdocs.yml between BEGIN/END markers. + Returns True if successful, False otherwise. + """ + if not mkdocs_file.exists(): + print(f"❌ Error: mkdocs.yml not found at {mkdocs_file}", file=sys.stderr) + return False + + try: + # Read the entire file + with open(mkdocs_file, 'r', encoding='utf-8') as f: + content = f.read() + + # Check if markers exist + if BEGIN_MARKER not in content or END_MARKER not in content: + print(f"❌ Error: Navigation markers not found in {mkdocs_file}", file=sys.stderr) + print(f" Please ensure the following markers exist in mkdocs.yml:") + print(f" {BEGIN_MARKER}") + print(f" ... your nav content ...") + print(f" {END_MARKER}") + return False + + # Build replacement pattern + # Use re.DOTALL to match across multiple lines + pattern = re.compile( + f"({re.escape(BEGIN_MARKER)}\n).*?(\n{re.escape(END_MARKER)})", + re.DOTALL + ) + + # Replacement content (with markers preserved) + replacement = f"\\1{nav_content}\n\\2" + + # Perform replacement + new_content = pattern.sub(replacement, content) + + # Write back to file + with open(mkdocs_file, 'w', encoding='utf-8') as f: + f.write(new_content) + + return True + + except Exception as e: + print(f"❌ Error updating {mkdocs_file}: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + return False + +def preview_nav(puzzle_names: List[str], max_show: int = 10): + """Preview the nav configuration to be injected.""" + nav_yaml = generate_nav_yaml(puzzle_names) + + print("\n" + "="*60) + print("PREVIEW: MkDocs Navigation Configuration") + print("="*60) + print() + + lines = nav_yaml.split('\n') + for i, line in enumerate(lines): + if i < max_show or i >= len(lines) - 3: + print(f" {line}") + elif i == max_show: + print(f" ... ({len(lines) - max_show - 3} more lines)") + + print() + print("="*60) + print(f"Total puzzles: {len(puzzle_names)}") + print("="*60) + +def main(): + parser = argparse.ArgumentParser( + description="Generate and inject MkDocs navigation configuration for puzzles" + ) + parser.add_argument( + "--mkdocs", "-m", + type=Path, + default=MKDOCS_YML, + help=f"Path to mkdocs.yml file (default: {MKDOCS_YML})" + ) + parser.add_argument( + "--docs-dir", + type=Path, + default=DOCS_PUZZLES_DIR, + help=f"Puzzles documentation directory (default: {DOCS_PUZZLES_DIR})" + ) + parser.add_argument( + "--preview", + action="store_true", + help="Preview the nav configuration without modifying mkdocs.yml" + ) + parser.add_argument( + "--stdout", + action="store_true", + help="Print the nav configuration to stdout" + ) + + args = parser.parse_args() + + # Scan puzzle documentation files + puzzle_files = scan_puzzle_docs(args.docs_dir) + + if not puzzle_files: + print(f"❌ Error: No puzzle documentation files found in {args.docs_dir}", file=sys.stderr) + sys.exit(1) + + print(f"✅ Found {len(puzzle_files)} puzzle documentation files") + + # Generate nav configuration + nav_yaml = generate_nav_yaml(puzzle_files) + + # Handle different output modes + if args.stdout: + print(nav_yaml) + return + + if args.preview: + preview_nav(puzzle_files) + print("\n💡 Run without --preview to inject into mkdocs.yml") + return + + # Inject into mkdocs.yml + print(f"\n📝 Injecting navigation into: {args.mkdocs}") + preview_nav(puzzle_files, max_show=5) + + confirm = input("\nType 'YES' to confirm injection: ").strip() + if confirm != "YES": + print("❌ Injection aborted by user.") + sys.exit(1) + + success = inject_nav_to_mkdocs(args.mkdocs, nav_yaml) + + if success: + print(f"\n✅ Successfully injected navigation into {args.mkdocs}") + print(f" Total puzzles: {len(puzzle_files)}") + else: + print(f"\n❌ Failed to inject navigation into {args.mkdocs}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/quick_start.py b/scripts/quick_start.py index b7dc0306..ec1209f3 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -4,11 +4,11 @@ # Raw input data start_time = time.time() problem_str = """ -4 4\n- 3 5 -\n 4 6 - -\n - - 7 4\n 5 - - 1 +10 10\n- - - - - 3sx - - - -\n- - - o - - - 3so - -\n- x - - - - - - - -\n- - - - 2eo - - - o -\n1nx - - - - - 1wo - - -\n- - - 1so - - - - - 2nx\n- x - - - 1sx - - - -\n- - - - - - - - o -\n- - 3nx - - - x - - -\n- - - - 2ex - - - - - """ # Solve -res = puzzlekit.solve(problem_str, puzzle_type="kakkuru") +res = puzzlekit.solve(problem_str, puzzle_type="castle_wall") # Print solution grid print(res.solution_data.get('solution_grid', [])) diff --git a/src/puzzlekit/__init__.py b/src/puzzlekit/__init__.py index c86ef518..9535eba3 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -72,4 +72,4 @@ def solver(puzzle_type: str, data: Dict[str, Any] = None, **kwargs) -> Any: return SolverClass(**init_params) __all__ = ["solve", "solver"] -__version__ = '0.3.0' \ No newline at end of file +__version__ = '0.3.1' \ No newline at end of file diff --git a/src/puzzlekit/core/solver.py b/src/puzzlekit/core/solver.py index b9bfe434..0c699ee3 100644 --- a/src/puzzlekit/core/solver.py +++ b/src/puzzlekit/core/solver.py @@ -1,8 +1,9 @@ -from typing import Optional, List, Any, Callable +from typing import Optional, List, Any, Callable, Dict from abc import ABC, abstractmethod from ortools.sat.python import cp_model as cp +from ortools.linear_solver import pywraplp from puzzlekit.core.grid import Grid -from puzzlekit.utils.ortools_utils import ortools_cpsat_analytics +from puzzlekit.utils.ortools_utils import ortools_cpsat_analytics, ortools_mip_analytics from puzzlekit.utils.name_utils import infer_puzzle_type from puzzlekit.core.result import PuzzleResult import re @@ -151,61 +152,91 @@ def solve(self) -> dict: solution_data = solution_dict ) - - # def solve_and_show(self, show: bool = False, save_path: Optional[str] = None, auto_close_sec: float = 0.5, **kwargs): - # """ - # Solve and show func - # """ +class IterativePuzzleSolver(PuzzleSolver, ABC): + """ + Base class for puzzles solved using Iterative MIP (Cutting Planes). + """ + def _add_constr(self): + self.solver = pywraplp.Solver.CreateSolver('SCIP') + self._setup_initial_model() + + @abstractmethod + def _setup_initial_model(self): + """ + Set up initial model constraints. + A relaxation model which neglects some constraints. + e.g., Hitori solver neglects the connectivity constraint. + """ + pass + + @abstractmethod + def _check_and_add_cuts(self, current_solution_values: Dict) -> bool: + """ + Check if the current solution satisfies certain Lazy Constraints. + If not, add new linear constraints (Cuts, Cutting Planes) to self.solver and return False. + e.g., Hitori solver checks if the current solution satisfies the connectivity constraint. + If satisfied, return True. + """ + pass - # result = self.solve() - # solution_status = result.get('status') - # context_data = vars(self).copy() - - # for k, v in context_data.items(): - # if isinstance(v, Grid): - # context_data[k] = v.matrix - # if hasattr(v, 'matrix'): - # context_data[k] = v.matrix + # Override solve method, encapsulate the common iterative logic. + def solve(self) -> dict: + + tic = time.perf_counter() + # 1. Build the initial model via child class' _add_constr method. + # Log the build time. + self._add_constr() + toc = time.perf_counter() + build_time = toc - tic + + start_time = time.perf_counter() + max_iterations = 10000 + iteration = 0 + status = pywraplp.Solver.NOT_SOLVED + final_status_str = "Unknown" + + # 2. Iterative Log-Cut Loop + while iteration < max_iterations: + iteration += 1 + status = self.solver.Solve() + + # If the basic model is infeasible, exit directly. + if status not in [pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE]: + final_status_str = "Infeasible" if status == pywraplp.Solver.INFEASIBLE else "Error" + break - # # print(f"[{self.puzzle_type}] Status: {solution_status}, Time: {result.get('build_time', 0):.4f}s") + # Check if it's necessary to add new cuts. + # If _check_and_add_cuts returns True, it means the current solution does not satisfy the connectivity constraint, + # and new constraints have been added. We need to continue solving. + cuts_added = self._check_and_add_cuts() + + if not cuts_added: + # No new cuts added -> All constraints satisfied -> Found final solution. + final_status_str = "Optimal" if status == pywraplp.Solver.OPTIMAL else "Feasible" + break + + else: + final_status_str = "Not Solved (Max Iterations)" - # if solution_status not in ['Optimal', 'Feasible']: - # print("No visualizable solution found.") - # if show: - # try: - # visualize( - # puzzle_type = self.puzzle_type, - # solution_grid = None, - # puzzle_data = context_data, - # title = f"{self.puzzle_type.replace('_', ' ').title()} puzzle INFEASIBLE.", - # show=show, - # save_path=save_path - # ) - # except NotImplementedError: - # print(f"Visualizer for {self.puzzle_type} is not implemented yet.") - # except Exception as e: - # print(f"Visualization failed: {e}") - # return result - # else: - # solution_grid = result.get('grid') + end_time = time.perf_counter() + + # 3. Collect results. + solution_dict = ortools_mip_analytics(self.solver) + solution_dict.update({ + 'build_time': build_time, + 'solve_time': end_time - start_time, + 'status': final_status_str, + 'iterations': iteration + }) + + solution_grid = Grid.empty() + if final_status_str in ["Optimal", "Feasible"]: + solution_grid = self.get_solution() - # # 3. vis factory - # if show: - # try: - # visualize( - # puzzle_type = self.puzzle_type, - # solution_grid = solution_grid, - # puzzle_data = context_data, - # title = f"{self.puzzle_type.replace('_', ' ').title()} Result", - # show=show, - # save_path=save_path, - # auto_close_sec=auto_close_sec - # ) - # except NotImplementedError: - # print(f"Visualizer for {self.puzzle_type} is not implemented yet.") - # except Exception as e: - # print(f"Visualization failed: {e}") - # else: - # print(solution_grid) - # return result - \ No newline at end of file + solution_dict['solution_grid'] = solution_grid + + return PuzzleResult( + puzzle_type=self.puzzle_type, + puzzle_data=vars(self).copy(), + solution_data=solution_dict + ) \ No newline at end of file diff --git a/src/puzzlekit/parsers/registry.py b/src/puzzlekit/parsers/registry.py index 61cf025a..d19e8c8f 100644 --- a/src/puzzlekit/parsers/registry.py +++ b/src/puzzlekit/parsers/registry.py @@ -110,8 +110,14 @@ "mathrax": standard_grid_parser_mathrax, "ken_ken": standard_region_grid_parser, "juosan": standard_region_grid_parser, - "kakkuru": standard_grid_parser - + "kakkuru": standard_grid_parser, + "castle_wall": standard_grid_parser, + "mejilink": standard_grid_parser, + "mid_loop": standard_grid_parser, + "kurotto": standard_grid_parser, + "nurimisaki": standard_grid_parser, + "aqre": standard_region_grid_parser, + "canal_view": standard_grid_parser, } diff --git a/src/puzzlekit/solvers/__init__.py b/src/puzzlekit/solvers/__init__.py index f2dc1506..0f74b31a 100644 --- a/src/puzzlekit/solvers/__init__.py +++ b/src/puzzlekit/solvers/__init__.py @@ -91,6 +91,12 @@ from .ken_ken import KenKenSolver from .juosan import JuosanSolver from .kakkuru import KakkuruSolver + from .castle_wall import CastleWallSolver + from .mejilink import MejilinkSolver + from .kurotto import KurottoSolver + from .nurimisaki import NurimisakiSolver + from .aqre import AqreSolver + from .canal_view import CanalViewSolver # ========================================== # Core: Mapping of puzzle type to solver class # ========================================== @@ -189,6 +195,13 @@ "ken_ken": ("ken_ken", "KenKenSolver"), "juosan": ("juosan", "JuosanSolver"), "kakkuru": ("kakkuru", "KakkuruSolver"), + "castle_wall": ("castle_wall", "CastleWallSolver"), + "mejilink": ("mejilink", "MejilinkSolver"), + "mid_loop": ("mid_loop", "MidLoopSolver"), + "kurotto": ("kurotto", "KurottoSolver"), + "nurimisaki": ("nurimisaki", "NurimisakiSolver"), + "aqre": ("aqre", "AqreSolver"), + "canal_view": ("canal_view", "CanalViewSolver"), } # ========================================== diff --git a/src/puzzlekit/solvers/aqre.py b/src/puzzlekit/solvers/aqre.py new file mode 100644 index 00000000..e4c1c02a --- /dev/null +++ b/src/puzzlekit/solvers/aqre.py @@ -0,0 +1,162 @@ +from typing import Any, List, Dict, Set, Tuple +from collections import defaultdict +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.regionsgrid import RegionsGrid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class AqreSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "aqre", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?aqre", + "external_links": [ + + ], + "input_desc": "TBD", + "output_desc": "TBD", # Reuse shade output structure + "input_example": """ + 6 6\n11 - - - - -\n- - - - - -\n- 1 - - - -\n- - - 1 - -\n- - - - - -\n- - - - - -\n1 1 1 1 1 1\n1 1 1 1 1 1\n1 2 2 1 1 1\n1 2 1 3 3 1\n1 1 1 1 3 1\n1 1 1 1 1 1 + """, + "output_example": """ + 6 6\n- - x - - -\n- x x x - -\nx x - x x x\n- - x x - -\n- - x - - -\n- - x - - - + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]], region_grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + # The clues grid (values represent numbers inside regions, often only one cell per region has a number) + self.grid: Grid[str] = Grid(grid) + # The region map (defines boundaries) + self.region_grid: RegionsGrid[str] = RegionsGrid(region_grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_grid_dims(self.num_rows, self.num_cols, self.region_grid.matrix) + # Input hints are numbers or '-' + # Note: 11 is often used in puzzle formats to denote '?' or specific encodings, + # but standard Aqre inputs are digits. Assuming non-digit is empty/placeholder. + self._check_allowed_chars(self.grid.matrix, {'-', 'x', '.'}, validator = lambda x: x.isdigit() and int(x) >= 0) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.is_black = {} + + # 1. Decision Variables + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + # True(1) = Black/Shaded, False(0) = White/Unshaded + self.is_black[pos] = self.model.NewBoolVar(f"black_{pos}") + + # 2. Region Constraints + # A number in a region indicates how many cells in this region must be blackened. + # In regions without a number any amount of cells may be blackened. + + # Find if a region has a number constraint. + # The self.grid usually contains the number at a specific position, but it applies to the whole region + # defined in self.region_grid. + + region_clues = {} # Map region_id -> number constraint + + for r in range(self.num_rows): + for c in range(self.num_cols): + val = self.grid.value(r, c) + if val.isdigit(): + region_id = self.region_grid.value(r, c) + # Consistency check: usually 1 number per region, or all numbers in region are same + region_clues[region_id] = int(val) + + for region_id, cells in self.region_grid.regions.items(): + vars_in_region = [self.is_black[pos] for pos in cells] + + if region_id in region_clues: + required_count = region_clues[region_id] + self.model.Add(sum(vars_in_region) == required_count) + + # 3. Stripe Constraints (The "Snake" logic) + # Stripes of adjacent cells of the same color may not span across more than 3 cells. + # Meaning: No 4 consecutive Black, No 4 consecutive White. + + # Horizontal check + if self.num_cols >= 4: + for r in range(self.num_rows): + for c in range(self.num_cols - 3): + # Sum of 4 cells + segment = [ + self.is_black[Position(r, c)], + self.is_black[Position(r, c+1)], + self.is_black[Position(r, c+2)], + self.is_black[Position(r, c+3)] + ] + s = sum(segment) + # Cannot be 0 (all white) + self.model.Add(s >= 1) + # Cannot be 4 (all black) + self.model.Add(s <= 3) + + # Vertical check + if self.num_rows >= 4: + for c in range(self.num_cols): + for r in range(self.num_rows - 3): + segment = [ + self.is_black[Position(r, c)], + self.is_black[Position(r + 1, c)], + self.is_black[Position(r + 2, c)], + self.is_black[Position(r + 3, c)] + ] + s = sum(segment) + self.model.Add(s >= 1) + self.model.Add(s <= 3) + + # 4. Connectivity Constraint + # All black cells must form a single orthogonally-connected area. + self._add_connectivity_constr() + + def _add_connectivity_constr(self): + """ + Uses the provided utility to enforce that all active (black) cells form a tree. + """ + adjacency_map = {} + + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + neighbors = self.grid.get_neighbors(pos, "orthogonal") + adjacency_map[pos] = list(neighbors) + + # Check for empty grid case? Aqre usually has black cells. + # If the solution is all white, connectivity implies 0 nodes? + # Usually Aqre requires non-empty set of black cells, implicitly handled by region clues. + + # We assume there is at least one black cell (add implication if strictly necessary, + # but usually regions dictate >0 black cells). + + add_connected_subgraph_constraint( + self.model, + self.is_black, # BoolVars for nodes to connect + adjacency_map, + prefix="aqre_conn" + ) + + def get_solution(self): + sol_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + if self.solver.Value(self.is_black[pos]) == 1: + sol_grid[i][j] = "x" # Black + else: + sol_grid[i][j] = "-" # White + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/solvers/canal_view.py b/src/puzzlekit/solvers/canal_view.py new file mode 100644 index 00000000..75258531 --- /dev/null +++ b/src/puzzlekit/solvers/canal_view.py @@ -0,0 +1,164 @@ +from typing import Any, List, Dict, Tuple +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class CanalViewSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "CanalView", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?canal", + "external_links": [ + + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 6 6\n- 4 - - - -\n- 1 6 - 3 -\n3 - - - 3 -\n- - - 5 3 -\n5 - - - - -\n- 2 - - - - + """, + "output_example": """ + 6 6\n- - x x x x\n- - - x - x\n- x x x - -\n- - x - - x\n- x x x x x\n- - x - x - + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + # Allow '-', '?', or positive integers + self._check_allowed_chars( + self.grid.matrix, + {'-', 'x'}, + validator=lambda x: x.isdigit() and int(x) >= 0 + ) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.is_black = {} + + # 1. Define Variables + # True(1) = Black (Shaded), False(0) = White (Unshaded) + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + self.is_black[pos] = self.model.NewBoolVar(f"black_{pos}") + + # 2. Clue Constraints & Visibility logic + for r in range(self.num_rows): + for c in range(self.num_cols): + val_str = self.grid.value(r, c) + pos = Position(r, c) + + # Rule: Cells with numbers must not be blackened. + if val_str.isdigit(): + clue_val = int(val_str) + self.model.Add(self.is_black[pos] == 0) + + # Rule: A number indicates how many black cells can be "seen" + # in 4 directions until the first white cell. + + visible_vars = [] + directions = [(0, 1), (0, -1), (1, 0), (-1, 0)] + + for dr, dc in directions: + prev_chain_var = None + + # Walk until edge + k = 1 + while True: + nr, nc = r + k*dr, c + k*dc + if not (0 <= nr < self.num_rows and 0 <= nc < self.num_cols): + break + + neighbor_pos = Position(nr, nc) + neighbor_is_black = self.is_black[neighbor_pos] + + # Create a variable 's_k' which is true IFF + # the k-th cell is black AND the (k-1)-th chain was valid. + current_chain_var = self.model.NewBoolVar(f"chain_{pos}_{dr}_{dc}_{k}") + + if prev_chain_var is None: + # First cell in direction: chain is valid if this cell is black + # Simply alias it (optimization) or assign equality + self.model.Add(current_chain_var == neighbor_is_black) + else: + # Subsequent cells: chain valid if Prev AND Curr are black + # Use MinEquality which acts as logical AND for BoolVars (0/1) + self.model.AddMinEquality(current_chain_var, [prev_chain_var, neighbor_is_black]) + + visible_vars.append(current_chain_var) + prev_chain_var = current_chain_var + + # Optimization: If we encounter *another* number clue on the path, + # we know for a fact it is White (0). + # So the chain definitively breaks here. We don't need to add variables + # beyond this point for this specific direction. + if self.grid.value(nr, nc).isdigit(): + break + + k += 1 + + self.model.Add(sum(visible_vars) == clue_val) + + # 3. 2x2 Constraints + # The black cells must not cover an area of 2x2 cells. + for r in range(self.num_rows - 1): + for c in range(self.num_cols - 1): + p1 = Position(r, c) + p2 = Position(r, c + 1) + p3 = Position(r + 1, c) + p4 = Position(r + 1, c + 1) + + # Sum of black cells in 2x2 area must be <= 3 (at least one is white) + self.model.Add( + self.is_black[p1] + self.is_black[p2] + + self.is_black[p3] + self.is_black[p4] <= 3 + ) + + # 4. Connectivity Constraint + # All black cells must form a single orthogonally-connected area. + self._add_connectivity_constr() + + def _add_connectivity_constr(self): + adjacency_map = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + neighbors = self.grid.get_neighbors(pos, "orthogonal") + adjacency_map[pos] = list(neighbors) + + # Assumes the puzzle requires at least one black cell. + # Canal view puzzles usually have substantial black areas. + add_connected_subgraph_constraint( + self.model, + self.is_black, + adjacency_map, + prefix="canal_conn" + ) + + def get_solution(self): + sol_grid = [['-' for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + val = self.solver.Value(self.is_black[pos]) + + # Keep original numbers as clues + if self.grid.value(r, c).isdigit(): + continue + else: + sol_grid[r][c] = "x" if val == 1 else "-" + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/solvers/castle_wall.py b/src/puzzlekit/solvers/castle_wall.py new file mode 100644 index 00000000..279b421b --- /dev/null +++ b/src/puzzlekit/solvers/castle_wall.py @@ -0,0 +1,235 @@ +from typing import Any, List, Dict, Tuple, Optional +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_circuit_constraint_from_undirected +from ortools.sat.python import cp_model as cp +from typeguard import typechecked +import re + +class CastleWallSolver(PuzzleSolver): + metadata: Dict[str, Any] = { + "name": "castle_wall", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?castle", + "external_links": [ + {"Play at puzz.link": "https://puzz.link/p?castle/12/12/224j234e122125g124f131r222b231e121h131b144h112e241b212r145f114g132142e244j214"}, + {"janko": "https://www.janko.at/Raetsel/Castle-Wall/001.a.htm" } + ], + "input_desc": """ + TBD + """, + "output_desc": "TBD", + "input_example": "10 10\n- - 1wx - - - - - - -\n- - - - - - 4so - 2wx -\n- - - - 2so - - - - -\n- 2sx - - - - - 4wo - -\n- - - - - - - - - -\n- - - - - - - - - -\n- - 1wx - - - - - 0no -\n- - - - - 1eo - - - -\n- 0eo - 3no - - - - - -\n- - - - - - - 3nx - -", + "output_example": "10 10\nse sw - se ew ew ew sw - -\nns ne ew nw - - - ns - -\nne ew sw - - - - ne ew sw\n- - ne ew ew ew sw - - ns\n- se ew ew sw se nw - - ns\n- ne ew sw ns ns - - - ns\nse sw - ns ne nw se sw - ns\nns ne ew nw - - ns ne sw ns\nns - - - - - ns - ns ns\nne ew ew ew ew ew nw - ne nw" + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.clues = self._parse_clues() + self.validate_input() + + def _parse_clues(self) -> Dict[Position, Tuple[Optional[int], Optional[str], Optional[str]]]: + parsed = {} + pattern = re.compile(r"^(\d+)([nsew])([xo]?)$") + + for r in range(self.num_rows): + for c in range(self.num_cols): + val = self.grid.value(r, c) + + if val in {"x", "o"}: + parsed[Position(r, c)] = (None, None, val) + continue + + if val == "-" or val == ".": + continue + + m = pattern.match(val) + if m: + count = int(m.group(1)) + direction = m.group(2) + color = m.group(3) or None + parsed[Position(r, c)] = (count, direction, color) + + return parsed + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.arc_vars = {} + self._add_circuit_vars() + self._add_inside_outside_vars() + self._add_clue_constraints() + + def _add_circuit_vars(self): + all_nodes = [] + for r in range(self.num_rows): + for c in range(self.num_cols): + p = Position(r, c) + all_nodes.append(p) + if c < self.num_cols - 1: + self.arc_vars[(p, Position(r, c + 1))] = self.model.NewBoolVar(f"H_{r}_{c}") + if r < self.num_rows - 1: + self.arc_vars[(p, Position(r + 1, c))] = self.model.NewBoolVar(f"V_{r}_{c}") + + self.node_active = add_circuit_constraint_from_undirected( + self.model, all_nodes, self.arc_vars + ) + + def _get_edge(self, u, v): + if (u, v) in self.arc_vars: return self.arc_vars[(u, v)] + if (v, u) in self.arc_vars: return self.arc_vars[(v, u)] + return None + + # -------------------------- Inside/Outside Face Model -------------------------- + + def _add_inside_outside_vars(self): + R, C = self.num_rows, self.num_cols + self.face_rows = max(0, 2 * R - 2) + self.face_cols = max(0, 2 * C - 2) + + # no face exists if grid too small + if self.face_rows == 0 or self.face_cols == 0: + self.faces = [] + return + + self.faces = [ + [self.model.NewBoolVar(f"inside_{i}_{j}") for j in range(self.face_cols)] + for i in range(self.face_rows) + ] + + # segment maps + self.h_seg = {} # horizontal segment: (row, col) -> arc_var + self.v_seg = {} # vertical segment: (row, col) -> arc_var + + for (u, v), var in self.arc_vars.items(): + if u.r == v.r: # horizontal edge + r = u.r + c = min(u.c, v.c) + row = 2 * r + col = 2 * c + self.h_seg[(row, col)] = var + self.h_seg[(row, col + 1)] = var + else: # vertical edge + c = u.c + r = min(u.r, v.r) + row = 2 * r + col = 2 * c + self.v_seg[(row, col)] = var + self.v_seg[(row + 1, col)] = var + + # adjacency constraints + for i in range(self.face_rows): + for j in range(self.face_cols): + if j + 1 < self.face_cols: + seg = self.v_seg.get((i, j + 1)) + self._link_faces(self.faces[i][j], self.faces[i][j + 1], seg) + + if i + 1 < self.face_rows: + seg = self.h_seg.get((i + 1, j)) + self._link_faces(self.faces[i][j], self.faces[i + 1][j], seg) + + # boundary constraints: outside is 0 + for j in range(self.face_cols): + # top boundary (row=0) + self._boundary_face(self.faces[0][j], self.h_seg.get((0, j))) + # bottom boundary (row=face_rows) + self._boundary_face(self.faces[self.face_rows - 1][j], self.h_seg.get((self.face_rows, j))) + + for i in range(self.face_rows): + # left boundary (col=0) + self._boundary_face(self.faces[i][0], self.v_seg.get((i, 0))) + # right boundary (col=face_cols) + self._boundary_face(self.faces[i][self.face_cols - 1], self.v_seg.get((i, self.face_cols))) + + def _link_faces(self, a, b, seg): + if seg is None: + self.model.Add(a == b) + else: + self.model.Add(a + b == 1).OnlyEnforceIf(seg) + self.model.Add(a == b).OnlyEnforceIf(seg.Not()) + + def _boundary_face(self, face, seg): + if seg is None: + self.model.Add(face == 0) + else: + self.model.Add(face == 1).OnlyEnforceIf(seg) + self.model.Add(face == 0).OnlyEnforceIf(seg.Not()) + + def _face_index_for_cell(self, r, c): + # pick a nearby face square + if self.face_rows == 0 or self.face_cols == 0: + return None + fi = 2 * r + fj = 2 * c + if fi >= self.face_rows: fi = self.face_rows - 1 + if fj >= self.face_cols: fj = self.face_cols - 1 + return fi, fj + + # -------------------------- Clue Constraints -------------------------- + + def _add_clue_constraints(self): + for pos, (count, direction, color) in self.clues.items(): + # no clue cell can be on the loop + self.model.Add(self.node_active[pos] == 0) + + # direction count constraint + if count is not None and direction is not None: + segments = [] + r, c = pos.r, pos.c + if direction == 'n': + for k in range(r): + e = self._get_edge(Position(k, c), Position(k + 1, c)) + if e is not None: segments.append(e) + elif direction == 's': + for k in range(r, self.num_rows - 1): + e = self._get_edge(Position(k, c), Position(k + 1, c)) + if e is not None: segments.append(e) + elif direction == 'w': + for k in range(c): + e = self._get_edge(Position(r, k), Position(r, k + 1)) + if e is not None: segments.append(e) + elif direction == 'e': + for k in range(c, self.num_cols - 1): + e = self._get_edge(Position(r, k), Position(r, k + 1)) + if e is not None: segments.append(e) + self.model.Add(sum(segments) == count) + + # inside / outside constraint + if color is not None and self.face_rows > 0 and self.face_cols > 0: + fi, fj = self._face_index_for_cell(pos.r, pos.c) + target = 1 if color == 'o' else 0 + self.model.Add(self.faces[fi][fj] == target) + + def get_solution(self): + sol_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + if self.solver.Value(self.node_active[pos]) == 0: + continue + + chs = [] + edge_up = self._get_edge(pos, pos.up) + if edge_up is not None and self.solver.Value(edge_up): chs.append("n") + + edge_down = self._get_edge(pos, pos.down) + if edge_down is not None and self.solver.Value(edge_down): chs.append("s") + + edge_left = self._get_edge(pos, pos.left) + if edge_left is not None and self.solver.Value(edge_left): chs.append("w") + + edge_right = self._get_edge(pos, pos.right) + if edge_right is not None and self.solver.Value(edge_right): chs.append("e") + if chs: + sol_grid[r][c] = "".join(sorted(chs)) # e.g., "ns", "es" + return Grid(sol_grid) diff --git a/src/puzzlekit/solvers/hitori.py b/src/puzzlekit/solvers/hitori.py index ef1988af..a553ae49 100644 --- a/src/puzzlekit/solvers/hitori.py +++ b/src/puzzlekit/solvers/hitori.py @@ -1,11 +1,237 @@ -from typing import Any, List, Dict -from puzzlekit.core.solver import PuzzleSolver +# from typing import Any, List, Dict +# from puzzlekit.core.solver import PuzzleSolver, IterativePuzzleSolver +# from puzzlekit.core.grid import Grid +# from puzzlekit.core.position import Position +# from ortools.linear_solver import pywraplp +# from collections import deque +# from itertools import chain +# from typeguard import typechecked +# from puzzlekit.utils.ortools_utils import ortools_mip_analytics + +# class HitoriSolver(PuzzleSolver): + +# @typechecked +# def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): +# self.num_rows: int = num_rows +# self.num_cols: int = num_cols +# self.grid: Grid[str] = Grid(grid) +# self.validate_input() +# self.solver = None +# self.is_white = {} + +# def validate_input(self): +# self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) +# self._check_allowed_chars( +# self.grid.matrix, +# {'-'}, +# validator=lambda x: x.isdigit() and int(x) >= 0 +# ) + +# def _add_constr(self): +# self.solver = pywraplp.Solver.CreateSolver('SCIP') +# if not self.solver: +# raise RuntimeError("Unable to create solver.") + +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# pos = Position(i, j) +# var_name = f"white_{pos}" +# self.is_white[pos] = self.solver.BoolVar(var_name) + +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# curr = Position(i, j) +# for neighbor in self.grid.get_neighbors(curr, "orthogonal"): +# self.solver.Add( +# self.is_white[curr] + self.is_white[neighbor] >= 1 +# ) + +# for r in range(self.num_rows): +# val_map = {} +# for c in range(self.num_cols): +# val = self.grid.value(r, c) +# val_map.setdefault(val, []).append(Position(r, c)) + +# for val, positions in val_map.items(): +# if len(positions) > 1: +# self.solver.Add( +# sum(self.is_white[pos] for pos in positions) <= 1 +# ) + +# for c in range(self.num_cols): +# val_map = {} +# for r in range(self.num_rows): +# val = self.grid.value(r, c) +# val_map.setdefault(val, []).append(Position(r, c)) + +# for val, positions in val_map.items(): +# if len(positions) > 1: +# self.solver.Add( +# sum(self.is_white[pos] for pos in positions) <= 1 +# ) + +# def _check_connectivity_and_add_cuts(self, solution_values): +# rows, cols = self.num_rows, self.num_cols +# grid_state = [[0] * cols for _ in range(rows)] +# for i in range(rows): +# for j in range(cols): +# pos = Position(i, j) +# grid_state[i][j] = solution_values[pos] + +# visited = set() +# directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] +# white_cells = [] + +# for i in range(rows): +# for j in range(cols): +# if grid_state[i][j] == 1 and (i, j) not in visited: +# queue = deque([(i, j)]) +# component = [] +# boundary_black_cells = set() + +# while queue: +# x, y = queue.popleft() +# if (x, y) in visited: +# continue + +# visited.add((x, y)) +# component.append((x, y)) + +# # 检查相邻单元格 +# for dx, dy in directions: +# nx, ny = x + dx, y + dy +# if 0 <= nx < rows and 0 <= ny < cols: +# if grid_state[nx][ny] == 1 and (nx, ny) not in visited: +# queue.append((nx, ny)) +# elif grid_state[nx][ny] == 0: +# boundary_black_cells.add((nx, ny)) + +# white_cells.append({ +# 'component': component, +# 'boundary_black_cells': list(boundary_black_cells) +# }) + +# if len(white_cells) <= 1: +# return True + +# for comp_data in white_cells: +# boundary = comp_data['boundary_black_cells'] +# if not boundary: +# continue +# constraint = self.solver.Constraint(1, len(boundary)) +# for (x, y) in boundary: +# pos = Position(x, y) +# constraint.SetCoefficient(self.is_white[pos], 1) + +# return False + +# def get_solution(self): +# sol_grid = [["" for _ in range(self.num_cols)] for _ in range(self.num_rows)] +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# pos = Position(i, j) +# if self.is_white[pos].solution_value() == 1: +# sol_grid[i][j] = "-" +# else: +# sol_grid[i][j] = "x" +# return Grid(sol_grid) + +# def solve(self) -> dict: +# """求解Hitori问题""" +# from puzzlekit.core.result import PuzzleResult +# import time + +# solution_dict = {} + +# # 1. 构建模型 +# tic = time.perf_counter() +# self._add_constr() +# toc = time.perf_counter() +# build_time = toc - tic + +# # 2. 设置目标函数:最小化黑色单元格数量 +# objective = self.solver.Objective() +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# pos = Position(i, j) +# # 最小化黑色单元格数量 = 最大化白色单元格数量的负数 +# # 等价于最小化 (1 - is_white) +# objective.SetCoefficient(self.is_white[pos], -1) +# objective.SetMinimization() + +# # 3. 迭代求解,添加连通性割平面 +# max_iterations = 10000 +# iteration = 0 +# is_connected = False +# status = "Not Solved" +# start_time = time.perf_counter() + +# while iteration < max_iterations and not is_connected: +# # 求解当前模型 +# status = self.solver.Solve() + +# if status != pywraplp.Solver.OPTIMAL and status != pywraplp.Solver.FEASIBLE: +# # 无可行解 +# break + +# # 获取当前解 +# solution_values = {} +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# pos = Position(i, j) +# solution_values[pos] = 1 if self.is_white[pos].solution_value() >= 0.5 else 0 + +# # 检查连通性并添加割平面 +# is_connected = self._check_connectivity_and_add_cuts(solution_values) + +# if is_connected: +# break + +# iteration += 1 + +# end_time = time.perf_counter() +# solve_time = end_time - start_time + +# # 4. 收集统计信息 +# solution_dict = ortools_mip_analytics(self.solver, self.is_white) +# solution_dict['build_time'] = build_time +# solution_dict['solve_time'] = solve_time +# solution_dict['num_cuts_added'] = iteration + +# solution_status = { +# pywraplp.Solver.OPTIMAL: "Optimal", +# pywraplp.Solver.FEASIBLE: "Feasible", +# pywraplp.Solver.INFEASIBLE: "Infeasible", +# pywraplp.Solver.ABNORMAL: "Abnormal", +# pywraplp.Solver.NOT_SOLVED: "Not Solved", +# pywraplp.Solver.MODEL_INVALID: "Invalid Model", +# } + +# status = solution_status.get(status, "Unknown") + +# if status in ["Optimal", "Feasible"]: +# solution_grid = self.get_solution() +# else: +# solution_grid = Grid.empty() + +# solution_dict['solution_grid'] = solution_grid + +# return PuzzleResult( +# puzzle_type=self.puzzle_type, +# puzzle_data=vars(self).copy(), +# solution_data=solution_dict +# ) + +# src/puzzlekit/solvers/hitori.py + +from typing import List, Dict, Any +from puzzlekit.core.solver import IterativePuzzleSolver from puzzlekit.core.grid import Grid from puzzlekit.core.position import Position -from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint -from ortools.sat.python import cp_model as cp +from puzzlekit.utils.ortools_utils import add_connectivity_cut_node_based, ortools_mip_analytics from typeguard import typechecked -class HitoriSolver(PuzzleSolver): + +class HitoriSolver(IterativePuzzleSolver): metadata : Dict[str, Any] = { "name": "hitori", "aliases": [], @@ -36,97 +262,87 @@ class HitoriSolver(PuzzleSolver): @typechecked def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): self.num_rows: int = num_rows - self.num_cols: int = num_cols + self.num_cols: int = num_cols self.grid: Grid[str] = Grid(grid) self.validate_input() + self.is_white: Dict[Position, Any] = {} def validate_input(self): self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) - self._check_allowed_chars(self.grid.matrix, {'-'}, validator = lambda x: x.isdigit() and int(x) >= 0) - - def _add_constr(self): - self.model = cp.CpModel() - self.solver = cp.CpSolver() - self.is_white = {} - # BoolVar: True if white (kept), False if black (removed) - - # ========== 1. Basic variables and black white constr ========= + self._check_allowed_chars(self.grid.matrix, {'-'}, validator=lambda x: x.isdigit() and int(x) >= 0) + + def _setup_initial_model(self): + # 1. create variables for i in range(self.num_rows): for j in range(self.num_cols): pos = Position(i, j) - self.is_white[pos] = self.model.NewBoolVar(f"white_{pos}") - - # 2. No two black squares adjacent - # = If a cell is black (false) -> its neighbor must be white (true) - # = sum(neighbors) < 2 (If both black, sum=0 < 1 is False -> Contradiction) - # Logic: NOT (Black(curr) AND Black(neighbor)) - # = NOT ( !White(curr) AND !White(neighbor) ) - # = White(curr) OR White(neighbor) - # = White(curr) + White(neighbor) >= 1 + self.is_white[pos] = self.solver.BoolVar(f"white_{pos}") + + # 2. Shaded cells cannot be horizontally or vertically adjacent. for i in range(self.num_rows): for j in range(self.num_cols): curr = Position(i, j) for neighbor in self.grid.get_neighbors(curr, "orthogonal"): - self.model.Add(self.is_white[curr] + self.is_white[neighbor] >= 1) + self.solver.Add(self.is_white[curr] + self.is_white[neighbor] >= 1) + + # 3. A row or column may not contain two unshaded cells with identical numbers. + self._add_unique_number_constraints() - # 3. No duplicate numbers in white cells (Row & Col) + # 4. (dummy) objective values min shaded cells + objective = self.solver.Objective() + for var in self.is_white.values(): + objective.SetCoefficient(var, 1) # Maximize sum(white) + objective.SetMaximization() + + def _add_unique_number_constraints(self): + # Helper to avoid cluttering setup_initial_model + # Row constraints for r in range(self.num_rows): - self._add_unique_constraint( - list(self.grid.value(r, c) for c in range(self.num_cols)), - [Position(r, c) for c in range(self.num_cols)] - ) - + val_map = {} + for c in range(self.num_cols): + val = self.grid.value(r, c) + val_map.setdefault(val, []).append(Position(r, c)) + for val, positions in val_map.items(): + if len(positions) > 1: + self.solver.Add(sum(self.is_white[pos] for pos in positions) <= 1) + + # Column constraints for c in range(self.num_cols): - self._add_unique_constraint( - list(self.grid.value(r, c) for r in range(self.num_rows)), - [Position(r, c) for r in range(self.num_rows)] - ) + val_map = {} + for r in range(self.num_rows): + val = self.grid.value(r, c) + val_map.setdefault(val, []).append(Position(r, c)) + for val, positions in val_map.items(): + if len(positions) > 1: + self.solver.Add(sum(self.is_white[pos] for pos in positions) <= 1) - # 4. Single Connected Component (Decoupled!) - self._add_connectivity_constr() - - def _add_unique_constraint(self, values: list[str], positions: list[Position]): - from collections import defaultdict - val_map = defaultdict(list) - for idx, val in enumerate(values): - val_map[val].append(idx) - - for val, indices in val_map.items(): - if len(indices) > 1: - # If number '5' appears multiple times, at most one of them can be white. - vars_to_sum = [self.is_white[positions[idx]] for idx in indices] - self.model.Add(sum(vars_to_sum) <= 1) - - def _add_connectivity_constr(self): + def _check_and_add_cuts(self) -> bool: """ - Prepares data for the generic connectivity constraint. """ - adjacency_map = {} - - # Build the graph (Adjacency List) - # Since grid is static, we can compute this easily. - for i in range(self.num_rows): - for j in range(self.num_cols): - pos = Position(i, j) - # We only care about valid grid neighbors - neighbors = self.grid.get_neighbors(pos, "orthogonal") - adjacency_map[pos] = neighbors - - # Call the generic utility - # self.is_white maps keys (Position) to BoolVars - add_connected_subgraph_constraint( - self.model, - self.is_white, - adjacency_map + # 1. Get specific number (0 or 1) + current_values = {} + for pos, var in self.is_white.items(): + # get integer + current_values[pos] = 1 if var.solution_value() > 0.5 else 0 + + # 2. function call + # lambda function to detect neighbors + cuts_added = add_connectivity_cut_node_based( + solver=self.solver, + active_vars=self.is_white, + current_values=current_values, + neighbors_fn=lambda p: list(self.grid.get_neighbors(p, "orthogonal")) ) + + return cuts_added def get_solution(self): sol_grid = [["" for _ in range(self.num_cols)] for _ in range(self.num_rows)] for i in range(self.num_rows): for j in range(self.num_cols): pos = Position(i, j) - if self.solver.Value(self.is_white[pos]) == 1: + if self.is_white[pos].solution_value() > 0.5: sol_grid[i][j] = "-" # Kept (White) else: sol_grid[i][j] = "x" # Removed (Black) - return Grid(sol_grid) \ No newline at end of file + return Grid(sol_grid) diff --git a/src/puzzlekit/solvers/kurotto.py b/src/puzzlekit/solvers/kurotto.py new file mode 100644 index 00000000..24a36c0a --- /dev/null +++ b/src/puzzlekit/solvers/kurotto.py @@ -0,0 +1,233 @@ +# from typing import Any, List, Dict +# from puzzlekit.core.solver import PuzzleSolver +# from puzzlekit.core.grid import Grid +# from puzzlekit.core.position import Position +# from ortools.sat.python import cp_model as cp +# from puzzlekit.utils.ortools_utils import add_contiguous_area_constraint +# from typeguard import typechecked + + +# class KurottoSolver(PuzzleSolver): +# metadata : Dict[str, Any] = { +# "name": "kurotto", +# "aliases": [], +# "difficulty": "", +# "tags": [], +# "rule_url": "https://pzplus.tck.mn/rules.html?kurotto", +# "input_desc": """ +# TBD. +# """, +# "external_links": [ +# {"Play at puzz.link": "https://puzz.link/p?kurotto/10/10/46s.g21k4k2h.i1n.4j34n.i3h.k3k61g3s23"}, +# ], +# "output_desc": "", +# "input_example": """ +# """, +# "output_example": """ +# """ +# } + +# @typechecked +# def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): +# self.num_rows = num_rows +# self.num_cols = num_cols +# self.grid = Grid(grid) +# self.validate_input() + +# def validate_input(self): +# self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) +# self._check_allowed_chars( +# self.grid.matrix, +# {'-', 'o'}, +# validator=lambda x: x.isdigit() and int(x) >= 0 +# ) + +# def _add_constr(self): +# self.model = cp.CpModel() +# self.solver = cp.CpSolver() +# self._add_vars() +# self._add_circle_constr() +# self._add_number_constr() + +# def _add_vars(self): +# """Create shading variables for each cell.""" +# self.shaded = {} +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# self.shaded[Position(i, j)] = self.model.NewBoolVar(f"shaded_{i}_{j}") + +# def _add_circle_constr(self): +# """Cells with circles (numbered or 'o') cannot be shaded.""" +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# val = self.grid.value(i, j) +# if val == 'o' or val.isdigit(): +# self.model.Add(self.shaded[Position(i, j)] == 0) + +# def _add_number_constr(self): +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# val = self.grid.value(i, j) +# if val.isdigit(): +# number = int(val) +# start = Position(i, j) + +# def make_is_good(start_pos): +# def is_good(pos): +# if pos == start_pos: +# return 1 # Start is always good +# return self.shaded[pos] +# return is_good + +# add_contiguous_area_constraint( +# self.model, +# self.grid, +# start, +# make_is_good(start), +# number + 1, +# prefix=f"num_{i}_{j}" +# ) + + +# def get_solution(self): +# output_matrix = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] +# for i in range(self.num_rows): +# for j in range(self.num_cols): +# if self.solver.Value(self.shaded[Position(i, j)]) == 1: +# output_matrix[i][j] = "x" +# return Grid(output_matrix) + +from typing import Any, List, Dict +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from ortools.sat.python import cp_model as cp +from typeguard import typechecked +import math + + +class KurottoSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "kurotto", + "aliases": [], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?kurotto", + "input_desc": """ + TBD. + """, + "external_links": [ + {"Play at puzz.link": "https://puzz.link/p?kurotto/10/10/46s.g21k4k2h.i1n.4j34n.i3h.k3k61g3s23"}, + ], + "output_desc": "", + "input_example": """ + """, + "output_example": """ + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows = num_rows + self.num_cols = num_cols + self.grid = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars( + self.grid.matrix, + {'-', 'o'}, + validator=lambda x: x.isdigit() and int(x) >= 0 + ) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + + self.all_positions = [ + Position(r, c) + for r in range(self.num_rows) + for c in range(self.num_cols) + ] + + # Shading variables + self.shaded = { + pos: self.model.NewBoolVar(f"s_{pos.r}_{pos.c}") + for pos in self.all_positions + } + + # Circle cells cannot be shaded + for pos in self.all_positions: + val = self.grid.value(pos) + if val == 'o' or val.isdigit(): + self.model.Add(self.shaded[pos] == 0) + # Special: '0' means no adjacent black cells + if val == '0': + for nbr in self.grid.get_neighbors(pos): + self.model.Add(self.shaded[nbr] == 0) + + # Number constraints + for pos in self.all_positions: + val = self.grid.value(pos) + if val.isdigit(): + number = int(val) + if number > 0: + self._add_flood_fill_constraint(pos, number + 1) + + def _add_flood_fill_constraint(self, start: Position, target_area: int): + """Optimized flood fill using reduced iterations.""" + # Use fewer iterations with better propagation + # max_iter = min(target_area, int(math.sqrt(self.num_rows * self.num_cols)) + 2) + max_iter = target_area + + prefix = f"{start.r}_{start.c}" + + reachable = {} + for pos in self.all_positions: + reachable[pos] = self.model.NewBoolVar(f"r0_{prefix}_{pos.r}_{pos.c}") + self.model.Add(reachable[pos] == (1 if pos == start else 0)) + + for step in range(max_iter): + new_reachable = {} + for pos in self.all_positions: + new_reachable[pos] = self.model.NewBoolVar(f"r{step+1}_{prefix}_{pos.r}_{pos.c}") + + if pos == start: + self.model.Add(new_reachable[pos] == 1) + continue + + neighbors = list(self.grid.get_neighbors(pos)) + + # Simplified constraint building + # new_reachable[pos] = reachable[pos] OR (shaded[pos] AND OR(reachable[nbr])) + + or_terms = [reachable[pos]] + + if neighbors: + # Create: shaded[pos] AND any_neighbor_reachable + any_nbr_reach = self.model.NewBoolVar(f"anr{step}_{prefix}_{pos.r}_{pos.c}") + self.model.AddBoolOr([reachable[n] for n in neighbors]).OnlyEnforceIf(any_nbr_reach) + self.model.AddBoolAnd([reachable[n].Not() for n in neighbors]).OnlyEnforceIf(any_nbr_reach.Not()) + + expand = self.model.NewBoolVar(f"ex{step}_{prefix}_{pos.r}_{pos.c}") + self.model.AddBoolAnd([self.shaded[pos], any_nbr_reach]).OnlyEnforceIf(expand) + self.model.AddBoolOr([self.shaded[pos].Not(), any_nbr_reach.Not()]).OnlyEnforceIf(expand.Not()) + + or_terms.append(expand) + + self.model.AddBoolOr(or_terms).OnlyEnforceIf(new_reachable[pos]) + self.model.AddBoolAnd([t.Not() for t in or_terms]).OnlyEnforceIf(new_reachable[pos].Not()) + + reachable = new_reachable + + self.model.Add(sum(reachable[pos] for pos in self.all_positions) == target_area) + + def get_solution(self): + output_matrix = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + for i in range(self.num_rows): + for j in range(self.num_cols): + if self.solver.Value(self.shaded[Position(i, j)]) == 1: + output_matrix[i][j] = "x" + return Grid(output_matrix) + diff --git a/src/puzzlekit/solvers/mejilink.py b/src/puzzlekit/solvers/mejilink.py new file mode 100644 index 00000000..17678700 --- /dev/null +++ b/src/puzzlekit/solvers/mejilink.py @@ -0,0 +1,218 @@ +from typing import Any, List, Dict, Tuple +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_circuit_constraint_from_undirected +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class MejilinkSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "mejilink", + "aliases": [], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?mejilink", + "external_links": [], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 8 8\n15 15 14 10 11 13 14 11\n13 14 11 13 14 3 13 13\n7 14 10 3 13 14 3 7\n14 10 8 11 4 10 11 13\n14 11 7 13 7 14 11 7\n12 10 11 7 14 9 14 11\n7 15 14 9 15 7 15 15\n14 11 15 7 15 14 10 11 + """, + "output_example": """ + 8 8\n13 7 12 10 8 8 10 9\n4 10 3 13 6 3 13 5\n7 12 8 0 8 8 1 5\n10 2 0 2 0 2 3 5\n12 9 7 13 7 12 10 3\n4 2 10 2 10 1 12 10\n5 14 10 8 11 5 7 13\n6 10 11 5 14 2 10 3 + """ + } + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows = num_rows + self.num_cols = num_cols + self.grid = Grid(grid) + + self.validate_input() + self._parse_edges() + self._build_regions() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars(self.grid.matrix, set(), validator=lambda x: x.isdigit() and 0 <= int(x) <= 15) + + # -------------------------------------------- + # Parse input (0~15) into edge flags + # -------------------------------------------- + def _parse_edges(self): + R, C = self.num_rows, self.num_cols + self.top = [[False]*C for _ in range(R)] + self.left = [[False]*C for _ in range(R)] + self.down = [[False]*C for _ in range(R)] + self.right = [[False]*C for _ in range(R)] + + for r in range(R): + for c in range(C): + val = int(self.grid.value(r, c)) + self.top[r][c] = bool(val & 8) + self.left[r][c] = bool(val & 4) + self.down[r][c] = bool(val & 2) + self.right[r][c] = bool(val & 1) + + def _has_border_between(self, r1, c1, r2, c2) -> bool: + if r1 == r2 and c2 == c1 + 1: + return self.right[r1][c1] or self.left[r2][c2] + if r1 == r2 and c2 == c1 - 1: + return self.left[r1][c1] or self.right[r2][c2] + if c1 == c2 and r2 == r1 + 1: + return self.down[r1][c1] or self.top[r2][c2] + if c1 == c2 and r2 == r1 - 1: + return self.top[r1][c1] or self.down[r2][c2] + return True + + def _edge_key(self, u: Position, v: Position): + return (u, v) if (u.r, u.c) <= (v.r, v.c) else (v, u) + + # -------------------------------------------- + # Build regions based on "no border" adjacency + # -------------------------------------------- + def _build_regions(self): + R, C = self.num_rows, self.num_cols + self.region_id = [[-1]*C for _ in range(R)] + self.regions = [] + self.region_boundaries = [] # list of edge keys + + region_idx = 0 + for r in range(R): + for c in range(C): + if self.region_id[r][c] != -1: + continue + # BFS / DFS + stack = [(r, c)] + self.region_id[r][c] = region_idx + cells = [] + + while stack: + cr, cc = stack.pop() + cells.append((cr, cc)) + for dr, dc in [(-1,0), (1,0), (0,-1), (0,1)]: + nr, nc = cr + dr, cc + dc + if 0 <= nr < R and 0 <= nc < C: + if self.region_id[nr][nc] == -1 and not self._has_border_between(cr, cc, nr, nc): + self.region_id[nr][nc] = region_idx + stack.append((nr, nc)) + + # Collect boundary edges for this region + boundary_edges = set() + for (cr, cc) in cells: + # Top + if self.top[cr][cc]: + u = Position(cr, cc) + v = Position(cr, cc+1) + boundary_edges.add(self._edge_key(u, v)) + # Left + if self.left[cr][cc]: + u = Position(cr, cc) + v = Position(cr+1, cc) + boundary_edges.add(self._edge_key(u, v)) + # Down + if self.down[cr][cc]: + u = Position(cr+1, cc) + v = Position(cr+1, cc+1) + boundary_edges.add(self._edge_key(u, v)) + # Right + if self.right[cr][cc]: + u = Position(cr, cc+1) + v = Position(cr+1, cc+1) + boundary_edges.add(self._edge_key(u, v)) + + self.regions.append(cells) + self.region_boundaries.append(list(boundary_edges)) + region_idx += 1 + + # -------------------------------------------- + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.arc_vars = {} + self._add_vars() + self._add_region_constraints() + + # -------------------------------------------- + def _add_vars(self): + # only dotted lines are candidates + all_nodes = [] + for r in range(self.num_rows + 1): + for c in range(self.num_cols + 1): + all_nodes.append(Position(r, c)) + + edge_set = set() + + for r in range(self.num_rows): + for c in range(self.num_cols): + if self.top[r][c]: + edge_set.add(self._edge_key(Position(r, c), Position(r, c+1))) + if self.left[r][c]: + edge_set.add(self._edge_key(Position(r, c), Position(r+1, c))) + if self.down[r][c]: + edge_set.add(self._edge_key(Position(r+1, c), Position(r+1, c+1))) + if self.right[r][c]: + edge_set.add(self._edge_key(Position(r, c+1), Position(r+1, c+1))) + + for u, v in edge_set: + self.arc_vars[(u, v)] = self.model.NewBoolVar(f"edge_{u}_{v}") + + self.node_active = add_circuit_constraint_from_undirected( + self.model, all_nodes, self.arc_vars + ) + + # -------------------------------------------- + def _get_edge(self, p1: Position, p2: Position): + if (p1, p2) in self.arc_vars: + return self.arc_vars[(p1, p2)] + if (p2, p1) in self.arc_vars: + return self.arc_vars[(p2, p1)] + return None + + def _add_region_constraints(self): + # For each region: + # number of cells = number of boundary edges NOT in loop + for cells, boundaries in zip(self.regions, self.region_boundaries): + boundary_vars = [] + for edge in boundaries: + var = self.arc_vars.get(edge) + if var is not None: + boundary_vars.append(var) + + boundary_len = len(boundary_vars) + region_size = len(cells) + + # sum(boundary NOT used) == region_size + # => boundary_len - sum(used) == region_size + # => sum(used) == boundary_len - region_size + target = boundary_len - region_size + self.model.Add(sum(boundary_vars) == target) + + # -------------------------------------------- + def get_solution(self): + sol_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + for r in range(self.num_rows): + for c in range(self.num_cols): + p_ul = Position(r, c) + p_ur = Position(r, c+1) + p_dl = Position(r+1, c) + p_dr = Position(r+1, c+1) + + top = self._get_edge(p_ul, p_ur) + left = self._get_edge(p_ul, p_dl) + down = self._get_edge(p_dl, p_dr) + right = self._get_edge(p_ur, p_dr) + + score = 0 + edges = [top, left, down, right] + weights = [8, 4, 2, 1] + + for e, w in zip(edges, weights): + if e is not None and self.solver.Value(e) > 0.5: + score += w + + sol_grid[r][c] = str(score) + + return Grid(sol_grid) diff --git a/src/puzzlekit/solvers/mid_loop.py b/src/puzzlekit/solvers/mid_loop.py new file mode 100644 index 00000000..0d0f8aba --- /dev/null +++ b/src/puzzlekit/solvers/mid_loop.py @@ -0,0 +1,206 @@ +from typing import Any, List, Dict, Tuple, Optional +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_circuit_constraint_from_undirected +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class MidLoopSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "mid_loop", + "aliases": ["Middorupu"], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?midloop", + "input_desc": """ + Grid of integers representing circle positions. + + - `-` or `0`: No clue. + - `1`: Circle at cell center. + - `2`: Circle on the bottom edge. + - `3`: Circle on the right edge. + - `5`: Circle on both bottom and right edges (2+3). + """, + "external_links": [ + {"Play at puzz.link": "https://puzz.link/p?midloop/10/10/sfzgbffr7fjfwd3fg3fzifzfzjbfgfzgfzlfzzjf"}, + ], + "output_desc": "", + "input_example": """ + 4 4 + - - 1 - + - 1 - 2 + 3 - - - + - - - - + """, + "output_example": """ + 4 4 + - es ew sw + - ns - ns + es nw - ns + en ew ew nw + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows = num_rows + self.num_cols = num_cols + self.grid = Grid(grid) + self.validate_input() + + def validate_input(self): + # Allow "-", "0", "1", "2", "3", "5" + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars(self.grid.matrix, {'-', '0', '1', '2', '3', '5'}) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.arc_vars = {} + + # 1. Standard Loop Setup + self._add_circuit_vars() + + # 2. Midpoint Constraints + self._add_midpoint_constraints() + + def _add_circuit_vars(self): + all_nodes = [Position(i, j) for i in range(self.num_rows) for j in range(self.num_cols)] + + for i in range(self.num_rows): + for j in range(self.num_cols): + u = Position(i, j) + if j < self.num_cols - 1: + v = Position(i, j + 1) + self.arc_vars[(u, v)] = self.model.NewBoolVar(f"edge_{u}_{v}") + if i < self.num_rows - 1: + v = Position(i + 1, j) + self.arc_vars[(u, v)] = self.model.NewBoolVar(f"edge_{u}_{v}") + + self.node_active = add_circuit_constraint_from_undirected(self.model, all_nodes, self.arc_vars) + + def _get_edge_var(self, p1: Position, p2: Position): + if (p1, p2) in self.arc_vars: return self.arc_vars[(p1, p2)] + if (p2, p1) in self.arc_vars: return self.arc_vars[(p2, p1)] + return None + + def _create_arm_length_var(self, r, c, dr, dc) -> cp.IntVar: + """ + Calculates the length of the straight line segment extending from (r, c) in direction (dr, dc). + Logic reused from BalanceLoopSolver. + """ + length_components = [] + curr_r, curr_c = r, c + prev_active = self.model.NewConstant(1) + + while True: + next_r, next_c = curr_r + dr, curr_c + dc + if not (0 <= next_r < self.num_rows and 0 <= next_c < self.num_cols): + break + + edge = self._get_edge_var(Position(curr_r, curr_c), Position(next_r, next_c)) + segment_active = self.model.NewBoolVar(f"seg_{r}_{c}_{dr}_{dc}_{len(length_components)}") + + # segment_active = prev_active AND edge + # If previous segment (or root) was active, AND there is an edge in this direction, count it. + self.model.AddBoolAnd([prev_active, edge]).OnlyEnforceIf(segment_active) + self.model.AddBoolOr([prev_active.Not(), edge.Not()]).OnlyEnforceIf(segment_active.Not()) + + length_components.append(segment_active) + curr_r, curr_c = next_r, next_c + prev_active = segment_active + + # If no segments, sum is 0 + if not length_components: + return self.model.NewConstant(0) + + return sum(length_components) + + def _add_midpoint_constraints(self): + for i in range(self.num_rows): + for j in range(self.num_cols): + val = self.grid.value(i, j) + if val == "-" or val == "0": + continue + + clue_type = int(val) + pos = Position(i, j) + + # --- TYPE 1: Circle at Cell Center --- + # Rule: The loop passes through center. Center is midpoint of straight segment. + # Implication: + # 1. Node is Active. + # 2. Straightness: (Up == Down) AND (Left == Right). + # If it's a vertical line, Left=0, Right=0 (Equal), Up=X, Down=X (Equal). + # If it turns (e.g., Up-Right), Up=X, Down=0 != X. Constraint Fails. + # So this constraints implicitly enforces Straight Line AND Midpoint. + if clue_type == 1: + self.model.Add(self.node_active[pos] == 1) + + len_n = self._create_arm_length_var(i, j, -1, 0) + len_s = self._create_arm_length_var(i, j, 1, 0) + len_w = self._create_arm_length_var(i, j, 0, -1) + len_e = self._create_arm_length_var(i, j, 0, 1) + + self.model.Add(len_n == len_s) + self.model.Add(len_w == len_e) + + # --- TYPE 2 & 5: Circle on Bottom Edge --- + # Location: Edge between (i,j) and (i+1,j). + # Rule: This edge is the midpoint. + # Implication: + # 1. The edge (i,j)->(i+1,j) MUST exist. + # 2. Length UP from (i,j) == Length DOWN from (i+1,j). + if clue_type in [2, 5]: + if i < self.num_rows - 1: + p_down = Position(i + 1, j) + edge_v = self._get_edge_var(pos, p_down) + + # Edge must be active + self.model.Add(edge_v == 1) + + # Calculate arms going AWAY from the clue edge + len_up_from_top = self._create_arm_length_var(i, j, -1, 0) + len_down_from_bot = self._create_arm_length_var(i + 1, j, 1, 0) + + self.model.Add(len_up_from_top == len_down_from_bot) + + # --- TYPE 3 & 5: Circle on Right Edge --- + # Location: Edge between (i,j) and (i,j+1). + # Rule: This edge is the midpoint. + # Implication: + # 1. The edge (i,j)->(i,j+1) MUST exist. + # 2. Length LEFT from (i,j) == Length RIGHT from (i,j+1). + if clue_type in [3, 5]: + if j < self.num_cols - 1: + p_right = Position(i, j + 1) + edge_h = self._get_edge_var(pos, p_right) + + # Edge must be active + self.model.Add(edge_h == 1) + + # Calculate arms going AWAY from the clue edge + len_left_from_u = self._create_arm_length_var(i, j, 0, -1) + len_right_from_v = self._create_arm_length_var(i, j + 1, 0, 1) + + self.model.Add(len_left_from_u == len_right_from_v) + + def get_solution(self): + # Standard Loop Output (Detailed ASCII) + output_matrix = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + if self.solver.Value(self.node_active[pos]) == 0: continue + dirs = [] + e_n = self._get_edge_var(pos, pos.up) + if e_n is not None and self.solver.Value(e_n): dirs.append('n') + e_s = self._get_edge_var(pos, pos.down) + if e_s is not None and self.solver.Value(e_s): dirs.append('s') + e_w = self._get_edge_var(pos, pos.left) + if e_w is not None and self.solver.Value(e_w): dirs.append('w') + e_e = self._get_edge_var(pos, pos.right) + if e_e is not None and self.solver.Value(e_e): dirs.append('e') + if dirs: output_matrix[i][j] = "".join(sorted(dirs)) + return Grid(output_matrix) \ No newline at end of file diff --git a/src/puzzlekit/solvers/nurimisaki.py b/src/puzzlekit/solvers/nurimisaki.py new file mode 100644 index 00000000..0ac671a4 --- /dev/null +++ b/src/puzzlekit/solvers/nurimisaki.py @@ -0,0 +1,180 @@ +from typing import Any, List, Dict, Tuple +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class NurimisakiSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Nurimisaki", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?nurimisaki", + "external_links": [ + {"Janko": "https://www.janko.at/Raetsel/Nurimisaki/003.a.htm"}, + {"Play at puzz.link": "https://puzz.link/p?nurimisaki/6/6/971917241649721615https://puzz.link/p?nurimisaki/10/10/j.k3h.s.l4j3r3i.p2j4t.l4j./"} + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 6 6\n- - ? - - -\n- - - - - -\n? - - - - -\n- 3 - - - 2\n- - - - - -\n5 - - - 5 - + """, + "output_example": """ + 6 6\nx x - x x x\n- - - - - x\n- x - x - -\nx - - - x -\nx x x - x x\n- - - - - x + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + # Allow '-', '?', or positive integers + self._check_allowed_chars( + self.grid.matrix, + {'-', '?'}, + validator=lambda x: x.isdigit() and int(x) >= 0 + ) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.is_white = {} # Main Decision Variable: 1 if White/Path, 0 if Black/Shaded + self.adj_map = {} # For connectivity constraint + + # 1. Define Variables + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + self.is_white[pos] = self.model.NewBoolVar(f"w_{r}_{c}") + self.adj_map[pos] = [] + + # 2. Build Adjacency Map for Connectivity + for r in range(self.num_rows): + for c in range(self.num_cols): + u = Position(r, c) + for v in self.grid.get_neighbors(u, mode="orthogonal"): + self.adj_map[u].append(v) + + # 3. Global Connectivity: All white cells must form a single area + add_connected_subgraph_constraint(self.model, self.is_white, self.adj_map) + + # 4. No 2x2 Rule (Neither White nor Black can form 2x2) + for r in range(self.num_rows - 1): + for c in range(self.num_cols - 1): + p00 = Position(r, c) + p01 = Position(r, c + 1) + p10 = Position(r + 1, c) + p11 = Position(r + 1, c + 1) + + s = (self.is_white[p00] + self.is_white[p01] + + self.is_white[p10] + self.is_white[p11]) + + # Cannot be all white (sum != 4) + self.model.Add(s != 4) + # Cannot be all black (sum != 0) + self.model.Add(s != 0) + + # 5. Cell Specific Constraints + for r in range(self.num_rows): + for c in range(self.num_cols): + val = self.grid.value(r, c) + pos = Position(r, c) + + # Get neighboring white status summation + neighbor_whites = [] + for nbr in self.adj_map[pos]: + neighbor_whites.append(self.is_white[nbr]) + + sum_neighbors = sum(neighbor_whites) + + if val == '-' or val == '.': + # Rule: A white cell without a circle must have at least two white neighbors. + # Implication: If is_white -> sum >= 2. (If black, no constraint on neighbors) + self.model.Add(sum_neighbors >= 2).OnlyEnforceIf(self.is_white[pos]) + + else: + # If it's '?' or 'Number', it's a Circle. + + # Rule: Cells with circles are always white. + self.model.Add(self.is_white[pos] == 1) + + # Rule: A circle cell must have exactly one white cell orthogonally adjacent. + self.model.Add(sum_neighbors == 1) + + if val.isdigit(): + number = int(val) + # Rule: View length constraint. + # Since it only has 1 neighbor, the "view" is just the sum of + # visible lengths in all 4 directions. + # Note: 'number' includes the cell itself. + # view_len logic excludes self usually, so total = sum(arms) + 1 + + len_up = self._create_view_length(r, c, -1, 0) + len_down = self._create_view_length(r, c, 1, 0) + len_left = self._create_view_length(r, c, 0, -1) + len_right = self._create_view_length(r, c, 0, 1) + + self.model.Add(len_up + len_down + len_left + len_right + 1 == number) + + def _create_view_length(self, r: int, c: int, dr: int, dc: int) -> cp.IntVar: + """ + Creates a variable representing the number of consecutive white cells + starting from (r, c) in direction (dr, dc), EXCLUDING (r, c) itself. + """ + length_vars = [] + curr_r, curr_c = r + dr, c + dc + + # prev_active indicates if the continuous line has reached the previous cell + prev_active = self.model.NewConstant(1) + + while 0 <= curr_r < self.num_rows and 0 <= curr_c < self.num_cols: + pos = Position(curr_r, curr_c) + is_w = self.is_white[pos] + + # This segment is part of the line length IFF: + # 1. The previous segment was active (continuous from origin) + # 2. The current cell is White + + current_active = self.model.NewBoolVar(f"view_{r}_{c}_to_{dr}_{dc}_at_{curr_r}_{curr_c}") + self.model.AddBoolAnd([prev_active, is_w]).OnlyEnforceIf(current_active) + self.model.AddBoolOr([prev_active.Not(), is_w.Not()]).OnlyEnforceIf(current_active.Not()) + + length_vars.append(current_active) + + prev_active = current_active + curr_r += dr + curr_c += dc + + if not length_vars: + return self.model.NewConstant(0) + + return sum(length_vars) + + def get_solution(self): + sol_grid = [row[:] for row in self.grid.matrix] + + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + if self.solver.Value(self.is_white[pos]) == 1: + # White cells are usually represented by emptiness or specific path chars. + # Based on example output, White remains as clue or '-'? + # Prompt Example Output shows: + # Input '-' -> Output 'x' (if black) or '-' (if white) + # Input '5' -> Output '-' (if white, which corresponds to prompt example) + # Wait, prompt example output used 'x' for black and '-' for white at clue positions too? + # Example Input: (3,1) is '3'. Example Output: (3,1) is '-'. + # Example Input: (0,0) is '-'. Example Output: (0,0) is 'x'. + sol_grid[r][c] = "-" + else: + sol_grid[r][c] = "x" + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/solvers/slitherlink.py b/src/puzzlekit/solvers/slitherlink.py index 4482dec5..65b54400 100644 --- a/src/puzzlekit/solvers/slitherlink.py +++ b/src/puzzlekit/solvers/slitherlink.py @@ -157,4 +157,5 @@ def get_solution(self): grid_score += score if grid_score > 0: sol_grid[i][j] = str(grid_score) - return Grid(sol_grid) \ No newline at end of file + return Grid(sol_grid) + diff --git a/src/puzzlekit/solvers/slitherlink_duality.py b/src/puzzlekit/solvers/slitherlink_duality.py new file mode 100644 index 00000000..5f177e31 --- /dev/null +++ b/src/puzzlekit/solvers/slitherlink_duality.py @@ -0,0 +1,214 @@ +from typing import Any, Callable, List, Dict, Tuple +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_circuit_constraint_from_undirected +from ortools.sat.python import cp_model as cp +from puzzlekit.core.docs_template import GENERAL_GRID_TEMPLATE_INPUT_DESC, SLITHERLINK_STYLE_TEMPLATE_OUTPUT_DESC +from typeguard import typechecked + +class SlitherlinkDualitySolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "slitherlink", + "aliases": ["slither"], + "difficulty": "", + "tags": ["loop", "parity"], # Added parity tag + "rule_url": "https://pzplus.tck.mn/rules.html?slither", + "external_links": [ + {"Play at puzz.link": "https://puzz.link/p?slither/10/10/ic5137bg7bchbgdccb7dgddg7ddabdgdhc7bg7316dbg"}, + {"Janko": "https://www.janko.at/Raetsel/Slitherlink/0421.a.htm"}], + "input_desc": GENERAL_GRID_TEMPLATE_INPUT_DESC, + "output_desc": SLITHERLINK_STYLE_TEMPLATE_OUTPUT_DESC, + "input_example": """ + 10 10 + - 1 1 - 1 - - - - 1 + - 1 1 - - 1 1 1 1 - + - 1 1 - - - 1 1 - 1 + 1 1 - - 1 - - 1 - 1 + 1 1 1 - - 1 1 1 1 - + - - 1 - - 1 - - 1 - + - 1 - 1 - - - 1 1 - + - 1 1 1 - 1 1 - - - + 1 1 - - 1 1 1 1 1 1 + - - - 1 1 - - - 1 - + """, + "output_example": "..." + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars(self.grid.matrix, {'-'}, validator = lambda x: x.isdigit() and 0 <= int(x) <= 4) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + + # 优化:通常求解这类问题不需要多线程,单线程顺序搜索配合好的启发式可能更快 + # self.solver.parameters.num_search_workers = 1 + + self._add_vars() + self._add_number_constr() + + # New: Add parity/region constraints + self._add_region_constraints() + + def _add_vars(self): + # ... (Existing Edge Variables) ... + self.arc_vars = {} + + # all possible edges (Corner Nodes) + all_nodes = [] + for i in range(self.num_rows + 1): + for j in range(self.num_cols + 1): + all_nodes.append(Position(i, j)) + + for i in range(self.num_rows + 1): + for j in range(self.num_cols + 1): + u = Position(i, j) + if j < self.num_cols: + v = Position(i, j + 1) + self.arc_vars[(u, v)] = self.model.NewBoolVar(f"edge_H_{i}_{j}") # Horizontal + if i < self.num_rows: + v = Position(i + 1, j) + self.arc_vars[(u, v)] = self.model.NewBoolVar(f"edge_V_{i}_{j}") # Vertical + + # ... (Existing Circuit Constraint) ... + self.node_active = add_circuit_constraint_from_undirected( + self.model, + all_nodes, + self.arc_vars + ) + + def _add_region_constraints(self): + """ + Implementation of the Inside/Outside Parity optimization. + We create a boolean variable for every cell representing if it is INSIDE the loop. + Edge exists <==> Cell colors are different (XOR). + """ + # 1. Create variables for Cells (Regions) + # 0 = Outside, 1 = Inside + self.cell_inside = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + self.cell_inside[(r, c)] = self.model.NewBoolVar(f"cell_in_{r}_{c}") + + # 2. Link Edges to Cell Parity + # Relation: Edge_Active <==> (Cell_A_Inside != Cell_B_Inside) + + # Horizontal Edges (between row i, col j and row i+1, col j ??? No.) + # Wait, let's map coordinates carefully. + # Grid cells are (r, c). + # Horizontal edge at corner (i, j) connects (i, j) and (i, j+1). + # This edge separates Cell (i-1, j) and Cell (i, j). + + for i in range(self.num_rows + 1): + for j in range(self.num_cols + 1): + u = Position(i, j) + + # --- Horizontal Edge --- + # Connects corners (i, j) and (i, j+1). + # Separates Cell (i-1, j) [Above] and Cell (i, j) [Below] + if j < self.num_cols: + v = Position(i, j + 1) + if (u, v) in self.arc_vars: + edge_var = self.arc_vars[(u, v)] + + cell_above_var = self.cell_inside.get((i - 1, j), None) # Outside grid is 0 (False) + cell_below_var = self.cell_inside.get((i, j), None) + + self._link_edge_and_cells(edge_var, cell_above_var, cell_below_var) + + # --- Vertical Edge --- + # Connects corners (i, j) and (i+1, j). + # Separates Cell (i, j-1) [Left] and Cell (i, j) [Right] + if i < self.num_rows: + v = Position(i + 1, j) + if (u, v) in self.arc_vars: + edge_var = self.arc_vars[(u, v)] + + cell_left_var = self.cell_inside.get((i, j - 1), None) + cell_right_var = self.cell_inside.get((i, j), None) + + self._link_edge_and_cells(edge_var, cell_left_var, cell_right_var) + + def _link_edge_and_cells(self, edge: cp.IntVar, cell1: cp.IntVar | None, cell2: cp.IntVar | None): + """ + Enforce: edge == (cell1 != cell2) + If a cell is None (outside grid), treat it as constant 0 (False). + """ + if cell1 is None and cell2 is None: + # Both outside? Impossible for internal edges, but valid for edge case logic. + self.model.Add(edge == 0) + return + + if cell1 is None: + # cell1 is Outside (0). So Edge == cell2 + self.model.Add(edge == cell2) + elif cell2 is None: + # cell2 is Outside (0). So Edge == cell1 + self.model.Add(edge == cell1) + else: + # Edge = XOR(cell1, cell2) + # In CP-SAT: AddBoolXor logic is usually strict sum mod 2, + # simplest way: edge != (cell1 == cell2) + self.model.Add(edge != cell1).OnlyEnforceIf(cell2) + self.model.Add(edge == cell1).OnlyEnforceIf(cell2.Not()) + + def _add_number_constr(self): + # unchanged... + for i in range(self.num_rows): + for j in range(self.num_cols): + val = self.grid.value(i, j) + if val.isdigit(): + number = int(val) + p_ul = Position(i, j) + p_ur = Position(i, j + 1) + p_dl = Position(i + 1, j) + p_dr = Position(i + 1, j + 1) + + edges = [ + self.arc_vars.get((p_ul, p_ur)), # Top + self.arc_vars.get((p_ul, p_dl)), # Left + self.arc_vars.get((p_dl, p_dr)), # Down + self.arc_vars.get((p_ur, p_dr)), # Right + ] + self.model.Add(sum(e for e in edges if e is not None) == number) + + def get_solution(self): + # unchanged... + sol_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + for i in range(self.num_rows): + for j in range(self.num_cols): + # Retrieve edge values to reconstruct grid + # ... (Logic identical to your provided code) ... + + # Visualization Tip: + # You can also use self.solver.Value(self.cell_inside[(i,j)]) + # to visualize the inside/outside regions directly! + p_ul = Position(i, j) + p_ur = Position(i, j + 1) + p_dl = Position(i + 1, j) + p_dr = Position(i + 1, j + 1) + + top = self.arc_vars.get((p_ul, p_ur)) + left = self.arc_vars.get((p_ul, p_dl)) + down = self.arc_vars.get((p_dl, p_dr)) + right = self.arc_vars.get((p_ur, p_dr)) + + grid_score = 0 + edges_list = [top, left, down, right] + scores = [8, 4, 2, 1] + + for edge, score in zip(edges_list, scores): + if edge is not None and self.solver.Value(edge) > 0.5: + grid_score += score + if grid_score > 0: + sol_grid[i][j] = str(grid_score) + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/utils/ortools_utils.py b/src/puzzlekit/utils/ortools_utils.py index 9f5b2b13..937a28e5 100644 --- a/src/puzzlekit/utils/ortools_utils.py +++ b/src/puzzlekit/utils/ortools_utils.py @@ -1,6 +1,9 @@ -from typing import Any, Dict, List, Tuple, Hashable, Callable +from typing import Any, Dict, List, Tuple, Hashable, Callable, Set +from collections import deque from ortools.sat.python import cp_model as cp +from ortools.linear_solver import pywraplp from puzzlekit.core.position import Position +from puzzlekit.core.grid import Grid def ortools_and_constr(model: cp.CpModel, target: cp.IntVar, vars: list[cp.IntVar]): model.AddBoolAnd(vars).OnlyEnforceIf(target) # target => (c1 ∧ ... ∧ cn) @@ -238,6 +241,272 @@ def add_circuit_constraint_from_undirected( return node_active +def add_contiguous_area_constraint( + model: cp.CpModel, + grid: Grid, + start: Position, + is_good: Callable[[Position], cp.IntVar], + target_area: int, + prefix: str = "" +) -> Dict[Position, cp.IntVar]: + """ + Add flood-fill based contiguous area constraint. + + Starting from 'start', flood fill through cells where is_good(pos) is true. + The total number of filled cells must equal 'target_area'. + + Args: + model: CP-SAT model + grid: The puzzle grid (for getting neighbors) + start: Starting position for flood fill + is_good: Function that returns a BoolVar indicating if a cell can be part of the region + target_area: Required total area + prefix: Variable name prefix (for uniqueness) + + Returns: + Dictionary mapping positions to their final reachability variables + + Example usage for Kurotto: + # good(pos) = (pos == start) OR shaded[pos] + def is_good(pos): + if pos == start: + return model.NewConstant(1) + return shaded[pos] + + add_contiguous_area_constraint(model, grid, start, is_good, number + 1) + + Example usage for other puzzles (e.g., counting connected white cells): + def is_good(pos): + return white[pos] # BoolVar for "is this cell white" + + add_contiguous_area_constraint(model, grid, start, is_good, expected_count) + """ + all_positions = [ + Position(r, c) + for r in range(grid.num_rows) + for c in range(grid.num_cols) + ] + + max_iterations = min(target_area, grid.num_rows + grid.num_cols) + pfx = f"{prefix}_" if prefix else "" + + # Initialize: only start is reachable (if it's good) + reachable = {} + for pos in all_positions: + reachable[pos] = model.NewBoolVar(f"{pfx}reach_0_{start.r}_{start.c}_{pos.r}_{pos.c}") + if pos == start: + # Start is reachable iff it's good + good_var = is_good(pos) + if isinstance(good_var, int): + model.Add(reachable[pos] == good_var) + else: + model.Add(reachable[pos] == 1).OnlyEnforceIf(good_var) + model.Add(reachable[pos] == 0).OnlyEnforceIf(good_var.Not()) + else: + model.Add(reachable[pos] == 0) + + # Iterative expansion + for step in range(max_iterations): + new_reachable = {} + for pos in all_positions: + new_reachable[pos] = model.NewBoolVar( + f"{pfx}reach_{step+1}_{start.r}_{start.c}_{pos.r}_{pos.c}" + ) + + neighbors = grid.get_neighbors(pos) + expand_vars = [] + + good_var = is_good(pos) + + for nbr in neighbors: + e = model.NewBoolVar( + f"{pfx}exp_{step}_{start.r}_{start.c}_{pos.r}_{pos.c}_{nbr.r}_{nbr.c}" + ) + # e <=> (is_good(pos) AND reachable[nbr]) + if isinstance(good_var, int): + if good_var == 1: + model.Add(e == reachable[nbr]) + else: + model.Add(e == 0) + else: + model.AddBoolAnd([good_var, reachable[nbr]]).OnlyEnforceIf(e) + model.AddBoolOr([good_var.Not(), reachable[nbr].Not()]).OnlyEnforceIf(e.Not()) + expand_vars.append(e) + + all_conditions = [reachable[pos]] + expand_vars + model.AddBoolOr(all_conditions).OnlyEnforceIf(new_reachable[pos]) + model.AddBoolAnd([c.Not() for c in all_conditions]).OnlyEnforceIf(new_reachable[pos].Not()) + + reachable = new_reachable + + model.Add(sum(reachable[pos] for pos in all_positions) == target_area) + + return reachable + + + +def add_connectivity_cut_node_based( + solver: pywraplp.Solver, + active_vars: Dict[Position, pywraplp.Variable], + current_values: Dict[Position, int], + neighbors_fn: Callable[[Position], List[Position]] +) -> bool: + """ + (Node-based Connectivity Cut). + + Logic: + 1. Traverse the nodes in current_values where the value is 1 (active). + 2. Find all connected components (Connected Components). + 3. If the number of components > 1, the graph is not connected. + 4. For each isolated component, find its boundary nodes (Boundary Nodes) with value 0. + 5. Add constraint: sum(boundary node variables) >= 1. This forces the solver to "break through" at least one boundary in the next iteration. + + Args: + solver: SCIP solver instance + active_vars: variable mapping {Position: SolverVariable} + current_values: snapshot of the current solution {Position: 0 or 1} + neighbors_fn: a function, input Position, return its neighbor Position list (for decoupling Grid implementation) + + Returns: + bool: if the cut plane is added (i.e.,not connected at that time),return True; otherwise return False (connected or empty)。 + """ + + active_nodes = [pos for pos, val in current_values.items() if val == 1] + if not active_nodes: + return False + + visited: Set[Position] = set() + components: List[Dict[str, Any]] = [] + + for start_node in active_nodes: + if start_node in visited: + continue + + component_nodes = [] + boundary_inactive_nodes = set() + queue = deque([start_node]) + visited.add(start_node) + + while queue: + curr = queue.popleft() + component_nodes.append(curr) + + for nbr in neighbors_fn(curr): + if nbr not in current_values: + continue + + val = current_values[nbr] + if val == 1: + if nbr not in visited: + visited.add(nbr) + queue.append(nbr) + else: + boundary_inactive_nodes.add(nbr) + + components.append({ + "nodes": component_nodes, + "boundary": list(boundary_inactive_nodes) + }) + + if len(components) <= 1: + return False + + cuts_added = False + for comp in components: + boundary = comp['boundary'] + if not boundary: + continue + + ct = solver.Constraint(1, solver.infinity()) + for pos in boundary: + if pos in active_vars: + ct.SetCoefficient(active_vars[pos], 1) + cuts_added = True + + return cuts_added + +# def add_connectivity_cut_node_based( +# solver: pywraplp.Solver, +# active_vars: Dict[Position, pywraplp.Variable], +# current_values: Dict[Position, int], +# neighbors_fn: Callable[[Position], List[Position]] +# ) -> bool: + + +# # 1. 提取活跃节点 +# active_nodes = [pos for pos, val in current_values.items() if val == 1] +# if not active_nodes: +# return False + +# # 2. 寻找连通分量 (BFS) +# visited: Set[Position] = set() +# components: List[List[Position]] = [] # 存储为 List 以便保持确定性 + +# # 将 list 转为 set 加速查询 +# active_set = set(active_nodes) + +# for node in active_nodes: +# if node in visited: +# continue + +# component = [] +# queue = deque([node]) +# visited.add(node) + +# while queue: +# curr = queue.popleft() +# component.append(curr) + +# for nbr in neighbors_fn(curr): +# if nbr in active_set and nbr not in visited: +# visited.add(nbr) +# queue.append(nbr) +# components.append(component) + +# # 如果只有一个分量,则已连通 +# if len(components) <= 1: +# return False + +# cuts_added = False + +# # 3. 对每个分量添加 Conditional Cut +# # 优化:通常对最小的分量添加约束就足够打破循环,不需要全部添加,但全部添加更稳健 +# # 这里我们对所有分量都加 +# for comp_nodes in components: + +# # 寻找该分量的“有效边界” +# # 边界定义为:与分量内节点相邻,且当前值为 0 的节点 +# boundary_nodes = set() +# for node in comp_nodes: +# for nbr in neighbors_fn(node): +# if nbr not in active_set: # 它是黑的 +# # 必须确保这个黑格是一个有效的变量,而不是墙或界外 +# if nbr in active_vars: +# boundary_nodes.add(nbr) + +# if not boundary_nodes: +# # 死局:一个孤立分量被墙围死,或者被无变量区域围死。 +# # 这种情况下,这个分量必须被消灭(全变黑)。 +# # 约束退化为: sum(S) - 0 <= |S| - 1 => sum(S) <= |S| - 1 +# # 这强迫 S 中至少有一个变黑。这是正确的。 +# pass + +# # 构建约束: sum(S) - sum(B) <= |S| - 1 +# # 移项方便调用 API: sum(S) - sum(B) <= len(S) - 1 +# ct = solver.Constraint(-solver.infinity(), len(comp_nodes) - 1) + +# # S 中的点系数为 +1 +# for pos in comp_nodes: +# ct.SetCoefficient(active_vars[pos], 1.0) + +# # B 中的点系数为 -1 +# for pos in boundary_nodes: +# ct.SetCoefficient(active_vars[pos], -1.0) + +# cuts_added = True + +# return cuts_added + def ortools_cpsat_analytics(model: cp.CpModel, solver: cp.CpSolver): proto = model.Proto() @@ -248,14 +517,14 @@ def ortools_cpsat_analytics(model: cp.CpModel, solver: cp.CpSolver): analytics_dict = dict() num_variables = len(proto.variables) - num_bool_vars = sum(1 for var in proto.variables if var.domain == [0, 1]) - num_int_vars = num_variables - num_bool_vars + # num_bool_vars = sum(1 for var in proto.variables if var.domain == [0, 1]) + # num_int_vars = num_variables - num_bool_vars num_constraints = len(proto.constraints) analytics_dict = { "num_vars": num_variables, - "num_bool_vars": num_bool_vars, - "num_int_vars": num_int_vars, + # "num_bool_vars": num_bool_vars, + # "num_int_vars": num_int_vars, "num_constrs": num_constraints, "num_conflicts": solver.NumConflicts(), "num_branches": solver.NumBranches(), @@ -264,4 +533,30 @@ def ortools_cpsat_analytics(model: cp.CpModel, solver: cp.CpSolver): "wall_time": solver.WallTime() } return analytics_dict - \ No newline at end of file + + +def ortools_mip_analytics(solver: pywraplp.Solver) -> dict: + + if not isinstance(solver, pywraplp.Solver): + raise ValueError("ortools MIP model invalid. ") + + analytics_dict = {} + + # Returns the number of variables and constraints. + analytics_dict['num_vars'] = solver.NumVariables() + analytics_dict['num_constrs'] = solver.NumConstraints() + + # Returns the number of branch-and-bound nodes evaluated during the solve. + analytics_dict['num_nodes'] = solver.nodes() + + # Returns the number of simplex iterations. + analytics_dict['num_iterations'] = solver.iterations() + + # Returns the wall-clock time in seconds. + analytics_dict['cpu_time'] = solver.wall_time() / 1000.0 + analytics_dict['wall_time'] = solver.wall_time() / 1000.0 + + # Returns a string describing the underlying solver and its version. + analytics_dict['solver_name'] = solver.SolverVersion() + + return analytics_dict \ No newline at end of file diff --git a/src/puzzlekit/verifiers/__init__.py b/src/puzzlekit/verifiers/__init__.py index 0a6ecdbe..77c4c361 100644 --- a/src/puzzlekit/verifiers/__init__.py +++ b/src/puzzlekit/verifiers/__init__.py @@ -94,6 +94,13 @@ "ken_ken": verify_exact, "juosan": verify_exact, "kakkuru": verify_exact, + "castle_wall": verify_lines, + "mejilink": verify_exact, + "mid_loop": verify_lines, + "kurotto": verify_exact, + "nurimisaki": lambda a, b: verify_target_content(a, b, "x"), + "aqre": lambda a, b: verify_target_content(a, b, "x"), + "canal_view": verify_exact, } def grid_verifier(puzzle_type: str, a: Grid, b: Grid) -> bool: diff --git a/src/puzzlekit/viz/__init__.py b/src/puzzlekit/viz/__init__.py index 862c8e2f..007e12da 100644 --- a/src/puzzlekit/viz/__init__.py +++ b/src/puzzlekit/viz/__init__.py @@ -98,6 +98,13 @@ "ken_ken": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), "juosan": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), "kakkuru": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), + "castle_wall": lambda g, d, p: draw_general_puzzle(g, d, p, style='line'), + "mejilink": lambda g, d, p: draw_general_puzzle(g, d, p, style='wall'), + "mid_loop": lambda g, d, p: draw_general_puzzle(g, d, p, style='line'), + "kurotto": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "nurimisaki": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "aqre": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "canal_view": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), # ... } diff --git a/src/puzzlekit/viz/base.py b/src/puzzlekit/viz/base.py index bb745f9d..1928051f 100644 --- a/src/puzzlekit/viz/base.py +++ b/src/puzzlekit/viz/base.py @@ -11,12 +11,15 @@ def __init__(self, grid: Grid, title: str = "Puzzle Solution", figsize_scale=0.5 self.cols = grid.num_cols self.title_text = title - # 1. Original Figure Scale only depends on grid, No padding - # Later by setting bbox_inches='tight' to auto scale canvas. w = self.cols * figsize_scale h = self.rows * figsize_scale - # 2. To prevent the initial too small to cause the font crowded,give a minimum size + max_display_size = 12 + if max(w, h) > max_display_size: + scale_factor = max_display_size / max(w, h) + w *= scale_factor + h *= scale_factor + w = max(w, 6) h = max(h, 6) diff --git a/tests/metadata_test_utils.py b/tests/metadata_test_utils.py new file mode 100644 index 00000000..2bbab230 --- /dev/null +++ b/tests/metadata_test_utils.py @@ -0,0 +1,81 @@ +"""Helpers for metadata-based tests: input_example -> parser -> solver -> grid; verify via verifiers.""" + +from typing import Any, Tuple +from puzzlekit.core.grid import Grid +from puzzlekit.parsers.common import standard_grid_parser +from puzzlekit.parsers.registry import get_parser +from puzzlekit.solvers import get_solver_class +from puzzlekit.verifiers import grid_verifier + + +def parse_output_example_to_grid(output_example: str) -> Grid: + """ + Parse output_example string (format "R C\\nrow0\\nrow1...") into a Grid. + Reuses standard_grid_parser from parsers; output format matches that structure. + """ + if not output_example or not output_example.strip(): + return Grid.empty() + parsed = standard_grid_parser(output_example.strip()) + grid = parsed.get("grid", []) + if not grid: + return Grid.empty() + return Grid(grid) + + +def _ensure_grid(val: Any) -> Grid: + if isinstance(val, Grid): + return val + if isinstance(val, list) and len(val) > 0: + return Grid(val) + return Grid.empty() + + +def run_metadata_test(puzzle_type: str) -> Tuple[Grid, Grid, str]: + """ + Parse input_example via parser -> solver -> solution grid. + Parse output_example via standard_grid_parser -> expected grid. + Returns (solution_grid, expected_grid, puzzle_type) for verification. + Use grid_verifier(puzzle_type, solution_grid, expected_grid) to assert. + Raises if metadata missing or solver fails. + """ + SolverClass = get_solver_class(puzzle_type) + meta = getattr(SolverClass, "metadata", {}) or {} + input_example = (meta.get("input_example") or "").strip() + output_example = (meta.get("output_example") or "").strip() + + if not input_example or not output_example: + raise ValueError( + f"Puzzle '{puzzle_type}' metadata must define both input_example and output_example." + ) + if output_example == "...": + raise ValueError( + f"Puzzle '{puzzle_type}' output_example is placeholder '...'; set a real expected output." + ) + + parser = get_parser(puzzle_type) + puzzle_data = parser(input_example) + if puzzle_data is None: + raise ValueError(f"Parser returned None for '{puzzle_type}'.") + + solver = SolverClass(**puzzle_data) + result = solver.solve() + status = result.solution_data.get("status", "Unknown") + if status not in ("Optimal", "Feasible"): + raise AssertionError( + f"Puzzle '{puzzle_type}' solver did not find a solution; status={status}." + ) + + sol_raw = result.solution_data.get("solution_grid") + solution_grid = _ensure_grid(sol_raw) + expected_grid = parse_output_example_to_grid(output_example) + + return solution_grid, expected_grid, puzzle_type + + +def run_metadata_test_and_verify(puzzle_type: str) -> bool: + """ + Run run_metadata_test and verify via grid_verifier. + Returns True iff verification passes. Use in tests: assert run_metadata_test_and_verify("castle_wall"). + """ + solution_grid, expected_grid, pt = run_metadata_test(puzzle_type) + return grid_verifier(pt, solution_grid, expected_grid) diff --git a/tests/test_battleship.py b/tests/test_battleship.py new file mode 100644 index 00000000..2822b5ce --- /dev/null +++ b/tests/test_battleship.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Battleship.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_battleship_from_metadata(): + assert run_metadata_test_and_verify("battleship") diff --git a/tests/test_bricks.py b/tests/test_bricks.py new file mode 100644 index 00000000..fe8f1695 --- /dev/null +++ b/tests/test_bricks.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Bricks.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_bricks_from_metadata(): + assert run_metadata_test_and_verify("bricks") diff --git a/tests/test_castle_wall.py b/tests/test_castle_wall.py new file mode 100644 index 00000000..3e1fd2ce --- /dev/null +++ b/tests/test_castle_wall.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Castle Wall: input_example -> parser -> solver -> verify via grid_verifier.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_castle_wall_from_metadata(): + assert run_metadata_test_and_verify("castle_wall") diff --git a/tests/test_cave.py b/tests/test_cave.py new file mode 100644 index 00000000..d38ab5d3 --- /dev/null +++ b/tests/test_cave.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Cave.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_cave_from_metadata(): + assert run_metadata_test_and_verify("cave") diff --git a/tests/test_creek.py b/tests/test_creek.py new file mode 100644 index 00000000..99c6e59e --- /dev/null +++ b/tests/test_creek.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Creek.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_creek_from_metadata(): + assert run_metadata_test_and_verify("creek") diff --git a/tests/test_dotchi_loop.py b/tests/test_dotchi_loop.py new file mode 100644 index 00000000..ea2a0603 --- /dev/null +++ b/tests/test_dotchi_loop.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Dotchi Loop.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_dotchi_loop_from_metadata(): + assert run_metadata_test_and_verify("dotchi_loop") diff --git a/tests/test_juosan.py b/tests/test_juosan.py new file mode 100644 index 00000000..a677adf6 --- /dev/null +++ b/tests/test_juosan.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Juosan.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_juosan_from_metadata(): + assert run_metadata_test_and_verify("juosan") diff --git a/tests/test_kakkuru.py b/tests/test_kakkuru.py new file mode 100644 index 00000000..14512fa6 --- /dev/null +++ b/tests/test_kakkuru.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Kakkuru.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_kakkuru_from_metadata(): + assert run_metadata_test_and_verify("kakkuru") diff --git a/tests/test_ken_ken.py b/tests/test_ken_ken.py new file mode 100644 index 00000000..5f9b364a --- /dev/null +++ b/tests/test_ken_ken.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Ken Ken.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_ken_ken_from_metadata(): + assert run_metadata_test_and_verify("ken_ken") diff --git a/tests/test_koburin.py b/tests/test_koburin.py new file mode 100644 index 00000000..1943fdef --- /dev/null +++ b/tests/test_koburin.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Koburin.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_koburin_from_metadata(): + assert run_metadata_test_and_verify("koburin") diff --git a/tests/test_lits.py b/tests/test_lits.py new file mode 100644 index 00000000..6d2a8712 --- /dev/null +++ b/tests/test_lits.py @@ -0,0 +1,9 @@ +"""Metadata-based test for LITS.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_lits_from_metadata(): + assert run_metadata_test_and_verify("lits") diff --git a/tests/test_makaro.py b/tests/test_makaro.py new file mode 100644 index 00000000..d54de45b --- /dev/null +++ b/tests/test_makaro.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Makaro.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_makaro_from_metadata(): + assert run_metadata_test_and_verify("makaro") diff --git a/tests/test_mathrax.py b/tests/test_mathrax.py new file mode 100644 index 00000000..dd86f038 --- /dev/null +++ b/tests/test_mathrax.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Mathrax.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_mathrax_from_metadata(): + assert run_metadata_test_and_verify("mathrax") diff --git a/tests/test_moon_sun.py b/tests/test_moon_sun.py new file mode 100644 index 00000000..bd6d0319 --- /dev/null +++ b/tests/test_moon_sun.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Moon Sun.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_moon_sun_from_metadata(): + assert run_metadata_test_and_verify("moon_sun") diff --git a/tests/test_nawabari.py b/tests/test_nawabari.py new file mode 100644 index 00000000..dd0ee462 --- /dev/null +++ b/tests/test_nawabari.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Nawabari.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_nawabari_from_metadata(): + assert run_metadata_test_and_verify("nawabari") diff --git a/tests/test_paint_area.py b/tests/test_paint_area.py new file mode 100644 index 00000000..27aa4288 --- /dev/null +++ b/tests/test_paint_area.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Paint Area.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_paint_area_from_metadata(): + assert run_metadata_test_and_verify("paint_area") diff --git a/tests/test_putteria.py b/tests/test_putteria.py new file mode 100644 index 00000000..2fb62f8a --- /dev/null +++ b/tests/test_putteria.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Putteria.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_putteria_from_metadata(): + assert run_metadata_test_and_verify("putteria") diff --git a/tests/test_regional_yajilin.py b/tests/test_regional_yajilin.py new file mode 100644 index 00000000..cbc11684 --- /dev/null +++ b/tests/test_regional_yajilin.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Regional Yajilin.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_regional_yajilin_from_metadata(): + assert run_metadata_test_and_verify("regional_yajilin") diff --git a/tests/test_shingoki.py b/tests/test_shingoki.py new file mode 100644 index 00000000..3fe6f438 --- /dev/null +++ b/tests/test_shingoki.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Shingoki.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_shingoki_from_metadata(): + assert run_metadata_test_and_verify("shingoki") diff --git a/tests/test_skyscraper.py b/tests/test_skyscraper.py new file mode 100644 index 00000000..1d3a12ed --- /dev/null +++ b/tests/test_skyscraper.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Skyscraper.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_skyscraper_from_metadata(): + assert run_metadata_test_and_verify("skyscraper") diff --git a/tests/test_stitches.py b/tests/test_stitches.py new file mode 100644 index 00000000..d91c4c57 --- /dev/null +++ b/tests/test_stitches.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Stitches.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_stitches_from_metadata(): + assert run_metadata_test_and_verify("stitches") diff --git a/tests/test_trinairo.py b/tests/test_trinairo.py new file mode 100644 index 00000000..b52ce366 --- /dev/null +++ b/tests/test_trinairo.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Trinairo.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_trinairo_from_metadata(): + assert run_metadata_test_and_verify("trinairo") diff --git a/tests/test_yajikabe.py b/tests/test_yajikabe.py new file mode 100644 index 00000000..5cb15399 --- /dev/null +++ b/tests/test_yajikabe.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Yajikabe.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_yajikabe_from_metadata(): + assert run_metadata_test_and_verify("yajikabe") diff --git a/tests/test_yin_yang.py b/tests/test_yin_yang.py new file mode 100644 index 00000000..5175d7f2 --- /dev/null +++ b/tests/test_yin_yang.py @@ -0,0 +1,9 @@ +"""Metadata-based test for Yin Yang.""" + +import pytest + +from tests.metadata_test_utils import run_metadata_test_and_verify + + +def test_yin_yang_from_metadata(): + assert run_metadata_test_and_verify("yin_yang")