feat(client/present): desktop reads the cadence statistic too
WP1 continued — the Linux/Windows session client joins Android on the shared PresentIntervals. Apple is the remaining leg. The metric lives on LatchClock because that is where the on-glass stamps and the learned panel grid it quantises against already meet, so no new plumbing and no second source of truth about the grid. Every stamp is scored, including the sub-millisecond pairs the grid learner deliberately skips: two presents inside one refresh is not a grid step, but it is very much a cadence event. The period is taken seeded-or-learned rather than learned-only, so cadence is scored from the first window instead of waiting for the learner to converge. Judder also becomes a trigger for the 1 Hz presenter line. That line only fired on drops, gate holds or the debug env var — and a cadence defect produces none of those: no drops, no holds, healthy percentiles, visibly broken motion. Without this a desktop stream could judder for an entire session and never emit a line, which is the same blind spot the metric exists to close. One honest asymmetry, recorded in the code: these stamps are CLOCK_REALTIME (this module's domain), so a wall-clock step would forge a hitch that never happened. It lands in the stall/disordered counters rather than the judder ratio, which is part of why that split is worth having. Android feeds the metric a raw monotonic stamp and has no such exposure. Gates: cargo check + clippy --all-targets on pf-presenter clean; punktfunk-core clippy -D warnings clean; fmt clean.
This commit is contained in:
@@ -30,6 +30,12 @@ const STALE_REOPEN_NS: u64 = 100_000_000;
|
||||
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
|
||||
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
|
||||
|
||||
/// Judder (‰ of present intervals off the modal spacing) that on its own justifies a 1 Hz
|
||||
/// presenter line. A cadence defect produces no drops, no gate holds and healthy latency
|
||||
/// percentiles, so it would otherwise stay silent until someone set the debug env var.
|
||||
/// Occasional single-frame slips are normal; a twentieth of a window is not.
|
||||
pub(crate) const JUDDER_LOG_PERMILLE: u16 = 50;
|
||||
|
||||
/// The decoded-frame store between the wake channel and the present call.
|
||||
///
|
||||
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
|
||||
@@ -183,6 +189,15 @@ pub(crate) struct LatchClock {
|
||||
pending_count: u32,
|
||||
grid: punktfunk_core::phase::PanelGrid,
|
||||
fallback_period_ns: u64,
|
||||
/// The cadence (judder) statistic — the only stat we publish that is not a latency,
|
||||
/// and the only one that can see a pacing defect. Lives here because this is where
|
||||
/// the on-glass stamps and the learned grid it quantises against already meet.
|
||||
///
|
||||
/// ⚠ These stamps are `CLOCK_REALTIME` (the module's domain), so a wall-clock step
|
||||
/// would forge one hitch that never happened. It lands in the stall/disordered
|
||||
/// counters rather than the judder ratio, which is why that split is worth having.
|
||||
/// Android feeds the metric a raw monotonic stamp and has no such exposure.
|
||||
intervals: punktfunk_core::phase::PresentIntervals,
|
||||
}
|
||||
|
||||
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
|
||||
@@ -198,14 +213,29 @@ impl LatchClock {
|
||||
pending_count: 0,
|
||||
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
|
||||
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
|
||||
intervals: punktfunk_core::phase::PresentIntervals::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the window's cadence summary — the 1 Hz stat boundary, beside the store and
|
||||
/// gate counters.
|
||||
pub(crate) fn take_cadence(&mut self) -> Option<punktfunk_core::phase::PresentCadence> {
|
||||
self.intervals.take()
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
|
||||
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
|
||||
/// the learner.
|
||||
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
|
||||
// Seeded-or-learned, so cadence is scored from the first window rather than only
|
||||
// once the learner has converged. Held for the batch: a mid-batch period change
|
||||
// would requantise a handful of samples for no benefit.
|
||||
let period_ns = self.period_ns() as i64;
|
||||
for &s in stamps {
|
||||
// Cadence sees EVERY stamp, including the sub-millisecond pairs the grid
|
||||
// learner skips below: two presents inside one refresh is not a grid step,
|
||||
// but it is very much a cadence event (it scores as a zero-refresh interval).
|
||||
self.intervals.record(s as i64, period_ns);
|
||||
if self.last_ns != 0 && s > self.last_ns {
|
||||
let d = s - self.last_ns;
|
||||
// < 1 ms apart = a queued pair, not a grid step.
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::present_pace::{
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, JUDDER_LOG_PERMILLE, MARGIN_MAX_NS,
|
||||
MARGIN_STEP_NS,
|
||||
};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
@@ -1821,6 +1822,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// a second `take_counters` would read zeros.
|
||||
let (replaced, q_drop, q_dry) = st.store.take_counters();
|
||||
let (gated, forced) = st.gate.take_counters();
|
||||
let cadence = st.clock.take_cadence();
|
||||
st.presented = PresentedWindow {
|
||||
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
|
||||
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
|
||||
@@ -1834,6 +1836,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
judder_permille: cadence.map(|c| c.judder_permille).unwrap_or(0),
|
||||
cadence_mode: cadence.map(|c| c.mode_units).unwrap_or(0),
|
||||
};
|
||||
st.win_e2e_us.clear();
|
||||
st.win_disp_us.clear();
|
||||
@@ -1855,7 +1859,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
|
||||
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
|
||||
// the field-triage instrument for the intent engine.
|
||||
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
|
||||
// Judder joins the "something moved" triggers deliberately: a cadence
|
||||
// defect shows NO drops, NO gate holds and healthy percentiles, so
|
||||
// without this a stream can judder visibly and never emit a line.
|
||||
if pacing_active
|
||||
&& (present_debug
|
||||
|| q_drop + q_dry + gated + forced > 0
|
||||
|| st.presented.judder_permille >= JUDDER_LOG_PERMILLE)
|
||||
{
|
||||
tracing::info!(
|
||||
smoothing = st.presented.smoothing,
|
||||
mode = st.presented.mode,
|
||||
@@ -1871,6 +1882,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
latch_ms = st.presented.latch_ms,
|
||||
period_us = st.clock.period_ns() / 1000,
|
||||
margin_us = st.margin_ns / 1000,
|
||||
judder_permille = st.presented.judder_permille,
|
||||
cadence_mode = st.presented.cadence_mode,
|
||||
"presenter window"
|
||||
);
|
||||
}
|
||||
@@ -2368,6 +2381,14 @@ struct PresentedWindow {
|
||||
q_dry: u32,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
/// The cadence (judder) statistic — the fraction of present intervals (‰) that missed
|
||||
/// the modal spacing, and that modal spacing in whole refreshes. Every other number
|
||||
/// here is a latency and none of them can see a pacing defect: alternating 1 and 3
|
||||
/// refreshes has the same mean rate as a steady 2, better latency percentiles, and
|
||||
/// looks broken. `mode 0` = not enough evidence this window.
|
||||
/// See [`punktfunk_core::phase::PresentIntervals`].
|
||||
judder_permille: u16,
|
||||
cadence_mode: u8,
|
||||
}
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
|
||||
Reference in New Issue
Block a user