diff --git a/CLAUDE.md b/CLAUDE.md index 6636a85..a77f085 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Optimal-solver pattern databases are baked in-app (Settings › Solving): the 7- Two layers with a hard boundary: -- **CubeKit/** — local Swift package, zero UI dependencies, all cube logic. `CubeState` is the cubie-level source of truth (corner/edge permutation + orientation, centers fixed). `FaceletCube` is the 54-sticker view with legality validation. Solvers: `KociembaSolver` (two-phase, coordinate tables cached on disk, time-budgeted search), `ThistlethwaiteSolver` (four-phase group reduction, greedy descent over BFS distance tables, each phase provably shortest), and `BeginnerSolver` (layer-by-layer). The staged solvers return a `StagedSolution` (generic over the stage enum). Everything here is unit-tested; UI code never mutates cube state directly. +- **CubeKit/** — local Swift package, zero UI dependencies, all cube logic. `CubeState` is the cubie-level source of truth (corner/edge permutation + orientation, centers fixed). `FaceletCube` is the 54-sticker view with legality validation. Solvers: `KociembaSolver` (two-phase, coordinate tables cached on disk, time-budgeted search), `ThistlethwaiteSolver` (four-phase group reduction, greedy descent over BFS distance tables, each phase provably shortest), `CFOPSolver` (optimal cross, macro-search F2L, full 57-case OLL + 21-case PLL with derived recognition keys and exhaustive coverage tests), and `BeginnerSolver` (layer-by-layer). The staged solvers return a `StagedSolution` (generic over the stage enum). Everything here is unit-tested; UI code never mutates cube state directly. - **cube/** — the app target (folder-synchronized Xcode group; new files under `cube/` join the target automatically). `AppModel` owns the logical state; `CubeSceneController` owns the RealityKit scene. The scene *renders and animates*; every completed turn flows back through `onMoveCommitted` into `AppModel.commit`, which is the only place `cubeState` advances. Key mechanisms to understand before touching play/scene code: diff --git a/CubeKit/Sources/CubeKit/BeginnerSolver.swift b/CubeKit/Sources/CubeKit/BeginnerSolver.swift index ac759a8..0cb7d82 100644 --- a/CubeKit/Sources/CubeKit/BeginnerSolver.swift +++ b/CubeKit/Sources/CubeKit/BeginnerSolver.swift @@ -75,21 +75,9 @@ private struct Worker { currentMoves = [] } - /// Merges adjacent same-face turns (R R → R2, R R' → nothing). + /// Merges adjacent same-face turns (shared helper). private static func merged(_ moves: [Move]) -> [Move] { - var result: [Move] = [] - for move in moves { - if let last = result.last, last.face == move.face { - let turns = (last.quarterTurns + move.quarterTurns) % 4 - result.removeLast() - if turns != 0 { - result.append(Move(face: move.face, quarterTurns: turns)) - } - } else { - result.append(move) - } - } - return result + moves.merged() } // MARK: Move plumbing @@ -110,26 +98,9 @@ private struct Worker { if k > 0 { perform([Move(face: .up, quarterTurns: k)]) } } - /// Conjugates a sequence by k whole-cube y-rotations: the algorithm - /// performed as if the cube had been rotated about the U axis. - /// Calibrated so that slot indices (corners urf→ufl→ulb→ubr, edges - /// ur→uf→ul→ub) shift by +k, matching the U-turn piece cycle. + /// Conjugates a sequence by k whole-cube y-rotations (shared helper). private static func rotatedY(_ moves: [Move], times: Int) -> [Move] { - let k = ((times % 4) + 4) % 4 - guard k > 0 else { return moves } - // One y step maps the face that was Front to where U sends front - // pieces: F→L→B→R→F (U and D unchanged). - let map: [Face: Face] = [.front: .left, .left: .back, .back: .right, .right: .front] - var result = moves - for _ in 0.. Int { location.slot * 2 + location.ori diff --git a/CubeKit/Sources/CubeKit/CFOPAlgorithms.swift b/CubeKit/Sources/CubeKit/CFOPAlgorithms.swift new file mode 100644 index 0000000..c192e0a --- /dev/null +++ b/CubeKit/Sources/CubeKit/CFOPAlgorithms.swift @@ -0,0 +1,277 @@ +/// The last-layer algorithm tables for CFOP: the full 57-case OLL and +/// 21-case PLL sets, transcribed from the standard algorithm sheets +/// (extended notation — wide/slice/rotation tokens — is expanded to +/// outer turns by `ExtendedNotation`). +/// +/// Nothing here is trusted by transcription alone: recognition keys are +/// *derived* from each algorithm (the case an algorithm solves is the +/// state its inverse produces from solved), and `validationIssues()` +/// re-checks every entry — parseability, first-two-layer preservation, +/// key uniqueness, and exhaustive coverage of every legal last-layer +/// state. The test suite asserts the issue list is empty. +enum CFOPAlgorithms { + // MARK: OLL — orient the last layer (57 cases) + + static let ollEntries: [(name: String, notation: String)] = [ + ("1", "R U2 R2 F R F' U2 R' F R F'"), + ("2", "F R U R' U' F' f R U R' U' f'"), + ("3", "f R U R' U' f' U' F R U R' U' F'"), + ("4", "f R U R' U' f' U F R U R' U' F'"), + ("5", "r' U2 R U R' U r"), + ("6", "r U2 R' U' R U' r'"), + ("7", "r U R' U R U2 r'"), + ("8", "r' U' R U' R' U2 r"), + ("9", "R U R' U' R' F R2 U R' U' F'"), + ("10", "R U R' U R' F R F' R U2 R'"), + ("11", "r U R' U R' F R F' R U2 r'"), + ("12", "F R U R' U' F' U F R U R' U' F'"), + ("13", "F U R U' R2 F' R U R U' R'"), + ("14", "R' F R U R' F' R F U' F'"), + ("15", "r' U' r R' U' R U r' U r"), + ("16", "r U r' R U R' U' r U' r'"), + ("17", "R U R' U R' F R F' U2 R' F R F'"), + ("18", "r U R' U R U2 r2 U' R U' R' U2 r"), + ("19", "M U R U R' U' M' R' F R F'"), + ("20", "M U R U R' U' M2 U R U' r'"), + ("21", "R U2 R' U' R U R' U' R U' R'"), + ("22", "R U2 R2 U' R2 U' R2 U2 R"), + ("23", "R2 D' R U2 R' D R U2 R"), + ("24", "r U R' U' r' F R F'"), + ("25", "F' r U R' U' r' F R"), + ("26 (Anti-Sune)", "R U2 R' U' R U' R'"), + ("27 (Sune)", "R U R' U R U2 R'"), + ("28", "r U R' U' M U R U' R'"), + ("29", "R U R' U' R U' R' F' U' F R U R'"), + ("30", "F R' F R2 U' R' U' R U R' F2"), + ("31", "R' U' F U R U' R' F' R"), + ("32", "L U F' U' L' U L F L'"), + ("33", "R U R' U' R' F R F'"), + ("34", "R U R2 U' R' F R U R U' F'"), + ("35", "R U2 R2 F R F' R U2 R'"), + ("36", "L' U' L U' L' U L U L F' L' F"), + ("37", "F R' F' R U R U' R'"), + ("38", "R U R' U R U' R' U' R' F R F'"), + ("39", "L F' L' U' L U F U' L'"), + ("40", "R' F R U R' U' F' U R"), + ("41", "R U R' U R U2 R' F R U R' U' F'"), + ("42", "R' U' R U' R' U2 R F R U R' U' F'"), + ("43", "F' U' L' U L F"), + ("44", "F U R U' R' F'"), + ("45", "F R U R' U' F'"), + ("46", "R' U' R' F R F' U R"), + ("47", "R' U' R' F R F' R' F R F' U R"), + ("48", "F R U R' U' R U R' U' F'"), + ("49", "r U' r2 U r2 U r2 U' r"), + ("50", "r' U r2 U' r2 U' r2 U r'"), + ("51", "F U R U' R' U R U' R' F'"), + ("52", "R U R' U R U' B U' B' R'"), + ("53", "r' U' R U' R' U R U' R' U2 r"), + ("54", "r U R' U R U' R' U R U2 r'"), + ("55", "R' F R U R U' R2 F' R2 U' R' U R U R'"), + ("56", "r' U' r U' R' U R U' R' U R r' U r"), + ("57", "R U R' U' M' U R U' r'"), + ] + + // MARK: PLL — permute the last layer (21 cases) + + static let pllEntries: [(name: String, notation: String)] = [ + ("Aa", "x R' U R' D2 R U' R' D2 R2 x'"), + ("Ab", "x R2 D2 R U R' D2 R U' R x'"), + ("E", "x' R U' R' D R U R' D' R U R' D R U' R' D' x"), + ("F", "R' U' F' R U R' U' R' F R2 U' R' U' R U R' U R"), + ("Ga", "R2 U R' U R' U' R U' R2 U' D R' U R D'"), + ("Gb", "R' U' R U D' R2 U R' U R U' R U' R2 D"), + ("Gc", "R2 U' R U' R U R' U R2 U D' R U' R' D"), + ("Gd", "R U R' U' D R2 U' R U' R' U R' U R2 D'"), + ("H", "M2 U M2 U2 M2 U M2"), + ("Ja", "R' U L' U2 R U' R' U2 R L"), + ("Jb", "R U R' F' R U R' U' R' F R2 U' R'"), + ("Na", "R U R' U R U R' F' R U R' U' R' F R2 U' R' U2 R U' R'"), + ("Nb", "R' U R U' R' F' U' F R U R' F R' F' R U' R"), + ("Ra", "R U' R' U' R U R D R' U' R D' R' U2 R'"), + ("Rb", "R2 F R U R U' R' F' R U2 R' U2 R"), + ("T", "R U R' U' R' F R2 U' R' U' R U R' F'"), + ("Ua", "M2 U M U2 M' U M2"), + ("Ub", "M2 U' M U2 M' U' M2"), + ("V", "R' U R' U' y R' F' R2 U' R' U R' F R F"), + ("Y", "F R U' R' U' R U R' F' R U R' U' R' F R F'"), + ("Z", "M' U M2 U M2 U M' U2 M2"), + ] + + // MARK: Derived tables + + /// Last-layer orientation pattern: corner twists (base 3) and edge + /// flips (base 2) of slots 0–3, folded into one integer. Only + /// meaningful when the first two layers are solved. + static func ollPattern(of state: CubeState) -> Int { + var corners = 0 + var edges = 0 + for slot in 0..<4 { + corners = corners * 3 + state.cornerOrientation[slot] + edges = edges * 2 + state.edgeOrientation[slot] + } + return corners * 16 + edges + } + + static let orientedPattern = ollPattern(of: .solved) + + /// The case each OLL algorithm solves, derived from the algorithm + /// itself: its inverse applied to a solved cube. + static let ollTable: [Int: (name: String, moves: [Move])] = { + var table: [Int: (name: String, moves: [Move])] = [:] + for entry in ollEntries { + guard let moves = ExtendedNotation.moves(entry.notation) else { continue } + let preState = CubeState.solved.applying(moves.inverse) + table[ollPattern(of: preState)] = (entry.name, moves) + } + return table + }() + + static let pllMoves: [(name: String, moves: [Move])] = pllEntries.compactMap { entry in + guard let moves = ExtendedNotation.moves(entry.notation) else { return nil } + return (entry.name, moves) + } + + // MARK: Validation + + /// True when the bottom two layers (cross edges, middle edges, and + /// bottom corners) are all home. + static func firstTwoLayersSolved(_ state: CubeState) -> Bool { + (4..<12).allSatisfy { + state.edgePermutation[$0] == $0 && state.edgeOrientation[$0] == 0 + } && (4..<8).allSatisfy { + state.cornerPermutation[$0] == $0 && state.cornerOrientation[$0] == 0 + } + } + + /// A U turn carries last-layer orientations along unchanged, so a + /// pattern rotates by slot index. + static func rotatedPattern(_ pattern: Int) -> Int { + var corners = pattern / 16 + var edges = pattern % 16 + var cornerDigits = [0, 0, 0, 0] + var edgeDigits = [0, 0, 0, 0] + for i in stride(from: 3, through: 0, by: -1) { + cornerDigits[i] = corners % 3 + corners /= 3 + edgeDigits[i] = edges % 2 + edges /= 2 + } + // U sends slot i → i + 1. + var rotatedCorners = 0 + var rotatedEdges = 0 + for i in 0..<4 { + rotatedCorners = rotatedCorners * 3 + cornerDigits[(i + 3) % 4] + rotatedEdges = rotatedEdges * 2 + edgeDigits[(i + 3) % 4] + } + return rotatedCorners * 16 + rotatedEdges + } + + /// Every problem with the algorithm tables, as human-readable + /// strings; the test suite requires this to be empty. + static func validationIssues() -> [String] { + var issues: [String] = [] + + // OLL: parse, preserve F2L, produce distinct cases (also + // distinct modulo U rotation), and cover every legal pattern. + var seenOrbits: [Int: String] = [:] + for entry in ollEntries { + guard let moves = ExtendedNotation.moves(entry.notation) else { + issues.append("OLL \(entry.name): unparseable notation") + continue + } + let preState = CubeState.solved.applying(moves.inverse) + guard firstTwoLayersSolved(preState) else { + issues.append("OLL \(entry.name): does not preserve the first two layers") + continue + } + var pattern = ollPattern(of: preState) + if pattern == orientedPattern { + issues.append("OLL \(entry.name): solves nothing (already-oriented case)") + continue + } + // Canonical orbit representative: smallest of the 4 rotations. + var orbitMin = pattern + for _ in 0..<3 { + pattern = rotatedPattern(pattern) + orbitMin = min(orbitMin, pattern) + } + if let other = seenOrbits[orbitMin] { + issues.append("OLL \(entry.name): same case as OLL \(other) (mod U)") + } else { + seenOrbits[orbitMin] = entry.name + } + } + + // Coverage: all 27 × 8 legal orientation patterns. + for corners in 0..<81 { + let digits = [corners / 27 % 3, corners / 9 % 3, corners / 3 % 3, corners % 3] + guard digits.reduce(0, +) % 3 == 0 else { continue } + for edges in 0..<16 where edges.nonzeroBitCount % 2 == 0 { + var pattern = (((digits[0] * 3 + digits[1]) * 3 + digits[2]) * 3 + digits[3]) * 16 + edges + var covered = pattern == orientedPattern + for _ in 0..<4 { + if ollTable[pattern] != nil { covered = true } + pattern = rotatedPattern(pattern) + } + if !covered { + issues.append("OLL: no algorithm covers pattern \(pattern) " + + "(corners \(digits), edges \(String(edges, radix: 2)))") + } + } + } + + // PLL: parse, preserve F2L and last-layer orientation, and + // cover every legal last-layer permutation. + for entry in pllEntries { + guard let moves = ExtendedNotation.moves(entry.notation) else { + issues.append("PLL \(entry.name): unparseable notation") + continue + } + let preState = CubeState.solved.applying(moves.inverse) + if !firstTwoLayersSolved(preState) { + issues.append("PLL \(entry.name): does not preserve the first two layers") + } else if ollPattern(of: preState) != orientedPattern { + issues.append("PLL \(entry.name): disturbs last-layer orientation") + } else if preState.isSolved { + issues.append("PLL \(entry.name): solves nothing") + } + } + + let uTurns = [[], [Move.u], [Move.u2], [Move.uPrime]] + var pllStates: [CubeState] = [] + for cornerRank in 0..<24 { + let cp = Coordinates.unrankPermutation(cornerRank, count: 4) + for edgeRank in 0..<24 { + let ep = Coordinates.unrankPermutation(edgeRank, count: 4) + guard permutationParity(cp) == permutationParity(ep) else { continue } + var state = CubeState.solved + for i in 0..<4 { + state.cornerPermutation[i] = cp[i] + state.edgePermutation[i] = ep[i] + } + pllStates.append(state) + } + } + for state in pllStates { + var covered = false + outer: for pre in uTurns { + let aligned = state.applying(pre) + if aligned.isSolved { covered = true; break } + for entry in pllMoves { + let applied = aligned.applying(entry.moves) + for post in uTurns where applied.applying(post).isSolved { + covered = true + break outer + } + } + } + if !covered { + issues.append("PLL: no algorithm covers corners " + + "\(Array(state.cornerPermutation[0..<4])) edges \(Array(state.edgePermutation[0..<4]))") + } + } + + return issues + } +} diff --git a/CubeKit/Sources/CubeKit/CFOPSolver.swift b/CubeKit/Sources/CubeKit/CFOPSolver.swift new file mode 100644 index 0000000..ea230e1 --- /dev/null +++ b/CubeKit/Sources/CubeKit/CFOPSolver.swift @@ -0,0 +1,298 @@ +import Foundation + +/// The stages of a CFOP solve. F2L pairs are numbered by their slot +/// (1 = front-right, 2 = front-left, 3 = back-left, 4 = back-right); +/// the OLL and PLL stages carry the recognized case name. +public enum CFOPStage: Hashable, Sendable, SolverStage { + case cross + case f2lPair(Int) + case oll(String) + case pll(String) + + public var displayName: String { + switch self { + case .cross: "Cross" + case .f2lPair(let slot): "F2L pair \(slot)" + case .oll(let name): "OLL \(name)" + case .pll(let name): name == "AUF" ? "Final turn" : "PLL · \(name)" + } + } +} + +/// The speedcubing method: an optimal cross, four corner–edge pairs +/// inserted with the standard F2L triggers, then the full 57-case OLL +/// and 21-case PLL algorithm sets. Solutions run ~60 moves and read +/// the way a speedcuber would solve. +public struct CFOPSolver: Sendable { + public init() {} + + public func solve(_ state: CubeState) -> StagedSolution? { + guard state.isLegal else { return nil } + var worker = Worker(state: state) + do { + try worker.run() + return StagedSolution(stages: worker.finishedStages) + } catch { + assertionFailure("CFOP solver stuck: \(error)") + return nil + } + } +} + +// MARK: - Implementation + +private enum SolverFailure: Error { + case stuck(String) +} + +private struct Worker { + var state: CubeState + var finishedStages: [StagedSolution.Stage] = [] + private var currentMoves: [Move] = [] + + init(state: CubeState) { + self.state = state + } + + mutating func run() throws { + try record(.cross) { try $0.solveCross() } + + // Pairs in greedy order: always insert the cheapest one next. + var remaining = (0..<4).filter { !self.pairSolved($0) } + while !remaining.isEmpty { + var best: (pair: Int, path: [Move])? + for pair in remaining { + guard let path = pairPath(pair, unsolved: remaining) else { + throw SolverFailure.stuck("pair \(pair + 1)") + } + if best == nil || path.count < best!.path.count { + best = (pair, path) + } + } + let chosen = best! + record(.f2lPair(chosen.pair + 1)) { $0.perform(chosen.path) } + guard pairSolved(chosen.pair) else { + throw SolverFailure.stuck("pair \(chosen.pair + 1) not solved") + } + remaining.removeAll { $0 == chosen.pair } + } + + if CFOPAlgorithms.ollPattern(of: state) != CFOPAlgorithms.orientedPattern { + let (name, moves) = try recognizeOLL() + record(.oll(name)) { $0.perform(moves) } + } + if !state.isSolved { + let (name, moves) = try recognizePLL() + record(.pll(name)) { $0.perform(moves) } + } + guard state.isSolved else { throw SolverFailure.stuck("end state not solved") } + } + + private mutating func record( + _ stage: CFOPStage, _ body: (inout Worker) throws -> Void + ) rethrows { + currentMoves = [] + try body(&self) + let moves = currentMoves.merged() + if !moves.isEmpty { + finishedStages.append(.init(stage: stage, moves: moves)) + } + currentMoves = [] + } + + private mutating func perform(_ moves: [Move]) { + for move in moves { + state.apply(move) + currentMoves.append(move) + } + } + + // MARK: Piece queries + + private func edgeLocation(of piece: Int) -> (slot: Int, ori: Int) { + let slot = state.edgePermutation.firstIndex(of: piece)! + return (slot, state.edgeOrientation[slot]) + } + + private func cornerLocation(of piece: Int) -> (slot: Int, ori: Int) { + let slot = state.cornerPermutation.firstIndex(of: piece)! + return (slot, state.cornerOrientation[slot]) + } + + private func pairSolved(_ pair: Int) -> Bool { + let corner = 4 + pair + let edge = 8 + pair + return state.cornerPermutation[corner] == corner + && state.cornerOrientation[corner] == 0 + && state.edgePermutation[edge] == edge + && state.edgeOrientation[edge] == 0 + } + + // MARK: Cross — whole-cross optimal from a distance table + + /// Exact distances for the four cross edges (pieces 4–7) over all + /// 18 moves: P(12,4) · 2⁴ = 190,080 entries, built once per process. + private static let crossDistances: [Int8] = { + let positionCount = PartialPermutation.count(slots: 12, pieces: 4) + let solved = PartialPermutation.rank([4, 5, 6, 7], slots: 12) * 16 + return TableBuilder.breadthFirstDistances( + stateCount: positionCount * 16, moveCount: 18, starts: [solved] + ) { index, move in + let positions = PartialPermutation.unrank(index / 16, slots: 12, pieces: 4) + let action = PieceAction.edge[move] + var movedPositions = [Int](repeating: 0, count: 4) + var orientations = 0 + for piece in 0..<4 { + let code = action[positions[piece] * 2 + (index >> piece & 1)] + movedPositions[piece] = code / 2 + orientations |= (code % 2) << piece + } + return PartialPermutation.rank(movedPositions, slots: 12) * 16 + orientations + } + }() + + private static func crossIndex(of state: CubeState) -> Int { + var positions = [Int](repeating: 0, count: 4) + var orientations = 0 + for piece in 0..<4 { + let slot = state.edgePermutation.firstIndex(of: 4 + piece)! + positions[piece] = slot + orientations |= state.edgeOrientation[slot] << piece + } + return PartialPermutation.rank(positions, slots: 12) * 16 + orientations + } + + /// Greedy descent: the 18-move set is closed under inverses, so a + /// distance-reducing move always exists. Cross is ≤ 8 moves. + private mutating func solveCross() throws { + var distance = Int(Self.crossDistances[Self.crossIndex(of: state)]) + while distance > 0 { + var stepped = false + for move in Move.allCases { + let next = state.applying(move) + if Int(Self.crossDistances[Self.crossIndex(of: next)]) == distance - 1 { + perform([move]) + distance -= 1 + stepped = true + break + } + } + guard stepped else { throw SolverFailure.stuck("cross") } + } + } + + // MARK: F2L — pair search over the standard trigger macros + + /// Pop-out triggers per slot (front-right, front-left, back-left, + /// back-right): each disturbs only its own pair and the top layer + /// (cross edges pass through and return). + private static let slotTriggers: [[Move]] = [ + [Move](notation: "R U R' U'")!, + [Move](notation: "F U F' U'")!, + [Move](notation: "L U L' U'")!, + [Move](notation: "B U B' U'")!, + ] + + /// The standard inserts for the front-right slot; other slots use + /// their y-conjugates. + private static let baseInserts: [[Move]] = [ + [Move](notation: "R U R'")!, + [Move](notation: "R U' R'")!, + [Move](notation: "R U2 R'")!, + [Move](notation: "F' U F")!, + [Move](notation: "F' U' F")!, + [Move](notation: "F' U2 F")!, + ] + + private static let uTurns: [[Move]] = [ + [Move(face: .up, quarterTurns: 1)], + [Move(face: .up, quarterTurns: 2)], + [Move(face: .up, quarterTurns: 3)], + ] + + /// Cheapest macro path (by move count) that homes the pair: a + /// Dijkstra over the tracked corner+edge state (24 × 24 codes), + /// with an alphabet of U turns, this slot's inserts, and pop-out + /// triggers for the other unsolved slots. Every macro preserves + /// the cross and all other solved pairs, so any path is clean. + private func pairPath(_ pair: Int, unsolved: [Int]) -> [Move]? { + var alphabet = Self.uTurns + for insert in Self.baseInserts { + alphabet.append(insert.rotatedY(times: pair)) + } + for other in unsolved where other != pair { + alphabet.append(Self.slotTriggers[other]) + } + let cornerActions = alphabet.map { PieceAction.cornerAction(of: $0) } + let edgeActions = alphabet.map { PieceAction.edgeAction(of: $0) } + + let cornerStart = cornerLocation(of: 4 + pair) + let edgeStart = edgeLocation(of: 8 + pair) + let start = (cornerStart.slot * 3 + cornerStart.ori) * 24 + + edgeStart.slot * 2 + edgeStart.ori + let goal = ((4 + pair) * 3) * 24 + (8 + pair) * 2 + if start == goal { return [] } + + var cost = [Int](repeating: .max, count: 576) + var parent = [(state: Int, macro: Int)?](repeating: nil, count: 576) + var done = [Bool](repeating: false, count: 576) + cost[start] = 0 + while true { + var current = -1 + var currentCost = Int.max + for id in 0..<576 where !done[id] && cost[id] < currentCost { + current = id + currentCost = cost[id] + } + if current < 0 { return nil } + if current == goal { break } + done[current] = true + let cornerCode = current / 24 + let edgeCode = current % 24 + for (index, macro) in alphabet.enumerated() { + let next = cornerActions[index][cornerCode] * 24 + edgeActions[index][edgeCode] + let nextCost = currentCost + macro.count + if nextCost < cost[next] { + cost[next] = nextCost + parent[next] = (current, index) + } + } + } + + var path: [Move] = [] + var cursor = goal + while cursor != start { + guard let step = parent[cursor] else { return nil } + path.append(contentsOf: alphabet[step.macro].reversed()) + cursor = step.state + } + return path.reversed() + } + + // MARK: OLL and PLL recognition + + private func recognizeOLL() throws -> (name: String, moves: [Move]) { + for setup in [[]] + Self.uTurns { + let aligned = state.applying(setup) + if let entry = CFOPAlgorithms.ollTable[CFOPAlgorithms.ollPattern(of: aligned)] { + return (entry.name, setup + entry.moves) + } + } + throw SolverFailure.stuck("OLL recognition") + } + + private func recognizePLL() throws -> (name: String, moves: [Move]) { + let aufs: [[Move]] = [[]] + Self.uTurns + for setup in aufs { + let aligned = state.applying(setup) + if aligned.isSolved { return ("AUF", setup) } + for entry in CFOPAlgorithms.pllMoves { + let applied = aligned.applying(entry.moves) + for final in aufs where applied.applying(final).isSolved { + return (entry.name, setup + entry.moves + final) + } + } + } + throw SolverFailure.stuck("PLL recognition") + } +} diff --git a/CubeKit/Sources/CubeKit/ExtendedNotation.swift b/CubeKit/Sources/CubeKit/ExtendedNotation.swift new file mode 100644 index 0000000..1f96233 --- /dev/null +++ b/CubeKit/Sources/CubeKit/ExtendedNotation.swift @@ -0,0 +1,123 @@ +/// Parses extended cube notation — wide turns (r u f l d b), slice +/// turns (M E S), and whole-cube rotations (x y z) — into plain outer +/// face turns, so standard algorithm sheets can be transcribed +/// verbatim while solvers keep emitting outer turns only. +/// +/// A wide or slice turn is its outer-turn equivalent plus a whole-cube +/// reorientation (the same equivalences the scene uses for M/E/S); +/// the parser tracks the cumulative reorientation and rewrites every +/// later letter through it. The net reorientation at the end must be +/// the identity or a pure y-rotation: y keeps the U axis, so "first +/// two layers" and "last-layer orientation" statements survive it, +/// anything else would silently change what the algorithm means. +enum ExtendedNotation { + /// Letter substitutions for one quarter of each rotation: + /// algorithm letter → the face it lands on. Derived from the + /// positional cycles (y follows U: F→L→B→R; x follows R: F→U→B→D; + /// z follows F: U→R→D→L) — the letter map is the inverse cycle. + private static let yMap: [Face: Face] = [ + .front: .right, .right: .back, .back: .left, .left: .front, + ] + private static let xMap: [Face: Face] = [ + .up: .front, .front: .down, .down: .back, .back: .up, + ] + private static let zMap: [Face: Face] = [ + .up: .left, .left: .down, .down: .right, .right: .up, + ] + + private struct Frame { + /// Maps an algorithm-frame face to the absolute face to turn. + private var map: [Face] = Face.allCases + + func absolute(_ face: Face) -> Face { map[face.rawValue] } + + /// Applies `quarters` quarter-turns of a base rotation map. + mutating func rotate(_ rotation: [Face: Face], quarters: Int) { + for _ in 0..<(((quarters % 4) + 4) % 4) { + map = Face.allCases.map { absolute(rotation[$0] ?? $0) } + } + } + + var isIdentity: Bool { map == Face.allCases } + /// True when the residual rotation keeps the U axis in place. + var isPureY: Bool { map[Face.up.rawValue] == .up && map[Face.down.rawValue] == .down } + } + + /// Parses `notation`; returns nil for unknown tokens or when the + /// net reorientation tilts the U axis. + static func moves(_ notation: String) -> [Move]? { + var frame = Frame() + var moves: [Move] = [] + + func emit(_ face: Face, _ quarters: Int) { + moves.append(Move(face: frame.absolute(face), quarterTurns: quarters)) + } + + for token in notation.split(whereSeparator: \.isWhitespace) { + guard let letter = token.first else { return nil } + let quarters: Int + switch token.dropFirst() { + case "": quarters = 1 + case "2": quarters = 2 + case "'", "’": quarters = 3 + case "2'", "'2": quarters = 2 + default: return nil + } + + switch letter { + case "U", "R", "F", "D", "L", "B": + emit(Face(letter: letter)!, quarters) + case "x": + frame.rotate(xMap, quarters: quarters) + case "y": + frame.rotate(yMap, quarters: quarters) + case "z": + frame.rotate(zMap, quarters: quarters) + // Wide turn = opposite outer face + rotation: r = L then x. + case "u": + emit(.down, quarters) + frame.rotate(yMap, quarters: quarters) + case "r": + emit(.left, quarters) + frame.rotate(xMap, quarters: quarters) + case "f": + emit(.back, quarters) + frame.rotate(zMap, quarters: quarters) + case "d": + emit(.up, quarters) + frame.rotate(yMap, quarters: -quarters) + case "l": + emit(.right, quarters) + frame.rotate(xMap, quarters: -quarters) + case "b": + emit(.front, quarters) + frame.rotate(zMap, quarters: -quarters) + // Slice turns, per the fixed-center equivalences: + // M ≙ L' R + x', E ≙ U D' + y', S ≙ F' B + z. + case "M": + for _ in 0.. [Move] { + let k = ((times % 4) + 4) % 4 + guard k > 0 else { return self } + let map: [Face: Face] = [.front: .left, .left: .back, .back: .right, .right: .front] + var result = self + for _ in 0.. [Move] { + var result: [Move] = [] + for move in self { + if let last = result.last, last.face == move.face { + let turns = (last.quarterTurns + move.quarterTurns) % 4 + result.removeLast() + if turns != 0 { + result.append(Move(face: move.face, quarterTurns: turns)) + } + } else { + result.append(move) + } + } + return result + } +} + +/// Per-move actions on a single tracked piece, encoded as +/// `slot * orientations + orientation`. Used by the search-based solver +/// stages, which track only the pieces they care about. +enum PieceAction { + /// edge[move][slot*2+ori] = newSlot*2 + newOri + static let edge: [[Int]] = Move.allCases.map { move in + let cube = CubeState.solved.applying(move) + var table = [Int](repeating: 0, count: 24) + for slot in 0..<12 { + // The piece that was at `from` is now at `slot`. + let from = cube.edgePermutation[slot] + for ori in 0..<2 { + table[from * 2 + ori] = slot * 2 + ((ori + cube.edgeOrientation[slot]) % 2) + } + } + return table + } + + /// corner[move][slot*3+ori] = newSlot*3 + newOri + static let corner: [[Int]] = Move.allCases.map { move in + let cube = CubeState.solved.applying(move) + var table = [Int](repeating: 0, count: 24) + for slot in 0..<8 { + let from = cube.cornerPermutation[slot] + for ori in 0..<3 { + table[from * 3 + ori] = slot * 3 + ((ori + cube.cornerOrientation[slot]) % 3) + } + } + return table + } + + /// The action of a whole move sequence on edge codes. + static func edgeAction(of moves: [Move]) -> [Int] { + var table = Array(0..<24) + for move in moves { + let action = edge[move.rawValue] + table = table.map { action[$0] } + } + return table + } + + /// The action of a whole move sequence on corner codes. + static func cornerAction(of moves: [Move]) -> [Int] { + var table = Array(0..<24) + for move in moves { + let action = corner[move.rawValue] + table = table.map { action[$0] } + } + return table + } +} diff --git a/CubeKit/Tests/CubeKitTests/CFOPSolverTests.swift b/CubeKit/Tests/CubeKitTests/CFOPSolverTests.swift new file mode 100644 index 0000000..f641802 --- /dev/null +++ b/CubeKit/Tests/CubeKitTests/CFOPSolverTests.swift @@ -0,0 +1,190 @@ +import Foundation +import Testing +@testable import CubeKit + +@Suite struct CFOPSolverTests { + private let solver = CFOPSolver() + + // MARK: Extended notation anchors + + /// M2 U M2 U2 M2 U M2 is the H perm: opposite last-layer edges + /// swap, everything else stays — a strong anchor for the slice + /// expansion and frame-tracking in the parser. + @Test func parserExpandsSlicesCorrectly() throws { + let moves = try #require(ExtendedNotation.moves("M2 U M2 U2 M2 U M2")) + let state = CubeState.solved.applying(moves) + #expect(state.cornerPermutation == Array(0..<8)) + #expect(state.cornerOrientation == Array(repeating: 0, count: 8)) + #expect(state.edgeOrientation == Array(repeating: 0, count: 12)) + // ur↔ul, uf↔ub, rest home. + #expect(state.edgePermutation == [2, 3, 0, 1] + Array(4..<12)) + } + + /// Plain notation passes through unchanged; rotations remap the + /// letters that follow them. + @Test func parserHandlesRotations() throws { + #expect(ExtendedNotation.moves("R U R'") == [Move](notation: "R U R'")) + // After y the algorithm letter R refers to the absolute B face. + #expect(ExtendedNotation.moves("y R y'") == [Move.b]) + // x and z tilt the U axis: a net tilt is rejected, net y is fine. + #expect(ExtendedNotation.moves("x R") == nil) + #expect(ExtendedNotation.moves("y U") != nil) + // A wide turn is the opposite face plus a rotation: r ≙ L + x. + let wide = try #require(ExtendedNotation.moves("r U r'")) + let direct = try #require(ExtendedNotation.moves("L x U x' L'")) + #expect(CubeState.solved.applying(wide) == CubeState.solved.applying(direct)) + } + + // MARK: Algorithm table validation + + /// Parse, F2L preservation, case uniqueness, and exhaustive + /// coverage of all 216 orientation and 288 permutation states of + /// the last layer — every entry of the 57+21 tables is checked. + @Test func algorithmTablesAreValid() { + let issues = CFOPAlgorithms.validationIssues() + #expect(issues.isEmpty, "\(issues.joined(separator: "\n"))") + } + + /// By construction: each OLL algorithm, applied to the case its + /// own inverse builds (under a random pre-rotation), must orient + /// the last layer — and recognition must pick exactly that entry. + @Test func everyOLLCaseSolvesByConstruction() throws { + var rng = SeededRandom(seed: 57) + for entry in CFOPAlgorithms.ollEntries { + let moves = try #require(ExtendedNotation.moves(entry.notation)) + let preState = CubeState.solved.applying(moves.inverse) + let turns = Int.random(in: 0..<4, using: &rng) + let shifted = preState.applying( + turns > 0 ? [Move(face: .up, quarterTurns: turns)] : []) + let solution = try #require( + solver.solve(shifted), "OLL \(entry.name) returned nil") + let ollStages = solution.stages.filter { + if case .oll = $0.stage { return true } + return false + } + #expect(ollStages.count == 1, "OLL \(entry.name): expected one OLL stage") + if case .oll(let recognized) = ollStages.first?.stage { + #expect(recognized == entry.name, + "OLL \(entry.name) recognized as \(recognized)") + } + #expect(shifted.applying(solution.moves).isSolved) + } + } + + /// Each PLL algorithm's own case (under random pre- and post-AUF) + /// must be recognized by name and solved. + @Test func everyPLLCaseSolvesByConstruction() throws { + var rng = SeededRandom(seed: 21) + for entry in CFOPAlgorithms.pllEntries { + let moves = try #require(ExtendedNotation.moves(entry.notation)) + var preState = CubeState.solved.applying(moves.inverse) + let turns = Int.random(in: 0..<4, using: &rng) + if turns > 0 { + preState = preState.applying([Move(face: .up, quarterTurns: turns)]) + } + let solution = try #require( + solver.solve(preState), "PLL \(entry.name) returned nil") + let pllStages = solution.stages.filter { + if case .pll = $0.stage { return true } + return false + } + #expect(pllStages.count == 1, "PLL \(entry.name): expected one PLL stage") + if case .pll(let recognized) = pllStages.first?.stage { + #expect(recognized == entry.name, + "PLL \(entry.name) recognized as \(recognized)") + } + #expect(preState.applying(solution.moves).isSolved) + } + } + + // MARK: Solver behavior + + @Test func solvesSolvedCubeWithNoMoves() throws { + let solution = try #require(solver.solve(.solved)) + #expect(solution.moves.isEmpty) + } + + @Test func rejectsIllegalState() { + var state = CubeState.solved + state.cornerOrientation[0] = 1 + #expect(solver.solve(state) == nil) + } + + @Test func solvesRandomStatesWithValidStages() throws { + var rng = SeededRandom(seed: 2025) + for iteration in 0..<200 { + let start = Scrambler.randomState(using: &rng) + let solution = try #require( + solver.solve(start), "iteration \(iteration) returned nil") + + var state = start + var crossDone = false + var solvedPairs: Set = [] + var ollDone = false + for stage in solution.stages { + state = state.applying(stage.moves) + switch stage.stage { + case .cross: + #expect(!crossDone, "duplicate cross stage at iteration \(iteration)") + crossDone = true + case .f2lPair(let slot): + #expect(solvedPairs.insert(slot).inserted, + "pair \(slot) solved twice at iteration \(iteration)") + case .oll: + #expect(!ollDone, "duplicate OLL stage at iteration \(iteration)") + ollDone = true + case .pll: + break + } + checkInvariant( + state: state, solvedPairs: solvedPairs, crossDone: crossDone, + ollDone: ollDone, iteration: iteration) + } + #expect(state.isSolved, "iteration \(iteration) not solved") + + // The cross stage is table-optimal: never longer than 8. + if let cross = solution.stages.first(where: { $0.stage == .cross }) { + #expect(cross.moves.count <= 8) + } + } + } + + private func checkInvariant( + state: CubeState, solvedPairs: Set, crossDone: Bool, ollDone: Bool, + iteration: Int, sourceLocation: SourceLocation = #_sourceLocation + ) { + var holds = true + if crossDone { + holds = holds && (4..<8).allSatisfy { + state.edgePermutation[$0] == $0 && state.edgeOrientation[$0] == 0 + } + } + for slot in solvedPairs { + let corner = 3 + slot // slot is 1-based + let edge = 7 + slot + holds = holds && state.cornerPermutation[corner] == corner + && state.cornerOrientation[corner] == 0 + && state.edgePermutation[edge] == edge + && state.edgeOrientation[edge] == 0 + } + if ollDone { + holds = holds && (0..<4).allSatisfy { + state.cornerOrientation[$0] == 0 && state.edgeOrientation[$0] == 0 + } + } + #expect(holds, "stage invariant failed at iteration \(iteration)", + sourceLocation: sourceLocation) + } + + @Test func averageLengthIsReasonable() throws { + var rng = SeededRandom(seed: 77) + var total = 0 + for _ in 0..<40 { + let solution = try #require(solver.solve(Scrambler.randomState(using: &rng))) + total += solution.moves.count + } + let average = Double(total) / 40 + // Measured 60.6 over this seed at the time of writing. + #expect(average <= 70, "average CFOP solution length \(average)") + } +} diff --git a/cube/AppModel.swift b/cube/AppModel.swift index a34eb29..43e4214 100644 --- a/cube/AppModel.swift +++ b/cube/AppModel.swift @@ -279,6 +279,13 @@ final class AppModel { } else { solution = nil } + case .cfop: + if let staged = CFOPSolver().solve(state) { + solution = staged.moves + markers = Self.stageMarkers(of: staged) + } else { + solution = nil + } case .beginner: if let staged = BeginnerSolver().solve(state) { solution = staged.moves diff --git a/cube/Stores/SettingsStore.swift b/cube/Stores/SettingsStore.swift index eebaca9..1e8f06b 100644 --- a/cube/Stores/SettingsStore.swift +++ b/cube/Stores/SettingsStore.swift @@ -107,6 +107,9 @@ final class SettingsStore { /// Thistlethwaite's four-phase group reduction: each phase /// provably shortest within its move set. case thistlethwaite + /// The speedcubing method: cross, F2L pairs, then the full + /// OLL/PLL algorithm sets with named cases. + case cfop /// Classic layer-by-layer, the way people learn: long but /// followable, with named stages. case beginner @@ -117,6 +120,7 @@ final class SettingsStore { case .fast: "Fast" case .optimal: "Optimal" case .thistlethwaite: "Thistlethwaite" + case .cfop: "CFOP" case .beginner: "Beginner" } } @@ -126,6 +130,7 @@ final class SettingsStore { case .fast: "~20 moves, found instantly" case .optimal: "Proven shortest — takes minutes, needs tables" case .thistlethwaite: "Four phases, each provably shortest (~30–45 moves)" + case .cfop: "Cross, F2L, OLL, PLL — the speedcuber's way (~60 moves)" case .beginner: "Step by step, the way people learn (~200 moves)" } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ade881..f744158 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,6 +50,15 @@ Phase 3's goal is the subtle one (Thistlethwaite's "tetrad twist" condition). In All four tables (~5 MB) generate in about a second (release) and cache as `thistlethwaite-tables.bin` in Application Support, built lazily on the first Thistlethwaite solve. Playback shows the four named stages just like the beginner method (`StagedSolution` is generic over the stage kind: `StagedSolution`). +### CFOPSolver (the speedcuber's method) + +Cross, four F2L pairs, full OLL (57 cases), full PLL (21 cases) — staged playback carries the recognized case names ("OLL 27 (Sune)", "PLL · T"). Average solution ~60 moves. + +- **Cross** is table-optimal: an exact BFS distance table over the four cross edges (P(12,4) · 2⁴ = 190,080 entries, built once per process in milliseconds) drives a greedy descent — never more than 8 moves. +- **F2L** is a per-pair Dijkstra over the tracked corner+edge state (24 × 24 codes) with an alphabet of the standard triggers: U turns, the slot's six inserts (R U R' family and F' U F family, y-conjugated per slot), and pop-out triggers for the other unsolved slots. Every macro provably preserves the cross and all solved pairs, so any path is clean by construction — no 41-case table to transcribe, and the output still reads like human F2L. Pairs are inserted in greedy order (cheapest next). +- **OLL/PLL** are the full hand-transcribed algorithm sets, but nothing rests on transcription: recognition keys are *derived from the algorithms* (the case an algorithm solves is the state its inverse produces), and the validation suite checks every entry for parseability and first-two-layer preservation, asserts case uniqueness, and proves exhaustive coverage of all 216 last-layer orientation patterns and all 288 permutation states. OLL recognizes by orientation-pattern lookup under the four U rotations; PLL recognizes by trial (pre-AUF × algorithm × post-AUF). +- Algorithms are written in **extended notation** — wide turns, M/E/S slices, x/y/z rotations — exactly as standard sheets print them; `ExtendedNotation` expands them to outer turns using the same fixed-center equivalences as the scene (M ≙ L′ R + x′ …), tracking the cumulative frame so letters after a rotation land on the right faces. The net frame must end as identity or a pure y-rotation (anything else would tilt the U axis and change the algorithm's meaning); anchor tests pin the expansion (M2 U M2 U2 M2 U M2 must equal the H-perm). + ### OptimalSolver (proven-shortest solutions) Korf-style IDA* over **pattern databases**: nibble-packed tables holding the exact solve distance of three projections — all corner configurations (88M entries), and two overlapping edge subsets (7-edge tier: 2×511M entries ≈ 0.5 GB; 8-edge tier: 2×5.1B entries ≈ 4.8 GB). The heuristic is the max of the three lookups (admissible), so the first solution found by per-bound exhaustive deepening is provably optimal.