fix(brownan): fix stdout parser and end-to-end validation harness

- brownan_solver_wrapper.py: skip the 'Continuing to look...' status line
  that appears after 'Solution found!' so the actual move sequence is parsed
  instead of garbage tokens.
- validate_brownan_e2e.py: replace static TEST_FACELETS with pycuber-driven
  random scramble generation; verify each solver's output by re-applying it
  to the scrambled pycuber cube instead of relying on kociemba alone.
- Confirmed brownan binary table loading now succeeds after rebuilding with
  binary fopen flags (addresses Windows text-mode 0x1A EOF issue).
This commit is contained in:
axiomlogicnexus 2026-06-10 23:04:25 +00:00
parent a87fd89762
commit c2efdcaa86
2 changed files with 117 additions and 79 deletions

View file

@ -253,7 +253,11 @@ def solve(cube_input: str, brownan_exe: str = None) -> str:
continue
if in_solution:
if not stripped:
break
continue
# Skip the "Continuing to look..." status line; the actual move
# sequence is on the following line.
if stripped.startswith("Continuing"):
continue
for token in stripped.split():
wca = convert_move(token)
if wca:

View file

@ -1,16 +1,23 @@
#!/usr/bin/env python3
"""
End-to-end validation script for the brownan solver wrapper.
Tests brownan and cahidenes solvers against preset scrambled cube facelets.
Validates that both solvers return legal WCA move sequences that kociemba
confirms actually solve the cube.
Generates random scrambles with pycuber, feeds the resulting 54-character
kociemba-order facelet string to both the cahidenes and brownan wrappers,
then verifies each solution actually solves the cube by re-applying it in pycuber.
"""
import argparse
import os
import random
import subprocess
import sys
from pathlib import Path
try:
from pycuber import Cube, Formula
except ImportError:
print("ERROR: pycuber package not installed. Run: pip install pycuber")
sys.exit(1)
try:
import kociemba
except ImportError:
@ -18,46 +25,71 @@ except ImportError:
sys.exit(1)
# Pre-computed scrambled facelet strings (kociemba order: U R F D L B)
# Generated with pycuber using 25-move random scrambles.
TEST_FACELETS = [
# Scramble 1: R U2 F D R R2 L L' U U D2 L L R2 R2 B B2 F D2 B' F D' U B F'
"DDRBUDLLRBLBURDUFDBBDRFRLRFUDRUDLFLLFRUBLFLFFUFRBBUBUD",
# Scramble 2: L' U2 B2 U' U2 F2 L' L R U2 D' U U' F' R' D' F R' R2 R L2 D2 R D' B'
"RLRRUULURBFULRDRULDRDBFDURFFDDFDBFLUUFBDLRLLLFUBFBBBBD",
# Scramble 3: R2 L R' U U' B' U L2 F R' B2 B F D2 L2 F2 L' L' F D2 B U D2 D2 B2
"DDURUBRBDLLBRRLBFRBUFFFBBLDRDLUDRFFUFDDRLDUFULBRUBUFLL",
# Scramble 4: U' B2 B2 F2 U2 R R2 F2 F F' D' U2 R' L L R' R2 L D' D2 L L' B U' F
"BUDRUDURUBFFURDFLDFURLFBDFRFRUDDFRRLUDLULBDLRLFLBBLBBB",
# Scramble 5: U L U2 B L D2 B2 D' L2 B L2 R R' B2 R' B2 B D U' U2 L L U R2 U
"UBBDUBDURDRLBRLUUBBFFFFUUDRRLBUDRFFRFRLDLLLLFUDLBBFDRD",
]
# Map pycuber colour names to single uppercase letters used in kociemba facelets.
# The wrapper only requires that the 6 centres are distinct, so any consistent
# mapping works.
PYCUBER_TO_CHAR = {
'red': 'L',
'orange': 'R',
'green': 'F',
'blue': 'B',
'white': 'D',
'yellow': 'U',
}
FACES = ["U", "D", "F", "B", "L", "R"]
SUFFIXES = ["", "'", "2"]
N_SCRAMBLES = 10
SCRAMBLE_DEPTH = 12
def solve_with_cahidenes(facelets, wrapper_path):
"""Call the cahidenes solver wrapper and return move list."""
result = subprocess.run(
["python.exe", str(wrapper_path), facelets],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
print(f"Cahidenes solver error: {result.stderr.strip()}")
return []
return result.stdout.strip().split()
def facelet_string_from_cube(cube: Cube) -> str:
"""Build a 54-char kociemba-order facelet string from a pycuber Cube."""
out = []
for f in "URFDLB":
face = cube.get_face(f)
for row in face:
for cell in row:
out.append(PYCUBER_TO_CHAR[cell.colour])
return "".join(out)
def solve_with_brownan(facelets, wrapper_path):
"""Call the brownan solver wrapper and return move list."""
def random_scramble(depth: int = SCRAMBLE_DEPTH) -> str:
return " ".join(random.choice(FACES) + random.choice(SUFFIXES) for _ in range(depth))
def inverse_moves(moves_str: str) -> list[str]:
rev = list(reversed(moves_str.strip().split()))
out = []
for m in rev:
if m.endswith("'"):
out.append(m[:-1])
elif m.endswith("2"):
out.append(m)
else:
out.append(m + "'")
return out
def is_solved(cube: Cube) -> bool:
for f in "URFDLB":
face = cube.get_face(f)
colours = {cell.colour for row in face for cell in row}
if len(colours) != 1:
return False
return True
def solve_with_wrapper(wrapper_path: Path, facelets: str) -> list[str]:
result = subprocess.run(
["python.exe", str(wrapper_path), facelets],
capture_output=True,
text=True,
timeout=300,
cwd=str(wrapper_path.parent),
)
if result.returncode != 0:
print(f"Brownan solver error: {result.stderr.strip()}")
print(f" {wrapper_path.name} error (rc={result.returncode}): {result.stderr.strip()}")
return []
output = result.stdout.strip()
if not output:
@ -65,8 +97,7 @@ def solve_with_brownan(facelets, wrapper_path):
return output.split()
def verify_wca_moves(moves):
"""Check that all moves are valid WCA notation."""
def verify_wca_moves(moves: list[str]) -> bool:
faces = set("URFDLB")
for m in moves:
if not m:
@ -80,28 +111,13 @@ def verify_wca_moves(moves):
return True
def verify_solution(facelets, moves):
"""Use kociemba to verify that moves solve the facelet string."""
if not moves:
return False
try:
solution = kociemba.solve(facelets)
# Normalize: same move sequence up to equivalent representations
# For a quick check, just verify kociemba accepts the provided moves
# by concatenating and letting kociemba validate (it raises on invalid).
# Better: apply moves via kociemba pattern-solving? Just check length.
# Simpler heuristic: if moves are valid WCA and solver succeeded, trust it.
return True
except Exception as e:
print(f"kociemba verification error: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="Validate brownan solver end-to-end")
parser.add_argument("--brownan-wrapper", default=None, help="Path to brownan_solver_wrapper.py")
parser.add_argument("--cahidenes-wrapper", default=None, help="Path to cahidenes_solver_wrapper.py")
parser.add_argument("--count", type=int, default=len(TEST_FACELETS), help="Number of test cases")
parser.add_argument("--count", type=int, default=N_SCRAMBLES, help="Number of random scrambles to test")
parser.add_argument("--depth", type=int, default=SCRAMBLE_DEPTH, help="Scramble depth")
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
args = parser.parse_args()
solvers_dir = Path("C:/HyperTwist/UnrealHyperTwist/Binaries/Win64/Solvers")
@ -115,50 +131,68 @@ def main():
print(f"ERROR: Cahidenes wrapper not found at {cahidenes_wrapper}")
sys.exit(1)
if args.seed is not None:
random.seed(args.seed)
all_passed = True
count = min(args.count, len(TEST_FACELETS))
for i in range(count):
facelets = TEST_FACELETS[i]
print(f"\n=== Test {i+1}/{count} ===")
for i in range(args.count):
print(f"\n=== Test {i+1}/{args.count} ===")
scramble = random_scramble(args.depth)
print(f"Scramble: {scramble}")
c = Cube()
c(Formula(scramble))
facelets = facelet_string_from_cube(c)
print(f"Facelets: {facelets}")
# Validate the facelet itself with kociemba
# Sanity-check with kociemba
try:
canonical = kociemba.solve(facelets)
print(f"Canonical solution ({len(canonical.split())} moves): {canonical}")
print(f"Canonical ({len(canonical.split())} moves): {canonical}")
except Exception as e:
print(f"FAIL: Facelet invalid according to kociemba: {e}")
print(f"FAIL: kociemba rejected facelets: {e}")
all_passed = False
continue
cahidenes_moves = solve_with_cahidenes(facelets, cahidenes_wrapper)
print(f"Cahidenes ({len(cahidenes_moves)} moves): {' '.join(cahidenes_moves)}")
cahidenes_moves = solve_with_wrapper(cahidenes_wrapper, facelets)
brownan_moves = solve_with_wrapper(brownan_wrapper, facelets)
brownan_moves = solve_with_brownan(facelets, brownan_wrapper)
print(f"Cahidenes ({len(cahidenes_moves)} moves): {' '.join(cahidenes_moves)}")
print(f"Brownan ({len(brownan_moves)} moves): {' '.join(brownan_moves)}")
cah_ok = True
brn_ok = True
if not verify_wca_moves(cahidenes_moves):
print("FAIL: Cahidenes returned invalid WCA moves")
cah_ok = False
all_passed = False
continue
if not verify_wca_moves(brownan_moves):
print("FAIL: Brownan returned invalid WCA moves")
all_passed = False
continue
cahidenes_ok = verify_solution(facelets, cahidenes_moves)
brownan_ok = verify_solution(facelets, brownan_moves)
if not cahidenes_ok:
print("FAIL: Cahidenes solution failed kociemba verification")
all_passed = False
if not brownan_ok:
print("FAIL: Brownan solution failed kociemba verification")
brn_ok = False
all_passed = False
if cahidenes_ok and brownan_ok:
length_diff = abs(len(cahidenes_moves) - len(brownan_moves))
print(f"PASS: Both solvers valid; length diff = {length_diff}")
if cah_ok:
check = Cube()
check(Formula(scramble))
check(Formula(" ".join(cahidenes_moves)))
if not is_solved(check):
print("FAIL: Cahidenes solution did not solve the cube")
cah_ok = False
all_passed = False
else:
print("PASS: Cahidenes solution verified")
if brn_ok:
check = Cube()
check(Formula(scramble))
check(Formula(" ".join(brownan_moves)))
if not is_solved(check):
print("FAIL: Brownan solution did not solve the cube")
brn_ok = False
all_passed = False
else:
print("PASS: Brownan solution verified")
print("\n" + ("ALL TESTS PASSED" if all_passed else "SOME TESTS FAILED"))
sys.exit(0 if all_passed else 1)