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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions inc/cvc/ariadne/holon.h
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <cvc/ariadne/avatar_sim.h>
#include <cvc/core/clock_driver.h>
#include <cvc/core/namespace.h>
#include <cvc/core/world_clock.h>
#include <cvc/physics/body.h>
#include <cvc/physics/pose_tracker.h>
#include <map>
#include <string>

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<std::string, participant> _participants;
double _alpha = 0.0; // the last frame's interpolation alpha, for display_pose
};

} // namespace ariadne
} // namespace cvc

#endif // __CVC_ARIADNE_HOLON_H__
3 changes: 2 additions & 1 deletion src/cvc/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1308,12 +1308,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
Expand Down
147 changes: 147 additions & 0 deletions src/cvc/ariadne/holon.cpp
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <cvc/ariadne/holon.h>

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<std::uint64_t>(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
11 changes: 11 additions & 0 deletions src/cvc/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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_codec_test physics_codec_test.cpp)
add_executable(physics_field_test physics_field_test.cpp)
add_executable(physics_vehicle_test physics_vehicle_test.cpp)
Expand Down Expand Up @@ -306,6 +307,7 @@ set(TEST_TARGETS
world_units_test
physics_world_test
avatar_sim_test
holon_test
physics_codec_test
physics_field_test
physics_vehicle_test
Expand Down Expand Up @@ -1651,6 +1653,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_codec_test
PRIVATE
cvc
Expand Down Expand Up @@ -2057,6 +2066,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_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)
Expand Down Expand Up @@ -2514,6 +2524,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_codec_test)
cvc_discover_tests(physics_field_test)
cvc_discover_tests(physics_vehicle_test)
Expand Down
Loading
Loading