Repository navigation
feat: Add SparseSet data structure - #8
Conversation
O(1) key-value mapping with cache-friendly iteration. Useful for entity -> component mappings and other sparse key scenarios. Performance: 0.76 ns/lookup vs 4.61 ns for HashMap (6x faster) Trade-off: Fixed ~40KB memory for 10k max keys Closes #7 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
PR SummaryIntroduces major graph algorithms and data structures, updates APIs/exports, and upgrades build tooling.
Written by Cursor Bugbot for commit 825b801. This will update automatically on new commits. Configure here. |
Summary of ChangesHello @apotema, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a new Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a SparseSet data structure, which is a great addition for performance-sensitive scenarios. However, a medium-severity vulnerability has been identified related to a potential integer overflow when the set grows to a very large size, which could lead to data corruption. Additionally, there's a critical bug in the memory resizing logic that could lead to a corrupted state, and further suggestions have been provided for improving robustness and maintainability. Addressing these points will make this a very useful utility.
| const new_cap = self.capacity * 2; | ||
| self.dense_keys = try self.allocator.realloc(self.dense_keys, new_cap); | ||
| self.dense_values = try self.allocator.realloc(self.dense_values, new_cap); | ||
| self.capacity = new_cap; |
There was a problem hiding this comment.
This resizing logic has a critical flaw. The two realloc calls are not atomic. If the first realloc (for dense_keys) succeeds but the second one (for dense_values) fails, the SparseSet is left in a corrupted state with inconsistent slice capacities. This can lead to memory safety violations and crashes. Additionally, if self.capacity is 0, new_cap will also be 0, which is not a valid growth capacity and will cause issues with realloc.
A robust implementation should handle the zero-capacity case (e.g., growing to a default initial capacity) and ensure the resizing is atomic. Consider looking at how std.MultiArrayList handles growing its storage for a safe implementation pattern (it uses alloc+memcpy+free to avoid the complexities of multiple reallocs).
| self.capacity = new_cap; | ||
| } | ||
|
|
||
| const idx: u32 = @intCast(self.count); |
There was a problem hiding this comment.
In the put function, the self.count variable (of type usize) is cast to a u32 using @intCast. If the sparse set is used on a 64-bit system and stores more than 2^32 - 1 elements, this will lead to an integer overflow due to truncation, resulting in an incorrect idx and potential data corruption in dense_keys and dense_values. This could also cause memory corruption or panicking in safe build modes. To remediate, add checks in init and the resizing logic within put to ensure the capacity never exceeds u32.max. Specifically, before casting on line 67, validate that self.count is within u32 bounds and return an error.CapacityExceeded if exceeded. Example: if (self.count >= std.math.maxInt(u32)) return error.CapacityExceeded; const idx: u32 = @intCast(self.count);
| const Allocator = std.mem.Allocator; | ||
|
|
||
| /// Generic sparse set for mapping u64 keys to values of type T | ||
| pub fn SparseSet(comptime T: type) type { |
There was a problem hiding this comment.
The SparseSet is generic over the value type T, which is great. However, the key type is hardcoded to u64. To make this data structure more broadly useful, consider making the key type generic as well. It could be constrained to an integer type using comptime checks.
pub fn SparseSet(comptime T: type, comptime KeyType: type) type {
| const dense_keys = try allocator.alloc(u64, initial_capacity); | ||
| errdefer allocator.free(dense_keys); | ||
|
|
||
| const dense_values = try allocator.alloc(T, initial_capacity); |
There was a problem hiding this comment.
This allocation of dense_values is not paired with an errdefer. While it's currently the last fallible operation in init, this is fragile. If any failable code is added later in this function, it would introduce a memory leak on error. It's best practice to add an errdefer immediately after a successful allocation to ensure resources are cleaned up properly on any subsequent failure.
| try std.testing.expectEqual(@as(u64, 855), sum); // 555 + 300 | ||
| } | ||
|
|
||
| test "SparseSet iteration order stable after remove" { |
There was a problem hiding this comment.
The name of this test, SparseSet iteration order stable after remove, is misleading. The remove implementation uses a swap-and-pop algorithm, which is efficient but explicitly does not preserve the iteration order of the remaining elements. The test itself correctly verifies the behavior of swap-and-pop, but the name suggests the opposite.
A more accurate name would be something like SparseSet iteration after remove is correct. Additionally, the comment on line 220 is confusing as it doesn't reflect the actual order of elements after the removal.
New modules extracted from labelle-engine: - z_index_buckets.zig: Generic bucket-sorted storage by u8 key (256 buckets) for efficient z-index ordering. O(1) insert, O(bucket_size) remove. - zon_coercion.zig: Comptime ZON coercion utilities for converting anonymous structs to typed structs. Handles nested structs, tuples, unions, and struct merging. - hook_dispatcher.zig: Zero-overhead comptime hook dispatcher for event handling. Supports single dispatchers, empty dispatchers, and merged hook handlers. All modules are generic with no external dependencies beyond std. Closes #7 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Additional Utilities AddedThis PR now includes more utilities beyond the original SparseSet: ZIndexBucketsBucket-sorted storage by u8 key (256 buckets) for efficient z-index ordering:
ZON CoercionComptime utilities for .zon file processing:
HookDispatcherZero-overhead comptime event dispatch:
All modules are generic with no external dependencies beyond std. |
If dense_keys realloc succeeds but dense_values fails, shrink dense_keys back to original capacity to maintain consistent state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
New modules from labelle-pathfinding: - floyd_warshall.zig: Basic Floyd-Warshall all-pairs shortest path O(V³) with entity ID mapping support - floyd_warshall_optimized.zig: High-performance version with: - Flat memory layout for cache efficiency - SIMD vectorization (4x u32 vectors) - Multi-threaded parallelization - 5-16x faster than basic version - a_star.zig: A* single-source shortest path algorithm with: - Multiple built-in heuristics - Custom heuristic function support - Entity ID mapping - Adjacency list representation - heuristics.zig: Distance heuristics for A*: - Euclidean (any-angle movement) - Manhattan (4-directional grid) - Chebyshev (8-dir equal diagonal) - Octile (8-dir realistic diagonal) - Zero (Dijkstra mode) All modules have no external dependencies beyond std. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comptime improvements: - SparseSet: Add generic KeyType parameter - QuadTree: Add QuadTreeConfig for comptime capacity/gutter - ZIndexBuckets: Add generic ZIndexType parameter - FloydWarshall: Add generic DistanceType parameter - AStar: Add generic WeightType parameter - FloydWarshallOptimized: Add configurable vector_width Data structure optimizations (AStar): - Replace closed_set HashMap with DynamicBitSet (~32x smaller) - Replace g_score/came_from HashMaps with flat arrays (O(1) direct indexing) - Replace positions/ids/reverse_ids HashMaps with SparseSet Also fixes PR review comments: - Atomic resizing in SparseSet using alloc+memcpy+free pattern - u32 overflow check before @intcast - Add missing errdefer for dense_values - Fix misleading test name 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
QuadTree now requires a config parameter: QuadTree(T, .{})
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- z_index_buckets: Fix item loss when insert fails after remove in changeZIndex by inserting first, then removing only on success - floyd_warshall_optimized: Fix double-free on partial allocation in clean() by allocating both arrays before freeing old ones - floyd_warshall_optimized: Fix thread spawn failure use-after-free by tracking spawned threads and signaling sync counters before joining on failure - floyd_warshall: Fix memory leak in clean() by adding errdefer for local RowLists - floyd_warshall: Fix panic after allocation error in addEdgeWithMapping by propagating errors instead of catching and continuing - a_star: Fix memory leak in init() by using errdefer for SparseSet allocations - build.zig.zon: Update zspec hash to 0.6.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
/gemini review |
| // Each thread that owns row k signals all threads | ||
| if (k >= start_row and k < end_row) { | ||
| _ = sync_counters[k + 1].fetchAdd(thread_count_u32, .release); | ||
| } |
There was a problem hiding this comment.
Parallel Floyd-Warshall has data race between iterations
High Severity
The parallel synchronization allows threads to proceed to iteration k+1 before all threads finish iteration k. Only the thread owning row k signals sync_counters[k+1], immediately setting it to thread_count. This lets all threads (including itself) start iteration k+1 while other threads may still be processing iteration k. When Thread A in iteration k+1 reads dist[j][k+1] while Thread B in iteration k is still writing to row j, a data race occurs that can produce incorrect shortest path results.
| const new_id = self.newKey(); | ||
| try self.ids.put(entity, new_id); | ||
| try self.reverse_ids.put(new_id, entity); | ||
| return new_id; |
There was a problem hiding this comment.
Mapping becomes inconsistent if second put fails
Medium Severity
In getOrCreateMapping, if self.ids.put(entity, new_id) succeeds but self.reverse_ids.put(new_id, entity) fails due to OOM, the ids map contains a forward mapping while reverse_ids lacks the corresponding reverse mapping. This leaves the data structure in an inconsistent state where findPathWithMapping can find internal IDs via ids but cannot translate them back to entity IDs via reverse_ids. The same issue exists in FloydWarshallOptimized.addEdgeWithMapping.
Additional Locations (1)
There was a problem hiding this comment.
Code Review
This pull request introduces a SparseSet data structure and a suite of other valuable utilities, including A* and Floyd-Warshall pathfinding algorithms, a HookDispatcher for comptime events, and ZON coercion helpers. The code is well-structured, documented, and includes tests for the new functionality. The addition of SparseSet is particularly beneficial for performance-sensitive applications.
My review focuses on improving robustness and performance in a few areas:
- Error Handling: An error in
a_star.zigis logged but not propagated, which could lead to silent failures. - State Consistency: Another part of
a_star.zigcould silently produce an incorrect path if its internal state becomes inconsistent. - Performance: The non-optimized
floyd_warshall.zigcontains an inefficient O(N) lookup that can be optimized to O(1). - Clarity: A comment in the parallel implementation of
floyd_warshall_optimized.zigcould be rephrased for better clarity and maintainability.
Overall, this is a strong contribution. Addressing these points will make the new utilities even more robust and performant.
| pub fn addEdge(self: *Self, u: u32, v: u32, w: WeightType) void { | ||
| if (u >= self.adjacency.items.len or v >= self.adjacency.items.len) return; | ||
| self.adjacency.items[u].append(self.allocator, .{ .to = v, .weight = w }) catch |err| { | ||
| std.log.err("Error adding edge: {any}\n", .{err}); | ||
| }; | ||
| } |
There was a problem hiding this comment.
The catch block here logs an allocation error but swallows it, preventing it from propagating to the caller. This can lead to an inconsistent graph state and silent failures if adding an edge fails. The function should be modified to return !void and use try to propagate the error. Consequently, the call to addEdge in addEdgeWithMapping will also need to be updated to use try.
pub fn addEdge(self: *Self, u: u32, v: u32, w: WeightType) !void {
if (u >= self.adjacency.items.len or v >= self.adjacency.items.len) return;
try self.adjacency.items[u].append(self.allocator, .{ .to = v, .weight = w });
}
| if (cost != null) { | ||
| path.clearRetainingCapacity(); | ||
| for (internal_path.items) |internal_id| { | ||
| const entity = self.reverse_ids.get(internal_id) orelse continue; |
There was a problem hiding this comment.
Using orelse continue here silently skips a node in the path if its reverse ID mapping is not found. This indicates a potential data corruption or a bug in the ID mapping logic, as every internal ID in a valid path should have a corresponding reverse mapping. Instead of failing silently, this should trigger a panic (especially in debug builds) to immediately flag the inconsistent state.
const entity = self.reverse_ids.get(internal_id) orelse {
// This indicates an inconsistent state, as every internal ID
// in the path should have a reverse mapping.
std.debug.panic("Inconsistent A* state: reverse ID not found for internal ID {}", .{internal_id});
};
| pub fn nextWithMapping(self: *Self, u: u32, v: u32) u32 { | ||
| const val = self.next(self.ids.get(u).?, self.ids.get(v).?); | ||
| var result = self.ids.iterator(); | ||
| while (result.next()) |entry| { | ||
| if (entry.value_ptr.* == val) { | ||
| return entry.key_ptr.*; | ||
| } | ||
| } | ||
| return std.math.maxInt(u32); | ||
| } |
There was a problem hiding this comment.
This implementation of nextWithMapping performs a linear scan (O(N)) over the ids hash map to find the external entity ID corresponding to the internal next hop ID. This is highly inefficient for large graphs.
To achieve an O(1) lookup, a reverse mapping from internal ID to external entity ID should be maintained. The AStar and FloydWarshallOptimized implementations in this pull request already use this superior approach with a reverse_ids map. I recommend applying the same pattern here.
| } | ||
|
|
||
| // If we own row k, signal that row k+1 is ready | ||
| // Each thread that owns row k signals all threads |
There was a problem hiding this comment.
This comment is slightly confusing. Since start_row and end_row define a unique range for each thread, only one thread can "own" row k. The phrase "Each thread that owns row k" could be misinterpreted. A clearer comment would improve the maintainability of this complex parallel logic.
// The thread that owns row k signals that the next stage is ready for all threads.
Migrated tests from 7 source files to dedicated test files: - sparse_set_test.zig - z_index_buckets_test.zig - floyd_warshall_test.zig - floyd_warshall_optimized_test.zig - a_star_test.zig - heuristics_test.zig - zon_coercion_test.zig Tests now use zspec format with expect assertions instead of std.testing. All 89 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- floyd_warshall.zig: - Fix errdefer use-after-free in clean() with ownership tracking - Fix silent path failures in setPathWithMapping (return error.PathNotFound) - Add reverse_ids map for O(1) reverse lookups (was O(N) linear scan) - Add errdefer rollback for mapping consistency in addEdgeWithMapping - floyd_warshall_optimized.zig: - Add documentation explaining why parallel algorithm is data-race safe - Clarify comment about sync counter signaling mechanism - a_star.zig: - Fix mapping inconsistency when reverse_ids.put fails after ids.put - Change addEdge to return error instead of swallowing allocation errors - Fix orelse continue in findPathWithMapping (return CorruptedMapping error) - Add PathError type for setPathWithMapping to return PathNotFound - tests/a_star_test.zig: - Update tests to handle new error-returning addEdge signature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update CI workflow to use Zig 0.15.0 - Update minimum_zig_version in build.zig.zon Required because std.array_list.Managed API was introduced in 0.15 (0.14 uses std.ArrayList directly) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2107b19 to
f8e10ba
Compare
Update CI to use mlugg/setup-zig@v2 with Zig 0.15.2 (v2 supports the 0.15.x development builds) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
f8e10ba to
825b801
Compare
| try self.reverse_ids.put(key, v); | ||
| } | ||
| self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); | ||
| } |
There was a problem hiding this comment.
Missing errdefer causes inconsistent ID mappings on failure
Medium Severity
The addEdgeWithMapping function in the optimized Floyd-Warshall implementation lacks errdefer cleanup for the ids hash map. If reverse_ids.put(key, u) fails after ids.put(u, key) succeeds, the maps become inconsistent - ids will contain the mapping but reverse_ids won't have the reverse mapping. The non-optimized FloydWarshall implementation correctly uses errdefer _ = self.ids.remove(u) to handle this case.
| } | ||
| } | ||
| try path_list.append(v_node); | ||
| } |
There was a problem hiding this comment.
Partial path data left in list on error
Medium Severity
The setPathWithMapping and setPathWithMappingUnmanaged functions don't clean up partial path data when returning error.NoPathFound. After appending nodes to path_list in the while loop, if no path exists, the error is returned but the partially-filled list isn't restored. The non-optimized version correctly saves initial_len and calls path_list.shrinkRetainingCapacity(initial_len) before returning the error, ensuring callers don't receive corrupted state.
Additional Locations (1)
|
|
||
| size: u32 = 100, | ||
| graph: GraphList, | ||
| path: GraphList, |
There was a problem hiding this comment.
Path matrix type mismatch causes overflow for small DistanceType
Medium Severity
The path matrix reuses DistanceType to store vertex indices, but these are semantically different. When size > maxInt(DistanceType) (e.g., using FloydWarshall(u8) with more than 255 vertices), the @intCast(j) in clean() will overflow and panic. The optimized version correctly uses u32 for the next-hop matrix. The path matrix should use a dedicated index type like u32 rather than conflating it with DistanceType.
Summary
Add SparseSet data structure - O(1) key-value mapping with cache-friendly iteration.
Features
Performance
6x faster than std.AutoHashMap for lookups.
Trade-off
Fixed memory allocation based on max key range (~40KB for 10k keys) vs variable for HashMap.
Use Cases
Closes #7
🤖 Generated with Claude Code