cayleyR is an R package for analyzing Cayley graphs of permutation puzzles. The package implements algorithms for cycle detection, state space exploration, bidirectional BFS pathfinding, and finding optimal operation sequences. Two families of puzzle are built in: TopSpin, whose group is generated by shift and reverse operations, and Rubik’s cubes of any size, whose moves are generated from the geometry — a turn of one layer about one axis. Both are handled in the same terms — a state is an integer vector, a move permutes it.
The core method — Iterative Cycle Intersection
(ICI), implemented in find_path_iterative() —
grows cycles from both endpoints and looks for states where the two
sides meet, using the meeting points as bridges to narrow the search. It
is described in the accompanying paper: CayleyR: Solving the TopSpin
puzzle via cycle intersection.
# From GitHub:
devtools::install_github("Zabis13/cayleyR")| Vignette | What it covers |
|---|---|
vignette("functions") |
Function reference |
vignette("human-algorithm") |
Solving TopSpin by hand, step by step |
vignette("landmark-geometry") |
Landmark states and the solid they span |
vignette("permutation-groups") |
One group object for TopSpin, the cube, or a puzzle of your own |
vignette("rubiks-cube") |
Stickers, moves, colours, and the five 3x3x3 solvers |
vignette("cube-4x4") |
Reduction, parity, the 4x4x4 solvers, Santa 2023 notation |
vignette("neural-solvers") |
Training a network to solve the cube, and Q-model scripts (needs ggmlR) |
Install with build_vignettes = TRUE to have them
available locally.
library(cayleyR)
# Basic operations
state <- 1:10
shift_left(state) # cyclic left shift
shift_right(state) # cyclic right shift
reverse_prefix(state, k = 4) # reverse first 4 elements
# Apply a sequence of operations
apply_operations(state, c("L", "R", "X"), k = 4)
# Find cycle length for an operation sequence
get_reachable_states_light(state, c("1", "3"), k = 4)
# Find best random operation sequences (flexible sorting)
find_best_random_combinations(
moves = c("1", "2", "3"),
combo_length = 10,
n_samples = 50,
n_top = 5,
start_state = 1:10,
k = 4,
sort_by = c("shortest", "most_unique") # or "longest", "most_repeated", etc.
)
# Bidirectional BFS shortest path
bidirectional_bfs(start = 1:10, target = c(2:10, 1), k = 4)
# Iterative path finder
find_path_iterative(
start_state = 1:10,
final_state = c(3, 1, 2, 4, 5, 6, 7, 8, 9, 10),
k = 4,
sort_by = c("longest", "most_unique")
)
# BFS highway path finder (auto-selects direct vs hub route)
find_path_bfs(
start_state = 1:10,
final_state = c(3, 1, 2, 4, 5, 6, 7, 8, 9, 10),
k = 4,
bfs_levels = 200,
sort_by = c("longest", "most_unique")
)
# Human-style solver: sort a scrambled ring the way a person would
state <- generate_state(20, k = 4, n_moves = 60)
res <- human_algorithm(state, k = 4)
res$found
res$path
# Or aim at an arbitrary target instead of 1:n
human_algorithm(state, final_state = c(2:20, 1), k = 4)A cube state is an integer vector of length 54, one entry per
sticker, and a move permutes it — the same shape of object the TopSpin
side works with. Stickers are numbered face by face (U 1–9,
R 10–18, F 19–27, D 28–36,
L 37–45, B 46–54), so the solved cube is
1:54. Face turns leave the six centres in place; slice
turns move them.
# the eighteen moves: twelve face turns and three slices
names(cube_moves(3))
#> [1] "U" "U'" "R" "R'" "F" "F'" "D" "D'" "L" "L'" "B" "B'"
#> [13] "M" "M'" "E" "E'" "S" "S'"
# apply a sequence, then undo it
g <- cube_group(3)
s <- group_apply(g, cube_identity(3), "R U R' U'")
identical(group_apply(g, s, group_inverse_seq(g, "R U R' U'")),
cube_identity(3))
#> TRUE
# how long a word takes to come back round
group_order(g, "R U") # 105
group_order(g, "R U R' U'") # 6Sequences go through the group API — group_apply(),
group_compose(), group_order(),
group_inverse_seq() — which is the same set of functions
TopSpin uses, and works on any cube size.
The alphabet is the quarter-turn metric: half turns
are not moves of their own, so R2 is the word
c("R", "R"). The choice matters because it is what a
shortest path is counted in — the face group has diameter 20 counting
half turns as one move, and 26 counting them as two.
# any cube size, from the same generator
names(cube_moves(2)) # 12: face turns only, no slices
names(cube_moves(4)) # 24: face turns plus numbered inner layers
names(cube_moves(5)) # 30
# a single layer about an axis, by number
identical(cube_layer_move(3, axis = 1, layer = 2, turns = 1),
cube_moves(3)[["M"]])
#> TRUESpinning a word round and round is the cube counterpart of the cycle
expansion the ICI solver is built on. group_compose()
multiplies the word out into one permutation, so each repetition is a
single indexing step, and a short word reaches states far from its start
without a BFS frontier having to hold them.
p <- group_compose(g, "R U")
s <- cube_identity(3)
loop <- list()
repeat {
s <- s[p]
loop[[length(loop) + 1]] <- s
if (identical(s, cube_identity(3))) break
}
length(loop) # 105 repetitions to close, every state on the loopA move is an axis, a layer along it, and a quarter turn. That
vocabulary covers every cube, so the permutations are generated from the
geometry rather than tabulated: a cube of side n has
6n^2 stickers and 6n moves.
cube_group(2) # 24 stickers, 12 moves
cube_group(3) # 54 stickers, 18 moves
cube_group(4) # 96 stickers, 24 moves
cube_move_names(3)
#> [1] "U" "U'" "R" "R'" "F" "F'" "D" "D'" "L" "L'" "B" "B'"
#> [13] "M" "M'" "E" "E'" "S" "S'"The 3x3x3 slice turns are not a special case: the three layers of an
axis are two faces and the slice between them, so M,
E and S come out of the same rule as
R and L. Inner layers of larger cubes have no
standard letter and are written axis-and-index, "1x" being
layer 1 along x.
Half turns are words rather than letters here — U2 is
"U U". This is the quarter-turn metric, and it is what a
shortest path gets counted in.
Once slices are in play the centres move, and a cube turned bodily in space is solved without its stickers being back where they started:
g <- cube_group(3)
s <- group_apply(g, cube_identity(3), c("R", "M'", "L'")) # the whole cube turns
cube_is_colour_solved(s) # TRUE — every face one colour
identical(s, cube_identity(3)) # FALSE — but the stickers movedThe generated table is checked against the hand-written 3x3x3 one, which it reproduces entry for entry, and the 2x2x2 against its published state counts per depth.
Four methods, all taking a reachable 54-sticker state and returning
the same four things — path, found,
stages, states — so they can be swapped for
one another and compared.
set.seed(42)
s <- generate_state(group = cube_group(3), n_moves = 20)
cube_solve_cfop(s)$path # cross, F2L, OLL, PLL
cube_solve_lbl(s)$path # layer by layer
cube_solve_old_pochmann(s)$path # blindfolded, one piece at a time
cube_solve_m2(s)$path # blindfolded, cheaper edges
cube_kociemba(s) # two-phase search, the fewest movesThey split in two on one question: does the method look at the cube while it runs? CFOP and layer by layer do — a stage ends when the cuber can see that it has, and a move is chosen for the position in front of them, so one move can serve several pieces at once. The two blindfolded methods never look after the start. That forces a single repeated step whose shape is always the same: swap whatever is in the buffer with one chosen piece, disturb nothing else. Nothing is searched. Each piece is placed by a conjugate — setup moves that bring the target where the algorithm can reach it, one memorised algorithm, the setups undone.
The algorithms used are ordinary PLLs, because “swap two edges and two corners” is exactly what a T-perm does. The second swap is not incidental: a single swap is an odd permutation and the cube group has none, so the spare swaps cancel in pairs and the leftover when their count is odd is the parity step, between the edges and the corners.
cube_solve_m2() is the usual next thing a blindfold
solver learns. The corners are Old Pochmann’s; the edges are not. Where
an edge cost a whole PLL wrapped in setups, the buffer is DF and the
swap is M2 — two moves. M2 is not a clean
swap, and the method is arranged around that rather than avoiding it: it
turns the middle slice a half turn, so it also moves the centres and
exchanges UF/DB. Those two edges get their own algorithms, edges left
facing the wrong way are set aside while the chain runs and turned
afterwards in one orientation phase, and whether parity needs fixing is
decided by reading the edge permutation rather than by counting swaps —
a chain that breaks into a new cycle spends turns the tally does not
see. The cube comes back solved relative to the centres, which is what
the method means by solved and what a real cube looks like.
Ten scrambled states, 985 moves from solved, every method solving all
ten (inst/examples/demo_cube3_solve.R):
| method | solved | mean moves | after shortening | mean sec | vs KociembaMod |
|---|---|---|---|---|---|
| KociembaMod | 10/10 | 40.4 | 40.0 | 1.03 | 1.00x |
| CFOP | 10/10 | 103.0 | 94.0 | 0.04 | 2.55x |
| LBL | 10/10 | 169.2 | 154.6 | 0.06 | 4.19x |
| M2 | 10/10 | 277.6 | 254.2 | 0.09 | 6.87x |
| Old Pochmann | 10/10 | 432.6 | 399.0 | 0.14 | 10.71x |
The baseline is the shortest of them, so the last column reads as “how many times longer”. The ordering is the methods’ own, and each step down it gives up something the one above relied on.
KociembaMod is apart from the other four: it is the only one that searches the cube rather than following a method, and the only one whose word cannot be explained move by move. It is also the only one the shortener barely improves — 40.4 to 40.0 — because a searched phase has no seams to cut, while a method that finishes each stage before looking at the next leaves a turn and its inverse at every join.
Among the four human methods, CFOP is cheapest in moves because it pairs a corner with its edge and inserts both at once. Blindfolded costs about three to five times CFOP, and the reason is structural rather than a matter of tuning — no move does two things at once when the method may not look at what it is doing. M2 buys back a third of that, all of it on the edges.
tests/testthat/test-cube-kociemba.R checks the search at
four scramble lengths, slice moves included.
A Cayley graph at n = 20 has 20! vertices, so nothing
about it can be measured by enumeration. landmark_states()
gives a set of fixed probe points instead: 25 permutations defined by
rules that generalise across n — a full reversal, a riffle
shuffle, the doubling map 2j mod (n+1), and so on — so the
same construction can be compared between graph sizes.
lm <- landmark_states(20)
lm[, c("name", "state_str")]Two constructions can be different permutations and still be the same
point of the graph. An early version of derangement looked
like this next to reverse_first:
reverse_first: 10 9 8 7 6 5 4 3 2 1 | 11 12 ... 20
derangement: 11 12 ... 20 | 10 9 8 7 6 5 4 3 2 1
One has eight fixed points, the other none — but they are the same
sequence cut at a different place, and cutting the ring elsewhere is
exactly what L does. The two sat 7 moves apart at
n = 14, against a mean of 133. block_swap,
block_rotate3 and shift_third collided for the
same reason.
Fixing the formulas by hand did not work: each correction collided
with something else. landmark_states() now detects
collisions instead — it reduces every state to its lexicographic minimum
over all n rotations and breaks a tie by transposing one
adjacent pair in the later construction. Verified distinct for every
n from 6 to 20. The cost is that a state may differ by one
transposition from its stated formula.
Use human_algorithm_to(), not
human_algorithm(). The latter reaches an arbitrary target
by solving both endpoints down to 1:n and splicing
the first word with the inverse of the second, so every route detours
through the identity — measurably so: the path from
envelope to riffle at n = 20
passes a state with 11 of 20 tiles already home.
a <- lm$state[[match("full_reverse", lm$name)]]
b <- lm$state[[match("riffle", lm$name)]]
human_algorithm_to(a, b, k = 4)$length # direct
human_algorithm(a, final_state = b, k = 4)$length # via the identitypair (n = 20) |
via identity | direct |
|---|---|---|
two_cycles — full_reverse |
441 | 141 |
envelope — riffle |
329 | 146 |
Switching solvers moved the median of all 300 pairwise distances from 238.5 to 158, which in turn changed which pairs count as far apart.
Twelve points in space bound a figure. convex_hull_3d()
gives the smallest convex body containing them, but convexity means some
points end up strictly inside and are not corners.
enclosing_hull_3d() dents that hull inwards until every
point is a vertex.
cube <- as.matrix(expand.grid(c(0, 1), c(0, 1), c(0, 1)))
convex_hull_3d(cube)[c("area", "volume")] # 6 and 1
withcentre <- rbind(cube, c(0.5, 0.5, 0.5))
h <- enclosing_hull_3d(withcentre)
length(h$vertices) # 9 -- the centre is a corner too
c(h$area, h$volume) # 6.5607 and 0.9167The dent costs volume and adds area, as it should. Both functions are
implemented in the package rather than taken from geometry,
keeping the dependency list at Rcpp alone; they are checked against a
cube, a box, a tetrahedron and a regular icosahedron and agree to six
decimal places.
Measured on twelve landmarks chosen to be far apart:
n = 20 convex |
n = 20 enclosing |
n = 50 convex |
n = 50 enclosing |
|
|---|---|---|---|---|
| vertices | 11 of 12 | 12 of 12 | 11 of 12 | 12 of 12 |
| faces | 18 | 20 | 18 | 20 |
| surface area | 5 950.57 | 5 956.42 | 530 374.12 | 531 911.41 |
| volume | 14 173.83 | 14 165.50 | 6 141 465.83 | 5 928 340.50 |
A caveat worth stating plainly: a landmark’s position comes from the
celestial coordinates of the route that reached it, and that route is
not guaranteed shortest. These numbers describe a particular set of
walks under a particular solver — comparable across n or
across solver changes, but not an invariant of the graph.
source(system.file("examples", "demo_landmark_network.R", package = "cayleyR"))
source(system.file("examples", "demo_landmark_paths.R", package = "cayleyR"))The first measures the distances, picks disjoint pairs that are far
apart, and draws either the paths or the solid; the second draws the
star of paths from the identity out to each landmark. Parameters sit in
a block at the top of each: N, the layout
(celestial, spectral, diffusion),
and how landmarks are selected. See
vignette("landmark-geometry") for the full pipeline.
The core state storage uses a compact C++ backend for high-performance state accumulation during iterative search:
# Create store for permutations of length 10
store <- create_state_store(10L)
# Analyze combos directly into store (no intermediate data.frames)
store_analyze_combos(store, top_combos, start_state, k = 4, cycle_val = 1L)
# Hash-based intersection between two stores: O(min(N,M))
common_keys <- store_find_intersections(store_start, store_final)
# Manhattan distance best match on flat integer array
best_idx <- store_find_best_match(store, target_state)
# Convert to data.frame for debugging
df <- store_to_dataframe(store)Optional GPU support via the ggmlR package (Vulkan backend). Install ggmlR separately; cayleyR works without it (CPU fallback).
# Check GPU availability
cayley_gpu_available()
cayley_gpu_status()
# Manhattan distance (GPU vs CPU)
calculate_differences(start_state, states_df, use_gpu = TRUE)
# Batch apply operations to many states at once
apply_operations_batch_gpu(states_matrix, c("1", "3", "2"), k = 4)
# Pairwise distance matrix
manhattan_distance_matrix_gpu(states1, states2)Basic Operations (C++): - shift_left()
/ shift_right() — cyclic shifts with coordinate tracking -
shift_left_simple() / shift_right_simple() —
simple cyclic shifts - reverse_prefix() /
reverse_prefix_simple() — reverse first k elements -
apply_operations() — apply sequence of operations
3x3x3 cube: - cube_group(3) — the cube
as a group: 54 stickers, 18 quarter turns - cube_moves(3) /
cube_move_names(3) — the moves as sticker permutations, and
their names - cube_identity(3) /
cube_is_colour_solved() — the solved state, and the test
for it - generate_state(group = cube_group(3)) — a random
reachable state - cube_colours() — what the cube looks like
from outside, sticker by sticker
Sequences are handled by the group API — group_apply(),
group_compose(), group_order(),
group_inverse_seq() — which works on any group in the
package, TopSpin included.
Every cube function in the package, and the sizes it actually
accepts. The sizes are measured rather than read off the documentation:
each function was called at n = 2 through 7
and the answer recorded. Any n means all six sizes
worked.
Structure — the group and its geometry (all: any n):
| Function | What it gives |
|---|---|
cube_group() |
the group of an n x n x n cube, 6n moves
over 6n^2 stickers |
cube_moves() / cube_move_names() |
the generated move table, and its names |
cube_layer_move() |
one turn named by axis, layer and quarter turns |
cube_identity() |
the solved state, 1:(6n^2) |
cube_is_colour_solved() |
every face one colour, which slice turns make distinct from the identity |
cube_orbits() / cube_pieces() |
the orbits a size has, and which stickers each piece carries |
cube_progress() / cube_pieces_home() |
how much of each orbit is solved |
cube_colours() |
a state written as colours rather than positions |
cube_wing_geometry_cpp() |
where the wings of a size sit |
Notation — reading and expanding words (all: any n):
| Function | What it gives |
|---|---|
cube_expand_move() /
cube_expand_word() |
Rw, 3R, x turned into
single-layer moves at that size |
cube_wide_move() / cube_wide_word() |
the same, composed into one permutation |
cube_expand_alg() |
an algorithm string expanded |
cube_word_order() |
how many repetitions bring a word back to the start |
cube_alg_table() |
the OLL, PLL and layer-by-layer tables |
cube_centre_positions() |
the centre sticker of each face |
Centres — derived from the size, not tabulated (all: any n):
| Function | What it gives |
|---|---|
cube_centre_structure() |
every centre sticker: face, orbit and slot |
cube_slice_map() |
where a turn sends each centre, as
(face, orbit, slot) |
cube_centre_shots() |
commutators that carry centres off one face and spare another |
cube_central_moves() |
the turns that rotate the whole cube — empty on an even cube |
cube_centre_counts() |
how many centres are home, per face, optionally per orbit |
Santa-format interchange (all: any n):
cube_santa_group(), cube_santa_moves(),
cube_santa_move_names(), cube_moves_santa(),
cube_santa_perm(), cube_santa_state(),
cube_santa_state_out(), cube_santa_path(),
cube_santa_path_out() — converting states and paths to and
from the Santa competition’s conventions.
Learned solving (any n):
cube_adi_model(), cube_adi_train(),
cube_adi_solve() — autodidactic iteration over any cube
group, given ggmlR.
Tied to one size:
| Function | Sizes | Why |
|---|---|---|
cube_solve_cfop(), cube_solve_lbl(),
cube_solve_m2(), cube_solve_old_pochmann(),
cube_kociemba() |
3 only | human and blindfolded methods written for a 54-sticker cube |
cube_read_state(), cube_predicates(),
cube_apply_word() |
3 only | read and describe a 3x3x3 |
cube_solve4(), cube_solve4_cascade(),
cube_solve_centres() |
4 only | reduction built around four centres to a face |
cube_is_reduced() |
4 only | the 4x4x4 test for “now solvable as a 3x3x3” |
cube_kociemba4(), cube_kociemba4_reduce(),
and the _cpp phase helpers |
4 only | the two-phase search over a 4x4x4’s coordinates |
The split is clean and worth stating plainly: the structure of a cube is universal, the methods that solve one are not. Anything that describes a cube — its moves, orbits, pieces, notation, centres — works at any size, because it is derived from the geometry. Anything that solves a cube is still written for the size it was written for. A 5x5x5 therefore has a full vocabulary and no solver.
Solving the 3x3x3: - cube_solve_cfop()
— cross, F2L by IDA*, OLL, PLL; the fewest moves and the only one that
searches - cube_solve_lbl() — layer by layer, the
beginner’s method - cube_solve_old_pochmann() —
blindfolded: one piece per conjugated PLL, plus the parity step -
cube_solve_m2() — blindfolded with the edges done by
M2 instead, about two thirds the moves -
cube_kociemba() — the two-phase search: into the subgroup
<U, D, L2, R2, F2, B2>, then to solved within it. A
quarter of CFOP’s moves, and the only solver here with a prune table.
Short rather than shortest — phase 1 takes the first way in it finds.
The search machinery follows twips (MPL-2.0)
Analysis: - get_reachable_states() —
full cycle analysis with state tracking -
get_reachable_states_light() — lightweight cycle detection
- find_best_random_combinations() — find best random
sequences with flexible sort_by -
analyze_top_combinations() — analyze top operation
sequences
StateStore (C++ backend): -
create_state_store() — create compact hash-indexed store -
store_add_from_df() — add states from data.frame -
store_analyze_combos() — analyze combos directly into store
(no data.frame) - store_find_intersections() — O(min(N,M))
hash intersection - store_find_best_match() — Manhattan
distance best match - store_filter_middle() — filter middle
steps per combo - store_to_dataframe() — convert to
data.frame for debugging - store_reconstruct_path() — C++
path reconstruction through cycles - store_lookup() — hash
lookup by state vector - store_get_state() /
store_get_meta() — retrieve state and metadata by index
Pathfinding: - bidirectional_bfs() —
bidirectional BFS shortest path - find_path_iterative() —
Iterative Cycle Intersection (ICI) path solver (StateStore backend) -
find_path_bfs() — find path via BFS highways + iterative
connector (auto direct vs hub) - short_path_bfs() — shorten
existing path via greedy BFS hopping - human_algorithm() —
human-style solver: grow a sorted run, finish the tail with derived
cycle primitives - sparse_bfs() — sparse BFS with hybrid
hub/random selection - reconstruct_bfs_path() — reconstruct
path from sparse BFS result - validate_and_simplify_path()
— validate and simplify operation path - invert_path() —
reverse an operation path
GPU (optional, requires ggmlR): -
cayley_gpu_available() / cayley_gpu_init() /
cayley_gpu_status() / cayley_gpu_free() — GPU
management - calculate_differences(..., use_gpu = TRUE) —
Manhattan distance on GPU - apply_operations_batch_gpu() —
batch operations via matrix multiplication -
manhattan_distance_matrix_gpu() — pairwise distance
matrix
Distance Metrics: -
manhattan_distance() — Manhattan distance between states -
breakpoint_distance() — breakpoint distance between states
- calculate_differences() — compute distances for all
states in a table
Celestial Coordinates: -
convert_LRX_to_celestial() — map LRX operation counts to
spherical coordinates - calculate_angular_distance_z() —
angular distance between two coordinate sets -
calculate_midpoint_z() — midpoint between two coordinate
sets - find_closest_to_coords() — find state closest to
target coordinates
State Utilities: - generate_state() /
generate_unique_states_df() — random state generation -
select_unique() — deduplicate states by V-columns -
check_duplicates() — find states present in two tables -
find_combination_in_states() — find a specific state in
results - save_bridge_states() — save bridge states to CSV
- short_position() — simplify operation path -
convert_digits() — parse operation strings
Measured on a 12-core machine, k = 4, paths produced by
human_algorithm_to(). Every figure below comes from a run
of the scripts in inst/examples/; each shortened path is
verified by applying it to the start state and comparing against the
target.
inst/examples/benchmark_human_algorithm_to.R)The ring-size cap of 63 is gone, so these sizes are reachable at all.
Time grows close to linearly in n; the move count does not,
because it depends on how the scramble happened to fall.
| n | sec | raw | short | found |
|---|---|---|---|---|
| 100 | 2.576 | 1340.3 | 1325.7 | 3/3 |
| 200 | 4.727 | 1614.3 | 1606.3 | 3/3 |
| 300 | 6.949 | 4144.3 | 4137.0 | 3/3 |
| 400 | 9.208 | 5290.7 | 5278.0 | 3/3 |
| 500 | 11.607 | 6589.0 | 6138.3 | 3/3 |
| 600 | 14.045 | 6445.3 | 6431.3 | 3/3 |
| 700 | 16.493 | 6140.3 | 6125.7 | 3/3 |
| 800 | 19.083 | 12717.0 | 12713.7 | 3/3 |
| 900 | 21.651 | 15540.3 | 15534.3 | 3/3 |
| 1000 | 24.266 | 8124.7 | 8108.0 | 3/3 |
3 runs per size, shortening via
short_path_bfs(depth = 4).
inst/examples/benchmark_cycle_shortcut.R)cycle_shortcut() and short_path_bfs() hunt
for the same thing by different means, and on these paths neither finds
much. The honest summary is that short_path_bfs() is the
better default up to a point, and that ranking the combos costs several
times what it returns:
| n | path | cycle_shortcut (ranked) | cycle_shortcut (no ranking) | short_path_bfs |
|---|---|---|---|---|
| 100 | 978 | 0 saved, 7.2s | 0 saved, 1.2s | 0 saved, 0.5s |
| 500 | 4248 | 6 saved, 131.8s | 6 saved, 21.6s | 10 saved, 19.7s (depth 6) |
| 2000 | 31005 | 0 saved, 339.0s | 0 saved, 57.7s | 4 saved, 624.9s (depth 6) |
Two things worth reading off this table. Ranking combos
(sort_by) has to unroll every candidate to score it and
bought nothing here — sort_by = NULL returned the same cuts
five to six times faster. And the crossover is at n = 2000,
where short_path_bfs() becomes the slower of the two by an
order of magnitude while still being the only one that finds
anything.
Cutting across cycles pays off on small rings and thins out as the state space grows: a random cycle leaving the path rarely meets it again when there is that much room to wander. See TODO for what this needs.
inst/examples/train_cube3_q_online.R)A transformer with eighteen outputs, one per move, trained on fresh
midpoint walks (generate_walk(pivot = TRUE), depth 11,
batch 512, nothing repeated), through one
ggmlR::ggml_trainer for the whole run. Same seed, model 512
/ 8 heads / 4 blocks / ff 1024, RX 9070 over Vulkan, about 0.24 s a
step.
| run | steps | pair | top1 | MAE |
|---|---|---|---|---|
sched=const, lr 1e-3 throughout |
stopped at 7700 | 84–85% | 43–45% | ~0.95 |
sched=cosine, 500 warm-up, down to 1e-5 |
8000 | 92.8% | 62.1% | 0.74 |
sched=const, model 256 / ff 512 |
4000 | 85.8%* | 45.0%* | 0.99 |
* scored on 2000 held-out states rather than 4000.
By depth, for the cosine run (q(prev) should be depth − 1):
| depth | 2 | 4 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|
| top1 | 86.8% | 78.8% | 62.6% | 54.2% | 48.1% | 38.5% | 31.5% |
| q(prev) | 1.16 | 3.37 | 5.85 | 6.80 | 7.42 | 8.08 | 8.49 |
q rises by about one a step up to depth 7 and saturates from 8. Part
of that is the labels: a walk refusing only an immediate undo still lets
U D U' and U U U through, so the deep labels
overstate the distance. no_cancel = TRUE in
generate_walk() refuses those; the runs above were made
without it.
In search (inst/examples/diag_cube3_q.R, beam 32, 20
walks per depth):
| depth | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17–20 |
|---|---|---|---|---|---|---|---|---|---|---|
| 256 const, budget 60 | 90% | 75% | 55% | 35% | 30% | 15% | 5% | 5% | 5% | 0–5% |
| 512 cosine, budget 25 | 100% | 95% | 85% | 80% | 70% | 55% | 40% | 40% | 30% | 10–15% |
512 cosine, init, depth 16, no_cancel, budget 3x
depth |
100% | 95% | 90% | 90% | 85% | 75% | 75% | 35% | 35% | 25–40% |
Both solve every cube up to depth 7. The cosine model moves that edge
to 8 and does not fall to zero past its training depth of 11: 55% at 13,
40% at 15, with less than half the move budget. Read the deep end with
care: these runs drew their walks with no_undo only
(diag_cube3_q.R now uses no_cancel = 1 by
default), so their depths are overstated too — the mean solution is 13.2
moves at depth 14 and 14.7 at depth 16. At 17 the mean solution (20.3)
is longer than the walk: the beam found a way round, not back.
The third row is the cosine model trained on from
qon_cosine8k for 8000 more steps at depth 16 with
no_cancel = TRUE (pair 89.0%, top1 53.5% on its own
held-out set), searched on no_cancel walks, so its depths
are not overstated. It holds 75% up to depth 14 and drops to 35% at 15,
inside its training depth: there q(prev) is already flat (11.95, 12.30,
12.53 at 13, 14, 15). From depth 12 the solutions are longer than the
walk (18.4 moves at 14, 27–31 at 17–18). A solve takes under 0.55 s up
to depth 14 and about 0.9 s beyond, where the failures run to the move
cap. The cap is 3x the depth rather than 25, which likely accounts for
some of the 25–40% at 17–20.
On Vulkan the default multiplies keep f16 tiles, so every q carries a
little noise. Measured with diag_cube3_q.R cmp=1 on this
model (20 walks, depths 1–16): q moves by at most 0.004 moves between
default and f32 precision, and the best move of a frontier state changes
in at most 0.08% of states. The search therefore runs at the default
precision; the training reports and the final score use
matmul_precision = "f32", so that runs and resumed runs are
scored on the same numbers. The edge at depth 15 is the model’s, not the
arithmetic’s.
A greedy argmin descent falls to 50% by depth 5, mostly by stepping back and forth between two states, so it is no longer reported.
init=<model directory> loads
model.ggml and meta.rds from an earlier run
and trains on from there with the same ggml_trainer:
Rscript inst/examples/train_cube3_q_online.R init=/mnt/Data2/DS_projects/333/qon_cosine8k \
depth=16 steps=8000 warmup=500 lr=5e-4 report=100 sched=cosine
d_model, heads,
blocks, ff) is taken from the saved model; the
same options on the command line are ignored.y_mu, y_sd) is kept
from the saved model too, not re-derived from the new
depth: the weights were learnt on that scale.meta.rds must match the cube’s, or the
run stops.depth, lr, sched,
warmup, no_cancel and the rest are the new
run’s own._init suffix and
meta.rds records the path of the model it started
from.init= starts a new run with its own schedule. To carry
on a run that was cut short, save checkpoints with ckpt=N
(the model and meta.rds every N steps, with the step
reached and the random state) and restart it with
resume=<run directory>:
Rscript inst/examples/train_cube3_q_online.R steps=20000 ckpt=1000
Rscript inst/examples/train_cube3_q_online.R resume=/mnt/Data2/DS_projects/333/<run>
The schedule, data and architecture all come from the run’s
meta.rds; only backend, report
and ckpt may be given again. The learning rate picks up the
same curve at the next step, and the training batches are the ones the
uninterrupted run would have drawn. Adam’s moments are not saved, so the
first steps after a resume are Adam’s first: the result is close to an
uninterrupted run, not identical to it.
Scripts in inst/examples/, runnable with
Rscript inst/examples/<name>.R. Each has a parameters
block at the top; nothing needs editing to get a first run out of
it.
| Script | What it does |
|---|---|
test_path.R |
Integration run of find_path_iterative() (ICI) on a
scrambled ring |
test_bh_in_path.R |
find_path_bfs() on a fixed 20-tile target, writing path
and stats to CSV |
test_bh_path_coords.R |
Same path, plus celestial coordinates accumulated at every step |
test_human_algorithm.R |
The human-style solver: grow a sorted run, finish the tail with cycle primitives |
test_human_navigation.R |
Solving driven by phase 1 of the human algorithm instead of Manhattan distance, one direction only |
test_sparse_bh.R |
Sparse BFS with look-ahead and hybrid hub selection |
| Script | What it measures |
|---|---|
benchmark_human_algorithm_to.R |
human_algorithm_to() sweeping ring size: time, raw and
shortened move counts |
benchmark_cycle_shortcut.R |
cycle_shortcut() against short_path_bfs(),
with and without combo ranking |
benchmark_human_nav.R |
Three ways of solving the same states — navigation plus search, versus the human algorithm |
benchmark_n_size.R |
Difficulty as the ring grows, n_moves held fixed |
benchmark_n_moves.R |
Difficulty as the scramble lengthens, n held fixed |
benchmark_sort_by.R |
Which sort_by strategy serves
find_path_iterative() best |
benchmark_gpu_vs_cpu.R |
store_analyze_combos() on CPU/C++ against the GPU
path |
| Script | What it shows |
|---|---|
graph_diameter.R |
Graph diameter and the state pairs that realise it |
demo_graph_celestial.R |
The whole Cayley graph plotted in celestial coordinates |
demo_graph_spectral.R |
The same graph in spectral coordinates, which separate states celestial ones collapse |
demo_graph_spectral_nobfs.R |
Spectral layout without a full BFS, for rings too large to enumerate |
coord_diagnostics.R |
Whether a coordinate actually predicts graph distance, over arbitrary pairs rather than pairs involving the identity |
probe_human_table.R |
What drives the cost of building the finish table — k,
as it turns out, not the ring size |
rbindlist when availableMIT