Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) and `BeginnerSolver` (layer-by-layer, returns a `StagedSolution`). 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), and `BeginnerSolver` (layer-by-layer). The staged solvers return a `StagedSolution<StageKind>` (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:
Expand Down
20 changes: 4 additions & 16 deletions CubeKit/Sources/CubeKit/BeginnerSolver.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// The stages of the classic layer-by-layer (beginner) method, in order.
public enum BeginnerStage: String, CaseIterable, Sendable {
public enum BeginnerStage: String, CaseIterable, Sendable, SolverStage {
case bottomCross
case bottomCorners
case middleEdges
Expand All @@ -21,24 +21,13 @@ public enum BeginnerStage: String, CaseIterable, Sendable {
}
}

/// A solution annotated with the method stage each move belongs to.
public struct StagedSolution: Sendable {
public struct Stage: Sendable {
public let stage: BeginnerStage
public let moves: [Move]
}

public let stages: [Stage]
public var moves: [Move] { stages.flatMap(\.moves) }
}

/// Solves the way people are taught to: layer by layer, with short
/// memorable algorithms. Solutions are long (~150–250 moves) but every
/// stage has a understandable goal — the point is to follow along.
public struct BeginnerSolver: Sendable {
public init() {}

public func solve(_ state: CubeState) -> StagedSolution? {
public func solve(_ state: CubeState) -> StagedSolution<BeginnerStage>? {
guard state.isLegal else { return nil }
var worker = Worker(state: state)
do {
Expand All @@ -59,7 +48,7 @@ private enum SolverFailure: Error {

private struct Worker {
var state: CubeState
var finishedStages: [StagedSolution.Stage] = []
var finishedStages: [StagedSolution<BeginnerStage>.Stage] = []
private var currentMoves: [Move] = []

init(state: CubeState) {
Expand All @@ -82,8 +71,7 @@ private struct Worker {
) rethrows {
currentMoves = []
try body(&self)
finishedStages.append(
StagedSolution.Stage(stage: stage, moves: Self.merged(currentMoves)))
finishedStages.append(.init(stage: stage, moves: Self.merged(currentMoves)))
currentMoves = []
}

Expand Down
148 changes: 20 additions & 128 deletions CubeKit/Sources/CubeKit/SolverTables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public final class SolverTables: Sendable {
// basic moves, flattened for the generators.
let moves = CubeState.basicMoves

twistMove = Self.buildOrientationTable(
twistMove = TableBuilder.orientationTable(
count: Coordinates.twistCount, pieceCount: 8, modulus: 3,
decode: Coordinates.cornerOrientations(forTwist:),
encode: { orientations in
Expand All @@ -47,7 +47,7 @@ public final class SolverTables: Sendable {
moveOrientation: { moves[$0].cornerOrientation }
)

flipMove = Self.buildOrientationTable(
flipMove = TableBuilder.orientationTable(
count: Coordinates.flipCount, pieceCount: 12, modulus: 2,
decode: Coordinates.edgeOrientations(forFlip:),
encode: { orientations in
Expand All @@ -61,50 +61,29 @@ public final class SolverTables: Sendable {

// Slice table: representative places slice edges at the ranked
// positions; the filler arrangement is irrelevant to the result.
var sliceTable = [UInt16](repeating: 0, count: Coordinates.sliceCount * 18)
for coordinate in 0..<Coordinates.sliceCount {
let positions = Set(Coordinates.slicePositions(forSlice: coordinate))
var permutation = [Int](repeating: 0, count: 12)
var nextSlice = 8
var nextOther = 0
for slot in 0..<12 {
if positions.contains(slot) {
permutation[slot] = nextSlice
nextSlice += 1
} else {
permutation[slot] = nextOther
nextOther += 1
}
}
for move in 0..<18 {
let moved = Self.permutationApplying(
permutation, moves[move / 3].edgePermutation, times: move % 3 + 1)
var rank = 0
var found = 0
for slot in 0..<12 where moved[slot] >= 8 {
found += 1
rank += Coordinates.binomial[slot][found]
}
sliceTable[coordinate * 18 + move] = UInt16(rank)
}
}
sliceMove = sliceTable
sliceMove = TableBuilder.occupancyTable(
slotCount: 12, markerCount: 4, totalSlots: 12, moves: Array(0..<18),
movePermutation: { moves[$0].edgePermutation }
)

cornerPermutationMove = Self.buildPermutationTable(
cornerPermutationMove = TableBuilder.permutationTable(
count: Coordinates.cornerPermutationCount, pieceCount: 8,
moves: Self.phase2Moves,
movePermutation: { moves[$0].cornerPermutation },
project: { $0 }, embed: { $0 }
)

udEdgePermutationMove = Self.buildPermutationTable(
udEdgePermutationMove = TableBuilder.permutationTable(
count: Coordinates.udEdgePermutationCount, pieceCount: 8,
moves: Self.phase2Moves,
movePermutation: { moves[$0].edgePermutation },
project: { Array($0[0..<8]) },
embed: { $0 + [8, 9, 10, 11] }
)

sliceEdgePermutationMove = Self.buildPermutationTable(
sliceEdgePermutationMove = TableBuilder.permutationTable(
count: Coordinates.sliceEdgePermutationCount, pieceCount: 4,
moves: Self.phase2Moves,
movePermutation: { moves[$0].edgePermutation },
project: { $0[8..<12].map { $0 - 8 } },
embed: { Array(0..<8) + $0.map { $0 + 8 } }
Expand All @@ -118,32 +97,32 @@ public final class SolverTables: Sendable {
let udEdgePermutationMove = self.udEdgePermutationMove
let sliceEdgePermutationMove = self.sliceEdgePermutationMove

prune1SliceFlip = Self.breadthFirstDistances(
prune1SliceFlip = TableBuilder.breadthFirstDistances(
stateCount: Coordinates.sliceCount * Coordinates.flipCount,
moveCount: 18,
start: Coordinates.solvedSlice * Coordinates.flipCount
starts: [Coordinates.solvedSlice * Coordinates.flipCount]
) { state, move in
let slice = state / Coordinates.flipCount
let flip = state % Coordinates.flipCount
return Int(sliceMove[slice * 18 + move]) * Coordinates.flipCount
+ Int(flipMove[flip * 18 + move])
}

prune1SliceTwist = Self.breadthFirstDistances(
prune1SliceTwist = TableBuilder.breadthFirstDistances(
stateCount: Coordinates.sliceCount * Coordinates.twistCount,
moveCount: 18,
start: Coordinates.solvedSlice * Coordinates.twistCount
starts: [Coordinates.solvedSlice * Coordinates.twistCount]
) { state, move in
let slice = state / Coordinates.twistCount
let twist = state % Coordinates.twistCount
return Int(sliceMove[slice * 18 + move]) * Coordinates.twistCount
+ Int(twistMove[twist * 18 + move])
}

prune2SliceCorner = Self.breadthFirstDistances(
prune2SliceCorner = TableBuilder.breadthFirstDistances(
stateCount: Coordinates.sliceEdgePermutationCount * Coordinates.cornerPermutationCount,
moveCount: 10,
start: 0
starts: [0]
) { state, move in
let slicePerm = state / Coordinates.cornerPermutationCount
let cornerPerm = state % Coordinates.cornerPermutationCount
Expand All @@ -152,10 +131,10 @@ public final class SolverTables: Sendable {
+ Int(cornerPermutationMove[cornerPerm * 10 + move])
}

prune2SliceEdge = Self.breadthFirstDistances(
prune2SliceEdge = TableBuilder.breadthFirstDistances(
stateCount: Coordinates.sliceEdgePermutationCount * Coordinates.udEdgePermutationCount,
moveCount: 10,
start: 0
starts: [0]
) { state, move in
let slicePerm = state / Coordinates.udEdgePermutationCount
let edgePerm = state % Coordinates.udEdgePermutationCount
Expand Down Expand Up @@ -184,93 +163,6 @@ public final class SolverTables: Sendable {
self.prune2SliceEdge = prune2SliceEdge
}

// MARK: Generation helpers

private static func permutationApplying(
_ permutation: [Int], _ movePermutation: [Int], times: Int
) -> [Int] {
var result = permutation
for _ in 0..<times {
var next = result
for i in 0..<result.count { next[i] = result[movePermutation[i]] }
result = next
}
return result
}

private static func buildOrientationTable(
count: Int, pieceCount: Int, modulus: Int,
decode: (Int) -> [Int], encode: ([Int]) -> Int,
movePermutation: (Int) -> [Int], moveOrientation: (Int) -> [Int]
) -> [UInt16] {
var table = [UInt16](repeating: 0, count: count * 18)
for coordinate in 0..<count {
let orientations = decode(coordinate)
for move in 0..<18 {
let face = move / 3
let permutation = movePermutation(face)
let orientation = moveOrientation(face)
var current = orientations
for _ in 0..<(move % 3 + 1) {
var next = current
for i in 0..<pieceCount {
next[i] = (current[permutation[i]] + orientation[i]) % modulus
}
current = next
}
table[coordinate * 18 + move] = UInt16(encode(current))
}
}
return table
}

/// Builds a phase-2 permutation move table. `project` extracts the
/// ranked sub-permutation from a full arrangement; `embed` rebuilds a
/// representative full arrangement from it.
private static func buildPermutationTable(
count: Int, pieceCount: Int,
movePermutation: (Int) -> [Int],
project: ([Int]) -> [Int], embed: ([Int]) -> [Int]
) -> [UInt16] {
var table = [UInt16](repeating: 0, count: count * 10)
for coordinate in 0..<count {
let full = embed(Coordinates.unrankPermutation(coordinate, count: pieceCount))
for (index, moveValue) in phase2Moves.enumerated() {
let moved = permutationApplying(
full, movePermutation(moveValue / 3), times: moveValue % 3 + 1)
table[coordinate * 10 + index] =
UInt16(Coordinates.rankPermutation(project(moved)))
}
}
return table
}

private static func breadthFirstDistances(
stateCount: Int, moveCount: Int, start: Int,
neighbor: (Int, Int) -> Int
) -> [Int8] {
var distances = [Int8](repeating: -1, count: stateCount)
var queue = [Int32]()
queue.reserveCapacity(stateCount)
distances[start] = 0
queue.append(Int32(start))
var head = 0
while head < queue.count {
let state = Int(queue[head])
head += 1
let next = distances[state] + 1
for move in 0..<moveCount {
let target = neighbor(state, move)
if distances[target] < 0 {
distances[target] = next
queue.append(Int32(target))
}
}
}
assert(!distances.contains(-1), "pruning table has unreachable states")
return distances
}

// MARK: Disk cache

private static let cacheVersion: UInt32 = 1
Expand Down
25 changes: 25 additions & 0 deletions CubeKit/Sources/CubeKit/StagedSolution.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// A named phase of a multi-stage solving method.
public protocol SolverStage: Hashable, Sendable {
var displayName: String { get }
}

/// A solution annotated with the method stage each move belongs to.
public struct StagedSolution<StageKind: SolverStage>: Sendable {
public struct Stage: Sendable {
public let stage: StageKind
public let moves: [Move]

public init(stage: StageKind, moves: [Move]) {
self.stage = stage
self.moves = moves
}
}

public let stages: [Stage]

public init(stages: [Stage]) {
self.stages = stages
}

public var moves: [Move] { stages.flatMap(\.moves) }
}
Loading
Loading