chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// Informational summary of optional coding-activity observations in a trace's `decision` events.
|
||||
/// It reports how many decisions carried activity data and how many sampled decisions were below
|
||||
/// `activeThresholdSeconds` for either CLI.
|
||||
public struct ActivityCoverageStats: Sendable, Equatable {
|
||||
public let decisionCount: Int
|
||||
public let sampledCount: Int
|
||||
public let activeCount: Int
|
||||
|
||||
public init(decisionCount: Int, sampledCount: Int, activeCount: Int) {
|
||||
self.decisionCount = decisionCount
|
||||
self.sampledCount = sampledCount
|
||||
self.activeCount = activeCount
|
||||
}
|
||||
|
||||
/// Fraction of `decision` events that carried at least one non-nil activity field.
|
||||
public var sampledFraction: Double {
|
||||
self.decisionCount == 0 ? 0 : Double(self.sampledCount) / Double(self.decisionCount)
|
||||
}
|
||||
|
||||
/// Fraction of the *sampled* decisions (not all decisions) that looked like active coding.
|
||||
public var activeFraction: Double {
|
||||
self.sampledCount == 0 ? 0 : Double(self.activeCount) / Double(self.sampledCount)
|
||||
}
|
||||
|
||||
/// - Parameter activeThresholdSeconds: below this many seconds since the newest transcript
|
||||
/// write, a CLI counts as "active coding at decision time". Defaults to 5 minutes.
|
||||
public static func compute(
|
||||
from records: [AdaptiveRefreshTraceRecord],
|
||||
activeThresholdSeconds: TimeInterval = 300) -> Self
|
||||
{
|
||||
var sampledCount = 0
|
||||
var activeCount = 0
|
||||
var decisionCount = 0
|
||||
for record in records where record.kind == .decision {
|
||||
decisionCount += 1
|
||||
let codexSeconds = record.codexActivitySeconds
|
||||
let claudeSeconds = record.claudeActivitySeconds
|
||||
guard codexSeconds != nil || claudeSeconds != nil else { continue }
|
||||
sampledCount += 1
|
||||
let isActive = (codexSeconds ?? .infinity) < activeThresholdSeconds
|
||||
|| (claudeSeconds ?? .infinity) < activeThresholdSeconds
|
||||
if isActive {
|
||||
activeCount += 1
|
||||
}
|
||||
}
|
||||
return Self(decisionCount: decisionCount, sampledCount: sampledCount, activeCount: activeCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
|
||||
/// The event kinds a trace records. `decision` events capture a full policy tick (the
|
||||
/// signals it saw plus what it chose). `menuOpen` and `refreshCompleted` capture the two
|
||||
/// ground-truth events the replay engine anchors a simulation to, independent of any candidate
|
||||
/// policy. `timerAdvanced` captures the one place live behavior *isn't* a plain tick loop: when
|
||||
/// opening the menu makes `UsageStore.noteMenuOpened(at:)` pull the next adaptive refresh forward
|
||||
/// (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`). Recording it separately
|
||||
/// from `decision` lets a trace answer "did an advance happen, and to when" without relying on
|
||||
/// fragile inference from decision-timestamp gaps.
|
||||
public enum AdaptiveRefreshTraceEventKind: String, Sendable, Codable {
|
||||
case decision
|
||||
case menuOpen
|
||||
case refreshCompleted
|
||||
case timerAdvanced
|
||||
/// Every live advance comparison, including the cases correctly rejected because the current
|
||||
/// timer was already earlier. This is distinct from counterfactual replay advances.
|
||||
case timerAdvanceEvaluated
|
||||
}
|
||||
|
||||
/// One line of a JSONL adaptive-refresh trace. Field presence depends on `kind`: `decision`
|
||||
/// records populate `menuAgeSeconds`, `lowPowerModeEnabled`, `thermalState`, `reason`, and
|
||||
/// `delaySeconds`, plus optional activity observations supplied by the input trace
|
||||
/// (`codexActivitySeconds`/`claudeActivitySeconds`, the seconds-since-newest-transcript fields,
|
||||
/// and the per-file intensity fields alongside them); timer advance records populate
|
||||
/// `previousScheduledAt`, `candidateScheduledAt`, `reason`, and `delaySeconds`; `menuOpen` and
|
||||
/// `refreshCompleted` carry only `kind` and `timestamp`.
|
||||
public struct AdaptiveRefreshTraceRecord: Sendable, Codable, Equatable {
|
||||
public let kind: AdaptiveRefreshTraceEventKind
|
||||
public let timestamp: Date
|
||||
public let menuAgeSeconds: TimeInterval?
|
||||
public let lowPowerModeEnabled: Bool?
|
||||
public let thermalState: ReplayThermalState?
|
||||
public let reason: String?
|
||||
public let delaySeconds: TimeInterval?
|
||||
/// Timer advance records only: the adaptive timer's scheduled refresh time before the
|
||||
/// comparison, or `nil` when no refresh had been scheduled yet (matches
|
||||
/// `UsageStore.shouldAdvanceAdaptiveTimer`'s "always advance when nothing is scheduled" rule).
|
||||
public let previousScheduledAt: Date?
|
||||
/// Timer advance records only: the candidate refresh time, i.e. the menu-open timestamp plus
|
||||
/// the freshly computed decision's delay.
|
||||
public let candidateScheduledAt: Date?
|
||||
/// `timerAdvanceEvaluated` only: whether the live schedule comparison accepted the candidate.
|
||||
public let timerAdvanceAccepted: Bool?
|
||||
/// `timerAdvanceEvaluated` only: `previousScheduledAt - candidateScheduledAt`, captured before
|
||||
/// whole-second ISO-8601 serialization. Positive means the candidate was earlier. Optional for
|
||||
/// compatibility with traces recorded before exact comparison deltas were added.
|
||||
public let scheduleLeadSeconds: TimeInterval?
|
||||
/// `timerAdvanceEvaluated` only: whether another refresh was in flight at comparison time.
|
||||
public let refreshInFlight: Bool?
|
||||
/// `decision` only: seconds since the newest observed Codex session transcript modification,
|
||||
/// or `nil` when unavailable. Optional so old trace lines without this field keep decoding.
|
||||
public let codexActivitySeconds: TimeInterval?
|
||||
/// `decision` only: the Claude Code counterpart of `codexActivitySeconds`.
|
||||
public let claudeActivitySeconds: TimeInterval?
|
||||
/// `decision` only: how long the newest Codex transcript has been
|
||||
/// growing (its mtime minus its creationDate), or `nil` when unavailable. Not a separate
|
||||
/// session-age field — age is `codexActivitySeconds` + `codexSessionDurationSeconds`.
|
||||
public let codexSessionDurationSeconds: TimeInterval?
|
||||
/// `decision` only: the Claude Code counterpart of `codexSessionDurationSeconds`.
|
||||
public let claudeSessionDurationSeconds: TimeInterval?
|
||||
/// `decision` only: size in bytes of the newest Codex transcript, as a stateless raw value.
|
||||
public let codexTranscriptBytes: Int64?
|
||||
/// `decision` only: the Claude Code counterpart of `codexTranscriptBytes`.
|
||||
public let claudeTranscriptBytes: Int64?
|
||||
/// `decision` only: count of Codex `.jsonl` transcripts modified in the observation window.
|
||||
public let codexActiveTranscriptCount: Int?
|
||||
/// `decision` only: the Claude Code counterpart of `codexActiveTranscriptCount`.
|
||||
public let claudeActiveTranscriptCount: Int?
|
||||
|
||||
public init(
|
||||
kind: AdaptiveRefreshTraceEventKind,
|
||||
timestamp: Date,
|
||||
menuAgeSeconds: TimeInterval? = nil,
|
||||
lowPowerModeEnabled: Bool? = nil,
|
||||
thermalState: ReplayThermalState? = nil,
|
||||
reason: String? = nil,
|
||||
delaySeconds: TimeInterval? = nil,
|
||||
previousScheduledAt: Date? = nil,
|
||||
candidateScheduledAt: Date? = nil,
|
||||
timerAdvanceAccepted: Bool? = nil,
|
||||
scheduleLeadSeconds: TimeInterval? = nil,
|
||||
refreshInFlight: Bool? = nil,
|
||||
codexActivitySeconds: TimeInterval? = nil,
|
||||
claudeActivitySeconds: TimeInterval? = nil,
|
||||
codexSessionDurationSeconds: TimeInterval? = nil,
|
||||
claudeSessionDurationSeconds: TimeInterval? = nil,
|
||||
codexTranscriptBytes: Int64? = nil,
|
||||
claudeTranscriptBytes: Int64? = nil,
|
||||
codexActiveTranscriptCount: Int? = nil,
|
||||
claudeActiveTranscriptCount: Int? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.timestamp = timestamp
|
||||
self.menuAgeSeconds = menuAgeSeconds
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalState = thermalState
|
||||
self.reason = reason
|
||||
self.delaySeconds = delaySeconds
|
||||
self.previousScheduledAt = previousScheduledAt
|
||||
self.candidateScheduledAt = candidateScheduledAt
|
||||
self.timerAdvanceAccepted = timerAdvanceAccepted
|
||||
self.scheduleLeadSeconds = scheduleLeadSeconds
|
||||
self.refreshInFlight = refreshInFlight
|
||||
self.codexActivitySeconds = codexActivitySeconds
|
||||
self.claudeActivitySeconds = claudeActivitySeconds
|
||||
self.codexSessionDurationSeconds = codexSessionDurationSeconds
|
||||
self.claudeSessionDurationSeconds = claudeSessionDurationSeconds
|
||||
self.codexTranscriptBytes = codexTranscriptBytes
|
||||
self.claudeTranscriptBytes = claudeTranscriptBytes
|
||||
self.codexActiveTranscriptCount = codexActiveTranscriptCount
|
||||
self.claudeActiveTranscriptCount = claudeActiveTranscriptCount
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public static func decision(
|
||||
timestamp: Date,
|
||||
menuAgeSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ReplayThermalState,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval,
|
||||
codexActivitySeconds: TimeInterval? = nil,
|
||||
claudeActivitySeconds: TimeInterval? = nil,
|
||||
codexSessionDurationSeconds: TimeInterval? = nil,
|
||||
claudeSessionDurationSeconds: TimeInterval? = nil,
|
||||
codexTranscriptBytes: Int64? = nil,
|
||||
claudeTranscriptBytes: Int64? = nil,
|
||||
codexActiveTranscriptCount: Int? = nil,
|
||||
claudeActiveTranscriptCount: Int? = nil) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .decision,
|
||||
timestamp: timestamp,
|
||||
menuAgeSeconds: menuAgeSeconds,
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalState: thermalState,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
codexActivitySeconds: codexActivitySeconds,
|
||||
claudeActivitySeconds: claudeActivitySeconds,
|
||||
codexSessionDurationSeconds: codexSessionDurationSeconds,
|
||||
claudeSessionDurationSeconds: claudeSessionDurationSeconds,
|
||||
codexTranscriptBytes: codexTranscriptBytes,
|
||||
claudeTranscriptBytes: claudeTranscriptBytes,
|
||||
codexActiveTranscriptCount: codexActiveTranscriptCount,
|
||||
claudeActiveTranscriptCount: claudeActiveTranscriptCount)
|
||||
}
|
||||
|
||||
public static func menuOpen(timestamp: Date) -> Self {
|
||||
Self(kind: .menuOpen, timestamp: timestamp)
|
||||
}
|
||||
|
||||
public static func refreshCompleted(timestamp: Date) -> Self {
|
||||
Self(kind: .refreshCompleted, timestamp: timestamp)
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - timestamp: When the menu open that triggered the advance occurred.
|
||||
/// - previousScheduledAt: The timer's scheduled refresh time immediately before the advance.
|
||||
/// - candidateScheduledAt: The refresh time the timer advanced to (`timestamp + delaySeconds`).
|
||||
/// - reason: The freshly computed decision's reason (e.g. `"recentInteraction"`).
|
||||
/// - delaySeconds: The freshly computed decision's delay.
|
||||
public static func timerAdvanced(
|
||||
timestamp: Date,
|
||||
previousScheduledAt: Date?,
|
||||
candidateScheduledAt: Date,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .timerAdvanced,
|
||||
timestamp: timestamp,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
previousScheduledAt: previousScheduledAt,
|
||||
candidateScheduledAt: candidateScheduledAt)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public static func timerAdvanceEvaluated(
|
||||
timestamp: Date,
|
||||
previousScheduledAt: Date?,
|
||||
candidateScheduledAt: Date,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval,
|
||||
accepted: Bool,
|
||||
refreshInFlight: Bool) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: timestamp,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
previousScheduledAt: previousScheduledAt,
|
||||
candidateScheduledAt: candidateScheduledAt,
|
||||
timerAdvanceAccepted: accepted,
|
||||
scheduleLeadSeconds: previousScheduledAt.map { $0.timeIntervalSince(candidateScheduledAt) },
|
||||
refreshInFlight: refreshInFlight)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// A malformed trace line, with enough context to find and fix it.
|
||||
public struct AdaptiveRefreshTraceParseError: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
public let lineNumber: Int
|
||||
public let content: String
|
||||
public let underlyingDescription: String
|
||||
|
||||
public init(lineNumber: Int, content: String, underlyingDescription: String) {
|
||||
self.lineNumber = lineNumber
|
||||
self.content = content
|
||||
self.underlyingDescription = underlyingDescription
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"trace line \(self.lineNumber) is malformed: \(self.underlyingDescription) (content: \(self.content))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses newline-delimited JSON adaptive-refresh traces.
|
||||
///
|
||||
/// Deliberate choice: a malformed line **fails the whole parse** rather than being silently
|
||||
/// skipped. A trace is acceptance evidence — if a line is corrupt (truncated write, disk-full
|
||||
/// mid-append, hand-edited fixture with a typo), the honest answer is "this trace is untrustworthy
|
||||
/// as a whole", not "here are metrics computed from however much of it happened to parse". A
|
||||
/// silently-shortened trace would still produce a superficially plausible replay report, which is
|
||||
/// worse than a loud failure: it hides exactly the kind of gap that would bias staleness/refresh
|
||||
/// counts. Callers that genuinely want best-effort parsing can catch the error and fall back to
|
||||
/// `AdaptiveRefreshTraceParser.parseTolerantly`, which skips bad lines and returns what parsed.
|
||||
public enum AdaptiveRefreshTraceParser {
|
||||
public static func parse(_ text: String) throws -> [AdaptiveRefreshTraceRecord] {
|
||||
let decoder = Self.makeDecoder()
|
||||
var records: [AdaptiveRefreshTraceRecord] = []
|
||||
for (index, line) in text.split(
|
||||
omittingEmptySubsequences: false,
|
||||
whereSeparator: \.isNewline).enumerated()
|
||||
{
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
guard let data = trimmed.data(using: .utf8) else {
|
||||
throw AdaptiveRefreshTraceParseError(
|
||||
lineNumber: index + 1,
|
||||
content: trimmed,
|
||||
underlyingDescription: "not valid UTF-8")
|
||||
}
|
||||
do {
|
||||
try records.append(decoder.decode(AdaptiveRefreshTraceRecord.self, from: data))
|
||||
} catch {
|
||||
throw AdaptiveRefreshTraceParseError(
|
||||
lineNumber: index + 1,
|
||||
content: trimmed,
|
||||
underlyingDescription: String(describing: error))
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
public static func parse(contentsOf url: URL) throws -> [AdaptiveRefreshTraceRecord] {
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
return try self.parse(text)
|
||||
}
|
||||
|
||||
/// Best-effort variant: skips lines that fail to parse instead of throwing. Not the default —
|
||||
/// see the type-level documentation for why silent skipping is the wrong default for
|
||||
/// acceptance-evidence traces. Exists for callers (future exploratory tooling) that explicitly
|
||||
/// want partial data over none.
|
||||
public static func parseTolerantly(_ text: String) -> [AdaptiveRefreshTraceRecord] {
|
||||
let decoder = Self.makeDecoder()
|
||||
var records: [AdaptiveRefreshTraceRecord] = []
|
||||
for line in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { continue }
|
||||
if let record = try? decoder.decode(AdaptiveRefreshTraceRecord.self, from: data) {
|
||||
records.append(record)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private static func makeDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
|
||||
/// Replay adapter for the same canonical policy core used by the CodexBar app.
|
||||
public struct AdaptiveReplayPolicy: ReplayPolicy, Sendable {
|
||||
public let name = "adaptive"
|
||||
|
||||
/// Matches `UsageStore.noteMenuOpened(at:)`'s adaptive-only advance guard: this is the one
|
||||
/// baseline that actually models the interaction-advance path, so it is the only one that
|
||||
/// overrides the protocol's `false` default.
|
||||
public let advancesOnInteraction = true
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
|
||||
now: input.now,
|
||||
lastMenuOpenAt: input.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: input.lowPowerModeEnabled,
|
||||
thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal))
|
||||
return ReplayPolicyDecision(
|
||||
delaySeconds: TimeInterval(decision.delay.components.seconds),
|
||||
reason: decision.reason.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed-cadence baseline: always waits the same interval, regardless of signals. Used to
|
||||
/// compare the adaptive policy against the flat refresh frequencies CodexBar also offers
|
||||
/// (2/5/15/30 minutes). Never advances on interaction (`advancesOnInteraction` stays the protocol
|
||||
/// default of `false`), matching the real app: fixed-cadence refresh frequencies never wire up
|
||||
/// `noteMenuOpened`'s advance check.
|
||||
public struct FixedIntervalPolicy: ReplayPolicy, Sendable {
|
||||
public let name: String
|
||||
private let intervalSeconds: TimeInterval
|
||||
|
||||
public init(minutes: Int) {
|
||||
self.name = "fixed-\(minutes)m"
|
||||
self.intervalSeconds = TimeInterval(minutes) * 60
|
||||
}
|
||||
|
||||
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
ReplayPolicyDecision(delaySeconds: self.intervalSeconds, reason: "fixed")
|
||||
}
|
||||
}
|
||||
|
||||
/// The degenerate floor: never schedules a refresh. A trace replayed against this policy always
|
||||
/// reports zero refreshes, which is the point — it establishes the worst-case staleness bound the
|
||||
/// other policies are compared against.
|
||||
public struct ManualPolicy: ReplayPolicy, Sendable {
|
||||
public let name = "manual"
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
ReplayPolicyDecision(delaySeconds: nil, reason: "manual")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// Replay-only candidate used to test whether stat-only coding activity can close the accepted
|
||||
/// "active work is never slower than five minutes" gap. It is not a production policy approval.
|
||||
public struct CodingActivityAdaptivePolicy: ReplayPolicy, Sendable {
|
||||
public let name = "adaptive-activity"
|
||||
public let advancesOnInteraction = true
|
||||
|
||||
private let base = AdaptiveReplayPolicy()
|
||||
private static let activeThreshold: TimeInterval = 5 * 60
|
||||
private static let activeDelayCap: TimeInterval = 5 * 60
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
let baseDecision = self.base.decide(input)
|
||||
guard !input.isConstrained,
|
||||
let activityAge = input.codingActivityAgeSeconds,
|
||||
activityAge < Self.activeThreshold,
|
||||
let baseDelay = baseDecision.delaySeconds,
|
||||
baseDelay > Self.activeDelayCap
|
||||
else {
|
||||
return baseDecision
|
||||
}
|
||||
return ReplayPolicyDecision(delaySeconds: Self.activeDelayCap, reason: "codingActivity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# AdaptiveReplayKit
|
||||
|
||||
`AdaptiveReplayKit` is an offline harness for comparing refresh-timing policies against an
|
||||
explicit JSONL trace. `AdaptiveReplayCLI` is the command-line wrapper around the library.
|
||||
|
||||
## Scope
|
||||
|
||||
The replay targets do not import `CodexBar` or `CodexBarCore`; they share only the package-internal,
|
||||
Foundation-only `AdaptiveRefreshCore` target with the app. They do not record app behavior, scan
|
||||
Codex or Claude transcript directories, write trace files, call providers, or change the production
|
||||
refresh policy. Trace capture and lifecycle management are deliberately outside this PR; callers
|
||||
provide an existing trace path to the CLI.
|
||||
|
||||
Optional activity fields in the trace schema are inputs only. The replay kit never discovers or
|
||||
collects them. Old records without those fields continue to decode.
|
||||
|
||||
## Components
|
||||
|
||||
- `AdaptiveRefreshTrace.swift` defines the version-tolerant trace schema.
|
||||
- `AdaptiveRefreshTraceParser.swift` parses JSONL strictly by default. The tolerant entry point is
|
||||
available for exploratory work that explicitly accepts skipped malformed records.
|
||||
- `AdaptiveRefreshCore` owns the production decision table. `ReplayPolicy.swift`,
|
||||
`BaselinePolicies.swift`, and `CandidatePolicies.swift` provide replay adapters, fixed/manual
|
||||
baselines, and the replay-only activity candidate.
|
||||
- `ReplayEngine.swift` and `ReplayMetrics.swift` calculate simulated refresh cadence, menu-open
|
||||
staleness, interaction advances, and constrained-state compliance.
|
||||
- `ReplayTraceSegmentation.swift` excludes legacy deadline-overrun gaps with an explicit heuristic
|
||||
and reports the excluded duration.
|
||||
- `RecordedScheduleAudit.swift` audits recorded timer-advance events independently from the replay
|
||||
clock.
|
||||
- `Sources/AdaptiveReplayCLI` formats table or JSON reports.
|
||||
|
||||
`interactionAdvanceCount` is counterfactual. Replay assumes a zero-duration refresh, while the
|
||||
live app waits for provider work and may already have a refresh in flight. Recorded schedule events
|
||||
therefore have a separate audit instead of a direct count comparison.
|
||||
|
||||
The legacy gap heuristic cannot distinguish sleep or reboot from a long refresh or event-loop
|
||||
stall. Reports expose the segment count, grace interval, and excluded time rather than assigning a
|
||||
cause.
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
public struct RecordedScheduleAudit: Sendable, Equatable {
|
||||
public let recordedAdvanceCount: Int
|
||||
public let evaluatedCount: Int
|
||||
public let acceptedEvaluationCount: Int
|
||||
public let rejectedEvaluationCount: Int
|
||||
public let payloadMismatchCount: Int
|
||||
public let decisionMismatchCount: Int
|
||||
public let menuLinkMismatchCount: Int
|
||||
public let ambiguousComparisonCount: Int
|
||||
|
||||
public var isValid: Bool {
|
||||
self.payloadMismatchCount == 0
|
||||
&& self.decisionMismatchCount == 0
|
||||
&& self.menuLinkMismatchCount == 0
|
||||
&& self.ambiguousComparisonCount == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Audits the live schedule records without equating them to ReplayEngine's counterfactual clock.
|
||||
public enum RecordedScheduleAuditor {
|
||||
public static func audit(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
timestampTolerance: TimeInterval = 1) -> RecordedScheduleAudit
|
||||
{
|
||||
let sorted = records.sorted { $0.timestamp < $1.timestamp }
|
||||
let menuTimestamps = sorted.filter { $0.kind == .menuOpen }.map(\.timestamp)
|
||||
let advances = sorted.filter { $0.kind == .timerAdvanced }
|
||||
let evaluations = sorted.filter { $0.kind == .timerAdvanceEvaluated }
|
||||
|
||||
var payloadMismatchCount = advances.count(where: { !Self.payloadIsValid($0) })
|
||||
let evaluationOutcomes = evaluations.map(Self.evaluationOutcome)
|
||||
let decisionMismatchCount = evaluationOutcomes.count(where: { $0 == .mismatch })
|
||||
let ambiguousComparisonCount = evaluationOutcomes.count(where: { $0 == .ambiguous })
|
||||
// Every evaluation is caused by one menu open. Before evaluation records existed, an
|
||||
// accepted advance was the only causal record, so retain those legacy advances as linkage
|
||||
// events. Modern accepted advances are reconciled against evaluations below instead of
|
||||
// consuming the same menu open twice.
|
||||
let legacyAdvances = evaluations.first.map { firstEvaluation in
|
||||
advances.filter { $0.timestamp < firstEvaluation.timestamp }
|
||||
} ?? advances
|
||||
let menuLinkMismatchCount = Self.unmatchedEventCount(
|
||||
evaluations + legacyAdvances,
|
||||
menuTimestamps: menuTimestamps,
|
||||
timestampTolerance: timestampTolerance)
|
||||
|
||||
if let firstEvaluationAt = evaluations.first?.timestamp {
|
||||
let accepted = evaluations.filter { $0.timerAdvanceAccepted == true }
|
||||
let auditableAdvances = advances.filter { $0.timestamp >= firstEvaluationAt }
|
||||
payloadMismatchCount += Self.scheduleMultiplicityDifference(accepted, auditableAdvances)
|
||||
}
|
||||
|
||||
return RecordedScheduleAudit(
|
||||
recordedAdvanceCount: advances.count,
|
||||
evaluatedCount: evaluations.count,
|
||||
acceptedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == true }),
|
||||
rejectedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == false }),
|
||||
payloadMismatchCount: payloadMismatchCount,
|
||||
decisionMismatchCount: decisionMismatchCount,
|
||||
menuLinkMismatchCount: menuLinkMismatchCount,
|
||||
ambiguousComparisonCount: ambiguousComparisonCount)
|
||||
}
|
||||
|
||||
private static func payloadIsValid(_ record: AdaptiveRefreshTraceRecord) -> Bool {
|
||||
guard let candidate = record.candidateScheduledAt,
|
||||
let delay = record.delaySeconds,
|
||||
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
|
||||
else { return false }
|
||||
// Whole-second legacy timestamps can collapse a sub-second accepted lead to equality.
|
||||
return record.previousScheduledAt.map { candidate <= $0 } ?? true
|
||||
}
|
||||
|
||||
private enum EvaluationOutcome: Equatable {
|
||||
case valid
|
||||
case mismatch
|
||||
case ambiguous
|
||||
}
|
||||
|
||||
private static func evaluationOutcome(_ record: AdaptiveRefreshTraceRecord) -> EvaluationOutcome {
|
||||
guard let accepted = record.timerAdvanceAccepted,
|
||||
let candidate = record.candidateScheduledAt,
|
||||
let delay = record.delaySeconds,
|
||||
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
|
||||
else { return .mismatch }
|
||||
guard let previous = record.previousScheduledAt else { return accepted ? .valid : .mismatch }
|
||||
if candidate != previous {
|
||||
return accepted == (candidate < previous) ? .valid : .mismatch
|
||||
}
|
||||
guard let lead = record.scheduleLeadSeconds else { return .ambiguous }
|
||||
return accepted == (lead > 0) ? .valid : .mismatch
|
||||
}
|
||||
|
||||
private struct ScheduleKey: Hashable {
|
||||
let timestamp: Date
|
||||
let previousScheduledAt: Date?
|
||||
let candidateScheduledAt: Date?
|
||||
let reason: String?
|
||||
let delaySeconds: TimeInterval?
|
||||
}
|
||||
|
||||
private static func scheduleMultiplicityDifference(
|
||||
_ lhs: [AdaptiveRefreshTraceRecord],
|
||||
_ rhs: [AdaptiveRefreshTraceRecord]) -> Int
|
||||
{
|
||||
func counts(_ records: [AdaptiveRefreshTraceRecord]) -> [ScheduleKey: Int] {
|
||||
Dictionary(grouping: records, by: scheduleKey).mapValues(\.count)
|
||||
}
|
||||
let lhsCounts = counts(lhs)
|
||||
let rhsCounts = counts(rhs)
|
||||
return Set(lhsCounts.keys).union(rhsCounts.keys).reduce(0) { difference, key in
|
||||
difference + abs(lhsCounts[key, default: 0] - rhsCounts[key, default: 0])
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum one-to-one matching for sorted points with a symmetric tolerance window. Extra
|
||||
/// menu opens are valid because fixed/manual modes do not emit schedule evaluations; only an
|
||||
/// event without its own causal menu open is a mismatch.
|
||||
private static func unmatchedEventCount(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
menuTimestamps: [Date],
|
||||
timestampTolerance: TimeInterval) -> Int
|
||||
{
|
||||
let eventTimestamps = records.map(\.timestamp).sorted()
|
||||
var eventIndex = 0
|
||||
var menuIndex = 0
|
||||
var unmatched = 0
|
||||
|
||||
while eventIndex < eventTimestamps.count, menuIndex < menuTimestamps.count {
|
||||
let eventTimestamp = eventTimestamps[eventIndex]
|
||||
let menuTimestamp = menuTimestamps[menuIndex]
|
||||
if menuTimestamp < eventTimestamp.addingTimeInterval(-timestampTolerance) {
|
||||
menuIndex += 1
|
||||
} else if menuTimestamp > eventTimestamp.addingTimeInterval(timestampTolerance) {
|
||||
unmatched += 1
|
||||
eventIndex += 1
|
||||
} else {
|
||||
eventIndex += 1
|
||||
menuIndex += 1
|
||||
}
|
||||
}
|
||||
return unmatched + eventTimestamps.count - eventIndex
|
||||
}
|
||||
|
||||
private static func scheduleKey(_ record: AdaptiveRefreshTraceRecord) -> ScheduleKey {
|
||||
ScheduleKey(
|
||||
timestamp: record.timestamp,
|
||||
previousScheduledAt: record.previousScheduledAt,
|
||||
candidateScheduledAt: record.candidateScheduledAt,
|
||||
reason: record.reason,
|
||||
delaySeconds: record.delaySeconds)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Mean/median/p95 of staleness (seconds since the last simulated refresh) observed at each
|
||||
/// historical menu-open event. `p95` uses nearest-rank: samples are sorted ascending and index
|
||||
/// `ceil(0.95 * n) - 1` (clamped to the last index) is reported — the same convention most
|
||||
/// dashboards use for small-to-medium sample counts, and simple enough to hand-verify in tests.
|
||||
public struct StalenessStats: Sendable, Equatable {
|
||||
public let mean: Double
|
||||
public let median: Double
|
||||
public let p95: Double
|
||||
public let sampleCount: Int
|
||||
|
||||
public init(mean: Double, median: Double, p95: Double, sampleCount: Int) {
|
||||
self.mean = mean
|
||||
self.median = median
|
||||
self.p95 = p95
|
||||
self.sampleCount = sampleCount
|
||||
}
|
||||
|
||||
init?(samples: [Double]) {
|
||||
guard !samples.isEmpty else { return nil }
|
||||
let sorted = samples.sorted()
|
||||
self.init(
|
||||
mean: sorted.reduce(0, +) / Double(sorted.count),
|
||||
median: Self.percentile(sorted, fraction: 0.5),
|
||||
p95: Self.percentile(sorted, fraction: 0.95),
|
||||
sampleCount: sorted.count)
|
||||
}
|
||||
|
||||
private static func percentile(_ sorted: [Double], fraction: Double) -> Double {
|
||||
let rank = Int((fraction * Double(sorted.count)).rounded(.up))
|
||||
return sorted[max(0, min(sorted.count - 1, rank - 1))]
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a policy honored the "never refresh faster than 30 minutes while constrained (low
|
||||
/// power or serious/critical thermal)" rule at every simulated decision point where the input was
|
||||
/// constrained.
|
||||
public struct ConstrainedCompliance: Sendable, Equatable {
|
||||
public let constrainedDecisionCount: Int
|
||||
public let violationCount: Int
|
||||
|
||||
public init(constrainedDecisionCount: Int, violationCount: Int) {
|
||||
self.constrainedDecisionCount = constrainedDecisionCount
|
||||
self.violationCount = violationCount
|
||||
}
|
||||
|
||||
public var isCompliant: Bool {
|
||||
self.violationCount == 0
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplayMetrics: Sendable, Equatable {
|
||||
public let policyName: String
|
||||
public let simulatedSpanSeconds: TimeInterval
|
||||
public let totalRefreshCount: Int
|
||||
public let refreshCountPer24h: Double
|
||||
public let stalenessAtMenuOpen: StalenessStats?
|
||||
public let constrainedCompliance: ConstrainedCompliance
|
||||
/// How many of `totalRefreshCount` were pulled forward by a menu-open interaction rather than
|
||||
/// firing on the policy's own previously scheduled cadence — i.e. how many times
|
||||
/// `ReplayEngine.run` took the `advancesOnInteraction` branch for this policy. Always `0` for
|
||||
/// policies that report `advancesOnInteraction == false` (see `ReplayPolicy`).
|
||||
public let interactionAdvanceCount: Int
|
||||
/// Unconstrained replayed decisions with a known transcript-write observation under five minutes old.
|
||||
public let codingActiveDecisionCount: Int
|
||||
/// Unconstrained active decisions whose selected delay exceeded the five-minute acceptance cap.
|
||||
public let codingActiveDelayViolationCount: Int
|
||||
/// Number of independently simulated awake/run segments contributing to these metrics.
|
||||
public let segmentCount: Int
|
||||
/// Wall-clock time excluded after an expected timer deadline because the app was unobserved.
|
||||
public let excludedGapSeconds: TimeInterval
|
||||
/// Menu opens before a segment's first recorded refresh, excluded equally for every policy.
|
||||
public let boundaryCensoredMenuOpenCount: Int
|
||||
|
||||
public init(
|
||||
policyName: String,
|
||||
simulatedSpanSeconds: TimeInterval,
|
||||
totalRefreshCount: Int,
|
||||
refreshCountPer24h: Double,
|
||||
stalenessAtMenuOpen: StalenessStats?,
|
||||
constrainedCompliance: ConstrainedCompliance,
|
||||
interactionAdvanceCount: Int = 0,
|
||||
codingActiveDecisionCount: Int = 0,
|
||||
codingActiveDelayViolationCount: Int = 0,
|
||||
segmentCount: Int = 1,
|
||||
excludedGapSeconds: TimeInterval = 0,
|
||||
boundaryCensoredMenuOpenCount: Int = 0)
|
||||
{
|
||||
self.policyName = policyName
|
||||
self.simulatedSpanSeconds = simulatedSpanSeconds
|
||||
self.totalRefreshCount = totalRefreshCount
|
||||
self.refreshCountPer24h = refreshCountPer24h
|
||||
self.stalenessAtMenuOpen = stalenessAtMenuOpen
|
||||
self.constrainedCompliance = constrainedCompliance
|
||||
self.interactionAdvanceCount = interactionAdvanceCount
|
||||
self.codingActiveDecisionCount = codingActiveDecisionCount
|
||||
self.codingActiveDelayViolationCount = codingActiveDelayViolationCount
|
||||
self.segmentCount = segmentCount
|
||||
self.excludedGapSeconds = excludedGapSeconds
|
||||
self.boundaryCensoredMenuOpenCount = boundaryCensoredMenuOpenCount
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplayRun: Sendable {
|
||||
let metrics: ReplayMetrics
|
||||
let stalenessSamples: [Double]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
// Replay harness for the adaptive refresh policy shipped in the `CodexBar` app target. The app
|
||||
// and replay adapter both call `AdaptiveRefreshPolicyCore`; these types only normalize replay
|
||||
// inputs and report replay-friendly output.
|
||||
|
||||
/// Coarse thermal-pressure signal matching the two `ProcessInfo.ThermalState` cases the policy
|
||||
/// distinguishes (`.serious`/`.critical` vs everything else), expressed independently so this
|
||||
/// library never needs Darwin-only APIs and can build on any platform.
|
||||
public enum ReplayThermalState: String, Sendable, Codable, CaseIterable {
|
||||
case nominal
|
||||
case fair
|
||||
case serious
|
||||
case critical
|
||||
|
||||
public var isConstrained: Bool {
|
||||
self == .serious || self == .critical
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs a refresh-timing policy needs to decide how long to wait before the next refresh.
|
||||
/// Replay-specific policy input. Platform-independent fields map into the shared policy core.
|
||||
public struct ReplayPolicyInput: Sendable, Equatable {
|
||||
public let now: Date
|
||||
public let lastMenuOpenAt: Date?
|
||||
/// Most recent transcript write reconstructed from the latest activity observation available
|
||||
/// at or before `now`. This is nil when that observation could not see either CLI.
|
||||
public let lastCodingActivityAt: Date?
|
||||
public let lowPowerModeEnabled: Bool
|
||||
public let thermalState: ReplayThermalState
|
||||
|
||||
public init(
|
||||
now: Date,
|
||||
lastMenuOpenAt: Date?,
|
||||
lastCodingActivityAt: Date? = nil,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
self.now = now
|
||||
self.lastMenuOpenAt = lastMenuOpenAt
|
||||
self.lastCodingActivityAt = lastCodingActivityAt
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalState = thermalState
|
||||
}
|
||||
|
||||
/// Whether this input represents a power/thermal-constrained moment, independent of which
|
||||
/// policy is deciding. Used by the replay engine to score constrained-tier compliance without
|
||||
/// depending on any single policy's own notion of "constrained".
|
||||
public var isConstrained: Bool {
|
||||
self.lowPowerModeEnabled || self.thermalState.isConstrained
|
||||
}
|
||||
|
||||
public var codingActivityAgeSeconds: TimeInterval? {
|
||||
self.lastCodingActivityAt.map { max(0, self.now.timeIntervalSince($0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A policy's decision: how long to wait, and a short human-readable reason code for reporting.
|
||||
/// `delaySeconds == nil` means "never schedule another refresh" — the degenerate floor used by
|
||||
/// `ManualPolicy`.
|
||||
public struct ReplayPolicyDecision: Sendable, Equatable {
|
||||
public let delaySeconds: TimeInterval?
|
||||
public let reason: String
|
||||
|
||||
public init(delaySeconds: TimeInterval?, reason: String) {
|
||||
self.delaySeconds = delaySeconds
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/// A pure, deterministic function from `ReplayPolicyInput` to `ReplayPolicyDecision`.
|
||||
public protocol ReplayPolicy: Sendable {
|
||||
var name: String { get }
|
||||
|
||||
/// Whether opening the menu can pull this policy's next refresh forward, mirroring
|
||||
/// `UsageStore.noteMenuOpened(at:)`'s guard on `settings.refreshFrequency == .adaptive`: in the
|
||||
/// real app, only adaptive mode ever advances the timer from an interaction — fixed-cadence and
|
||||
/// manual modes just record `lastMenuOpenAt` and let the existing schedule run. Defaults to
|
||||
/// `false` so baseline policies (`FixedIntervalPolicy`, `ManualPolicy`) need no override; only
|
||||
/// policies that actually model the adaptive table set this to `true`.
|
||||
var advancesOnInteraction: Bool { get }
|
||||
|
||||
func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision
|
||||
}
|
||||
|
||||
extension ReplayPolicy {
|
||||
public var advancesOnInteraction: Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
public struct ReplayTraceSegment: Sendable, Equatable {
|
||||
public let records: [AdaptiveRefreshTraceRecord]
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
|
||||
var replayRecords: [AdaptiveRefreshTraceRecord] {
|
||||
guard self.records.last?.timestamp != self.end else { return self.records }
|
||||
return self.records + [.refreshCompleted(timestamp: self.end)]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaySegmentationReport: Sendable, Equatable {
|
||||
public let segments: [ReplayTraceSegment]
|
||||
public let excludedGapSeconds: TimeInterval
|
||||
public let breakCount: Int
|
||||
public let graceSeconds: TimeInterval
|
||||
|
||||
public var includedSpanSeconds: TimeInterval {
|
||||
self.segments.reduce(0) { $0 + max(0, $1.end.timeIntervalSince($1.start)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits legacy traces only when observation resumes well after the last timer deadline. The
|
||||
/// normal scheduled wait remains inside the preceding segment; only overdue wall time is excluded.
|
||||
public enum ReplayTraceSegmenter {
|
||||
public static let defaultGraceSeconds: TimeInterval = 5 * 60
|
||||
|
||||
public static func automatic(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
graceSeconds: TimeInterval = Self.defaultGraceSeconds) -> ReplaySegmentationReport
|
||||
{
|
||||
let sorted = records.sorted { $0.timestamp < $1.timestamp }
|
||||
guard let first = sorted.first else {
|
||||
return ReplaySegmentationReport(
|
||||
segments: [], excludedGapSeconds: 0, breakCount: 0, graceSeconds: graceSeconds)
|
||||
}
|
||||
|
||||
var segments: [ReplayTraceSegment] = []
|
||||
var currentRecords: [AdaptiveRefreshTraceRecord] = []
|
||||
var currentStart = first.timestamp
|
||||
var expectedDeadline: Date?
|
||||
var excludedGapSeconds: TimeInterval = 0
|
||||
|
||||
for record in sorted {
|
||||
if let deadline = expectedDeadline,
|
||||
record.timestamp.timeIntervalSince(deadline) > graceSeconds,
|
||||
!currentRecords.isEmpty
|
||||
{
|
||||
let end = max(currentRecords.last!.timestamp, deadline)
|
||||
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: end))
|
||||
excludedGapSeconds += max(0, record.timestamp.timeIntervalSince(end))
|
||||
currentRecords = []
|
||||
currentStart = record.timestamp
|
||||
expectedDeadline = nil
|
||||
}
|
||||
|
||||
currentRecords.append(record)
|
||||
if record.kind == .decision, let delay = record.delaySeconds, delay > 0 {
|
||||
expectedDeadline = record.timestamp.addingTimeInterval(delay)
|
||||
} else if record.kind == .timerAdvanced, let candidate = record.candidateScheduledAt {
|
||||
expectedDeadline = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if let last = currentRecords.last {
|
||||
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: last.timestamp))
|
||||
}
|
||||
return ReplaySegmentationReport(
|
||||
segments: segments,
|
||||
excludedGapSeconds: excludedGapSeconds,
|
||||
breakCount: max(0, segments.count - 1),
|
||||
graceSeconds: graceSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension ReplayEngine {
|
||||
public static func runSegmented(
|
||||
trace: [AdaptiveRefreshTraceRecord],
|
||||
policy: some ReplayPolicy,
|
||||
graceSeconds: TimeInterval = ReplayTraceSegmenter.defaultGraceSeconds) -> ReplayMetrics
|
||||
{
|
||||
let report = ReplayTraceSegmenter.automatic(trace, graceSeconds: graceSeconds)
|
||||
let stalenessStarts = report.segments.map { segment in
|
||||
segment.records.first(where: { $0.kind == .refreshCompleted })?.timestamp
|
||||
}
|
||||
let runs = zip(report.segments, stalenessStarts).map { segment, stalenessStart in
|
||||
self.runDetailed(
|
||||
trace: segment.replayRecords,
|
||||
policy: policy,
|
||||
stalenessStartAt: stalenessStart ?? .distantFuture)
|
||||
}
|
||||
let boundaryCensoredMenuOpenCount = zip(report.segments, stalenessStarts).reduce(0) { partial, pair in
|
||||
let (segment, stalenessStart) = pair
|
||||
return partial + segment.records.count(where: { record in
|
||||
record.kind == .menuOpen && (stalenessStart.map { record.timestamp < $0 } ?? true)
|
||||
})
|
||||
}
|
||||
let span = report.includedSpanSeconds
|
||||
let refreshCount = runs.reduce(0) { $0 + $1.metrics.totalRefreshCount }
|
||||
let stalenessSamples = runs.flatMap(\.stalenessSamples)
|
||||
return ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: span,
|
||||
totalRefreshCount: refreshCount,
|
||||
refreshCountPer24h: span > 0 ? Double(refreshCount) * 86400 / span : 0,
|
||||
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
|
||||
constrainedCompliance: ConstrainedCompliance(
|
||||
constrainedDecisionCount: runs.reduce(0) {
|
||||
$0 + $1.metrics.constrainedCompliance.constrainedDecisionCount
|
||||
},
|
||||
violationCount: runs.reduce(0) { $0 + $1.metrics.constrainedCompliance.violationCount }),
|
||||
interactionAdvanceCount: runs.reduce(0) { $0 + $1.metrics.interactionAdvanceCount },
|
||||
codingActiveDecisionCount: runs.reduce(0) { $0 + $1.metrics.codingActiveDecisionCount },
|
||||
codingActiveDelayViolationCount: runs.reduce(0) {
|
||||
$0 + $1.metrics.codingActiveDelayViolationCount
|
||||
},
|
||||
segmentCount: report.segments.count,
|
||||
excludedGapSeconds: report.excludedGapSeconds,
|
||||
boundaryCensoredMenuOpenCount: boundaryCensoredMenuOpenCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user