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
11 changes: 11 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,14 @@ before adding one. The Orbit64 browser codec is adapted from
`flix-orbit64` under [Apache-2.0](../third_party/flix-orbit64.LICENSE), with
cross-project vectors in `test/cube/notation/orbit64Vectors.json` and
`test/cube/notation/orbit64LargeVectors.json`.

The Orbit64 V2 state codec for 2x2-15x15 cubes
([`Orbit64V2.res`](../src/cube/notation/Orbit64V2.res)) works on orbit
coordinates. [`Orbit64V2Facelets.res`](../src/cube/notation/Orbit64V2Facelets.res)
is its facelet bridge for the color model, both poses: slot tables, cube to
facelets and back, and token to facelets and back, with every failure an error
value. The picture model has no facelet letters and is an error there. Its
fixture `test/cube/notation/orbit64V2Facelets.json` (the slot tables of
`FORMAT-V2-SLOTS.md` and the facelet rows of `TestV2FaceletData.flix`) is written
by `julia scripts/orbit64V2FaceletFixture.jl <flix-orbit64 dir>` and
regenerates byte for byte.
201 changes: 201 additions & 0 deletions scripts/orbit64V2FaceletFixture.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env julia
#
# Writes test/cube/notation/orbit64V2Facelets.json from the Orbit64 V2
# reference repository (flix-orbit64, Apache-2.0).
#
# julia scripts/orbit64V2FaceletFixture.jl <path to flix-orbit64>
#
# It copies the slot tables of FORMAT-V2-SLOTS.md (n = 2..15) and the rows of
# test/TestV2FaceletData.flix, which tools/format-v2/facelet_vectors.jl
# generates there. Nothing is computed here. The row count comes from the
# input. Needs only Julia's standard library; the output is deterministic, and
# written the way `biome format` leaves it, so regenerating it changes nothing.

root = length(ARGS) == 1 ? ARGS[1] : error("usage: julia scripts/orbit64V2FaceletFixture.jl <flix-orbit64 dir>")
read_text(path) = read(joinpath(root, path), String)

revision = strip(read(`git -C $root rev-parse HEAD`, String))
# A tree with uncommitted changes is not the commit it names; the fixture says so.
dirty = !isempty(strip(read(`git -C $root status --porcelain`, String)))
source_name = dirty ? "flix-orbit64@$revision (uncommitted changes)" : "flix-orbit64@$revision"

# ---- FORMAT-V2-SLOTS.md --------------------------------------------------------
# ## 5x5x5
# corners slot: facelets
# C 0: 24 25 54
# wings, depth 1
# W1 0: 1 128
# centers, 24 slots each, ascending
# Z[1,1]: 7 10 25 ...
# A slot is the facelets after the colon, in the order listed.

struct Slots
corners::Vector{String}
midges::Vector{String}
wings::Vector{Vector{String}}
centers::Vector{String}
end

function parse_slots(text::String)
sizes = Dict{Int,Slots}()
current = nothing
kind = :none
for line in split(text, '\n')
if (m = match(r"^## (\d+)x\1x\1$", line)) !== nothing
current = Slots(String[], String[], Vector{String}[], String[])
sizes[parse(Int, m.captures[1])] = current
kind = :none
elseif current === nothing
continue
elseif startswith(line, "corners")
kind = :corners
elseif startswith(line, "midges")
kind = :midges
elseif startswith(line, "wings, depth")
kind = :wings
push!(current.wings, String[])
elseif startswith(line, "centers")
kind = :centers
elseif (m = match(r"^ \S+(?: \d+)?: ([\d ]+)$", line)) !== nothing
kind == :none && error("slot line outside an orbit: $line")
facelets = join(split(m.captures[1]), " ")
kind == :corners && push!(current.corners, facelets)
kind == :midges && push!(current.midges, facelets)
kind == :wings && push!(current.wings[end], facelets)
kind == :centers && push!(current.centers, facelets)
elseif startswith(line, " ") && !isempty(strip(line))
error("unparsed slot line: $line")
end
end
sizes
end

slots = parse_slots(read_text("FORMAT-V2-SLOTS.md"))
sort(collect(keys(slots))) == collect(2:15) || error("FORMAT-V2-SLOTS.md: sizes $(sort(collect(keys(slots)))), expected 2:15")
for (n, s) in slots
length(s.corners) == 8 || error("$(n)x$(n)x$(n): $(length(s.corners)) corners")
length(s.midges) == (isodd(n) ? 12 : 0) || error("$(n)x$(n)x$(n): $(length(s.midges)) midges")
length(s.wings) == (n - 2) ÷ 2 || error("$(n)x$(n)x$(n): $(length(s.wings)) wing depths")
length(s.centers) == ((n - 2)^2) ÷ 4 || error("$(n)x$(n)x$(n): $(length(s.centers)) center orbits")
all(c -> length(split(c)) == 24, s.centers) || error("$(n)x$(n)x$(n): a center orbit without 24 slots")
all(w -> length(w) == 24, s.wings) || error("$(n)x$(n)x$(n): a wing depth without 24 slots")
end

# ---- test/TestV2FaceletData.flix -----------------------------------------------
# (0, "1R", "UFUF RRRR FDFD DBDB LLLL UBUB", "IbH7", ""),

struct Row
id::Int
moves::String
facelets::String
token::String
canonical::String
end

row_pattern = r"^\s*\((\d+), \"([^\"]*)\", \"([^\"]*)\", \"([^\"]*)\", \"([^\"]*)\"\),?\s*$"
rows = Row[]
tuple_lines = 0
for line in split(read_text("test/TestV2FaceletData.flix"), '\n')
# A line that starts a tuple must be a row; the count is checked below.
occursin(r"^\s*\(\d+,", line) && (global tuple_lines += 1)
m = match(row_pattern, line)
m === nothing && continue
push!(rows, Row(parse(Int, m.captures[1]), m.captures[2], m.captures[3], m.captures[4], m.captures[5]))
end
isempty(rows) && error("TestV2FaceletData.flix: no rows")
length(rows) == tuple_lines || error("TestV2FaceletData.flix: $tuple_lines tuple lines, $(length(rows)) parsed as rows")
# Structure the input already has: every move sequence of a size is written once
# for each of its two color entries (canonical and framed). A dropped row leaves
# a sequence with one entry, and an extra one a sequence with three.
rows_by_sequence = Dict{Tuple{Int,String},Vector{Int}}()
ids_by_size = Dict{Int,Set{Int}}()
for r in rows
cells = length(replace(r.facelets, " " => ""))
n = round(Int, sqrt(cells / 6))
6n^2 == cells || error("row of entry $(r.id): $cells facelets are no cube")
push!(get!(rows_by_sequence, (n, r.moves), Int[]), r.id)
push!(get!(ids_by_size, n, Set{Int}()), r.id)
end
for (n, ids) in ids_by_size
length(ids) == 2 || error("$(n)x$(n)x$(n): rows of $(length(ids)) entries, expected 2")
end
for ((n, moves), ids) in rows_by_sequence
sort(ids) == sort(collect(ids_by_size[n])) ||
error("$(n)x$(n)x$(n): moves \"$moves\" have rows for entries $(sort(ids)), expected $(sort(collect(ids_by_size[n]))) once each")
end
sort(collect(keys(ids_by_size))) == collect(2:15) || error("rows for sizes $(sort(collect(keys(ids_by_size)))), expected 2:15")

# Pinned counts of the reference repository's rows: dropping both rows of a move
# sequence leaves the structure above intact, so the counts are fixed here and in
# the test. When the reference adds or removes rows, change them with the input.
const EXPECTED_SEQUENCES = Dict(2 => 32, 3 => 41, 4 => 50, 5 => 59, 6 => 5, 7 => 5,
8 => 4, 9 => 4, 10 => 4, 11 => 4, 12 => 4, 13 => 4, 14 => 4, 15 => 4)
const EXPECTED_ROWS = 2 * sum(values(EXPECTED_SEQUENCES))
length(rows) == EXPECTED_ROWS || error("TestV2FaceletData.flix: $(length(rows)) rows, expected $EXPECTED_ROWS")
sequences_per_size = Dict(n => count(k -> k[1] == n, keys(rows_by_sequence)) for n in keys(ids_by_size))
sequences_per_size == EXPECTED_SEQUENCES || error("TestV2FaceletData.flix: move sequences per size $(sort(collect(sequences_per_size))), expected $(sort(collect(EXPECTED_SEQUENCES)))")

# ---- JSON ----------------------------------------------------------------------

function quoted(text::AbstractString)
out = IOBuffer()
print(out, '"')
for c in text
c == '"' ? print(out, "\\\"") :
c == '\\' ? print(out, "\\\\") :
c < ' ' ? print(out, "\\u", string(UInt16(c), base = 16, pad = 4)) : print(out, c)
end
print(out, '"')
String(take!(out))
end

# One `"key": value` line at an indent of `depth` levels of two spaces.
member(depth::Int, key::AbstractString, value::AbstractString) = repeat(" ", depth) * quoted(key) * ": " * value

# Slots are strings, `|` between slots, so that no array is short enough for the
# formatter to join its lines. Indentation is the formatter's.
function slot_object(n::Int, s::Slots)
wings = isempty(s.wings) ? "{}" :
"{\n" * join([member(4, string(d), quoted(join(w, " | "))) for (d, w) in enumerate(s.wings)], ",\n") * "\n }"
join([
" " * quoted(string(n)) * ": {",
member(3, "corners", quoted(join(s.corners, " | "))) * ",",
member(3, "midges", quoted(join(s.midges, " | "))) * ",",
member(3, "wings", wings) * ",",
member(3, "centers", quoted(join(s.centers, " | "))),
" }",
], "\n")
end

row_object(r::Row) = join([
" {",
member(3, "id", string(r.id)) * ",",
member(3, "moves", quoted(r.moves)) * ",",
member(3, "facelets", quoted(r.facelets)) * ",",
member(3, "token", quoted(r.token)) * ",",
member(3, "canonical", quoted(r.canonical)),
" }",
], "\n")

slot_text = join([slot_object(n, slots[n]) for n in 2:15], ",\n")
row_text = join([row_object(r) for r in rows], ",\n")

json = """
{
"source": $(quoted("$source_name: FORMAT-V2-SLOTS.md and test/TestV2FaceletData.flix (generated there by tools/format-v2/facelet_vectors.jl)")),
"generatedBy": "scripts/orbit64V2FaceletFixture.jl. Do not edit.",
"slotText": "Facelets of each slot of an orbit, separated by spaces, slots separated by |, in slot order. Wings are keyed by depth, outermost first.",
"rowText": "id: registry entry; moves: layer turns (depth, face, suffix) and whole-cube turns x y z from the solved cube; facelets: what the entry writes for the cube; token: its V2 token; canonical: the reference pose of that cube when the entry's facelets differ from it, else empty.",
"rowCount": $(length(rows)),
"slots": {
$slot_text
},
"rows": [
$row_text
]
}
"""

target = joinpath(@__DIR__, "..", "test", "cube", "notation", "orbit64V2Facelets.json")
write(target, json)
println("wrote ", length(rows), " rows and the slot tables of ", length(slots), " sizes")
11 changes: 9 additions & 2 deletions src/cube/notation/Orbit64V2.res
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Orbit64 V2 state tokens for n x n x n cubes, n = 2..15, in coordinates only:
// registry, state ranking and the token codec. There is no facelet bridge.
// registry, state ranking and the token codec. The facelet bridge is
// Orbit64V2Facelets.res.
// Format: FORMAT-V2.md of flix-orbit64@8dc17fb (Apache-2.0); reference
// implementation: src/Orbit64/V2/{Registry,State,Codec}.flix there.
//
Expand Down Expand Up @@ -416,7 +417,13 @@ let snapshot = (cube: cube): cube => {
let encodeOrbit64V2State = (id, cube) =>
try {
switch findEntry(id) {
| None => Error(`unknown registry entry ${Int.toString(id)}`)
| None =>
// Only a genuine integer is quoted: no caller text, no caller code.
Error(
Type.typeof(id) == #number && isInt(id)
? `unknown registry entry ${Int.toString(id)}`
: "the registry entry is not an integer",
)
| Some(entry) =>
rankState(entry.n, entry.model, entry.pose, snapshot(cube))->Result.map(rank =>
Orbit64.toBase64(entry.offset + rank, entry.length)
Expand Down
6 changes: 6 additions & 0 deletions src/cube/notation/Orbit64V2.resi
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ type decoded = {entry: entry, cube: cube}

// The registry entry and state a token addresses, or why it addresses none.
@genType let decodeOrbit64V2State: string => result<decoded, string>

// For the facelet bridge (Orbit64V2Facelets.res): a copy of a cube read once
// and by index, and why a cube is no state of the type, if it is none. The
// fault is the reason `encodeOrbit64V2State` gives; `faultOf` needs a snapshot.
let snapshot: cube => cube
let faultOf: (int, model, pose, cube) => option<string>
Loading
Loading