feat(client/present): Apple reads the cadence statistic — WP1 complete

The third and last leg of WP1. All three clients now publish the same
judder number, which was the point: one ruler, so a smoothness A/B can be
compared across platforms instead of argued about.

A verbatim Swift port of punktfunk_core::phase::PresentIntervals, in the
same spirit as PhaseReporter.circularLatch alongside it, with a test file
that runs the SAME vectors as the Rust unit tests. A hand-written port is
exactly where "all three emit the same numbers" quietly stops being true,
so it is pinned rather than trusted.

Porting it found a real cross-client hazard. The modal spacing was read
with max_by_key, which returns the LAST maximum, while Swift's max(by:)
returns the FIRST — so a 50/50 window (the classic 1-and-3 sawtooth) would
have reported the same judder but a different mode on Android and Apple.
Both sides now spell the rule out: ties resolve to the smallest spacing.
The Rust test that previously accepted either answer now pins it.

Two Apple-specific decisions:

  - the stats object is built for EVERY session, not just under the debug
    env var or deadline pacing. A smoothness defect produces no drops and
    healthy percentiles, so gating the one statistic that could see it
    behind an env var means it is off exactly when it matters. A `verbose`
    flag preserves the old behaviour for the wordy counters line; the
    cadence line always emits.
  - the panel period comes from the link's own reported period (glass
    pacing) or is learned from the link's target instants (deadline
    pacing). Those tick at the panel rate whether or not WE present, which
    is what makes the window minimum the true period — the same reasoning
    PhaseReporter already documents. Learning it from on-glass spacings
    instead would read a 60-on-120 stream as a 60 Hz panel and mislabel the
    cadence mode.

A dropped drawable splits the run rather than scoring the gap: it never
reached glass, so it is not a cadence event, and the next present does not
continue the previous interval either.

Gates: punktfunk-core 21 phase tests green; the Swift port verified against
all 11 Rust vectors via a standalone harness (identical mode/judder/samples/
stalls/disordered on every case, incl. the tie-break); both edited Swift
files parse clean; fmt clean.

⚠ The Swift INTEGRATION is not compiler-verified locally: building
PunktfunkCore.xcframework on this machine fails a pre-existing deployment-
target guard (objects at minos 26 survive a cache wipe and an exported
MACOSX_DEPLOYMENT_TARGET). Source-only change, so it cannot be the cause.
CI's Apple leg owns that check — treat it as owed, not passed.
This commit is contained in:
2026-08-05 23:14:28 +02:00
parent 53278c6f5f
commit d0d2399476
3 changed files with 346 additions and 8 deletions
@@ -474,6 +474,7 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
stats?.notePanelTarget(mediaTime: update.targetPresentationTimestamp)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
@@ -562,6 +563,103 @@ final class PresentGate: @unchecked Sendable {
}
}
/// One window's present-cadence summary (see `PresentIntervals`).
struct PresentCadence: Equatable {
/// The most common spacing, in whole panel refreshes: 1 at panel rate, 2 for 60-on-120.
let modeUnits: Int
/// Fraction of intervals that were NOT the mode, in . **The judder number.**
let judderPermille: Int
let samples: Int
/// Spacings wider than `maxUnits` stalls, not judder.
let stalls: Int
/// Present instants that did not advance (duplicate/out-of-order callbacks).
let disordered: Int
}
/// Present-interval distribution in whole panel refreshes the cadence (judder) statistic.
///
/// A **verbatim port of `punktfunk_core::phase::PresentIntervals`**, in the same spirit as
/// `PhaseReporter.circularLatch` above: the three clients must publish the SAME statistic, so the
/// numbers can be compared across platforms and so a feature-on/off A/B uses one ruler. Any change
/// here belongs in the Rust original first including the tie-break, which is spelled out on both
/// sides precisely because the two languages' `max` disagree about which equal element wins.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows each
/// frame one refresh early and the next one late has excellent percentiles and looks broken.
///
/// Fed the MEASURED on-glass instant, never the requested present time the latter would measure
/// our own intent and report a perfect cadence no matter what the display did.
struct PresentIntervals {
/// Largest spacing still treated as cadence; wider is a stall, counted apart.
private static let maxUnits = 8
/// Minimum intervals before a summary means anything (matches `circularLatch`'s bar).
private static let minSamples = 8
private var lastPresentNs: Int64 = 0
private var hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
private var samples = 0
private var stalls = 0
private var disordered = 0
/// Forget the previous instant without discarding the window's counts a discontinuity where
/// the next present does not continue this cadence.
mutating func split() { lastPresentNs = 0 }
/// Fold one on-glass instant. A non-positive `periodNs` means the grid is not known yet and
/// the sample is held as the new predecessor without being scored.
mutating func record(presentNs: Int64, periodNs: Int64) {
let prev = lastPresentNs
lastPresentNs = presentNs
guard prev > 0, periodNs > 0 else { return }
let spacing = presentNs - prev
if spacing <= 0 {
// Keep the LATER instant so one disordered delivery cannot corrupt every
// following spacing.
disordered += 1
lastPresentNs = max(prev, presentNs)
return
}
// Nearest whole refresh: a present is "on the grid" if it is closer to this vblank than
// the next, which is exactly what the display did with it.
let units = Int((spacing * 2 + periodNs) / (periodNs * 2))
if units > PresentIntervals.maxUnits {
stalls += 1
return
}
hist[units] += 1
samples += 1
}
/// This window's summary, or nil under `minSamples`.
func summary() -> PresentCadence? {
guard samples >= PresentIntervals.minSamples else { return nil }
// Ties resolve to the SMALLEST spacing see the Rust original: `max_by_key` takes the
// last maximum and Swift's `max(by:)` the first, so this is written out on both sides.
var modeUnits = 0
var modeCount = 0
for (i, c) in hist.enumerated() where c > modeCount {
modeCount = c
modeUnits = i
}
return PresentCadence(
modeUnits: modeUnits,
judderPermille: (samples - modeCount) * 1000 / samples,
samples: samples, stalls: stalls, disordered: disordered)
}
/// Drain the window. The previous instant SURVIVES the cadence continues across a window
/// boundary, and dropping it would manufacture one unscored interval per window.
mutating func take() -> PresentCadence? {
let out = summary()
hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
samples = 0
stalls = 0
disordered = 0
return out
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
@@ -588,6 +686,50 @@ private final class PresentDebugStats: @unchecked Sendable {
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
private var inFlight = 0
private var maxInFlight = 0
/// The cadence (judder) statistic the only number here that is not a latency, and the only
/// one that can see a pacing defect. `glassDeltasMs` above is the same raw material reported
/// as a percentile, which cannot distinguish a steady 2-refresh cadence from an alternating
/// 1-and-3 one: same mean, same median, one of them visibly broken.
private var intervals = PresentIntervals()
/// The panel period cadence quantises against: seeded from the display mode and refined from
/// the link's own reported period, mirroring `punktfunk_core::phase::PanelGrid`'s seed-then-
/// correct design. 0 until known, which simply means cadence is not scored yet.
private var panelPeriodNs: Int64 = 0
/// Deadline-pacing period learner state (see `notePanelTarget`). Re-armed each window so a
/// mode or VRR rate change is tracked both ways rather than latching the first value seen.
private var lastTargetS: CFTimeInterval = 0
private var minTargetSpacingS: CFTimeInterval = 0
/// Whether the verbose per-second line prints. The cadence line always does: a smoothness
/// defect must not be invisible until someone thinks to set an env var.
private let verbose: Bool
init(verbose: Bool) { self.verbose = verbose }
/// Seed or refine the panel period (render/link thread).
func setPanelPeriod(ns: Int64) {
guard ns > 0 else { return }
lock.lock()
panelPeriodNs = ns
lock.unlock()
}
/// Deadline pacing has no reported period, so learn it from the link's own target instants.
/// Those tick at the panel rate whether or not WE present, which is what makes the window
/// minimum the true period the same reasoning (and the same guard band) `PhaseReporter`
/// uses above. Learning it from on-glass spacings instead would read a 60-on-120 stream as a
/// 60 Hz panel and mislabel the cadence mode.
func notePanelTarget(mediaTime t: CFTimeInterval) {
lock.lock()
defer { lock.unlock() }
defer { lastTargetS = t }
guard lastTargetS > 0 else { return }
let d = t - lastTargetS
guard d > 0.0005, d < 0.1 else { return }
if minTargetSpacingS == 0 || d < minTargetSpacingS {
minTargetSpacingS = d
panelPeriodNs = Int64(d * 1_000_000_000)
}
}
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
@@ -624,8 +766,13 @@ private final class PresentDebugStats: @unchecked Sendable {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
intervals.record(presentNs: atNs, periodNs: panelPeriodNs)
} else {
// A dropped drawable never reached glass, so it is not a cadence event but the
// NEXT one does not continue the previous interval either. Split rather than let
// the gap read as judder.
dropped += 1
intervals.split()
}
lock.unlock()
}
@@ -656,6 +803,9 @@ private final class PresentDebugStats: @unchecked Sendable {
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
let cadence = intervals.take()
let verbose = self.verbose
minTargetSpacingS = 0 // re-arm the period learner for the next window
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
@@ -663,6 +813,21 @@ private final class PresentDebugStats: @unchecked Sendable {
latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock()
// The cadence line is ALWAYS emitted (when the window had evidence): it is the ruler the
// smoothness A/B reads, and it must not depend on an env var the field never sets. The
// verbose counters line stays behind its existing lever.
if let cadence {
let cadenceLine = String(
format: "pf-present judderPermille=%d modeVsync=%d n=%d stalls=%d disorder=%d",
cadence.judderPermille, cadence.modeUnits, cadence.samples,
cadence.stalls, cadence.disordered)
presentLog.info("\(cadenceLine, privacy: .public)")
if presentDebug {
print(cadenceLine)
fflush(stdout)
}
}
guard verbose else { return }
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
@@ -746,6 +911,10 @@ public final class Stage2Pipeline {
/// mirror the pump's bounded join.
private let renderSignal = DispatchSemaphore(value: 0)
private let vsyncClock = VsyncClock()
/// The per-session present statistics, retained so the clock-bearing threads can republish
/// the panel period the cadence statistic quantises against. Assigned once in `start`, read
/// from the render/link threads; the object is itself lock-guarded.
private var presentStats: PresentDebugStats?
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
@@ -967,7 +1136,14 @@ public final class Stage2Pipeline {
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
//
// The stats object is now built for EVERY session, because the cadence statistic inside
// it has to be: a smoothness defect produces no drops and healthy percentiles, so gating
// it behind an env var means the one number that could see it is off exactly when it
// matters. `verbose` preserves the old behaviour for the wordy counters line.
let debugStats: PresentDebugStats? = PresentDebugStats(
verbose: presentDebug || pacing == .deadline)
presentStats = debugStats
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
@@ -1241,6 +1417,9 @@ public final class Stage2Pipeline {
/// (their CAMetalDisplayLink's updates are both clock and retry).
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
// The link's own reported period is the authoritative grid for the cadence statistic
// it tracks VRR rate changes, which a mode-derived seed cannot.
presentStats?.setPanelPeriod(ns: Int64(period * 1_000_000_000))
renderSignal.signal()
}
@@ -0,0 +1,150 @@
// Parity tests for the Swift `PresentIntervals` port (Video/Stage2Pipeline.swift) against
// `punktfunk_core::phase::PresentIntervals` the cadence (judder) statistic of
// design/presenter-cadence-rework.md WP1.
//
// These are deliberately the SAME cases and the SAME vectors as the Rust unit tests in
// crates/punktfunk-core/src/phase.rs (module `cadence_tests`). WP1's acceptance criterion is that
// all three clients emit the same numbers for the same synthetic input, and a hand-written port is
// exactly where that quietly stops being true so the port is pinned here rather than trusted.
//
// If you change one side, change both, and keep the vectors identical.
import Foundation
import XCTest
@testable import PunktfunkKit
final class PresentIntervalsTests: XCTestCase {
/// 120 Hz in ns the Rust tests' `P`.
private static let P: Int64 = 8_333_333
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
/// Mirrors the Rust helper of the same shape.
private func cadence(_ spacings: [Int64], _ n: Int, period: Int64 = P) -> PresentIntervals {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: period)
for i in 0..<n {
t += spacings[i % spacings.count]
pi.record(presentNs: t, periodNs: period)
}
return pi
}
func testARegularCadenceHasNoJudder() {
let s = cadence([Self.P], 60).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 60)
}
/// The property that makes this one ruler across rates: a stream at half (or a quarter of)
/// the panel rate is SMOOTH, not judder the mode absorbs the cadence ratio.
func testSixtyOnOneTwentyReadsSmooth() {
for (mult, expected) in [(Int64(2), 2), (Int64(4), 4)] {
let s = cadence([Self.P * mult], 40).summary()
XCTAssertEqual(s?.modeUnits, expected)
XCTAssertEqual(s?.judderPermille, 0)
}
}
/// D3's signature: the same mean spacing as a steady 2, delivered as alternating 1 and 3.
/// Identical average frame rate, identical latency percentiles this is the broken-looking one.
///
/// Also pins the TIE-BREAK. The histogram is 50/50 here, and Rust's `max_by_key` takes the
/// last maximum while Swift's `max(by:)` takes the first, so both sides spell the rule out:
/// ties resolve to the smallest spacing.
func testTheSawtoothThatLatencyStatsCannotSee() {
let s = cadence([Self.P, Self.P * 3], 40).summary()
XCTAssertEqual(s?.judderPermille, 500)
XCTAssertEqual(s?.modeUnits, 1, "a tied mode resolves to the smallest spacing")
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
func testJitterInsideARefreshIsNotJudder() {
let s = cadence([Self.P + Self.P * 2 / 5, Self.P - Self.P * 2 / 5], 40).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
}
func testAStallIsCountedApartFromJudder() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
t += Self.P * 400 // a pause, not a pacing defect
pi.record(presentNs: t, periodNs: Self.P)
let s = pi.summary()
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.stalls, 1)
XCTAssertEqual(s?.samples, 20)
}
func testOutOfOrderCallbacksDoNotCorruptTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
pi.record(presentNs: t - Self.P * 3, periodNs: Self.P) // a late/duplicate delivery
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1)
XCTAssertEqual(
s?.judderPermille, 0,
"keeping the later instant means the following spacings stay on the grid")
}
func testAnUnknownGridScoresNothing() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
for _ in 0..<60 {
t += Self.P
pi.record(presentNs: t, periodNs: 0) // no learned period yet
}
XCTAssertNil(pi.summary())
XCTAssertNotNil(cadence([Self.P], 60).summary(), "control")
}
func testAShortWindowPublishesNothing() {
XCTAssertNil(cadence([Self.P], 5).summary())
}
/// The cadence continues across a window boundary dropping the predecessor on drain would
/// silently discard one interval per window, every window.
func testTakeResetsTheCountsButNotTheCadence() {
var pi = cadence([Self.P], 20)
XCTAssertNotNil(pi.take())
XCTAssertNil(pi.summary(), "counts cleared")
var t: Int64 = 1_000_000_000 + Self.P * 20
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
XCTAssertEqual(
pi.summary()?.samples, 10,
"the first post-drain present scored against the pre-drain one")
}
func testSplitForgetsThePredecessor() {
var pi = cadence([Self.P], 20)
_ = pi.take()
pi.split()
var t: Int64 = 5_000_000_000 // a discontinuity: the gap across it is meaningless
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.samples, 9)
XCTAssertEqual(s?.stalls, 0, "the gap was not scored at all")
}
}
+16 -7
View File
@@ -234,12 +234,18 @@ impl PresentIntervals {
if self.samples < CADENCE_MIN_SAMPLES {
return None;
}
let (mode_units, mode_count) = self
.hist
.iter()
.enumerate()
.max_by_key(|&(_, c)| *c)
.map(|(i, &c)| (i as u8, c))?;
// Ties resolve to the SMALLEST spacing, spelled out rather than left to a library:
// `max_by_key` would take the last maximum and Swift's `max(by:)` the first, so a
// 50/50 window (the classic 1-and-3 sawtooth) would label its mode differently on
// Android and Apple while reporting the same judder. The clients have to agree.
let mut mode_units = 0u8;
let mut mode_count = 0u32;
for (i, &c) in self.hist.iter().enumerate() {
if c > mode_count {
mode_count = c;
mode_units = i as u8;
}
}
Some(PresentCadence {
mode_units,
judder_permille: (u64::from(self.samples - mode_count) * 1000 / u64::from(self.samples))
@@ -474,7 +480,10 @@ mod cadence_tests {
fn the_sawtooth_that_latency_stats_cannot_see() {
let s = cadence(&[P, P * 3], 40).summary().unwrap();
assert_eq!(s.judder_permille, 500);
assert!(matches!(s.mode_units, 1 | 3));
assert_eq!(
s.mode_units, 1,
"a tied mode resolves to the smallest spacing — pinned so the Swift port agrees"
);
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.