From 9f3f6d2880000f089c8f9d29a04ee03867d05bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Thu, 8 Jan 2026 15:40:00 -0300 Subject: [PATCH 01/13] feat: Add SparseSet data structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- claude.md | 16 ++++ src/root.zig | 3 + src/sparse_set.zig | 221 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/sparse_set.zig diff --git a/claude.md b/claude.md index 5a311c5..1d4549d 100644 --- a/claude.md +++ b/claude.md @@ -10,6 +10,7 @@ src/ vector.zig - Position and PositionI types quad_tree.zig - QuadTree spatial partitioning (generic, Position-based) sweep_and_prune.zig - Sweep and Prune collision detection + sparse_set.zig - SparseSet O(1) key-value mapping tests/ root.zig - Test entry point vector_test.zig - Position/PositionI tests @@ -23,6 +24,7 @@ tests/ - `PositionI` (i32) - Integer position for pixel-perfect work - `QuadTree(T)` - Generic spatial index with Position-based queries - `SweepAndPrune(T)` - Generic broad-phase collision detection +- `SparseSet(T)` - O(1) key-value mapping with cache-friendly iteration - `Rectangle` - AABB for bounds and collision - `EntityPoint(T)` - Point with generic ID for QuadTree - `AABB` - Axis-aligned bounding box for SweepAndPrune @@ -59,6 +61,20 @@ zig build test - Generic ID type support - Position-based AABB queries +### SparseSet +- O(1) insert, remove, lookup (worst-case, not amortized) +- Cache-friendly dense array iteration +- Generic value type support +- Fixed memory based on max key range +- Ideal for entity -> component mappings + +**Benchmark results (vs HashMap):** +| Operation | SparseSet | HashMap | +|-----------|-----------|---------| +| contains | 0.76 ns | 4.61 ns | + +**Trade-off:** ~40KB memory for 10k max keys vs variable for HashMap + ## Related - labelle-pathfinding - Uses QuadTree for spatial queries diff --git a/src/root.zig b/src/root.zig index bf6bb73..4cf92f6 100644 --- a/src/root.zig +++ b/src/root.zig @@ -16,5 +16,8 @@ pub const AABB = sweep_and_prune.AABB; pub const CollisionPair = sweep_and_prune.CollisionPair; pub const sweepAndPruneSimple = sweep_and_prune.sweepAndPrune; +pub const sparse_set = @import("sparse_set.zig"); +pub const SparseSet = sparse_set.SparseSet; + // Backwards compatibility alias (deprecated) pub const Vector2 = Position; diff --git a/src/sparse_set.zig b/src/sparse_set.zig new file mode 100644 index 0000000..3d1b733 --- /dev/null +++ b/src/sparse_set.zig @@ -0,0 +1,221 @@ +//! Sparse Set +//! +//! O(1) lookup, insert, remove with cache-friendly iteration. +//! Used for entity -> physics body mappings. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Generic sparse set for mapping u64 keys to values of type T +pub fn SparseSet(comptime T: type) type { + return struct { + const Self = @This(); + + allocator: Allocator, + sparse: []?u32, // key -> dense_index + dense_keys: []u64, // dense_index -> key + dense_values: []T, // dense_index -> value + count: usize, + capacity: usize, + max_key: usize, + + pub fn init(allocator: Allocator, max_keys: usize, initial_capacity: usize) !Self { + const sparse = try allocator.alloc(?u32, max_keys); + errdefer allocator.free(sparse); + @memset(sparse, null); + + const dense_keys = try allocator.alloc(u64, initial_capacity); + errdefer allocator.free(dense_keys); + + const dense_values = try allocator.alloc(T, initial_capacity); + + return Self{ + .allocator = allocator, + .sparse = sparse, + .dense_keys = dense_keys, + .dense_values = dense_values, + .count = 0, + .capacity = initial_capacity, + .max_key = max_keys, + }; + } + + pub fn deinit(self: *Self) void { + self.allocator.free(self.sparse); + self.allocator.free(self.dense_keys); + self.allocator.free(self.dense_values); + } + + /// Insert or update a key-value pair + pub fn put(self: *Self, key: u64, value: T) !void { + if (key >= self.max_key) return error.KeyOutOfRange; + + // Update existing + if (self.sparse[key]) |idx| { + self.dense_values[idx] = value; + return; + } + + // Grow if needed + if (self.count >= self.capacity) { + 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; + } + + const idx: u32 = @intCast(self.count); + self.sparse[key] = idx; + self.dense_keys[idx] = key; + self.dense_values[idx] = value; + self.count += 1; + } + + /// Get value for key + pub fn get(self: *const Self, key: u64) ?T { + if (key >= self.max_key) return null; + const idx = self.sparse[key] orelse return null; + return self.dense_values[idx]; + } + + /// Get pointer to value for key + pub fn getPtr(self: *Self, key: u64) ?*T { + if (key >= self.max_key) return null; + const idx = self.sparse[key] orelse return null; + return &self.dense_values[idx]; + } + + /// Check if key exists + pub fn contains(self: *const Self, key: u64) bool { + if (key >= self.max_key) return false; + return self.sparse[key] != null; + } + + /// Remove key-value pair + pub fn remove(self: *Self, key: u64) void { + if (key >= self.max_key) return; + const idx = self.sparse[key] orelse return; + + // Swap with last element + const last_idx = self.count - 1; + if (idx != last_idx) { + const last_key = self.dense_keys[last_idx]; + self.dense_keys[idx] = last_key; + self.dense_values[idx] = self.dense_values[last_idx]; + self.sparse[last_key] = idx; + } + + self.sparse[key] = null; + self.count -= 1; + } + + /// Clear all entries + pub fn clear(self: *Self) void { + for (self.dense_keys[0..self.count]) |key| { + self.sparse[key] = null; + } + self.count = 0; + } + + /// Iterate over all values (cache-friendly) + pub fn values(self: *const Self) []const T { + return self.dense_values[0..self.count]; + } + + /// Iterate over all keys + pub fn keys(self: *const Self) []const u64 { + return self.dense_keys[0..self.count]; + } + + /// Get key-value pairs for iteration + pub const Entry = struct { + key: u64, + value: *T, + }; + + pub fn iterator(self: *Self) Iterator { + return .{ .set = self, .index = 0 }; + } + + pub const Iterator = struct { + set: *Self, + index: usize, + + pub fn next(self: *Iterator) ?Entry { + if (self.index >= self.set.count) return null; + const entry = Entry{ + .key = self.set.dense_keys[self.index], + .value = &self.set.dense_values[self.index], + }; + self.index += 1; + return entry; + } + }; + + /// Number of entries + pub fn len(self: *const Self) usize { + return self.count; + } + }; +} + +// Tests +test "SparseSet basic operations" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64).init(allocator, 1000, 16); + defer set.deinit(); + + // Insert + try set.put(5, 500); + try set.put(10, 1000); + try set.put(3, 300); + + // Get + try std.testing.expectEqual(@as(?u64, 500), set.get(5)); + try std.testing.expectEqual(@as(?u64, 1000), set.get(10)); + try std.testing.expectEqual(@as(?u64, 300), set.get(3)); + try std.testing.expectEqual(@as(?u64, null), set.get(999)); + + // Contains + try std.testing.expect(set.contains(5)); + try std.testing.expect(!set.contains(999)); + + // Update + try set.put(5, 555); + try std.testing.expectEqual(@as(?u64, 555), set.get(5)); + + // Remove + set.remove(10); + try std.testing.expect(!set.contains(10)); + try std.testing.expectEqual(@as(usize, 2), set.len()); + + // Iteration + var sum: u64 = 0; + for (set.values()) |v| { + sum += v; + } + try std.testing.expectEqual(@as(u64, 855), sum); // 555 + 300 +} + +test "SparseSet iteration order stable after remove" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u32).init(allocator, 100, 16); + defer set.deinit(); + + try set.put(1, 10); + try set.put(2, 20); + try set.put(3, 30); + try set.put(4, 40); + + // Remove middle element + set.remove(2); + + // Should still iterate all remaining + var sum: u32 = 0; + for (set.values()) |v| { + sum += v; + } + try std.testing.expectEqual(@as(u32, 80), sum); // 10 + 30 + 40 +} From 4b359b7b12c1b956b72c70da1924f9feef570637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Thu, 8 Jan 2026 15:54:05 -0300 Subject: [PATCH 02/13] feat: Add ZIndexBuckets, ZON coercion, and HookDispatcher utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hook_dispatcher.zig | 200 ++++++++++++++++++++++ src/root.zig | 16 +- src/z_index_buckets.zig | 209 +++++++++++++++++++++++ src/zon_coercion.zig | 365 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 src/hook_dispatcher.zig create mode 100644 src/z_index_buckets.zig create mode 100644 src/zon_coercion.zig diff --git a/src/hook_dispatcher.zig b/src/hook_dispatcher.zig new file mode 100644 index 0000000..133f5ed --- /dev/null +++ b/src/hook_dispatcher.zig @@ -0,0 +1,200 @@ +//! Hook Dispatcher +//! +//! Provides a comptime-based hook dispatcher for zero-overhead event handling. +//! Hooks are resolved entirely at compile time, with no runtime overhead. + +const std = @import("std"); + +/// Creates a hook dispatcher from a comptime hook map. +/// +/// The HookMap should be a struct type where each public declaration is either: +/// - A function matching the signature for that hook +/// - A function name matching a hook name (e.g., `game_init`, `scene_load`) +/// +/// Example: +/// ```zig +/// const MyHooks = struct { +/// pub fn game_init(payload: HookPayload) void { +/// // Handle game init +/// } +/// +/// pub fn scene_load(payload: HookPayload) void { +/// const info = payload.scene_load; +/// std.log.info("Scene loaded: {s}", .{info.name}); +/// } +/// }; +/// +/// const Dispatcher = HookDispatcher(EngineHook, HookPayload, MyHooks); +/// Dispatcher.emit(.{ .scene_load = .{ .name = "main" } }); +/// ``` +pub fn HookDispatcher( + comptime HookEnum: type, + comptime PayloadUnion: type, + comptime HookMap: type, +) type { + // Validate that PayloadUnion is a union tagged by HookEnum + const payload_info = @typeInfo(PayloadUnion); + if (payload_info != .@"union") { + @compileError("PayloadUnion must be a union type"); + } + if (payload_info.@"union".tag_type != HookEnum) { + @compileError("PayloadUnion must be tagged by HookEnum"); + } + + return struct { + const Self = @This(); + + /// The hook enum type this dispatcher handles. + pub const Hook = HookEnum; + + /// The payload union type this dispatcher handles. + pub const Payload = PayloadUnion; + + /// The hook handler map type. + pub const Handlers = HookMap; + + /// Emit a hook event. Resolved entirely at comptime - no runtime overhead. + /// + /// If no handler is registered for the hook, this is a no-op. + pub inline fn emit(payload: PayloadUnion) void { + // Use inline switch to resolve hook name at comptime + switch (payload) { + inline else => |_, tag| { + const hook_name = @tagName(tag); + if (@hasDecl(HookMap, hook_name)) { + const handler = @field(HookMap, hook_name); + handler(payload); + } + // No handler registered - that's fine, just a no-op + }, + } + } + + /// Check at comptime if a hook has a handler registered. + pub fn hasHandler(comptime hook: HookEnum) bool { + return @hasDecl(HookMap, @tagName(hook)); + } + + /// Get the number of hooks that have handlers registered. + pub fn handlerCount() comptime_int { + var count: comptime_int = 0; + for (std.enums.values(HookEnum)) |hook| { + if (@hasDecl(HookMap, @tagName(hook))) { + count += 1; + } + } + return count; + } + }; +} + +/// Creates an empty hook dispatcher with no handlers. +/// Useful as a default when no hooks are needed. +pub fn EmptyDispatcher(comptime HookEnum: type, comptime PayloadUnion: type) type { + return HookDispatcher(HookEnum, PayloadUnion, struct {}); +} + +/// Merges multiple hook handler structs into one composite dispatcher. +/// When a hook is emitted, all matching handlers from all structs are called in order. +/// +/// This enables two-way plugin binding: +/// - Plugins can provide engine hook handlers that get merged with game hooks +/// - Each handler struct can implement any subset of hooks +/// +/// Example: +/// ```zig +/// const GameHooks = struct { +/// pub fn game_init(_: HookPayload) void { +/// std.log.info("Game started!", .{}); +/// } +/// }; +/// +/// const PluginHooks = struct { +/// pub fn game_init(_: HookPayload) void { +/// std.log.info("Plugin initialized!", .{}); +/// } +/// pub fn frame_start(payload: HookPayload) void { +/// // Plugin frame logic +/// } +/// }; +/// +/// // Merge game + plugin hooks - both game_init handlers will be called +/// const AllHooks = MergeHooks(EngineHook, HookPayload, .{ GameHooks, PluginHooks }); +/// const Game = engine.GameWith(AllHooks); +/// ``` +pub fn MergeHooks( + comptime HookEnum: type, + comptime PayloadUnion: type, + comptime handler_structs: anytype, +) type { + // Validate that PayloadUnion is a union tagged by HookEnum + const payload_info = @typeInfo(PayloadUnion); + if (payload_info != .@"union") { + @compileError("PayloadUnion must be a union type"); + } + if (payload_info.@"union".tag_type != HookEnum) { + @compileError("PayloadUnion must be tagged by HookEnum"); + } + + return struct { + const Self = @This(); + + /// The hook enum type this dispatcher handles. + pub const Hook = HookEnum; + + /// The payload union type this dispatcher handles. + pub const Payload = PayloadUnion; + + /// Emit a hook event to all registered handlers. + /// Handlers are called in the order the structs appear in handler_structs. + /// If no handler is registered for the hook in any struct, this is a no-op. + pub inline fn emit(payload: PayloadUnion) void { + switch (payload) { + inline else => |_, tag| { + const hook_name = @tagName(tag); + inline for (handler_structs) |H| { + if (@hasDecl(H, hook_name)) { + const handler = @field(H, hook_name); + handler(payload); + } + } + }, + } + } + + /// Check at comptime if any handler struct has a handler for this hook. + pub fn hasHandler(comptime hook: HookEnum) bool { + inline for (handler_structs) |H| { + if (@hasDecl(H, @tagName(hook))) { + return true; + } + } + return false; + } + + /// Get the number of unique hooks that have at least one handler registered. + pub fn handlerCount() comptime_int { + var count: comptime_int = 0; + for (std.enums.values(HookEnum)) |hook| { + if (hasHandler(hook)) { + count += 1; + } + } + return count; + } + + /// Get the total number of handlers across all structs (including duplicates). + pub fn totalHandlerCount() comptime_int { + var count: comptime_int = 0; + for (std.enums.values(HookEnum)) |hook| { + const hook_name = @tagName(hook); + inline for (handler_structs) |H| { + if (@hasDecl(H, hook_name)) { + count += 1; + } + } + } + return count; + } + }; +} diff --git a/src/root.zig b/src/root.zig index 4cf92f6..6dfc704 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,4 +1,4 @@ -// zig-utils - Standalone math utilities for Zig +// zig-utils - Standalone utilities for Zig // No external dependencies, only std pub const vector = @import("vector.zig"); @@ -19,5 +19,19 @@ pub const sweepAndPruneSimple = sweep_and_prune.sweepAndPrune; pub const sparse_set = @import("sparse_set.zig"); pub const SparseSet = sparse_set.SparseSet; +pub const z_index_buckets = @import("z_index_buckets.zig"); +pub const ZIndexBuckets = z_index_buckets.ZIndexBuckets; + +pub const zon = @import("zon_coercion.zig"); +pub const coerceValue = zon.coerceValue; +pub const buildStruct = zon.buildStruct; +pub const tupleToSlice = zon.tupleToSlice; +pub const mergeStructs = zon.mergeStructs; + +pub const hooks = @import("hook_dispatcher.zig"); +pub const HookDispatcher = hooks.HookDispatcher; +pub const EmptyDispatcher = hooks.EmptyDispatcher; +pub const MergeHooks = hooks.MergeHooks; + // Backwards compatibility alias (deprecated) pub const Vector2 = Position; diff --git a/src/z_index_buckets.zig b/src/z_index_buckets.zig new file mode 100644 index 0000000..6f63a83 --- /dev/null +++ b/src/z_index_buckets.zig @@ -0,0 +1,209 @@ +//! Z-Index Bucket Storage +//! +//! Maintains items sorted by z-index using 256 buckets (one per z-index level). +//! This eliminates the need to re-sort the entire list when z-indices change. +//! +//! Complexity: +//! - Insert: O(1) amortized +//! - Remove: O(bucket_size) - typically small due to clustered z-indices +//! - Change z-index: O(bucket_size) +//! - Iteration: O(256 + n) ā‰ˆ O(n) + +const std = @import("std"); + +/// Z-index bucket storage for efficient ordered iteration by u8 key. +/// Generic over item type T. +pub fn ZIndexBuckets(comptime T: type) type { + return struct { + const Self = @This(); + const Bucket = std.ArrayListUnmanaged(T); + + buckets: [256]Bucket, + allocator: std.mem.Allocator, + total_count: usize, + + pub fn init(allocator: std.mem.Allocator) Self { + return Self{ + .buckets = [_]Bucket{.{}} ** 256, + .allocator = allocator, + .total_count = 0, + }; + } + + pub fn deinit(self: *Self) void { + for (&self.buckets) |*bucket| { + bucket.deinit(self.allocator); + } + } + + /// Insert an item at the given z-index + pub fn insert(self: *Self, item: T, z: u8) !void { + try self.buckets[z].append(self.allocator, item); + self.total_count += 1; + } + + /// Remove an item from the given z-index bucket using equality comparison. + /// Returns true if the item was found and removed. + pub fn remove(self: *Self, item: T, z: u8) bool { + const bucket = &self.buckets[z]; + for (bucket.items, 0..) |existing, i| { + if (eql(existing, item)) { + _ = bucket.swapRemove(i); + self.total_count -= 1; + return true; + } + } + return false; + } + + /// Change an item's z-index from old_z to new_z + /// Returns error if the item was not found at old_z + pub fn changeZIndex(self: *Self, item: T, old_z: u8, new_z: u8) !void { + if (old_z == new_z) return; + const removed = self.remove(item, old_z); + if (!removed) { + return error.ItemNotFound; + } + try self.insert(item, new_z); + } + + /// Get total number of items across all buckets + pub fn count(self: *const Self) usize { + return self.total_count; + } + + /// Clear all buckets + pub fn clear(self: *Self) void { + for (&self.buckets) |*bucket| { + bucket.clearRetainingCapacity(); + } + self.total_count = 0; + } + + /// Iterator that yields items in z-index order (0 to 255) + pub fn iterator(self: *const Self) Iterator { + return Iterator.init(self); + } + + pub const Iterator = struct { + buckets: *const [256]Bucket, + z: u16, + idx: usize, + + pub fn init(storage: *const Self) Iterator { + var iter = Iterator{ + .buckets = &storage.buckets, + .z = 0, + .idx = 0, + }; + iter.skipEmptyBuckets(); + return iter; + } + + pub fn next(self: *Iterator) ?T { + while (self.z < 256) { + const bucket = &self.buckets[self.z]; + if (self.idx < bucket.items.len) { + const item = bucket.items[self.idx]; + self.idx += 1; + return item; + } + self.z += 1; + self.idx = 0; + } + return null; + } + + fn skipEmptyBuckets(self: *Iterator) void { + while (self.z < 256 and self.buckets[self.z].items.len == 0) { + self.z += 1; + } + } + + pub fn reset(self: *Iterator) void { + self.z = 0; + self.idx = 0; + self.skipEmptyBuckets(); + } + }; + + /// Collect all items into a slice in z-index order. + /// The caller must provide a buffer of at least `count()` size. + pub fn collectInto(self: *const Self, buffer: []T) []T { + var i: usize = 0; + var iter = self.iterator(); + while (iter.next()) |item| { + if (i >= buffer.len) break; + buffer[i] = item; + i += 1; + } + return buffer[0..i]; + } + + /// Equality comparison for items + fn eql(a: T, b: T) bool { + // Use eql method if available, otherwise use == + if (@hasDecl(T, "eql")) { + return a.eql(b); + } else { + return std.meta.eql(a, b); + } + } + }; +} + +// Tests +test "ZIndexBuckets basic operations" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32).init(allocator); + defer buckets.deinit(); + + // Insert + try buckets.insert(100, 5); + try buckets.insert(200, 10); + try buckets.insert(300, 5); + + try std.testing.expectEqual(@as(usize, 3), buckets.count()); + + // Iterate in z-order + var iter = buckets.iterator(); + try std.testing.expectEqual(@as(?u32, 100), iter.next()); + try std.testing.expectEqual(@as(?u32, 300), iter.next()); // same z=5 + try std.testing.expectEqual(@as(?u32, 200), iter.next()); // z=10 + try std.testing.expectEqual(@as(?u32, null), iter.next()); + + // Remove + try std.testing.expect(buckets.remove(100, 5)); + try std.testing.expectEqual(@as(usize, 2), buckets.count()); + + // Change z-index + try buckets.changeZIndex(200, 10, 0); + iter.reset(); + try std.testing.expectEqual(@as(?u32, 200), iter.next()); // now at z=0 +} + +test "ZIndexBuckets with struct items" { + const allocator = std.testing.allocator; + + const Item = struct { + id: u32, + name: []const u8, + + pub fn eql(self: @This(), other: @This()) bool { + return self.id == other.id; + } + }; + + var buckets = ZIndexBuckets(Item).init(allocator); + defer buckets.deinit(); + + try buckets.insert(.{ .id = 1, .name = "first" }, 10); + try buckets.insert(.{ .id = 2, .name = "second" }, 5); + + try std.testing.expectEqual(@as(usize, 2), buckets.count()); + + // Remove by id (uses eql method) + try std.testing.expect(buckets.remove(.{ .id = 1, .name = "" }, 10)); + try std.testing.expectEqual(@as(usize, 1), buckets.count()); +} diff --git a/src/zon_coercion.zig b/src/zon_coercion.zig new file mode 100644 index 0000000..5a6f318 --- /dev/null +++ b/src/zon_coercion.zig @@ -0,0 +1,365 @@ +//! Comptime ZON coercion utilities +//! +//! Converts anonymous structs from .zon files to typed structs at comptime. +//! Handles nested struct coercion, tuple-to-slice conversion, and union coercion. +//! +//! Usage: +//! const MyType = zon.buildStruct(TargetType, zon_data); +//! const field_val = zon.coerceValue(FieldType, zon_value); + +const std = @import("std"); + +/// Coerce a comptime ZON value to the expected field type. +/// Handles nested struct coercion and tuple-to-slice conversion. +pub fn coerceValue(comptime FieldType: type, comptime data_value: anytype) FieldType { + const DataType = @TypeOf(data_value); + const field_info = @typeInfo(FieldType); + + // Handle optional types - unwrap and coerce the child type + if (field_info == .optional) { + const ChildType = field_info.optional.child; + // Check for null + if (DataType == @TypeOf(null)) { + return null; + } + // Coerce to the child type and wrap in optional + return coerceValue(ChildType, data_value); + } + + // Handle slice types + if (field_info == .pointer) { + const ptr_info = field_info.pointer; + if (ptr_info.size == .slice) { + const ChildType = ptr_info.child; + const data_info = @typeInfo(DataType); + + // If data is a tuple, convert to slice + if (data_info == .@"struct" and data_info.@"struct".is_tuple) { + return tupleToSlice(ChildType, data_value); + } + } + } + + // Handle fixed-size array coercion (tuple to array) + if (field_info == .array) { + const arr_info = field_info.array; + const data_info = @typeInfo(DataType); + if (data_info == .@"struct" and data_info.@"struct".is_tuple) { + const tuple_len = data_info.@"struct".fields.len; + if (tuple_len != arr_info.len) { + @compileError(std.fmt.comptimePrint( + "Array size mismatch: expected {d} elements, got {d}", + .{ arr_info.len, tuple_len }, + )); + } + var array: [arr_info.len]arr_info.child = undefined; + inline for (0..arr_info.len) |i| { + array[i] = coerceValue(arr_info.child, data_value[i]); + } + return array; + } + } + + // Handle tagged union coercion from anonymous struct + // Example: .{ .box = .{ .width = 50, .height = 50 } } -> Shape union + if (field_info == .@"union") { + return coerceToUnion(FieldType, data_value); + } + + // Handle nested struct coercion + if (field_info == .@"struct" and @typeInfo(DataType) == .@"struct") { + return buildStruct(FieldType, data_value); + } + + // Direct assignment for compatible types + return data_value; +} + +/// Coerce a comptime value to a tagged union type. +/// Supports: +/// - Single-field anonymous struct: .{ .box = .{ .width = 50 } } -> Union.box +/// - Enum literal for void payloads: .idle -> State.idle +/// - Payload-matching struct: .{ .width = 50, .height = 50 } -> Container.explicit (if fields match) +fn coerceToUnion(comptime UnionType: type, comptime data_value: anytype) UnionType { + const DataType = @TypeOf(data_value); + const data_info = @typeInfo(DataType); + const union_info = @typeInfo(UnionType).@"union"; + + // Case 1: Enum literal for void payload variants + // Example: .idle -> State.idle (where State = union(enum) { idle, running, ... }) + if (data_info == .enum_literal) { + const tag_name = @tagName(data_value); + + inline for (union_info.fields) |union_field| { + if (comptime std.mem.eql(u8, union_field.name, tag_name)) { + // Verify the payload is void + if (union_field.type != void) { + @compileError("Cannot use enum literal for union variant '" ++ tag_name ++ + "' with non-void payload. Use .{ ." ++ tag_name ++ " = ... } syntax instead."); + } + return @unionInit(UnionType, tag_name, {}); + } + } + @compileError("No union variant named '" ++ tag_name ++ "' in " ++ @typeName(UnionType)); + } + + // Case 2: Anonymous struct - could be variant selector or direct payload + if (data_info == .@"struct") { + const data_fields = data_info.@"struct".fields; + + // Case 2a: Single-field struct where field name matches a union variant + // Example: .{ .box = .{ .width = 50, .height = 50 } } -> Shape.box + if (data_fields.len == 1) { + const field_name = data_fields[0].name; + + // Check if this field name matches a union variant + inline for (union_info.fields) |union_field| { + if (comptime std.mem.eql(u8, union_field.name, field_name)) { + // This is the variant selector pattern + const variant_value = @field(data_value, field_name); + const coerced_payload = coerceValue(union_field.type, variant_value); + return @unionInit(UnionType, field_name, coerced_payload); + } + } + } + + // Case 2b: Multi-field struct that matches a union variant's payload type + // Example: .{ .width = 400, .height = 300 } -> Container.explicit + inline for (union_info.fields) |union_field| { + const payload_info = @typeInfo(union_field.type); + if (payload_info == .@"struct") { + // Check if data fields are compatible with this payload struct + if (comptime structFieldsCompatible(DataType, union_field.type)) { + const coerced_payload = buildStruct(union_field.type, data_value); + return @unionInit(UnionType, union_field.name, coerced_payload); + } + } + } + + @compileError("Cannot coerce struct to union type " ++ @typeName(UnionType) ++ + ". Use .{ .variant_name = payload } syntax or ensure struct fields match a variant's payload."); + } + + // Case 3: Direct assignment if types match + if (DataType == UnionType) { + return data_value; + } + + @compileError("Cannot coerce " ++ @typeName(DataType) ++ " to union type " ++ @typeName(UnionType) ++ + ". Use .{ .variant_name = payload } or .variant_name (for void payloads)."); +} + +/// Check if all fields in DataType exist in TargetType (for struct compatibility) +fn structFieldsCompatible(comptime DataType: type, comptime TargetType: type) bool { + const data_fields = @typeInfo(DataType).@"struct".fields; + const target_fields = @typeInfo(TargetType).@"struct".fields; + + // All data fields must exist in target + for (data_fields) |df| { + var found = false; + for (target_fields) |tf| { + if (std.mem.eql(u8, df.name, tf.name)) { + found = true; + break; + } + } + if (!found) return false; + } + + return data_fields.len > 0; +} + +/// Build a struct from comptime anonymous struct data. +/// Recursively coerces nested fields. +/// Raises compile error for missing required fields (fields without defaults). +pub fn buildStruct(comptime StructType: type, comptime data: anytype) StructType { + return buildStructWithContext(StructType, data, "struct"); +} + +/// Build a struct with a custom context string for error messages. +fn buildStructWithContext(comptime StructType: type, comptime data: anytype, comptime context: []const u8) StructType { + const fields = std.meta.fields(StructType); + var result: StructType = undefined; + + inline for (fields) |field| { + if (@hasField(@TypeOf(data), field.name)) { + const data_value = @field(data, field.name); + @field(result, field.name) = coerceValue(field.type, data_value); + } else if (field.default_value_ptr) |ptr| { + const default_ptr: *const field.type = @ptrCast(@alignCast(ptr)); + @field(result, field.name) = default_ptr.*; + } else { + @compileError("Missing required field '" ++ field.name ++ "' for " ++ context ++ " '" ++ @typeName(StructType) ++ "'"); + } + } + + return result; +} + +/// Convert a tuple to a slice at comptime. +/// Recursively coerces each element. +pub fn tupleToSlice(comptime ChildType: type, comptime tuple: anytype) []const ChildType { + const tuple_info = @typeInfo(@TypeOf(tuple)).@"struct"; + const len = tuple_info.fields.len; + + const array = comptime blk: { + var arr: [len]ChildType = undefined; + for (0..len) |i| { + arr[i] = coerceValue(ChildType, tuple[i]); + } + break :blk arr; + }; + + return &array; +} + +/// Merge two comptime structs, with overrides taking precedence. +/// Returns a new anonymous struct with all fields from base, plus any +/// fields from overrides (which override base values). +/// +/// Example: +/// base = .{ .x = 10, .y = 20, .color = .red } +/// overrides = .{ .color = .blue } +/// result = .{ .x = 10, .y = 20, .color = .blue } +pub fn mergeStructs(comptime base: anytype, comptime overrides: anytype) MergedStructType(@TypeOf(base), @TypeOf(overrides)) { + const BaseType = @TypeOf(base); + const OverridesType = @TypeOf(overrides); + + var result: MergedStructType(BaseType, OverridesType) = undefined; + + // Copy all fields from base + inline for (std.meta.fields(BaseType)) |field| { + if (@hasField(OverridesType, field.name)) { + @field(result, field.name) = @field(overrides, field.name); + } else { + @field(result, field.name) = @field(base, field.name); + } + } + + // Add fields that exist only in overrides (not in base) + inline for (std.meta.fields(OverridesType)) |field| { + if (!@hasField(BaseType, field.name)) { + @field(result, field.name) = @field(overrides, field.name); + } + } + + return result; +} + +/// Compute the merged struct type from two struct types. +fn MergedStructType(comptime BaseType: type, comptime OverridesType: type) type { + const base_fields = std.meta.fields(BaseType); + const override_fields = std.meta.fields(OverridesType); + + comptime var field_count = base_fields.len; + inline for (override_fields) |of| { + if (!@hasField(BaseType, of.name)) { + field_count += 1; + } + } + + comptime var fields: [field_count]std.builtin.Type.StructField = undefined; + comptime var i = 0; + + inline for (base_fields) |bf| { + if (@hasField(OverridesType, bf.name)) { + inline for (override_fields) |of| { + if (comptime std.mem.eql(u8, of.name, bf.name)) { + fields[i] = .{ + .name = bf.name, + .type = of.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(of.type), + }; + } + } + } else { + fields[i] = .{ + .name = bf.name, + .type = bf.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(bf.type), + }; + } + i += 1; + } + + inline for (override_fields) |of| { + if (!@hasField(BaseType, of.name)) { + fields[i] = .{ + .name = of.name, + .type = of.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(of.type), + }; + i += 1; + } + } + + return @Type(.{ + .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + }, + }); +} + +/// Check if a struct type has any fields +pub fn hasFields(comptime T: type) bool { + return std.meta.fields(T).len > 0; +} + +// Tests +test "coerceValue: simple struct with all fields" { + const Target = struct { x: i32, y: i32 }; + const result = coerceValue(Target, .{ .x = 10, .y = 20 }); + try std.testing.expectEqual(@as(i32, 10), result.x); + try std.testing.expectEqual(@as(i32, 20), result.y); +} + +test "coerceValue: nested struct" { + const Inner = struct { value: i32 }; + const Outer = struct { inner: Inner, name: []const u8 }; + const result = coerceValue(Outer, .{ .inner = .{ .value = 42 }, .name = "test" }); + try std.testing.expectEqual(@as(i32, 42), result.inner.value); +} + +test "coerceValue: union with enum literal" { + const State = union(enum) { idle, running: u32 }; + const result = coerceValue(State, .idle); + try std.testing.expect(result == .idle); +} + +test "coerceValue: union with payload" { + const Shape = union(enum) { circle: struct { radius: f32 }, rect: struct { w: f32, h: f32 } }; + const result = coerceValue(Shape, .{ .circle = .{ .radius = 5.0 } }); + try std.testing.expectEqual(@as(f32, 5.0), result.circle.radius); +} + +test "tupleToSlice: converts tuple to slice" { + const slice = tupleToSlice(i32, .{ 1, 2, 3 }); + try std.testing.expectEqual(@as(usize, 3), slice.len); + try std.testing.expectEqual(@as(i32, 1), slice[0]); + try std.testing.expectEqual(@as(i32, 2), slice[1]); + try std.testing.expectEqual(@as(i32, 3), slice[2]); +} + +test "buildStruct: with defaults" { + const Config = struct { width: i32 = 800, height: i32 = 600, title: []const u8 }; + const result = buildStruct(Config, .{ .title = "Test" }); + try std.testing.expectEqual(@as(i32, 800), result.width); + try std.testing.expectEqual(@as(i32, 600), result.height); +} + +test "mergeStructs: override values" { + const base = .{ .x = 10, .y = 20, .color = "red" }; + const overrides = .{ .color = "blue" }; + const result = mergeStructs(base, overrides); + try std.testing.expectEqual(@as(i32, 10), result.x); + try std.testing.expectEqualStrings("blue", result.color); +} From c1d58270b70363cd3e3b7c31c7574c4f323baddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Thu, 8 Jan 2026 15:54:51 -0300 Subject: [PATCH 03/13] docs: Update CLAUDE.md with new utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude.md | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/claude.md b/claude.md index 1d4549d..0626757 100644 --- a/claude.md +++ b/claude.md @@ -6,11 +6,14 @@ Standalone math utilities library for Zig. Part of the labelle-toolkit. ``` src/ - root.zig - Main module exports - vector.zig - Position and PositionI types - quad_tree.zig - QuadTree spatial partitioning (generic, Position-based) - sweep_and_prune.zig - Sweep and Prune collision detection - sparse_set.zig - SparseSet O(1) key-value mapping + root.zig - Main module exports + vector.zig - Position and PositionI types + quad_tree.zig - QuadTree spatial partitioning (generic, Position-based) + sweep_and_prune.zig - Sweep and Prune collision detection + sparse_set.zig - SparseSet O(1) key-value mapping + z_index_buckets.zig - ZIndexBuckets sorted storage by u8 key + zon_coercion.zig - Comptime ZON to struct conversion + hook_dispatcher.zig - Zero-overhead comptime event dispatcher tests/ root.zig - Test entry point vector_test.zig - Position/PositionI tests @@ -25,6 +28,8 @@ tests/ - `QuadTree(T)` - Generic spatial index with Position-based queries - `SweepAndPrune(T)` - Generic broad-phase collision detection - `SparseSet(T)` - O(1) key-value mapping with cache-friendly iteration +- `ZIndexBuckets(T)` - Bucket-sorted storage by u8 key (256 buckets) +- `HookDispatcher` - Zero-overhead comptime event dispatcher - `Rectangle` - AABB for bounds and collision - `EntityPoint(T)` - Point with generic ID for QuadTree - `AABB` - Axis-aligned bounding box for SweepAndPrune @@ -75,6 +80,27 @@ zig build test **Trade-off:** ~40KB memory for 10k max keys vs variable for HashMap +### ZIndexBuckets +- 256 buckets (one per u8 z-index level) +- O(1) insert, O(bucket_size) remove +- O(256 + n) ordered iteration +- Generic over item type T +- Optional custom equality via `eql` method + +### ZON Coercion +- Comptime conversion of anonymous structs to typed structs +- Handles nested structs, optionals, slices, arrays +- Tagged union coercion from enum literals or structs +- Struct merging with override semantics +- Functions: `coerceValue`, `buildStruct`, `tupleToSlice`, `mergeStructs` + +### HookDispatcher +- Zero-overhead comptime event dispatch +- Handlers resolved entirely at compile time +- No runtime overhead for missing handlers +- `MergeHooks` for composing multiple handler structs +- `EmptyDispatcher` for default no-op dispatching + ## Related - labelle-pathfinding - Uses QuadTree for spatial queries From 9bcb6ba6b1616e2d5a987d0f616525b3dc18d4a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Thu, 8 Jan 2026 16:05:16 -0300 Subject: [PATCH 04/13] fix(SparseSet): Handle partial realloc failure in put() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/sparse_set.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sparse_set.zig b/src/sparse_set.zig index 3d1b733..df7358b 100644 --- a/src/sparse_set.zig +++ b/src/sparse_set.zig @@ -60,7 +60,12 @@ pub fn SparseSet(comptime T: type) type { if (self.count >= self.capacity) { 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); + // If dense_values realloc fails, shrink dense_keys back to maintain consistency + self.dense_values = self.allocator.realloc(self.dense_values, new_cap) catch |err| { + // Shrink dense_keys back (shrinking shouldn't fail) + self.dense_keys = self.allocator.realloc(self.dense_keys, self.capacity) catch self.dense_keys; + return err; + }; self.capacity = new_cap; } From 9e817ec02ef53097b894510fedaba1a7a9f5834e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Thu, 8 Jan 2026 16:15:16 -0300 Subject: [PATCH 05/13] feat: Add graph algorithms (Floyd-Warshall, A*, heuristics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/a_star.zig | 452 +++++++++++++++++++++++ src/floyd_warshall.zig | 253 +++++++++++++ src/floyd_warshall_optimized.zig | 609 +++++++++++++++++++++++++++++++ src/heuristics.zig | 138 +++++++ src/root.zig | 16 + 5 files changed, 1468 insertions(+) create mode 100644 src/a_star.zig create mode 100644 src/floyd_warshall.zig create mode 100644 src/floyd_warshall_optimized.zig create mode 100644 src/heuristics.zig diff --git a/src/a_star.zig b/src/a_star.zig new file mode 100644 index 0000000..5d3146f --- /dev/null +++ b/src/a_star.zig @@ -0,0 +1,452 @@ +//! A* (A-Star) Pathfinding Algorithm +//! +//! A best-first search algorithm that finds the shortest path between a source +//! and destination node. Uses heuristics to guide the search, making it more +//! efficient than Dijkstra's algorithm for single-source pathfinding. +//! +//! ## Features +//! - Single-source shortest path (efficient for point-to-point queries) +//! - Multiple built-in heuristics (Euclidean, Manhattan, Chebyshev, Octile) +//! - Custom heuristic support +//! - Entity ID mapping for integration with external systems +//! - Adjacency list representation (memory efficient for sparse graphs) +//! +//! ## When to use A* vs Floyd-Warshall +//! - **A***: Best for single-source queries, large sparse graphs, real-time games +//! - **Floyd-Warshall**: Best when you need all-pairs paths, dense graphs, or +//! when paths are queried repeatedly between many node pairs + +const std = @import("std"); +const heuristics_mod = @import("heuristics.zig"); + +pub const Heuristic = heuristics_mod.Heuristic; +pub const HeuristicFn = heuristics_mod.HeuristicFn; +pub const Position = heuristics_mod.Position; + +const INF: u64 = std.math.maxInt(u64); + +/// A* pathfinding algorithm with configurable heuristics. +/// Supports both direct vertex indices and entity ID mapping. +pub const AStar = struct { + const Edge = struct { + to: u32, + weight: u64, + }; + const EdgeList = std.ArrayListUnmanaged(Edge); + const AdjacencyList = std.ArrayListUnmanaged(EdgeList); + + /// Priority queue node for A* open set + const PQNode = struct { + vertex: u32, + f_score: f32, + + fn compare(_: void, a: PQNode, b: PQNode) std.math.Order { + return std.math.order(a.f_score, b.f_score); + } + }; + + allocator: std.mem.Allocator, + adjacency: AdjacencyList, + positions: std.AutoHashMap(u32, Position), + ids: std.AutoHashMap(u32, u32), + reverse_ids: std.AutoHashMap(u32, u32), + last_key: u32 = 0, + size: u32 = 100, + heuristic_type: Heuristic, + custom_heuristic: ?HeuristicFn, + + pub fn init(allocator: std.mem.Allocator) AStar { + return .{ + .allocator = allocator, + .adjacency = .empty, + .positions = std.AutoHashMap(u32, Position).init(allocator), + .ids = std.AutoHashMap(u32, u32).init(allocator), + .reverse_ids = std.AutoHashMap(u32, u32).init(allocator), + .heuristic_type = .euclidean, + .custom_heuristic = null, + }; + } + + pub fn deinit(self: *AStar) void { + for (self.adjacency.items) |*edges| { + edges.deinit(self.allocator); + } + self.adjacency.deinit(self.allocator); + self.positions.deinit(); + self.ids.deinit(); + self.reverse_ids.deinit(); + } + + /// Set the heuristic type to use for pathfinding + pub fn setHeuristic(self: *AStar, heuristic_type: Heuristic) void { + self.heuristic_type = heuristic_type; + self.custom_heuristic = null; + } + + /// Set a custom heuristic function + pub fn setCustomHeuristic(self: *AStar, heuristic_fn: HeuristicFn) void { + self.custom_heuristic = heuristic_fn; + } + + /// Set the position of a node (used for heuristic calculation) + pub fn setNodePosition(self: *AStar, node: u32, pos: Position) !void { + try self.positions.put(node, pos); + } + + /// Set node position using entity ID mapping + pub fn setNodePositionWithMapping(self: *AStar, entity: u32, pos: Position) !void { + const internal_id = self.getOrCreateMapping(entity); + try self.positions.put(internal_id, pos); + } + + /// Generate a new internal key for entity mapping + fn newKey(self: *AStar) u32 { + self.last_key += 1; + return self.last_key - 1; + } + + /// Get or create an internal ID mapping for an entity + fn getOrCreateMapping(self: *AStar, entity: u32) u32 { + if (self.ids.get(entity)) |id| { + return id; + } + const new_id = self.newKey(); + self.ids.put(entity, new_id) catch |err| { + std.log.err("Error inserting entity mapping: {any}\n", .{err}); + return std.math.maxInt(u32); + }; + self.reverse_ids.put(new_id, entity) catch |err| { + std.log.err("Error inserting reverse mapping: {any}\n", .{err}); + return std.math.maxInt(u32); + }; + return new_id; + } + + /// Resize the graph to support a given number of vertices + pub fn resize(self: *AStar, size: u32) void { + self.size = size; + } + + /// Reset the graph and prepare for new data + pub fn clean(self: *AStar) !void { + self.last_key = 0; + + for (self.adjacency.items) |*edges| { + edges.deinit(self.allocator); + } + self.adjacency.clearRetainingCapacity(); + self.positions.clearRetainingCapacity(); + self.ids.clearRetainingCapacity(); + self.reverse_ids.clearRetainingCapacity(); + + // Initialize adjacency lists for each vertex + try self.adjacency.ensureTotalCapacity(self.allocator, self.size); + for (0..self.size) |_| { + try self.adjacency.append(self.allocator, .empty); + } + } + + /// Add an edge between two vertices with given weight (direct index) + pub fn addEdge(self: *AStar, u: u32, v: u32, w: u64) 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}); + }; + } + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + pub fn addEdgeWithMapping(self: *AStar, u: u32, v: u32, w: u64) void { + const u_internal = self.getOrCreateMapping(u); + const v_internal = self.getOrCreateMapping(v); + self.addEdge(u_internal, v_internal, w); + } + + /// Calculate heuristic between two internal vertex indices + fn calculateHeuristic(self: *AStar, from: u32, to: u32) f32 { + const from_pos = self.positions.get(from) orelse Position{ .x = 0, .y = 0 }; + const to_pos = self.positions.get(to) orelse Position{ .x = 0, .y = 0 }; + + if (self.custom_heuristic) |custom| { + return custom(from_pos, to_pos); + } + return heuristics_mod.calculate(self.heuristic_type, from_pos, to_pos); + } + + /// Run A* algorithm to find shortest path from source to destination. + /// Returns the path cost, or null if no path exists. + /// The path is stored in the provided ArrayList. + pub fn findPath( + self: *AStar, + source: u32, + dest: u32, + path: *std.array_list.Managed(u32), + ) !?u64 { + if (source >= self.adjacency.items.len or dest >= self.adjacency.items.len) { + return null; + } + + path.clearRetainingCapacity(); + + if (source == dest) { + try path.append(source); + return 0; + } + + var g_score = std.AutoHashMap(u32, u64).init(self.allocator); + defer g_score.deinit(); + + var came_from = std.AutoHashMap(u32, u32).init(self.allocator); + defer came_from.deinit(); + + var closed_set = std.AutoHashMap(u32, void).init(self.allocator); + defer closed_set.deinit(); + + var open_set = std.PriorityQueue(PQNode, void, PQNode.compare).init(self.allocator, {}); + defer open_set.deinit(); + + // Initialize source + try g_score.put(source, 0); + const h = self.calculateHeuristic(source, dest); + try open_set.add(.{ .vertex = source, .f_score = h }); + + while (open_set.removeOrNull()) |current| { + if (current.vertex == dest) { + // Reconstruct path + var node = dest; + while (true) { + try path.append(node); + if (came_from.get(node)) |prev| { + node = prev; + } else { + break; + } + } + // Reverse to get source -> dest order + std.mem.reverse(u32, path.items); + return g_score.get(dest); + } + + if (closed_set.contains(current.vertex)) { + continue; + } + try closed_set.put(current.vertex, {}); + + const current_g = g_score.get(current.vertex) orelse INF; + + // Explore neighbors + for (self.adjacency.items[current.vertex].items) |edge| { + if (closed_set.contains(edge.to)) { + continue; + } + + const tentative_g = current_g + edge.weight; + const neighbor_g = g_score.get(edge.to) orelse INF; + + if (tentative_g < neighbor_g) { + try came_from.put(edge.to, current.vertex); + try g_score.put(edge.to, tentative_g); + + const f = @as(f32, @floatFromInt(tentative_g)) + self.calculateHeuristic(edge.to, dest); + try open_set.add(.{ .vertex = edge.to, .f_score = f }); + } + } + } + + return null; // No path found + } + + /// Find path using entity ID mapping + pub fn findPathWithMapping( + self: *AStar, + source_entity: u32, + dest_entity: u32, + path: *std.array_list.Managed(u32), + ) !?u64 { + const source = self.ids.get(source_entity) orelse return null; + const dest = self.ids.get(dest_entity) orelse return null; + + var internal_path = std.array_list.Managed(u32).init(self.allocator); + defer internal_path.deinit(); + + const cost = try self.findPath(source, dest, &internal_path); + + if (cost != null) { + path.clearRetainingCapacity(); + for (internal_path.items) |internal_id| { + const entity = self.reverse_ids.get(internal_id) orelse continue; + try path.append(entity); + } + } + + return cost; + } + + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *AStar, u: usize, v: usize) bool { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return false; + return result != null; + } + + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *AStar, u: u32, v: u32) bool { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPathWithMapping(u, v, &path) catch return false; + return result != null; + } + + /// Get the distance between two vertices (direct index) + /// Note: This runs A* each time - cache results if needed frequently + pub fn value(self: *AStar, u: usize, v: usize) u64 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return INF; + return result orelse INF; + } + + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *AStar, u: u32, v: u32) u64 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPathWithMapping(u, v, &path) catch return INF; + return result orelse INF; + } + + /// Build the path from u to v and store in the provided ArrayList + pub fn setPathWithMapping(self: *AStar, path_list: *std.array_list.Managed(u32), u: u32, v: u32) !void { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = try self.findPathWithMapping(u, v, &path); + if (result == null) { + std.log.err("No path found from {} to {}\n", .{ u, v }); + return; + } + + path_list.clearRetainingCapacity(); + for (path.items) |node| { + try path_list.append(node); + } + } + + /// Get the next entity in the shortest path from u to v (using ID mapping) + pub fn nextWithMapping(self: *AStar, u: u32, v: u32) u32 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPathWithMapping(u, v, &path) catch return std.math.maxInt(u32); + if (result == null or path.items.len < 2) { + return std.math.maxInt(u32); + } + return path.items[1]; // Second element is next step + } + + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn next(self: *AStar, u: usize, v: usize) u32 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return std.math.maxInt(u32); + if (result == null or path.items.len < 2) { + return std.math.maxInt(u32); + } + return path.items[1]; + } + + /// No-op for A* (paths computed on-demand) + pub fn generate(self: *AStar) void { + _ = self; + // A* computes paths on-demand, no pre-computation needed + } +}; + +// Tests +test "AStar basic pathfinding" { + const allocator = std.testing.allocator; + + var astar = AStar.init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + + // Set positions for heuristic + try astar.setNodePosition(0, .{ .x = 0, .y = 0 }); + try astar.setNodePosition(1, .{ .x = 1, .y = 0 }); + try astar.setNodePosition(2, .{ .x = 2, .y = 0 }); + try astar.setNodePosition(3, .{ .x = 3, .y = 0 }); + + // Create graph: 0 -> 1 -> 2 -> 3 + astar.addEdge(0, 1, 1); + astar.addEdge(1, 2, 1); + astar.addEdge(2, 3, 1); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try std.testing.expectEqual(@as(?u64, 3), cost); + try std.testing.expectEqual(@as(usize, 4), path.items.len); + try std.testing.expectEqual(@as(u32, 0), path.items[0]); + try std.testing.expectEqual(@as(u32, 1), path.items[1]); + try std.testing.expectEqual(@as(u32, 2), path.items[2]); + try std.testing.expectEqual(@as(u32, 3), path.items[3]); +} + +test "AStar weighted shortest path" { + const allocator = std.testing.allocator; + + var astar = AStar.init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + astar.setHeuristic(.zero); // Use Dijkstra for testing weighted paths + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + astar.addEdge(0, 1, 5); + astar.addEdge(1, 3, 3); + astar.addEdge(0, 2, 2); + astar.addEdge(2, 3, 2); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try std.testing.expectEqual(@as(?u64, 4), cost); + try std.testing.expectEqual(@as(usize, 3), path.items.len); + try std.testing.expectEqual(@as(u32, 0), path.items[0]); + try std.testing.expectEqual(@as(u32, 2), path.items[1]); + try std.testing.expectEqual(@as(u32, 3), path.items[2]); +} + +test "AStar no path" { + const allocator = std.testing.allocator; + + var astar = AStar.init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + + // Disconnected graph: 0 -> 1, 2 -> 3 (no path from 0 to 3) + astar.addEdge(0, 1, 1); + astar.addEdge(2, 3, 1); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try std.testing.expectEqual(@as(?u64, null), cost); +} diff --git a/src/floyd_warshall.zig b/src/floyd_warshall.zig new file mode 100644 index 0000000..71286a8 --- /dev/null +++ b/src/floyd_warshall.zig @@ -0,0 +1,253 @@ +//! Floyd-Warshall Algorithm Implementation +//! +//! Computes shortest paths between all pairs of vertices in a weighted graph. +//! Uses dynamic programming to find optimal paths and supports entity ID mapping. +//! +//! Complexity: O(V³) time, O(V²) space +//! +//! Best for: +//! - Dense graphs with many all-pairs queries +//! - Graphs that change infrequently +//! - Pre-computing all shortest paths + +const std = @import("std"); + +const INF = std.math.maxInt(u32); + +/// Floyd-Warshall all-pairs shortest path algorithm. +/// Supports both direct vertex indices and entity ID mapping. +pub const FloydWarshall = struct { + const RowList = std.array_list.Managed(u64); + const GraphList = std.array_list.Managed(RowList); + + size: u32 = 100, + graph: GraphList, + path: GraphList, + ids: std.AutoHashMap(u32, u32), + last_key: u32 = 0, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) FloydWarshall { + return .{ + .graph = GraphList.init(allocator), + .path = GraphList.init(allocator), + .ids = std.AutoHashMap(u32, u32).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *FloydWarshall) void { + for (self.graph.items) |*row| { + row.deinit(); + } + for (self.path.items) |*row| { + row.deinit(); + } + self.graph.deinit(); + self.path.deinit(); + self.ids.deinit(); + } + + /// Generate a new internal key for entity mapping + pub fn newKey(self: *FloydWarshall) u32 { + self.last_key += 1; + return self.last_key - 1; + } + + /// Add an edge between two vertices with given weight (direct index) + pub fn addEdge(self: *FloydWarshall, u: u32, v: u32, w: u64) void { + self.graph.items[u].items[v] = w; + } + + /// Get the distance between two vertices (direct index) + pub fn value(self: *FloydWarshall, u: usize, v: usize) u64 { + return self.graph.items[u].items[v]; + } + + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *FloydWarshall, u: usize, v: usize) bool { + return self.graph.items[u].items[v] != INF; + } + + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn next(self: *FloydWarshall, u: usize, v: usize) u32 { + return @intCast(self.path.items[u].items[v]); + } + + /// Resize the graph to support a given number of vertices + pub fn resize(self: *FloydWarshall, size: u32) void { + self.size = size; + } + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + pub fn addEdgeWithMapping(self: *FloydWarshall, u: u32, v: u32, w: u64) void { + if (!self.ids.contains(u)) { + self.ids.put(u, self.newKey()) catch |err| { + std.log.err("Error inserting on map: {}\n", .{err}); + }; + } + if (!self.ids.contains(v)) { + self.ids.put(v, self.newKey()) catch |err| { + std.log.err("Error inserting on map: {}\n", .{err}); + }; + } + self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); + } + + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *FloydWarshall, u: u32, v: u32) u64 { + return self.value(self.ids.get(u).?, self.ids.get(v).?); + } + + /// Build the path from u to v and store in the provided ArrayList + pub fn setPathWithMapping(self: *FloydWarshall, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) !void { + var current = u_node; + while (current != v_node) { + try path_list.append(current); + current = self.nextWithMapping(current, v_node); + if (current == INF) { + std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); + return; + } + } + try path_list.append(v_node); + } + + /// Build the path from u to v and store in the provided unmanaged ArrayList + pub fn setPathWithMappingUnmanaged(self: *FloydWarshall, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) !void { + var current = u_node; + while (current != v_node) { + try path_list.append(allocator, current); + current = self.nextWithMapping(current, v_node); + if (current == INF) { + std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); + return; + } + } + try path_list.append(allocator, v_node); + } + + /// Get the next entity in the shortest path from u to v (using ID mapping) + pub fn nextWithMapping(self: *FloydWarshall, 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 INF; + } + + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *FloydWarshall, u: u32, v: u32) bool { + if (self.ids.get(u) == null or self.ids.get(v) == null) { + return false; + } + return self.hasPath(self.ids.get(u).?, self.ids.get(v).?); + } + + /// Reset the graph and prepare for new data + pub fn clean(self: *FloydWarshall) !void { + self.last_key = 0; + for (self.graph.items) |*row| { + row.deinit(); + } + for (self.path.items) |*row| { + row.deinit(); + } + self.graph.clearRetainingCapacity(); + self.path.clearRetainingCapacity(); + self.ids.clearRetainingCapacity(); + + // Initialize adjacency matrix and path matrix + for (0..self.size) |_| { + var list = RowList.init(self.allocator); + var row_path = RowList.init(self.allocator); + for (0..self.size) |_| { + try list.append(0); + try row_path.append(0); + } + try self.graph.append(list); + try self.path.append(row_path); + } + + // Set initial values: 0 for self-loops, INF for no edge + for (0..self.size) |i| { + for (0..self.size) |j| { + self.path.items[i].items[j] = j; + if (i == j) { + self.graph.items[i].items[j] = 0; + } else { + self.graph.items[i].items[j] = INF; + } + } + } + } + + /// Run the Floyd-Warshall algorithm to compute all shortest paths + pub fn generate(self: *FloydWarshall) void { + for (0..self.size) |k| { + for (0..self.size) |i| { + for (0..self.size) |j| { + if (self.graph.items[i].items[k] + self.graph.items[k].items[j] < self.graph.items[i].items[j]) { + self.graph.items[i].items[j] = self.graph.items[i].items[k] + self.graph.items[k].items[j]; + self.path.items[i].items[j] = self.path.items[i].items[k]; + } + } + } + } + } +}; + +// Tests +test "FloydWarshall basic functionality" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Create graph: 0 -> 1 -> 2 -> 3 + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + // Check distances + try std.testing.expectEqual(@as(u64, 0), fw.value(0, 0)); + try std.testing.expectEqual(@as(u64, 1), fw.value(0, 1)); + try std.testing.expectEqual(@as(u64, 2), fw.value(0, 2)); + try std.testing.expectEqual(@as(u64, 3), fw.value(0, 3)); + + // Check next hops + try std.testing.expectEqual(@as(u32, 1), fw.next(0, 3)); + try std.testing.expectEqual(@as(u32, 2), fw.next(1, 3)); +} + +test "FloydWarshall weighted shortest path" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + fw.addEdge(0, 1, 5); + fw.addEdge(1, 3, 3); + fw.addEdge(0, 2, 2); + fw.addEdge(2, 3, 2); + + fw.generate(); + + // Should find shortest path + try std.testing.expectEqual(@as(u64, 4), fw.value(0, 3)); + try std.testing.expectEqual(@as(u32, 2), fw.next(0, 3)); // Goes through node 2 +} diff --git a/src/floyd_warshall_optimized.zig b/src/floyd_warshall_optimized.zig new file mode 100644 index 0000000..1f743cd --- /dev/null +++ b/src/floyd_warshall_optimized.zig @@ -0,0 +1,609 @@ +//! Optimized Floyd-Warshall Algorithm Implementation +//! +//! High-performance implementation with: +//! - Flat memory layout for cache efficiency +//! - SIMD vectorization for inner loop operations +//! - Multi-threaded parallelization across rows +//! +//! For graphs that change infrequently but require many arbitrary +//! source-destination queries. + +const std = @import("std"); + +const INF: u32 = std.math.maxInt(u32); + +/// Configuration for the optimized Floyd-Warshall algorithm +pub const Config = struct { + /// Enable multi-threaded parallelization (recommended for graphs with 256+ nodes) + parallel: bool = true, + /// Enable SIMD vectorization + simd: bool = true, +}; + +/// Optimized Floyd-Warshall all-pairs shortest path algorithm. +/// Uses flat memory layout, SIMD, and multi-threading for performance. +pub fn FloydWarshallOptimized(comptime config: Config) type { + return struct { + const Self = @This(); + + // SIMD vector width (4 x u32 = 128 bits, widely supported) + const VectorWidth = 4; + const DistVector = @Vector(VectorWidth, u32); + const IndexVector = @Vector(VectorWidth, u32); + + size: u32 = 0, + capacity: u32 = 0, + /// Flat distance matrix (size x size), row-major order + dist: []u32, + /// Flat next-hop matrix (size x size), row-major order + next: []u32, + /// Entity ID to internal index mapping + ids: std.AutoHashMap(u32, u32), + /// Reverse mapping: internal index to entity ID. + /// Provides O(1) reverse lookups, significantly faster than the legacy + /// implementation which required O(n) iteration for each reverse lookup. + reverse_ids: std.AutoHashMap(u32, u32), + last_key: u32 = 0, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .dist = &.{}, + .next = &.{}, + .ids = std.AutoHashMap(u32, u32).init(allocator), + .reverse_ids = std.AutoHashMap(u32, u32).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Self) void { + if (self.dist.len > 0) { + self.allocator.free(self.dist); + } + if (self.next.len > 0) { + self.allocator.free(self.next); + } + self.ids.deinit(); + self.reverse_ids.deinit(); + } + + /// Generate a new internal key for entity mapping + pub fn newKey(self: *Self) u32 { + self.last_key += 1; + return self.last_key - 1; + } + + /// Get flat array index for (row, col) + inline fn index(self: *const Self, row: u32, col: u32) usize { + return @as(usize, row) * @as(usize, self.size) + @as(usize, col); + } + + /// Add an edge between two vertices with given weight (direct index) + pub fn addEdge(self: *Self, u: u32, v: u32, w: u32) void { + self.dist[self.index(u, v)] = w; + } + + /// Get the distance between two vertices (direct index) + pub fn value(self: *const Self, u: u32, v: u32) u32 { + return self.dist[self.index(u, v)]; + } + + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *const Self, u: u32, v: u32) bool { + return self.dist[self.index(u, v)] != INF; + } + + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn getNext(self: *const Self, u: u32, v: u32) u32 { + return self.next[self.index(u, v)]; + } + + /// Resize the graph to support a given number of vertices + pub fn resize(self: *Self, new_size: u32) void { + self.size = new_size; + } + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + /// Returns error.OutOfMemory if the internal hash maps fail to allocate + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: u32) !void { + if (!self.ids.contains(u)) { + const key = self.newKey(); + try self.ids.put(u, key); + try self.reverse_ids.put(key, u); + } + if (!self.ids.contains(v)) { + const key = self.newKey(); + try self.ids.put(v, key); + try self.reverse_ids.put(key, v); + } + self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); + } + + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *const Self, u: u32, v: u32) u32 { + const u_idx = self.ids.get(u) orelse return INF; + const v_idx = self.ids.get(v) orelse return INF; + return self.value(u_idx, v_idx); + } + + /// Get the next entity in the shortest path from u to v (using ID mapping) + /// Returns INF if no path exists + pub fn nextWithMapping(self: *const Self, u: u32, v: u32) u32 { + const u_idx = self.ids.get(u) orelse return INF; + const v_idx = self.ids.get(v) orelse return INF; + const next_idx = self.getNext(u_idx, v_idx); + return self.reverse_ids.get(next_idx) orelse INF; + } + + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *const Self, u: u32, v: u32) bool { + const u_idx = self.ids.get(u) orelse return false; + const v_idx = self.ids.get(v) orelse return false; + return self.hasPath(u_idx, v_idx); + } + + pub const PathError = error{ + NoPathFound, + OutOfMemory, + }; + + /// Build the path from u to v and store in the provided ArrayList + /// Returns error.NoPathFound if no path exists between the nodes + pub fn setPathWithMapping(self: *const Self, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) PathError!void { + var current = u_node; + while (current != v_node) { + try path_list.append(current); + current = self.nextWithMapping(current, v_node); + if (current == INF) { + return error.NoPathFound; + } + } + try path_list.append(v_node); + } + + /// Build the path from u to v and store in the provided unmanaged ArrayList + /// Returns error.NoPathFound if no path exists between the nodes + pub fn setPathWithMappingUnmanaged(self: *const Self, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) PathError!void { + var current = u_node; + while (current != v_node) { + try path_list.append(allocator, current); + current = self.nextWithMapping(current, v_node); + if (current == INF) { + return error.NoPathFound; + } + } + try path_list.append(allocator, v_node); + } + + pub const CleanError = error{ + SizeOverflow, + OutOfMemory, + }; + + /// Reset the graph and prepare for new data + pub fn clean(self: *Self) CleanError!void { + self.last_key = 0; + self.ids.clearRetainingCapacity(); + self.reverse_ids.clearRetainingCapacity(); + + // Check for overflow before computing matrix_size + const n: usize = self.size; + const matrix_size = std.math.mul(usize, n, n) catch return error.SizeOverflow; + + // Reallocate if needed + if (self.capacity < self.size) { + if (self.dist.len > 0) { + self.allocator.free(self.dist); + } + if (self.next.len > 0) { + self.allocator.free(self.next); + } + self.dist = try self.allocator.alloc(u32, matrix_size); + self.next = try self.allocator.alloc(u32, matrix_size); + self.capacity = self.size; + } + + // Initialize matrices + const dist_slice = self.dist[0..matrix_size]; + const next_slice = self.next[0..matrix_size]; + + // Set all distances to INF + @memset(dist_slice, INF); + + // Initialize next-hop and diagonal + for (0..self.size) |i| { + for (0..self.size) |j| { + const idx = i * self.size + j; + next_slice[idx] = @intCast(j); + } + // Self-loops have distance 0 + dist_slice[i * self.size + i] = 0; + } + } + + /// Run the Floyd-Warshall algorithm to compute all shortest paths + pub fn generate(self: *Self) void { + if (config.parallel and self.size > 64) { + self.generateParallel(); + } else if (config.simd) { + self.generateSimd(); + } else { + self.generateScalar(); + } + } + + /// Scalar implementation (baseline) + fn generateScalar(self: *Self) void { + const n = self.size; + for (0..n) |k| { + for (0..n) |i| { + const dist_ik = self.dist[i * n + k]; + if (dist_ik == INF) continue; // Optimization: skip if no path to k + + for (0..n) |j| { + const dist_kj = self.dist[k * n + j]; + if (dist_kj == INF) continue; + + const new_dist = dist_ik +| dist_kj; // Saturating add to prevent overflow + const idx = i * n + j; + if (new_dist < self.dist[idx]) { + self.dist[idx] = new_dist; + self.next[idx] = self.next[i * n + k]; + } + } + } + } + } + + /// SIMD-optimized implementation + fn generateSimd(self: *Self) void { + const n = self.size; + const n_usize: usize = n; + + for (0..n) |k| { + for (0..n) |i| { + const dist_ik = self.dist[i * n_usize + k]; + if (dist_ik == INF) continue; + + const next_ik = self.next[i * n_usize + k]; + const dist_ik_vec: DistVector = @splat(dist_ik); + const next_ik_vec: IndexVector = @splat(next_ik); + + const row_i_start = i * n_usize; + const row_k_start = k * n_usize; + + // Process in SIMD chunks + var j: usize = 0; + while (j + VectorWidth <= n_usize) : (j += VectorWidth) { + // Load dist[k][j..j+VectorWidth] + const dist_kj_vec: DistVector = self.dist[row_k_start + j ..][0..VectorWidth].*; + + // Load current dist[i][j..j+VectorWidth] + const dist_ij_ptr = self.dist[row_i_start + j ..][0..VectorWidth]; + const dist_ij_vec: DistVector = dist_ij_ptr.*; + + // Load current next[i][j..j+VectorWidth] + const next_ij_ptr = self.next[row_i_start + j ..][0..VectorWidth]; + const next_ij_vec: IndexVector = next_ij_ptr.*; + + // Calculate new distances (saturating add) + const new_dist_vec = dist_ik_vec +| dist_kj_vec; + + // Compare: new_dist < dist_ij + const mask = new_dist_vec < dist_ij_vec; + + // Select: if mask then new_dist else dist_ij + dist_ij_ptr.* = @select(u32, mask, new_dist_vec, dist_ij_vec); + next_ij_ptr.* = @select(u32, mask, next_ik_vec, next_ij_vec); + } + + // Handle remaining elements + while (j < n_usize) : (j += 1) { + const dist_kj = self.dist[row_k_start + j]; + if (dist_kj == INF) continue; + + const new_dist = dist_ik +| dist_kj; + const idx = row_i_start + j; + if (new_dist < self.dist[idx]) { + self.dist[idx] = new_dist; + self.next[idx] = next_ik; + } + } + } + } + } + + /// Multi-threaded parallel implementation using row decomposition + /// Based on the semaphore-per-row synchronization pattern + fn generateParallel(self: *Self) void { + const n = self.size; + if (n == 0) return; + + // Determine thread count + const cpu_count = std.Thread.getCpuCount() catch 4; + const thread_count: usize = @min(cpu_count, n); + + // Fall back to SIMD for small graphs or single core + if (thread_count <= 1 or n < 32) { + self.generateSimd(); + return; + } + + // Allocate synchronization counters (one per k value + 1) + // Each counter tracks how many threads have signaled that row k is ready + const sync_counters = self.allocator.alloc(std.atomic.Value(u32), n + 1) catch { + self.generateSimd(); + return; + }; + defer self.allocator.free(sync_counters); + + // Initialize: first counter allows all threads to start, rest are 0 + sync_counters[0] = std.atomic.Value(u32).init(@intCast(thread_count)); + for (1..n + 1) |i| { + sync_counters[i] = std.atomic.Value(u32).init(0); + } + + // Calculate row distribution + const rows_per_thread = n / thread_count; + const extra_rows = n % thread_count; + + // Spawn worker threads + const threads = self.allocator.alloc(std.Thread, thread_count - 1) catch { + self.generateSimd(); + return; + }; + defer self.allocator.free(threads); + + var next_row: usize = 0; + for (0..thread_count - 1) |t| { + const start = next_row; + var end = start + rows_per_thread; + if (t < extra_rows) end += 1; + next_row = end; + + threads[t] = std.Thread.spawn(.{}, parallelWorker, .{ + self, + start, + end, + thread_count, + sync_counters, + }) catch { + // If thread spawn fails, fall back to SIMD + self.generateSimd(); + return; + }; + } + + // Main thread processes its portion + const main_start = next_row; + const main_end = n; + self.parallelWorkerImpl(main_start, main_end, thread_count, sync_counters); + + // Join all threads + for (threads) |t| { + t.join(); + } + } + + /// Worker function for parallel threads + fn parallelWorker( + self: *Self, + start_row: usize, + end_row: usize, + thread_count: usize, + sync_counters: []std.atomic.Value(u32), + ) void { + self.parallelWorkerImpl(start_row, end_row, thread_count, sync_counters); + } + + /// Implementation of parallel worker logic + fn parallelWorkerImpl( + self: *Self, + start_row: usize, + end_row: usize, + thread_count: usize, + sync_counters: []std.atomic.Value(u32), + ) void { + const n = self.size; + const thread_count_u32: u32 = @intCast(thread_count); + + for (0..n) |k| { + // Wait until row k is ready (counter reaches thread_count) + // Use a spin loop with exponential backoff + var spins: u32 = 0; + while (sync_counters[k].load(.acquire) < thread_count_u32) { + spins += 1; + if (spins < 100) { + std.atomic.spinLoopHint(); + } else { + // Yield to OS scheduler after spinning + std.Thread.yield() catch {}; + spins = 0; + } + } + + // Process our assigned rows for this k iteration (with SIMD) + for (start_row..end_row) |i| { + self.processRowSimd(k, i); + } + + // If we own row k, signal that row k+1 is ready + // 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); + } + } + } + + /// Process a single row with SIMD (for parallel use) + fn processRowSimd(self: *Self, k: usize, i: usize) void { + const n = self.size; + const n_usize: usize = n; + + const dist_ik = self.dist[i * n_usize + k]; + if (dist_ik == INF) return; + + const next_ik = self.next[i * n_usize + k]; + const dist_ik_vec: DistVector = @splat(dist_ik); + const next_ik_vec: IndexVector = @splat(next_ik); + + const row_i_start = i * n_usize; + const row_k_start = k * n_usize; + + // SIMD processing + var j: usize = 0; + while (j + VectorWidth <= n_usize) : (j += VectorWidth) { + const dist_kj_vec: DistVector = self.dist[row_k_start + j ..][0..VectorWidth].*; + const dist_ij_ptr = self.dist[row_i_start + j ..][0..VectorWidth]; + const dist_ij_vec: DistVector = dist_ij_ptr.*; + const next_ij_ptr = self.next[row_i_start + j ..][0..VectorWidth]; + const next_ij_vec: IndexVector = next_ij_ptr.*; + + const new_dist_vec = dist_ik_vec +| dist_kj_vec; + const mask = new_dist_vec < dist_ij_vec; + + dist_ij_ptr.* = @select(u32, mask, new_dist_vec, dist_ij_vec); + next_ij_ptr.* = @select(u32, mask, next_ik_vec, next_ij_vec); + } + + // Handle remaining elements + while (j < n_usize) : (j += 1) { + const dist_kj = self.dist[row_k_start + j]; + if (dist_kj == INF) continue; + + const new_dist = dist_ik +| dist_kj; + const idx = row_i_start + j; + if (new_dist < self.dist[idx]) { + self.dist[idx] = new_dist; + self.next[idx] = next_ik; + } + } + } + }; +} + +/// Parallel + SIMD optimized Floyd-Warshall (best for large graphs 256+ nodes) +/// Uses multi-threading with row decomposition and SIMD vectorization within each thread. +pub const FloydWarshallParallel = FloydWarshallOptimized(.{ + .parallel = true, + .simd = true, +}); + +/// SIMD-only version (no threading overhead for smaller graphs) +pub const FloydWarshallSimd = FloydWarshallOptimized(.{ + .parallel = false, + .simd = true, +}); + +/// Scalar version (for comparison/debugging) +pub const FloydWarshallScalar = FloydWarshallOptimized(.{ + .parallel = false, + .simd = false, +}); + +// Unit tests +test "FloydWarshallOptimized basic functionality" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Create graph: 0 -> 1 -> 2 -> 3 + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + // Check distances + try std.testing.expectEqual(@as(u32, 0), fw.value(0, 0)); + try std.testing.expectEqual(@as(u32, 1), fw.value(0, 1)); + try std.testing.expectEqual(@as(u32, 2), fw.value(0, 2)); + try std.testing.expectEqual(@as(u32, 3), fw.value(0, 3)); + + // Check next hops + try std.testing.expectEqual(@as(u32, 1), fw.getNext(0, 3)); + try std.testing.expectEqual(@as(u32, 2), fw.getNext(1, 3)); +} + +test "FloydWarshallOptimized with entity mapping" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Use entity IDs: 100 -> 200 -> 300 -> 400 + try fw.addEdgeWithMapping(100, 200, 1); + try fw.addEdgeWithMapping(200, 300, 1); + try fw.addEdgeWithMapping(300, 400, 1); + + fw.generate(); + + // Check paths exist + try std.testing.expect(fw.hasPathWithMapping(100, 400)); + try std.testing.expect(fw.hasPathWithMapping(100, 200)); + + // Check distances + try std.testing.expectEqual(@as(u32, 1), fw.valueWithMapping(100, 200)); + try std.testing.expectEqual(@as(u32, 3), fw.valueWithMapping(100, 400)); + + // Check next hops + try std.testing.expectEqual(@as(u32, 200), fw.nextWithMapping(100, 400)); +} + +test "FloydWarshallOptimized weighted shortest path" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + fw.addEdge(0, 1, 5); + fw.addEdge(1, 3, 3); + fw.addEdge(0, 2, 2); + fw.addEdge(2, 3, 2); + + fw.generate(); + + // Should find shortest path + try std.testing.expectEqual(@as(u32, 4), fw.value(0, 3)); + try std.testing.expectEqual(@as(u32, 2), fw.getNext(0, 3)); // Goes through node 2 +} + +test "FloydWarshallOptimized path reconstruction" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + try fw.addEdgeWithMapping(10, 20, 1); + try fw.addEdgeWithMapping(20, 30, 1); + try fw.addEdgeWithMapping(30, 40, 1); + + fw.generate(); + + var path = std.ArrayListUnmanaged(u32){}; + defer path.deinit(allocator); + + try fw.setPathWithMappingUnmanaged(allocator, &path, 10, 40); + + try std.testing.expectEqual(@as(usize, 4), path.items.len); + try std.testing.expectEqual(@as(u32, 10), path.items[0]); + try std.testing.expectEqual(@as(u32, 20), path.items[1]); + try std.testing.expectEqual(@as(u32, 30), path.items[2]); + try std.testing.expectEqual(@as(u32, 40), path.items[3]); +} diff --git a/src/heuristics.zig b/src/heuristics.zig new file mode 100644 index 0000000..c631b6c --- /dev/null +++ b/src/heuristics.zig @@ -0,0 +1,138 @@ +//! Heuristic Functions for Pathfinding +//! +//! Provides multiple distance heuristics for A* algorithm optimization. +//! All heuristics use Position (Vector2) for coordinates. +//! +//! ## Available Heuristics +//! - **Euclidean**: Straight-line distance, best for any-angle movement +//! - **Manhattan**: Grid distance, best for 4-directional movement +//! - **Chebyshev**: Chessboard distance, best for 8-dir with equal diagonal cost +//! - **Octile**: Optimal 8-directional with sqrt(2) diagonal cost +//! - **Zero**: No heuristic (Dijkstra's algorithm) +//! +//! ## Heuristic Selection Guide +//! | Movement Type | Recommended Heuristic | +//! |---------------|----------------------| +//! | Free/any-angle | Euclidean | +//! | 4-directional grid | Manhattan | +//! | 8-dir, equal diagonal cost | Chebyshev | +//! | 8-dir, realistic diagonal | Octile | +//! | Unknown/mixed | Zero (safest) | + +const std = @import("std"); +const vector = @import("vector.zig"); + +pub const Position = vector.Position; + +/// Built-in heuristic types for A* pathfinding +pub const Heuristic = enum { + /// Straight-line distance: sqrt((x2-x1)^2 + (y2-y1)^2) + /// Best for: Any-angle movement, open spaces + /// Admissible: Always + euclidean, + + /// Grid distance: |x2-x1| + |y2-y1| + /// Best for: 4-directional grid movement + /// Admissible: For 4-directional movement only + manhattan, + + /// Chessboard distance: max(|x2-x1|, |y2-y1|) + /// Best for: 8-directional movement with equal diagonal cost + /// Admissible: For 8-directional with uniform cost + chebyshev, + + /// Optimal 8-directional: max(dx,dy) + (sqrt(2)-1) * min(dx,dy) + /// Best for: 8-directional movement where diagonal costs sqrt(2) + /// Admissible: For 8-directional with sqrt(2) diagonal cost + octile, + + /// No heuristic (always returns 0) + /// Effect: Degrades A* to Dijkstra's algorithm + /// Use when: You need guaranteed shortest path without heuristic assumptions + zero, +}; + +/// Custom heuristic function type for user-defined heuristics. +/// Must return an estimated cost from position `a` to position `b`. +/// For admissibility, the estimate must never exceed the actual cost. +pub const HeuristicFn = *const fn (a: Position, b: Position) f32; + +/// sqrt(2) - 1, precomputed for octile heuristic +pub const SQRT2_MINUS_1: f32 = std.math.sqrt2 - 1.0; + +/// Calculate heuristic distance between two positions using the specified heuristic type. +pub fn calculate(heuristic: Heuristic, a: Position, b: Position) f32 { + return switch (heuristic) { + .euclidean => euclidean(a, b), + .manhattan => manhattan(a, b), + .chebyshev => chebyshev(a, b), + .octile => octile(a, b), + .zero => 0, + }; +} + +/// Euclidean (straight-line) distance +pub fn euclidean(a: Position, b: Position) f32 { + const dx = b.x - a.x; + const dy = b.y - a.y; + return @sqrt(dx * dx + dy * dy); +} + +/// Squared Euclidean distance (faster, avoids sqrt) +pub fn euclideanSquared(a: Position, b: Position) f32 { + const dx = b.x - a.x; + const dy = b.y - a.y; + return dx * dx + dy * dy; +} + +/// Manhattan (taxicab) distance +pub fn manhattan(a: Position, b: Position) f32 { + return @abs(b.x - a.x) + @abs(b.y - a.y); +} + +/// Chebyshev (chessboard) distance +pub fn chebyshev(a: Position, b: Position) f32 { + return @max(@abs(b.x - a.x), @abs(b.y - a.y)); +} + +/// Octile distance for 8-directional movement +pub fn octile(a: Position, b: Position) f32 { + const dx = @abs(b.x - a.x); + const dy = @abs(b.y - a.y); + return @max(dx, dy) + SQRT2_MINUS_1 * @min(dx, dy); +} + +// Tests +test "euclidean distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + try std.testing.expectApproxEqAbs(@as(f32, 5.0), euclidean(a, b), 0.001); +} + +test "manhattan distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + try std.testing.expectApproxEqAbs(@as(f32, 7.0), manhattan(a, b), 0.001); +} + +test "chebyshev distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + try std.testing.expectApproxEqAbs(@as(f32, 4.0), chebyshev(a, b), 0.001); +} + +test "octile distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + // 4 + (sqrt(2)-1) * 3 = 4 + 0.414 * 3 = 5.243 + try std.testing.expectApproxEqAbs(@as(f32, 5.243), octile(a, b), 0.01); +} + +test "calculate with enum" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + try std.testing.expectApproxEqAbs(@as(f32, 5.0), calculate(.euclidean, a, b), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 7.0), calculate(.manhattan, a, b), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 0.0), calculate(.zero, a, b), 0.001); +} diff --git a/src/root.zig b/src/root.zig index 6dfc704..58cd3ae 100644 --- a/src/root.zig +++ b/src/root.zig @@ -33,5 +33,21 @@ pub const HookDispatcher = hooks.HookDispatcher; pub const EmptyDispatcher = hooks.EmptyDispatcher; pub const MergeHooks = hooks.MergeHooks; +// Graph algorithms +pub const floyd_warshall = @import("floyd_warshall.zig"); +pub const FloydWarshall = floyd_warshall.FloydWarshall; + +pub const floyd_warshall_optimized = @import("floyd_warshall_optimized.zig"); +pub const FloydWarshallOptimized = floyd_warshall_optimized.FloydWarshallOptimized; +pub const FloydWarshallParallel = floyd_warshall_optimized.FloydWarshallParallel; +pub const FloydWarshallSimd = floyd_warshall_optimized.FloydWarshallSimd; +pub const FloydWarshallScalar = floyd_warshall_optimized.FloydWarshallScalar; + +pub const a_star = @import("a_star.zig"); +pub const AStar = a_star.AStar; + +pub const heuristics = @import("heuristics.zig"); +pub const Heuristic = heuristics.Heuristic; + // Backwards compatibility alias (deprecated) pub const Vector2 = Position; From 64d96292c8eead67cd2e3d2b7580276ac9bc9f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Thu, 8 Jan 2026 16:16:04 -0300 Subject: [PATCH 06/13] docs: Update CLAUDE.md with graph algorithms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude.md | 47 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/claude.md b/claude.md index 0626757..3430579 100644 --- a/claude.md +++ b/claude.md @@ -6,14 +6,18 @@ Standalone math utilities library for Zig. Part of the labelle-toolkit. ``` src/ - root.zig - Main module exports - vector.zig - Position and PositionI types - quad_tree.zig - QuadTree spatial partitioning (generic, Position-based) - sweep_and_prune.zig - Sweep and Prune collision detection - sparse_set.zig - SparseSet O(1) key-value mapping - z_index_buckets.zig - ZIndexBuckets sorted storage by u8 key - zon_coercion.zig - Comptime ZON to struct conversion - hook_dispatcher.zig - Zero-overhead comptime event dispatcher + root.zig - Main module exports + vector.zig - Position and PositionI types + quad_tree.zig - QuadTree spatial partitioning + sweep_and_prune.zig - Sweep and Prune collision detection + sparse_set.zig - SparseSet O(1) key-value mapping + z_index_buckets.zig - ZIndexBuckets sorted storage by u8 key + zon_coercion.zig - Comptime ZON to struct conversion + hook_dispatcher.zig - Zero-overhead comptime event dispatcher + floyd_warshall.zig - Floyd-Warshall all-pairs shortest path + floyd_warshall_optimized.zig - SIMD/parallel Floyd-Warshall + a_star.zig - A* single-source pathfinding + heuristics.zig - Distance heuristics for A* tests/ root.zig - Test entry point vector_test.zig - Position/PositionI tests @@ -30,6 +34,9 @@ tests/ - `SparseSet(T)` - O(1) key-value mapping with cache-friendly iteration - `ZIndexBuckets(T)` - Bucket-sorted storage by u8 key (256 buckets) - `HookDispatcher` - Zero-overhead comptime event dispatcher +- `FloydWarshall` - All-pairs shortest path O(V³) +- `FloydWarshallOptimized` - SIMD/parallel Floyd-Warshall (5-16x faster) +- `AStar` - A* single-source pathfinding with heuristics - `Rectangle` - AABB for bounds and collision - `EntityPoint(T)` - Point with generic ID for QuadTree - `AABB` - Axis-aligned bounding box for SweepAndPrune @@ -101,6 +108,30 @@ zig build test - `MergeHooks` for composing multiple handler structs - `EmptyDispatcher` for default no-op dispatching +### Graph Algorithms + +**Floyd-Warshall** (all-pairs shortest path): +- O(V³) time, O(V²) space +- Best for dense graphs, frequent all-pairs queries +- Entity ID mapping support +- Optimized variant with SIMD (5-8x faster) and parallel (up to 16x) + +**A\*** (single-source shortest path): +- Heuristic-guided best-first search +- Built-in heuristics: Euclidean, Manhattan, Chebyshev, Octile, Zero +- Custom heuristic function support +- Entity ID mapping for ECS integration +- Best for sparse graphs, real-time queries + +**Heuristics**: +| Movement Type | Recommended | +|---------------|-------------| +| Any-angle | Euclidean | +| 4-directional | Manhattan | +| 8-dir equal cost | Chebyshev | +| 8-dir realistic | Octile | +| Unknown | Zero (Dijkstra) | + ## Related - labelle-pathfinding - Uses QuadTree for spatial queries From debb45df85bcbeff28f8a1ad736502226cda8356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 16:56:09 -0300 Subject: [PATCH 07/13] feat: Add comptime generics and optimize data structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/a_star.zig | 596 ++++++++++++++++--------------- src/floyd_warshall.zig | 338 ++++++++++-------- src/floyd_warshall_optimized.zig | 9 +- src/quad_tree.zig | 40 ++- src/root.zig | 2 + src/sparse_set.zig | 95 +++-- src/z_index_buckets.zig | 73 +++- 7 files changed, 659 insertions(+), 494 deletions(-) diff --git a/src/a_star.zig b/src/a_star.zig index 5d3146f..fe785a3 100644 --- a/src/a_star.zig +++ b/src/a_star.zig @@ -18,359 +18,371 @@ const std = @import("std"); const heuristics_mod = @import("heuristics.zig"); +const SparseSet = @import("sparse_set.zig").SparseSet; pub const Heuristic = heuristics_mod.Heuristic; pub const HeuristicFn = heuristics_mod.HeuristicFn; pub const Position = heuristics_mod.Position; -const INF: u64 = std.math.maxInt(u64); - /// A* pathfinding algorithm with configurable heuristics. +/// Generic over WeightType for memory efficiency. /// Supports both direct vertex indices and entity ID mapping. -pub const AStar = struct { - const Edge = struct { - to: u32, - weight: u64, - }; - const EdgeList = std.ArrayListUnmanaged(Edge); - const AdjacencyList = std.ArrayListUnmanaged(EdgeList); +pub fn AStar(comptime WeightType: type) type { + comptime { + const info = @typeInfo(WeightType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("WeightType must be an unsigned integer type"); + } + } - /// Priority queue node for A* open set - const PQNode = struct { - vertex: u32, - f_score: f32, + const INF = std.math.maxInt(WeightType); - fn compare(_: void, a: PQNode, b: PQNode) std.math.Order { - return std.math.order(a.f_score, b.f_score); - } - }; + return struct { + const Self = @This(); - allocator: std.mem.Allocator, - adjacency: AdjacencyList, - positions: std.AutoHashMap(u32, Position), - ids: std.AutoHashMap(u32, u32), - reverse_ids: std.AutoHashMap(u32, u32), - last_key: u32 = 0, - size: u32 = 100, - heuristic_type: Heuristic, - custom_heuristic: ?HeuristicFn, - - pub fn init(allocator: std.mem.Allocator) AStar { - return .{ - .allocator = allocator, - .adjacency = .empty, - .positions = std.AutoHashMap(u32, Position).init(allocator), - .ids = std.AutoHashMap(u32, u32).init(allocator), - .reverse_ids = std.AutoHashMap(u32, u32).init(allocator), - .heuristic_type = .euclidean, - .custom_heuristic = null, + const Edge = struct { + to: u32, + weight: WeightType, }; - } - - pub fn deinit(self: *AStar) void { - for (self.adjacency.items) |*edges| { - edges.deinit(self.allocator); - } - self.adjacency.deinit(self.allocator); - self.positions.deinit(); - self.ids.deinit(); - self.reverse_ids.deinit(); - } + const EdgeList = std.ArrayListUnmanaged(Edge); + const AdjacencyList = std.ArrayListUnmanaged(EdgeList); - /// Set the heuristic type to use for pathfinding - pub fn setHeuristic(self: *AStar, heuristic_type: Heuristic) void { - self.heuristic_type = heuristic_type; - self.custom_heuristic = null; - } + /// Priority queue node for A* open set + const PQNode = struct { + vertex: u32, + f_score: f32, - /// Set a custom heuristic function - pub fn setCustomHeuristic(self: *AStar, heuristic_fn: HeuristicFn) void { - self.custom_heuristic = heuristic_fn; - } + fn compare(_: void, a: PQNode, b: PQNode) std.math.Order { + return std.math.order(a.f_score, b.f_score); + } + }; - /// Set the position of a node (used for heuristic calculation) - pub fn setNodePosition(self: *AStar, node: u32, pos: Position) !void { - try self.positions.put(node, pos); - } + allocator: std.mem.Allocator, + adjacency: AdjacencyList, + positions: SparseSet(u32, Position), + ids: SparseSet(u32, u32), + reverse_ids: SparseSet(u32, u32), + last_key: u32 = 0, + size: u32 = 100, + heuristic_type: Heuristic, + custom_heuristic: ?HeuristicFn, + + pub fn init(allocator: std.mem.Allocator) !Self { + return .{ + .allocator = allocator, + .adjacency = .empty, + .positions = try SparseSet(u32, Position).init(allocator, 1024, 64), + .ids = try SparseSet(u32, u32).init(allocator, 1024, 64), + .reverse_ids = try SparseSet(u32, u32).init(allocator, 1024, 64), + .heuristic_type = .euclidean, + .custom_heuristic = null, + }; + } - /// Set node position using entity ID mapping - pub fn setNodePositionWithMapping(self: *AStar, entity: u32, pos: Position) !void { - const internal_id = self.getOrCreateMapping(entity); - try self.positions.put(internal_id, pos); - } + pub fn deinit(self: *Self) void { + for (self.adjacency.items) |*edges| { + edges.deinit(self.allocator); + } + self.adjacency.deinit(self.allocator); + self.positions.deinit(); + self.ids.deinit(); + self.reverse_ids.deinit(); + } - /// Generate a new internal key for entity mapping - fn newKey(self: *AStar) u32 { - self.last_key += 1; - return self.last_key - 1; - } + /// Set the heuristic type to use for pathfinding + pub fn setHeuristic(self: *Self, heuristic_type: Heuristic) void { + self.heuristic_type = heuristic_type; + self.custom_heuristic = null; + } - /// Get or create an internal ID mapping for an entity - fn getOrCreateMapping(self: *AStar, entity: u32) u32 { - if (self.ids.get(entity)) |id| { - return id; + /// Set a custom heuristic function + pub fn setCustomHeuristic(self: *Self, heuristic_fn: HeuristicFn) void { + self.custom_heuristic = heuristic_fn; } - const new_id = self.newKey(); - self.ids.put(entity, new_id) catch |err| { - std.log.err("Error inserting entity mapping: {any}\n", .{err}); - return std.math.maxInt(u32); - }; - self.reverse_ids.put(new_id, entity) catch |err| { - std.log.err("Error inserting reverse mapping: {any}\n", .{err}); - return std.math.maxInt(u32); - }; - return new_id; - } - /// Resize the graph to support a given number of vertices - pub fn resize(self: *AStar, size: u32) void { - self.size = size; - } + /// Set the position of a node (used for heuristic calculation) + pub fn setNodePosition(self: *Self, node: u32, pos: Position) !void { + try self.positions.put(node, pos); + } - /// Reset the graph and prepare for new data - pub fn clean(self: *AStar) !void { - self.last_key = 0; + /// Set node position using entity ID mapping + pub fn setNodePositionWithMapping(self: *Self, entity: u32, pos: Position) !void { + const internal_id = try self.getOrCreateMapping(entity); + try self.positions.put(internal_id, pos); + } - for (self.adjacency.items) |*edges| { - edges.deinit(self.allocator); + /// Generate a new internal key for entity mapping + fn newKey(self: *Self) u32 { + self.last_key += 1; + return self.last_key - 1; } - self.adjacency.clearRetainingCapacity(); - self.positions.clearRetainingCapacity(); - self.ids.clearRetainingCapacity(); - self.reverse_ids.clearRetainingCapacity(); - - // Initialize adjacency lists for each vertex - try self.adjacency.ensureTotalCapacity(self.allocator, self.size); - for (0..self.size) |_| { - try self.adjacency.append(self.allocator, .empty); + + /// Get or create an internal ID mapping for an entity + fn getOrCreateMapping(self: *Self, entity: u32) !u32 { + if (self.ids.get(entity)) |id| { + return id; + } + const new_id = self.newKey(); + try self.ids.put(entity, new_id); + try self.reverse_ids.put(new_id, entity); + return new_id; } - } - /// Add an edge between two vertices with given weight (direct index) - pub fn addEdge(self: *AStar, u: u32, v: u32, w: u64) 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}); - }; - } + /// Resize the graph to support a given number of vertices + pub fn resize(self: *Self, size: u32) void { + self.size = size; + } - /// Add an edge using entity ID mapping (auto-assigns internal indices) - pub fn addEdgeWithMapping(self: *AStar, u: u32, v: u32, w: u64) void { - const u_internal = self.getOrCreateMapping(u); - const v_internal = self.getOrCreateMapping(v); - self.addEdge(u_internal, v_internal, w); - } + /// Reset the graph and prepare for new data + pub fn clean(self: *Self) !void { + self.last_key = 0; - /// Calculate heuristic between two internal vertex indices - fn calculateHeuristic(self: *AStar, from: u32, to: u32) f32 { - const from_pos = self.positions.get(from) orelse Position{ .x = 0, .y = 0 }; - const to_pos = self.positions.get(to) orelse Position{ .x = 0, .y = 0 }; + for (self.adjacency.items) |*edges| { + edges.deinit(self.allocator); + } + self.adjacency.clearRetainingCapacity(); + self.positions.clear(); + self.ids.clear(); + self.reverse_ids.clear(); + + // Initialize adjacency lists for each vertex + try self.adjacency.ensureTotalCapacity(self.allocator, self.size); + for (0..self.size) |_| { + try self.adjacency.append(self.allocator, .empty); + } + } - if (self.custom_heuristic) |custom| { - return custom(from_pos, to_pos); + /// Add an edge between two vertices with given weight (direct index) + 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}); + }; } - return heuristics_mod.calculate(self.heuristic_type, from_pos, to_pos); - } - /// Run A* algorithm to find shortest path from source to destination. - /// Returns the path cost, or null if no path exists. - /// The path is stored in the provided ArrayList. - pub fn findPath( - self: *AStar, - source: u32, - dest: u32, - path: *std.array_list.Managed(u32), - ) !?u64 { - if (source >= self.adjacency.items.len or dest >= self.adjacency.items.len) { - return null; + /// Add an edge using entity ID mapping (auto-assigns internal indices) + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: WeightType) !void { + const u_internal = try self.getOrCreateMapping(u); + const v_internal = try self.getOrCreateMapping(v); + self.addEdge(u_internal, v_internal, w); } - path.clearRetainingCapacity(); + /// Calculate heuristic between two internal vertex indices + fn calculateHeuristic(self: *Self, from: u32, to: u32) f32 { + const from_pos = self.positions.get(from) orelse Position{ .x = 0, .y = 0 }; + const to_pos = self.positions.get(to) orelse Position{ .x = 0, .y = 0 }; - if (source == dest) { - try path.append(source); - return 0; + if (self.custom_heuristic) |custom| { + return custom(from_pos, to_pos); + } + return heuristics_mod.calculate(self.heuristic_type, from_pos, to_pos); } - var g_score = std.AutoHashMap(u32, u64).init(self.allocator); - defer g_score.deinit(); - - var came_from = std.AutoHashMap(u32, u32).init(self.allocator); - defer came_from.deinit(); - - var closed_set = std.AutoHashMap(u32, void).init(self.allocator); - defer closed_set.deinit(); - - var open_set = std.PriorityQueue(PQNode, void, PQNode.compare).init(self.allocator, {}); - defer open_set.deinit(); - - // Initialize source - try g_score.put(source, 0); - const h = self.calculateHeuristic(source, dest); - try open_set.add(.{ .vertex = source, .f_score = h }); - - while (open_set.removeOrNull()) |current| { - if (current.vertex == dest) { - // Reconstruct path - var node = dest; - while (true) { - try path.append(node); - if (came_from.get(node)) |prev| { - node = prev; - } else { - break; - } - } - // Reverse to get source -> dest order - std.mem.reverse(u32, path.items); - return g_score.get(dest); + /// Run A* algorithm to find shortest path from source to destination. + /// Returns the path cost, or null if no path exists. + /// The path is stored in the provided ArrayList. + pub fn findPath( + self: *Self, + source: u32, + dest: u32, + path: *std.array_list.Managed(u32), + ) !?WeightType { + const n = self.adjacency.items.len; + if (source >= n or dest >= n) { + return null; } - if (closed_set.contains(current.vertex)) { - continue; + path.clearRetainingCapacity(); + + if (source == dest) { + try path.append(source); + return 0; } - try closed_set.put(current.vertex, {}); - const current_g = g_score.get(current.vertex) orelse INF; + // Flat arrays instead of HashMaps - O(1) access, cache-friendly + const g_score = try self.allocator.alloc(WeightType, n); + defer self.allocator.free(g_score); + @memset(g_score, INF); + + const came_from = try self.allocator.alloc(u32, n); + defer self.allocator.free(came_from); + @memset(came_from, std.math.maxInt(u32)); // maxInt = no parent + + // BitSet instead of HashMap for closed_set - ~32x smaller, faster + var closed_set = try std.DynamicBitSet.initEmpty(self.allocator, n); + defer closed_set.deinit(); + + var open_set = std.PriorityQueue(PQNode, void, PQNode.compare).init(self.allocator, {}); + defer open_set.deinit(); + + // Initialize source + g_score[source] = 0; + const h = self.calculateHeuristic(source, dest); + try open_set.add(.{ .vertex = source, .f_score = h }); + + while (open_set.removeOrNull()) |current| { + if (current.vertex == dest) { + // Reconstruct path + var node = dest; + while (true) { + try path.append(node); + const parent = came_from[node]; + if (parent == std.math.maxInt(u32)) { + break; + } + node = parent; + } + // Reverse to get source -> dest order + std.mem.reverse(u32, path.items); + return g_score[dest]; + } - // Explore neighbors - for (self.adjacency.items[current.vertex].items) |edge| { - if (closed_set.contains(edge.to)) { + if (closed_set.isSet(current.vertex)) { continue; } + closed_set.set(current.vertex); - const tentative_g = current_g + edge.weight; - const neighbor_g = g_score.get(edge.to) orelse INF; + const current_g = g_score[current.vertex]; - if (tentative_g < neighbor_g) { - try came_from.put(edge.to, current.vertex); - try g_score.put(edge.to, tentative_g); - - const f = @as(f32, @floatFromInt(tentative_g)) + self.calculateHeuristic(edge.to, dest); - try open_set.add(.{ .vertex = edge.to, .f_score = f }); - } - } - } + // Explore neighbors + for (self.adjacency.items[current.vertex].items) |edge| { + if (closed_set.isSet(edge.to)) { + continue; + } - return null; // No path found - } + const tentative_g = current_g +| edge.weight; // Saturating add + const neighbor_g = g_score[edge.to]; - /// Find path using entity ID mapping - pub fn findPathWithMapping( - self: *AStar, - source_entity: u32, - dest_entity: u32, - path: *std.array_list.Managed(u32), - ) !?u64 { - const source = self.ids.get(source_entity) orelse return null; - const dest = self.ids.get(dest_entity) orelse return null; + if (tentative_g < neighbor_g) { + came_from[edge.to] = current.vertex; + g_score[edge.to] = tentative_g; - var internal_path = std.array_list.Managed(u32).init(self.allocator); - defer internal_path.deinit(); + const f = @as(f32, @floatFromInt(tentative_g)) + self.calculateHeuristic(edge.to, dest); + try open_set.add(.{ .vertex = edge.to, .f_score = f }); + } + } + } - const cost = try self.findPath(source, dest, &internal_path); + return null; // No path found + } - if (cost != null) { - path.clearRetainingCapacity(); - for (internal_path.items) |internal_id| { - const entity = self.reverse_ids.get(internal_id) orelse continue; - try path.append(entity); + /// Find path using entity ID mapping + pub fn findPathWithMapping( + self: *Self, + source_entity: u32, + dest_entity: u32, + path: *std.array_list.Managed(u32), + ) !?WeightType { + const source = self.ids.get(source_entity) orelse return null; + const dest = self.ids.get(dest_entity) orelse return null; + + var internal_path = std.array_list.Managed(u32).init(self.allocator); + defer internal_path.deinit(); + + const cost = try self.findPath(source, dest, &internal_path); + + if (cost != null) { + path.clearRetainingCapacity(); + for (internal_path.items) |internal_id| { + const entity = self.reverse_ids.get(internal_id) orelse continue; + try path.append(entity); + } } - } - return cost; - } + return cost; + } - /// Check if a path exists between two vertices (direct index) - pub fn hasPath(self: *AStar, u: usize, v: usize) bool { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *Self, u: usize, v: usize) bool { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = self.findPath(@intCast(u), @intCast(v), &path) catch return false; - return result != null; - } + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return false; + return result != null; + } - /// Check if a path exists between two entities (using ID mapping) - pub fn hasPathWithMapping(self: *AStar, u: u32, v: u32) bool { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *Self, u: u32, v: u32) bool { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = self.findPathWithMapping(u, v, &path) catch return false; - return result != null; - } + const result = self.findPathWithMapping(u, v, &path) catch return false; + return result != null; + } - /// Get the distance between two vertices (direct index) - /// Note: This runs A* each time - cache results if needed frequently - pub fn value(self: *AStar, u: usize, v: usize) u64 { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Get the distance between two vertices (direct index) + /// Note: This runs A* each time - cache results if needed frequently + pub fn value(self: *Self, u: usize, v: usize) WeightType { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = self.findPath(@intCast(u), @intCast(v), &path) catch return INF; - return result orelse INF; - } + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return INF; + return result orelse INF; + } - /// Get the distance between two entities (using ID mapping) - pub fn valueWithMapping(self: *AStar, u: u32, v: u32) u64 { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *Self, u: u32, v: u32) WeightType { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = self.findPathWithMapping(u, v, &path) catch return INF; - return result orelse INF; - } + const result = self.findPathWithMapping(u, v, &path) catch return INF; + return result orelse INF; + } - /// Build the path from u to v and store in the provided ArrayList - pub fn setPathWithMapping(self: *AStar, path_list: *std.array_list.Managed(u32), u: u32, v: u32) !void { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Build the path from u to v and store in the provided ArrayList + pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u: u32, v: u32) !void { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = try self.findPathWithMapping(u, v, &path); - if (result == null) { - std.log.err("No path found from {} to {}\n", .{ u, v }); - return; - } + const result = try self.findPathWithMapping(u, v, &path); + if (result == null) { + std.log.err("No path found from {} to {}\n", .{ u, v }); + return; + } - path_list.clearRetainingCapacity(); - for (path.items) |node| { - try path_list.append(node); + path_list.clearRetainingCapacity(); + for (path.items) |node| { + try path_list.append(node); + } } - } - /// Get the next entity in the shortest path from u to v (using ID mapping) - pub fn nextWithMapping(self: *AStar, u: u32, v: u32) u32 { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Get the next entity in the shortest path from u to v (using ID mapping) + pub fn nextWithMapping(self: *Self, u: u32, v: u32) u32 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = self.findPathWithMapping(u, v, &path) catch return std.math.maxInt(u32); - if (result == null or path.items.len < 2) { - return std.math.maxInt(u32); + const result = self.findPathWithMapping(u, v, &path) catch return std.math.maxInt(u32); + if (result == null or path.items.len < 2) { + return std.math.maxInt(u32); + } + return path.items[1]; // Second element is next step } - return path.items[1]; // Second element is next step - } - /// Get the next vertex in the shortest path from u to v (direct index) - pub fn next(self: *AStar, u: usize, v: usize) u32 { - var path = std.array_list.Managed(u32).init(self.allocator); - defer path.deinit(); + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn next(self: *Self, u: usize, v: usize) u32 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); - const result = self.findPath(@intCast(u), @intCast(v), &path) catch return std.math.maxInt(u32); - if (result == null or path.items.len < 2) { - return std.math.maxInt(u32); + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return std.math.maxInt(u32); + if (result == null or path.items.len < 2) { + return std.math.maxInt(u32); + } + return path.items[1]; } - return path.items[1]; - } - /// No-op for A* (paths computed on-demand) - pub fn generate(self: *AStar) void { - _ = self; - // A* computes paths on-demand, no pre-computation needed - } -}; + /// No-op for A* (paths computed on-demand) + pub fn generate(self: *Self) void { + _ = self; + // A* computes paths on-demand, no pre-computation needed + } + }; +} // Tests test "AStar basic pathfinding" { const allocator = std.testing.allocator; - var astar = AStar.init(allocator); + var astar = try AStar(u64).init(allocator); defer astar.deinit(); astar.resize(4); @@ -403,7 +415,7 @@ test "AStar basic pathfinding" { test "AStar weighted shortest path" { const allocator = std.testing.allocator; - var astar = AStar.init(allocator); + var astar = try AStar(u64).init(allocator); defer astar.deinit(); astar.resize(4); @@ -433,7 +445,7 @@ test "AStar weighted shortest path" { test "AStar no path" { const allocator = std.testing.allocator; - var astar = AStar.init(allocator); + var astar = try AStar(u64).init(allocator); defer astar.deinit(); astar.resize(4); @@ -450,3 +462,25 @@ test "AStar no path" { try std.testing.expectEqual(@as(?u64, null), cost); } + +test "AStar with u32 weights" { + const allocator = std.testing.allocator; + + // Use u32 for smaller memory footprint + var astar = try AStar(u32).init(allocator); + defer astar.deinit(); + + astar.resize(3); + try astar.clean(); + astar.setHeuristic(.zero); + + astar.addEdge(0, 1, 10); + astar.addEdge(1, 2, 20); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 2, &path); + + try std.testing.expectEqual(@as(?u32, 30), cost); +} diff --git a/src/floyd_warshall.zig b/src/floyd_warshall.zig index 71286a8..6b880e8 100644 --- a/src/floyd_warshall.zig +++ b/src/floyd_warshall.zig @@ -12,199 +12,217 @@ const std = @import("std"); -const INF = std.math.maxInt(u32); - /// Floyd-Warshall all-pairs shortest path algorithm. +/// Generic over DistanceType for memory efficiency. /// Supports both direct vertex indices and entity ID mapping. -pub const FloydWarshall = struct { - const RowList = std.array_list.Managed(u64); - const GraphList = std.array_list.Managed(RowList); - - size: u32 = 100, - graph: GraphList, - path: GraphList, - ids: std.AutoHashMap(u32, u32), - last_key: u32 = 0, - allocator: std.mem.Allocator, - - pub fn init(allocator: std.mem.Allocator) FloydWarshall { - return .{ - .graph = GraphList.init(allocator), - .path = GraphList.init(allocator), - .ids = std.AutoHashMap(u32, u32).init(allocator), - .allocator = allocator, - }; +pub fn FloydWarshall(comptime DistanceType: type) type { + comptime { + const info = @typeInfo(DistanceType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("DistanceType must be an unsigned integer type"); + } } - pub fn deinit(self: *FloydWarshall) void { - for (self.graph.items) |*row| { - row.deinit(); - } - for (self.path.items) |*row| { - row.deinit(); + const INF = std.math.maxInt(DistanceType); + + return struct { + const Self = @This(); + const RowList = std.array_list.Managed(DistanceType); + const GraphList = std.array_list.Managed(RowList); + + size: u32 = 100, + graph: GraphList, + path: GraphList, + ids: std.AutoHashMap(u32, u32), + last_key: u32 = 0, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .graph = GraphList.init(allocator), + .path = GraphList.init(allocator), + .ids = std.AutoHashMap(u32, u32).init(allocator), + .allocator = allocator, + }; } - self.graph.deinit(); - self.path.deinit(); - self.ids.deinit(); - } - /// Generate a new internal key for entity mapping - pub fn newKey(self: *FloydWarshall) u32 { - self.last_key += 1; - return self.last_key - 1; - } + pub fn deinit(self: *Self) void { + for (self.graph.items) |*row| { + row.deinit(); + } + for (self.path.items) |*row| { + row.deinit(); + } + self.graph.deinit(); + self.path.deinit(); + self.ids.deinit(); + } - /// Add an edge between two vertices with given weight (direct index) - pub fn addEdge(self: *FloydWarshall, u: u32, v: u32, w: u64) void { - self.graph.items[u].items[v] = w; - } + /// Generate a new internal key for entity mapping + pub fn newKey(self: *Self) u32 { + self.last_key += 1; + return self.last_key - 1; + } - /// Get the distance between two vertices (direct index) - pub fn value(self: *FloydWarshall, u: usize, v: usize) u64 { - return self.graph.items[u].items[v]; - } + /// Add an edge between two vertices with given weight (direct index) + pub fn addEdge(self: *Self, u: u32, v: u32, w: DistanceType) void { + self.graph.items[u].items[v] = w; + } - /// Check if a path exists between two vertices (direct index) - pub fn hasPath(self: *FloydWarshall, u: usize, v: usize) bool { - return self.graph.items[u].items[v] != INF; - } + /// Get the distance between two vertices (direct index) + pub fn value(self: *Self, u: usize, v: usize) DistanceType { + return self.graph.items[u].items[v]; + } - /// Get the next vertex in the shortest path from u to v (direct index) - pub fn next(self: *FloydWarshall, u: usize, v: usize) u32 { - return @intCast(self.path.items[u].items[v]); - } + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *Self, u: usize, v: usize) bool { + return self.graph.items[u].items[v] != INF; + } - /// Resize the graph to support a given number of vertices - pub fn resize(self: *FloydWarshall, size: u32) void { - self.size = size; - } + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn next(self: *Self, u: usize, v: usize) u32 { + return @intCast(self.path.items[u].items[v]); + } - /// Add an edge using entity ID mapping (auto-assigns internal indices) - pub fn addEdgeWithMapping(self: *FloydWarshall, u: u32, v: u32, w: u64) void { - if (!self.ids.contains(u)) { - self.ids.put(u, self.newKey()) catch |err| { - std.log.err("Error inserting on map: {}\n", .{err}); - }; + /// Resize the graph to support a given number of vertices + pub fn resize(self: *Self, size: u32) void { + self.size = size; } - if (!self.ids.contains(v)) { - self.ids.put(v, self.newKey()) catch |err| { - std.log.err("Error inserting on map: {}\n", .{err}); - }; + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: DistanceType) void { + if (!self.ids.contains(u)) { + self.ids.put(u, self.newKey()) catch |err| { + std.log.err("Error inserting on map: {}\n", .{err}); + }; + } + if (!self.ids.contains(v)) { + self.ids.put(v, self.newKey()) catch |err| { + std.log.err("Error inserting on map: {}\n", .{err}); + }; + } + self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); } - self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); - } - /// Get the distance between two entities (using ID mapping) - pub fn valueWithMapping(self: *FloydWarshall, u: u32, v: u32) u64 { - return self.value(self.ids.get(u).?, self.ids.get(v).?); - } + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *Self, u: u32, v: u32) DistanceType { + return self.value(self.ids.get(u).?, self.ids.get(v).?); + } - /// Build the path from u to v and store in the provided ArrayList - pub fn setPathWithMapping(self: *FloydWarshall, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) !void { - var current = u_node; - while (current != v_node) { - try path_list.append(current); - current = self.nextWithMapping(current, v_node); - if (current == INF) { - std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); - return; + /// Build the path from u to v and store in the provided ArrayList + pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) !void { + var current = u_node; + while (current != v_node) { + try path_list.append(current); + current = self.nextWithMapping(current, v_node); + if (current == std.math.maxInt(u32)) { + std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); + return; + } } + try path_list.append(v_node); } - try path_list.append(v_node); - } - /// Build the path from u to v and store in the provided unmanaged ArrayList - pub fn setPathWithMappingUnmanaged(self: *FloydWarshall, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) !void { - var current = u_node; - while (current != v_node) { - try path_list.append(allocator, current); - current = self.nextWithMapping(current, v_node); - if (current == INF) { - std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); - return; + /// Build the path from u to v and store in the provided unmanaged ArrayList + pub fn setPathWithMappingUnmanaged(self: *Self, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) !void { + var current = u_node; + while (current != v_node) { + try path_list.append(allocator, current); + current = self.nextWithMapping(current, v_node); + if (current == std.math.maxInt(u32)) { + std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); + return; + } } + try path_list.append(allocator, v_node); } - try path_list.append(allocator, v_node); - } - /// Get the next entity in the shortest path from u to v (using ID mapping) - pub fn nextWithMapping(self: *FloydWarshall, 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.*; + /// Get the next entity in the shortest path from u to v (using ID mapping) + 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); } - return INF; - } - /// Check if a path exists between two entities (using ID mapping) - pub fn hasPathWithMapping(self: *FloydWarshall, u: u32, v: u32) bool { - if (self.ids.get(u) == null or self.ids.get(v) == null) { - return false; + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *Self, u: u32, v: u32) bool { + if (self.ids.get(u) == null or self.ids.get(v) == null) { + return false; + } + return self.hasPath(self.ids.get(u).?, self.ids.get(v).?); } - return self.hasPath(self.ids.get(u).?, self.ids.get(v).?); - } - /// Reset the graph and prepare for new data - pub fn clean(self: *FloydWarshall) !void { - self.last_key = 0; - for (self.graph.items) |*row| { - row.deinit(); - } - for (self.path.items) |*row| { - row.deinit(); - } - self.graph.clearRetainingCapacity(); - self.path.clearRetainingCapacity(); - self.ids.clearRetainingCapacity(); + /// Reset the graph and prepare for new data + pub fn clean(self: *Self) !void { + self.last_key = 0; + for (self.graph.items) |*row| { + row.deinit(); + } + for (self.path.items) |*row| { + row.deinit(); + } + self.graph.clearRetainingCapacity(); + self.path.clearRetainingCapacity(); + self.ids.clearRetainingCapacity(); - // Initialize adjacency matrix and path matrix - for (0..self.size) |_| { - var list = RowList.init(self.allocator); - var row_path = RowList.init(self.allocator); + // Initialize adjacency matrix and path matrix for (0..self.size) |_| { - try list.append(0); - try row_path.append(0); - } - try self.graph.append(list); - try self.path.append(row_path); - } - - // Set initial values: 0 for self-loops, INF for no edge - for (0..self.size) |i| { - for (0..self.size) |j| { - self.path.items[i].items[j] = j; - if (i == j) { - self.graph.items[i].items[j] = 0; - } else { - self.graph.items[i].items[j] = INF; + var list = RowList.init(self.allocator); + var row_path = RowList.init(self.allocator); + for (0..self.size) |_| { + try list.append(0); + try row_path.append(0); } + try self.graph.append(list); + try self.path.append(row_path); } - } - } - /// Run the Floyd-Warshall algorithm to compute all shortest paths - pub fn generate(self: *FloydWarshall) void { - for (0..self.size) |k| { + // Set initial values: 0 for self-loops, INF for no edge for (0..self.size) |i| { for (0..self.size) |j| { - if (self.graph.items[i].items[k] + self.graph.items[k].items[j] < self.graph.items[i].items[j]) { - self.graph.items[i].items[j] = self.graph.items[i].items[k] + self.graph.items[k].items[j]; - self.path.items[i].items[j] = self.path.items[i].items[k]; + self.path.items[i].items[j] = @intCast(j); + if (i == j) { + self.graph.items[i].items[j] = 0; + } else { + self.graph.items[i].items[j] = INF; } } } } - } -}; + + /// Run the Floyd-Warshall algorithm to compute all shortest paths + pub fn generate(self: *Self) void { + for (0..self.size) |k| { + for (0..self.size) |i| { + const dist_ik = self.graph.items[i].items[k]; + if (dist_ik == INF) continue; // Skip if no path to k + + for (0..self.size) |j| { + const dist_kj = self.graph.items[k].items[j]; + if (dist_kj == INF) continue; // Skip if no path from k + + const new_dist = dist_ik +| dist_kj; // Saturating add + if (new_dist < self.graph.items[i].items[j]) { + self.graph.items[i].items[j] = new_dist; + self.path.items[i].items[j] = self.path.items[i].items[k]; + } + } + } + } + } + }; +} // Tests test "FloydWarshall basic functionality" { const allocator = std.testing.allocator; - var fw = FloydWarshall.init(allocator); + var fw = FloydWarshall(u64).init(allocator); defer fw.deinit(); fw.resize(4); @@ -231,7 +249,7 @@ test "FloydWarshall basic functionality" { test "FloydWarshall weighted shortest path" { const allocator = std.testing.allocator; - var fw = FloydWarshall.init(allocator); + var fw = FloydWarshall(u64).init(allocator); defer fw.deinit(); fw.resize(4); @@ -251,3 +269,21 @@ test "FloydWarshall weighted shortest path" { try std.testing.expectEqual(@as(u64, 4), fw.value(0, 3)); try std.testing.expectEqual(@as(u32, 2), fw.next(0, 3)); // Goes through node 2 } + +test "FloydWarshall with u32 distances" { + const allocator = std.testing.allocator; + + // Use u32 for smaller memory footprint + var fw = FloydWarshall(u32).init(allocator); + defer fw.deinit(); + + fw.resize(3); + try fw.clean(); + + fw.addEdge(0, 1, 10); + fw.addEdge(1, 2, 20); + + fw.generate(); + + try std.testing.expectEqual(@as(u32, 30), fw.value(0, 2)); +} diff --git a/src/floyd_warshall_optimized.zig b/src/floyd_warshall_optimized.zig index 1f743cd..759c761 100644 --- a/src/floyd_warshall_optimized.zig +++ b/src/floyd_warshall_optimized.zig @@ -18,6 +18,11 @@ pub const Config = struct { parallel: bool = true, /// Enable SIMD vectorization simd: bool = true, + /// SIMD vector width (elements per vector operation) + /// - 4: 128-bit vectors (SSE, NEON) - widely supported + /// - 8: 256-bit vectors (AVX2) + /// - 16: 512-bit vectors (AVX-512) + vector_width: comptime_int = 4, }; /// Optimized Floyd-Warshall all-pairs shortest path algorithm. @@ -26,8 +31,8 @@ pub fn FloydWarshallOptimized(comptime config: Config) type { return struct { const Self = @This(); - // SIMD vector width (4 x u32 = 128 bits, widely supported) - const VectorWidth = 4; + // SIMD vector configuration + const VectorWidth = config.vector_width; const DistVector = @Vector(VectorWidth, u32); const IndexVector = @Vector(VectorWidth, u32); diff --git a/src/quad_tree.zig b/src/quad_tree.zig index de4158b..ddd99c3 100644 --- a/src/quad_tree.zig +++ b/src/quad_tree.zig @@ -77,12 +77,12 @@ pub fn EntityPoint(comptime T: type) type { } /// QuadTree node for internal storage -fn QuadTreeNode(comptime T: type) type { +fn QuadTreeNode(comptime T: type, comptime capacity: u32) type { const Point = EntityPoint(T); return struct { total_elements: u32 = 0, - points: [4]Point = undefined, + points: [capacity]Point = undefined, boundary: Rectangle, divided: bool = false, nw: u32 = 0, @@ -92,19 +92,27 @@ fn QuadTreeNode(comptime T: type) type { }; } +/// QuadTree configuration +pub const QuadTreeConfig = struct { + /// Maximum points per node before subdivision (default: 4) + capacity: u32 = 4, + /// Boundary margin for auto-computed bounds (default: 120.0) + gutter: f32 = 120.0, +}; + /// QuadTree for efficient spatial partitioning and queries /// -/// Generic over the ID type for flexibility (u32, u64, custom types) -pub fn QuadTree(comptime T: type) type { +/// Generic over: +/// - T: ID type for flexibility (u32, u64, custom types) +/// - config: Comptime configuration for capacity and gutter +pub fn QuadTree(comptime T: type, comptime config: QuadTreeConfig) type { const Point = EntityPoint(T); - const Node = QuadTreeNode(T); + const Node = QuadTreeNode(T, config.capacity); return struct { const Self = @This(); nodes: std.ArrayListUnmanaged(Node), - capacity: u32 = 4, - gutter: f32 = 120.0, lowest_x: f32 = 0.0, lowest_y: f32 = 0.0, @@ -164,20 +172,20 @@ pub fn QuadTree(comptime T: type) type { } try self.nodes.append(self.allocator, .{ .boundary = .{ - .x = self.lowest_x - self.gutter, - .y = self.lowest_y - self.gutter, - .width = (self.highest_x - self.lowest_x) + self.gutter * 2, - .height = (self.highest_y - self.lowest_y) + self.gutter * 2, + .x = self.lowest_x - config.gutter, + .y = self.lowest_y - config.gutter, + .width = (self.highest_x - self.lowest_x) + config.gutter * 2, + .height = (self.highest_y - self.lowest_y) + config.gutter * 2, } }); } /// Clear the tree keeping current boundaries pub fn reset(self: *Self) !void { const boundary = Rectangle{ - .x = self.lowest_x - self.gutter, - .y = self.lowest_y - self.gutter, - .width = (self.highest_x - self.lowest_x) + self.gutter * 2, - .height = (self.highest_y - self.lowest_y) + self.gutter * 2, + .x = self.lowest_x - config.gutter, + .y = self.lowest_y - config.gutter, + .width = (self.highest_x - self.lowest_x) + config.gutter * 2, + .height = (self.highest_y - self.lowest_y) + config.gutter * 2, }; self.nodes.clearRetainingCapacity(); self.lowest_x = std.math.inf(f32); @@ -202,7 +210,7 @@ pub fn QuadTree(comptime T: type) type { return false; } - if (self.nodes.items[node_idx].total_elements < self.capacity and !self.nodes.items[node_idx].divided) { + if (self.nodes.items[node_idx].total_elements < config.capacity and !self.nodes.items[node_idx].divided) { self.nodes.items[node_idx].points[self.nodes.items[node_idx].total_elements] = point; self.nodes.items[node_idx].total_elements += 1; return true; diff --git a/src/root.zig b/src/root.zig index 58cd3ae..583a700 100644 --- a/src/root.zig +++ b/src/root.zig @@ -7,6 +7,7 @@ pub const PositionI = vector.PositionI; pub const quad_tree = @import("quad_tree.zig"); pub const QuadTree = quad_tree.QuadTree; +pub const QuadTreeConfig = quad_tree.QuadTreeConfig; pub const EntityPoint = quad_tree.EntityPoint; pub const Rectangle = quad_tree.Rectangle; @@ -39,6 +40,7 @@ pub const FloydWarshall = floyd_warshall.FloydWarshall; pub const floyd_warshall_optimized = @import("floyd_warshall_optimized.zig"); pub const FloydWarshallOptimized = floyd_warshall_optimized.FloydWarshallOptimized; +pub const FloydWarshallConfig = floyd_warshall_optimized.Config; pub const FloydWarshallParallel = floyd_warshall_optimized.FloydWarshallParallel; pub const FloydWarshallSimd = floyd_warshall_optimized.FloydWarshallSimd; pub const FloydWarshallScalar = floyd_warshall_optimized.FloydWarshallScalar; diff --git a/src/sparse_set.zig b/src/sparse_set.zig index df7358b..8f262d8 100644 --- a/src/sparse_set.zig +++ b/src/sparse_set.zig @@ -6,15 +6,23 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// Generic sparse set for mapping u64 keys to values of type T -pub fn SparseSet(comptime T: type) type { +/// Generic sparse set for mapping keys to values +/// KeyType must be an unsigned integer type (u8, u16, u32, u64, usize) +pub fn SparseSet(comptime KeyType: type, comptime T: type) type { + comptime { + const info = @typeInfo(KeyType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("KeyType must be an unsigned integer type"); + } + } + return struct { const Self = @This(); allocator: Allocator, - sparse: []?u32, // key -> dense_index - dense_keys: []u64, // dense_index -> key - dense_values: []T, // dense_index -> value + sparse: []?u32, // key -> dense_index + dense_keys: []KeyType, // dense_index -> key + dense_values: []T, // dense_index -> value count: usize, capacity: usize, max_key: usize, @@ -24,10 +32,11 @@ pub fn SparseSet(comptime T: type) type { errdefer allocator.free(sparse); @memset(sparse, null); - const dense_keys = try allocator.alloc(u64, initial_capacity); + const dense_keys = try allocator.alloc(KeyType, initial_capacity); errdefer allocator.free(dense_keys); const dense_values = try allocator.alloc(T, initial_capacity); + errdefer allocator.free(dense_values); return Self{ .allocator = allocator, @@ -47,7 +56,7 @@ pub fn SparseSet(comptime T: type) type { } /// Insert or update a key-value pair - pub fn put(self: *Self, key: u64, value: T) !void { + pub fn put(self: *Self, key: KeyType, value: T) !void { if (key >= self.max_key) return error.KeyOutOfRange; // Update existing @@ -58,17 +67,11 @@ pub fn SparseSet(comptime T: type) type { // Grow if needed if (self.count >= self.capacity) { - const new_cap = self.capacity * 2; - self.dense_keys = try self.allocator.realloc(self.dense_keys, new_cap); - // If dense_values realloc fails, shrink dense_keys back to maintain consistency - self.dense_values = self.allocator.realloc(self.dense_values, new_cap) catch |err| { - // Shrink dense_keys back (shrinking shouldn't fail) - self.dense_keys = self.allocator.realloc(self.dense_keys, self.capacity) catch self.dense_keys; - return err; - }; - self.capacity = new_cap; + const new_cap = if (self.capacity == 0) 4 else self.capacity * 2; + try self.grow(new_cap); } + if (self.count >= std.math.maxInt(u32)) return error.CapacityExceeded; const idx: u32 = @intCast(self.count); self.sparse[key] = idx; self.dense_keys[idx] = key; @@ -76,28 +79,50 @@ pub fn SparseSet(comptime T: type) type { self.count += 1; } + /// Atomically grow both dense arrays + fn grow(self: *Self, new_cap: usize) !void { + // Allocate new arrays first (no state change yet) + const new_keys = try self.allocator.alloc(KeyType, new_cap); + errdefer self.allocator.free(new_keys); + + const new_values = try self.allocator.alloc(T, new_cap); + + // Copy existing data + @memcpy(new_keys[0..self.count], self.dense_keys[0..self.count]); + @memcpy(new_values[0..self.count], self.dense_values[0..self.count]); + + // Free old arrays + self.allocator.free(self.dense_keys); + self.allocator.free(self.dense_values); + + // Update state atomically + self.dense_keys = new_keys; + self.dense_values = new_values; + self.capacity = new_cap; + } + /// Get value for key - pub fn get(self: *const Self, key: u64) ?T { + pub fn get(self: *const Self, key: KeyType) ?T { if (key >= self.max_key) return null; const idx = self.sparse[key] orelse return null; return self.dense_values[idx]; } /// Get pointer to value for key - pub fn getPtr(self: *Self, key: u64) ?*T { + pub fn getPtr(self: *Self, key: KeyType) ?*T { if (key >= self.max_key) return null; const idx = self.sparse[key] orelse return null; return &self.dense_values[idx]; } /// Check if key exists - pub fn contains(self: *const Self, key: u64) bool { + pub fn contains(self: *const Self, key: KeyType) bool { if (key >= self.max_key) return false; return self.sparse[key] != null; } /// Remove key-value pair - pub fn remove(self: *Self, key: u64) void { + pub fn remove(self: *Self, key: KeyType) void { if (key >= self.max_key) return; const idx = self.sparse[key] orelse return; @@ -128,13 +153,13 @@ pub fn SparseSet(comptime T: type) type { } /// Iterate over all keys - pub fn keys(self: *const Self) []const u64 { + pub fn keys(self: *const Self) []const KeyType { return self.dense_keys[0..self.count]; } /// Get key-value pairs for iteration pub const Entry = struct { - key: u64, + key: KeyType, value: *T, }; @@ -168,7 +193,7 @@ pub fn SparseSet(comptime T: type) type { test "SparseSet basic operations" { const allocator = std.testing.allocator; - var set = try SparseSet(u64).init(allocator, 1000, 16); + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); defer set.deinit(); // Insert @@ -203,10 +228,10 @@ test "SparseSet basic operations" { try std.testing.expectEqual(@as(u64, 855), sum); // 555 + 300 } -test "SparseSet iteration order stable after remove" { +test "SparseSet iteration after remove is correct" { const allocator = std.testing.allocator; - var set = try SparseSet(u32).init(allocator, 100, 16); + var set = try SparseSet(u32, u32).init(allocator, 100, 16); defer set.deinit(); try set.put(1, 10); @@ -224,3 +249,23 @@ test "SparseSet iteration order stable after remove" { } try std.testing.expectEqual(@as(u32, 80), sum); // 10 + 30 + 40 } + +test "SparseSet with different key types" { + const allocator = std.testing.allocator; + + // Test with u8 keys (256 max keys) + var set_u8 = try SparseSet(u8, []const u8).init(allocator, 256, 4); + defer set_u8.deinit(); + + try set_u8.put(0, "zero"); + try set_u8.put(255, "max"); + try std.testing.expectEqualStrings("zero", set_u8.get(0).?); + try std.testing.expectEqualStrings("max", set_u8.get(255).?); + + // Test with u16 keys + var set_u16 = try SparseSet(u16, f32).init(allocator, 1000, 4); + defer set_u16.deinit(); + + try set_u16.put(500, 3.14); + try std.testing.expectEqual(@as(?f32, 3.14), set_u16.get(500)); +} diff --git a/src/z_index_buckets.zig b/src/z_index_buckets.zig index 6f63a83..38e5ac8 100644 --- a/src/z_index_buckets.zig +++ b/src/z_index_buckets.zig @@ -1,30 +1,44 @@ //! Z-Index Bucket Storage //! -//! Maintains items sorted by z-index using 256 buckets (one per z-index level). +//! Maintains items sorted by z-index using configurable buckets. //! This eliminates the need to re-sort the entire list when z-indices change. //! //! Complexity: //! - Insert: O(1) amortized //! - Remove: O(bucket_size) - typically small due to clustered z-indices //! - Change z-index: O(bucket_size) -//! - Iteration: O(256 + n) ā‰ˆ O(n) +//! - Iteration: O(bucket_count + n) ā‰ˆ O(n) const std = @import("std"); -/// Z-index bucket storage for efficient ordered iteration by u8 key. -/// Generic over item type T. -pub fn ZIndexBuckets(comptime T: type) type { +/// Z-index bucket storage for efficient ordered iteration. +/// Generic over: +/// - T: item type +/// - ZIndexType: unsigned integer type for z-index (u8, u16, etc.) +/// +/// The number of buckets is determined by the max value of ZIndexType + 1. +/// For u8: 256 buckets, for u4: 16 buckets, etc. +pub fn ZIndexBuckets(comptime T: type, comptime ZIndexType: type) type { + comptime { + const info = @typeInfo(ZIndexType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("ZIndexType must be an unsigned integer type"); + } + } + + const bucket_count = std.math.maxInt(ZIndexType) + 1; + return struct { const Self = @This(); const Bucket = std.ArrayListUnmanaged(T); - buckets: [256]Bucket, + buckets: [bucket_count]Bucket, allocator: std.mem.Allocator, total_count: usize, pub fn init(allocator: std.mem.Allocator) Self { return Self{ - .buckets = [_]Bucket{.{}} ** 256, + .buckets = [_]Bucket{.{}} ** bucket_count, .allocator = allocator, .total_count = 0, }; @@ -37,14 +51,14 @@ pub fn ZIndexBuckets(comptime T: type) type { } /// Insert an item at the given z-index - pub fn insert(self: *Self, item: T, z: u8) !void { + pub fn insert(self: *Self, item: T, z: ZIndexType) !void { try self.buckets[z].append(self.allocator, item); self.total_count += 1; } /// Remove an item from the given z-index bucket using equality comparison. /// Returns true if the item was found and removed. - pub fn remove(self: *Self, item: T, z: u8) bool { + pub fn remove(self: *Self, item: T, z: ZIndexType) bool { const bucket = &self.buckets[z]; for (bucket.items, 0..) |existing, i| { if (eql(existing, item)) { @@ -58,7 +72,7 @@ pub fn ZIndexBuckets(comptime T: type) type { /// Change an item's z-index from old_z to new_z /// Returns error if the item was not found at old_z - pub fn changeZIndex(self: *Self, item: T, old_z: u8, new_z: u8) !void { + pub fn changeZIndex(self: *Self, item: T, old_z: ZIndexType, new_z: ZIndexType) !void { if (old_z == new_z) return; const removed = self.remove(item, old_z); if (!removed) { @@ -80,14 +94,14 @@ pub fn ZIndexBuckets(comptime T: type) type { self.total_count = 0; } - /// Iterator that yields items in z-index order (0 to 255) + /// Iterator that yields items in z-index order (0 to max) pub fn iterator(self: *const Self) Iterator { return Iterator.init(self); } pub const Iterator = struct { - buckets: *const [256]Bucket, - z: u16, + buckets: *const [bucket_count]Bucket, + z: usize, idx: usize, pub fn init(storage: *const Self) Iterator { @@ -101,7 +115,7 @@ pub fn ZIndexBuckets(comptime T: type) type { } pub fn next(self: *Iterator) ?T { - while (self.z < 256) { + while (self.z < bucket_count) { const bucket = &self.buckets[self.z]; if (self.idx < bucket.items.len) { const item = bucket.items[self.idx]; @@ -115,7 +129,7 @@ pub fn ZIndexBuckets(comptime T: type) type { } fn skipEmptyBuckets(self: *Iterator) void { - while (self.z < 256 and self.buckets[self.z].items.len == 0) { + while (self.z < bucket_count and self.buckets[self.z].items.len == 0) { self.z += 1; } } @@ -142,8 +156,9 @@ pub fn ZIndexBuckets(comptime T: type) type { /// Equality comparison for items fn eql(a: T, b: T) bool { - // Use eql method if available, otherwise use == - if (@hasDecl(T, "eql")) { + // Use eql method if available (for structs), otherwise use std.meta.eql + const info = @typeInfo(T); + if (info == .@"struct" and @hasDecl(T, "eql")) { return a.eql(b); } else { return std.meta.eql(a, b); @@ -156,7 +171,7 @@ pub fn ZIndexBuckets(comptime T: type) type { test "ZIndexBuckets basic operations" { const allocator = std.testing.allocator; - var buckets = ZIndexBuckets(u32).init(allocator); + var buckets = ZIndexBuckets(u32, u8).init(allocator); defer buckets.deinit(); // Insert @@ -195,7 +210,7 @@ test "ZIndexBuckets with struct items" { } }; - var buckets = ZIndexBuckets(Item).init(allocator); + var buckets = ZIndexBuckets(Item, u8).init(allocator); defer buckets.deinit(); try buckets.insert(.{ .id = 1, .name = "first" }, 10); @@ -207,3 +222,23 @@ test "ZIndexBuckets with struct items" { try std.testing.expect(buckets.remove(.{ .id = 1, .name = "" }, 10)); try std.testing.expectEqual(@as(usize, 1), buckets.count()); } + +test "ZIndexBuckets with smaller z-index type" { + const allocator = std.testing.allocator; + + // Only 16 buckets with u4 + var buckets = ZIndexBuckets(u32, u4).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 0); + try buckets.insert(200, 15); // max u4 value + try buckets.insert(300, 8); + + try std.testing.expectEqual(@as(usize, 3), buckets.count()); + + var iter = buckets.iterator(); + try std.testing.expectEqual(@as(?u32, 100), iter.next()); // z=0 + try std.testing.expectEqual(@as(?u32, 300), iter.next()); // z=8 + try std.testing.expectEqual(@as(?u32, 200), iter.next()); // z=15 + try std.testing.expectEqual(@as(?u32, null), iter.next()); +} From 89ebfa9de62381ef8a07297c99d8561093f56b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 17:00:22 -0300 Subject: [PATCH 08/13] fix: Update QuadTree test to use new config signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuadTree now requires a config parameter: QuadTree(T, .{}) šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/quad_tree_test.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/quad_tree_test.zig b/tests/quad_tree_test.zig index ea0693f..966bfe2 100644 --- a/tests/quad_tree_test.zig +++ b/tests/quad_tree_test.zig @@ -78,7 +78,7 @@ pub const RectangleSpec = struct { }; pub const QuadTreeSpec = struct { - const QT = QuadTree(u32); + const QT = QuadTree(u32, .{}); const Point = EntityPoint(u32); pub const init = struct { From af886517dfc9c9dc0643bab01a4c7b5cb9d51b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 17:10:27 -0300 Subject: [PATCH 09/13] fix: Address cursor[bot] PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- build.zig.zon | 2 +- src/a_star.zig | 14 +++++-- src/floyd_warshall.zig | 15 ++++---- src/floyd_warshall_optimized.zig | 66 ++++++++++++++++++++++++-------- src/z_index_buckets.zig | 23 +++++++++-- 5 files changed, 90 insertions(+), 30 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index ab72545..c261449 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,7 +7,7 @@ .dependencies = .{ .zspec = .{ .url = "https://github.com/apotema/zspec/archive/refs/heads/main.tar.gz", - .hash = "zspec-0.3.0-jaKLbbqZAgAB-MBfy6M3HKB3jSTQ-TsNW9OxpLhLlav5", + .hash = "zspec-0.6.0-jaKLbU5DAwAYa0jR0cnFGuIbSrxD6ojh-oyXj9ooyb3V", }, }, diff --git a/src/a_star.zig b/src/a_star.zig index fe785a3..7c98b3a 100644 --- a/src/a_star.zig +++ b/src/a_star.zig @@ -68,12 +68,20 @@ pub fn AStar(comptime WeightType: type) type { custom_heuristic: ?HeuristicFn, pub fn init(allocator: std.mem.Allocator) !Self { + var positions = try SparseSet(u32, Position).init(allocator, 1024, 64); + errdefer positions.deinit(); + + var ids = try SparseSet(u32, u32).init(allocator, 1024, 64); + errdefer ids.deinit(); + + const reverse_ids = try SparseSet(u32, u32).init(allocator, 1024, 64); + return .{ .allocator = allocator, .adjacency = .empty, - .positions = try SparseSet(u32, Position).init(allocator, 1024, 64), - .ids = try SparseSet(u32, u32).init(allocator, 1024, 64), - .reverse_ids = try SparseSet(u32, u32).init(allocator, 1024, 64), + .positions = positions, + .ids = ids, + .reverse_ids = reverse_ids, .heuristic_type = .euclidean, .custom_heuristic = null, }; diff --git a/src/floyd_warshall.zig b/src/floyd_warshall.zig index 6b880e8..204d8c4 100644 --- a/src/floyd_warshall.zig +++ b/src/floyd_warshall.zig @@ -90,16 +90,13 @@ pub fn FloydWarshall(comptime DistanceType: type) type { } /// Add an edge using entity ID mapping (auto-assigns internal indices) - pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: DistanceType) void { + /// Returns error.OutOfMemory if the internal hash map fails to allocate + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: DistanceType) !void { if (!self.ids.contains(u)) { - self.ids.put(u, self.newKey()) catch |err| { - std.log.err("Error inserting on map: {}\n", .{err}); - }; + try self.ids.put(u, self.newKey()); } if (!self.ids.contains(v)) { - self.ids.put(v, self.newKey()) catch |err| { - std.log.err("Error inserting on map: {}\n", .{err}); - }; + try self.ids.put(v, self.newKey()); } self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); } @@ -173,7 +170,11 @@ pub fn FloydWarshall(comptime DistanceType: type) type { // Initialize adjacency matrix and path matrix for (0..self.size) |_| { var list = RowList.init(self.allocator); + errdefer list.deinit(); + var row_path = RowList.init(self.allocator); + errdefer row_path.deinit(); + for (0..self.size) |_| { try list.append(0); try row_path.append(0); diff --git a/src/floyd_warshall_optimized.zig b/src/floyd_warshall_optimized.zig index 759c761..2eb65d7 100644 --- a/src/floyd_warshall_optimized.zig +++ b/src/floyd_warshall_optimized.zig @@ -197,14 +197,22 @@ pub fn FloydWarshallOptimized(comptime config: Config) type { // Reallocate if needed if (self.capacity < self.size) { + // Allocate both new arrays first (before freeing old ones) + // This ensures no state change if allocation fails + const new_dist = try self.allocator.alloc(u32, matrix_size); + errdefer self.allocator.free(new_dist); + + const new_next = try self.allocator.alloc(u32, matrix_size); + + // Now safe to free old arrays and update state if (self.dist.len > 0) { self.allocator.free(self.dist); } if (self.next.len > 0) { self.allocator.free(self.next); } - self.dist = try self.allocator.alloc(u32, matrix_size); - self.next = try self.allocator.alloc(u32, matrix_size); + self.dist = new_dist; + self.next = new_next; self.capacity = self.size; } @@ -352,40 +360,68 @@ pub fn FloydWarshallOptimized(comptime config: Config) type { const rows_per_thread = n / thread_count; const extra_rows = n % thread_count; - // Spawn worker threads + // Spawn worker threads - collect row assignments first const threads = self.allocator.alloc(std.Thread, thread_count - 1) catch { self.generateSimd(); return; }; defer self.allocator.free(threads); + const row_ranges = self.allocator.alloc([2]usize, thread_count) catch { + self.generateSimd(); + return; + }; + defer self.allocator.free(row_ranges); + + // Calculate all row assignments upfront var next_row: usize = 0; - for (0..thread_count - 1) |t| { + for (0..thread_count) |t| { const start = next_row; var end = start + rows_per_thread; if (t < extra_rows) end += 1; + row_ranges[t] = .{ start, end }; next_row = end; + } + // Try to spawn all worker threads + var spawned_count: usize = 0; + var spawn_failed = false; + + for (0..thread_count - 1) |t| { threads[t] = std.Thread.spawn(.{}, parallelWorker, .{ self, - start, - end, + row_ranges[t][0], + row_ranges[t][1], thread_count, sync_counters, }) catch { - // If thread spawn fails, fall back to SIMD - self.generateSimd(); - return; + spawn_failed = true; + break; }; + spawned_count += 1; + } + + if (spawn_failed) { + // Some threads spawned but not all - we can't use the parallel algorithm + // because it requires exactly thread_count participants for synchronization. + // Signal all sync counters to unblock spawned threads so they complete quickly. + for (0..n + 1) |i| { + _ = sync_counters[i].fetchAdd(@intCast(thread_count), .release); + } + // Join all spawned threads + for (threads[0..spawned_count]) |t| { + t.join(); + } + // Redo with SIMD (threads did partial work but it's incomplete) + self.generateSimd(); + return; } - // Main thread processes its portion - const main_start = next_row; - const main_end = n; - self.parallelWorkerImpl(main_start, main_end, thread_count, sync_counters); + // Main thread processes its portion (last entry in row_ranges) + self.parallelWorkerImpl(row_ranges[thread_count - 1][0], row_ranges[thread_count - 1][1], thread_count, sync_counters); - // Join all threads - for (threads) |t| { + // Join all spawned threads + for (threads[0..spawned_count]) |t| { t.join(); } } diff --git a/src/z_index_buckets.zig b/src/z_index_buckets.zig index 38e5ac8..84b95f3 100644 --- a/src/z_index_buckets.zig +++ b/src/z_index_buckets.zig @@ -71,14 +71,29 @@ pub fn ZIndexBuckets(comptime T: type, comptime ZIndexType: type) type { } /// Change an item's z-index from old_z to new_z - /// Returns error if the item was not found at old_z + /// Returns error if the item was not found at old_z or allocation fails pub fn changeZIndex(self: *Self, item: T, old_z: ZIndexType, new_z: ZIndexType) !void { if (old_z == new_z) return; - const removed = self.remove(item, old_z); - if (!removed) { + + // First verify item exists at old_z before making any changes + const bucket = &self.buckets[old_z]; + var found_index: ?usize = null; + for (bucket.items, 0..) |existing, i| { + if (eql(existing, item)) { + found_index = i; + break; + } + } + if (found_index == null) { return error.ItemNotFound; } - try self.insert(item, new_z); + + // Insert to new bucket first - if this fails, no state has changed + try self.buckets[new_z].append(self.allocator, item); + + // Now safe to remove from old bucket (insert succeeded) + _ = bucket.swapRemove(found_index.?); + // total_count stays the same (removed one, added one) } /// Get total number of items across all buckets From b8c2901c2ee66f3d632ab0028e8fa546d3243208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 17:21:52 -0300 Subject: [PATCH 10/13] refactor: Move all tests from src/ to tests/ folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/a_star.zig | 107 ------------------- src/floyd_warshall.zig | 70 ------------- src/floyd_warshall_optimized.zig | 107 ------------------- src/heuristics.zig | 35 ------- src/sparse_set.zig | 81 --------------- src/z_index_buckets.zig | 76 -------------- src/zon_coercion.zig | 50 --------- tests/a_star_test.zig | 119 ++++++++++++++++++++++ tests/floyd_warshall_optimized_test.zig | 130 ++++++++++++++++++++++++ tests/floyd_warshall_test.zig | 93 +++++++++++++++++ tests/heuristics_test.zig | 63 ++++++++++++ tests/root.zig | 7 ++ tests/sparse_set_test.zig | 127 +++++++++++++++++++++++ tests/z_index_buckets_test.zig | 112 ++++++++++++++++++++ tests/zon_coercion_test.zig | 70 +++++++++++++ 15 files changed, 721 insertions(+), 526 deletions(-) create mode 100644 tests/a_star_test.zig create mode 100644 tests/floyd_warshall_optimized_test.zig create mode 100644 tests/floyd_warshall_test.zig create mode 100644 tests/heuristics_test.zig create mode 100644 tests/sparse_set_test.zig create mode 100644 tests/z_index_buckets_test.zig create mode 100644 tests/zon_coercion_test.zig diff --git a/src/a_star.zig b/src/a_star.zig index 7c98b3a..a8ab887 100644 --- a/src/a_star.zig +++ b/src/a_star.zig @@ -385,110 +385,3 @@ pub fn AStar(comptime WeightType: type) type { } }; } - -// Tests -test "AStar basic pathfinding" { - const allocator = std.testing.allocator; - - var astar = try AStar(u64).init(allocator); - defer astar.deinit(); - - astar.resize(4); - try astar.clean(); - - // Set positions for heuristic - try astar.setNodePosition(0, .{ .x = 0, .y = 0 }); - try astar.setNodePosition(1, .{ .x = 1, .y = 0 }); - try astar.setNodePosition(2, .{ .x = 2, .y = 0 }); - try astar.setNodePosition(3, .{ .x = 3, .y = 0 }); - - // Create graph: 0 -> 1 -> 2 -> 3 - astar.addEdge(0, 1, 1); - astar.addEdge(1, 2, 1); - astar.addEdge(2, 3, 1); - - var path = std.array_list.Managed(u32).init(allocator); - defer path.deinit(); - - const cost = try astar.findPath(0, 3, &path); - - try std.testing.expectEqual(@as(?u64, 3), cost); - try std.testing.expectEqual(@as(usize, 4), path.items.len); - try std.testing.expectEqual(@as(u32, 0), path.items[0]); - try std.testing.expectEqual(@as(u32, 1), path.items[1]); - try std.testing.expectEqual(@as(u32, 2), path.items[2]); - try std.testing.expectEqual(@as(u32, 3), path.items[3]); -} - -test "AStar weighted shortest path" { - const allocator = std.testing.allocator; - - var astar = try AStar(u64).init(allocator); - defer astar.deinit(); - - astar.resize(4); - try astar.clean(); - astar.setHeuristic(.zero); // Use Dijkstra for testing weighted paths - - // Graph with two paths to node 3: - // 0 --5--> 1 --3--> 3 (total: 8) - // 0 --2--> 2 --2--> 3 (total: 4) <- shorter - astar.addEdge(0, 1, 5); - astar.addEdge(1, 3, 3); - astar.addEdge(0, 2, 2); - astar.addEdge(2, 3, 2); - - var path = std.array_list.Managed(u32).init(allocator); - defer path.deinit(); - - const cost = try astar.findPath(0, 3, &path); - - try std.testing.expectEqual(@as(?u64, 4), cost); - try std.testing.expectEqual(@as(usize, 3), path.items.len); - try std.testing.expectEqual(@as(u32, 0), path.items[0]); - try std.testing.expectEqual(@as(u32, 2), path.items[1]); - try std.testing.expectEqual(@as(u32, 3), path.items[2]); -} - -test "AStar no path" { - const allocator = std.testing.allocator; - - var astar = try AStar(u64).init(allocator); - defer astar.deinit(); - - astar.resize(4); - try astar.clean(); - - // Disconnected graph: 0 -> 1, 2 -> 3 (no path from 0 to 3) - astar.addEdge(0, 1, 1); - astar.addEdge(2, 3, 1); - - var path = std.array_list.Managed(u32).init(allocator); - defer path.deinit(); - - const cost = try astar.findPath(0, 3, &path); - - try std.testing.expectEqual(@as(?u64, null), cost); -} - -test "AStar with u32 weights" { - const allocator = std.testing.allocator; - - // Use u32 for smaller memory footprint - var astar = try AStar(u32).init(allocator); - defer astar.deinit(); - - astar.resize(3); - try astar.clean(); - astar.setHeuristic(.zero); - - astar.addEdge(0, 1, 10); - astar.addEdge(1, 2, 20); - - var path = std.array_list.Managed(u32).init(allocator); - defer path.deinit(); - - const cost = try astar.findPath(0, 2, &path); - - try std.testing.expectEqual(@as(?u32, 30), cost); -} diff --git a/src/floyd_warshall.zig b/src/floyd_warshall.zig index 204d8c4..d1474b4 100644 --- a/src/floyd_warshall.zig +++ b/src/floyd_warshall.zig @@ -218,73 +218,3 @@ pub fn FloydWarshall(comptime DistanceType: type) type { } }; } - -// Tests -test "FloydWarshall basic functionality" { - const allocator = std.testing.allocator; - - var fw = FloydWarshall(u64).init(allocator); - defer fw.deinit(); - - fw.resize(4); - try fw.clean(); - - // Create graph: 0 -> 1 -> 2 -> 3 - fw.addEdge(0, 1, 1); - fw.addEdge(1, 2, 1); - fw.addEdge(2, 3, 1); - - fw.generate(); - - // Check distances - try std.testing.expectEqual(@as(u64, 0), fw.value(0, 0)); - try std.testing.expectEqual(@as(u64, 1), fw.value(0, 1)); - try std.testing.expectEqual(@as(u64, 2), fw.value(0, 2)); - try std.testing.expectEqual(@as(u64, 3), fw.value(0, 3)); - - // Check next hops - try std.testing.expectEqual(@as(u32, 1), fw.next(0, 3)); - try std.testing.expectEqual(@as(u32, 2), fw.next(1, 3)); -} - -test "FloydWarshall weighted shortest path" { - const allocator = std.testing.allocator; - - var fw = FloydWarshall(u64).init(allocator); - defer fw.deinit(); - - fw.resize(4); - try fw.clean(); - - // Graph with two paths to node 3: - // 0 --5--> 1 --3--> 3 (total: 8) - // 0 --2--> 2 --2--> 3 (total: 4) <- shorter - fw.addEdge(0, 1, 5); - fw.addEdge(1, 3, 3); - fw.addEdge(0, 2, 2); - fw.addEdge(2, 3, 2); - - fw.generate(); - - // Should find shortest path - try std.testing.expectEqual(@as(u64, 4), fw.value(0, 3)); - try std.testing.expectEqual(@as(u32, 2), fw.next(0, 3)); // Goes through node 2 -} - -test "FloydWarshall with u32 distances" { - const allocator = std.testing.allocator; - - // Use u32 for smaller memory footprint - var fw = FloydWarshall(u32).init(allocator); - defer fw.deinit(); - - fw.resize(3); - try fw.clean(); - - fw.addEdge(0, 1, 10); - fw.addEdge(1, 2, 20); - - fw.generate(); - - try std.testing.expectEqual(@as(u32, 30), fw.value(0, 2)); -} diff --git a/src/floyd_warshall_optimized.zig b/src/floyd_warshall_optimized.zig index 2eb65d7..d6c8bef 100644 --- a/src/floyd_warshall_optimized.zig +++ b/src/floyd_warshall_optimized.zig @@ -541,110 +541,3 @@ pub const FloydWarshallScalar = FloydWarshallOptimized(.{ .parallel = false, .simd = false, }); - -// Unit tests -test "FloydWarshallOptimized basic functionality" { - const allocator = std.testing.allocator; - - var fw = FloydWarshallSimd.init(allocator); - defer fw.deinit(); - - fw.resize(4); - try fw.clean(); - - // Create graph: 0 -> 1 -> 2 -> 3 - fw.addEdge(0, 1, 1); - fw.addEdge(1, 2, 1); - fw.addEdge(2, 3, 1); - - fw.generate(); - - // Check distances - try std.testing.expectEqual(@as(u32, 0), fw.value(0, 0)); - try std.testing.expectEqual(@as(u32, 1), fw.value(0, 1)); - try std.testing.expectEqual(@as(u32, 2), fw.value(0, 2)); - try std.testing.expectEqual(@as(u32, 3), fw.value(0, 3)); - - // Check next hops - try std.testing.expectEqual(@as(u32, 1), fw.getNext(0, 3)); - try std.testing.expectEqual(@as(u32, 2), fw.getNext(1, 3)); -} - -test "FloydWarshallOptimized with entity mapping" { - const allocator = std.testing.allocator; - - var fw = FloydWarshallSimd.init(allocator); - defer fw.deinit(); - - fw.resize(4); - try fw.clean(); - - // Use entity IDs: 100 -> 200 -> 300 -> 400 - try fw.addEdgeWithMapping(100, 200, 1); - try fw.addEdgeWithMapping(200, 300, 1); - try fw.addEdgeWithMapping(300, 400, 1); - - fw.generate(); - - // Check paths exist - try std.testing.expect(fw.hasPathWithMapping(100, 400)); - try std.testing.expect(fw.hasPathWithMapping(100, 200)); - - // Check distances - try std.testing.expectEqual(@as(u32, 1), fw.valueWithMapping(100, 200)); - try std.testing.expectEqual(@as(u32, 3), fw.valueWithMapping(100, 400)); - - // Check next hops - try std.testing.expectEqual(@as(u32, 200), fw.nextWithMapping(100, 400)); -} - -test "FloydWarshallOptimized weighted shortest path" { - const allocator = std.testing.allocator; - - var fw = FloydWarshallSimd.init(allocator); - defer fw.deinit(); - - fw.resize(4); - try fw.clean(); - - // Graph with two paths to node 3: - // 0 --5--> 1 --3--> 3 (total: 8) - // 0 --2--> 2 --2--> 3 (total: 4) <- shorter - fw.addEdge(0, 1, 5); - fw.addEdge(1, 3, 3); - fw.addEdge(0, 2, 2); - fw.addEdge(2, 3, 2); - - fw.generate(); - - // Should find shortest path - try std.testing.expectEqual(@as(u32, 4), fw.value(0, 3)); - try std.testing.expectEqual(@as(u32, 2), fw.getNext(0, 3)); // Goes through node 2 -} - -test "FloydWarshallOptimized path reconstruction" { - const allocator = std.testing.allocator; - - var fw = FloydWarshallSimd.init(allocator); - defer fw.deinit(); - - fw.resize(4); - try fw.clean(); - - try fw.addEdgeWithMapping(10, 20, 1); - try fw.addEdgeWithMapping(20, 30, 1); - try fw.addEdgeWithMapping(30, 40, 1); - - fw.generate(); - - var path = std.ArrayListUnmanaged(u32){}; - defer path.deinit(allocator); - - try fw.setPathWithMappingUnmanaged(allocator, &path, 10, 40); - - try std.testing.expectEqual(@as(usize, 4), path.items.len); - try std.testing.expectEqual(@as(u32, 10), path.items[0]); - try std.testing.expectEqual(@as(u32, 20), path.items[1]); - try std.testing.expectEqual(@as(u32, 30), path.items[2]); - try std.testing.expectEqual(@as(u32, 40), path.items[3]); -} diff --git a/src/heuristics.zig b/src/heuristics.zig index c631b6c..808d5c2 100644 --- a/src/heuristics.zig +++ b/src/heuristics.zig @@ -101,38 +101,3 @@ pub fn octile(a: Position, b: Position) f32 { const dy = @abs(b.y - a.y); return @max(dx, dy) + SQRT2_MINUS_1 * @min(dx, dy); } - -// Tests -test "euclidean distance" { - const a = Position{ .x = 0, .y = 0 }; - const b = Position{ .x = 3, .y = 4 }; - try std.testing.expectApproxEqAbs(@as(f32, 5.0), euclidean(a, b), 0.001); -} - -test "manhattan distance" { - const a = Position{ .x = 0, .y = 0 }; - const b = Position{ .x = 3, .y = 4 }; - try std.testing.expectApproxEqAbs(@as(f32, 7.0), manhattan(a, b), 0.001); -} - -test "chebyshev distance" { - const a = Position{ .x = 0, .y = 0 }; - const b = Position{ .x = 3, .y = 4 }; - try std.testing.expectApproxEqAbs(@as(f32, 4.0), chebyshev(a, b), 0.001); -} - -test "octile distance" { - const a = Position{ .x = 0, .y = 0 }; - const b = Position{ .x = 3, .y = 4 }; - // 4 + (sqrt(2)-1) * 3 = 4 + 0.414 * 3 = 5.243 - try std.testing.expectApproxEqAbs(@as(f32, 5.243), octile(a, b), 0.01); -} - -test "calculate with enum" { - const a = Position{ .x = 0, .y = 0 }; - const b = Position{ .x = 3, .y = 4 }; - - try std.testing.expectApproxEqAbs(@as(f32, 5.0), calculate(.euclidean, a, b), 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 7.0), calculate(.manhattan, a, b), 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 0.0), calculate(.zero, a, b), 0.001); -} diff --git a/src/sparse_set.zig b/src/sparse_set.zig index 8f262d8..a50faaa 100644 --- a/src/sparse_set.zig +++ b/src/sparse_set.zig @@ -188,84 +188,3 @@ pub fn SparseSet(comptime KeyType: type, comptime T: type) type { } }; } - -// Tests -test "SparseSet basic operations" { - const allocator = std.testing.allocator; - - var set = try SparseSet(u64, u64).init(allocator, 1000, 16); - defer set.deinit(); - - // Insert - try set.put(5, 500); - try set.put(10, 1000); - try set.put(3, 300); - - // Get - try std.testing.expectEqual(@as(?u64, 500), set.get(5)); - try std.testing.expectEqual(@as(?u64, 1000), set.get(10)); - try std.testing.expectEqual(@as(?u64, 300), set.get(3)); - try std.testing.expectEqual(@as(?u64, null), set.get(999)); - - // Contains - try std.testing.expect(set.contains(5)); - try std.testing.expect(!set.contains(999)); - - // Update - try set.put(5, 555); - try std.testing.expectEqual(@as(?u64, 555), set.get(5)); - - // Remove - set.remove(10); - try std.testing.expect(!set.contains(10)); - try std.testing.expectEqual(@as(usize, 2), set.len()); - - // Iteration - var sum: u64 = 0; - for (set.values()) |v| { - sum += v; - } - try std.testing.expectEqual(@as(u64, 855), sum); // 555 + 300 -} - -test "SparseSet iteration after remove is correct" { - const allocator = std.testing.allocator; - - var set = try SparseSet(u32, u32).init(allocator, 100, 16); - defer set.deinit(); - - try set.put(1, 10); - try set.put(2, 20); - try set.put(3, 30); - try set.put(4, 40); - - // Remove middle element - set.remove(2); - - // Should still iterate all remaining - var sum: u32 = 0; - for (set.values()) |v| { - sum += v; - } - try std.testing.expectEqual(@as(u32, 80), sum); // 10 + 30 + 40 -} - -test "SparseSet with different key types" { - const allocator = std.testing.allocator; - - // Test with u8 keys (256 max keys) - var set_u8 = try SparseSet(u8, []const u8).init(allocator, 256, 4); - defer set_u8.deinit(); - - try set_u8.put(0, "zero"); - try set_u8.put(255, "max"); - try std.testing.expectEqualStrings("zero", set_u8.get(0).?); - try std.testing.expectEqualStrings("max", set_u8.get(255).?); - - // Test with u16 keys - var set_u16 = try SparseSet(u16, f32).init(allocator, 1000, 4); - defer set_u16.deinit(); - - try set_u16.put(500, 3.14); - try std.testing.expectEqual(@as(?f32, 3.14), set_u16.get(500)); -} diff --git a/src/z_index_buckets.zig b/src/z_index_buckets.zig index 84b95f3..10e6f28 100644 --- a/src/z_index_buckets.zig +++ b/src/z_index_buckets.zig @@ -181,79 +181,3 @@ pub fn ZIndexBuckets(comptime T: type, comptime ZIndexType: type) type { } }; } - -// Tests -test "ZIndexBuckets basic operations" { - const allocator = std.testing.allocator; - - var buckets = ZIndexBuckets(u32, u8).init(allocator); - defer buckets.deinit(); - - // Insert - try buckets.insert(100, 5); - try buckets.insert(200, 10); - try buckets.insert(300, 5); - - try std.testing.expectEqual(@as(usize, 3), buckets.count()); - - // Iterate in z-order - var iter = buckets.iterator(); - try std.testing.expectEqual(@as(?u32, 100), iter.next()); - try std.testing.expectEqual(@as(?u32, 300), iter.next()); // same z=5 - try std.testing.expectEqual(@as(?u32, 200), iter.next()); // z=10 - try std.testing.expectEqual(@as(?u32, null), iter.next()); - - // Remove - try std.testing.expect(buckets.remove(100, 5)); - try std.testing.expectEqual(@as(usize, 2), buckets.count()); - - // Change z-index - try buckets.changeZIndex(200, 10, 0); - iter.reset(); - try std.testing.expectEqual(@as(?u32, 200), iter.next()); // now at z=0 -} - -test "ZIndexBuckets with struct items" { - const allocator = std.testing.allocator; - - const Item = struct { - id: u32, - name: []const u8, - - pub fn eql(self: @This(), other: @This()) bool { - return self.id == other.id; - } - }; - - var buckets = ZIndexBuckets(Item, u8).init(allocator); - defer buckets.deinit(); - - try buckets.insert(.{ .id = 1, .name = "first" }, 10); - try buckets.insert(.{ .id = 2, .name = "second" }, 5); - - try std.testing.expectEqual(@as(usize, 2), buckets.count()); - - // Remove by id (uses eql method) - try std.testing.expect(buckets.remove(.{ .id = 1, .name = "" }, 10)); - try std.testing.expectEqual(@as(usize, 1), buckets.count()); -} - -test "ZIndexBuckets with smaller z-index type" { - const allocator = std.testing.allocator; - - // Only 16 buckets with u4 - var buckets = ZIndexBuckets(u32, u4).init(allocator); - defer buckets.deinit(); - - try buckets.insert(100, 0); - try buckets.insert(200, 15); // max u4 value - try buckets.insert(300, 8); - - try std.testing.expectEqual(@as(usize, 3), buckets.count()); - - var iter = buckets.iterator(); - try std.testing.expectEqual(@as(?u32, 100), iter.next()); // z=0 - try std.testing.expectEqual(@as(?u32, 300), iter.next()); // z=8 - try std.testing.expectEqual(@as(?u32, 200), iter.next()); // z=15 - try std.testing.expectEqual(@as(?u32, null), iter.next()); -} diff --git a/src/zon_coercion.zig b/src/zon_coercion.zig index 5a6f318..a40857d 100644 --- a/src/zon_coercion.zig +++ b/src/zon_coercion.zig @@ -313,53 +313,3 @@ fn MergedStructType(comptime BaseType: type, comptime OverridesType: type) type pub fn hasFields(comptime T: type) bool { return std.meta.fields(T).len > 0; } - -// Tests -test "coerceValue: simple struct with all fields" { - const Target = struct { x: i32, y: i32 }; - const result = coerceValue(Target, .{ .x = 10, .y = 20 }); - try std.testing.expectEqual(@as(i32, 10), result.x); - try std.testing.expectEqual(@as(i32, 20), result.y); -} - -test "coerceValue: nested struct" { - const Inner = struct { value: i32 }; - const Outer = struct { inner: Inner, name: []const u8 }; - const result = coerceValue(Outer, .{ .inner = .{ .value = 42 }, .name = "test" }); - try std.testing.expectEqual(@as(i32, 42), result.inner.value); -} - -test "coerceValue: union with enum literal" { - const State = union(enum) { idle, running: u32 }; - const result = coerceValue(State, .idle); - try std.testing.expect(result == .idle); -} - -test "coerceValue: union with payload" { - const Shape = union(enum) { circle: struct { radius: f32 }, rect: struct { w: f32, h: f32 } }; - const result = coerceValue(Shape, .{ .circle = .{ .radius = 5.0 } }); - try std.testing.expectEqual(@as(f32, 5.0), result.circle.radius); -} - -test "tupleToSlice: converts tuple to slice" { - const slice = tupleToSlice(i32, .{ 1, 2, 3 }); - try std.testing.expectEqual(@as(usize, 3), slice.len); - try std.testing.expectEqual(@as(i32, 1), slice[0]); - try std.testing.expectEqual(@as(i32, 2), slice[1]); - try std.testing.expectEqual(@as(i32, 3), slice[2]); -} - -test "buildStruct: with defaults" { - const Config = struct { width: i32 = 800, height: i32 = 600, title: []const u8 }; - const result = buildStruct(Config, .{ .title = "Test" }); - try std.testing.expectEqual(@as(i32, 800), result.width); - try std.testing.expectEqual(@as(i32, 600), result.height); -} - -test "mergeStructs: override values" { - const base = .{ .x = 10, .y = 20, .color = "red" }; - const overrides = .{ .color = "blue" }; - const result = mergeStructs(base, overrides); - try std.testing.expectEqual(@as(i32, 10), result.x); - try std.testing.expectEqualStrings("blue", result.color); -} diff --git a/tests/a_star_test.zig b/tests/a_star_test.zig new file mode 100644 index 0000000..04368d1 --- /dev/null +++ b/tests/a_star_test.zig @@ -0,0 +1,119 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const AStar = zig_utils.AStar; + +pub const AStarSpec = struct { + pub const basic_pathfinding = struct { + test "finds path in linear graph" { + const allocator = std.testing.allocator; + + var astar = try AStar(u64).init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + + // Set positions for heuristic + try astar.setNodePosition(0, .{ .x = 0, .y = 0 }); + try astar.setNodePosition(1, .{ .x = 1, .y = 0 }); + try astar.setNodePosition(2, .{ .x = 2, .y = 0 }); + try astar.setNodePosition(3, .{ .x = 3, .y = 0 }); + + // Create graph: 0 -> 1 -> 2 -> 3 + astar.addEdge(0, 1, 1); + astar.addEdge(1, 2, 1); + astar.addEdge(2, 3, 1); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try expect.equal(cost.?, 3); + try expect.equal(path.items.len, 4); + try expect.equal(path.items[0], 0); + try expect.equal(path.items[1], 1); + try expect.equal(path.items[2], 2); + try expect.equal(path.items[3], 3); + } + }; + + pub const weighted_shortest_path = struct { + test "finds shorter weighted path" { + const allocator = std.testing.allocator; + + var astar = try AStar(u64).init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + astar.setHeuristic(.zero); // Use Dijkstra for testing weighted paths + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + astar.addEdge(0, 1, 5); + astar.addEdge(1, 3, 3); + astar.addEdge(0, 2, 2); + astar.addEdge(2, 3, 2); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try expect.equal(cost.?, 4); + try expect.equal(path.items.len, 3); + try expect.equal(path.items[0], 0); + try expect.equal(path.items[1], 2); + try expect.equal(path.items[2], 3); + } + }; + + pub const no_path = struct { + test "returns null when no path exists" { + const allocator = std.testing.allocator; + + var astar = try AStar(u64).init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + + // Disconnected graph: 0 -> 1, 2 -> 3 (no path from 0 to 3) + astar.addEdge(0, 1, 1); + astar.addEdge(2, 3, 1); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try expect.toBeTrue(cost == null); + } + }; + + pub const different_weight_types = struct { + test "works with u32 weights" { + const allocator = std.testing.allocator; + + var astar = try AStar(u32).init(allocator); + defer astar.deinit(); + + astar.resize(3); + try astar.clean(); + astar.setHeuristic(.zero); + + astar.addEdge(0, 1, 10); + astar.addEdge(1, 2, 20); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 2, &path); + + try expect.equal(cost.?, 30); + } + }; +}; diff --git a/tests/floyd_warshall_optimized_test.zig b/tests/floyd_warshall_optimized_test.zig new file mode 100644 index 0000000..ca9c887 --- /dev/null +++ b/tests/floyd_warshall_optimized_test.zig @@ -0,0 +1,130 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const FloydWarshallSimd = zig_utils.FloydWarshallSimd; + +pub const FloydWarshallOptimizedSpec = struct { + pub const basic_functionality = struct { + test "computes distances for linear graph" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Create graph: 0 -> 1 -> 2 -> 3 + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.value(0, 0), 0); + try expect.equal(fw.value(0, 1), 1); + try expect.equal(fw.value(0, 2), 2); + try expect.equal(fw.value(0, 3), 3); + } + + test "computes next hops correctly" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.getNext(0, 3), 1); + try expect.equal(fw.getNext(1, 3), 2); + } + }; + + pub const entity_mapping = struct { + test "works with entity ID mapping" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Use entity IDs: 100 -> 200 -> 300 -> 400 + try fw.addEdgeWithMapping(100, 200, 1); + try fw.addEdgeWithMapping(200, 300, 1); + try fw.addEdgeWithMapping(300, 400, 1); + + fw.generate(); + + try expect.toBeTrue(fw.hasPathWithMapping(100, 400)); + try expect.toBeTrue(fw.hasPathWithMapping(100, 200)); + + try expect.equal(fw.valueWithMapping(100, 200), 1); + try expect.equal(fw.valueWithMapping(100, 400), 3); + + try expect.equal(fw.nextWithMapping(100, 400), 200); + } + }; + + pub const weighted_shortest_path = struct { + test "finds shorter path through intermediate node" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + fw.addEdge(0, 1, 5); + fw.addEdge(1, 3, 3); + fw.addEdge(0, 2, 2); + fw.addEdge(2, 3, 2); + + fw.generate(); + + try expect.equal(fw.value(0, 3), 4); + try expect.equal(fw.getNext(0, 3), 2); + } + }; + + pub const path_reconstruction = struct { + test "reconstructs path correctly" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + try fw.addEdgeWithMapping(10, 20, 1); + try fw.addEdgeWithMapping(20, 30, 1); + try fw.addEdgeWithMapping(30, 40, 1); + + fw.generate(); + + var path = std.ArrayListUnmanaged(u32){}; + defer path.deinit(allocator); + + try fw.setPathWithMappingUnmanaged(allocator, &path, 10, 40); + + try expect.equal(path.items.len, 4); + try expect.equal(path.items[0], 10); + try expect.equal(path.items[1], 20); + try expect.equal(path.items[2], 30); + try expect.equal(path.items[3], 40); + } + }; +}; diff --git a/tests/floyd_warshall_test.zig b/tests/floyd_warshall_test.zig new file mode 100644 index 0000000..f0dce37 --- /dev/null +++ b/tests/floyd_warshall_test.zig @@ -0,0 +1,93 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const FloydWarshall = zig_utils.FloydWarshall; + +pub const FloydWarshallSpec = struct { + pub const basic_functionality = struct { + test "computes distances for linear graph" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u64).init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Create graph: 0 -> 1 -> 2 -> 3 + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.value(0, 0), 0); + try expect.equal(fw.value(0, 1), 1); + try expect.equal(fw.value(0, 2), 2); + try expect.equal(fw.value(0, 3), 3); + } + + test "computes next hops correctly" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u64).init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.next(0, 3), 1); + try expect.equal(fw.next(1, 3), 2); + } + }; + + pub const weighted_shortest_path = struct { + test "finds shorter path through intermediate node" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u64).init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + fw.addEdge(0, 1, 5); + fw.addEdge(1, 3, 3); + fw.addEdge(0, 2, 2); + fw.addEdge(2, 3, 2); + + fw.generate(); + + try expect.equal(fw.value(0, 3), 4); + try expect.equal(fw.next(0, 3), 2); + } + }; + + pub const different_distance_types = struct { + test "works with u32 distances" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u32).init(allocator); + defer fw.deinit(); + + fw.resize(3); + try fw.clean(); + + fw.addEdge(0, 1, 10); + fw.addEdge(1, 2, 20); + + fw.generate(); + + try expect.equal(fw.value(0, 2), 30); + } + }; +}; diff --git a/tests/heuristics_test.zig b/tests/heuristics_test.zig new file mode 100644 index 0000000..044445a --- /dev/null +++ b/tests/heuristics_test.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const heuristics = zig_utils.heuristics; +const Position = zig_utils.Position; + +pub const HeuristicsSpec = struct { + pub const euclidean = struct { + test "calculates euclidean distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.euclidean(a, b); + + try expect.toBeTrue(@abs(result - 5.0) < 0.001); + } + }; + + pub const manhattan = struct { + test "calculates manhattan distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.manhattan(a, b); + + try expect.toBeTrue(@abs(result - 7.0) < 0.001); + } + }; + + pub const chebyshev = struct { + test "calculates chebyshev distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.chebyshev(a, b); + + try expect.toBeTrue(@abs(result - 4.0) < 0.001); + } + }; + + pub const octile = struct { + test "calculates octile distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.octile(a, b); + // 4 + (sqrt(2)-1) * 3 = 4 + 0.414 * 3 = 5.243 + + try expect.toBeTrue(@abs(result - 5.243) < 0.01); + } + }; + + pub const calculate_with_enum = struct { + test "calculates using heuristic enum" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + try expect.toBeTrue(@abs(heuristics.calculate(.euclidean, a, b) - 5.0) < 0.001); + try expect.toBeTrue(@abs(heuristics.calculate(.manhattan, a, b) - 7.0) < 0.001); + try expect.toBeTrue(@abs(heuristics.calculate(.zero, a, b) - 0.0) < 0.001); + } + }; +}; diff --git a/tests/root.zig b/tests/root.zig index b02674b..383ca63 100644 --- a/tests/root.zig +++ b/tests/root.zig @@ -3,6 +3,13 @@ const std = @import("std"); pub const vector_test = @import("vector_test.zig"); pub const quad_tree_test = @import("quad_tree_test.zig"); pub const sweep_and_prune_test = @import("sweep_and_prune_test.zig"); +pub const sparse_set_test = @import("sparse_set_test.zig"); +pub const z_index_buckets_test = @import("z_index_buckets_test.zig"); +pub const floyd_warshall_test = @import("floyd_warshall_test.zig"); +pub const floyd_warshall_optimized_test = @import("floyd_warshall_optimized_test.zig"); +pub const a_star_test = @import("a_star_test.zig"); +pub const heuristics_test = @import("heuristics_test.zig"); +pub const zon_coercion_test = @import("zon_coercion_test.zig"); test { std.testing.refAllDeclsRecursive(@This()); diff --git a/tests/sparse_set_test.zig b/tests/sparse_set_test.zig new file mode 100644 index 0000000..e33c07a --- /dev/null +++ b/tests/sparse_set_test.zig @@ -0,0 +1,127 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const SparseSet = zig_utils.SparseSet; + +pub const SparseSetSpec = struct { + pub const basic_operations = struct { + test "inserts and retrieves values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + try set.put(10, 1000); + try set.put(3, 300); + + try expect.equal(set.get(5).?, 500); + try expect.equal(set.get(10).?, 1000); + try expect.equal(set.get(3).?, 300); + try expect.toBeTrue(set.get(999) == null); + } + + test "contains returns correct values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + + try expect.toBeTrue(set.contains(5)); + try expect.toBeFalse(set.contains(999)); + } + + test "updates existing values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + try set.put(5, 555); + + try expect.equal(set.get(5).?, 555); + } + + test "removes values correctly" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + try set.put(10, 1000); + + set.remove(10); + + try expect.toBeFalse(set.contains(10)); + try expect.equal(set.len(), 1); + } + + test "iterates over values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 555); + try set.put(3, 300); + + var sum: u64 = 0; + for (set.values()) |v| { + sum += v; + } + try expect.equal(sum, 855); + } + }; + + pub const iteration_after_remove = struct { + test "iterates correctly after removing middle element" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u32, u32).init(allocator, 100, 16); + defer set.deinit(); + + try set.put(1, 10); + try set.put(2, 20); + try set.put(3, 30); + try set.put(4, 40); + + set.remove(2); + + var sum: u32 = 0; + for (set.values()) |v| { + sum += v; + } + try expect.equal(sum, 80); + } + }; + + pub const different_key_types = struct { + test "works with u8 keys" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u8, []const u8).init(allocator, 256, 4); + defer set.deinit(); + + try set.put(0, "zero"); + try set.put(255, "max"); + + try expect.toBeTrue(std.mem.eql(u8, set.get(0).?, "zero")); + try expect.toBeTrue(std.mem.eql(u8, set.get(255).?, "max")); + } + + test "works with u16 keys" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u16, f32).init(allocator, 1000, 4); + defer set.deinit(); + + try set.put(500, 3.14); + + try expect.equal(set.get(500).?, 3.14); + } + }; +}; diff --git a/tests/z_index_buckets_test.zig b/tests/z_index_buckets_test.zig new file mode 100644 index 0000000..32626b1 --- /dev/null +++ b/tests/z_index_buckets_test.zig @@ -0,0 +1,112 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const ZIndexBuckets = zig_utils.ZIndexBuckets; + +pub const ZIndexBucketsSpec = struct { + pub const basic_operations = struct { + test "inserts and counts items" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 5); + try buckets.insert(200, 10); + try buckets.insert(300, 5); + + try expect.equal(buckets.count(), 3); + } + + test "iterates in z-order" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 5); + try buckets.insert(200, 10); + try buckets.insert(300, 5); + + var iter = buckets.iterator(); + try expect.equal(iter.next().?, 100); + try expect.equal(iter.next().?, 300); + try expect.equal(iter.next().?, 200); + try expect.toBeTrue(iter.next() == null); + } + + test "removes items correctly" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 5); + try buckets.insert(200, 10); + + try expect.toBeTrue(buckets.remove(100, 5)); + try expect.equal(buckets.count(), 1); + } + + test "changes z-index" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(200, 10); + + try buckets.changeZIndex(200, 10, 0); + + var iter = buckets.iterator(); + try expect.equal(iter.next().?, 200); + } + }; + + pub const struct_items = struct { + test "works with struct items using eql method" { + const allocator = std.testing.allocator; + + const Item = struct { + id: u32, + name: []const u8, + + pub fn eql(self: @This(), other: @This()) bool { + return self.id == other.id; + } + }; + + var buckets = ZIndexBuckets(Item, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(.{ .id = 1, .name = "first" }, 10); + try buckets.insert(.{ .id = 2, .name = "second" }, 5); + + try expect.equal(buckets.count(), 2); + + try expect.toBeTrue(buckets.remove(.{ .id = 1, .name = "" }, 10)); + try expect.equal(buckets.count(), 1); + } + }; + + pub const smaller_z_index_type = struct { + test "works with u4 z-index type" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u4).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 0); + try buckets.insert(200, 15); + try buckets.insert(300, 8); + + try expect.equal(buckets.count(), 3); + + var iter = buckets.iterator(); + try expect.equal(iter.next().?, 100); + try expect.equal(iter.next().?, 300); + try expect.equal(iter.next().?, 200); + try expect.toBeTrue(iter.next() == null); + } + }; +}; diff --git a/tests/zon_coercion_test.zig b/tests/zon_coercion_test.zig new file mode 100644 index 0000000..970e4d4 --- /dev/null +++ b/tests/zon_coercion_test.zig @@ -0,0 +1,70 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const zon = zig_utils.zon; + +pub const ZonCoercionSpec = struct { + pub const coerceValue = struct { + test "coerces simple struct with all fields" { + const Target = struct { x: i32, y: i32 }; + const result = zon.coerceValue(Target, .{ .x = 10, .y = 20 }); + + try expect.equal(result.x, 10); + try expect.equal(result.y, 20); + } + + test "coerces nested struct" { + const Inner = struct { value: i32 }; + const Outer = struct { inner: Inner, name: []const u8 }; + const result = zon.coerceValue(Outer, .{ .inner = .{ .value = 42 }, .name = "test" }); + + try expect.equal(result.inner.value, 42); + } + + test "coerces union with enum literal" { + const State = union(enum) { idle, running: u32 }; + const result = zon.coerceValue(State, .idle); + + try expect.toBeTrue(result == .idle); + } + + test "coerces union with payload" { + const Shape = union(enum) { circle: struct { radius: f32 }, rect: struct { w: f32, h: f32 } }; + const result = zon.coerceValue(Shape, .{ .circle = .{ .radius = 5.0 } }); + + try expect.equal(result.circle.radius, 5.0); + } + }; + + pub const tupleToSlice = struct { + test "converts tuple to slice" { + const slice = zon.tupleToSlice(i32, .{ 1, 2, 3 }); + + try expect.equal(slice.len, 3); + try expect.equal(slice[0], 1); + try expect.equal(slice[1], 2); + try expect.equal(slice[2], 3); + } + }; + + pub const buildStruct = struct { + test "builds struct with defaults" { + const Config = struct { width: i32 = 800, height: i32 = 600, title: []const u8 }; + const result = zon.buildStruct(Config, .{ .title = "Test" }); + + try expect.equal(result.width, 800); + try expect.equal(result.height, 600); + } + }; + + pub const mergeStructs = struct { + test "merges structs with override values" { + const base = .{ .x = 10, .y = 20, .color = "red" }; + const overrides = .{ .color = "blue" }; + const result = zon.mergeStructs(base, overrides); + + try expect.equal(result.x, 10); + try expect.toBeTrue(std.mem.eql(u8, result.color, "blue")); + } + }; +}; From 8bdc9026e9ed97b9bc2ca90a26a58a033354f6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 17:31:08 -0300 Subject: [PATCH 11/13] fix: Address additional PR review comments (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/a_star.zig | 43 +++++++++++++++++++-------- src/floyd_warshall.zig | 51 ++++++++++++++++++++++---------- src/floyd_warshall_optimized.zig | 8 +++-- tests/a_star_test.zig | 22 +++++++------- 4 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/a_star.zig b/src/a_star.zig index a8ab887..e421ee4 100644 --- a/src/a_star.zig +++ b/src/a_star.zig @@ -132,6 +132,7 @@ pub fn AStar(comptime WeightType: type) type { } const new_id = self.newKey(); try self.ids.put(entity, new_id); + errdefer _ = self.ids.remove(entity); try self.reverse_ids.put(new_id, entity); return new_id; } @@ -160,19 +161,25 @@ pub fn AStar(comptime WeightType: type) type { } } + pub const AddEdgeError = error{ + InvalidVertex, + OutOfMemory, + }; + /// Add an edge between two vertices with given weight (direct index) - 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}); - }; + /// Returns error.InvalidVertex if u or v is out of bounds. + pub fn addEdge(self: *Self, u: u32, v: u32, w: WeightType) AddEdgeError!void { + if (u >= self.adjacency.items.len or v >= self.adjacency.items.len) { + return error.InvalidVertex; + } + try self.adjacency.items[u].append(self.allocator, .{ .to = v, .weight = w }); } /// Add an edge using entity ID mapping (auto-assigns internal indices) pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: WeightType) !void { const u_internal = try self.getOrCreateMapping(u); const v_internal = try self.getOrCreateMapping(v); - self.addEdge(u_internal, v_internal, w); + try self.addEdge(u_internal, v_internal, w); } /// Calculate heuristic between two internal vertex indices @@ -274,25 +281,31 @@ pub fn AStar(comptime WeightType: type) type { return null; // No path found } + pub const MappingError = error{ + CorruptedMapping, + OutOfMemory, + }; + /// Find path using entity ID mapping + /// Returns error.CorruptedMapping if internal nodes cannot be reverse-mapped. pub fn findPathWithMapping( self: *Self, source_entity: u32, dest_entity: u32, path: *std.array_list.Managed(u32), - ) !?WeightType { + ) MappingError!?WeightType { const source = self.ids.get(source_entity) orelse return null; const dest = self.ids.get(dest_entity) orelse return null; var internal_path = std.array_list.Managed(u32).init(self.allocator); defer internal_path.deinit(); - const cost = try self.findPath(source, dest, &internal_path); + const cost = self.findPath(source, dest, &internal_path) catch return error.OutOfMemory; if (cost != null) { path.clearRetainingCapacity(); for (internal_path.items) |internal_id| { - const entity = self.reverse_ids.get(internal_id) orelse continue; + const entity = self.reverse_ids.get(internal_id) orelse return error.CorruptedMapping; try path.append(entity); } } @@ -337,15 +350,21 @@ pub fn AStar(comptime WeightType: type) type { return result orelse INF; } + pub const PathError = error{ + PathNotFound, + CorruptedMapping, + OutOfMemory, + }; + /// Build the path from u to v and store in the provided ArrayList - pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u: u32, v: u32) !void { + /// Returns error.PathNotFound if no path exists between the nodes. + pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u: u32, v: u32) PathError!void { var path = std.array_list.Managed(u32).init(self.allocator); defer path.deinit(); const result = try self.findPathWithMapping(u, v, &path); if (result == null) { - std.log.err("No path found from {} to {}\n", .{ u, v }); - return; + return error.PathNotFound; } path_list.clearRetainingCapacity(); diff --git a/src/floyd_warshall.zig b/src/floyd_warshall.zig index d1474b4..765f844 100644 --- a/src/floyd_warshall.zig +++ b/src/floyd_warshall.zig @@ -34,6 +34,8 @@ pub fn FloydWarshall(comptime DistanceType: type) type { graph: GraphList, path: GraphList, ids: std.AutoHashMap(u32, u32), + /// Reverse mapping: internal index to entity ID for O(1) reverse lookups + reverse_ids: std.AutoHashMap(u32, u32), last_key: u32 = 0, allocator: std.mem.Allocator, @@ -42,6 +44,7 @@ pub fn FloydWarshall(comptime DistanceType: type) type { .graph = GraphList.init(allocator), .path = GraphList.init(allocator), .ids = std.AutoHashMap(u32, u32).init(allocator), + .reverse_ids = std.AutoHashMap(u32, u32).init(allocator), .allocator = allocator, }; } @@ -56,6 +59,7 @@ pub fn FloydWarshall(comptime DistanceType: type) type { self.graph.deinit(); self.path.deinit(); self.ids.deinit(); + self.reverse_ids.deinit(); } /// Generate a new internal key for entity mapping @@ -93,10 +97,16 @@ pub fn FloydWarshall(comptime DistanceType: type) type { /// Returns error.OutOfMemory if the internal hash map fails to allocate pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: DistanceType) !void { if (!self.ids.contains(u)) { - try self.ids.put(u, self.newKey()); + const key = self.newKey(); + try self.ids.put(u, key); + errdefer _ = self.ids.remove(u); + try self.reverse_ids.put(key, u); } if (!self.ids.contains(v)) { - try self.ids.put(v, self.newKey()); + const key = self.newKey(); + try self.ids.put(v, key); + errdefer _ = self.ids.remove(v); + try self.reverse_ids.put(key, v); } self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); } @@ -107,43 +117,46 @@ pub fn FloydWarshall(comptime DistanceType: type) type { } /// Build the path from u to v and store in the provided ArrayList + /// Returns error.PathNotFound if no path exists between the nodes. pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) !void { + const initial_len = path_list.items.len; var current = u_node; while (current != v_node) { try path_list.append(current); current = self.nextWithMapping(current, v_node); if (current == std.math.maxInt(u32)) { - std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); - return; + // Clear partial path data before returning error + path_list.shrinkRetainingCapacity(initial_len); + return error.PathNotFound; } } try path_list.append(v_node); } /// Build the path from u to v and store in the provided unmanaged ArrayList + /// Returns error.PathNotFound if no path exists between the nodes. pub fn setPathWithMappingUnmanaged(self: *Self, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) !void { + const initial_len = path_list.items.len; var current = u_node; while (current != v_node) { try path_list.append(allocator, current); current = self.nextWithMapping(current, v_node); if (current == std.math.maxInt(u32)) { - std.log.err("No path found from {} to {}\n", .{ u_node, v_node }); - return; + // Clear partial path data before returning error + path_list.shrinkRetainingCapacity(initial_len); + return error.PathNotFound; } } try path_list.append(allocator, v_node); } /// Get the next entity in the shortest path from u to v (using ID mapping) + /// Uses O(1) reverse lookup via reverse_ids map. 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); + const u_idx = self.ids.get(u) orelse return std.math.maxInt(u32); + const v_idx = self.ids.get(v) orelse return std.math.maxInt(u32); + const next_idx = self.next(u_idx, v_idx); + return self.reverse_ids.get(next_idx) orelse std.math.maxInt(u32); } /// Check if a path exists between two entities (using ID mapping) @@ -166,21 +179,27 @@ pub fn FloydWarshall(comptime DistanceType: type) type { self.graph.clearRetainingCapacity(); self.path.clearRetainingCapacity(); self.ids.clearRetainingCapacity(); + self.reverse_ids.clearRetainingCapacity(); // Initialize adjacency matrix and path matrix for (0..self.size) |_| { var list = RowList.init(self.allocator); - errdefer list.deinit(); + var list_appended = false; + errdefer if (!list_appended) list.deinit(); var row_path = RowList.init(self.allocator); - errdefer row_path.deinit(); + var row_path_appended = false; + errdefer if (!row_path_appended) row_path.deinit(); for (0..self.size) |_| { try list.append(0); try row_path.append(0); } try self.graph.append(list); + list_appended = true; + try self.path.append(row_path); + row_path_appended = true; } // Set initial values: 0 for self-loops, INF for no edge diff --git a/src/floyd_warshall_optimized.zig b/src/floyd_warshall_optimized.zig index d6c8bef..e843901 100644 --- a/src/floyd_warshall_optimized.zig +++ b/src/floyd_warshall_optimized.zig @@ -464,12 +464,16 @@ pub fn FloydWarshallOptimized(comptime config: Config) type { } // Process our assigned rows for this k iteration (with SIMD) + // Note: Reading row k during iteration k is safe because dist[k][j] and + // dist[i][k] don't change during iteration k (Floyd-Warshall property: + // dist[k][k] = 0, so min(dist[k][j], dist[k][k] + dist[k][j]) = dist[k][j]) for (start_row..end_row) |i| { self.processRowSimd(k, i); } - // If we own row k, signal that row k+1 is ready - // Each thread that owns row k signals all threads + // Signal that iteration k is complete for our rows. + // Only one thread owns each row k (ranges are non-overlapping). + // That thread signals by adding thread_count to unblock all waiting threads. if (k >= start_row and k < end_row) { _ = sync_counters[k + 1].fetchAdd(thread_count_u32, .release); } diff --git a/tests/a_star_test.zig b/tests/a_star_test.zig index 04368d1..54f38e9 100644 --- a/tests/a_star_test.zig +++ b/tests/a_star_test.zig @@ -21,9 +21,9 @@ pub const AStarSpec = struct { try astar.setNodePosition(3, .{ .x = 3, .y = 0 }); // Create graph: 0 -> 1 -> 2 -> 3 - astar.addEdge(0, 1, 1); - astar.addEdge(1, 2, 1); - astar.addEdge(2, 3, 1); + try astar.addEdge(0, 1, 1); + try astar.addEdge(1, 2, 1); + try astar.addEdge(2, 3, 1); var path = std.array_list.Managed(u32).init(allocator); defer path.deinit(); @@ -53,10 +53,10 @@ pub const AStarSpec = struct { // Graph with two paths to node 3: // 0 --5--> 1 --3--> 3 (total: 8) // 0 --2--> 2 --2--> 3 (total: 4) <- shorter - astar.addEdge(0, 1, 5); - astar.addEdge(1, 3, 3); - astar.addEdge(0, 2, 2); - astar.addEdge(2, 3, 2); + try astar.addEdge(0, 1, 5); + try astar.addEdge(1, 3, 3); + try astar.addEdge(0, 2, 2); + try astar.addEdge(2, 3, 2); var path = std.array_list.Managed(u32).init(allocator); defer path.deinit(); @@ -82,8 +82,8 @@ pub const AStarSpec = struct { try astar.clean(); // Disconnected graph: 0 -> 1, 2 -> 3 (no path from 0 to 3) - astar.addEdge(0, 1, 1); - astar.addEdge(2, 3, 1); + try astar.addEdge(0, 1, 1); + try astar.addEdge(2, 3, 1); var path = std.array_list.Managed(u32).init(allocator); defer path.deinit(); @@ -105,8 +105,8 @@ pub const AStarSpec = struct { try astar.clean(); astar.setHeuristic(.zero); - astar.addEdge(0, 1, 10); - astar.addEdge(1, 2, 20); + try astar.addEdge(0, 1, 10); + try astar.addEdge(1, 2, 20); var path = std.array_list.Managed(u32).init(allocator); defer path.deinit(); From 1c5bdeade3bd1812955833f9a83f824ad97804dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 17:36:50 -0300 Subject: [PATCH 12/13] chore: Update to Zig 0.15.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/ci.yml | 2 +- build.zig.zon | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37d321b..0ef3335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Zig uses: mlugg/setup-zig@v1 with: - version: 0.14.0 + version: 0.15.0 - name: Run tests run: zig build test diff --git a/build.zig.zon b/build.zig.zon index c261449..1cc0f69 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .zig_utils, .version = "0.5.0", .fingerprint = 0x6dc482caf73c4a75, - .minimum_zig_version = "0.14.0", + .minimum_zig_version = "0.15.0", .dependencies = .{ .zspec = .{ From 825b801b47fb4188a29e1038cde789a506ed49da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 8 Jan 2026 17:38:46 -0300 Subject: [PATCH 13/13] chore: Use Zig 0.15.2 for CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 4 ++-- build.zig.zon | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ef3335..4c8b2e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: - uses: actions/checkout@v4 - name: Setup Zig - uses: mlugg/setup-zig@v1 + uses: mlugg/setup-zig@v2 with: - version: 0.15.0 + version: 0.15.2 - name: Run tests run: zig build test diff --git a/build.zig.zon b/build.zig.zon index 1cc0f69..ec9538c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .zig_utils, .version = "0.5.0", .fingerprint = 0x6dc482caf73c4a75, - .minimum_zig_version = "0.15.0", + .minimum_zig_version = "0.15.2", .dependencies = .{ .zspec = .{