303 lines · 13.8 KB · Swift
RawDownload
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    }}