diff --git a/clients/android/native/src/decode/presenter.rs b/clients/android/native/src/decode/presenter.rs index 928daa25..1a159c46 100644 --- a/clients/android/native/src/decode/presenter.rs +++ b/clients/android/native/src/decode/presenter.rs @@ -138,6 +138,29 @@ impl PresentMeter { } } +/// Circular (vector-mean) statistics of latch samples against the panel period: the mean latch +/// mod the period (ns) and the coherence (‰). The mean is what a phase controller can actually +/// steer under jitter — a median of a period-spanning distribution is immovable (the v1 lesson); +/// the coherence says whether ANY phase exists to steer (0 = uniformly smeared, 1000 = locked). +/// `None` under 8 samples — too little evidence to report a phase at all. +fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)> { + if samples_us.len() < 8 || period_ns <= 0 { + return None; + } + let period_us = period_ns as f64 / 1000.0; + let (mut x, mut y) = (0.0f64, 0.0f64); + for &s in samples_us { + let theta = (s as f64 % period_us) / period_us * std::f64::consts::TAU; + x += theta.cos(); + y += theta.sin(); + } + let n = samples_us.len() as f64; + let r = (x * x + y * y).sqrt() / n; + let mean_theta = y.atan2(x).rem_euclid(std::f64::consts::TAU); + let mean_ns = (mean_theta / std::f64::consts::TAU * period_ns as f64) as u64; + Some((mean_ns, (r * 1000.0) as u16)) +} + /// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty. fn p50_max_ms(mut v: Vec) -> (f64, f64) { if v.is_empty() { @@ -340,13 +363,14 @@ impl Presenter { /// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) / /// `vsync` (the measured panel period). /// - /// Returns this window's measured latch p50 (ns) when a window actually flushed — the - /// phase-lock reporter's `arrival_lead` error signal (design/phase-locked-capture.md §6). + /// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period, + /// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal + /// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter). pub(super) fn flush_log( &mut self, meter: &PresentMeter, clock: Option<&VsyncShared>, - ) -> Option { + ) -> Option<(u64, u16)> { if self.last_flush.elapsed() < std::time::Duration::from_secs(1) { return None; } @@ -356,6 +380,8 @@ impl Presenter { return None; // idle stream — nothing worth a line } let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us)); + let circ = + clock.and_then(|c| circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))); let (latch_p50, latch_max) = p50_max_ms(latch); let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0); let panel_ms = clock @@ -364,7 +390,8 @@ impl Presenter { log::info!( target: "pf.present", "released={} displays={} paced={} noBudget={} forced={} qDry={} \ - paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} vsyncMs={:.2} panelMs={:.2}", + paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} circ={:.2}ms coh={} \ + vsyncMs={:.2} panelMs={:.2}", self.released, displays, self.paced_drops, @@ -375,6 +402,8 @@ impl Presenter { pace_max, latch_p50, latch_max, + circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0), + circ.map(|(_, c)| c).unwrap_or(0), period_ms, panel_ms, ); @@ -383,7 +412,7 @@ impl Presenter { self.no_budget = 0; self.forced = 0; self.dry = 0; - (latch_p50 > 0.0).then(|| (latch_p50 * 1e6) as u64) + circ } } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 01211429..54495f87 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -4029,6 +4029,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_phase( latch_period_ns: u32, uncertainty_ns: u32, arrival_lead_ns: u32, + coherence_milli: u16, ) -> PunktfunkStatus { guard(|| { // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller @@ -4042,6 +4043,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_phase( latch_period_ns, uncertainty_ns, arrival_lead_ns, + coherence_milli, ); PunktfunkStatus::Ok }) diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 3218ecf7..ba36f9f7 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -787,6 +787,7 @@ impl NativeClient { latch_period_ns: u32, uncertainty_ns: u32, arrival_lead_ns: u32, + coherence_milli: u16, ) { let _ = self .ctrl_tx @@ -795,6 +796,7 @@ impl NativeClient { latch_period_ns, uncertainty_ns, arrival_lead_ns, + coherence_milli, })); } diff --git a/crates/punktfunk-core/src/quic/control.rs b/crates/punktfunk-core/src/quic/control.rs index 62e01cee..54155cc4 100644 --- a/crates/punktfunk-core/src/quic/control.rs +++ b/crates/punktfunk-core/src/quic/control.rs @@ -175,9 +175,15 @@ pub struct PhaseReport { /// The client's own error bound on this grid (skew residual + latch jitter p95) — the host /// widens its arrival margin by this, never narrows below its floor. pub uncertainty_ns: u32, - /// Measured median lead of frame ARRIVAL before latch over the last window (ns; clamped - /// ≥ 0). The controller drives this toward its target lead — the error signal. + /// Measured lead of frame arrival before latch over the last window (ns; clamped ≥ 0). + /// v2 senders put the CIRCULAR (vector-mean) lead mod the panel period here; v1 senders put + /// the window median. The controller drives this toward its target lead — the error signal. pub arrival_lead_ns: u32, + /// v2 tail: circular coherence of the arrival phase, ‰ (0 = phase uniformly smeared over the + /// period — alignment is physically pointless; 1000 = perfectly phase-locked arrivals). + /// [`u16::MAX`] = a v1 sender (25-byte wire form) that reported a median with no coherence — + /// the host then relies on its travel-cap safety net alone. + pub coherence_milli: u16, } /// Type byte of [`Reconfigure`] (first byte after the magic). @@ -490,18 +496,23 @@ impl ClockEcho { impl PhaseReport { pub fn encode(&self) -> Vec { // magic[0..4] type[4] latch[5..13] period[13..17] uncertainty[17..21] lead[21..25] - let mut b = Vec::with_capacity(25); + // coherence[25..27] — the v2 tail; a MAX sentinel encodes as the 25-byte v1 form so a + // v1-shaped report stays byte-identical (append discipline, like the 0xCF tail). + let mut b = Vec::with_capacity(27); b.extend_from_slice(CTL_MAGIC); b.push(MSG_PHASE_REPORT); b.extend_from_slice(&self.next_latch_host_ns.to_le_bytes()); b.extend_from_slice(&self.latch_period_ns.to_le_bytes()); b.extend_from_slice(&self.uncertainty_ns.to_le_bytes()); b.extend_from_slice(&self.arrival_lead_ns.to_le_bytes()); + if self.coherence_milli != u16::MAX { + b.extend_from_slice(&self.coherence_milli.to_le_bytes()); + } b } pub fn decode(b: &[u8]) -> Result { - if b.len() != 25 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PHASE_REPORT { + if !(b.len() == 25 || b.len() == 27) || &b[0..4] != CTL_MAGIC || b[4] != MSG_PHASE_REPORT { return Err(PunktfunkError::InvalidArg("bad PhaseReport")); } let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); @@ -510,6 +521,11 @@ impl PhaseReport { latch_period_ns: u32at(13), uncertainty_ns: u32at(17), arrival_lead_ns: u32at(21), + coherence_milli: if b.len() == 27 { + u16::from_le_bytes(b[25..27].try_into().unwrap()) + } else { + u16::MAX // v1 sender — no coherence signal + }, }) } } @@ -983,12 +999,25 @@ mod tests { latch_period_ns: 8_333_333, uncertainty_ns: 900_000, arrival_lead_ns: 4_100_000, + coherence_milli: 742, }; - assert_eq!(PhaseReport::decode(&pr.encode()).unwrap(), pr); + let d = pr.encode(); + assert_eq!(d.len(), 27, "a real coherence rides the v2 tail"); + assert_eq!(PhaseReport::decode(&d).unwrap(), pr); + // The MAX sentinel encodes as the 25-byte v1 form and roundtrips back to the sentinel. + let v1 = PhaseReport { + coherence_milli: u16::MAX, + ..pr + }; + let d1 = v1.encode(); + assert_eq!(d1.len(), 25); + assert_eq!(&d1[..25], &d[..25], "v1 form is a strict prefix of v2"); + assert_eq!(PhaseReport::decode(&d1).unwrap(), v1); // Wrong type byte (a ClockProbe) must not decode as a PhaseReport. assert!(PhaseReport::decode(&ClockProbe { t1_ns: 7 }.encode()).is_err()); - // Truncation must not decode. - assert!(PhaseReport::decode(&pr.encode()[..24]).is_err()); + // Truncation to a length that is neither form must not decode. + assert!(PhaseReport::decode(&d[..24]).is_err()); + assert!(PhaseReport::decode(&d[..26]).is_err()); } #[test] diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index fc7b2ee1..ba74ed3a 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -253,19 +253,34 @@ impl PhaseCtl { } } -/// The encode loop's phase controller state (design/phase-locked-capture.md §3): a per-frame -/// HOLD before submit, walked toward the client's reported arrival lead hitting the target lead. +/// The encode loop's phase controller state (design/phase-locked-capture.md §3, controller +/// v2): a per-frame HOLD before submit, driven by the client's CIRCULAR arrival-phase report. /// Plain data — lives as a loop local so it survives every in-loop rebuild path; a new session /// (new loop call) starts unlocked, which is correct (new client, new grid). +/// +/// v2 lessons (both measured on-glass 2026-07-31, NP3 ↔ .173): +/// * A median lead is a DEAD signal under period-spanning jitter (the distribution is ~uniform +/// mod the refresh; shifting its mean can't move its median) — v1 orbited the period at +/// 2 ms/s forever, and every wrap coalesced ~30 frames at the client. +/// * A HELD hold is not free: it delays sampling AFTER the capture stamp, so an orbiting or +/// parked hold taxes e2e by up to a period (user-visible: ~+4 ms average during the orbit). +/// The failure response is therefore DECAY TO ZERO — the pre-phase-lock behavior — never +/// freeze-in-place. +/// +/// The v2 signal is the circular (vector-mean) lead + coherence; stepping happens only while +/// the phase is coherent, along the signed SHORTEST way around the period, under a cumulative +/// travel budget that catches any residual chase the statistics miss. struct PhaseController { /// Applied per-frame hold before submit, ns ∈ [0, period). hold_ns: i64, /// Last adjust instant (~1 Hz cadence). last_adjust: std::time::Instant, - /// The previous adjust's error (ns) — the dead-signal detector's memory. - last_error_ns: Option, - /// Consecutive adjusts whose step failed to move the error (see `DEAD_SIGNAL_ADJUSTS`). - unresponsive_adjusts: u32, + /// |step| integrated since the last convergence/decay — the chase detector. Any true lock + /// needs at most ~one period of travel, so exceeding the budget proves the error is not + /// converging regardless of what the (noisy) reports claim. + cum_travel_ns: i64, + /// The travel budget tripped — decay the hold to zero, then re-arm. + decaying: bool, } impl PhaseController { @@ -278,75 +293,71 @@ impl PhaseController { /// The lead floor the controller drives toward: SurfaceFlinger-class compositors need the /// frame in the queue ~2.5 ms before latch; the client's own `uncertainty_ns` widens this. const TARGET_LEAD_FLOOR_NS: i64 = 2_500_000; + /// Below this circular coherence (‰) the arrival phase is smeared over the period and + /// alignment is physically pointless — decay instead of stepping. `u16::MAX` (a v1 report, + /// median semantics) bypasses the gate and relies on the travel budget alone. + const COHERENCE_FLOOR_MILLI: u16 = 300; fn new() -> PhaseController { PhaseController { hold_ns: 0, last_adjust: std::time::Instant::now(), - last_error_ns: None, - unresponsive_adjusts: 0, + cum_travel_ns: 0, + decaying: false, } } - /// Consecutive adjusts allowed to move the hold WITHOUT the reported error responding - /// before the controller freezes (a dead error signal). Under period-spanning arrival - /// jitter the latch DISTRIBUTION is ~uniform mod the refresh: shifting its mean cannot - /// move its median, so a naive controller steps one way forever, orbiting the period — - /// and every wrap coalesces a burst of frames at the client (the 2026-07-31 on-glass - /// finding: `paced` spikes of ~30 every ~4 s, locked to the wraps). Frozen ≠ off: the - /// applied hold stays, and a report whose error moved ≥ [`Self::UNFREEZE_DELTA_NS`] - /// (the regime changed — jitter tightened) re-arms stepping. Controller v2 replaces the - /// median with a circular phase statistic; this freeze is the safety net either way. - const DEAD_SIGNAL_ADJUSTS: u32 = 3; - /// Error movement that counts as "the signal responded" / re-arms a frozen controller. - const UNFREEZE_DELTA_NS: i64 = 1_500_000; - /// Fold the client's latest report into the hold. `period_ns` is the wire interval (the - /// session's frame period). Sign convention: `arrival_lead` is how long before its latch the - /// median frame arrives — a LARGE lead means frames sit waiting at the client, so the host - /// should send LATER (grow the hold); a small/zero lead risks missing the latch, so send - /// EARLIER (shrink the hold, wrapping through the period when it hits zero — the newest-wins - /// capture slot makes a wrapped hold sample a fresher frame, not a staler one). + /// session's frame period). Sign convention: a positive (shortest-way) error means frames + /// arrive too early and wait at the client — send LATER (grow the hold); negative — send + /// EARLIER. `rem_euclid` wraps the hold through the period; the newest-wins capture slot + /// makes a wrapped hold sample a fresher frame, not a staler one. fn adjust(&mut self, r: &punktfunk_core::quic::PhaseReport, period_ns: i64) { if period_ns <= 0 { return; } self.last_adjust = std::time::Instant::now(); - let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000); - let error = r.arrival_lead_ns as i64 - target; - if error.abs() < Self::DEADBAND_NS { - self.unresponsive_adjusts = 0; - self.last_error_ns = Some(error); + // Incoherent phase, or a tripped travel budget: the hold is a pure e2e tax buying no + // alignment — walk it back to zero and wait for the regime to tighten. + let coherent = + r.coherence_milli == u16::MAX || r.coherence_milli >= Self::COHERENCE_FLOOR_MILLI; + if !coherent || self.decaying { + self.decay(); return; } - // Dead-signal detection: if stepping the hold hasn't moved the error, stop stepping — - // and while frozen, only a genuinely-moved error re-arms. - if let Some(last) = self.last_error_ns { - if (error - last).abs() >= Self::UNFREEZE_DELTA_NS { - self.unresponsive_adjusts = 0; // the signal responded (or the regime changed) - } else if self.frozen() { - self.last_error_ns = Some(error); - return; - } else { - self.unresponsive_adjusts += 1; - if self.frozen() { - tracing::info!( - error_ms = error as f64 / 1e6, - hold_ms = self.hold_ns as f64 / 1e6, - "phase lock: error signal unresponsive — freezing the hold" - ); - self.last_error_ns = Some(error); - return; - } - } + let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000); + // Signed SHORTEST-WAY error around the period — the controller never walks the long way. + let raw = (r.arrival_lead_ns as i64 - target).rem_euclid(period_ns); + let error = if raw > period_ns / 2 { + raw - period_ns + } else { + raw + }; + if error.abs() < Self::DEADBAND_NS { + self.cum_travel_ns = 0; // locked — the budget re-arms for the next disturbance + return; } - self.last_error_ns = Some(error); let step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS); self.hold_ns = (self.hold_ns + step).rem_euclid(period_ns); + self.cum_travel_ns += step.abs(); + if self.cum_travel_ns > period_ns + period_ns / 4 { + tracing::info!( + hold_ms = self.hold_ns as f64 / 1e6, + "phase lock: travel budget exhausted without convergence — decaying the hold" + ); + self.decaying = true; + } } - fn frozen(&self) -> bool { - self.unresponsive_adjusts >= Self::DEAD_SIGNAL_ADJUSTS + /// One decay tick: walk the hold toward zero; once there, re-arm the travel budget so a + /// later coherent regime can lock again. + fn decay(&mut self) { + if self.hold_ns > 0 { + self.hold_ns = (self.hold_ns - Self::MAX_STEP_NS).max(0); + } else { + self.decaying = false; + self.cum_travel_ns = 0; + } } /// Whether the ~1 Hz adjust window has elapsed. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index ff9dfa9e..4180f2e8 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -2796,7 +2796,8 @@ PunktfunkStatus punktfunk_connection_report_phase(const PunktfunkConnection *c, uint64_t next_latch_host_ns, uint32_t latch_period_ns, uint32_t uncertainty_ns, - uint32_t arrival_lead_ns); + uint32_t arrival_lead_ns, + uint16_t coherence_milli); #endif #if defined(PUNKTFUNK_FEATURE_QUIC)