feat(phase-lock): controller v2 — circular phase, coherence gate, decay-to-zero
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
android / android (push) Failing after 2m21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust (push) Canceled after 2m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m59s
deb / build-publish-host (push) Successful in 4m29s
deb / build-publish (push) Successful in 5m59s
flatpak / build-publish (push) Successful in 4m58s
arch / build-publish (push) Successful in 10m18s
windows-host / package (push) Successful in 11m6s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 21m16s

Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173):

- A median lead is immovable under period-spanning jitter (uniform mod P),
  so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze
  hotfix never armed, because ±1.5 ms of window sampling noise kept
  resetting its 'error responded' check. Thresholds can't referee noise.
- A HELD hold is not free: it delays sampling after the capture stamp,
  taxing e2e by up to a period (user-measured: ~+4 ms during the orbit).
  The correct failure response is DECAY TO ZERO, never freeze-in-place.

v2, all three layers:
- Wire: PhaseReport grows a length-discriminated coherence tail (27-byte
  form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 —
  strict-prefix discipline, both forms pinned by tests).
- Client: the reporter sends the CIRCULAR vector-mean latch phase mod the
  panel period + its coherence (‰); pf.present logs circ=/coh=.
- Host: steps only while coherent (floor 300‰; a v1 report bypasses the
  gate), along the signed SHORTEST way around the period, under a
  cumulative 1.25-period travel budget that is noise-immune by construction
  (it integrates applied steps, not reported errors); incoherence or budget
  exhaustion decays the hold to zero and re-arms once flat. Deadband
  convergence resets the budget — tight regimes lock exactly as designed.

report_phase + the C ABI mirror gain the coherence parameter (canary-only
ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy
--all-targets -D warnings (host+core, nvenc) clean; core suite 244/244;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:55:24 +02:00
co-authored by Claude Fable 5
parent a5896f0883
commit 1d31e4c565
6 changed files with 141 additions and 67 deletions
+34 -5
View File
@@ -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<u64>) -> (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<u64> {
) -> 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
}
}
+2
View File
@@ -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
})
+2
View File
@@ -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,
}));
}
+36 -7
View File
@@ -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<u8> {
// 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<PhaseReport> {
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]
+65 -54
View File
@@ -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<i64>,
/// 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.
+2 -1
View File
@@ -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)