From 72f454b1dde79b0cd5ddf900564c503d9536aeba Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 12:42:58 -0500 Subject: [PATCH 1/7] feat(ariadne): headless avatar predict/reconcile/interpolate (H1 slice 2) The one genuinely new mechanism H1 needs on top of the clock_driver + data() replication it already has: a participant's avatar is server-authoritative, so a client must move its own avatar without a round-trip (predict), correct when the authoritative state arrives (reconcile), and show peers lag-free without guessing ahead (interpolate). New cvc::ariadne (headless, engine-free, no GL / state tree / transport -- that plumbing is the holon, slice 3): - avatar_sim: a deterministic linear kinematic integrator over a fixed dt (position += velocity*dt, pure double arithmetic, no trig), so the same intents reproduce bit-for-bit -- what makes replay sound. A stub for the physics::world save/restore stepping slice 3 swaps in behind the same shape. - predicted_avatar: client prediction + server reconciliation. Applies local intents immediately, logs the unacknowledged ones; on an authoritative state for a past tick, keeps the prediction when it already matches within tolerance (no correction), else snaps to the authoritative state and REPLAYS the inputs issued after that tick, so in-flight player input is corrected-on-top, not dropped. - interpolate_peer: a non-local peer between its last two authoritative samples, alpha clamped to [0,1) so it never extrapolates past newest. avatar_sim_test (6 cases, all green): bit-reproducible stepping; a perfect prediction reconciles with no correction; prediction ahead of the server keeps inputs pending; a server override snaps + replays in-flight ticks (and really moves the avatar); a matching ack after a correction does not re-snap or jump backward; peer interpolation never overshoots (and clamps negative/NaN alpha to the previous sample). All comparisons are lib-step vs lib-step, so bit-equality holds without -ffp-contract=off. Branches off master (has #766 clock_driver + #767 H0). Independent of #768 (the physics codec); the MVP pose rides scalar keys, so this does not need the typed-object codec. Next: S3 wires this into a cvc::ariadne::holon on its own clock_driver. --- inc/cvc/ariadne/avatar_sim.h | 124 +++++++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/avatar_sim.cpp | 97 ++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 ++ src/cvc/tests/avatar_sim_test.cpp | 179 ++++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/avatar_sim.h create mode 100644 src/cvc/ariadne/avatar_sim.cpp create mode 100644 src/cvc/tests/avatar_sim_test.cpp diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h new file mode 100644 index 000000000..db66e335e --- /dev/null +++ b/inc/cvc/ariadne/avatar_sim.h @@ -0,0 +1,124 @@ +/* + 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_AVATAR_SIM_H__ +#define __CVC_ARIADNE_AVATAR_SIM_H__ + +#include +#include +#include // cvc::physics::body_state +#include // cvc::physics::pose / interpolate +#include // cvc::physics::vec3 +#include + +namespace cvc { +namespace ariadne { + +// ---------------- +// cvc::ariadne — headless avatar prediction / reconciliation (H1 slice 2) +// ---------------- +// The one genuinely new mechanism H1 needs on top of the clock + replication it +// already has: a participant's avatar is server-authoritative, but a client must +// move its OWN avatar without waiting a round-trip, so it PREDICTS locally and +// RECONCILES when the server's authoritative state arrives; peers it never +// predicts, only interpolates between their last two authoritative samples. +// +// This is deliberately headless and engine-free: pure double arithmetic over a +// fixed timestep, no cvc::physics::world (Jolt), no GL, no state tree, no +// transport. That isolates the loop's correctness -- which is what is subtle -- +// from the plumbing (the holon owns the clock_driver and the state wiring; that is +// slice 3+). The integrator is a linear stub; slice 3 swaps it for +// physics::world save/restore stepping behind the same predict/reconcile shape. + +// The per-tick input that drives an avatar: a desired world-space linear velocity. +// Purely linear (no orientation integration) so the stub needs no trig and is +// trivially bit-reproducible across platforms. +struct avatar_intent { + cvc::physics::vec3 move; // desired linear velocity, world metres/second +}; + +// A deterministic, headless kinematic avatar: integrate an avatar_intent over +// fixed-dt quanta into a cvc::physics::body_state. position += velocity*dt and +// velocity = move; rotation is carried unchanged. Pure double arithmetic, so the +// same intents from the same start at the same dt reproduce BIT-FOR-BIT -- the +// property prediction and reconciliation both rely on. +class avatar_sim { +public: + avatar_sim() = default; + explicit avatar_sim(const cvc::physics::body_state &start) : _state(start) {} + + const cvc::physics::body_state &state() const { return _state; } + void set_state(const cvc::physics::body_state &s) { _state = s; } + + // Advance exactly one fixed quantum from `in`. + void step(const avatar_intent &in, double dt); + +private: + cvc::physics::body_state _state; +}; + +// Client-side prediction + server reconciliation for ONE locally-controlled +// avatar on a fixed-dt clock. The client applies its own intents immediately +// (predict), keeping a log of inputs the server has not yet acknowledged; when an +// authoritative state for a past tick arrives it reconciles: if the state the +// client predicted at that tick already matches the authoritative one within `tol` +// metres, it keeps its prediction (no visible correction); otherwise it snaps to +// the authoritative state and REPLAYS the inputs issued after that tick, so the +// current prediction is corrected without discarding the player's in-flight input. +// +// Deterministic: with matching inputs and dt the replay reproduces the prediction +// exactly, so a correct prediction reconciles to a bit-identical state and costs +// nothing; only a genuine divergence (a server override, a dropped step) snaps. +class predicted_avatar { +public: + predicted_avatar() = default; + explicit predicted_avatar(const cvc::physics::body_state &start) : _sim(start) {} + + // Apply the local player's intent for `tick` and advance the prediction. Ticks + // are expected monotonically increasing (the clock's quanta). + void predict(std::uint64_t tick, const avatar_intent &in, double dt); + + // Reconcile against an authoritative state valid as of `server_tick` + // (expected monotonically non-decreasing across calls). Returns true if a hard + // correction (snap + replay) was applied, false if the prediction already held. + bool reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, + double dt, double tol); + + const cvc::physics::body_state &state() const { return _sim.state(); } + // Inputs issued but not yet acknowledged by the server (awaiting replay). + std::size_t pending() const { return _log.size(); } + +private: + struct logged { + std::uint64_t tick = 0; + avatar_intent in; + cvc::physics::body_state predicted; // state AFTER applying `in` at `tick` + }; + avatar_sim _sim; + std::deque _log; +}; + +// Interpolate a NON-local peer avatar between its last two authoritative samples, +// NEVER extrapolating: alpha is clamped to [0, 1), so the result never passes the +// newest sample (a one-step display lag is correct; extrapolation would overshoot +// on a late sample). Thin wrapper over cvc::physics::interpolate on the pose +// (position + rotation) of each body_state. +cvc::physics::pose interpolate_peer(const cvc::physics::body_state &prev, + const cvc::physics::body_state &newest, double alpha); + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_AVATAR_SIM_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index bf23c1a10..b295b3466 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,11 +1306,12 @@ 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) +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 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/app_runtime.h ../../inc/cvc/ariadne/doc_args.h ../../inc/cvc/ariadne/host_cli.h diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp new file mode 100644 index 000000000..938106d7e --- /dev/null +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -0,0 +1,97 @@ +/* + 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 { + +void avatar_sim::step(const avatar_intent &in, double dt) { + // Linear kinematic integration: the body moves at the requested velocity. Pure + // double arithmetic (vec3 + and *), no trig, so it is bit-reproducible. + _state.linear_velocity = in.move; + _state.position = _state.position + in.move * dt; + _state.active = true; +} + +void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, double dt) { + _sim.step(in, dt); + logged e; + e.tick = tick; + e.in = in; + e.predicted = _sim.state(); + _log.push_back(e); +} + +bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, + std::uint64_t server_tick, double dt, double tol) { + // What did we predict AT server_tick? (null if we never predicted that tick -- + // e.g. the server is ahead of us, or that input was already acknowledged.) + const logged *at = nullptr; + for (const auto &e : _log) + if (e.tick == server_tick) { + at = &e; + break; + } + + const double tol2 = tol * tol; + const auto within_tol = [&](const cvc::physics::body_state &a, + const cvc::physics::body_state &b) { + const cvc::physics::vec3 d = a.position - b.position; + return cvc::physics::dot(d, d) <= tol2; + }; + + if (at != nullptr && within_tol(at->predicted, authoritative)) { + // Prediction held: the server agrees with what we had at server_tick, so the + // inputs after it (which we already applied) still stand. Just drop the inputs + // the server has now acknowledged; keep predicting forward from our state. + while (!_log.empty() && _log.front().tick <= server_tick) + _log.pop_front(); + return false; + } + + // Diverged (a server override, a dropped/extra step, or we have no record of + // server_tick): adopt the authoritative state, then re-apply the inputs we + // issued AFTER server_tick so the player's in-flight motion is preserved on top + // of the correction rather than lost. + std::deque replay; + for (const auto &e : _log) + if (e.tick > server_tick) + replay.push_back(e); + + _sim.set_state(authoritative); + _log.clear(); + for (logged &e : replay) { + _sim.step(e.in, dt); + e.predicted = _sim.state(); + _log.push_back(e); + } + return true; +} + +cvc::physics::pose interpolate_peer(const cvc::physics::body_state &prev, + const cvc::physics::body_state &newest, double alpha) { + if (!(alpha >= 0.0)) // also catches NaN + alpha = 0.0; + if (alpha >= 1.0) + alpha = std::nextafter(1.0, 0.0); // never reach or pass the newest sample + const cvc::physics::pose a{prev.position, prev.rotation}; + const cvc::physics::pose b{newest.position, newest.rotation}; + return cvc::physics::interpolate(a, b, alpha); +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 3d563ae9a..efb4348f7 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -107,6 +107,7 @@ add_executable(world_clock_test world_clock_test.cpp) 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(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) @@ -303,6 +304,7 @@ set(TEST_TARGETS clock_driver_test world_units_test physics_world_test + avatar_sim_test physics_field_test physics_vehicle_test physics_energy_test @@ -1640,6 +1642,13 @@ target_link_libraries(physics_world_test GTest::gtest_main ) +target_link_libraries(avatar_sim_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2038,6 +2047,7 @@ target_compile_features(world_clock_test PRIVATE cxx_std_17) 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(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) @@ -2493,6 +2503,7 @@ cvc_discover_tests(world_clock_test) 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(physics_field_test) cvc_discover_tests(physics_vehicle_test) cvc_discover_tests(physics_energy_test) diff --git a/src/cvc/tests/avatar_sim_test.cpp b/src/cvc/tests/avatar_sim_test.cpp new file mode 100644 index 000000000..626bd7adf --- /dev/null +++ b/src/cvc/tests/avatar_sim_test.cpp @@ -0,0 +1,179 @@ +/* + 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 avatar prediction / reconciliation (H1 slice 2). The +// properties worth pinning are the ones a real netcode loop lives or dies on: +// deterministic integration (so replay reproduces prediction), a perfect +// prediction that reconciles with NO correction, a server override that snaps the +// client and REPLAYS its in-flight inputs rather than dropping them, and a peer +// interpolation that never extrapolates past the newest sample. +// +// Every comparison here is lib-step vs lib-step (both the server reference and the +// client go through cvc::ariadne::avatar_sim::step), so bit-equality holds without +// -ffp-contract=off: there is no test-side recomputation of the lib's inline math. + +#include +#include +#include +#include +#include +#include +#include +#include + +using cvc::ariadne::avatar_intent; +using cvc::ariadne::avatar_sim; +using cvc::ariadne::interpolate_peer; +using cvc::ariadne::predicted_avatar; +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; +} +// Bit-for-bit equality of two positions (exact; catches a 1-ulp replay drift). +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)); +} +constexpr double k120 = 1.0 / 120.0; + +// A varied, non-trivial intent for tick t (distinct components, not all equal). +avatar_intent intent_at(std::uint64_t t) { + const double f = static_cast(t); + return avatar_intent{vec3{0.1 * f, -0.05 * f, 0.2}}; +} +} // namespace + +// ── determinism ───────────────────────────────────────────────────────────── + +TEST(AvatarSimTest, StepIsBitReproducible) { + avatar_sim a, b; + for (std::uint64_t t = 1; t <= 200; ++t) { + const avatar_intent in = intent_at(t); + a.step(in, k120); + b.step(in, k120); + } + expect_pos_bit_eq(a.state().position, b.state().position); + expect_pos_bit_eq(a.state().linear_velocity, b.state().linear_velocity); +} + +// ── perfect prediction costs nothing ──────────────────────────────────────── + +TEST(AvatarSimTest, PerfectPredictionReconcilesWithNoCorrection) { + avatar_sim server; // authoritative + predicted_avatar client; // predicts the same inputs + for (std::uint64_t t = 1; t <= 50; ++t) { + const avatar_intent in = intent_at(t); + client.predict(t, in, k120); + server.step(in, k120); + const bool corrected = client.reconcile(server.state(), t, k120, 1e-9); + EXPECT_FALSE(corrected) << "a matching prediction must not snap (t=" << t << ")"; + expect_pos_bit_eq(client.state().position, server.state().position); + } + EXPECT_EQ(client.pending(), 0u) << "all inputs acknowledged"; +} + +// Client runs AHEAD of the server (prediction under latency): it predicts several +// ticks, the server acknowledges older ticks with matching inputs. No correction, +// and the unacknowledged inputs stay pending. +TEST(AvatarSimTest, PredictionAheadOfServerKeepsPendingInputs) { + avatar_sim server; + predicted_avatar client; + std::vector issued; + for (std::uint64_t t = 1; t <= 5; ++t) { + const avatar_intent in = intent_at(t); + issued.push_back(in); + client.predict(t, in, k120); // client is now 5 ticks ahead + } + // Server has only integrated up to tick 2 so far. + for (std::uint64_t t = 1; t <= 2; ++t) + server.step(issued[t - 1], k120); + const bool corrected = client.reconcile(server.state(), 2, k120, 1e-9); + EXPECT_FALSE(corrected); + EXPECT_EQ(client.pending(), 3u) << "inputs for ticks 3,4,5 remain unacknowledged"; +} + +// ── divergence snaps + replays, preserving in-flight input ────────────────── + +TEST(AvatarSimTest, ServerOverrideSnapsAndReplaysInFlightInputs) { + body_state start; // origin + predicted_avatar client(start); + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; // moving +x at 1 m/s + for (std::uint64_t t = 1; t <= 5; ++t) + client.predict(t, mv, k120); // client predicts x = 5*k120 + + // The server overrides the avatar at tick 3 (a teleport the client could not + // predict), with the same ongoing velocity. + body_state auth; + auth.position = vec3{100.0, 0.0, 0.0}; + auth.linear_velocity = mv.move; + const bool corrected = client.reconcile(auth, /*server_tick=*/3, k120, 0.01); + EXPECT_TRUE(corrected) << "a divergent authoritative state must snap"; + + // Expected: adopt auth@3, then REPLAY the in-flight inputs for ticks 4 and 5. + avatar_sim ref(auth); + ref.step(mv, k120); + ref.step(mv, k120); + expect_pos_bit_eq(client.state().position, ref.state().position); + EXPECT_EQ(client.pending(), 2u) << "ticks 4,5 are still in flight (not yet acked)"; + // The correction is real: the client moved from ~0.04 m to ~100 m. + EXPECT_GT(client.state().position.x, 99.0); +} + +// A later matching ack after a correction settles cleanly (no second snap). +TEST(AvatarSimTest, AckAfterCorrectionDoesNotReSnap) { + predicted_avatar client; + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + for (std::uint64_t t = 1; t <= 4; ++t) + client.predict(t, mv, k120); + body_state auth; + auth.position = vec3{50.0, 0.0, 0.0}; + auth.linear_velocity = mv.move; + ASSERT_TRUE(client.reconcile(auth, 2, k120, 0.01)); // snap at 2, replay 3,4 + const body_state after_snap = client.state(); + + // Now the server, having adopted the same post-snap trajectory, acks tick 3 + // with the state the client now holds for tick 3 (auth@2 + one replay step). + avatar_sim server(auth); + server.step(mv, k120); // server's tick-3 state + const bool corrected = client.reconcile(server.state(), 3, k120, 0.01); + EXPECT_FALSE(corrected) << "a matching ack after a correction must not re-snap"; + EXPECT_EQ(client.pending(), 1u) << "only tick 4 remains"; + // State advanced forward, never jumped backward. + EXPECT_GE(client.state().position.x, after_snap.position.x); +} + +// ── peer interpolation never extrapolates ─────────────────────────────────── + +TEST(AvatarSimTest, PeerInterpolationNeverOvershoots) { + body_state prev, newest; + prev.position = vec3{0.0, 0.0, 0.0}; + prev.rotation = cvc::physics::quat{1.0, 0.0, 0.0, 0.0}; + newest.position = vec3{10.0, 0.0, 0.0}; + newest.rotation = cvc::physics::quat{1.0, 0.0, 0.0, 0.0}; + + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, 0.0).position.x, 0.0); // alpha 0 = prev exactly + EXPECT_NEAR(interpolate_peer(prev, newest, 0.5).position.x, 5.0, 1e-12); + + // alpha >= 1 is clamped below 1: the result approaches but never reaches or + // passes the newest sample (no extrapolation / overshoot). + EXPECT_LT(interpolate_peer(prev, newest, 1.0).position.x, 10.0); + EXPECT_LT(interpolate_peer(prev, newest, 99.0).position.x, 10.0); + EXPECT_GT(interpolate_peer(prev, newest, 99.0).position.x, 9.0); + + // Negative / NaN alpha clamps to prev (no behind-the-start extrapolation). + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, -5.0).position.x, 0.0); + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, std::nan("")).position.x, 0.0); +} From 2de2bb2e7c991a4fa7f8dda2fe743319dec5d1f4 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 12:56:19 -0500 Subject: [PATCH 2/7] fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the predict/reconcile mechanism surfaced three items, all addressed here: - [med] predicted_avatar::reconcile had no guard against a non-monotonic server_tick. A reordered or duplicate ack (tick <= one already reconciled) failed the lookup, fell into the diverged branch, and snapped the avatar back to a stale authoritative state while dropping the inputs between the two acks (a visible backward rubber-band). Real datagram transports reorder/duplicate, so this was a live latent hazard for S3's caller. Fix: track the highest reconciled server_tick and no-op (return false, touch nothing) on any ack at or below it. Test: StaleOrDuplicateAckIsIgnored. - [med] the held-vs-snap tolerance was never exercised at a nonzero distance — every "no correction" test had a bit-identical prediction (d==0), so a tol/tol2 or threshold-direction regression would ship green. Test: ReconcileHoldsWithinToleranceAndSnapsBeyond (0.5*tol held, 2*tol snaps). - [low] the determinism test was tautological (two in-process runs of a pure function). Replaced with IntegratesConstantVelocityToClosedForm: a closed-form n*v*dt check (EXPECT_NEAR, not a bit-exact golden, so FMA contraction across toolchains can't flip it) that catches a formula regression, plus a retained purity assertion. Noted for S3 (dismissed as out-of-scope for the headless stub): the tolerance is position-only (fine while the stub integrates no rotation; revisit when physics::world adds orientation), and _log is unbounded if a caller never acks (a real server acks every tick). avatar_sim_test now 8 cases, all green. --- inc/cvc/ariadne/avatar_sim.h | 12 +++- src/cvc/ariadne/avatar_sim.cpp | 10 ++++ src/cvc/tests/avatar_sim_test.cpp | 92 ++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h index db66e335e..a35947323 100644 --- a/inc/cvc/ariadne/avatar_sim.h +++ b/inc/cvc/ariadne/avatar_sim.h @@ -90,9 +90,13 @@ class predicted_avatar { // are expected monotonically increasing (the clock's quanta). void predict(std::uint64_t tick, const avatar_intent &in, double dt); - // Reconcile against an authoritative state valid as of `server_tick` - // (expected monotonically non-decreasing across calls). Returns true if a hard - // correction (snap + replay) was applied, false if the prediction already held. + // Reconcile against an authoritative state valid as of `server_tick`. Returns + // true if a hard correction (snap + replay) was applied, false if the prediction + // already held OR the ack was ignored. A `server_tick` at or below the highest + // one already reconciled is a no-op (returns false, touches nothing): a + // duplicate or reordered/stale ack -- which real datagram transports produce -- + // must not snap the avatar back to an old authoritative state and discard + // progress, so only a strictly newer ack is acted on. bool reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, double dt, double tol); @@ -108,6 +112,8 @@ class predicted_avatar { }; avatar_sim _sim; std::deque _log; + std::uint64_t _last_acked = 0; // highest server_tick reconciled; guards non-monotonic acks + bool _acked = false; // whether _last_acked is meaningful yet }; // Interpolate a NON-local peer avatar between its last two authoritative samples, diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp index 938106d7e..0478d6f4a 100644 --- a/src/cvc/ariadne/avatar_sim.cpp +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -38,6 +38,16 @@ void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, doub bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, double dt, double tol) { + // A stale, duplicate or reordered ack (<= the highest tick we have already + // reconciled) is a no-op: acting on it would fail the lookup below (that tick + // was pruned), fall into the diverged branch, and snap the avatar back to an old + // authoritative state while discarding the inputs between the two acks. Only a + // strictly newer ack advances the reconciliation. + if (_acked && server_tick <= _last_acked) + return false; + _last_acked = server_tick; + _acked = true; + // What did we predict AT server_tick? (null if we never predicted that tick -- // e.g. the server is ahead of us, or that input was already acknowledged.) const logged *at = nullptr; diff --git a/src/cvc/tests/avatar_sim_test.cpp b/src/cvc/tests/avatar_sim_test.cpp index 626bd7adf..a5b1a28b1 100644 --- a/src/cvc/tests/avatar_sim_test.cpp +++ b/src/cvc/tests/avatar_sim_test.cpp @@ -56,17 +56,35 @@ avatar_intent intent_at(std::uint64_t t) { } } // namespace -// ── determinism ───────────────────────────────────────────────────────────── - -TEST(AvatarSimTest, StepIsBitReproducible) { +// ── integration correctness + determinism ────────────────────────────────── + +TEST(AvatarSimTest, IntegratesConstantVelocityToClosedForm) { + // Closed form: after n fixed steps at constant velocity v the position is n*v*dt + // (to the rounding of the iterated sum) and the velocity equals the last intent. + // This catches a formula regression (sign, scale, a missing dt) that a + // "same function twice -> same answer" comparison never could. EXPECT_NEAR, not a + // bit-exact golden, so an FMA-contraction difference across toolchains (the + // #747/#750 class) can't flip it red. + avatar_sim sim; + const avatar_intent v{vec3{3.0, -2.0, 0.5}}; + const int n = 10; + for (int i = 0; i < n; ++i) + sim.step(v, k120); + EXPECT_NEAR(sim.state().position.x, n * 3.0 * k120, 1e-12); + EXPECT_NEAR(sim.state().position.y, n * -2.0 * k120, 1e-12); + EXPECT_NEAR(sim.state().position.z, n * 0.5 * k120, 1e-12); + EXPECT_DOUBLE_EQ(sim.state().linear_velocity.x, 3.0); + EXPECT_TRUE(sim.state().active); + + // And it is a pure function of its inputs (no hidden/global state): a second + // instance fed the same varied intents reaches a bit-identical state -- the + // purity replay depends on. avatar_sim a, b; - for (std::uint64_t t = 1; t <= 200; ++t) { - const avatar_intent in = intent_at(t); - a.step(in, k120); - b.step(in, k120); + for (std::uint64_t t = 1; t <= 64; ++t) { + a.step(intent_at(t), k120); + b.step(intent_at(t), k120); } expect_pos_bit_eq(a.state().position, b.state().position); - expect_pos_bit_eq(a.state().linear_velocity, b.state().linear_velocity); } // ── perfect prediction costs nothing ──────────────────────────────────────── @@ -155,6 +173,64 @@ TEST(AvatarSimTest, AckAfterCorrectionDoesNotReSnap) { EXPECT_GE(client.state().position.x, after_snap.position.x); } +// The tolerance decides held-vs-snap: a sub-tolerance divergence is held (keep the +// prediction), a super-tolerance one snaps. Pins the squared-distance comparison +// (and its direction) at the boundary -- the d==0 "no correction" tests above +// exercise it only at zero distance. +TEST(AvatarSimTest, ReconcileHoldsWithinToleranceAndSnapsBeyond) { + const double tol = 0.1; + const avatar_intent still{vec3{0.0, 0.0, 0.0}}; // client predicts staying at origin + { + predicted_avatar c; + c.predict(1, still, k120); + body_state auth; + auth.position = vec3{0.5 * tol, 0.0, 0.0}; // within tolerance + EXPECT_FALSE(c.reconcile(auth, 1, k120, tol)) << "sub-tolerance divergence must be held"; + EXPECT_NEAR(c.state().position.x, 0.0, 1e-12) << "held keeps the prediction, not the nudge"; + } + { + predicted_avatar c; + c.predict(1, still, k120); + body_state auth; + auth.position = vec3{2.0 * tol, 0.0, 0.0}; // beyond tolerance + EXPECT_TRUE(c.reconcile(auth, 1, k120, tol)) << "super-tolerance divergence must snap"; + EXPECT_NEAR(c.state().position.x, 2.0 * tol, 1e-12) << "snapped to authoritative"; + } +} + +// A stale / duplicate / reordered ack (server_tick at or below one already +// reconciled) is a no-op: it must not snap the avatar back to an old authoritative +// state nor drop progress. Guards the non-monotonic-ack hazard. +TEST(AvatarSimTest, StaleOrDuplicateAckIsIgnored) { + predicted_avatar client; + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; + for (std::uint64_t t = 1; t <= 10; ++t) + client.predict(t, mv, k120); + + // An accepted, matching ack at tick 7 (built by the identical step path, so it is + // held, not snapped): prunes ticks 1..7, leaving 8,9,10 pending. + avatar_sim ref; + for (int i = 0; i < 7; ++i) + ref.step(mv, k120); + ASSERT_FALSE(client.reconcile(ref.state(), 7, k120, 1e-9)); + const body_state after7 = client.state(); + ASSERT_EQ(client.pending(), 3u); + + // A reordered ack for an older tick 5 (far from the current state) must be + // ignored -- no snap backward, no loss. + body_state stale; + stale.position = vec3{5.0 * k120, 0.0, 0.0}; + stale.linear_velocity = mv.move; + EXPECT_FALSE(client.reconcile(stale, 5, k120, 1e-9)) << "stale/reordered ack must be a no-op"; + expect_pos_bit_eq(client.state().position, after7.position); + EXPECT_EQ(client.pending(), 3u); + + // A duplicate of the most recent ack (tick 7) is likewise ignored. + EXPECT_FALSE(client.reconcile(stale, 7, k120, 1e-9)) << "duplicate ack must be a no-op"; + expect_pos_bit_eq(client.state().position, after7.position); + EXPECT_EQ(client.pending(), 3u); +} + // ── peer interpolation never extrapolates ─────────────────────────────────── TEST(AvatarSimTest, PeerInterpolationNeverOvershoots) { From 6cbde4a93edd770d53943b749bfd31101331e28c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:14:03 -0500 Subject: [PATCH 3/7] =?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 4/7] =?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)"; } From cb6c0a32b111fdee2d4dbd9f9e8ac3a5be1584bc Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:37:58 -0500 Subject: [PATCH 5/7] feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires cvc::ariadne::holon to the replicated conference room tree over plain SCALAR leaf keys, under the participant subtree the conference already uses (conf.rooms..participants., via room_participant_path): .pose.pos/orient/vel.{...} + .pose.server_tick server-written .intent.move.{x,y,z} client-written cvc::ariadne holon_sync.{h,cpp}: write_pose/read_pose and write_intent/read_intent. Scalar leaves (not a typed body_state data() object) are deliberate — they already journal + replicate through the existing "conf" interest filter with zero transport changes, and sidestep the data()-object work (§52). Each double is written round-trip-safe (%.17g, portable across toolchains, no std::to_chars FP dependency) so a reader recovers it exactly. Writes use the room tree's touch-before-value idiom so a fresh node's first write is not lost to the distributed-state attach race. Authority (the proposed/authoritative path pair): a client writes only its own .intent.*; the server reads every participant's .intent, integrates it into the holon, and is the SOLE writer of .pose + .server_tick; the client reads .pose read-only and reconciles. Writes go through a C++ host (never a DSL state-set, since ui.docs.* is excluded from replication). H1 is loopback-trusted sole-writer-by-convention; the connection<->pid admission binding is the §5e/mesh increment. holon_sync_test (4 cases, green): pose + intent scalar round-trip recover binary64 exactly; read returns false when absent; and an END-TO-END over a real two-shard state_transport_inproc — the client proposes intent, it replicates to the server, the server holon integrates it and writes the authoritative pose, that replicates back, and the client reconciles and tracks the authoritative avatar bit-for-bit across 8 frames. Stacks on #772 (slice 3). The remaining live-integration piece (a C++ room-set-intent verb + arisrv hosting the server holon + a per-room client pump) is the follow-on that the live two-client cyprty test exercises. --- inc/cvc/ariadne/holon_sync.h | 83 +++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/holon_sync.cpp | 130 +++++++++++++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 +++ src/cvc/tests/holon_sync_test.cpp | 143 ++++++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/holon_sync.h create mode 100644 src/cvc/ariadne/holon_sync.cpp create mode 100644 src/cvc/tests/holon_sync_test.cpp diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h new file mode 100644 index 000000000..c98559ac2 --- /dev/null +++ b/inc/cvc/ariadne/holon_sync.h @@ -0,0 +1,83 @@ +/* + 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_SYNC_H__ +#define __CVC_ARIADNE_HOLON_SYNC_H__ + +#include +#include +#include +#include +#include + +namespace cvc { + +class app; + +namespace ariadne { + +// ---------------- +// cvc::ariadne — holon <-> state-tree sync over SCALAR keys (H1 slice 4) +// ---------------- +// Connects a cvc::ariadne::holon to the replicated conference room tree over plain +// SCALAR leaf keys, under the participant subtree the conference already uses +// (conf.rooms..participants., via room_participant_path): +// +// .pose.pos.{x,y,z} authoritative, SERVER-written +// .pose.orient.{w,x,y,z} +// .pose.vel.lin.{x,y,z} +// .pose.vel.ang.{x,y,z} +// .pose.server_tick the tick the pose is valid at +// .intent.move.{x,y,z} proposed, CLIENT-written +// +// Scalar leaves (not a typed body_state data() object) are used on purpose: they +// already journal and replicate through the existing "conf" interest filter with +// zero transport changes, and they sidestep the data()-object replication work +// (DISTRIBUTED_STATE §52). Each double is written round-trip-safe (17 significant +// digits), so a reader recovers it to within far less than the reconcile +// tolerance. +// +// AUTHORITY (the proposed/authoritative path pair): a client writes only its own +// .intent.*; the server reads every participant's .intent, integrates it into the +// holon, and is the SOLE writer of .pose + .server_tick. A client reads .pose +// read-only and reconciles. Writes go through a C++ host (these helpers / a verb), +// never a DSL (state-set), because the document chroot ui.docs.* is excluded from +// replication -- the same reason room-join is a C++ verb. The connection<->pid +// admission binding that would make "sole writer" cryptographically enforced is +// the §5e/mesh increment (H2); H1 is loopback-trusted sole-writer-by-convention. + +// Write a participant's AUTHORITATIVE pose (server side): the body_state's pose + +// velocity leaves and the server_tick, each replicated (touch-before-value). +void write_pose(cvc::app &app, const std::string &room, const std::string &pid, + const cvc::physics::body_state &state, std::uint64_t server_tick); + +// Read a participant's authoritative pose. Returns false (leaving out_* untouched) +// if the pose subtree is absent or malformed; true with out_state / out_server_tick +// filled otherwise. +bool read_pose(cvc::app &app, const std::string &room, const std::string &pid, + cvc::physics::body_state &out_state, std::uint64_t &out_server_tick); + +// Write a participant's PROPOSED intent (client side -- its OWN avatar only). +void write_intent(cvc::app &app, const std::string &room, const std::string &pid, + const avatar_intent &intent); + +// Read a participant's proposed intent (server side). Returns false if absent. +bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, + avatar_intent &out_intent); + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_HOLON_SYNC_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index 571ad8cb6..0d5fd25cc 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,13 +1306,14 @@ 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 ariadne/holon.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 ariadne/holon_sync.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/holon_sync.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_sync.cpp b/src/cvc/ariadne/holon_sync.cpp new file mode 100644 index 000000000..1c16de303 --- /dev/null +++ b/src/cvc/ariadne/holon_sync.cpp @@ -0,0 +1,130 @@ +/* + 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 +#include // room_participant_path +#include +#include +#include // vec3, quat +#include // cvc::state, SEPARATOR, findDescendant +#include +#include + +namespace cvc { +namespace ariadne { + +namespace { + +// Round-trip-safe double text (17 significant digits exactly recovers a binary64), +// portable across every CI toolchain (no std::to_chars floating-point dependency). +std::string fmt(double d) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", d); + return std::string(buf); +} + +// A leaf path ., joined with cvc::state::SEPARATOR (never an +// embedded literal separator, so this holds whatever SEPARATOR is). +std::string leaf(const std::string &room, const std::string &pid, + std::initializer_list segs) { + std::string s = conference::room_participant_path(room, pid); + for (const char *seg : segs) { + s += cvc::state::SEPARATOR; + s += seg; + } + return s; +} + +// touch-before-value, so a just-created node's first write is not lost to the +// distributed-state adapter's attach race (the room tree's set_replicated idiom). +void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { + cvc::state &n = app.root()(path); + n.touch(); + n.value(val); +} + +double read_double(cvc::app &app, const std::string &path) { + cvc::state *n = app.root().findDescendant(path); + return n ? std::strtod(n->value().c_str(), nullptr) : 0.0; +} +bool node_present(cvc::app &app, const std::string &path) { + return app.root().findDescendant(path) != nullptr; +} + +} // namespace + +void write_pose(cvc::app &app, const std::string &room, const std::string &pid, + const cvc::physics::body_state &s, std::uint64_t server_tick) { + set_replicated(app, leaf(room, pid, {"pose", "pos", "x"}), fmt(s.position.x)); + set_replicated(app, leaf(room, pid, {"pose", "pos", "y"}), fmt(s.position.y)); + set_replicated(app, leaf(room, pid, {"pose", "pos", "z"}), fmt(s.position.z)); + set_replicated(app, leaf(room, pid, {"pose", "orient", "w"}), fmt(s.rotation.w)); + set_replicated(app, leaf(room, pid, {"pose", "orient", "x"}), fmt(s.rotation.x)); + set_replicated(app, leaf(room, pid, {"pose", "orient", "y"}), fmt(s.rotation.y)); + set_replicated(app, leaf(room, pid, {"pose", "orient", "z"}), fmt(s.rotation.z)); + set_replicated(app, leaf(room, pid, {"pose", "vel", "lin", "x"}), fmt(s.linear_velocity.x)); + set_replicated(app, leaf(room, pid, {"pose", "vel", "lin", "y"}), fmt(s.linear_velocity.y)); + set_replicated(app, leaf(room, pid, {"pose", "vel", "lin", "z"}), fmt(s.linear_velocity.z)); + set_replicated(app, leaf(room, pid, {"pose", "vel", "ang", "x"}), fmt(s.angular_velocity.x)); + set_replicated(app, leaf(room, pid, {"pose", "vel", "ang", "y"}), fmt(s.angular_velocity.y)); + set_replicated(app, leaf(room, pid, {"pose", "vel", "ang", "z"}), fmt(s.angular_velocity.z)); + set_replicated(app, leaf(room, pid, {"pose", "server_tick"}), + std::to_string(static_cast(server_tick))); +} + +bool read_pose(cvc::app &app, const std::string &room, const std::string &pid, + cvc::physics::body_state &out, std::uint64_t &out_server_tick) { + if (!node_present(app, leaf(room, pid, {"pose", "pos", "x"}))) + return false; // the pose subtree has not been written/replicated yet + out.position = {read_double(app, leaf(room, pid, {"pose", "pos", "x"})), + read_double(app, leaf(room, pid, {"pose", "pos", "y"})), + read_double(app, leaf(room, pid, {"pose", "pos", "z"}))}; + out.rotation = {read_double(app, leaf(room, pid, {"pose", "orient", "w"})), + read_double(app, leaf(room, pid, {"pose", "orient", "x"})), + read_double(app, leaf(room, pid, {"pose", "orient", "y"})), + read_double(app, leaf(room, pid, {"pose", "orient", "z"}))}; + out.linear_velocity = {read_double(app, leaf(room, pid, {"pose", "vel", "lin", "x"})), + read_double(app, leaf(room, pid, {"pose", "vel", "lin", "y"})), + read_double(app, leaf(room, pid, {"pose", "vel", "lin", "z"}))}; + out.angular_velocity = {read_double(app, leaf(room, pid, {"pose", "vel", "ang", "x"})), + read_double(app, leaf(room, pid, {"pose", "vel", "ang", "y"})), + read_double(app, leaf(room, pid, {"pose", "vel", "ang", "z"}))}; + out.active = true; + cvc::state *t = app.root().findDescendant(leaf(room, pid, {"pose", "server_tick"})); + out_server_tick = + t ? static_cast(std::strtoull(t->value().c_str(), nullptr, 10)) : 0; + return true; +} + +void write_intent(cvc::app &app, const std::string &room, const std::string &pid, + const avatar_intent &intent) { + set_replicated(app, leaf(room, pid, {"intent", "move", "x"}), fmt(intent.move.x)); + set_replicated(app, leaf(room, pid, {"intent", "move", "y"}), fmt(intent.move.y)); + set_replicated(app, leaf(room, pid, {"intent", "move", "z"}), fmt(intent.move.z)); +} + +bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, + avatar_intent &out) { + if (!node_present(app, leaf(room, pid, {"intent", "move", "x"}))) + return false; + out.move = {read_double(app, leaf(room, pid, {"intent", "move", "x"})), + read_double(app, leaf(room, pid, {"intent", "move", "y"})), + read_double(app, leaf(room, pid, {"intent", "move", "z"}))}; + return true; +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 74fd2cafb..ad67d0b32 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -109,6 +109,7 @@ 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(holon_sync_test holon_sync_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) @@ -307,6 +308,7 @@ set(TEST_TARGETS physics_world_test avatar_sim_test holon_test + holon_sync_test physics_field_test physics_vehicle_test physics_energy_test @@ -1658,6 +1660,13 @@ target_link_libraries(holon_test GTest::gtest_main ) +target_link_libraries(holon_sync_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2058,6 +2067,7 @@ 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(holon_sync_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) @@ -2515,6 +2525,7 @@ 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(holon_sync_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_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp new file mode 100644 index 000000000..07b25b985 --- /dev/null +++ b/src/cvc/tests/holon_sync_test.cpp @@ -0,0 +1,143 @@ +/* + 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 <-> state-tree sync over scalar keys (H1 slice 4): +// the pose/intent scalar-leaf round-trip, and an end-to-end where a participant's +// avatar is server-authoritative over REPLICATED scalar keys -- the client +// proposes intent, the server holon integrates it and writes the authoritative +// pose, and the client reconciles to it. No typed data() object, no DSL verb (the +// C++ verb + arisrv hosting are the live-integration follow-on): just the mapping +// and that it rides the existing conf.rooms.* replication. + +#include +#include +#include +#include +#include +#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 + +// ── scalar-leaf round-trip (one app) ──────────────────────────────────────── + +TEST(HolonSyncTest, PoseRoundTripsThroughScalarKeys) { + cvc::app app; + body_state s; + s.position = vec3{1.5, -2.25, 3.125}; + s.rotation = cvc::physics::quat{0.5, -0.5, 0.5, -0.5}; + s.linear_velocity = vec3{0.1, 0.2, 0.3}; + s.angular_velocity = vec3{-0.4, 0.5, -0.6}; + cvc::ariadne::write_pose(app, "r", "A", s, /*server_tick=*/42); + + body_state out; + std::uint64_t tick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(app, "r", "A", out, tick)); + EXPECT_EQ(tick, 42u); + // 17-sig-digit text recovers binary64 exactly. + expect_pos_bit_eq(out.position, s.position); + EXPECT_EQ(bits(out.rotation.w), bits(s.rotation.w)); + EXPECT_EQ(bits(out.rotation.z), bits(s.rotation.z)); + expect_pos_bit_eq(out.linear_velocity, s.linear_velocity); + expect_pos_bit_eq(out.angular_velocity, s.angular_velocity); +} + +TEST(HolonSyncTest, IntentRoundTripsThroughScalarKeys) { + cvc::app app; + const avatar_intent in{vec3{7.0, -8.0, 9.5}}; + cvc::ariadne::write_intent(app, "r", "A", in); + avatar_intent out; + ASSERT_TRUE(cvc::ariadne::read_intent(app, "r", "A", out)); + expect_pos_bit_eq(out.move, in.move); +} + +TEST(HolonSyncTest, ReadReturnsFalseWhenAbsent) { + cvc::app app; + body_state s; + std::uint64_t tick = 0; + avatar_intent in; + EXPECT_FALSE(cvc::ariadne::read_pose(app, "r", "ghost", s, tick)); + EXPECT_FALSE(cvc::ariadne::read_intent(app, "r", "ghost", in)); +} + +// ── end-to-end: server-authoritative pose over replicated scalar keys ─────── + +TEST(HolonSyncTest, ServerAuthoritativePoseReplicatesAndClientReconciles) { + cvc::app server_app, client_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard cs(client_app, "conf", "client"); + ss.attach(); + cs.attach(); + tp.register_shard(&ss); + tp.register_shard(&cs); + + holon server(holon_role::server); + holon client(holon_role::client); + const std::string room = "r", pid = "A"; + server.add_participant(pid); // authoritative on the server + client.add_participant(pid, /*local=*/true); // the client predicts its own avatar + const double dt = server.clock().fixed_dt(); + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + + for (int frame = 1; frame <= 8; ++frame) { + // Client proposes its intent; it replicates to the server, which integrates it. + client.set_intent(pid, mv); + cvc::ariadne::write_intent(client_app, room, pid, mv); + tp.pump_all(); + avatar_intent got; + ASSERT_TRUE(cvc::ariadne::read_intent(server_app, room, pid, got)) << "frame " << frame; + server.set_intent(pid, got); + + // Both advance one quantum. + server.advance(dt); + client.advance(dt); + + // The server writes the authoritative pose; it replicates; the client reconciles. + cvc::ariadne::write_pose(server_app, room, pid, server.state(pid), server.tick()); + tp.pump_all(); + body_state auth; + std::uint64_t stick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, auth, stick)) << "frame " << frame; + EXPECT_EQ(stick, server.tick()); + client.apply_authoritative(pid, auth, stick); + + // A perfect prediction holds; the client's avatar tracks the authoritative one. + expect_pos_bit_eq(client.state(pid).position, server.state(pid).position); + } + EXPECT_GT(server.state(pid).position.x, 0.0); + + tp.unregister_shard(&ss); + tp.unregister_shard(&cs); + ss.detach(); + cs.detach(); +} From 8ee30c6bcb2c4de99cb0ff6d565851e8975eff1f Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 14:08:12 -0500 Subject: [PATCH 6/7] feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the holon into the live relay so a participant's avatar becomes server-authoritative across the wire. - holon_sync: server_pump(holon, app, room, wall_dt=-1) — one authoritative frame against the replicated room tree: for every present participant (room_roster) add it to the holon if new, read its proposed .intent, advance the holon, and write each authoritative .pose + .server_tick. wall_dt defaults to measured real time (the relay); a test injects a fixed dt for a deterministic step count. - arisrv: construct one server holon for the room and call server_pump each relay tick, right after room_relay_tick, so the authoritative pose writes are local shard mutations the existing drain_local block fans to every client on both transports. (~5 Hz relay cadence -> the holon catches up several quanta per tick and clients interpolate; a smoother authoritative rate is a later tuning.) - room_intrinsics: a (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z) C++ verb so a client proposes only its OWN avatar's intent (pid from the room-join handle, so it cannot propose for a peer). Writes via the holon_sync intent keys; a C++ verb because the DSL chroot ui.docs.* is excluded from replication (same reason room-join is C++). holon_sync_test gains ServerPumpIntegratesRosterFromIntents: two participants join + propose intent on a client app, replicate, and server_pump integrates each from its own intent and writes authoritative poses that replicate back (A moves +x, B moves +y). 5 cases green; arisrv builds + links. Extends #773 (slice 4). The remaining client-side piece (arigl driving its local avatar's intent from input + rendering peer avatars from the replicated poses) is S5/S6 + the live two-client cyprty test. --- inc/cvc/ariadne/holon_sync.h | 13 +++ src/arisrv/main.cpp | 12 +++ .../ariadne/conference/room_intrinsics.cpp | 87 +++++++++++++------ src/cvc/ariadne/holon_sync.cpp | 22 ++++- src/cvc/tests/holon_sync_test.cpp | 54 ++++++++++++ 5 files changed, 161 insertions(+), 27 deletions(-) diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index c98559ac2..c11723250 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -28,6 +28,8 @@ class app; namespace ariadne { +class holon; + // ---------------- // cvc::ariadne — holon <-> state-tree sync over SCALAR keys (H1 slice 4) // ---------------- @@ -77,6 +79,17 @@ void write_intent(cvc::app &app, const std::string &room, const std::string &pid bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, avatar_intent &out_intent); +// Drive one authoritative frame of a SERVER holon against the replicated room +// tree: for every present participant (room_roster), add it to the holon if new, +// read its proposed .intent, feed it to the holon; advance the holon one frame; +// then write every participant's authoritative .pose + .server_tick back to the +// tree (which replicates to the clients). The caller supplies the server holon +// and calls this once per relay tick. A departed participant simply stops +// receiving intent/pose updates (reaping its holon slot is a follow-up). wall_dt +// is forwarded to holon::advance: the default (-1) measures real elapsed wall time +// (what the relay wants); a test injects a fixed dt for a deterministic step count. +void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt = -1.0); + } // namespace ariadne } // namespace cvc diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 8cc0ba4fd..89f449170 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -26,6 +26,8 @@ #include #include // room_leave (retract a departed peer) #include // room_relay_tick +#include // server-authoritative avatar holon (H1) +#include // server_pump: intent -> holon -> authoritative pose #include #include #include // protocol-agnostic media hub (WS + gRPC + future protocols) @@ -150,6 +152,13 @@ int main(int argc, char **argv) { #if CVC_ENABLE_WS int reapticks = 0; // cadence for reaping ghost (navigated-away) connections #endif + // The server-authoritative avatar holon for this room (H1): each tick it reads + // every participant's proposed intent from the replicated tree, integrates it on + // its own clock, and writes the authoritative pose back (fanned to clients by the + // drain_local block below). The relay loop paces ~5 Hz, so the holon catches up + // several quanta per tick and clients interpolate between the ~5 Hz pose samples; + // a smoother authoritative rate is a later tuning (tick the holon on its own). + cvc::ariadne::holon server_holon(cvc::ariadne::holon_role::server); while (!g_stop.load()) { #if CVC_ENABLE_WS // Reap ghosts (~every second): a tab that navigated or reloaded away without a clean WebSocket @@ -182,6 +191,9 @@ int main(int argc, char **argv) { } #endif cvc::ariadne::conference::room_relay_tick(app, room, relay, relayed); + // Advance the authoritative avatar holon and write each participant's pose. The + // pose writes are local shard mutations, fanned to clients by the drain below. + cvc::ariadne::server_pump(server_holon, app, room); #if CVC_ENABLE_WS // Fan the relay's OWN room-tree writes — the room_leave retractions queued above and // room_relay_tick's per-stream "ready" markers — out to EVERY client on BOTH transports. The diff --git a/src/cvc/ariadne/conference/room_intrinsics.cpp b/src/cvc/ariadne/conference/room_intrinsics.cpp index dce64fbf2..d8a013e42 100644 --- a/src/cvc/ariadne/conference/room_intrinsics.cpp +++ b/src/cvc/ariadne/conference/room_intrinsics.cpp @@ -8,8 +8,11 @@ License version 2.1 as published by the Free Software Foundation. */ +#include +#include // strtod (room-set-intent numeric args) #include // register_action_intrinsics #include +#include // write_intent (room-set-intent verb) #include // app.root() #include // cvc::state, SEPARATOR, findDescendant, children, value #include // default_for / local_node_id (the trust-all pid) @@ -73,6 +76,18 @@ std::string get_str(const se::value_t *v, const std::string &def = std::string() return *s; return def; } +// A number from a value_t arg (double / int64 / numeric string), for the pose/intent verbs. +double get_num(const se::value_t *v, double def = 0.0) { + if (!v) + return def; + if (const double *d = std::get_if(&v->v)) + return *d; + if (const std::int64_t *i = std::get_if(&v->v)) + return static_cast(*i); + if (const std::string *s = std::get_if(&v->v)) + return std::strtod(s->c_str(), nullptr); + return def; +} se::value_t err(const std::string &msg) { return se::make_dict({{"ok", se::value_t(false)}, {"error", se::value_t(msg)}}); } @@ -240,32 +255,30 @@ void register_room_intrinsics(cvc::app &app) { }); // (room-roster ROOM) -> list of { pid name present }. - se::builtins::register_fn(env, "room-roster", - [&app, doc](std::span args) -> se::value_t { - if (!doc) - return err("room-roster: no document scope"); - room_table *tbl = doc_rooms(doc); - if (!tbl) - return err("room-roster: internal: room-table slot unavailable"); - if (args.empty()) - return err("room-roster: missing room handle or room-id"); - // Accept a (room-join) handle OR a bare room-id string, so an - // N-party .ari can iterate with (room-roster (state-get - // "conf.room")). - room_handle *rh = tbl->find(handle_token(args[0])); - const std::string room_id = - rh ? rh->room_id : handle_token(args[0]); - if (room_id.empty()) - return err( - "room-roster: ROOM must be a room-join handle or room-id"); - std::vector list; - for (const room_participant &p : room_roster(app, room_id)) - list.push_back(se::make_dict({ - {"pid", se::value_t(p.pid)}, - {"name", se::value_t(p.name)}, - })); - return se::make_list(std::move(list)); - }); + se::builtins::register_fn( + env, "room-roster", [&app, doc](std::span args) -> se::value_t { + if (!doc) + return err("room-roster: no document scope"); + room_table *tbl = doc_rooms(doc); + if (!tbl) + return err("room-roster: internal: room-table slot unavailable"); + if (args.empty()) + return err("room-roster: missing room handle or room-id"); + // Accept a (room-join) handle OR a bare room-id string, so an + // N-party .ari can iterate with (room-roster (state-get + // "conf.room")). + room_handle *rh = tbl->find(handle_token(args[0])); + const std::string room_id = rh ? rh->room_id : handle_token(args[0]); + if (room_id.empty()) + return err("room-roster: ROOM must be a room-join handle or room-id"); + std::vector list; + for (const room_participant &p : room_roster(app, room_id)) + list.push_back(se::make_dict({ + {"pid", se::value_t(p.pid)}, + {"name", se::value_t(p.name)}, + })); + return se::make_list(std::move(list)); + }); // (room-leave ROOM) -> { ok #t/#f }. se::builtins::register_fn(env, "room-leave", @@ -285,6 +298,28 @@ void register_room_intrinsics(cvc::app &app) { tbl->erase(token); return se::make_dict({{"ok", se::value_t(true)}}); }); + + // (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z) -> { ok #t/#f }. The caller + // PROPOSES its own avatar's desired motion by writing only its own .intent.* + // (the server holon is the sole writer of the authoritative .pose). The handle + // supplies the caller's pid, so a client cannot propose for another participant. + se::builtins::register_fn( + env, "room-set-intent", [&app, doc](std::span args) -> se::value_t { + if (!doc) + return err("room-set-intent: no document scope"); + room_table *tbl = doc_rooms(doc); + if (!tbl) + return err("room-set-intent: internal: room-table slot unavailable"); + if (args.size() < 4) + return err("room-set-intent: (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z)"); + room_handle *rh = tbl->find(handle_token(args[0])); + if (!rh) + return err("room-set-intent: ROOM must be a room-join handle (needs the caller's pid)"); + cvc::ariadne::avatar_intent in; + in.move = {get_num(&args[1]), get_num(&args[2]), get_num(&args[3])}; + cvc::ariadne::write_intent(app, rh->room_id, rh->self_pid, in); + return se::make_dict({{"ok", se::value_t(true)}}); + }); }); } diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index 1c16de303..f29479317 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -15,7 +15,8 @@ #include #include -#include // room_participant_path +#include // room_participant_path, room_roster +#include // cvc::ariadne::holon (server_pump) #include #include #include // vec3, quat @@ -126,5 +127,24 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, return true; } +void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt) { + // The present participants, straight from the replicated room tree. + const std::vector roster = conference::room_roster(app, room); + // Add any newcomer, then feed each one's latest proposed intent to the holon. + for (const conference::room_participant &p : roster) { + if (!h.has_participant(p.pid)) + h.add_participant(p.pid); + avatar_intent in; + if (read_intent(app, room, p.pid, in)) + h.set_intent(p.pid, in); + } + // One authoritative frame. + h.advance(wall_dt); + // Publish each participant's authoritative pose (these are local writes on the + // relay's shard; the caller fans them to the clients). + for (const conference::room_participant &p : roster) + write_pose(app, room, p.pid, h.state(p.pid), h.tick()); +} + } // namespace ariadne } // namespace cvc diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index 07b25b985..8caadd29d 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -141,3 +142,56 @@ TEST(HolonSyncTest, ServerAuthoritativePoseReplicatesAndClientReconciles) { ss.detach(); cs.detach(); } + +// server_pump drives the whole roster: it reads each present participant's intent, +// integrates the server holon, and writes each authoritative pose -- the loop +// arisrv runs. (Injected dt so the step count is deterministic in a tight loop.) +TEST(HolonSyncTest, ServerPumpIntegratesRosterFromIntents) { + cvc::app server_app, client_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard cs(client_app, "conf", "client"); + ss.attach(); + cs.attach(); + tp.register_shard(&ss); + tp.register_shard(&cs); + const std::string room = "r"; + + // Two participants join and propose intent on the client side. + cvc::ariadne::conference::room_join(client_app, room, "A"); + cvc::ariadne::conference::room_join(client_app, room, "B"); + cvc::ariadne::write_intent(client_app, room, "A", avatar_intent{vec3{1.0, 0.0, 0.0}}); + cvc::ariadne::write_intent(client_app, room, "B", avatar_intent{vec3{0.0, 2.0, 0.0}}); + tp.pump_all(); // replicate the roster + intents to the server + + holon server(holon_role::server); + const double dt = server.clock().fixed_dt(); + for (int frame = 0; frame < 5; ++frame) { + cvc::ariadne::server_pump(server, server_app, room, dt); + tp.pump_all(); + } + + // The server integrated each avatar from its own intent and wrote authoritative + // poses, tagged with the holon tick. + body_state pa, pb; + std::uint64_t ta = 0, tb = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(server_app, room, "A", pa, ta)); + ASSERT_TRUE(cvc::ariadne::read_pose(server_app, room, "B", pb, tb)); + EXPECT_GT(pa.position.x, 0.0) << "A moved +x per its intent"; + EXPECT_DOUBLE_EQ(pa.position.y, 0.0); + EXPECT_GT(pb.position.y, 0.0) << "B moved +y per its intent"; + EXPECT_DOUBLE_EQ(pb.position.x, 0.0); + EXPECT_EQ(ta, server.tick()); + + // And the authoritative poses replicate back to the client. + tp.pump_all(); + body_state ca; + std::uint64_t ct = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, "A", ca, ct)); + EXPECT_GT(ca.position.x, 0.0); + + tp.unregister_shard(&ss); + tp.unregister_shard(&cs); + ss.detach(); + cs.detach(); +} From 197035ba89797e36f96647605a541e714e7fd55a Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 15:43:50 -0500 Subject: [PATCH 7/7] ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile)