Files
2026-07-13 12:22:33 +08:00

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
}
}