chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import Foundation
|
||||
|
||||
/// Simulates the live timer loop (`decide` → sleep → refresh → `decide` → ...) over a trace's
|
||||
/// observed span for a given `ReplayPolicy`, pure and deterministic: the same trace and policy
|
||||
/// always produce the same `ReplayMetrics`, since every input the policy sees comes from the
|
||||
/// trace, never from a live clock.
|
||||
///
|
||||
/// Ground truth vs. reconstructed signal: `menuOpen` events are ground truth — a menu either
|
||||
/// opened at a timestamp or it didn't, independent of any policy. `lowPowerModeEnabled` and
|
||||
/// `thermalState`, by contrast, are only *sampled* at the timestamps the trace's original
|
||||
/// `decision` events happened to occur at (whatever policy produced the trace). When a candidate
|
||||
/// policy's own tick times fall between those samples, the engine holds the most recent known
|
||||
/// value (step function). This is the phase-1 approximation: without a continuous power/thermal
|
||||
/// signal in the trace, "most recent sample" is the best available reconstruction. Before the
|
||||
/// first known sample, the earliest available sample is used (hold-first).
|
||||
///
|
||||
/// Interaction advances: this is a *counterfactual* replay, not a literal replay of whatever the
|
||||
/// recording policy happened to do — each candidate policy gets its own tick schedule computed
|
||||
/// fresh from `policy.decide(_:)`. To reproduce `UsageStore.noteMenuOpened(at:)`'s "pull the timer
|
||||
/// forward" behavior (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`) for
|
||||
/// *any* candidate policy, every `menuOpen` event that falls inside a policy's current tick window
|
||||
/// is independently re-evaluated: if `policy.advancesOnInteraction` and the decision computed as of
|
||||
/// that menu open would land earlier than the already-scheduled next tick, the schedule advances to
|
||||
/// that earlier time, exactly like `startTimer(preservingResetBoundaryRefresh: true)` replacing a
|
||||
/// pending sleep with a shorter one. Recorded `timerAdvanced` events are audited separately: their
|
||||
/// count is not expected to equal
|
||||
/// this counterfactual schedule because live refresh work has non-zero duration and can coalesce.
|
||||
public enum ReplayEngine {
|
||||
/// Safety valve against a pathological policy (e.g. a zero-or-negative delay bug) turning a
|
||||
/// long trace into an unbounded loop.
|
||||
private static let maxIterations = 2_000_000
|
||||
|
||||
/// The trace-derived, replay-invariant inputs the simulation loop reads on every tick:
|
||||
/// menu-open ground truth plus the sampled power/thermal signal, both precomputed and sorted
|
||||
/// once per `run` so the per-tick lookups stay O(log n).
|
||||
private struct TraceSignals {
|
||||
let menuOpenTimestamps: [Date]
|
||||
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)]
|
||||
let signalTimestamps: [Date]
|
||||
let activitySamples: [ActivityObservation]
|
||||
let activityTimestamps: [Date]
|
||||
}
|
||||
|
||||
private struct ActivityObservation {
|
||||
let timestamp: Date
|
||||
let lastCodingActivityAt: Date?
|
||||
}
|
||||
|
||||
public static func run(trace: [AdaptiveRefreshTraceRecord], policy: some ReplayPolicy) -> ReplayMetrics {
|
||||
self.runDetailed(trace: trace, policy: policy).metrics
|
||||
}
|
||||
|
||||
static func runDetailed(
|
||||
trace: [AdaptiveRefreshTraceRecord],
|
||||
policy: some ReplayPolicy,
|
||||
stalenessStartAt: Date? = nil) -> ReplayRun
|
||||
{
|
||||
guard let start = trace.map(\.timestamp).min(), let end = trace.map(\.timestamp).max() else {
|
||||
return ReplayRun(
|
||||
metrics: ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: 0,
|
||||
totalRefreshCount: 0,
|
||||
refreshCountPer24h: 0,
|
||||
stalenessAtMenuOpen: nil,
|
||||
constrainedCompliance: ConstrainedCompliance(constrainedDecisionCount: 0, violationCount: 0)),
|
||||
stalenessSamples: [])
|
||||
}
|
||||
|
||||
let menuOpenTimestamps = trace
|
||||
.filter { $0.kind == .menuOpen }
|
||||
.map(\.timestamp)
|
||||
.sorted()
|
||||
|
||||
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)] = trace
|
||||
.filter { $0.kind == .decision }
|
||||
.compactMap { record in
|
||||
guard let lowPower = record.lowPowerModeEnabled, let thermal = record.thermalState else {
|
||||
return nil
|
||||
}
|
||||
return (timestamp: record.timestamp, lowPower: lowPower, thermal: thermal)
|
||||
}
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
let activitySamples = trace
|
||||
.filter { $0.kind == .decision }
|
||||
.map { record in
|
||||
let activityDates = [record.codexActivitySeconds, record.claudeActivitySeconds]
|
||||
.compactMap(\.self)
|
||||
.map { record.timestamp.addingTimeInterval(-max(0, $0)) }
|
||||
return ActivityObservation(
|
||||
timestamp: record.timestamp,
|
||||
lastCodingActivityAt: activityDates.max())
|
||||
}
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
let signals = TraceSignals(
|
||||
menuOpenTimestamps: menuOpenTimestamps,
|
||||
signalSamples: signalSamples,
|
||||
signalTimestamps: signalSamples.map(\.timestamp),
|
||||
activitySamples: activitySamples,
|
||||
activityTimestamps: activitySamples.map(\.timestamp))
|
||||
|
||||
var cursor = start
|
||||
var refreshTimestamps: [Date] = []
|
||||
var constrainedDecisionCount = 0
|
||||
var violationCount = 0
|
||||
var interactionAdvanceCount = 0
|
||||
var codingActiveDecisionCount = 0
|
||||
var codingActiveDelayViolationCount = 0
|
||||
var iterations = 0
|
||||
// Monotonic pointer into `menuOpenTimestamps`: the scan below considers each menu open for
|
||||
// an advance at most once, in the single tick window (cursor, next] it falls into.
|
||||
var menuOpenScanIndex = 0
|
||||
|
||||
while cursor <= end, iterations < self.maxIterations {
|
||||
iterations += 1
|
||||
let (lowPower, thermal) = self.signal(
|
||||
signals.signalSamples,
|
||||
timestamps: signals.signalTimestamps,
|
||||
at: cursor)
|
||||
let input = ReplayPolicyInput(
|
||||
now: cursor,
|
||||
lastMenuOpenAt: self.lastValue(menuOpenTimestamps, atOrBefore: cursor),
|
||||
lastCodingActivityAt: self.lastActivity(
|
||||
signals.activitySamples,
|
||||
timestamps: signals.activityTimestamps,
|
||||
at: cursor),
|
||||
lowPowerModeEnabled: lowPower,
|
||||
thermalState: thermal)
|
||||
let decision = policy.decide(input)
|
||||
|
||||
if input.isConstrained {
|
||||
constrainedDecisionCount += 1
|
||||
if let delay = decision.delaySeconds, delay < 1800 {
|
||||
violationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if !input.isConstrained,
|
||||
let activityAge = input.codingActivityAgeSeconds,
|
||||
activityAge < 5 * 60
|
||||
{
|
||||
codingActiveDecisionCount += 1
|
||||
if decision.delaySeconds.map({ $0 <= 0 || $0 > 5 * 60 }) ?? true {
|
||||
codingActiveDelayViolationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard let delay = decision.delaySeconds, delay > 0 else { break }
|
||||
var next = cursor.addingTimeInterval(delay)
|
||||
|
||||
if policy.advancesOnInteraction {
|
||||
let advanced = self.applyInteractionAdvances(
|
||||
policy: policy,
|
||||
signals: signals,
|
||||
scanIndex: &menuOpenScanIndex,
|
||||
windowStart: cursor,
|
||||
scheduledAt: next)
|
||||
next = advanced.scheduledAt
|
||||
interactionAdvanceCount += advanced.advanceCount
|
||||
}
|
||||
|
||||
guard next <= end else { break }
|
||||
refreshTimestamps.append(next)
|
||||
cursor = next
|
||||
}
|
||||
|
||||
let span = end.timeIntervalSince(start)
|
||||
let refreshCountPer24h = span > 0 ? Double(refreshTimestamps.count) * 86400 / span : 0
|
||||
|
||||
let stalenessMenuTimestamps = stalenessStartAt.map { start in
|
||||
menuOpenTimestamps.filter { $0 >= start }
|
||||
} ?? menuOpenTimestamps
|
||||
let stalenessSamples = stalenessMenuTimestamps.isEmpty ? [] : self.stalenessSamples(
|
||||
menuOpenTimestamps: stalenessMenuTimestamps,
|
||||
refreshTimestamps: refreshTimestamps,
|
||||
initialFreshAt: stalenessStartAt ?? start)
|
||||
|
||||
return ReplayRun(
|
||||
metrics: ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: span,
|
||||
totalRefreshCount: refreshTimestamps.count,
|
||||
refreshCountPer24h: refreshCountPer24h,
|
||||
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
|
||||
constrainedCompliance: ConstrainedCompliance(
|
||||
constrainedDecisionCount: constrainedDecisionCount,
|
||||
violationCount: violationCount),
|
||||
interactionAdvanceCount: interactionAdvanceCount,
|
||||
codingActiveDecisionCount: codingActiveDecisionCount,
|
||||
codingActiveDelayViolationCount: codingActiveDelayViolationCount),
|
||||
stalenessSamples: stalenessSamples)
|
||||
}
|
||||
|
||||
/// Re-evaluates every not-yet-scanned menu open that falls in `(windowStart, scheduledAt]`
|
||||
/// against `policy`, mirroring `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`:
|
||||
/// a menu open at time `T` computes `policy.decide(now: T, lastMenuOpenAt: T, ...)` (age zero,
|
||||
/// exactly as `noteMenuOpened(at:)` does with `self.lastMenuOpenAt = date` already applied), and
|
||||
/// if the resulting candidate (`T + delay`) lands earlier than the currently scheduled refresh,
|
||||
/// the schedule advances to that candidate. Later menu opens in the same window are then
|
||||
/// compared against the *advanced* schedule, same as a real second interaction tightening an
|
||||
/// already-shortened sleep. Returns the (possibly advanced) scheduled time plus how many
|
||||
/// advances were taken in this window.
|
||||
private static func applyInteractionAdvances(
|
||||
policy: some ReplayPolicy,
|
||||
signals: TraceSignals,
|
||||
scanIndex: inout Int,
|
||||
windowStart: Date,
|
||||
scheduledAt: Date) -> (scheduledAt: Date, advanceCount: Int)
|
||||
{
|
||||
var next = scheduledAt
|
||||
var advanceCount = 0
|
||||
while scanIndex < signals.menuOpenTimestamps.count {
|
||||
let menuOpenAt = signals.menuOpenTimestamps[scanIndex]
|
||||
guard menuOpenAt > windowStart else {
|
||||
scanIndex += 1
|
||||
continue
|
||||
}
|
||||
guard menuOpenAt <= next else { break }
|
||||
|
||||
let (lowPower, thermal) = self.signal(
|
||||
signals.signalSamples,
|
||||
timestamps: signals.signalTimestamps,
|
||||
at: menuOpenAt)
|
||||
let advanceDecision = policy.decide(ReplayPolicyInput(
|
||||
now: menuOpenAt,
|
||||
lastMenuOpenAt: menuOpenAt,
|
||||
lastCodingActivityAt: self.lastActivity(
|
||||
signals.activitySamples,
|
||||
timestamps: signals.activityTimestamps,
|
||||
at: menuOpenAt),
|
||||
lowPowerModeEnabled: lowPower,
|
||||
thermalState: thermal))
|
||||
scanIndex += 1
|
||||
|
||||
guard let advanceDelay = advanceDecision.delaySeconds, advanceDelay > 0 else { continue }
|
||||
let candidate = menuOpenAt.addingTimeInterval(advanceDelay)
|
||||
if candidate < next {
|
||||
next = candidate
|
||||
advanceCount += 1
|
||||
}
|
||||
}
|
||||
return (next, advanceCount)
|
||||
}
|
||||
|
||||
private static func stalenessSamples(
|
||||
menuOpenTimestamps: [Date],
|
||||
refreshTimestamps: [Date],
|
||||
initialFreshAt: Date) -> [Double]
|
||||
{
|
||||
menuOpenTimestamps.map { menuOpenAt in
|
||||
let simulatedRefresh = self.lastValue(refreshTimestamps, atOrBefore: menuOpenAt)
|
||||
let freshestAt = simulatedRefresh.map { max($0, initialFreshAt) } ?? initialFreshAt
|
||||
return menuOpenAt.timeIntervalSince(freshestAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func lastActivity(
|
||||
_ samples: [ActivityObservation],
|
||||
timestamps: [Date],
|
||||
at time: Date) -> Date?
|
||||
{
|
||||
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
|
||||
return samples[index].lastCodingActivityAt
|
||||
}
|
||||
|
||||
/// Binds the most recent power/thermal sample at or before `time` (hold-last), falling back
|
||||
/// to the earliest known sample when `time` precedes every sample (hold-first), and to
|
||||
/// nominal/not-low-power when no samples exist at all.
|
||||
private static func signal(
|
||||
_ samples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)],
|
||||
timestamps: [Date],
|
||||
at time: Date) -> (Bool, ReplayThermalState)
|
||||
{
|
||||
guard !samples.isEmpty else { return (false, .nominal) }
|
||||
if let index = self.lastIndex(timestamps, atOrBefore: time) {
|
||||
return (samples[index].lowPower, samples[index].thermal)
|
||||
}
|
||||
return (samples[0].lowPower, samples[0].thermal)
|
||||
}
|
||||
|
||||
private static func lastValue(_ timestamps: [Date], atOrBefore time: Date) -> Date? {
|
||||
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
|
||||
return timestamps[index]
|
||||
}
|
||||
|
||||
/// Binary search for the last index whose timestamp is `<= time`, assuming `timestamps` is
|
||||
/// sorted ascending. O(log n) so a long trace (thousands of decisions) stays fast to replay.
|
||||
private static func lastIndex(_ timestamps: [Date], atOrBefore time: Date) -> Int? {
|
||||
var low = 0
|
||||
var high = timestamps.count - 1
|
||||
var result: Int?
|
||||
while low <= high {
|
||||
let mid = (low + high) / 2
|
||||
if timestamps[mid] <= time {
|
||||
result = mid
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user