304 lines
14 KiB
Swift
304 lines
14 KiB
Swift
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
|
|
}
|
|
}
|