Skip to content

Commit b941566

Browse files
committed
Fix a5-rs lonlat parity and add deep oracle fuzz test
1 parent da2dd8e commit b941566

6 files changed

Lines changed: 325 additions & 11 deletions

File tree

src/core/cell.zig

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const dodecahedron = @import("cell_dodecahedron.zig");
1212

1313
const Face = coordinate_systems.Face;
1414
const LonLat = coordinate_systems.LonLat;
15+
const Spherical = coordinate_systems.Spherical;
1516
const A5Cell = core_utils.A5Cell;
1617
const PentagonShape = geometry.pentagon.PentagonShape;
1718
const DodecahedronProjection = dodecahedron.DodecahedronProjection;
@@ -36,6 +37,10 @@ pub fn lonlat_to_cell(lonlat: LonLat, resolution: i32) Error!u64 {
3637
return serialization.serialize(estimate);
3738
}
3839

40+
return lonlat_to_cell_sampled(lonlat, resolution);
41+
}
42+
43+
fn lonlat_to_cell_sampled(lonlat: LonLat, resolution: i32) Error!u64 {
3944
const sample_count: usize = 25;
4045
const total_samples = sample_count + 1;
4146
const hilbert_resolution = 1 + resolution - serialization.FIRST_HILBERT_RESOLUTION;
@@ -53,7 +58,7 @@ pub fn lonlat_to_cell(lonlat: LonLat, resolution: i32) Error!u64 {
5358

5459
var unique_keys: [total_samples]u64 = undefined;
5560
var unique_len: usize = 0;
56-
var best_cell: ?A5Cell = null;
61+
var best_cell: ?u64 = null;
5762
var best_distance: f64 = -std.math.inf(f64);
5863

5964
for (samples) |sample| {
@@ -78,12 +83,12 @@ pub fn lonlat_to_cell(lonlat: LonLat, resolution: i32) Error!u64 {
7883
}
7984
if (distance > best_distance) {
8085
best_distance = distance;
81-
best_cell = estimate;
86+
best_cell = key;
8287
}
8388
}
8489

8590
if (best_cell) |cell| {
86-
return serialization.serialize(cell);
91+
return cell;
8792
}
8893
return Error.NoCandidateCell;
8994
}
@@ -92,6 +97,15 @@ fn lonlat_to_estimate(lonlat: LonLat, resolution: i32) Error!A5Cell {
9297
const spherical = ct.from_lon_lat(lonlat);
9398
const current_origin = core_origin.find_nearest_origin(spherical);
9499

100+
return lonlat_to_estimate_for_origin(spherical, current_origin, resolution);
101+
}
102+
103+
fn lonlat_to_estimate_for_origin(
104+
spherical: Spherical,
105+
current_origin: core_utils.Origin,
106+
resolution: i32,
107+
) Error!A5Cell {
108+
95109
const projection = try DodecahedronProjection.get_thread_local();
96110
var dodec_point = try projection.forward(spherical, current_origin.id);
97111
const polar = ct.to_polar(dodec_point);

src/core/coordinate_transforms.zig

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const std = @import("std");
22
const coordinate_systems = @import("coordinate_systems");
33
const authalic = @import("authalic.zig");
4+
const pentagon = @import("pentagon.zig");
45

56
const Degrees = coordinate_systems.Degrees;
67
const Radians = coordinate_systems.Radians;
@@ -119,11 +120,21 @@ pub fn to_lon_lat(spherical: Spherical) LonLat {
119120
}
120121

121122
pub fn face_to_ij(face: Face) IJ {
122-
return IJ.new(face.x(), face.y());
123+
const basis_inverse_mat = pentagon.basis_inverse();
124+
const x = face.x();
125+
const y = face.y();
126+
const u = basis_inverse_mat.m00 * x + basis_inverse_mat.m01 * y;
127+
const v = basis_inverse_mat.m10 * x + basis_inverse_mat.m11 * y;
128+
return IJ.new(u, v);
123129
}
124130

125131
pub fn ij_to_face(ij: IJ) Face {
126-
return Face.new(ij.x(), ij.y());
132+
const basis_mat = pentagon.basis();
133+
const u = ij.x();
134+
const v = ij.y();
135+
const x = basis_mat.m00 * u + basis_mat.m01 * v;
136+
const y = basis_mat.m10 * u + basis_mat.m11 * v;
137+
return Face.new(x, y);
127138
}
128139

129140
pub fn face_to_kj(face: Face) KJ {

src/core/origin.zig

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,16 +190,12 @@ pub fn segment_to_quintant(segment: usize, origin: Origin) SegmentToQuintant {
190190

191191
pub fn find_nearest_origin(point: Spherical) Origin {
192192
const origins = get_origins();
193-
const tie_epsilon = 1e-15;
194193
var min_distance = std.math.inf(f64);
195194
var nearest = origins[0];
196195

197196
for (origins) |origin| {
198197
const distance = haversine(point, origin.axis);
199-
const diff = distance - min_distance;
200-
if (diff < -tie_epsilon or
201-
(@abs(diff) <= tie_epsilon and origin.id < nearest.id))
202-
{
198+
if (distance < min_distance) {
203199
min_distance = distance;
204200
nearest = origin;
205201
}

tests/cell.zig

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,16 @@ test "cell centers are contained in their own cells for fixture ids sample" {
111111
try std.testing.expect(containment > -1e-8);
112112
}
113113
}
114+
115+
test "cell alaska regression matches website id" {
116+
const point = LonLat.new(-149.7239, 61.3039);
117+
const resolution: i32 = 5;
118+
119+
const computed_cell = try cell.lonlat_to_cell(point, resolution);
120+
const website_cell = try hex.hex_to_u64("00d2000000000000");
121+
try std.testing.expectEqual(website_cell, computed_cell);
122+
123+
const computed_cell_data = try serialization.deserialize(computed_cell);
124+
const computed_distance = try cell.a5cell_contains_point(computed_cell_data, point);
125+
try std.testing.expect(computed_distance > 0.0);
126+
}

tests/qa_e2e_rust_zig_cell_ids.zig

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ const MAX_RESOLUTION_TO_COMPARE: i32 = 9;
99
const MAX_COMMAND_OUTPUT_BYTES: usize = 512 * 1024 * 1024;
1010
const MAX_POLYGON_SAMPLES_PER_RESOLUTION: usize = 32;
1111
const POLYGON_TOLERANCE_RADIANS: f64 = 5e-5;
12+
const DEEP_RANDOM_PARITY_RESOLUTION: i32 = 25;
13+
const DEEP_RANDOM_PARITY_BATCH_SIZE: usize = 32;
14+
const DEEP_RANDOM_PARITY_DURATION_NS: i128 = 10 * std.time.ns_per_s;
15+
const DEEP_RANDOM_PARITY_SEED: u64 = 0x00d3_3e5e_0a4c_1e25;
16+
const DEEP_RANDOM_POINT_MIN_T: f64 = 0.000001;
17+
const DEEP_RANDOM_POINT_MAX_T: f64 = 0.000005;
18+
const DEEP_RANDOM_POINT_CONTAINMENT_EPSILON: f64 = 1e-8;
1219

1320
const CommandOutput = struct {
1421
stdout: []const u8,
@@ -40,6 +47,33 @@ const PointMismatch = struct {
4047
distance_radians: f64,
4148
};
4249

50+
fn randomCellAtResolution(
51+
random: std.Random,
52+
resolution: i32,
53+
) !u64 {
54+
if (resolution < 0 or resolution > serialization.MAX_RESOLUTION) {
55+
return error.InvalidResolution;
56+
}
57+
58+
const origin_id: u8 = @intCast(random.uintLessThan(usize, 12));
59+
const segment: usize = if (resolution > 0) random.uintLessThan(usize, 5) else 0;
60+
var s: u64 = 0;
61+
62+
if (resolution >= serialization.FIRST_HILBERT_RESOLUTION) {
63+
const hilbert_levels = resolution - serialization.FIRST_HILBERT_RESOLUTION + 1;
64+
const hilbert_bits: u6 = @intCast(2 * hilbert_levels);
65+
const max_s: u64 = @as(u64, 1) << hilbert_bits;
66+
s = random.uintLessThan(u64, max_s);
67+
}
68+
69+
return serialization.serialize(.{
70+
.origin_id = origin_id,
71+
.segment = segment,
72+
.s = s,
73+
.resolution = resolution,
74+
});
75+
}
76+
4377
fn runCommand(
4478
allocator: std.mem.Allocator,
4579
argv: []const []const u8,
@@ -129,6 +163,49 @@ fn runRustExportCellBoundaries(
129163
return runCommand(allocator, argv.items, null);
130164
}
131165

166+
fn runRustLonlatToCell(
167+
allocator: std.mem.Allocator,
168+
resolution: i32,
169+
points: []const LonLat,
170+
) !CommandOutput {
171+
var argv = try std.ArrayList([]const u8).initCapacity(allocator, 11 + points.len);
172+
defer argv.deinit(allocator);
173+
174+
var encoded_points = try std.ArrayList([]u8).initCapacity(allocator, points.len);
175+
defer {
176+
for (encoded_points.items) |encoded| allocator.free(encoded);
177+
encoded_points.deinit(allocator);
178+
}
179+
180+
var resolution_buffer: [16]u8 = undefined;
181+
const resolution_text = try std.fmt.bufPrint(&resolution_buffer, "{d}", .{resolution});
182+
183+
try argv.appendSlice(allocator, &.{
184+
"cargo",
185+
"run",
186+
"--quiet",
187+
"--manifest-path",
188+
"tests/qa_rust_oracle/Cargo.toml",
189+
"--target-dir",
190+
".zig-cache/qa_rust_oracle_target",
191+
"--",
192+
"lonlat-to-cell",
193+
resolution_text,
194+
});
195+
196+
for (points) |point| {
197+
const encoded = try std.fmt.allocPrint(
198+
allocator,
199+
"{d:.17},{d:.17}",
200+
.{ point.longitude(), point.latitude() },
201+
);
202+
try encoded_points.append(allocator, encoded);
203+
try argv.append(allocator, encoded);
204+
}
205+
206+
return runCommand(allocator, argv.items, null);
207+
}
208+
132209
fn parseHexCells(allocator: std.mem.Allocator, stdout: []const u8) ![]u64 {
133210
var ids = try std.ArrayList(u64).initCapacity(allocator, 0);
134211
errdefer ids.deinit(allocator);
@@ -244,6 +321,69 @@ fn lonLatToUnitVector(lon_degrees: f64, lat_degrees: f64) UnitVec3 {
244321
};
245322
}
246323

324+
fn normalizeUnitVector(vec: UnitVec3) UnitVec3 {
325+
const len = std.math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z);
326+
if (len == 0.0) {
327+
return .{ .x = 1.0, .y = 0.0, .z = 0.0 };
328+
}
329+
return .{
330+
.x = vec.x / len,
331+
.y = vec.y / len,
332+
.z = vec.z / len,
333+
};
334+
}
335+
336+
fn unitVectorToLonLat(vec: UnitVec3) LonLat {
337+
const normalized = normalizeUnitVector(vec);
338+
const lon_radians = std.math.atan2(normalized.y, normalized.x);
339+
const lat_radians = std.math.atan2(
340+
normalized.z,
341+
std.math.sqrt(normalized.x * normalized.x + normalized.y * normalized.y),
342+
);
343+
const radians_to_degrees = 180.0 / std.math.pi;
344+
return LonLat.new(
345+
lon_radians * radians_to_degrees,
346+
lat_radians * radians_to_degrees,
347+
);
348+
}
349+
350+
fn pointTowardBoundary(center: LonLat, boundary_point: LonLat, t: f64) LonLat {
351+
const center_vec = lonLatToUnitVector(center.longitude(), center.latitude());
352+
const boundary_vec = lonLatToUnitVector(boundary_point.longitude(), boundary_point.latitude());
353+
const blended = UnitVec3{
354+
.x = center_vec.x * (1.0 - t) + boundary_vec.x * t,
355+
.y = center_vec.y * (1.0 - t) + boundary_vec.y * t,
356+
.z = center_vec.z * (1.0 - t) + boundary_vec.z * t,
357+
};
358+
return unitVectorToLonLat(blended);
359+
}
360+
361+
fn randomInteriorPointForCell(
362+
random: std.Random,
363+
cell_id: u64,
364+
boundary: []const LonLat,
365+
) !LonLat {
366+
const center = try a5.cell_to_lonlat(cell_id);
367+
const boundary_len = effectiveZigBoundaryLen(boundary);
368+
if (boundary_len == 0) return center;
369+
370+
const cell_data = try serialization.deserialize(cell_id);
371+
const boundary_index = random.uintLessThan(usize, boundary_len);
372+
const target = boundary[boundary_index];
373+
var t = DEEP_RANDOM_POINT_MIN_T +
374+
(DEEP_RANDOM_POINT_MAX_T - DEEP_RANDOM_POINT_MIN_T) * random.float(f64);
375+
376+
var attempt: usize = 0;
377+
while (attempt < 10) : (attempt += 1) {
378+
const candidate = pointTowardBoundary(center, target, t);
379+
const distance = try a5.core.cell.a5cell_contains_point(cell_data, candidate);
380+
if (distance > DEEP_RANDOM_POINT_CONTAINMENT_EPSILON) return candidate;
381+
t *= 0.5;
382+
}
383+
384+
return center;
385+
}
386+
247387
fn angularDistanceRadians(a: UnitVec3, b: UnitVec3) f64 {
248388
const dot = std.math.clamp(a.x * b.x + a.y * b.y + a.z * b.z, -1.0, 1.0);
249389
return std.math.acos(dot);
@@ -589,3 +729,87 @@ test "qa e2e compare a5-rs and a5-zig cell outputs up to resolution 9" {
589729
}
590730
}
591731
}
732+
733+
test "qa e2e deep random cell boundary parity against rust oracle (~10s)" {
734+
var prng = std.Random.DefaultPrng.init(DEEP_RANDOM_PARITY_SEED);
735+
const random = prng.random();
736+
737+
const start_ns = std.time.nanoTimestamp();
738+
const deadline_ns = start_ns + DEEP_RANDOM_PARITY_DURATION_NS;
739+
740+
var checked_cells: usize = 0;
741+
var batch_count: usize = 0;
742+
743+
while (std.time.nanoTimestamp() < deadline_ns) : (batch_count += 1) {
744+
var batch = try std.ArrayList(u64).initCapacity(std.testing.allocator, DEEP_RANDOM_PARITY_BATCH_SIZE);
745+
defer batch.deinit(std.testing.allocator);
746+
747+
var i: usize = 0;
748+
while (i < DEEP_RANDOM_PARITY_BATCH_SIZE) : (i += 1) {
749+
const cell_id = try randomCellAtResolution(random, DEEP_RANDOM_PARITY_RESOLUTION);
750+
try batch.append(std.testing.allocator, cell_id);
751+
}
752+
753+
const rust_output = try runRustExportCellBoundaries(std.testing.allocator, batch.items);
754+
defer std.testing.allocator.free(rust_output.stdout);
755+
defer std.testing.allocator.free(rust_output.stderr);
756+
757+
const rust_boundaries = try parseRustBoundaries(std.testing.allocator, rust_output.stdout);
758+
defer freeRustBoundaries(std.testing.allocator, rust_boundaries);
759+
760+
var sampled_points = try std.ArrayList(LonLat).initCapacity(std.testing.allocator, rust_boundaries.len);
761+
defer sampled_points.deinit(std.testing.allocator);
762+
763+
try std.testing.expectEqual(batch.items.len, rust_boundaries.len);
764+
for (rust_boundaries, 0..) |rust_boundary, idx| {
765+
try std.testing.expectEqual(batch.items[idx], rust_boundary.id);
766+
const zig_boundary = try a5.cell_to_boundary(std.testing.allocator, rust_boundary.id, .{
767+
.closed_ring = true,
768+
.segments = 1,
769+
});
770+
defer std.testing.allocator.free(zig_boundary);
771+
try expectSameBoundary(DEEP_RANDOM_PARITY_RESOLUTION, rust_boundary.id, zig_boundary, rust_boundary.points);
772+
const sampled_point = try randomInteriorPointForCell(random, rust_boundary.id, zig_boundary);
773+
try sampled_points.append(std.testing.allocator, sampled_point);
774+
}
775+
776+
const rust_lonlat_output = try runRustLonlatToCell(
777+
std.testing.allocator,
778+
DEEP_RANDOM_PARITY_RESOLUTION,
779+
sampled_points.items,
780+
);
781+
defer std.testing.allocator.free(rust_lonlat_output.stdout);
782+
defer std.testing.allocator.free(rust_lonlat_output.stderr);
783+
784+
const rust_lonlat_cells = try parseHexCells(std.testing.allocator, rust_lonlat_output.stdout);
785+
defer std.testing.allocator.free(rust_lonlat_cells);
786+
787+
try std.testing.expectEqual(sampled_points.items.len, rust_lonlat_cells.len);
788+
for (sampled_points.items, 0..) |point, idx| {
789+
const zig_cell = try a5.lonlat_to_cell(point, DEEP_RANDOM_PARITY_RESOLUTION);
790+
const rust_cell = rust_lonlat_cells[idx];
791+
const source_cell = batch.items[idx];
792+
793+
if (zig_cell != rust_cell) {
794+
std.debug.print(
795+
"deep random lonlat parity mismatch: idx={d} lon={d:.12} lat={d:.12} source={x:0>16} zig={x:0>16} rust={x:0>16}\n",
796+
.{
797+
idx,
798+
point.longitude(),
799+
point.latitude(),
800+
source_cell,
801+
zig_cell,
802+
rust_cell,
803+
},
804+
);
805+
}
806+
807+
try std.testing.expectEqual(source_cell, rust_cell);
808+
try std.testing.expectEqual(rust_cell, zig_cell);
809+
}
810+
811+
checked_cells += batch.items.len;
812+
}
813+
814+
try std.testing.expect(checked_cells > 0);
815+
}

0 commit comments

Comments
 (0)