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. /// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) { fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
if v.is_empty() { if v.is_empty() {
@@ -340,13 +363,14 @@ impl Presenter {
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) / /// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
/// `vsync` (the measured panel period). /// `vsync` (the measured panel period).
/// ///
/// Returns this window's measured latch p50 (ns) when a window actually flushed — the /// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// phase-lock reporter's `arrival_lead` error signal (design/phase-locked-capture.md §6). /// 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( pub(super) fn flush_log(
&mut self, &mut self,
meter: &PresentMeter, meter: &PresentMeter,
clock: Option<&VsyncShared>, clock: Option<&VsyncShared>,
) -> Option<u64> { ) -> Option<(u64, u16)> {
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) { if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
return None; return None;
} }
@@ -356,6 +380,8 @@ impl Presenter {
return None; // idle stream — nothing worth a line return None; // idle stream — nothing worth a line
} }
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us)); 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 (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 period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ms = clock let panel_ms = clock
@@ -364,7 +390,8 @@ impl Presenter {
log::info!( log::info!(
target: "pf.present", target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \ "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, self.released,
displays, displays,
self.paced_drops, self.paced_drops,
@@ -375,6 +402,8 @@ impl Presenter {
pace_max, pace_max,
latch_p50, latch_p50,
latch_max, latch_max,
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
circ.map(|(_, c)| c).unwrap_or(0),
period_ms, period_ms,
panel_ms, panel_ms,
); );
@@ -383,7 +412,7 @@ impl Presenter {
self.no_budget = 0; self.no_budget = 0;
self.forced = 0; self.forced = 0;
self.dry = 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, latch_period_ns: u32,
uncertainty_ns: u32, uncertainty_ns: u32,
arrival_lead_ns: u32, arrival_lead_ns: u32,
coherence_milli: u16,
) -> PunktfunkStatus { ) -> PunktfunkStatus {
guard(|| { guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller // 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, latch_period_ns,
uncertainty_ns, uncertainty_ns,
arrival_lead_ns, arrival_lead_ns,
coherence_milli,
); );
PunktfunkStatus::Ok PunktfunkStatus::Ok
}) })
+2
View File
@@ -787,6 +787,7 @@ impl NativeClient {
latch_period_ns: u32, latch_period_ns: u32,
uncertainty_ns: u32, uncertainty_ns: u32,
arrival_lead_ns: u32, arrival_lead_ns: u32,
coherence_milli: u16,
) { ) {
let _ = self let _ = self
.ctrl_tx .ctrl_tx
@@ -795,6 +796,7 @@ impl NativeClient {
latch_period_ns, latch_period_ns,
uncertainty_ns, uncertainty_ns,
arrival_lead_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 /// 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. /// widens its arrival margin by this, never narrows below its floor.
pub uncertainty_ns: u32, pub uncertainty_ns: u32,
/// Measured median lead of frame ARRIVAL before latch over the last window (ns; clamped /// Measured lead of frame arrival before latch over the last window (ns; clamped ≥ 0).
/// ≥ 0). The controller drives this toward its target lead the error signal. /// 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, 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). /// Type byte of [`Reconfigure`] (first byte after the magic).
@@ -490,18 +496,23 @@ impl ClockEcho {
impl PhaseReport { impl PhaseReport {
pub fn encode(&self) -> Vec<u8> { pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] latch[5..13] period[13..17] uncertainty[17..21] lead[21..25] // 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.extend_from_slice(CTL_MAGIC);
b.push(MSG_PHASE_REPORT); b.push(MSG_PHASE_REPORT);
b.extend_from_slice(&self.next_latch_host_ns.to_le_bytes()); 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.latch_period_ns.to_le_bytes());
b.extend_from_slice(&self.uncertainty_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()); 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 b
} }
pub fn decode(b: &[u8]) -> Result<PhaseReport> { 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")); 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]]); 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), latch_period_ns: u32at(13),
uncertainty_ns: u32at(17), uncertainty_ns: u32at(17),
arrival_lead_ns: u32at(21), 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, latch_period_ns: 8_333_333,
uncertainty_ns: 900_000, uncertainty_ns: 900_000,
arrival_lead_ns: 4_100_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. // Wrong type byte (a ClockProbe) must not decode as a PhaseReport.
assert!(PhaseReport::decode(&ClockProbe { t1_ns: 7 }.encode()).is_err()); assert!(PhaseReport::decode(&ClockProbe { t1_ns: 7 }.encode()).is_err());
// Truncation must not decode. // Truncation to a length that is neither form must not decode.
assert!(PhaseReport::decode(&pr.encode()[..24]).is_err()); assert!(PhaseReport::decode(&d[..24]).is_err());
assert!(PhaseReport::decode(&d[..26]).is_err());
} }
#[test] #[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 /// The encode loop's phase controller state (design/phase-locked-capture.md §3, controller
/// HOLD before submit, walked toward the client's reported arrival lead hitting the target lead. /// 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 /// 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). /// (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 { struct PhaseController {
/// Applied per-frame hold before submit, ns ∈ [0, period). /// Applied per-frame hold before submit, ns ∈ [0, period).
hold_ns: i64, hold_ns: i64,
/// Last adjust instant (~1 Hz cadence). /// Last adjust instant (~1 Hz cadence).
last_adjust: std::time::Instant, last_adjust: std::time::Instant,
/// The previous adjust's error (ns) — the dead-signal detector's memory. /// |step| integrated since the last convergence/decay — the chase detector. Any true lock
last_error_ns: Option<i64>, /// needs at most ~one period of travel, so exceeding the budget proves the error is not
/// Consecutive adjusts whose step failed to move the error (see `DEAD_SIGNAL_ADJUSTS`). /// converging regardless of what the (noisy) reports claim.
unresponsive_adjusts: u32, cum_travel_ns: i64,
/// The travel budget tripped — decay the hold to zero, then re-arm.
decaying: bool,
} }
impl PhaseController { impl PhaseController {
@@ -278,75 +293,71 @@ impl PhaseController {
/// The lead floor the controller drives toward: SurfaceFlinger-class compositors need the /// 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. /// 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; 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 { fn new() -> PhaseController {
PhaseController { PhaseController {
hold_ns: 0, hold_ns: 0,
last_adjust: std::time::Instant::now(), last_adjust: std::time::Instant::now(),
last_error_ns: None, cum_travel_ns: 0,
unresponsive_adjusts: 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 /// 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 /// session's frame period). Sign convention: a positive (shortest-way) error means frames
/// median frame arrives — a LARGE lead means frames sit waiting at the client, so the host /// arrive too early and wait at the client — send LATER (grow the hold); negative — send
/// should send LATER (grow the hold); a small/zero lead risks missing the latch, so send /// EARLIER. `rem_euclid` wraps the hold through the period; the newest-wins capture slot
/// EARLIER (shrink the hold, wrapping through the period when it hits zero — the newest-wins /// makes a wrapped hold sample a fresher frame, not a staler one.
/// 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) { fn adjust(&mut self, r: &punktfunk_core::quic::PhaseReport, period_ns: i64) {
if period_ns <= 0 { if period_ns <= 0 {
return; return;
} }
self.last_adjust = std::time::Instant::now(); self.last_adjust = std::time::Instant::now();
let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000); // Incoherent phase, or a tripped travel budget: the hold is a pure e2e tax buying no
let error = r.arrival_lead_ns as i64 - target; // alignment — walk it back to zero and wait for the regime to tighten.
if error.abs() < Self::DEADBAND_NS { let coherent =
self.unresponsive_adjusts = 0; r.coherence_milli == u16::MAX || r.coherence_milli >= Self::COHERENCE_FLOOR_MILLI;
self.last_error_ns = Some(error); if !coherent || self.decaying {
self.decay();
return; return;
} }
// Dead-signal detection: if stepping the hold hasn't moved the error, stop stepping — let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000);
// and while frozen, only a genuinely-moved error re-arms. // Signed SHORTEST-WAY error around the period — the controller never walks the long way.
if let Some(last) = self.last_error_ns { let raw = (r.arrival_lead_ns as i64 - target).rem_euclid(period_ns);
if (error - last).abs() >= Self::UNFREEZE_DELTA_NS { let error = if raw > period_ns / 2 {
self.unresponsive_adjusts = 0; // the signal responded (or the regime changed) raw - period_ns
} else if self.frozen() { } else {
self.last_error_ns = Some(error); raw
return; };
} else { if error.abs() < Self::DEADBAND_NS {
self.unresponsive_adjusts += 1; self.cum_travel_ns = 0; // locked — the budget re-arms for the next disturbance
if self.frozen() { return;
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;
}
}
} }
self.last_error_ns = Some(error);
let step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS); let step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS);
self.hold_ns = (self.hold_ns + step).rem_euclid(period_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 { /// One decay tick: walk the hold toward zero; once there, re-arm the travel budget so a
self.unresponsive_adjusts >= Self::DEAD_SIGNAL_ADJUSTS /// 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. /// 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, uint64_t next_latch_host_ns,
uint32_t latch_period_ns, uint32_t latch_period_ns,
uint32_t uncertainty_ns, uint32_t uncertainty_ns,
uint32_t arrival_lead_ns); uint32_t arrival_lead_ns,
uint16_t coherence_milli);
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)