From 6cbde4a93edd770d53943b749bfd31101331e28c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:14:03 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(ariadne):=20cvc::ariadne::holon=20?= =?UTF-8?q?=E2=80=94=20clock=20+=20avatar=20bundle=20on=20its=20own=20cloc?= =?UTF-8?q?k=5Fdriver=20(H1=20slice=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The holon object the roadmap §5h names: a server-authoritative, client-replicable bundle of ONE world_clock and the participant avatars on it. The server drives the clock and owns every avatar's authoritative pose; a client runs a replica that predicts its own avatar (slice 2) and reconciles to the authoritative state, and interpolates peers. cvc::ariadne::holon (inc/cvc/ariadne/holon.h, src/cvc/ariadne/holon.cpp): - Owns its world_clock AND cvc::clock_driver BY VALUE (not the app-global clock), so two holons in one process never fight over time. - Drives avatar integration as a clock_driver STEP observer and records the presentation alpha as a FRAME observer — the same step-then-present order tick_scene uses, so a frame observer never reads a pose the step has not yet produced. - Per participant: server integrates the latest intent into the authoritative pose over the quanta that fell due; a client-local avatar predicts + reconciles (slice 2's predicted_avatar); a peer is driven by authoritative samples and interpolated at the frame alpha. A client may set an intent only on its OWN avatar, never a peer — the proposed/ authoritative split (the verb path that enforces it server-side is slice 4). Deliberately the headless clock + avatar-pose core: the Jolt cvc::physics::world (replacing the linear avatar_sim behind the same step shape) and the cvcGL scene_realize / physics_scene_binding rendering are later slices, so this stays deterministically testable with no GL / state tree / transport. holon_test (5 cases, all green): each holon owns a distinct clock (advancing one never ticks another); a server + client holon reproduce slice 2's convergence through the holon API (perfect prediction holds bit-for-bit; a server +y override snaps the client off its wrong +x prediction); a client cannot drive a peer; a peer interpolates through the frame alpha and never overshoots. Stacks on #770 (slice 2). The scalar-key state wiring that connects this to replication lands with slice 4 (the intent/pose verb path). --- inc/cvc/ariadne/holon.h | 134 +++++++++++++++++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/holon.cpp | 141 +++++++++++++++++++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 +++ src/cvc/tests/holon_test.cpp | 148 +++++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/holon.h create mode 100644 src/cvc/ariadne/holon.cpp create mode 100644 src/cvc/tests/holon_test.cpp diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h new file mode 100644 index 000000000..bcc76f3c6 --- /dev/null +++ b/inc/cvc/ariadne/holon.h @@ -0,0 +1,134 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#ifndef __CVC_ARIADNE_HOLON_H__ +#define __CVC_ARIADNE_HOLON_H__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { + +// ---------------- +// cvc::ariadne::holon (H1 slice 3) +// ---------------- +// A holon is a server-authoritative, client-replicable bundle of ONE world_clock +// and the participant avatars that live on it. It is the object the roadmap's §5h +// names: the server drives the clock and owns every avatar's authoritative pose; a +// client runs a replica -- it PREDICTS its own avatar locally (slice 2) and +// RECONCILES to the authoritative state when it arrives, and INTERPOLATES peers. +// +// This slice is the headless clock + avatar-pose core. The holon owns its own +// world_clock and cvc::clock_driver BY VALUE (never the app-global clock, so two +// holons in one process never fight over time), and it drives avatar integration +// as a clock_driver STEP observer and presentation as a FRAME observer -- the same +// step-then-present shape tick_scene uses. The shared SCENE geometry, the Jolt +// cvc::physics::world (replacing the linear avatar_sim behind the same shape), and +// the cvcGL rendering of all this layer on top in later slices; none of that is +// here, so the model stays headless and deterministically testable. +// +// Authority (slice 4 adds the verb path): a client NEVER writes another avatar's +// pose; it writes only its own intent, the server validates/vetoes/overrides and +// is the sole writer of the authoritative pose, and the client respects that on +// reconcile. This class embodies that split -- set_intent on a client only drives +// its own (local) avatar's prediction; apply_authoritative is how the server's +// word reaches the client. + +enum class holon_role { server, client }; + +struct holon_config { + cvc::world_clock::config clock; // the holon's own quantum / scale / mode + double reconcile_tol = 0.05; // metres; the client's snap threshold (slice 2) +}; + +class holon { +public: + explicit holon(holon_role role, holon_config cfg = holon_config{}); + + holon_role role() const noexcept { return _role; } + cvc::world_clock &clock() noexcept { return _clock; } + const cvc::world_clock &clock() const noexcept { return _clock; } + cvc::clock_driver &driver() noexcept { return _driver; } + std::uint64_t tick() const { return _clock.tick(); } + + // Register a participant. `local` marks the CLIENT's own avatar (predicted); on a + // server every participant is authoritative, and on a client a non-local + // participant is an interpolated peer. + void add_participant(const std::string &pid, bool local = false, + const cvc::physics::body_state &start = cvc::physics::body_state{}); + void remove_participant(const std::string &pid); + bool has_participant(const std::string &pid) const; + + // Set a participant's latest intent. Server: the validated input it integrates + // into the authoritative pose. Client: only the LOCAL avatar's own input (a + // non-local pid is ignored -- a client cannot drive a peer). + void set_intent(const std::string &pid, const avatar_intent &in); + + // CLIENT ONLY: an authoritative state for `pid`, valid as of `server_tick`, + // arrived via replication. The local avatar reconciles to it (snap + replay on + // divergence); a peer takes it as its newest interpolation sample. No-op on a + // server holon or an unknown pid. + void apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, + std::uint64_t server_tick); + + // Advance the holon one frame. SERVER: drives its clock from wall time (wall_dt<0) + // or an injected dt, then integrates every authoritative avatar over the quanta + // that fell due. CLIENT: drives its clock to predict its own avatar over the due + // quanta; peers are sample-driven (apply_authoritative) and only re-interpolated. + void advance(double wall_dt = -1.0); + + // A participant's authoritative body_state: the server's integrated state, the + // client-local avatar's current prediction, or a peer's newest sample. Throws + // std::out_of_range for an unknown pid. + cvc::physics::body_state state(const std::string &pid) const; + + // A participant's DISPLAY pose at the current frame's interpolation alpha: + // predicted for the local avatar, interpolated between the last two samples for a + // peer (never extrapolated), the integrated pose on a server. + cvc::physics::pose display_pose(const std::string &pid) const; + +private: + struct participant { + bool local = false; + avatar_sim sim; // server authoritative integrator + avatar_intent intent; // latest intent (server input / client-local input) + predicted_avatar predicted; // client-local prediction + reconcile + bool have_newest = false, have_prev = false; + cvc::physics::body_state prev, newest; // peer interpolation samples + }; + + void on_step(const cvc::clock_driver::clock_frame &f); + void on_present(const cvc::clock_driver::clock_frame &f); + + holon_role _role; + holon_config _cfg; + cvc::world_clock _clock; // declared before _driver: _driver references it + cvc::clock_driver _driver; + std::map _participants; + double _alpha = 0.0; // the last frame's interpolation alpha, for display_pose +}; + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_HOLON_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index b295b3466..571ad8cb6 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,12 +1306,13 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/net/http_client.h) # the retained Widget tree, the Runtime + reconcile boundary, and direct # cvc::state binding, walking a pluggable Backend. It touches NO VTK/ImGui/GL — # the ImGui-over-VTK backend lives in cvcGL (src/cvcGL/ariadne). Roadmap §16.1. -list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/net_intrinsics.cpp ariadne/stream_intrinsics.cpp ariadne/conference/room_intrinsics.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp ariadne/term_graphics.cpp ariadne/tvision_backend.cpp ariadne/app_runtime.cpp ariadne/doc_args.cpp ariadne/host_cli.cpp ariadne/host_signals.cpp ariadne/requires.cpp ariadne/host_info.cpp ariadne/avatar_sim.cpp) +list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/net_intrinsics.cpp ariadne/stream_intrinsics.cpp ariadne/conference/room_intrinsics.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp ariadne/term_graphics.cpp ariadne/tvision_backend.cpp ariadne/app_runtime.cpp ariadne/doc_args.cpp ariadne/host_cli.cpp ariadne/host_signals.cpp ariadne/requires.cpp ariadne/host_info.cpp ariadne/avatar_sim.cpp ariadne/holon.cpp) list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h ../../inc/cvc/ariadne/ariadne.h ../../inc/cvc/ariadne/avatar_sim.h + ../../inc/cvc/ariadne/holon.h ../../inc/cvc/ariadne/app_runtime.h ../../inc/cvc/ariadne/doc_args.h ../../inc/cvc/ariadne/host_cli.h diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp new file mode 100644 index 000000000..d9e45c966 --- /dev/null +++ b/src/cvc/ariadne/holon.cpp @@ -0,0 +1,141 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#include +#include + +namespace cvc { +namespace ariadne { + +namespace { +// Observer order within a frame: integrate the sim (step phase), THEN record the +// presentation alpha (frame phase) -- matching tick_scene's advance -> follow -> +// present, so a frame observer never reads a pose the step observer has not yet +// produced this frame. +constexpr int kOrderIntegrate = 10; +constexpr int kOrderPresent = 10; +} // namespace + +holon::holon(holon_role role, holon_config cfg) + : _role(role), _cfg(cfg), _clock(cfg.clock), _driver(_clock) { + _driver.add_step_observer(kOrderIntegrate, + [this](const cvc::clock_driver::clock_frame &f) { on_step(f); }); + _driver.add_frame_observer(kOrderPresent, + [this](const cvc::clock_driver::clock_frame &f) { on_present(f); }); +} + +void holon::add_participant(const std::string &pid, bool local, + const cvc::physics::body_state &start) { + participant p; + p.local = local; + p.sim.set_state(start); + p.predicted = predicted_avatar(start); + p.newest = start; + p.prev = start; + _participants[pid] = p; +} + +void holon::remove_participant(const std::string &pid) { _participants.erase(pid); } + +bool holon::has_participant(const std::string &pid) const { + return _participants.find(pid) != _participants.end(); +} + +void holon::set_intent(const std::string &pid, const avatar_intent &in) { + auto it = _participants.find(pid); + if (it == _participants.end()) + return; + // A client may drive only its OWN (local) avatar; it cannot move a peer. + if (_role == holon_role::client && !it->second.local) + return; + it->second.intent = in; +} + +void holon::apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, + std::uint64_t server_tick) { + if (_role != holon_role::client) + return; // the server IS the authority; nothing to apply + auto it = _participants.find(pid); + if (it == _participants.end()) + return; + participant &p = it->second; + if (p.local) { + p.predicted.reconcile(auth, server_tick, _clock.fixed_dt(), _cfg.reconcile_tol); + } else { + // A peer: shift the interpolation window (prev <- old newest, newest <- auth). + if (!p.have_newest) { + p.newest = auth; + p.prev = auth; + p.have_newest = true; + } else { + p.prev = p.newest; + p.newest = auth; + p.have_prev = true; + } + } +} + +void holon::on_step(const cvc::clock_driver::clock_frame &f) { + const double dt = f.fixed_dt; + const int steps = f.step.steps; + for (auto &kv : _participants) { + participant &p = kv.second; + if (_role == holon_role::server) { + // Authoritative integration: advance by the latest intent over every quantum + // that fell due this frame. + for (int i = 0; i < steps; ++i) + p.sim.step(p.intent, dt); + } else if (p.local) { + // Client prediction: predict the local avatar one quantum at a time, tagging + // each with its tick so reconcile can replay the right suffix. + for (int i = 0; i < steps; ++i) { + const std::uint64_t tick = f.step.first_tick + 1 + static_cast(i); + p.predicted.predict(tick, p.intent, dt); + } + } + // Peers integrate nothing: they are driven by apply_authoritative samples. + } +} + +void holon::on_present(const cvc::clock_driver::clock_frame &f) { _alpha = f.alpha; } + +void holon::advance(double wall_dt) { _driver.advance(wall_dt); } + +cvc::physics::body_state holon::state(const std::string &pid) const { + const participant &p = _participants.at(pid); + if (_role == holon_role::server) + return p.sim.state(); + if (p.local) + return p.predicted.state(); + return p.newest; +} + +cvc::physics::pose holon::display_pose(const std::string &pid) const { + const participant &p = _participants.at(pid); + if (_role == holon_role::server) { + const cvc::physics::body_state &s = p.sim.state(); + return cvc::physics::pose{s.position, s.rotation}; + } + if (p.local) { + const cvc::physics::body_state &s = p.predicted.state(); + return cvc::physics::pose{s.position, s.rotation}; + } + // Peer: interpolate between the last two authoritative samples at this frame's + // alpha (interpolate_peer clamps alpha to [0,1), so it never overshoots). + return interpolate_peer(p.prev, p.newest, _alpha); +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index efb4348f7..74fd2cafb 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -108,6 +108,7 @@ add_executable(clock_driver_test clock_driver_test.cpp) add_executable(world_units_test world_units_test.cpp) add_executable(physics_world_test physics_world_test.cpp) add_executable(avatar_sim_test avatar_sim_test.cpp) +add_executable(holon_test holon_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) add_executable(physics_energy_test physics_energy_test.cpp) @@ -305,6 +306,7 @@ set(TEST_TARGETS world_units_test physics_world_test avatar_sim_test + holon_test physics_field_test physics_vehicle_test physics_energy_test @@ -1649,6 +1651,13 @@ target_link_libraries(avatar_sim_test GTest::gtest_main ) +target_link_libraries(holon_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2048,6 +2057,7 @@ target_compile_features(clock_driver_test PRIVATE cxx_std_17) target_compile_features(world_units_test PRIVATE cxx_std_17) target_compile_features(physics_world_test PRIVATE cxx_std_17) target_compile_features(avatar_sim_test PRIVATE cxx_std_17) +target_compile_features(holon_test PRIVATE cxx_std_17) target_compile_features(physics_field_test PRIVATE cxx_std_17) target_compile_features(physics_vehicle_test PRIVATE cxx_std_17) target_compile_features(physics_energy_test PRIVATE cxx_std_17) @@ -2504,6 +2514,7 @@ cvc_discover_tests(clock_driver_test) cvc_discover_tests(world_units_test) cvc_discover_tests(physics_world_test) cvc_discover_tests(avatar_sim_test) +cvc_discover_tests(holon_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) cvc_discover_tests(physics_energy_test) diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp new file mode 100644 index 000000000..a6aed8875 --- /dev/null +++ b/src/cvc/tests/holon_test.cpp @@ -0,0 +1,148 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. +*/ + +// Tests for cvc::ariadne::holon (H1 slice 3): the server-authoritative, +// client-replicable clock + avatar-pose bundle. The properties worth pinning: +// each holon owns its OWN clock (two never share the app clock); a server holon +// and a client holon stepped in-process reproduce slice 2's prediction / +// reconciliation through the holon API (perfect prediction converges bit-for-bit; +// a server override snaps the client); a client never drives a peer; and a peer is +// interpolated through the holon's frame alpha, never overshooting. + +#include +#include +#include +#include +#include +#include +#include + +using cvc::ariadne::avatar_intent; +using cvc::ariadne::holon; +using cvc::ariadne::holon_role; +using cvc::physics::body_state; +using cvc::physics::vec3; + +namespace { +std::uint64_t bits(double d) { + std::uint64_t u = 0; + std::memcpy(&u, &d, sizeof(u)); + return u; +} +void expect_pos_bit_eq(const vec3 &a, const vec3 &b) { + EXPECT_EQ(bits(a.x), bits(b.x)); + EXPECT_EQ(bits(a.y), bits(b.y)); + EXPECT_EQ(bits(a.z), bits(b.z)); +} +} // namespace + +// ── each holon owns its own clock ─────────────────────────────────────────── + +TEST(HolonTest, OwnsItsOwnClockNotTheAppClock) { + holon a(holon_role::server); + holon b(holon_role::server); + EXPECT_NE(&a.clock(), &b.clock()) << "each holon must own a distinct world_clock"; + const double dt = a.clock().fixed_dt(); + a.advance(dt); // advance a by one quantum + EXPECT_EQ(a.tick(), 1u); + EXPECT_EQ(b.tick(), 0u) << "advancing one holon must not tick another"; +} + +// ── server + client: perfect prediction converges bit-for-bit ─────────────── + +TEST(HolonTest, ServerClientPerfectPredictionConverges) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); // authoritative on the server + client.add_participant("A", /*local=*/true); // the client controls A, predicted + const double dt = server.clock().fixed_dt(); + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + + for (int frame = 1; frame <= 10; ++frame) { + server.set_intent("A", mv); + client.set_intent("A", mv); // the client predicts the same input it sends + server.advance(dt); + client.advance(dt); + // Replicate the authoritative state to the client and reconcile. + client.apply_authoritative("A", server.state("A"), server.tick()); + // A matching prediction holds (no snap) and is bit-identical to authoritative. + expect_pos_bit_eq(client.state("A").position, server.state("A").position); + } + EXPECT_GT(server.state("A").position.x, 0.0); // actually moved +} + +// ── server override snaps the client onto the authoritative trajectory ────── + +TEST(HolonTest, ServerOverrideReconcilesOnClient) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", /*local=*/true); + const double dt = server.clock().fixed_dt(); + const avatar_intent mv_client{vec3{2.0, 0.0, 0.0}}; // client thinks A moves +x + const avatar_intent mv_server{vec3{0.0, 2.0, 0.0}}; // server says A moves +y + + for (int frame = 1; frame <= 10; ++frame) { + client.set_intent("A", mv_client); + server.set_intent("A", mv_server); + client.advance(dt); + server.advance(dt); + } + // The client predicted +x; the authoritative state is +y. Reconcile snaps it. + client.apply_authoritative("A", server.state("A"), server.tick()); + expect_pos_bit_eq(client.state("A").position, server.state("A").position); + EXPECT_GT(client.state("A").position.y, 0.1) << "client adopted the server's +y motion"; + EXPECT_NEAR(client.state("A").position.x, 0.0, 1e-12) + << "the client's wrong +x was corrected away"; +} + +// ── a client cannot drive a peer ──────────────────────────────────────────── + +TEST(HolonTest, ClientCannotDriveAPeer) { + holon client(holon_role::client); + client.add_participant("peer", /*local=*/false); + client.set_intent("peer", avatar_intent{vec3{9.0, 9.0, 9.0}}); // should be ignored + const double dt = client.clock().fixed_dt(); + for (int i = 0; i < 5; ++i) + client.advance(dt); + // No authoritative samples arrived, so the peer stays at its start (origin); the + // ignored intent did not move it. + EXPECT_DOUBLE_EQ(client.state("peer").position.x, 0.0); +} + +// ── a peer is interpolated through the holon's frame alpha, no overshoot ──── + +TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { + holon client(holon_role::client); + client.add_participant("B", /*local=*/false); + const double dt = client.clock().fixed_dt(); + + body_state s1; + s1.position = vec3{0.0, 0.0, 0.0}; + body_state s2; + s2.position = vec3{10.0, 0.0, 0.0}; + client.apply_authoritative("B", s1, 1); + client.apply_authoritative("B", s2, 2); // window is now [s1, s2] + + // Advance half a quantum: no step falls due, but the frame alpha becomes ~0.5. + client.advance(0.5 * dt); + const double x_half = client.display_pose("B").position.x; + EXPECT_GT(x_half, 0.0); + EXPECT_LT(x_half, 10.0) << "interpolated strictly between the two samples"; + EXPECT_NEAR(x_half, 5.0, 1e-9); + + // A near-full-quantum frame approaches but never reaches/passes the newest sample. + holon c2(holon_role::client); + c2.add_participant("B", false); + c2.apply_authoritative("B", s1, 1); + c2.apply_authoritative("B", s2, 2); + c2.advance(0.999 * dt); + EXPECT_LT(c2.display_pose("B").position.x, 10.0) << "peer interpolation never overshoots newest"; +} From de5b1ac69ad21f16ccde931e9fc31def0a3d6f3c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:25:07 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(ariadne):=20holon=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20peer=20sample=20guard,=20explicit=20non-movable,=20?= =?UTF-8?q?replay/stale=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the holon surfaced six items; addressed here: - [med] the peer interpolation path ignored server_tick while the local path guards against stale/duplicate/reordered acks. A reordered peer sample would rewind the interpolation window (prev <- newer, newest <- older) and jerk the peer backward; a duplicate would collapse it. apply_authoritative's peer branch now applies the same monotonic guard (drop a sample whose server_tick <= the newest applied). Test: PeerIgnoresStaleOrDuplicateSample. - [med] the convergence tests always acked the client's latest tick, so reconcile drained the whole log and the replay-suffix path (and on_step's tick numbering) was never exercised — an off-by-one would pass. Added ClientReplaysInFlightInputsOnServerCorrection: a divergent authoritative state for a PAST tick snaps the client and replays its inputs after that tick; the result is checked against a reference, so a wrong tick/replay-suffix fails. - [low/med] ClientCannotDriveAPeer passed vacuously (a peer is never intent-driven). Reframed as PeerIsSampleDrivenNotIntentDriven, stating what it actually proves; the set_intent guard stays (defensive). - [low] the holon was only implicitly non-movable (via world_clock's mutex). Made it explicit (deleted copy/move) so a future movable-clock refactor can't silently re-enable a move and dangle the observers' this. - [low] dropped the dead `have_prev` field; the peer window now tracks `have_sample` + `last_sample_tick`. - [low] strengthened the peer no-overshoot assertion with a lower bound (advanced most of the way, not frozen). No memory-safety/crash bugs were found. holon_test now 7 cases, green. --- inc/cvc/ariadne/holon.h | 18 +++++++++-- src/cvc/ariadne/holon.cpp | 16 ++++++--- src/cvc/tests/holon_test.cpp | 63 ++++++++++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h index bcc76f3c6..b83fac1be 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -65,6 +65,16 @@ class holon { public: explicit holon(holon_role role, holon_config cfg = holon_config{}); + // Non-copyable and non-movable: the clock_driver observers capture `this` and + // _driver holds a reference to _clock, so a moved holon would dangle both. This + // is already implicit (world_clock's mutex deletes the defaults), but the + // explicit deletes pin the invariant against a future refactor that makes the + // clock movable. + holon(const holon &) = delete; + holon &operator=(const holon &) = delete; + holon(holon &&) = delete; + holon &operator=(holon &&) = delete; + holon_role role() const noexcept { return _role; } cvc::world_clock &clock() noexcept { return _clock; } const cvc::world_clock &clock() const noexcept { return _clock; } @@ -113,8 +123,12 @@ class holon { avatar_sim sim; // server authoritative integrator avatar_intent intent; // latest intent (server input / client-local input) predicted_avatar predicted; // client-local prediction + reconcile - bool have_newest = false, have_prev = false; - cvc::physics::body_state prev, newest; // peer interpolation samples + // peer interpolation: the last two authoritative samples + a monotonic guard + // (mirroring predicted_avatar's stale/duplicate-ack rejection) so a reordered + // or duplicate peer sample never rewinds or freezes the interpolation window. + bool have_sample = false; + std::uint64_t last_sample_tick = 0; + cvc::physics::body_state prev, newest; }; void on_step(const cvc::clock_driver::clock_frame &f); diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp index d9e45c966..05bae69ca 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -74,16 +74,22 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body if (p.local) { p.predicted.reconcile(auth, server_tick, _clock.fixed_dt(), _cfg.reconcile_tol); } else { - // A peer: shift the interpolation window (prev <- old newest, newest <- auth). - if (!p.have_newest) { + // A peer. Reject a stale/duplicate/reordered sample (server_tick <= the newest + // one applied), exactly as predicted_avatar does for the local path: otherwise + // an out-of-order sample would rewind the interpolation window (prev <- newer, + // newest <- older) and visibly jerk the peer backward, or a duplicate would + // collapse the window (prev == newest) and freeze it. + if (p.have_sample && server_tick <= p.last_sample_tick) + return; + if (!p.have_sample) { p.newest = auth; p.prev = auth; - p.have_newest = true; + p.have_sample = true; } else { - p.prev = p.newest; + p.prev = p.newest; // shift the window: prev <- old newest, newest <- auth p.newest = auth; - p.have_prev = true; } + p.last_sample_tick = server_tick; } } diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index a6aed8875..d6c176cdc 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -25,6 +25,7 @@ #include using cvc::ariadne::avatar_intent; +using cvc::ariadne::avatar_sim; using cvc::ariadne::holon; using cvc::ariadne::holon_role; using cvc::physics::body_state; @@ -103,20 +104,68 @@ TEST(HolonTest, ServerOverrideReconcilesOnClient) { << "the client's wrong +x was corrected away"; } -// ── a client cannot drive a peer ──────────────────────────────────────────── +// A server correction for a PAST tick snaps the client and replays the inputs it +// issued after that tick (slice 2's replay, driven through the holon). This also +// validates on_step's tick numbering: an off-by-one would replay the wrong suffix +// and the result would not match the reference. +TEST(HolonTest, ClientReplaysInFlightInputsOnServerCorrection) { + holon client(holon_role::client); + client.add_participant("A", /*local=*/true); + const double dt = client.clock().fixed_dt(); + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; // client predicts A moving +x + for (int frame = 1; frame <= 5; ++frame) { // predicts ticks 1..5 + client.set_intent("A", mv); + client.advance(dt); + } + // Authoritative state for tick 3 diverges (the server teleported A to +y). The + // client snaps to it and replays its in-flight inputs for ticks 4 and 5. + body_state auth3; + auth3.position = vec3{0.0, 100.0, 0.0}; + auth3.linear_velocity = mv.move; + client.apply_authoritative("A", auth3, /*server_tick=*/3); + + avatar_sim ref(auth3); // auth@3 + replay of the two post-tick-3 inputs + ref.step(mv, dt); + ref.step(mv, dt); + expect_pos_bit_eq(client.state("A").position, ref.state().position); + EXPECT_GT(client.state("A").position.y, 99.0) << "adopted the server's +y teleport"; + EXPECT_GT(client.state("A").position.x, 0.0) << "replayed the in-flight +x inputs on top"; +} -TEST(HolonTest, ClientCannotDriveAPeer) { +// ── a peer is sample-driven, not intent-driven ────────────────────────────── + +TEST(HolonTest, PeerIsSampleDrivenNotIntentDriven) { holon client(holon_role::client); client.add_participant("peer", /*local=*/false); - client.set_intent("peer", avatar_intent{vec3{9.0, 9.0, 9.0}}); // should be ignored + // set_intent on a non-local avatar is ignored (a client cannot drive a peer); + // a peer only moves via authoritative samples, so with none it stays at origin. + client.set_intent("peer", avatar_intent{vec3{9.0, 9.0, 9.0}}); const double dt = client.clock().fixed_dt(); for (int i = 0; i < 5; ++i) client.advance(dt); - // No authoritative samples arrived, so the peer stays at its start (origin); the - // ignored intent did not move it. EXPECT_DOUBLE_EQ(client.state("peer").position.x, 0.0); } +// A stale / duplicate / reordered peer sample (server_tick at or below the newest +// applied) is ignored -- the interpolation window must not rewind or collapse. +TEST(HolonTest, PeerIgnoresStaleOrDuplicateSample) { + holon client(holon_role::client); + client.add_participant("B", /*local=*/false); + body_state s4, s5; + s4.position = vec3{4.0, 0.0, 0.0}; + s5.position = vec3{5.0, 0.0, 0.0}; + client.apply_authoritative("B", s4, 4); + client.apply_authoritative("B", s5, 5); // window is [s4 -> s5] + // A reordered older sample (tick 4 again, different value) must be dropped. + body_state stale; + stale.position = vec3{-100.0, 0.0, 0.0}; + client.apply_authoritative("B", stale, 4); + const double dt = client.clock().fixed_dt(); + client.advance(0.5 * dt); // alpha ~0.5 over the UNCHANGED window [4 -> 5] + EXPECT_NEAR(client.display_pose("B").position.x, 4.5, 1e-9) + << "stale sample must not rewind the interpolation window"; +} + // ── a peer is interpolated through the holon's frame alpha, no overshoot ──── TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { @@ -144,5 +193,7 @@ TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { c2.apply_authoritative("B", s1, 1); c2.apply_authoritative("B", s2, 2); c2.advance(0.999 * dt); - EXPECT_LT(c2.display_pose("B").position.x, 10.0) << "peer interpolation never overshoots newest"; + const double x_near = c2.display_pose("B").position.x; + EXPECT_LT(x_near, 10.0) << "peer interpolation never overshoots newest"; + EXPECT_GT(x_near, 9.0) << "and it did advance most of the way (not frozen)"; }