
How a5R stores cell IDs without strings
Source:vignettes/internal-cell-representation.Rmd
internal-cell-representation.RmdThe problem
An A5 cell ID is a 64-bit unsigned integer (u64). R has
no native u64 type: its integers are 32-bit signed
(-2^31 to 2^31 - 1), and its doubles are
64-bit floating point. A double can only represent integers
exactly up to 2^53, while a u64 can go up to 2^64 - 1.
The obvious workaround is to store cell IDs as hex strings
("0800000000000006"). This works, but every trip across the
R / Rust boundary requires hex parsing and formatting: O(n) string
allocation that dominates the cost of lightweight operations like
a5_get_resolution() or
a5_cell_to_parent().
The solution: eight raw-byte fields
A u64 is exactly 8 bytes. We store each byte of the
little-endian representation as a separate raw vector field
in a vctrs record type, with one twist: the most significant byte,
b8, is stored XOR 0xFC (see NA handling for why).
cell_id (u64): 0x0800000000000006
little-endian bytes:
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08
stored fields:
b1 = 0x06, b2 = 0x00, b3 = 0x00, b4 = 0x00,
b5 = 0x00, b6 = 0x00, b7 = 0x00, b8 = 0x08 XOR 0xFC = 0xF4
This is lossless: XOR with a constant is its own inverse, so the
eight stored bytes map one-to-one onto the original u64. On
the Rust side, reconstructing the id from the eight byte slices is one
XOR and a single u64::from_le_bytes() call. There are no
pointers, so an a5_cell can be saved with
saveRDS() like any other R object. Objects saved by a5R
0.6.0 or earlier used the plain byte layout and do not read correctly in
later versions; re-create them from hex strings or Arrow, which store
the true id.
R-side: a vctrs record type
On the R side, a5_cell is a vctrs
record (vctrs::new_rcrd()) with eight fields
(b1 through b8):
library(a5R)
#> a5R 0.9.9.9004 • a5 crate 1.0.0-beta.3 • A5 index 1.0 • 1 thread
cell <- a5_lonlat_to_cell(-3.19, 55.95, resolution = 10)
vctrs::field(cell, "b1")
#> [1] 00
vctrs::field(cell, "b8") # stored XOR 0xFC
#> [1] 9fEach field is a plain raw vector: a contiguous block of
memory with no per-element overhead. Subsetting, combining, and NA
propagation are all handled automatically by vctrs.
Hex strings are only produced on demand:
# Display calls format(), which converts to hex for readability
cell
#> <a5_cell[1]>
#> [1] 63bb948000000000
# Explicit conversion
a5_u64_to_hex(cell)
#> [1] "63bb948000000000"
# Round-trip from hex
a5_cell("0800000000000006")
#> <a5_cell[1]>
#> [1] 0800000000000006Why this matters
Compare memory for one million cells:
set.seed(42)
cells <- a5_lonlat_to_cell(
runif(1e6, -180, 180),
runif(1e6, -80, 80),
resolution = 10
)
# rcrd: eight contiguous raw vectors (8 × 1 byte × 1M ≈ 7.6 MB)
format(object.size(cells), units = "MB")
#> [1] "7.6 Mb"
# equivalent hex strings would be ~81 MB
# (16 chars + 56-byte SEXP header per string)
hex <- a5_u64_to_hex(cells)
format(object.size(hex), units = "MB")
#> [1] "81 Mb"NA handling
NA is the id 0xFC00000000000000. It is
never a valid cell: at resolutions 0 to 29 the top 6 bits hold the
quintant (0 to 59), and 63 (binary 111111) is unused; the
id does not decode as a resolution-30 cell either. Only the full id is
the sentinel. Resolution 30 keeps just 5, 3 or 1 quintant bits at the
top to make room for position bits, so valid resolution-30 cells can
have a top byte of 0xFC.
Storing b8 XOR 0xFC makes the sentinel’s
stored form eight 00 bytes. That matters because vctrs
fills gaps field by field, and a missing raw value is
00. Combining with NA, subsetting with an
NA index, assigning NA, and unmatched rows in
joins all produce eight 00 bytes, which decode to
NA. With plain bytes they would decode to id 0, the world
cell, a valid id.
On the Rust side, the sentinel is detected and mapped to
None. Standard R idioms work as expected:
Working with base R
Because an a5_cell is a list of eight raw fields
underneath, base functions that look at that structure directly rather
than dispatching on the class need some care. a5R covers the common
cases; the rest have a vctrs form that is both correct and fast.
Works as expected. Subsetting, c(),
rev(), unique(), duplicated(),
sort(), order(), is.na(),
sample(), ==, comparison operators,
split(cells, f) with a factor f, data frame
and tibble columns, and rbind() of data frames with
a5_cell columns. Assigning past the end
(x[length(x) + 1] <- cell) and
length(x) <- n grow the vector with NA, as
they do for base vectors.
Fast. match() and %in% use
an exact mtfrm() key rather than hex strings, so they cost
about the same as vctrs::vec_match() and
vctrs::vec_in().
Use the vctrs form instead. Three base cases cannot be intercepted by a method:
c()dispatches on its first argument only, soc(NA, cells)with a bareNAfirst never reaches thea5_cellmethod and returns a list. Base classes behave the same way:c(NA, Sys.Date())loses theDateclass. Start with a cell, as inc(a5_cell(NA), cells), or usevctrs::vec_c(NA, cells), which combines by every argument’s type. AnNAanywhere after the first argument is fine.split(x, cells)andtapply(x, cells, f)with ana5_cellgrouping vector see a list of eight raw vectors and group on their interaction, giving one group or an error. Usevctrs::vec_split(x, cells)orvctrs::vec_group_id(cells), or group withfactor(cells).unlist()on a plain list ofa5_cellvectors, such aslapply()output, descends into the fields and returns a meaninglessrawvector. Usevctrs::list_unchop(),do.call(c, x), or wrap the list withas_a5_cell_list(), after whichunlist()works. Lists returned bya5_cell_to_children()and friends withsimplify = FALSEare alreadya5_cell_listobjects.
Adding rows to a data frame by index
(df[nrow(df) + 1, ] <- ...) is not supported for any
record-backed column, because base R strips the class before growing the
column. Use rbind() or vctrs::vec_rbind().
cells <- a5_lonlat_to_cell(c(0, 10, 0), c(0, 10, 0), resolution = 5)
# A leading NA: start with a cell, or use vctrs
c(a5_cell(NA), cells)
vctrs::vec_c(NA, cells)
# Grouping by cell: use vctrs
vctrs::vec_split(1:3, cells)$val
#> [[1]] 1 3 [[2]] 2
# Flattening lapply() output
parts <- lapply(1:3, function(i) a5_cell_to_children(cells[i]))
unlist(as_a5_cell_list(parts))Coverings
An a5_covering holds one compacted set of cells per
element, so it uses a different layout. Each element is a single
raw vector of the covering’s 64-bit values, little-endian
and unmasked: its cells sorted in curve order, then the compaction
marker, exactly as the A5 libraries return them. Rust reads an element
as one slice, and Arrow export is one list<uint64>
value per covering.
The compaction marker is a value no cell can take: quintant 60 (only
0 to 59 exist) in the top 6 bits, the resolution in bits 55 to 48, and
the tag 1000000 in the low 7 bits, which no resolution
uses. The marker for resolution 12 is
0xf00c000000000040:
covering <- a5_compact(a5_cell_to_children(cell, resolution = 12))
covering
#> <a5_covering[1]>
#> [1] res 12: 16 cells (1 stored)
bytes <- unclass(covering)[[1]]
length(bytes) / 8 # one stored cell, then the marker
#> [1] 2
rev(bytes[9:16]) # the marker, most significant byte first
#> [1] f0 0c 00 00 00 00 00 40A covering never holds NA, so no byte is masked. A
missing covering is a NULL element, which is what vctrs
fills in for a list, so joins and vec_init() produce
NA coverings with no extra work.