From b4c8820fa55a84672108fa9e3ddecc67ab928c56 Mon Sep 17 00:00:00 2001 From: cypherair <262752927+cypherair@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:29:04 -0700 Subject: [PATCH 1/2] feat: add optimized thistlethwaite mode --- .../CubeKit/ThistlethwaiteSolver.swift | 403 ++++++++++++++++++ .../ThistlethwaiteSolverTests.swift | 185 +++++++- cube/AppModel.swift | 7 +- cube/Stores/SettingsStore.swift | 5 + 4 files changed, 588 insertions(+), 12 deletions(-) diff --git a/CubeKit/Sources/CubeKit/ThistlethwaiteSolver.swift b/CubeKit/Sources/CubeKit/ThistlethwaiteSolver.swift index 41c6673..ebe5b18 100644 --- a/CubeKit/Sources/CubeKit/ThistlethwaiteSolver.swift +++ b/CubeKit/Sources/CubeKit/ThistlethwaiteSolver.swift @@ -46,6 +46,19 @@ public struct ThistlethwaiteSolver: Sendable { return StagedSolution(stages: stages) } + /// A shorter, still staged, Thistlethwaite variant. It keeps the same + /// subgroup chain and move restrictions as `solve`, but searches several + /// phase exits and allows a small amount of phase slack to reduce the + /// total move count. If the deadline is hit, it returns the best complete + /// candidate found so far; the classic solution is always used as a + /// fallback. + public func solveOptimized( + _ state: CubeState, + timeBudget: Duration = .seconds(1) + ) -> StagedSolution? { + solveOptimized(state, config: .default, timeBudget: timeBudget) + } + /// Greedy descent over one phase's distance table: from distance d, /// some allowed move always reaches d − 1 (the move sets are closed /// under inverses, so a BFS parent edge runs backwards from every @@ -71,3 +84,393 @@ public struct ThistlethwaiteSolver: Sendable { return moves } } + +// MARK: - Optimized search + +struct ThistlethwaiteOptimizationConfig: Sendable { + var beamWidth: Int + var endpointCap: Int + var phaseSlack: [Int] + + static let `default` = ThistlethwaiteOptimizationConfig( + beamWidth: 64, + endpointCap: 256, + phaseSlack: [1, 2, 2, 0] + ) +} + +extension ThistlethwaiteSolver { + func solveOptimized( + _ state: CubeState, + config: ThistlethwaiteOptimizationConfig, + timeBudget: Duration + ) -> StagedSolution? { + guard state.isLegal else { return nil } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeBudget) + func expired() -> Bool { clock.now >= deadline } + + var best: StagedSolution? + func consider(_ candidate: StagedSolution?) { + guard let candidate else { return } + let merged = stageBoundaryMerged(candidate) + assert(stagesAreValid(merged, from: state)) + if isBetter(merged, than: best) { + best = merged + } + } + + // A complete, classic candidate is always available for legal states, + // so timeout paths never return a partial search result. + consider(solve(state)) + for policy in GreedyPolicy.allCases where !expired() { + consider(solveGreedy(state, policy: policy)) + } + if !expired() { + consider(solveBeam(state, config: config, expired: expired)) + } + return best + } + + private enum GreedyPolicy: CaseIterable { + case current + case reversed + case halfTurnsFirst + case nextPhaseEntry + } + + private struct Candidate { + var state: CubeState + var stages: [[Move]] + var score: Int + + var length: Int { stages.reduce(0) { $0 + $1.count } } + var solution: StagedSolution { + StagedSolution(stages: zip(ThistlethwaiteStage.allCases, stages).map { + .init(stage: $0.0, moves: $0.1) + }) + } + } + + private struct PhasePath { + var state: CubeState + var moves: [Move] + var distance: Int + var lastFace: Int + var orderBias: Int + } + + private func solveGreedy( + _ state: CubeState, + policy: GreedyPolicy + ) -> StagedSolution? { + var current = state + var stages: [StagedSolution.Stage] = [] + for (phase, stage) in ThistlethwaiteStage.allCases.enumerated() { + guard let moves = descendGreedy(¤t, phase: phase, policy: policy) else { + return nil + } + stages.append(.init(stage: stage, moves: moves)) + } + return current.isSolved ? StagedSolution(stages: stages) : nil + } + + private func descendGreedy( + _ state: inout CubeState, + phase: Int, + policy: GreedyPolicy + ) -> [Move]? { + guard var distance = tables.distance(of: state, phase: phase) else { return nil } + var moves: [Move] = [] + while distance > 0 { + let allowed = orderedMoves(phase: phase, policy: policy) + var choices: [(move: Move, next: CubeState, score: Int)] = [] + for (index, move) in allowed.enumerated() { + let next = state.applying(move) + guard tables.distance(of: next, phase: phase) == distance - 1 else { continue } + choices.append((move, next, greedyScore( + next: next, phase: phase, distance: distance, policy: policy, order: index))) + } + guard let choice = choices.min(by: { + $0.score == $1.score ? $0.move.rawValue < $1.move.rawValue : $0.score < $1.score + }) else { return nil } + state = choice.next + moves.append(choice.move) + distance -= 1 + } + return moves + } + + private func greedyScore( + next: CubeState, + phase: Int, + distance: Int, + policy: GreedyPolicy, + order: Int + ) -> Int { + switch policy { + case .nextPhaseEntry where distance == 1 && phase < 3: + return (tables.distance(of: next, phase: phase + 1) ?? 1_000) * 100 + order + default: + return order + } + } + + private func solveBeam( + _ state: CubeState, + config: ThistlethwaiteOptimizationConfig, + expired: () -> Bool + ) -> StagedSolution? { + var candidates = [Candidate(state: state, stages: [], score: 0)] + for phase in 0..<4 { + var nextCandidates: [Candidate] = [] + let endpointLimit = max(1, config.endpointCap / max(candidates.count, 1)) + for candidate in candidates { + if expired() { break } + let endpoints = phaseEndpoints( + from: candidate.state, phase: phase, config: config, + endpointLimit: endpointLimit, expired: expired) + for endpoint in endpoints { + var stages = candidate.stages + stages.append(endpoint.moves) + var next = Candidate( + state: endpoint.state, + stages: stages, + score: candidate.length + endpoint.moves.count) + next.score = score(candidate: next, nextPhase: phase + 1) + nextCandidates.append(next) + } + } + if nextCandidates.isEmpty { return nil } + candidates = prune(nextCandidates, limit: config.endpointCap) + .prefix(config.beamWidth) + .map { $0 } + } + + var best: StagedSolution? + for candidate in candidates where candidate.state.isSolved { + let solution = candidate.solution + let merged = stageBoundaryMerged(solution) + if isBetter(merged, than: best.map(stageBoundaryMerged)) { + best = solution + } + } + return best + } + + private func phaseEndpoints( + from state: CubeState, + phase: Int, + config: ThistlethwaiteOptimizationConfig, + endpointLimit: Int, + expired: () -> Bool + ) -> [PhasePath] { + guard let startDistance = tables.distance(of: state, phase: phase) else { return [] } + let slack = phase < config.phaseSlack.count ? config.phaseSlack[phase] : 0 + let bound = startDistance + slack + var endpoints: [PhasePath] = [] + var frontier = [PhasePath( + state: state, moves: [], distance: startDistance, lastFace: -1, orderBias: 0)] + + for depth in 0...bound { + if expired() { break } + var nextFrontier: [PhasePath] = [] + for path in frontier { + if path.distance == 0 { + endpoints.append(path) + continue + } + guard depth < bound else { continue } + let remainingAfterMove = bound - depth - 1 + for (order, move) in orderedMoves(phase: phase, policy: .halfTurnsFirst).enumerated() { + let face = move.face.rawValue + guard faceAllowed(face, after: path.lastFace) else { continue } + let next = path.state.applying(move) + guard let distance = tables.distance(of: next, phase: phase), + distance <= remainingAfterMove + else { continue } + nextFrontier.append(PhasePath( + state: next, + moves: path.moves + [move], + distance: distance, + lastFace: face, + orderBias: path.orderBias * 31 + order)) + } + } + if endpoints.count >= endpointLimit { break } + frontier = prunePaths(nextFrontier, phase: phase, limit: config.beamWidth) + } + + return Array(prunePaths(endpoints, phase: phase, limit: endpointLimit) + .prefix(endpointLimit)) + } + + private func score(candidate: Candidate, nextPhase: Int) -> Int { + guard nextPhase < 4 else { return candidate.length } + var current = candidate.state + var length = candidate.length + for phase in nextPhase..<4 { + guard let moves = descendGreedy(¤t, phase: phase, policy: .nextPhaseEntry) + else { return Int.max / 2 } + length += moves.count + } + return length + } + + private func orderedMoves(phase: Int, policy: GreedyPolicy) -> [Move] { + let moves = ThistlethwaiteTables.phaseMoves[phase].map { Move(rawValue: $0)! } + switch policy { + case .current, .nextPhaseEntry: + return moves + case .reversed: + return moves.reversed() + case .halfTurnsFirst: + return moves.enumerated().sorted { + let lhsHalf = $0.element.quarterTurns == 2 + let rhsHalf = $1.element.quarterTurns == 2 + if lhsHalf != rhsHalf { return lhsHalf } + return $0.offset < $1.offset + }.map(\.element) + } + } + + private func prune(_ candidates: [Candidate], limit: Int) -> [Candidate] { + var bestByState: [CubeState: Candidate] = [:] + for candidate in candidates { + if let existing = bestByState[candidate.state], + !candidateSortPrecedes(candidate, existing) { + continue + } + bestByState[candidate.state] = candidate + } + return bestByState.values.sorted(by: candidateSortPrecedes).prefix(limit).map { $0 } + } + + private func prunePaths(_ paths: [PhasePath], phase: Int, limit: Int) -> [PhasePath] { + var bestByState: [CubeState: PhasePath] = [:] + for path in paths { + if let existing = bestByState[path.state], !pathSortPrecedes(path, existing, phase: phase) { + continue + } + bestByState[path.state] = path + } + return bestByState.values.sorted { pathSortPrecedes($0, $1, phase: phase) } + .prefix(limit) + .map { $0 } + } + + private func candidateSortPrecedes(_ lhs: Candidate, _ rhs: Candidate) -> Bool { + if lhs.score != rhs.score { return lhs.score < rhs.score } + if lhs.length != rhs.length { return lhs.length < rhs.length } + return lexicographicallyPrecedes(lhs.stages.flatMap { $0 }, rhs.stages.flatMap { $0 }) + } + + private func pathSortPrecedes(_ lhs: PhasePath, _ rhs: PhasePath, phase: Int) -> Bool { + if lhs.distance != rhs.distance { return lhs.distance < rhs.distance } + let lhsNext = phase < 3 && lhs.distance == 0 + ? tables.distance(of: lhs.state, phase: phase + 1) ?? 1_000 + : 1_000 + let rhsNext = phase < 3 && rhs.distance == 0 + ? tables.distance(of: rhs.state, phase: phase + 1) ?? 1_000 + : 1_000 + if lhsNext != rhsNext { return lhsNext < rhsNext } + if lhs.moves.count != rhs.moves.count { return lhs.moves.count < rhs.moves.count } + if lhs.orderBias != rhs.orderBias { return lhs.orderBias < rhs.orderBias } + return lexicographicallyPrecedes(lhs.moves, rhs.moves) + } + + private func isBetter( + _ lhs: StagedSolution, + than rhs: StagedSolution? + ) -> Bool { + guard let rhs else { return true } + if lhs.moves.count != rhs.moves.count { return lhs.moves.count < rhs.moves.count } + return lexicographicallyPrecedes(lhs.moves, rhs.moves) + } + + private func lexicographicallyPrecedes(_ lhs: [Move], _ rhs: [Move]) -> Bool { + for (a, b) in zip(lhs, rhs) where a.rawValue != b.rawValue { + return a.rawValue < b.rawValue + } + return lhs.count < rhs.count + } + + /// Never repeat a face; force opposite-face pairs into one canonical order + /// to keep slack search from spending budget on duplicate commutations. + private func faceAllowed(_ face: Int, after lastFace: Int) -> Bool { + lastFace < 0 || (face != lastFace && face + 3 != lastFace) + } +} + +// MARK: - Stage-aware cleanup and validation + +extension ThistlethwaiteSolver { + func stageBoundaryMerged( + _ solution: StagedSolution + ) -> StagedSolution { + var stages = solution.stages.map { (stage: $0.stage, moves: $0.moves) } + var left = 0 + while left < stages.count { + guard !stages[left].moves.isEmpty else { + left += 1 + continue + } + var right = left + 1 + while right < stages.count, stages[right].moves.isEmpty { right += 1 } + guard right < stages.count else { break } + + guard let previous = stages[left].moves.last, + let next = stages[right].moves.first, + previous.face == next.face + else { + left = right + continue + } + + let turns = (previous.quarterTurns + next.quarterTurns) % 4 + stages[left].moves.removeLast() + stages[right].moves.removeFirst() + if turns != 0 { + stages[left].moves.append(Move(face: previous.face, quarterTurns: turns)) + } + } + + return StagedSolution(stages: stages.map { + .init(stage: $0.stage, moves: $0.moves) + }) + } + + func stagesAreValid( + _ solution: StagedSolution, + from start: CubeState + ) -> Bool { + var state = start + for (phase, stage) in solution.stages.enumerated() { + let allowed = Set(ThistlethwaiteTables.phaseMoves[phase].map { Move(rawValue: $0)! }) + guard stage.moves.allSatisfy({ allowed.contains($0) }) else { return false } + state = state.applying(stage.moves) + guard stageGoalHolds(phase: phase, state: state) else { return false } + } + return state.isSolved + } + + private func stageGoalHolds(phase: Int, state: CubeState) -> Bool { + var holds = state.edgeOrientation.allSatisfy { $0 == 0 } + if phase >= 1 { + holds = holds && state.cornerOrientation.allSatisfy { $0 == 0 } + && (8..<12).allSatisfy { state.edgePermutation[$0] >= 8 } + } + if phase >= 2 { + holds = holds && [1, 3, 5, 7].allSatisfy { + [1, 3, 5, 7].contains(state.edgePermutation[$0]) + } + holds = holds && tables.corner96Index[ + Coordinates.rankPermutation(state.cornerPermutation)] != nil + } + if phase >= 3 { + holds = holds && state.isSolved + } + return holds + } +} diff --git a/CubeKit/Tests/CubeKitTests/ThistlethwaiteSolverTests.swift b/CubeKit/Tests/CubeKitTests/ThistlethwaiteSolverTests.swift index 16d8ff0..fee27a6 100644 --- a/CubeKit/Tests/CubeKitTests/ThistlethwaiteSolverTests.swift +++ b/CubeKit/Tests/CubeKitTests/ThistlethwaiteSolverTests.swift @@ -6,6 +6,11 @@ import Testing private var solver: ThistlethwaiteSolver { ThistlethwaiteSolver(tables: TestTables.thistlethwaite) } + private static let fastTestConfig = ThistlethwaiteOptimizationConfig( + beamWidth: 8, endpointCap: 24, phaseSlack: [0, 1, 1, 0]) + private static var longSolverTestsEnabled: Bool { + ProcessInfo.processInfo.environment["CUBEKIT_LONG_SOLVER_TESTS"] == "1" + } /// Moves each phase may use, written out independently of the /// solver's own tables. @@ -70,18 +75,133 @@ import Testing let solution = try #require( solver.solve(start), "iteration \(iteration) returned nil") #expect(solution.stages.count == 4) - #expect(solution.moves.count <= 52, "iteration \(iteration) too long") + #expect(solution.moves.count <= 45, "iteration \(iteration) too long") - var state = start - for (phase, stage) in solution.stages.enumerated() { - #expect( - stage.moves.allSatisfy { Self.phaseMoves[phase].contains($0) }, - "phase \(phase + 1) used a forbidden move at iteration \(iteration)") - state = state.applying(stage.moves) - checkInvariant(phase: phase, state: state, iteration: iteration) - } - #expect(state.isSolved, "iteration \(iteration) not solved") + checkSolution(solution, from: start, iteration: iteration) + } + } + + @Test func optimizedSolvedCubeMatchesClassicShape() throws { + let solution = try #require(solver.solveOptimized(.solved)) + #expect(solution.stages.count == 4) + #expect(solution.stages.allSatisfy { $0.moves.isEmpty }) + } + + @Test func optimizedRejectsIllegalState() { + var state = CubeState.solved + state.cornerOrientation[0] = 1 + #expect(solver.solveOptimized(state) == nil) + } + + @Test func optimizedSolvesRandomStatesWithValidStages() throws { + var rng = SeededRandom(seed: 1982) + for iteration in 0..<40 { + let start = Scrambler.randomState(using: &rng) + let solution = try #require( + solver.solveOptimized( + start, config: Self.fastTestConfig, timeBudget: .milliseconds(120)), + "iteration \(iteration) returned nil") + #expect(solution.stages.count == 4) + checkSolution(solution, from: start, iteration: iteration) + checkNoMergeableBoundary(solution, iteration: iteration) + } + } + + @Test func optimizedIsNoLongerThanClassicBoundaryMergedOnSeededCorpus() throws { + var rng = SeededRandom(seed: 2718) + for iteration in 0..<40 { + let start = Scrambler.randomState(using: &rng) + let classic = try #require(solver.solve(start)) + let classicMerged = solver.stageBoundaryMerged(classic) + let optimized = try #require( + solver.solveOptimized( + start, config: Self.fastTestConfig, timeBudget: .milliseconds(120))) + #expect( + optimized.moves.count <= classicMerged.moves.count, + "iteration \(iteration): optimized \(optimized.moves.count) > classic \(classicMerged.moves.count)") + } + } + +#if !DEBUG + @Test func optimizedLengthDistributionIsHighTwentiesOnSeededCorpus() throws { + var rng = SeededRandom(seed: 0xC0DE) + var lengths: [Int] = [] + for _ in 0..<40 { + let solution = try #require( + solver.solveOptimized(Scrambler.randomState(using: &rng), timeBudget: .milliseconds(600))) + lengths.append(solution.moves.count) + } + lengths.sort() + let median = lengths[lengths.count / 2] + let p90 = lengths[lengths.count * 90 / 100] + #expect( + median <= 27, + "optimized median \(median), p90 \(p90); lengths \(lengths)") + } + + @Test func optimizedLongCorrectnessCorpusWhenEnabled() throws { + guard Self.longSolverTestsEnabled else { return } + + var rng = SeededRandom(seed: 0x500) + for iteration in 0..<500 { + let start = Scrambler.randomState(using: &rng) + let solution = try #require( + solver.solveOptimized(start, timeBudget: .seconds(1)), + "iteration \(iteration) returned nil") + checkSolution(solution, from: start, iteration: iteration) + checkNoMergeableBoundary(solution, iteration: iteration) + } + } + + @Test func optimizedLongReleaseBenchmarkWhenEnabled() throws { + guard Self.longSolverTestsEnabled else { return } + + var rng = SeededRandom(seed: 0x1_000) + var lengths: [Int] = [] + var milliseconds: [Double] = [] + for iteration in 0..<1000 { + let start = Scrambler.randomState(using: &rng) + let begin = Date() + let solution = try #require( + solver.solveOptimized(start, timeBudget: .seconds(1)), + "iteration \(iteration) returned nil") + milliseconds.append(Date().timeIntervalSince(begin) * 1000) + lengths.append(solution.moves.count) } + + let sortedLengths = lengths.sorted() + let sortedTimes = milliseconds.sorted() + let average = Double(lengths.reduce(0, +)) / Double(lengths.count) + let median = Self.percentile(sortedLengths, 50) + let p90 = Self.percentile(sortedLengths, 90) + let p95 = Self.percentile(sortedLengths, 95) + let p95Milliseconds = Self.percentile(sortedTimes, 95) + let maximum = sortedLengths.last ?? 0 + #expect( + median <= 27, + "avg \(average), median \(median), p90 \(p90), p95 \(p95), max \(maximum)") + #expect( + p95Milliseconds <= 1000, + "p95 solve time \(p95Milliseconds) ms; times \(sortedTimes)") + } +#endif + + @Test func stageBoundaryMergePreservesStageInvariants() throws { + let solutionMoves = [Move](notation: "F U F B R' F R F2 D' R B2 U B2 L L2 U R2 U B2 U2 L2 U' L2 D U2 R2 U2 R2 L2 F2 U2 R2 B2 U2 L2")! + let start = CubeState.solved.applying(solutionMoves.inverse) + let solution = try #require(solver.solve(start)) + let merged = solver.stageBoundaryMerged(solution) + #expect(merged.moves.count <= solution.moves.count) + checkSolution(merged, from: start, iteration: 0) + checkNoMergeableBoundary(merged, iteration: 0) + } + + @Test func tableReachabilityMatchesExpectedPhaseSpaces() { + let tables = TestTables.thistlethwaite + #expect(!tables.phase1.contains(-1)) + #expect(!tables.phase2.contains(-1)) + #expect(!tables.phase3.contains(-1)) + #expect(tables.phase4.lazy.filter { $0 >= 0 }.count == ThistlethwaiteTables.phase4Count / 2) } private func checkInvariant( @@ -105,6 +225,51 @@ import Testing sourceLocation: sourceLocation) } + private func checkSolution( + _ solution: StagedSolution, + from start: CubeState, + iteration: Int, + sourceLocation: SourceLocation = #_sourceLocation + ) { + var state = start + for (phase, stage) in solution.stages.enumerated() { + #expect( + stage.moves.allSatisfy { Self.phaseMoves[phase].contains($0) }, + "phase \(phase + 1) used a forbidden move at iteration \(iteration)", + sourceLocation: sourceLocation) + state = state.applying(stage.moves) + checkInvariant( + phase: phase, state: state, iteration: iteration, + sourceLocation: sourceLocation) + } + #expect(state.isSolved, "iteration \(iteration) not solved", sourceLocation: sourceLocation) + } + + private func checkNoMergeableBoundary( + _ solution: StagedSolution, + iteration: Int, + sourceLocation: SourceLocation = #_sourceLocation + ) { + var previous: Move? + for stage in solution.stages where !stage.moves.isEmpty { + if let previous, let first = stage.moves.first { + #expect( + previous.face != first.face, + "mergeable boundary before \(stage.stage.displayName) at iteration \(iteration)", + sourceLocation: sourceLocation) + } + previous = stage.moves.last + } + } + + private static func percentile(_ sorted: [Int], _ percentile: Int) -> Int { + sorted[min(sorted.count - 1, sorted.count * percentile / 100)] + } + + private static func percentile(_ sorted: [Double], _ percentile: Int) -> Double { + sorted[min(sorted.count - 1, sorted.count * percentile / 100)] + } + @Test func cachedTablesMatchGenerated() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("CubeKitThistlethwaiteRoundTrip-\(UUID().uuidString)") diff --git a/cube/AppModel.swift b/cube/AppModel.swift index 43e4214..263d5b5 100644 --- a/cube/AppModel.swift +++ b/cube/AppModel.swift @@ -268,12 +268,15 @@ final class AppModel { switch method { case .fast, .optimal: solution = solver.solve(state, timeBudget: .milliseconds(300)) - case .thistlethwaite: + case .thistlethwaite, .thistlethwaiteOptimized: let thistlethwaite = prebuiltThistlethwaite ?? (try? ThistlethwaiteTables.cached(in: directory)) .map(ThistlethwaiteSolver.init) builtThistlethwaite = thistlethwaite - if let staged = thistlethwaite?.solve(state) { + let staged = method == .thistlethwaiteOptimized + ? thistlethwaite?.solveOptimized(state, timeBudget: .seconds(1)) + : thistlethwaite?.solve(state) + if let staged { solution = staged.moves markers = Self.stageMarkers(of: staged) } else { diff --git a/cube/Stores/SettingsStore.swift b/cube/Stores/SettingsStore.swift index 1e8f06b..596b04a 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 same four-phase reduction, tuned across phase exits for + /// shorter total solutions rather than per-phase minimality. + case thistlethwaiteOptimized /// The speedcubing method: cross, F2L pairs, then the full /// OLL/PLL algorithm sets with named cases. case cfop @@ -120,6 +123,7 @@ final class SettingsStore { case .fast: "Fast" case .optimal: "Optimal" case .thistlethwaite: "Thistlethwaite" + case .thistlethwaiteOptimized: "Thistlethwaite+" case .cfop: "CFOP" case .beginner: "Beginner" } @@ -130,6 +134,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 .thistlethwaiteOptimized: "Optimized four-phase reduction (~high 20s)" case .cfop: "Cross, F2L, OLL, PLL — the speedcuber's way (~60 moves)" case .beginner: "Step by step, the way people learn (~200 moves)" } From b4f48b56d67351c153576acab50ef82d2beca6d4 Mon Sep 17 00:00:00 2001 From: cypherair <262752927+cypherair@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:29:20 -0700 Subject: [PATCH 2/2] chore: bump build number --- cubeone.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cubeone.xcodeproj/project.pbxproj b/cubeone.xcodeproj/project.pbxproj index 2a44a4c..78b4b2f 100644 --- a/cubeone.xcodeproj/project.pbxproj +++ b/cubeone.xcodeproj/project.pbxproj @@ -380,7 +380,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = cube/cube.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11001; + CURRENT_PROJECT_VERSION = 11002; DEVELOPMENT_TEAM = 7P9PPXP2SF; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = NO; @@ -426,7 +426,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = cube/cube.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11001; + CURRENT_PROJECT_VERSION = 11002; DEVELOPMENT_TEAM = 7P9PPXP2SF; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = NO; @@ -472,7 +472,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = cube/cube.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11001; + CURRENT_PROJECT_VERSION = 11002; DEVELOPMENT_TEAM = 7P9PPXP2SF; ENABLE_APP_SANDBOX = YES; ENABLE_ENHANCED_SECURITY = NO;