Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions rfc/prepared-endpoint-implementation.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions src/nav/components.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/nav/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
83 changes: 83 additions & 0 deletions tests/nav/prepared_endpoint_test.zig
Original file line number Diff line number Diff line change
@@ -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));
}
1 change: 1 addition & 0 deletions tests/nav/root.zig
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
Loading