testspace / submodules /flowfree /verifier.py
nilshoehing's picture
Upload TopoBench Space app
67acd34 verified
Raw
History Blame Contribute Delete
15.2 kB
#!/usr/bin/env python3
"""
verifier.py
Verify if a given ASCII board state is a valid solution to a FlowFree puzzle.
Uses the modified solver (flowfree_all_solutions.c) to find all solutions and
compares the given solution against them.
"""
import argparse
import platform
import re
import subprocess
import sys
from collections import defaultdict
from itertools import chain
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Reuse patterns from convert_freeflow.py
HEADER_RE = re.compile(r"^\s*(\d+)\s+(\d+)\s+(\d+)\s*$")
COORD_RE = re.compile(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)")
SOLUTIONS_RE = re.compile(r"^\s*Solutions\s+(\d+)\s*$", re.IGNORECASE)
def letter_labels(n):
"""Generate letter labels for colors (A, B, C, ..., Z, a, b, ..., z, AA, AB, ...)."""
base = [chr(i) for i in range(ord('A'), ord('Z')+1)]
extra = [chr(i) for i in range(ord('a'), ord('z')+1)]
if n <= len(base) + len(extra):
return list(chain(base, extra))[:n]
labels = list(chain(base, extra))
i = 0
while len(labels) < n:
for ch in base:
labels.append(base[i % len(base)] + ch)
if len(labels) == n:
break
i += 1
return labels
def build_all_solutions_solver(src: Path, bin_: Path) -> None:
"""Compile the modified solver that outputs all solutions."""
print(f"[build] compiling {src.name} -> {bin_.name}")
if platform.system().lower().startswith("win"):
cmd = ["gcc", "-std=c11", "-O2", "-Wall", "-Wextra", "-o", str(bin_), str(src)]
else:
cmd = ["gcc", "-std=c11", "-O2", "-Wall", "-Wextra", "-o", str(bin_), str(src)]
try:
subprocess.run(cmd, check=True)
except FileNotFoundError as e:
raise SystemExit(
"Error: C compiler not found (gcc). Install gcc or build the solver manually."
) from e
except subprocess.CalledProcessError as e:
raise SystemExit(f"Build failed with exit code {e.returncode}")
def parse_ascii_board(ascii_str: str) -> Tuple[List[List[str]], int, int]:
"""
Parse ASCII board string to 2D grid.
Returns: (grid as 2D list, cols, rows)
"""
lines = ascii_str.strip().split('\n')
grid = []
for line in lines:
line = line.strip()
if line:
grid.append(list(line))
if not grid:
raise ValueError("Empty board")
rows = len(grid)
cols = len(grid[0]) if rows > 0 else 0
# Validate all rows have same length
for i, row in enumerate(grid):
if len(row) != cols:
raise ValueError(f"Row {i} has length {len(row)}, expected {cols}")
return grid, cols, rows
def extract_endpoints(grid: List[List[str]], cols: int, rows: int) -> Dict[str, List[Tuple[int, int]]]:
"""
Find all endpoint pairs for each color.
Endpoints are cells that appear exactly once or twice per color (the two endpoints).
Returns: dict mapping color labels to list of (x, y) coordinates (col, row, zero-based).
"""
color_positions = defaultdict(list)
# Find all positions for each color
for y in range(rows):
for x in range(cols):
cell = grid[y][x]
if cell != '.' and cell.strip():
color_positions[cell].append((x, y))
endpoints = {}
for color, positions in color_positions.items():
if len(positions) == 2:
# Exactly 2 cells - these are the endpoints
endpoints[color] = positions
elif len(positions) == 1:
# Only one cell found - might be a single endpoint visible
# In problem boards, we expect exactly 2 endpoints per color
endpoints[color] = positions
else:
# More than 2 cells - this is a solution board, not a problem board
# For problem boards, we should only have endpoints
raise ValueError(f"Color '{color}' appears {len(positions)} times, expected 2 for problem board")
return endpoints
def convert_to_solver_input(endpoints: Dict[str, List[Tuple[int, int]]], cols: int, rows: int) -> str:
"""
Convert endpoints to solver input format.
Format:
<num_colors> <cols> <rows>
(x1, y1) (x2, y2)
...
"""
# Sort colors to ensure consistent ordering
sorted_colors = sorted(endpoints.keys())
num_colors = len(sorted_colors)
lines = [f"{num_colors} {cols} {rows}"]
for color in sorted_colors:
positions = endpoints[color]
if len(positions) == 2:
(x1, y1), (x2, y2) = positions
lines.append(f"({x1}, {y1}) ({x2}, {y2})")
elif len(positions) == 1:
# Only one endpoint found - this shouldn't happen in valid puzzles
# But handle it gracefully
(x1, y1) = positions[0]
lines.append(f"({x1}, {y1}) ({x1}, {y1})") # Duplicate as fallback
else:
raise ValueError(f"Color '{color}' has {len(positions)} endpoints, expected 2")
return "\n".join(lines) + "\n"
def run_solver(solver_bin: str, puzzle_input: str) -> Tuple[str, str, int]:
"""
Run the modified C solver with puzzle input.
Returns: (stdout, stderr, returncode)
"""
# Resolve path to absolute
solver_path = Path(solver_bin).resolve()
if not solver_path.exists():
raise SystemExit(f"Solver binary not found: {solver_path}")
try:
proc = subprocess.run(
[str(solver_path)],
input=puzzle_input,
text=True,
capture_output=True,
check=False,
)
return proc.stdout, proc.stderr, proc.returncode
except FileNotFoundError:
raise SystemExit(f"Solver binary not found: {solver_path}")
def parse_all_solutions(solver_output: str, cols: int, rows: int) -> List[List[List[str]]]:
"""
Parse solver output to extract ALL solution blocks.
Each solution block contains paths: (x1, y1) (x2, y2) ... (xn, yn) per color.
Solutions are separated by empty lines.
Returns: List of 2D grids (one per solution found).
"""
lines = solver_output.splitlines()
solutions = []
i = 0
# Find the number of colors from the first solution block
# We'll infer it from the first complete solution we find
num_colors = None
while i < len(lines):
# Skip status lines
if (SOLUTIONS_RE.match(lines[i]) or
"Touching" in lines[i] or
"Nodes" in lines[i] or
"Attempts" in lines[i] or
HEADER_RE.match(lines[i])): # Skip header lines if present
i += 1
continue
# Look for a solution block starting at an empty line or coordinate line
# Solutions start with an empty line (from puts("") in the solver)
# or directly with coordinate lines
if not lines[i].strip():
i += 1
# Next lines should be coordinate lines for a solution
# Collect coordinate lines for one solution
paths = []
j = i
while j < len(lines):
line = lines[j].strip()
# Stop at status lines
if (SOLUTIONS_RE.match(lines[j]) or
"Touching" in lines[j] or
"Nodes" in lines[j] or
"Attempts" in lines[j]):
break
# Stop at empty line if we already have paths (next solution)
if not line:
if paths:
break
j += 1
continue
# Try to parse coordinates
coords = COORD_RE.findall(line)
if coords:
path = [(int(x), int(y)) for x, y in coords]
paths.append(path)
j += 1
else:
# Non-coordinate line - might be end of solution or start of next
if paths:
break
j += 1
# If we found paths, it's a solution
if paths:
# Infer num_colors from first solution
if num_colors is None:
num_colors = len(paths)
# Only process if we have the expected number of colors
if len(paths) == num_colors:
try:
labels = letter_labels(num_colors)
grid = build_grid(cols, rows)
grid = place_paths_on_grid(cols, rows, paths, labels)
solutions.append(grid)
except Exception as e:
# Skip invalid solutions
pass
i = j
else:
i += 1
return solutions
def build_grid(cols: int, rows: int) -> List[List[str]]:
"""Build an empty grid."""
return [['.' for _ in range(cols)] for _ in range(rows)]
def place_paths_on_grid(cols: int, rows: int, paths: List[List[Tuple[int, int]]], labels: List[str]) -> List[List[str]]:
"""
Fill the grid with labels along paths.
Reused from convert_freeflow.py logic.
"""
grid = build_grid(cols, rows)
for label, path in zip(labels, paths):
for (x, y) in path:
if not (0 <= x < cols and 0 <= y < rows):
raise ValueError(f"Coordinate {(x,y)} out of bounds for {cols}x{rows}")
if grid[y][x] != '.' and grid[y][x] != label:
raise ValueError(f"Cell {(x,y)} already occupied by '{grid[y][x]}' (overlap).")
grid[y][x] = label
return grid
def convert_solution_to_ascii(grid: List[List[str]]) -> str:
"""Convert 2D grid back to ASCII string format."""
return '\n'.join(''.join(row) for row in grid)
def normalize_ascii_board(ascii_str: str) -> str:
"""Normalize ASCII board for comparison (handle whitespace, newlines)."""
lines = [line.strip() for line in ascii_str.strip().split('\n') if line.strip()]
return '\n'.join(lines)
def verify_solution(
problem_ascii: str,
solution_ascii: str,
solver_src: str = "./flowfree_all_solutions.c",
solver_bin: str = "./flowfree_all_solutions",
print_solutions: bool = False
) -> Tuple[bool, List[List[List[str]]]]:
"""
Main verification function.
Returns: (is_valid, all_solutions) where:
- is_valid: True if solution matches any valid solution, False otherwise
- all_solutions: List of all valid solutions found by the solver (as 2D grids)
"""
solver_src_path = Path(solver_src)
solver_bin_path = Path(solver_bin)
# Build solver if needed
if not solver_bin_path.exists():
if not solver_src_path.exists():
raise SystemExit(f"Solver source not found: {solver_src_path}")
build_all_solutions_solver(solver_src_path, solver_bin_path)
# Parse problem board
try:
problem_grid, cols, rows = parse_ascii_board(problem_ascii)
except Exception as e:
print(f"Error parsing problem board: {e}", file=sys.stderr)
return False, []
# Extract endpoints
try:
endpoints = extract_endpoints(problem_grid, cols, rows)
except Exception as e:
print(f"Error extracting endpoints: {e}", file=sys.stderr)
return False, []
# Convert to solver input
try:
puzzle_input = convert_to_solver_input(endpoints, cols, rows)
except Exception as e:
print(f"Error converting to solver input: {e}", file=sys.stderr)
return False, []
# Run solver
try:
stdout, stderr, returncode = run_solver(str(solver_bin_path), puzzle_input)
if returncode != 0:
print(f"Solver failed with exit code {returncode}", file=sys.stderr)
if stderr:
print(f"Stderr: {stderr}", file=sys.stderr)
return False, []
except Exception as e:
print(f"Error running solver: {e}", file=sys.stderr)
return False, []
# Parse all solutions
try:
solutions = parse_all_solutions(stdout, cols, rows)
except Exception as e:
print(f"Error parsing solutions: {e}", file=sys.stderr)
return False, []
if not solutions:
print("No solutions found by solver", file=sys.stderr)
return False, []
# Print all solutions if requested
if print_solutions:
print(f"\nFound {len(solutions)} solution(s):", file=sys.stderr)
for i, sol_grid in enumerate(solutions, 1):
sol_ascii = convert_solution_to_ascii(sol_grid)
print(f"\nSolution {i}:", file=sys.stderr)
print(sol_ascii, file=sys.stderr)
# Normalize the given solution
normalized_solution = normalize_ascii_board(solution_ascii)
# Compare against all solutions
for sol_grid in solutions:
sol_ascii = convert_solution_to_ascii(sol_grid)
normalized_sol = normalize_ascii_board(sol_ascii)
if normalized_solution == normalized_sol:
return True, solutions
return False, solutions
def main():
ap = argparse.ArgumentParser(
description="Verify if an ASCII board state is a valid solution to a FlowFree puzzle",
epilog="""
Example usage:
python verifier.py \\
--problem "BBBBBBBB\\nBFFFFFFB\\nFFGGGGFB\\nFGGAAGFB\\nFGAAGGBB\\nEGGGGDBC\\nEGGGDDBC\\nEEEEEBBC" \\
--solution "BBBBBBBB\\nBFFFFFFB\\nFFGGGGFB\\nFGGAAGFB\\nFGAAGGBB\\nEGGGGDBC\\nEGGGDDBC\\nEEEEEBBC"
"""
)
ap.add_argument("--problem", required=True, help="Problem board as ASCII string (with \\n for newlines)")
ap.add_argument("--solution", required=True, help="Solution board as ASCII string (with \\n for newlines)")
ap.add_argument("--solver-src", default="./flowfree_all_solutions.c", help="Path to solver source")
ap.add_argument("--solver-bin", default="./flowfree_all_solutions", help="Path to solver binary")
ap.add_argument("--print-solutions", action="store_true", help="Print all solutions found by the solver")
args = ap.parse_args()
# Convert \n escape sequences to actual newlines
problem = args.problem.replace('\\n', '\n')
solution = args.solution.replace('\\n', '\n')
is_valid, all_solutions = verify_solution(problem, solution, args.solver_src, args.solver_bin, args.print_solutions)
if is_valid:
print("VALID: Solution matches a valid solution found by the solver")
sys.exit(0)
else:
print("INVALID: Solution does not match any valid solution")
if args.print_solutions and all_solutions:
print(f"\nNote: The solver found {len(all_solutions)} valid solution(s) (printed above).", file=sys.stderr)
sys.exit(1)
# Example usage as a Python function:
#
# problem = '''BBBBBBBB
# BFFFFFFB
# FFGGGGFB
# FGGAAGFB
# FGAAGGBB
# EGGGGDBC
# EGGGDDBC
# EEEEEBBC'''
#
# solution = '''BBBBBBBB
# BFFFFFFB
# FFGGGGFB
# FGGAAGFB
# FGAAGGBB
# EGGGGDBC
# EGGGDDBC
# EEEEEBBC'''
#
# is_valid = verify_solution(problem, solution)
# print(f"Solution is valid: {is_valid}")
if __name__ == "__main__":
main()