Repository navigation
H1 slice 1: cvc::physics body_state/pose replication codec - #768
Merged
Merged
Conversation
The first H1 (single-holon) slice and the dependency H0 flagged: a holon
replicates a participant's avatar pose as a node's data(), but no
cvc::physics type had a registered data-type name or a codec, so none
could cross the cluster.
Add cvc::physics::physics_codec (inc/cvc/physics/physics_codec.h,
src/cvc/physics/physics_codec.cpp), mirroring the
cvc::ariadne::stream::format_desc codec: a little-endian, fixed-width,
FIELD-BY-FIELD wire (never a struct memcpy, so trailing-bool padding and
ABI never leak) with the double serializer stream_codec lacked — put_f64
writes the IEEE-754 binary64 bit pattern as 8 LE bytes (memcpy, C++17-
clean, matching the built-in "cvc.f64.v1" double codec). body_state is 13
doubles (position, rotation, linear_velocity, angular_velocity) + an
active byte; pose is 7 doubles. The decoder bounds-checks via reader{ok}
and throws on truncation. register_physics_data_types(app&) and
register_physics_codecs(state_codec_registry&) key both the
registerDataType name and the registry codec on the identical strings
"cvc::physics::body_state" / "cvc::physics::pose" (codec_ids
"cvc.physics.body_state.v1" / ".pose.v1"), so dataTypeName() always
matches a registered codec — the #767 S1 mismatch class.
Mass is intentionally NOT on the wire (body_state has none; it lives in
body_settings and stays server-owned). No floating-point math, so the TU
is not in the -ffp-contract=off exact-sources set.
physics_codec_test (8 cases): bit-for-bit round-trip of body_state and
pose including NaN / Inf / -0.0 / denormals; registry has() both;
registry key == dataTypeName<T>(); golden fixed-width LE wire (105 bytes
/ 56 bytes); truncated payloads throw. All green.
…consts Replace the `extern const char* const kBodyStateTypeName / kPoseTypeName` namespace-scope constants with inline accessors body_state_type_name() / pose_type_name() returning the literal. Two reasons: - No static globals (project convention): the names are now a plain value returned by a function, not a global variable. - It also fixes a Windows-only CI failure. CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS exports functions but NOT data symbols, so cvc.dll did not export the `const char* const` consts; physics_codec_test (a separate exe linking the DLL) failed to link them (LNK2019). An inline accessor is compiled into each consumer, so it links across a shared-library boundary on every platform with no exported symbol at all. register_physics_data_types / register_physics_codecs and the test now call the accessors. No wire or behavior change; 8 physics_codec_test cases still green.
This was referenced Oct 22, 2026
transfix
added a commit
that referenced
this pull request
Oct 23, 2026
* 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. * fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests 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.
transfix
added a commit
that referenced
this pull request
Oct 24, 2026
…lar keys (#773) * 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. * fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests 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. * feat(ariadne): cvc::ariadne::holon — clock + avatar bundle on its own clock_driver (H1 slice 3) 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). * fix(ariadne): holon review fixes — peer sample guard, explicit non-movable, replay/stale tests 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. * feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core) 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.<room>.participants.<pid>, via room_participant_path): <participant>.pose.pos/orient/vel.{...} + .pose.server_tick server-written <participant>.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. * feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b) 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. * ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile)
transfix
added a commit
that referenced
this pull request
Oct 24, 2026
…v seed (#776) * 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. * fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests 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. * feat(ariadne): cvc::ariadne::holon — clock + avatar bundle on its own clock_driver (H1 slice 3) 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). * fix(ariadne): holon review fixes — peer sample guard, explicit non-movable, replay/stale tests 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. * feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core) 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.<room>.participants.<pid>, via room_participant_path): <participant>.pose.pos/orient/vel.{...} + .pose.server_tick server-written <participant>.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. * feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b) 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. * ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile) * H1 slice 5: the shared room scene (conf.rooms.<room>.scene.*) + arisrv seed The server-authoritative 3D scene every client renders from its own camera (ARIADNE_CONFERENCE_ROADMAP.md §5g). It lives in the replicated room tree as scalar leaves, like the S4 pose keys, so it rides the "conf" interest filter with no transport change. The schema is the .ari scene's own types: objects are SceneNodes (a URI or a plane/box/sphere primitive, transform, material, visible), the environment is background + chrome + SceneLights. read_room_scene() turns the live subtree back into a cvc::ariadne::Scene, so the client render (S6) is realize_scene() over it. scene.rev is written last by every write so a renderer re-realizes only on change. Removal is soft (present = 0), as room_leave is. arisrv seeds the MVP environment once (gradient background, grid chrome, a sun, a 20 m ground plane, a 1 m reference box); seeding is a no-op when the room already has a scene. room_scene_test: 9 gtests, including the seeded scene replicating to a client over two in-process shards and tracking a move + removal. * ci: run the master-targeted matrix (base retargeted to master after #773)
transfix
added a commit
that referenced
this pull request
Oct 24, 2026
…e-timing holon fixes (#779) * 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. * fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests 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. * feat(ariadne): cvc::ariadne::holon — clock + avatar bundle on its own clock_driver (H1 slice 3) 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). * fix(ariadne): holon review fixes — peer sample guard, explicit non-movable, replay/stale tests 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. * feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core) 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.<room>.participants.<pid>, via room_participant_path): <participant>.pose.pos/orient/vel.{...} + .pose.server_tick server-written <participant>.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. * feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b) 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. * ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile) * H1 slice 5: the shared room scene (conf.rooms.<room>.scene.*) + arisrv seed The server-authoritative 3D scene every client renders from its own camera (ARIADNE_CONFERENCE_ROADMAP.md §5g). It lives in the replicated room tree as scalar leaves, like the S4 pose keys, so it rides the "conf" interest filter with no transport change. The schema is the .ari scene's own types: objects are SceneNodes (a URI or a plane/box/sphere primitive, transform, material, visible), the environment is background + chrome + SceneLights. read_room_scene() turns the live subtree back into a cvc::ariadne::Scene, so the client render (S6) is realize_scene() over it. scene.rev is written last by every write so a renderer re-realizes only on change. Removal is soft (present = 0), as room_leave is. arisrv seeds the MVP environment once (gradient background, grid chrome, a sun, a 20 m ground plane, a 1 m reference box); seeding is a no-op when the room already has a scene. room_scene_test: 9 gtests, including the seeded scene replicating to a client over two in-process shards and tracking a move + removal. * H1 S6 (core): client_pump, clock alignment, sparse-sample peer walk, 5 Hz server fix What the live client needs from the holon, found by timing the headless loop like the relay (server pumped every 24 quanta, clients every quantum): - The server holon ran at a third of real time. arisrv advances it at ~5 Hz, 24 quanta due each time, and world_clock's default catch-up cap is 8, so 2/3 of every interval was dropped. holon_config now allows 60 quanta (0.5 s) per advance, sized for a relay or a throttled tab rather than a render loop. - A client replica's clock is aligned to the server's ticks. Reconcile compares ticks, and a client joins a server that has been running, so a sample more than max_skew_ticks away (60) seeks the replica's clock and drops the local avatar's stale input log. Without it, every ack replayed the whole input history (client ahead) or discarded predicted motion (client behind). - Peers walk across sparse samples. They were interpolated by the per-quantum alpha, so with 5 Hz samples a peer flicked between its last two samples 120 times a second. A peer now walks from where it is shown to the newest sample over the server's own interval between samples, timed on the replica's clock; re-alignment shifts the walk instead of restarting it. - holon::take_correction reports reconcile jumps once, for a renderer to ease. - client_pump (the mirror of server_pump: roster -> local/peer registration from the replicated pose, departures dropped, authoritative samples applied, one frame advanced) and propose_intent (predict + write .intent). - server_pump spawns newcomers on a 4 m golden-angle ring around the origin instead of inside the reference object. Tests: holon_test 10 (sparse-sample walk, re-alignment incl. walk continuity, take_correction), holon_sync_test 6 (client_pump end to end at relay timing: peer never steps back, self within a relay interval of the authority, replicas count the server's ticks, departures dropped; spawn ring). * H1 S6 (client): the room in 3D in arigl — room_view, WASD avatar, follow camera cvc::gl::ariadne::room_view draws the conference room into the window's SceneGraph: the server's shared scene (read_room_scene -> realize_scene, ids prefixed room_, re-realized when scene.rev changes, background applied as a view property) and one marker per participant of a client holon, posed each frame at holon::display_pose. The local avatar's reconcile jumps (holon::take_correction) are eased out over 0.12 s, so a server correction glides instead of jumping. intent_from_keys maps held WASD / arrow keys to an intent relative to the camera's heading on the Z-up ground (diagonals normalized, opposing keys cancel). arigl's ConferenceSession gains attach_view(view, cam) and one ordered frame observer right after the transport pump. Each frame it reads the keys (Orbit mode only, since in Fly the same keys move the camera; the camera's held set is dropped while ImGui has the keyboard, so typing a name never walks), proposes the intent only when it changes, runs client_pump, updates the room view, and keeps the camera's orbit centre on our avatar (framed once at first sight). The conference windows stay ImGui windows over the 3D view. CameraController::keyHeld exposes the held set the camera already keeps (native interactor and wasm alike). cvcgl_room_view (offscreen, 22 checks): key -> intent, keyHeld, scene realize + re-realize on removal + teardown, avatar markers follow the holon, a 3 m correction moves the marker one frame's share then glides onto the pose, departures and teardown remove markers. * H1 S6: seed the room with the SceneGraph chrome off (live-test finding) The first live run (arisrv + two arigl clients over gRPC on Windows) rendered the seeded room with the SceneGraph's diagnostic chrome: the yellow bounding box, the axis gizmo and the "Max: (...)" label. Chrome is a debug overlay, not a floor grid, so the default room scene now leaves it off; the ground plane and the reference box carry the space. * arisrv: reflow the seed comment to 100 columns * cyprty: ?relay= and ?name= page parameters ?relay=ws://host:port (or wss://) points the browser client at another arisrv -- a staging or local relay -- instead of the page origin's /cyprty-ws; anything that is not a ws(s):// URL is ignored. ?name= presets the display name (trimmed, at most 32 characters) instead of a random guest-xxxxx. * H1: native and browser clients leave the room alike; arisrv room readout Running a native (gRPC) and a browser (WS) client in one room showed a native client that exits is never retracted: only the WebSocket transport reported closed connections, so the roster kept the ghost and the server holon kept integrating its last intent (a walker drifted ~240 m over ten minutes). - state_transport_grpc::set_on_connection_closed, the WS transport's contract: fired with the peer's node id (from its Hello) when its stream ends, never during stop(). Test: ConnectionClosedReportsThePeersNodeId. - arisrv queues departures from BOTH planes through one handler and applies them on the main loop for both (the apply step was WS-only), zeroing the departed participant's intent so a rejoin under the same id does not resume an old walk. Hooks are now registered before each transport starts. - server_pump drops holon participants who left the roster (the mirror of client_pump), so a rejoin respawns on the ring. - arigl proposes its intent on the first frame even when standing still. - CVC_ARISRV_ROOM_DEBUG=1 logs the room as the server holds it every ~5 s (each present participant's authoritative position and proposed intent). * H1 S6: frame the room on arrival, gold-free peer colours, log who is drawn From the mixed browser + native run: - The first-sight camera framed +-4 m around you, so in a narrow browser window the other participants (spawned 4-7 m away) were off-screen. Frame +-8 m: arriving at a party you should see who is there. - Peer colours skip the 30-75 degree hues, so no peer is drawn in a yellow that reads as your own gold avatar. - arigl logs "room: N avatar(s): ..." whenever the drawn set changes (the browser page shows stderr in its footer and console), via the new room_view::avatar_pids(). * arigl: a fresh conference node id per launch Replication's loop detection drops any mutation whose (origin node id, sequence) it has already seen. arigl derived its node id from the display name alone, so a client that restarted under the same name (a reloaded tab, a relaunched native client, or two people who both kept "guest") restarted its sequence at 1 and every write it made was dropped as a duplicate: it joined a room in which nobody, including the relay's roster, could see it, until the relay restarted. Found with a reloaded browser (?name=webby) and relaunched native clients. The id is now the sanitized name plus a random six-character suffix per launch, so each session is a new participant; the roster still shows the display name (profile.name), and the document identifies "me" by self.pid, which follows. * arisrv: log the stack on a fatal signal (Linux/glibc) The relay runs unattended, and during the mixed-transport test it died once with "malloc(): unsorted double linked list corrupted" and nothing else to go on (no gdb on the builder, apport ignores unpackaged binaries, and an ASan build did not reproduce it). On SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL it now writes the stack to stderr with backtrace_symbols_fd (no allocation; backtrace() is warmed up at startup so the handler never lazy-loads libgcc on a corrupted heap), then re-raises with the default action. * ci: run the master-targeted matrix (base retargeted to master after #773) * arigl: single space before the <random> include comment (changed-lines format gate) * Conference link stats: Ping/Pong per connection, background tabs stay, compact roster with ping + bandwidth (#781) * Conference link stats: Ping/Pong probes, per-link traffic, room net stats, compact roster Link probe + stats (both transports, every connection): - state_transport.proto: Ping/Pong frames. Each end pings every 2 s (state_transport::set_ping_interval); the other end answers at once and the sender measures the round trip on its own clock. Link-level only: never replicated, relayed or bridged. - state_link_stats (lock-free per-connection bytes/frames both ways, RTT EWMA / last / min) + state_link_rate_meter (cumulative reports -> bytes/s); state_transport::link_reports() on state_transport_ws and _grpc, so a relay's links to clients, a client's link to its relay and a server's links to peer (delegation) servers are all measured the same way. Background tabs no longer drop out: a browser answers the relay's Ping from its WebSocket onmessage event, which a backgrounded tab still receives, so the idle reaper keeps it; a ghost that cannot answer is still reaped. And arigl now redials a lost relay link every 3 s and re-asserts its presence afterwards. Room stats (room_net.h): each client publishes its own link quality as participants.<pid>.net.{rtt_ms,in_kbps,out_kbps}; the relay publishes its view of each participant (net.relay.*) and its totals (server.net.*) and logs them (per link every ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each minute). (room-roster) entries carry the numbers and roster-cell text. conference_roster.ari: the Users window is a compact table -- one row per participant (name | ping | down | up | cam | mic | vol), each row a grid with the same fixed column widths, so the rows line up as columns; your own row shows your measured link. Peer camera tiles moved right of the wider window. Tests: state_link_stats_test (3), StateTransportWsTest.LinkProbeMeasures- RoundTripAtBothEnds + QuietClientAnsweringPingsIsNotReaped, StateTransportGrpc- Test.LinkProbeMeasuresRoundTripAtBothEnds; RelayReapsIdleConnection turns the probe off to keep testing a silent client. * state_transport_grpc_test: leave only after the relay has read the Hello ConnectionClosedReportsThePeersNodeId stopped the client as soon as both ends counted a connection. Cancelling a gRPC stream drops frames the relay has not read yet, so the relay sometimes never saw the client's Hello -- and a stream that never identified itself is, by contract, not reported. The link probe's extra threads shifted the timing enough to expose it (about 1 run in 5 after ThreeEndpointFanOut). Wait until the relay's link report names the client. 20/20 repeats of the reproducing sequence pass. * conference_roster: widen the ping column (a congested link reads 4 digits) * Transports: a peer that stops reading can no longer hang the relay; RTT median Production stalled for everyone after a browser tab froze: a write to that client blocked once its TCP buffers filled, while holding the connection's write lock, so the relay's main loop (and the idle reaper that would have dropped the client, which runs on it) waited on the lock forever. The backlog thresholds only make that less likely; nothing bounded it. - Write watchdog (WS + gRPC): each connection records when a write starts; the probe thread closes a connection whose write has been blocked > 3 s (socket shutdown / stream TryCancel), so the write fails and the lock is freed. The client redials (arigl reconnect) and gets a fresh snapshot. - The probe never waits: Pings use try_write (skip a busy connection) and skip one with more than 64 KB unsent. - gRPC: the server handler's cancel hook is cleared under a mutex before its ServerContext goes away. - state_link_stats: rtt_ms is now the median of the last 8 round trips; the moving average carried a 14.8 s probe (answered after a frozen tab woke) for minutes. Tests: StateTransportWsTest.WriteToAPeerThatStopsReadingIsAbortedNotHung (raw peer that handshakes and never reads; 32 MB media frames), StateLinkStatsTest. MedianShrugsOffAStall.
transfix
added a commit
that referenced
this pull request
Oct 25, 2026
…ysics (#783) * 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. * fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests 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. * feat(ariadne): cvc::ariadne::holon — clock + avatar bundle on its own clock_driver (H1 slice 3) 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). * fix(ariadne): holon review fixes — peer sample guard, explicit non-movable, replay/stale tests 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. * feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core) 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.<room>.participants.<pid>, via room_participant_path): <participant>.pose.pos/orient/vel.{...} + .pose.server_tick server-written <participant>.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. * feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b) 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. * ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile) * H1 slice 5: the shared room scene (conf.rooms.<room>.scene.*) + arisrv seed The server-authoritative 3D scene every client renders from its own camera (ARIADNE_CONFERENCE_ROADMAP.md §5g). It lives in the replicated room tree as scalar leaves, like the S4 pose keys, so it rides the "conf" interest filter with no transport change. The schema is the .ari scene's own types: objects are SceneNodes (a URI or a plane/box/sphere primitive, transform, material, visible), the environment is background + chrome + SceneLights. read_room_scene() turns the live subtree back into a cvc::ariadne::Scene, so the client render (S6) is realize_scene() over it. scene.rev is written last by every write so a renderer re-realizes only on change. Removal is soft (present = 0), as room_leave is. arisrv seeds the MVP environment once (gradient background, grid chrome, a sun, a 20 m ground plane, a 1 m reference box); seeding is a no-op when the room already has a scene. room_scene_test: 9 gtests, including the seeded scene replicating to a client over two in-process shards and tracking a move + removal. * H1 S6 (core): client_pump, clock alignment, sparse-sample peer walk, 5 Hz server fix What the live client needs from the holon, found by timing the headless loop like the relay (server pumped every 24 quanta, clients every quantum): - The server holon ran at a third of real time. arisrv advances it at ~5 Hz, 24 quanta due each time, and world_clock's default catch-up cap is 8, so 2/3 of every interval was dropped. holon_config now allows 60 quanta (0.5 s) per advance, sized for a relay or a throttled tab rather than a render loop. - A client replica's clock is aligned to the server's ticks. Reconcile compares ticks, and a client joins a server that has been running, so a sample more than max_skew_ticks away (60) seeks the replica's clock and drops the local avatar's stale input log. Without it, every ack replayed the whole input history (client ahead) or discarded predicted motion (client behind). - Peers walk across sparse samples. They were interpolated by the per-quantum alpha, so with 5 Hz samples a peer flicked between its last two samples 120 times a second. A peer now walks from where it is shown to the newest sample over the server's own interval between samples, timed on the replica's clock; re-alignment shifts the walk instead of restarting it. - holon::take_correction reports reconcile jumps once, for a renderer to ease. - client_pump (the mirror of server_pump: roster -> local/peer registration from the replicated pose, departures dropped, authoritative samples applied, one frame advanced) and propose_intent (predict + write .intent). - server_pump spawns newcomers on a 4 m golden-angle ring around the origin instead of inside the reference object. Tests: holon_test 10 (sparse-sample walk, re-alignment incl. walk continuity, take_correction), holon_sync_test 6 (client_pump end to end at relay timing: peer never steps back, self within a relay interval of the authority, replicas count the server's ticks, departures dropped; spawn ring). * H1 S6 (client): the room in 3D in arigl — room_view, WASD avatar, follow camera cvc::gl::ariadne::room_view draws the conference room into the window's SceneGraph: the server's shared scene (read_room_scene -> realize_scene, ids prefixed room_, re-realized when scene.rev changes, background applied as a view property) and one marker per participant of a client holon, posed each frame at holon::display_pose. The local avatar's reconcile jumps (holon::take_correction) are eased out over 0.12 s, so a server correction glides instead of jumping. intent_from_keys maps held WASD / arrow keys to an intent relative to the camera's heading on the Z-up ground (diagonals normalized, opposing keys cancel). arigl's ConferenceSession gains attach_view(view, cam) and one ordered frame observer right after the transport pump. Each frame it reads the keys (Orbit mode only, since in Fly the same keys move the camera; the camera's held set is dropped while ImGui has the keyboard, so typing a name never walks), proposes the intent only when it changes, runs client_pump, updates the room view, and keeps the camera's orbit centre on our avatar (framed once at first sight). The conference windows stay ImGui windows over the 3D view. CameraController::keyHeld exposes the held set the camera already keeps (native interactor and wasm alike). cvcgl_room_view (offscreen, 22 checks): key -> intent, keyHeld, scene realize + re-realize on removal + teardown, avatar markers follow the holon, a 3 m correction moves the marker one frame's share then glides onto the pose, departures and teardown remove markers. * H1 S6: seed the room with the SceneGraph chrome off (live-test finding) The first live run (arisrv + two arigl clients over gRPC on Windows) rendered the seeded room with the SceneGraph's diagnostic chrome: the yellow bounding box, the axis gizmo and the "Max: (...)" label. Chrome is a debug overlay, not a floor grid, so the default room scene now leaves it off; the ground plane and the reference box carry the space. * arisrv: reflow the seed comment to 100 columns * cyprty: ?relay= and ?name= page parameters ?relay=ws://host:port (or wss://) points the browser client at another arisrv -- a staging or local relay -- instead of the page origin's /cyprty-ws; anything that is not a ws(s):// URL is ignored. ?name= presets the display name (trimmed, at most 32 characters) instead of a random guest-xxxxx. * H1: native and browser clients leave the room alike; arisrv room readout Running a native (gRPC) and a browser (WS) client in one room showed a native client that exits is never retracted: only the WebSocket transport reported closed connections, so the roster kept the ghost and the server holon kept integrating its last intent (a walker drifted ~240 m over ten minutes). - state_transport_grpc::set_on_connection_closed, the WS transport's contract: fired with the peer's node id (from its Hello) when its stream ends, never during stop(). Test: ConnectionClosedReportsThePeersNodeId. - arisrv queues departures from BOTH planes through one handler and applies them on the main loop for both (the apply step was WS-only), zeroing the departed participant's intent so a rejoin under the same id does not resume an old walk. Hooks are now registered before each transport starts. - server_pump drops holon participants who left the roster (the mirror of client_pump), so a rejoin respawns on the ring. - arigl proposes its intent on the first frame even when standing still. - CVC_ARISRV_ROOM_DEBUG=1 logs the room as the server holds it every ~5 s (each present participant's authoritative position and proposed intent). * H1 S6: frame the room on arrival, gold-free peer colours, log who is drawn From the mixed browser + native run: - The first-sight camera framed +-4 m around you, so in a narrow browser window the other participants (spawned 4-7 m away) were off-screen. Frame +-8 m: arriving at a party you should see who is there. - Peer colours skip the 30-75 degree hues, so no peer is drawn in a yellow that reads as your own gold avatar. - arigl logs "room: N avatar(s): ..." whenever the drawn set changes (the browser page shows stderr in its footer and console), via the new room_view::avatar_pids(). * arigl: a fresh conference node id per launch Replication's loop detection drops any mutation whose (origin node id, sequence) it has already seen. arigl derived its node id from the display name alone, so a client that restarted under the same name (a reloaded tab, a relaunched native client, or two people who both kept "guest") restarted its sequence at 1 and every write it made was dropped as a duplicate: it joined a room in which nobody, including the relay's roster, could see it, until the relay restarted. Found with a reloaded browser (?name=webby) and relaunched native clients. The id is now the sanitized name plus a random six-character suffix per launch, so each session is a new participant; the roster still shows the display name (profile.name), and the document identifies "me" by self.pid, which follows. * arisrv: log the stack on a fatal signal (Linux/glibc) The relay runs unattended, and during the mixed-transport test it died once with "malloc(): unsorted double linked list corrupted" and nothing else to go on (no gdb on the builder, apport ignores unpackaged binaries, and an ASan build did not reproduce it). On SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL it now writes the stack to stderr with backtrace_symbols_fd (no allocation; backtrace() is warmed up at startup so the handler never lazy-loads libgcc on a corrupted heap), then re-raises with the default action. * ci: run the master-targeted matrix (base retargeted to master after #773) * Conference link stats: Ping/Pong probes, per-link traffic, room net stats, compact roster Link probe + stats (both transports, every connection): - state_transport.proto: Ping/Pong frames. Each end pings every 2 s (state_transport::set_ping_interval); the other end answers at once and the sender measures the round trip on its own clock. Link-level only: never replicated, relayed or bridged. - state_link_stats (lock-free per-connection bytes/frames both ways, RTT EWMA / last / min) + state_link_rate_meter (cumulative reports -> bytes/s); state_transport::link_reports() on state_transport_ws and _grpc, so a relay's links to clients, a client's link to its relay and a server's links to peer (delegation) servers are all measured the same way. Background tabs no longer drop out: a browser answers the relay's Ping from its WebSocket onmessage event, which a backgrounded tab still receives, so the idle reaper keeps it; a ghost that cannot answer is still reaped. And arigl now redials a lost relay link every 3 s and re-asserts its presence afterwards. Room stats (room_net.h): each client publishes its own link quality as participants.<pid>.net.{rtt_ms,in_kbps,out_kbps}; the relay publishes its view of each participant (net.relay.*) and its totals (server.net.*) and logs them (per link every ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each minute). (room-roster) entries carry the numbers and roster-cell text. conference_roster.ari: the Users window is a compact table -- one row per participant (name | ping | down | up | cam | mic | vol), each row a grid with the same fixed column widths, so the rows line up as columns; your own row shows your measured link. Peer camera tiles moved right of the wider window. Tests: state_link_stats_test (3), StateTransportWsTest.LinkProbeMeasures- RoundTripAtBothEnds + QuietClientAnsweringPingsIsNotReaped, StateTransportGrpc- Test.LinkProbeMeasuresRoundTripAtBothEnds; RelayReapsIdleConnection turns the probe off to keep testing a silent client. * state_transport_grpc_test: leave only after the relay has read the Hello ConnectionClosedReportsThePeersNodeId stopped the client as soon as both ends counted a connection. Cancelling a gRPC stream drops frames the relay has not read yet, so the relay sometimes never saw the client's Hello -- and a stream that never identified itself is, by contract, not reported. The link probe's extra threads shifted the timing enough to expose it (about 1 run in 5 after ThreeEndpointFanOut). Wait until the relay's link report names the client. 20/20 repeats of the reproducing sequence pass. * arigl: single space before the <random> include comment (changed-lines format gate) * conference_roster: widen the ping column (a congested link reads 4 digits) * Transports: a peer that stops reading can no longer hang the relay; RTT median Production stalled for everyone after a browser tab froze: a write to that client blocked once its TCP buffers filled, while holding the connection's write lock, so the relay's main loop (and the idle reaper that would have dropped the client, which runs on it) waited on the lock forever. The backlog thresholds only make that less likely; nothing bounded it. - Write watchdog (WS + gRPC): each connection records when a write starts; the probe thread closes a connection whose write has been blocked > 3 s (socket shutdown / stream TryCancel), so the write fails and the lock is freed. The client redials (arigl reconnect) and gets a fresh snapshot. - The probe never waits: Pings use try_write (skip a busy connection) and skip one with more than 64 KB unsent. - gRPC: the server handler's cancel hook is cleared under a mutex before its ServerContext goes away. - state_link_stats: rtt_ms is now the median of the last 8 round trips; the moving average carried a 14.8 s probe (answered after a frozen tab woke) for minutes. Tests: StateTransportWsTest.WriteToAPeerThatStopsReadingIsAbortedNotHung (raw peer that handshakes and never reads; 32 MB media frames), StateLinkStatsTest. MedianShrugsOffAStall. * Room: click-to-face, name labels, stamped intents and a client clock lead Facing: W/S walk forward/back along the avatar's facing and A/D strafe; a click on the scene (not a drag) turns the avatar toward the clicked ground point. avatar_intent carries a yaw, replicated as intent.yaw, and the sim faces it; each marker gets a visor so facing is visible. The room-set-intent verb takes an optional YAW (default: face the walk). Labels: each peer's roster name is drawn above its marker, through a new ImGuiBackend frame hook so it runs in cvcGL's ImGui context. Timing: a proposed intent is stamped with the tick the client predicts it from (intent.tick); the server holon queues it and applies it at that tick. The client clock runs ahead of the server by the measured RTT plus a margin, so intents arrive before their tick and the authoritative pose matches the prediction. The relay steps the room at ~20 Hz and write_pose writes only changed leaves plus a ~1 s heartbeat. The holon now ignores a pose sample it has already seen (a re-read of an unchanged pose would otherwise drag the clock back), and peer walk spans are capped. * Room: name labels are a scene annotation, drawn even with the panels hidden ImGuiOverlay gains setSceneDrawCallback: run every frame, panels open or not, before the UI, so labels drawn into the background draw list sit under the document's windows. This replaces the ImGuiBackend frame hook, whose callback only ran while the panels were open (a phone starts with them hidden). * Room: the conference document declares the light rig and real-time shadows conference_roster.ari gains a scene: block: a three-point StageLighting rig staged over the 20 m floor whose key spot casts shadows from the scene objects and every avatar, re-baked each frame so a moving avatar's shadow moves with it. The relay's default room env no longer carries a sun: the room owns what is in it, the client document owns how it looks. The room env can still turn shadows on (scene.env.shadows.{enabled, resolution}, replicated); read_room_scene asserts them only when a room does, so a room that leaves them off never switches off the document's. * Room: rigid-body avatars in a Jolt world Avatars are now Jolt bodies (cvc::physics::world, cross-platform determinism): a dynamic box with a mass, under gravity, colliding with each other and with the room's statics (the floor slab, the cube, any box or sphere in the room scene). An intent drives its avatar: the horizontal velocity eases toward the intent's at up to max_accel, and the body is held upright at its facing; pushes, stops and falls are the solver's. An avatar that falls off the world respawns where it joined. The relay's holon simulates every avatar. A client's holon predicts its own avatar in a local world with each peer as a kinematic body at the pose it is shown, and on a divergence snaps to the authority and replays its logged intents through that world. holon_sync builds the statics from the room scene (room_statics) and reloads them when the scene revision changes; room_holon_config() is the configuration the relay and every client share. A build without the Jolt backend keeps the kinematic avatar_sim path, with a warning. write_pose's changed-only test is now numeric (1e-4): a resting rigid body jitters in its last float bits every step, which is not a change. * arigl: propose the user's intent only when they change it; follow the document's otherwise step_room proposed the key/facing intent on the first frame and again when framing set the initial facing, which overwrote an intent the document set with (room-set-intent ...) -- a scripted walker never walked. Propose only when the user's own ask changes (a key, a click to face); otherwise run the prediction on the intent the tree holds for us. The first-frame proposal guarded against a stale walk under a reused node id, which per-launch ids and the relay's departure reset already rule out. * arigl: until the user clicks to face, W walks into the screen The initial facing was the camera heading captured when the camera was framed on our avatar, which is not the view the user ends up with: in the browser the first W walked toward the camera. Until the user clicks to face, the facing now follows the live camera heading while no movement key is held (and holds while one is), mirrored into the last user intent so orbiting while idle proposes nothing. * Conference link stats: Ping/Pong per connection, background tabs stay, compact roster with ping + bandwidth (#781) * Conference link stats: Ping/Pong probes, per-link traffic, room net stats, compact roster Link probe + stats (both transports, every connection): - state_transport.proto: Ping/Pong frames. Each end pings every 2 s (state_transport::set_ping_interval); the other end answers at once and the sender measures the round trip on its own clock. Link-level only: never replicated, relayed or bridged. - state_link_stats (lock-free per-connection bytes/frames both ways, RTT EWMA / last / min) + state_link_rate_meter (cumulative reports -> bytes/s); state_transport::link_reports() on state_transport_ws and _grpc, so a relay's links to clients, a client's link to its relay and a server's links to peer (delegation) servers are all measured the same way. Background tabs no longer drop out: a browser answers the relay's Ping from its WebSocket onmessage event, which a backgrounded tab still receives, so the idle reaper keeps it; a ghost that cannot answer is still reaped. And arigl now redials a lost relay link every 3 s and re-asserts its presence afterwards. Room stats (room_net.h): each client publishes its own link quality as participants.<pid>.net.{rtt_ms,in_kbps,out_kbps}; the relay publishes its view of each participant (net.relay.*) and its totals (server.net.*) and logs them (per link every ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each minute). (room-roster) entries carry the numbers and roster-cell text. conference_roster.ari: the Users window is a compact table -- one row per participant (name | ping | down | up | cam | mic | vol), each row a grid with the same fixed column widths, so the rows line up as columns; your own row shows your measured link. Peer camera tiles moved right of the wider window. Tests: state_link_stats_test (3), StateTransportWsTest.LinkProbeMeasures- RoundTripAtBothEnds + QuietClientAnsweringPingsIsNotReaped, StateTransportGrpc- Test.LinkProbeMeasuresRoundTripAtBothEnds; RelayReapsIdleConnection turns the probe off to keep testing a silent client. * state_transport_grpc_test: leave only after the relay has read the Hello ConnectionClosedReportsThePeersNodeId stopped the client as soon as both ends counted a connection. Cancelling a gRPC stream drops frames the relay has not read yet, so the relay sometimes never saw the client's Hello -- and a stream that never identified itself is, by contract, not reported. The link probe's extra threads shifted the timing enough to expose it (about 1 run in 5 after ThreeEndpointFanOut). Wait until the relay's link report names the client. 20/20 repeats of the reproducing sequence pass. * conference_roster: widen the ping column (a congested link reads 4 digits) * Transports: a peer that stops reading can no longer hang the relay; RTT median Production stalled for everyone after a browser tab froze: a write to that client blocked once its TCP buffers filled, while holding the connection's write lock, so the relay's main loop (and the idle reaper that would have dropped the client, which runs on it) waited on the lock forever. The backlog thresholds only make that less likely; nothing bounded it. - Write watchdog (WS + gRPC): each connection records when a write starts; the probe thread closes a connection whose write has been blocked > 3 s (socket shutdown / stream TryCancel), so the write fails and the lock is freed. The client redials (arigl reconnect) and gets a fresh snapshot. - The probe never waits: Pings use try_write (skip a busy connection) and skip one with more than 64 KB unsent. - gRPC: the server handler's cancel hook is cleared under a mutex before its ServerContext goes away. - state_link_stats: rtt_ms is now the median of the last 8 round trips; the moving average carried a 14.8 s probe (answered after a frozen tab woke) for minutes. Tests: StateTransportWsTest.WriteToAPeerThatStopsReadingIsAbortedNotHung (raw peer that handshakes and never reads; 32 MB media frames), StateLinkStatsTest. MedianShrugsOffAStall. * Holon authority: the server vetoes intents and can override avatars (#788) Roadmap §5h's propose -> veto -> override, now that avatars are rigid bodies (an unchecked intent is a real abuse vector: asking for 1000 m/s would hurl an avatar through its peers). Veto: holon::sanitize_intent caps the horizontal speed at max_speed (scaled, direction kept), drops the vertical unless allow_vertical, turns a non-finite move into no move and a non-finite yaw into +X, and wraps the yaw to [-pi, pi]. Every intent passes it on the way in -- set_intent and queue_intent on the server, set_intent on a client -- so an honest client predicts exactly what the server will integrate, and one that asks for more is overruled on reconcile. The server counts its vetoes (arisrv's room log prints them). The conference cap is 4 m/s, twice the walk. Override: holon::override_state teleports an avatar and holon::set_mass re-masses it (physics; avatar_world::set_mass, kept across set_statics). Both are the authority's alone (false on a client), and the result replicates like any authoritative pose. * S7: relay-side avatar interest, ghost-free clients, opt-in mesh LOD (#789) * Holon authority: the server vetoes intents and can override avatars Roadmap §5h's propose -> veto -> override, now that avatars are rigid bodies (an unchecked intent is a real abuse vector: asking for 1000 m/s would hurl an avatar through its peers). Veto: holon::sanitize_intent caps the horizontal speed at max_speed (scaled, direction kept), drops the vertical unless allow_vertical, turns a non-finite move into no move and a non-finite yaw into +X, and wraps the yaw to [-pi, pi]. Every intent passes it on the way in -- set_intent and queue_intent on the server, set_intent on a client -- so an honest client predicts exactly what the server will integrate, and one that asks for more is overruled on reconcile. The server counts its vetoes (arisrv's room log prints them). The conference cap is 4 m/s, twice the walk. Override: holon::override_state teleports an avatar and holon::set_mass re-masses it (physics; avatar_world::set_mass, kept across set_statics). Both are the authority's alone (false on a client), and the result replicates like any authoritative pose. * S7 groundwork: per-peer delivery filter, journal trim + O(new) replay, shard admits(); scaffold the S7 sources and tests * S7 groundwork: push_snapshot_to and media gate hooks on the transport interfaces * S7: relay-side avatar interest, ghost-free clients, opt-in mesh LOD Relay (arisrv): - room_aoi: per-receiver distance tiers (inner/middle/outer) with a downgrade dwell, conference and hall presets; the conference preset's inner radius covers the whole 20 m room. - room_aoi_relay: installs a per-peer delivery filter (deny another participant's intent, a non-inner subject's pose subtree, a departed subject's leaves), pushes non-inner poses at 5 Hz with a fresh server_tick before the drain, and gates media (middle: 2 fps camera thumbnails + audio, outer: nothing). - arisrv flags: --aoi, --aoi-video-max, --relay-gate, --relay-journal, --media-stall-ms, --scene large, and --s7 off to restore S6 behaviour. Transports and state core: - write-once peer identity on gRPC and WS connections; the relay interest gate (opt-in) uses shard admits(); forwards, snapshot replies and the WS broadcast honour the peer registry; push_snapshot_to; media gate and gRPC stall drop. - journal trim after each drain on the relay. Clients: - the arigl shard is rooted at "conf" (CVC_ARIGL_REPLICATE_ROOT restores the whole tree); pose leaves are journaled once per change; a peer gets no body and no marker until its first pose; distance audio gain and a voice.talking leaf. LOD: - LodPyramidCache builds one QEM pyramid per distinct mesh off the frame; realize_scene takes RealizeOptions; room_view selects LOD each frame; seed_large_demo_scene adds a 100 m hall with a grid of bunnies. Tests: room_aoi, room_aoi_relay, the loopback room relay integration test, new transport/holon/state cases, cvcgl_lod_cache and room_view LOD cases; arisrv_aoi_bench measures per-receiver traffic. * S7: read only whole pose samples; fix two integration-test races - read_pose now requires the position, orientation and tick leaves and a non-degenerate quaternion (normalised). The leaves replicate one by one, so a reader could catch a subtree half applied; with peers now given a body only at their first sample, that zero quaternion reached add_body and threw. - The test relay (and the bench) place newcomers until a pass finds none: a join landing between the two pumps used to leave the AOI seeing a participant on the spawn ring first. - The reconnect test no longer rejoins under the same node id, which replication's (origin, sequence) seen-set cannot accept; it checks the zeroed-intent bootstrap under that id, then joins a relaunch under a fresh id as arigl does. * gRPC transport: an accepted stream is never written after its handler returns The server handler's ServerReaderWriter dies when Channel() returns, but publish, the relay's forwards and the link probe write to it from other threads. The handler only cleared `alive`, so a write already past that check kept using the freed stream: under parallel load on Windows the star-relay tests crashed in CompletionQueue::Pluck from send_pings (access violations, heap corruption, aborts in 4 of 6 runs). The handler now cancels the stream, failing any Write blocked on it, then takes write_mu once: every write runs under write_mu and checks `alive` there, so none is in progress or starts after the stream goes. * S7: keep the AOI's bookkeeping out of cvc.dll's export table WINDOWS_EXPORT_ALL_SYMBOLS exports every std:: container instantiated over a cvc type, and S7 left cvc.dll at 65,414 of its 65,535 export slots (gRPC build). room_aoi's members and pair state, the relay driver's per-transport registrations and pose versions, and the media gate's thumbnail timers are now file-local types behind a pimpl, and aoi_snapshot stores its tiers as uint8_t, so their instantiations are no longer exported: 64,941 names (473 freed). Behaviour is unchanged. * S7 review fixes: no relay crash on odd pids, bounded media waits, departed and rejoin handling From an adversarial review of the S7 branch: - A participant pid that is no C identifier (the room tree takes any child name) made state_peer_registry::add_peer throw out of the room step and abort the relay; such a pid is now filtered as a subject but never registered as a receiver (no connection can carry it). - gRPC media: write_mu is a timed mutex and a media frame waits at most the stall window for a receiver's stream, instead of only sampling the in-flight write's start once and then queueing unbounded; the gate is asked once the stream is held, so a dropped frame spends no thumbnail. - A departed subject keeps only pose and intent withheld (S6 forwarded its net and voice, and an in-process reconnect's voice writes landed in the departed window), and the departed set is capped at the most recent 1024. - A client no longer takes the pose subtree a departed peer left behind as the pose of its rejoin (no body at the old spot, no clock seek back); a restarted relay's low ticks still count as new. - voice.talking: arigl forces its flips, so a stale echo of its own leaf from the WS re-broadcast cannot swallow the next one. - arisrv always writes the AOI params (disabled with --aoi off / --s7 off), so a client that kept an earlier relay's leaves restores gain 1; numeric flags and CVC_ARISRV_S7 are validated (usage error, exit 2). - LodPyramidCache skips the content hash for a mesh over the build cap. - The loopback suite is RoomRelayLoopbackTest: CI excludes names matching "Integration" from PR and recipe runs. * S7 docs: measured bench results, known limits, and the review's corrections * S7 loopback tests: lossless media and paced egress, so a loaded runner can't fail them DefaultRoomUnchangedUnderConferencePreset failed about half its runs on a loaded Linux runner. The cause was the WebSocket transport's backlog drop, not the gate. On Linux that drop sheds a media frame whenever a starved receiver's socket holds more than 32 KB unsent, so with the AOI on or off a few percent of frames never arrived (0.914 to 0.972 against the 0.98 floor). - The backlog drop is now state_transport_ws::set_media_backlog_drop(bytes), still 32 KB by default, with 0 turning it off. Its skips are counted in total_media_backlog_dropped(), and arisrv's 5 s AOI line prints them as "backlog". - The loopback test relay and clients turn the drop off, as gRPC's stall drop already is. Media is then lossless on loopback, and the default-room and middle-tier media checks are exact (every frame lands) instead of a 2% tolerance. - ControlPlaneEgressDrops compared per-second rates. run_room skips ahead when behind, so a starved loop ran fewer client frames per room step, which shrinks the echo's share of the pre-S7 egress. The ratio fell from about 4.3 to 2.3, under the required 3. The measurement now runs a fixed 30 steps with exactly 3 client frames per 2 steps, and compares bytes per second of room time. Tested on catx-03, the tests pinned to two cores beside four busy loops: - the default-room test, 30/30 runs (5/10 before); - the middle-tier media test, 30/30 (it hadn't failed yet, but the same drop could cost it a mic chunk); - the egress test, 8/8 (1/3 before; ratio 4.2 to 4.4 with or without contention). On the loaded Windows box the egress test passes 5/5 (0/5 before), and the S7 set there passes 134/134. * WS re-broadcast: per-connection diffs, full tree only on request (#791) * WS broadcast_snapshot: send each connection a diff, full tree only on request The relay's ~1 Hz WebSocket re-broadcast wrote every connection the whole room tree each call. In the hall (large scene) that was most of a browser's state traffic. It exists to heal a client that missed a live re-fan, so it now sends only what each connection does not already hold. Per connection the transport keeps, for each path, a digest of the content the last snapshot frame (the admit snapshot or a broadcast) carried. It forgets a path whenever any other frame touching it is written to that peer (a live mutation, a targeted push) or arrives from it (the peer's own write). A broadcast then sends the entries whose digest differs, plus the forgotten ones; an unchanged tree sends nothing. Because a live frame forgets the path, a change the client missed after one it received is re-sent even when the tree is back to what the last broadcast carried. A peer's own write is re-sent with the tree's value, which also corrects a write the relay overrode. The bookkeeping sits under its own mutex, which is never held across a socket write (a reader thread forgets its peer's writes). A writer holds write_mu from its record or forget through its write, so the record matches the order the peer receives frames. The whole tree goes to a peer only when it asks: the new client call state_transport_ws::request_full_refresh() sends a SnapshotRequest, and that peer's next broadcast carries everything it is delivered. That covers state a client dropped on its own side, which the relay cannot see. There is no periodic full snapshot. The admit snapshot seeds the record, so a new connection's first broadcast is already a diff. The per-connection registry filtering (delivery_view) and the 2 MB backlog skip are unchanged. A skipped connection keeps its record, so its next broadcast carries everything that changed meanwhile. Tests: BroadcastDiffConvergesAClientThatDroppedFrames covers a missed re-fan, a missed change after a received live frame, a frame the client dropped itself (healed only by request_full_refresh), and the client's own overridden write. BroadcastSnapshotIsPerConnectionFiltered now also checks that a second broadcast with nothing changed writes nothing. * Roadmap 5h.1: measured WS re-broadcast diffs against the full tree * WS transport: a write to a peer that stops reading no longer hangs on Windows (#794) A peer that stops reading makes the relay's synchronous WebSocket write block once the socket buffers fill, while it holds the connection's write lock. The probe thread's watchdog handles that by calling shutdown() on the socket after kWriteStallNs (3 s), which wakes the blocked send() on Linux. Winsock's shutdown() does not wake a send() blocked in another thread, so on Windows the write never returned. StateTransportWsTest. WriteToAPeerThatStopsReadingIsAbortedNotHung hung forever. The watchdog did fire ("blocked > 3 s ... closing"). On Windows every connection's socket now gets SO_SNDTIMEO = kWriteStallNs, set right after it is created (accepted or dialed). A blocked send then fails with WSAETIMEDOUT at the same 3 s limit, the write returns an error, and the connection is marked dead and releases its lock. No other thread closes the socket. kWriteStallNs moves out of ping_loop so both share it. Other platforms are unchanged: bound_blocking_writes is a no-op there. Windows (static WS+gRPC build, MSVC 14.44): the test passes in ~3.1 s, the whole state_transport_ws_test suite is 23/23 twice with nothing excluded, and room_relay_integration_test is 10/10. * clang-format the S7 lines #783's changed-lines check flags media_endpoint.h's media_gate_fn and state_change_journal.cpp's two upper_bound calls, as clang-format 18.1.8 lays them out. Formatting only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First slice of H1 (the single holon) and the concrete dependency H0's audit flagged: a holon replicates a participant's avatar pose as a node's
data(), but nocvc::physicstype had a registered data-type name or a codec, so none could cross the cluster.What
cvc::physics::physics_codec(inc/cvc/physics/physics_codec.h+src/cvc/physics/physics_codec.cpp), mirroring thecvc::ariadne::stream::format_desccodec:doubleserializerstream_codeclacked:put_f64writes the IEEE-754 binary64 bit pattern as 8 LE bytes (memcpy, C++17-clean, matching the built-incvc.f64.v1double codec).body_state= 13 doubles (position, rotation, linear/angular velocity) + anactivebyte;pose= 7 doubles. Decoder bounds-checks (reader{ok}) and throws on truncation.register_physics_data_types(app&)+register_physics_codecs(state_codec_registry&)key theregisterDataTypename and the registry codec on the identical stringscvc::physics::body_state/cvc::physics::pose— sodataTypeName()always matches a registered codec (the H0: close cvc::state data() object replication (§52) — integer codec + value type_name + IPC/echo tests + roadmap #767 S1 mismatch class).body_statehas none; it's inbody_settings, server-owned).Testing
physics_codec_test(8 cases, all green): bit-for-bit round-trip ofbody_stateandposeincl. NaN / Inf / −0.0 / denormals; registryhas()both; registry key ==dataTypeName<T>(); golden fixed-width LE wire (105 / 56 bytes); truncated payloads throw. clang-format 18.1.8.Context
Zero upstream dependencies; architecturally neutral (pure serialization — useful for the MVP pose, reconcile snapshots, and H2 regardless of the scalar-vs-typed MVP choice). Branches off master (has #766 clock_driver). The remaining H1 slices (predict/reconcile harness, the
cvc::ariadne::holonbundle, the intent→validate→veto verb, shared scene, render nodes, interest+LOD) and their architecture decisions are being surfaced to the author separately.