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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user