diff --git a/rfc/prepared-endpoint-implementation.md b/rfc/prepared-endpoint-implementation.md new file mode 100644 index 0000000..ae52bd3 --- /dev/null +++ b/rfc/prepared-endpoint-implementation.md @@ -0,0 +1,60 @@ +# Prepared endpoint implementation: foundation + +Design: [Flying Platform RFC #1046](https://github.com/moca-tecnologia/flying-platform-labelle/pull/1046). + +This is an additive first slice, not the completed prepared matching API. +Existing navigation and game matching behavior is unchanged. + +## Implemented + +- Transient CMN publication status: pending, ready, or completed off-graph. +- Input stamp covering world revision, entity ID and generation, effective + position revision, and graph revision. Revision zero is unpublished; + entity generation zero and node ID zero are valid values. +- Invalidation retains the old node, distance and last-ready stamp for debugging. +- Publication rejects results whose captured inputs no longer match current + inputs. An obsolete result cannot overwrite a newer publication. Completed + off-graph work stays current until its inputs change. +- Explicit distance-table preparation and a const, finite-or-null node-table + query that never allocates, resolves positions, or rebuilds a dirty table. + It rejects absent tables, dirty graphs, removed/out-of-range nodes and + disconnected pairs. + +The node-table query is a low-level primitive. Bare node IDs do not establish +endpoint readiness or identity. Controller integration must validate current +registration and generation before calling it. It is not a replacement for +`Controller.distance` yet. + +Legacy CMN writers omit the new metadata and therefore create pending records. +Legacy consumers still use the old fields and retain their current behavior. +No current consumer treats these records as prepared endpoints. Do not migrate +matching consumers before the following integration is complete. + +## Next integration boundary + +1. Add shared effective-position revision propagation to engine position and + hierarchy mutation paths. `src/game/components_mixin.zig` has `setPosition`, + `setWorldPosition`, `setParent`, `setParentKeepTransform`, `removeParent` and + `detachFromParent`; generic component setters and direct ECS writes also need + coverage. Renderer dirty flags alone do not establish navigation freshness. + Propagate ancestor changes without walking parent chains in distance queries. +2. Integrate world/entity generations, graph registration and cost-table + publication in the controller. MovementNode entities use registration directly, + never self-CMNs. A stale registration cannot fall back to CMN data. +3. Reconcile all CMN writes (`targetPosition`, repath, arrivals) and replace the + destructive epoch sweep with invalidation retaining the debugging record. +4. Select domain participants through ECS views in stable rotating order with a + continuation cursor. Snapshot a bounded batch after movement, compute serially, + validate/publish together before matching. Include discovery cost in measurement. +5. Add entity-level prepared queries, then migrate game matching and diagnostics. + Missing matches remain normal retries. Known-node bandit placement uses the + same publication validation. Keep in-flight navigation policy explicit. +6. Measure serial preparation before any threaded execution. Threaded batches + must publish at the same barrier, with identical selection and late-batch waits. + +## Validation + +`zig build test spec`: unit tests and all 76 navigation specs pass. Five new +regression tests exercise freshness/publication and the read-only table contract. +This does not establish mutation coverage, rotating-batch fairness, game +integration or WASM performance; those are subsequent acceptance checks. diff --git a/src/nav/components.zig b/src/nav/components.zig index 81f2049..9603ec9 100644 --- a/src/nav/components.zig +++ b/src/nav/components.zig @@ -44,9 +44,68 @@ pub const MovementLink = struct { pub const ClosestMovementNode = struct { pub const save_policy: @import("labelle-core").SavePolicy = .transient; + /// Input identity captured by preparation. Revision zero is unpublished; + /// entity generation zero remains valid for ECSs whose first generation is 0. + pub const Stamp = struct { + world_revision: u64 = 0, + entity_id: u64 = 0, + entity_generation: u64 = 0, + position_revision: u64 = 0, + graph_revision: u64 = 0, + + pub fn valid(self: Stamp) bool { + return self.world_revision != 0 and self.position_revision != 0 and self.graph_revision != 0; + } + + pub fn eql(a: Stamp, b: Stamp) bool { + return a.world_revision == b.world_revision and a.entity_id == b.entity_id and a.entity_generation == b.entity_generation and + a.position_revision == b.position_revision and a.graph_revision == b.graph_revision; + } + }; + pub const Status = enum { pending, ready, off_graph }; + pub const Invalidation = enum { none, position, ancestor, parent, graph, world }; + pub const Resolution = struct { node_entity: u64, node_id: u32, distance: f32 }; + node_entity: u64 = 0, node_id: u32 = 0, distance: f32 = 0.0, + status: Status = .pending, + stamp: Stamp = .{}, + invalidation: Invalidation = .none, + /// Identifies the retained node data even after an off-graph publication. + last_ready_stamp: Stamp = .{}, + + /// Retain the last resolution for debugging; never use it while pending. + pub fn invalidate(self: *ClosestMovementNode, reason: Invalidation) void { + self.status = .pending; + self.invalidation = reason; + } + + pub fn current(self: ClosestMovementNode, inputs: Stamp) bool { + return self.status != .pending and inputs.valid() and self.stamp.eql(inputs); + } + + pub fn ready(self: ClosestMovementNode, inputs: Stamp) bool { + return self.status == .ready and self.current(inputs); + } + + /// Publication barrier primitive. The caller must validate node identity, + /// liveness and graph registration before supplying a non-null resolution. + /// Reject obsolete results without overwriting a newer published record. + pub fn publish(self: *ClosestMovementNode, snapshot: Stamp, current_inputs: Stamp, resolution: ?Resolution) bool { + if (!snapshot.valid() or !snapshot.eql(current_inputs)) return false; + if (resolution) |r| { + if (!@import("std").math.isFinite(r.distance) or r.distance < 0) return false; + self.node_entity = r.node_entity; + self.node_id = r.node_id; + self.distance = r.distance; + self.last_ready_stamp = snapshot; + } + self.stamp = snapshot; + self.status = if (resolution != null) .ready else .off_graph; + self.invalidation = .none; + return true; + } }; /// Per-entity walk speed in px/s, read by BOTH the graph walker and diff --git a/src/nav/engine.zig b/src/nav/engine.zig index 46112f5..edc0311 100644 --- a/src/nav/engine.zig +++ b/src/nav/engine.zig @@ -289,6 +289,25 @@ pub fn PathfinderWith( return fw.getDistance(from, to); } + /// Explicit preparation phase; never called by preparedNodeDistance. + /// False means the table could not be built. Legacy APIs retain their + /// existing lazy rebuild behavior. + pub fn prepareDistanceTable(self: *Self) bool { + self.ensureBuilt(); + return !self.graph.dirty and self.fw != null; + } + + /// Read-only table primitive, NOT an entity-readiness query. Controller + /// integration must validate generation-qualified endpoint identities + /// before passing node IDs. No allocation, repair or lazy table build. + pub fn preparedNodeDistance(self: *const Self, from: NodeId, to: NodeId) ?f32 { + if (self.graph.dirty or from >= self.graph.totalSlots() or to >= self.graph.totalSlots()) return null; + if (self.graph.isRemoved(from) or self.graph.isRemoved(to)) return null; + const fw = self.fw orelse return null; + const d = fw.getDistance(from, to); + return if (std.math.isFinite(d)) d else null; + } + /// Check if a path exists between two nodes. pub fn isReachable(self: *Self, from: NodeId, to: NodeId) bool { return self.distance(from, to) != INF; diff --git a/tests/nav/prepared_endpoint_test.zig b/tests/nav/prepared_endpoint_test.zig new file mode 100644 index 0000000..c2e8a75 --- /dev/null +++ b/tests/nav/prepared_endpoint_test.zig @@ -0,0 +1,83 @@ +const std = @import("std"); +const testing = std.testing; +const pathfinder = @import("pathfinder"); +const CMN = pathfinder.ClosestMovementNode; + +const initial = CMN.Stamp{ .world_revision = 1, .entity_id = 42, .entity_generation = 0, .position_revision = 1, .graph_revision = 1 }; +const node_zero = CMN.Resolution{ .node_entity = 7, .node_id = 0, .distance = 0 }; + +test "CMN defaults and legacy writes stay pending; node zero can be published" { + var cmn = CMN{ .node_entity = 7, .node_id = 0 }; + try testing.expect(!cmn.ready(initial)); + try testing.expect(!cmn.publish(.{}, .{}, node_zero)); + try testing.expect(cmn.publish(initial, initial, node_zero)); + try testing.expect(cmn.ready(initial)); + cmn.invalidate(.parent); + try testing.expect(!cmn.current(initial)); + try testing.expectEqual(@as(u64, 7), cmn.node_entity); + try testing.expectEqual(@as(u32, 0), cmn.node_id); + try testing.expectEqualDeep(initial, cmn.last_ready_stamp); + try testing.expectEqual(CMN.Invalidation.parent, cmn.invalidation); +} + +test "publication rejects changed identity, position, world or graph without overwriting newer data" { + inline for (std.meta.fields(CMN.Stamp)) |field| { + var changed = initial; + @field(changed, field.name) += 1; + var cmn = CMN{}; + try testing.expect(cmn.publish(initial, initial, node_zero)); + try testing.expect(!cmn.ready(changed)); + try testing.expect(cmn.publish(changed, changed, .{ .node_entity = 8, .node_id = 1, .distance = 2 })); + const newer = cmn; + try testing.expect(!cmn.publish(initial, changed, node_zero)); + try testing.expectEqualDeep(newer, cmn); + } +} + +test "off graph is completed work until inputs change and keeps last node's original stamp" { + var cmn = CMN{}; + try testing.expect(cmn.publish(initial, initial, node_zero)); + var moved = initial; + moved.position_revision += 1; + cmn.invalidate(.position); + try testing.expect(cmn.publish(moved, moved, null)); + try testing.expect(cmn.current(moved)); + try testing.expect(!cmn.ready(moved)); + try testing.expect(!cmn.current(initial)); + try testing.expectEqualDeep(initial, cmn.last_ready_stamp); + try testing.expectEqual(@as(u64, 7), cmn.node_entity); + moved.graph_revision += 1; + try testing.expect(!cmn.current(moved)); +} + +test "invalid resolution distances cannot publish a ready endpoint" { + for ([_]f32{ -1, std.math.inf(f32), std.math.nan(f32) }) |distance| { + var cmn = CMN{}; + var resolution = node_zero; + resolution.distance = distance; + try testing.expect(!cmn.publish(initial, initial, resolution)); + try testing.expect(!cmn.ready(initial)); + } +} + +test "prepared table queries do not build or repair and reject dirty, removed and disconnected nodes" { + var pf = pathfinder.PathfinderWith(u64, struct {}).init(testing.allocator, .{ .max_vertical_distance = 200, .max_horizontal_distance = 150 }); + defer pf.deinit(); + const a = try pf.addNode(.{ .x = 0, .y = 0 }, false); + const b = try pf.addNode(.{ .x = 20, .y = 0 }, false); + const isolated = try pf.addNode(.{ .x = 1000, .y = 1000 }, false); + try testing.expectEqual(null, pf.preparedNodeDistance(a, b)); + try testing.expect(pf.graph.dirty); + try testing.expectEqual(null, pf.fw); + try testing.expect(pf.prepareDistanceTable()); + try testing.expectEqual(@as(?f32, 20), pf.preparedNodeDistance(a, b)); + try testing.expectEqual(@as(?f32, 0), pf.preparedNodeDistance(a, a)); + try testing.expectEqual(null, pf.preparedNodeDistance(a, isolated)); + try testing.expectEqual(null, pf.preparedNodeDistance(a, 10000)); + pf.removeNode(b); + try testing.expectEqual(null, pf.preparedNodeDistance(a, a)); + try testing.expect(pf.graph.dirty); + try testing.expect(pf.prepareDistanceTable()); + try testing.expectEqual(null, pf.preparedNodeDistance(b, b)); + try testing.expectEqual(@as(?f32, 0), pf.preparedNodeDistance(a, a)); +} diff --git a/tests/nav/root.zig b/tests/nav/root.zig index d511545..b87f306 100644 --- a/tests/nav/root.zig +++ b/tests/nav/root.zig @@ -1,6 +1,7 @@ const std = @import("std"); test { + _ = @import("prepared_endpoint_test.zig"); _ = @import("graph_test.zig"); _ = @import("floyd_warshall_test.zig"); _ = @import("engine_test.zig");