Skip to content

H1 slice 1: cvc::physics body_state/pose replication codec - #768

Merged
transfix merged 2 commits into
masterfrom
feat/h1-single-holon
Oct 22, 2026
Merged

transfix merged 2 commits into
masterfrom
feat/h1-single-holon

Conversation

@transfix

Copy link
Copy Markdown
Owner

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 no cvc::physics type 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 the cvc::ariadne::stream::format_desc codec:

  • A little-endian, fixed-width, field-by-field wire (never a struct memcpy — trailing-bool padding / 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 = 13 doubles (position, rotation, linear/angular velocity) + an active byte; pose = 7 doubles. Decoder bounds-checks (reader{ok}) and throws on truncation.
  • register_physics_data_types(app&) + register_physics_codecs(state_codec_registry&) key the registerDataType name and the registry codec on the identical strings cvc::physics::body_state / cvc::physics::pose — so dataTypeName() 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).
  • Mass is deliberately not on the wire (body_state has none; it's in body_settings, server-owned).

Testing

physics_codec_test (8 cases, all green): bit-for-bit round-trip of body_state and pose incl. NaN / Inf / −0.0 / denormals; registry has() 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::holon bundle, the intent→validate→veto verb, shared scene, render nodes, interest+LOD) and their architecture decisions are being surfaced to the author separately.

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.
@transfix
transfix merged commit 1b73dcc into master Oct 22, 2026
24 checks passed
@transfix
transfix deleted the feat/h1-single-holon branch October 22, 2026 20:26
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant