diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h new file mode 100644 index 000000000..b83fac1be --- /dev/null +++ b/inc/cvc/ariadne/holon.h @@ -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. + + 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{}); + + // 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; } + 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 + // 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); + 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/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h new file mode 100644 index 000000000..c11723250 --- /dev/null +++ b/inc/cvc/ariadne/holon_sync.h @@ -0,0 +1,96 @@ +/* + 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 { + +class holon; + +// ---------------- +// 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); + +// 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 + +#endif // __CVC_ARIADNE_HOLON_SYNC_H__ 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/CMakeLists.txt b/src/cvc/CMakeLists.txt index 2a32d6390..577a1e347 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1308,12 +1308,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) +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/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.cpp b/src/cvc/ariadne/holon.cpp new file mode 100644 index 000000000..05bae69ca --- /dev/null +++ b/src/cvc/ariadne/holon.cpp @@ -0,0 +1,147 @@ +/* + 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. 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_sample = true; + } else { + p.prev = p.newest; // shift the window: prev <- old newest, newest <- auth + p.newest = auth; + } + p.last_sample_tick = server_tick; + } +} + +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/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp new file mode 100644 index 000000000..f29479317 --- /dev/null +++ b/src/cvc/ariadne/holon_sync.cpp @@ -0,0 +1,150 @@ +/* + 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, room_roster +#include // cvc::ariadne::holon (server_pump) +#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; +} + +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/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 0670fa058..6be1e57a8 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -108,6 +108,8 @@ 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(holon_sync_test holon_sync_test.cpp) add_executable(physics_codec_test physics_codec_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) @@ -306,6 +308,8 @@ set(TEST_TARGETS world_units_test physics_world_test avatar_sim_test + holon_test + holon_sync_test physics_codec_test physics_field_test physics_vehicle_test @@ -1651,6 +1655,20 @@ target_link_libraries(avatar_sim_test GTest::gtest_main ) +target_link_libraries(holon_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + +target_link_libraries(holon_sync_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_codec_test PRIVATE cvc @@ -2057,6 +2075,8 @@ 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(holon_sync_test PRIVATE cxx_std_17) target_compile_features(physics_codec_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) @@ -2514,6 +2534,8 @@ 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(holon_sync_test) cvc_discover_tests(physics_codec_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_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..8caadd29d --- /dev/null +++ b/src/cvc/tests/holon_sync_test.cpp @@ -0,0 +1,197 @@ +/* + 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 +#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(); +} + +// 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(); +} diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp new file mode 100644 index 000000000..d6c176cdc --- /dev/null +++ b/src/cvc/tests/holon_test.cpp @@ -0,0 +1,199 @@ +/* + 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::avatar_sim; +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 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"; +} + +// ── a peer is sample-driven, not intent-driven ────────────────────────────── + +TEST(HolonTest, PeerIsSampleDrivenNotIntentDriven) { + holon client(holon_role::client); + client.add_participant("peer", /*local=*/false); + // 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); + 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) { + 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); + 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)"; +}