202 lines
9.8 KiB
Swift
202 lines
9.8 KiB
Swift
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)
|
|
}
|
|
}
|