chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Purely informational trace-level stats surfaced by `AdaptiveReplayCLI` — computed directly from
|
||||
/// raw `decision` records, independent of any `ReplayPolicy` or `ReplayEngine` simulation.
|
||||
struct ActivityCoverageStatsTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private static func decision(codex: TimeInterval?, claude: TimeInterval?) -> AdaptiveRefreshTraceRecord {
|
||||
.decision(
|
||||
timestamp: self.referenceNow,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "warm",
|
||||
delaySeconds: 300,
|
||||
codexActivitySeconds: codex,
|
||||
claudeActivitySeconds: claude)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `an empty trace reports zero decisions and zero fractions`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [])
|
||||
#expect(stats.decisionCount == 0)
|
||||
#expect(stats.sampledCount == 0)
|
||||
#expect(stats.activeCount == 0)
|
||||
#expect(stats.sampledFraction == 0)
|
||||
#expect(stats.activeFraction == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non decision records are ignored entirely`() {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.referenceNow),
|
||||
.refreshCompleted(timestamp: Self.referenceNow),
|
||||
]
|
||||
let stats = ActivityCoverageStats.compute(from: records)
|
||||
#expect(stats.decisionCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a decision with neither activity field set counts toward decisionCount but not sampledCount`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: nil, claude: nil)])
|
||||
#expect(stats.decisionCount == 1)
|
||||
#expect(stats.sampledCount == 0)
|
||||
#expect(stats.activeCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a decision with only one activity field set still counts as sampled`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: 500, claude: nil)])
|
||||
#expect(stats.sampledCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a sampled decision under the active threshold on either CLI counts as active`() {
|
||||
let codexActive = ActivityCoverageStats.compute(from: [Self.decision(codex: 100, claude: nil)])
|
||||
#expect(codexActive.activeCount == 1)
|
||||
|
||||
let claudeActive = ActivityCoverageStats.compute(from: [Self.decision(codex: nil, claude: 100)])
|
||||
#expect(claudeActive.activeCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a sampled decision at or above the active threshold on both CLIs does not count as active`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: 500, claude: 400)])
|
||||
#expect(stats.sampledCount == 1)
|
||||
#expect(stats.activeCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fractions are computed against decisionCount and sampledCount respectively`() {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
Self.decision(codex: 100, claude: nil), // sampled, active
|
||||
Self.decision(codex: 500, claude: 400), // sampled, not active
|
||||
Self.decision(codex: nil, claude: nil), // not sampled
|
||||
Self.decision(codex: nil, claude: nil), // not sampled
|
||||
]
|
||||
let stats = ActivityCoverageStats.compute(from: records)
|
||||
#expect(stats.decisionCount == 4)
|
||||
#expect(stats.sampledCount == 2)
|
||||
#expect(stats.activeCount == 1)
|
||||
#expect(stats.sampledFraction == 0.5)
|
||||
#expect(stats.activeFraction == 0.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a custom active threshold changes the active classification`() {
|
||||
let stats = ActivityCoverageStats.compute(
|
||||
from: [Self.decision(codex: 250, claude: nil)],
|
||||
activeThresholdSeconds: 60)
|
||||
#expect(stats.sampledCount == 1)
|
||||
#expect(stats.activeCount == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AdaptiveRefreshPolicyCoreTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func input(
|
||||
ageSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool = false,
|
||||
thermalPressure: AdaptiveRefreshPolicyCore.ThermalPressure = .nominal)
|
||||
-> AdaptiveRefreshPolicyCore.Input
|
||||
{
|
||||
AdaptiveRefreshPolicyCore.Input(
|
||||
now: Self.referenceNow,
|
||||
lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalPressure: thermalPressure)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(-600.0, AdaptiveRefreshPolicyCore.Reason.recentInteraction, 120),
|
||||
(0.0, .recentInteraction, 120),
|
||||
(299.0, .recentInteraction, 120),
|
||||
(300.0, .recentInteraction, 120),
|
||||
(301.0, .warm, 300),
|
||||
(3599.0, .warm, 300),
|
||||
(3600.0, .warm, 300),
|
||||
(3601.0, .idle, 900),
|
||||
(14399.0, .idle, 900),
|
||||
(14400.0, .longIdle, 1800),
|
||||
(100_000.0, .longIdle, 1800),
|
||||
])
|
||||
func `age determines the canonical table boundary`(
|
||||
ageSeconds: TimeInterval,
|
||||
expectedReason: AdaptiveRefreshPolicyCore.Reason,
|
||||
expectedDelaySeconds: Int)
|
||||
{
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: ageSeconds))
|
||||
#expect(decision.reason == expectedReason)
|
||||
#expect(decision.delay == .seconds(expectedDelaySeconds))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `nil last menu open is long idle`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: nil))
|
||||
#expect(decision.reason == .longIdle)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `low power mode wins over recent interaction`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(
|
||||
ageSeconds: 0,
|
||||
lowPowerModeEnabled: true))
|
||||
#expect(decision.reason == .constrained)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `thermal pressure wins when no menu open is recorded`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(
|
||||
ageSeconds: nil,
|
||||
thermalPressure: .constrained))
|
||||
#expect(decision.reason == .constrained)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `future timestamps read as recent`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: -1_000_000))
|
||||
#expect(decision.reason == .recentInteraction)
|
||||
#expect(decision.delay == .seconds(2 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `every decision stays within the two to thirty minute bounds`() {
|
||||
let ages: [TimeInterval?] = [nil, -1_000_000, 0, 300, 301, 3600, 3601, 14399, 14400, 1_000_000]
|
||||
for age in ages {
|
||||
for lowPowerModeEnabled in [false, true] {
|
||||
for thermalPressure in [
|
||||
AdaptiveRefreshPolicyCore.ThermalPressure.nominal,
|
||||
.constrained,
|
||||
] {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(
|
||||
ageSeconds: age,
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalPressure: thermalPressure))
|
||||
#expect(decision.delay >= .seconds(2 * 60))
|
||||
#expect(decision.delay <= .seconds(30 * 60))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `nominal heuristic interval remains five minutes`() {
|
||||
#expect(AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics == 5 * 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Hand-computed metric checks against small synthetic traces, plus determinism and baseline
|
||||
/// (manual/fixed) sanity checks for `ReplayEngine`. Trace construction stays in-code (no fixture
|
||||
/// files): each trace is small enough that its expected metrics can be derived by hand in the
|
||||
/// comments beside it, which is the actual verification for requirement 4 ("metric math verified
|
||||
/// against hand-computed values").
|
||||
struct AdaptiveReplayEngineTests {
|
||||
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
private func at(_ seconds: TimeInterval) -> Date {
|
||||
Self.epoch.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
/// A one-hour span (t=0...3600) pinned by two `decision` boundary records, `FixedIntervalPolicy`
|
||||
/// refreshing every 10 minutes, and four `menuOpen` events chosen so each falls a different,
|
||||
/// hand-computable number of seconds after the preceding simulated refresh.
|
||||
///
|
||||
/// Refreshes land at t=600,1200,...,3600 (6 total: cursor starts at 0, and 3600 <= end is still
|
||||
/// included). Staleness samples: menuOpen@50 -> 50-0=50 (no refresh yet, falls back to
|
||||
/// time-since-trace-start); @900 -> 900-600=300; @2200 -> 2200-1800=400; @3500 -> 3500-3000=500.
|
||||
/// mean=(50+300+400+500)/4=312.5, median (nearest-rank, sorted=[50,300,400,500])=sorted[1]=300,
|
||||
/// p95=sorted[3]=500.
|
||||
private func fixedCadenceTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: self.at(3600),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
.menuOpen(timestamp: self.at(900)),
|
||||
.menuOpen(timestamp: self.at(2200)),
|
||||
.menuOpen(timestamp: self.at(3500)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed cadence refresh count and staleness match hand computation`() throws {
|
||||
let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: FixedIntervalPolicy(minutes: 10))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 6)
|
||||
#expect(metrics.simulatedSpanSeconds == 3600.0)
|
||||
#expect(metrics.refreshCountPer24h == 144.0) // 6 refreshes/hour * 24h
|
||||
#expect(metrics.interactionAdvanceCount == 0) // fixed cadence never advances on interaction
|
||||
|
||||
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
||||
#expect(staleness.sampleCount == 4)
|
||||
#expect(staleness.mean == 312.5)
|
||||
#expect(staleness.median == 300.0)
|
||||
#expect(staleness.p95 == 500.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replaying the same trace and policy twice is deterministic`() {
|
||||
let trace = self.fixedCadenceTrace()
|
||||
let first = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
let second = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `manual policy never schedules a refresh`() {
|
||||
let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: ManualPolicy())
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
#expect(metrics.refreshCountPer24h == 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a trace with no menu-open events reports no staleness stats`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.refreshCompleted(timestamp: self.at(1800)),
|
||||
]
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy())
|
||||
#expect(metrics.stalenessAtMenuOpen == nil)
|
||||
}
|
||||
|
||||
/// A single constrained (`lowPowerModeEnabled: true`) sample at t=0, held for the whole
|
||||
/// 0...1000 span (no later sample overrides it), replayed against `FixedIntervalPolicy(2m)`
|
||||
/// (120s, well under the 30-minute constrained floor).
|
||||
///
|
||||
/// decide() is called at cursor = 0,120,240,...,960 (9 calls: the call at 960 computes
|
||||
/// next=1080 > end=1000 and breaks before appending). All 9 calls see the constrained sample,
|
||||
/// and every one returns a 120s delay, so all 9 are violations. 8 of those calls' `next` landed
|
||||
/// at or before 1000 (120,240,...,960), so 8 refreshes were recorded.
|
||||
private func constrainedTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: true,
|
||||
thermalState: .nominal,
|
||||
reason: "constrained",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(1000)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a policy that ignores the constrained floor is flagged non-compliant`() {
|
||||
let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: FixedIntervalPolicy(minutes: 2))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 8)
|
||||
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 9)
|
||||
#expect(metrics.constrainedCompliance.violationCount == 9)
|
||||
#expect(!metrics.constrainedCompliance.isCompliant)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `the shared adaptive policy honors the constrained floor`() {
|
||||
let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: AdaptiveReplayPolicy())
|
||||
|
||||
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 1)
|
||||
#expect(metrics.constrainedCompliance.violationCount == 0)
|
||||
#expect(metrics.constrainedCompliance.isCompliant)
|
||||
// The menu open at t=1000 is still under low-power, so the advance-check itself also
|
||||
// returns the constrained floor (candidate = 1000+1800 = 2800), which is later than the
|
||||
// already-scheduled t=1800 tick — no advance is taken. Mirrors the real
|
||||
// `noteMenuOpened(at:)` guard: opening the menu while constrained never shortens the timer.
|
||||
#expect(metrics.interactionAdvanceCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `an empty trace reports zero metrics without crashing`() {
|
||||
let metrics = ReplayEngine.run(trace: [], policy: AdaptiveReplayPolicy())
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
#expect(metrics.simulatedSpanSeconds == 0.0)
|
||||
#expect(metrics.stalenessAtMenuOpen == nil)
|
||||
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 0)
|
||||
#expect(metrics.interactionAdvanceCount == 0)
|
||||
}
|
||||
|
||||
// MARK: - Interaction-advance path (mirrors UsageStore.noteMenuOpened(at:))
|
||||
|
||||
/// A 300-second span with a single tick boundary at t=0 (which alone would schedule a longIdle
|
||||
/// refresh at t=1800, far past the trace's end) and one `menuOpen` at t=50 landing inside that
|
||||
/// tick's window.
|
||||
///
|
||||
/// Hand computation for `AdaptiveReplayPolicy` (`advancesOnInteraction == true`):
|
||||
/// - cursor=0: decide(now:0, lastMenuOpenAt: nil) -> longIdle, delay=1800 -> next=1800.
|
||||
/// menuOpen@50 falls in (0, 1800]: decide(now:50, lastMenuOpenAt:50) (age 0) ->
|
||||
/// recentInteraction, delay=120 -> candidate=170. 170 < 1800, so the schedule advances:
|
||||
/// next=170 (1 advance so far). next(170) <= end(300), so a refresh lands at t=170.
|
||||
/// - cursor=170: decide(now:170, lastMenuOpenAt:50) (age 120 <= 300 recentInteractionThreshold)
|
||||
/// -> recentInteraction, delay=120 -> next=290. No more menu opens to scan. 290 <= 300, so a
|
||||
/// refresh lands at t=290.
|
||||
/// - cursor=290: decide(now:290, lastMenuOpenAt:50) (age 240 <= 300) -> recentInteraction,
|
||||
/// delay=120 -> next=410. 410 > end(300), loop breaks without appending.
|
||||
///
|
||||
/// Total: 2 refreshes (170, 290), 1 interaction advance. Without the advance, the *only*
|
||||
/// schedulable event would be the t=1800 tick, which falls entirely outside this 300s span —
|
||||
/// i.e. `totalRefreshCount` would be 0. The non-zero count here is only possible because the
|
||||
/// engine reproduces the interaction-advance path.
|
||||
private func menuOpenAdvanceTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: self.at(300),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a menu open pulls the adaptive schedule forward, matching hand computation`() {
|
||||
let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: AdaptiveReplayPolicy())
|
||||
|
||||
#expect(metrics.totalRefreshCount == 2)
|
||||
#expect(metrics.interactionAdvanceCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a policy that does not advance on interaction ignores the same menu open`() {
|
||||
// Same trace, but FixedIntervalPolicy(30m) never overrides `advancesOnInteraction` (stays
|
||||
// false), matching fixed-cadence refresh frequencies in the real app, which never wire
|
||||
// `noteMenuOpened(at:)`'s advance check at all. The t=1800 tick falls outside the 300s
|
||||
// span, so nothing is scheduled — the menu open at t=50 has zero scheduling effect.
|
||||
let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: FixedIntervalPolicy(minutes: 30))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
#expect(metrics.interactionAdvanceCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a recorded timerAdvanced ground-truth event agrees with the engine's own recomputation`() throws {
|
||||
// The menuOpen ground truth plus a timerAdvanced record for the accepted schedule change.
|
||||
// The offline audit checks that record against the policy's recomputed candidate.
|
||||
let menuOpenAt = self.at(50)
|
||||
let recordedCandidate = self.at(170) // menuOpenAt + recentInteractionDelay (120s)
|
||||
var trace = self.menuOpenAdvanceTrace()
|
||||
trace.append(.timerAdvanced(
|
||||
timestamp: menuOpenAt,
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: recordedCandidate,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120))
|
||||
|
||||
let policy = AdaptiveReplayPolicy()
|
||||
let recomputed = policy.decide(ReplayPolicyInput(
|
||||
now: menuOpenAt,
|
||||
lastMenuOpenAt: menuOpenAt,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal))
|
||||
let recomputedCandidate = try menuOpenAt.addingTimeInterval(#require(recomputed.delaySeconds))
|
||||
|
||||
#expect(recomputedCandidate == recordedCandidate)
|
||||
|
||||
// The recorded event doesn't change the metrics (the engine recomputes advances itself,
|
||||
// independent of any timerAdvanced lines in the trace); replaying still reproduces the
|
||||
// same two refreshes as the trace without the extra record.
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: policy)
|
||||
#expect(metrics.totalRefreshCount == 2)
|
||||
#expect(metrics.interactionAdvanceCount == 1)
|
||||
}
|
||||
|
||||
/// Two menu opens in the same tick window: the second one's candidate is compared against the
|
||||
/// *already-advanced* schedule from the first, not the original tick schedule — mirroring a
|
||||
/// real second `noteMenuOpened(at:)` call tightening an already-shortened sleep.
|
||||
///
|
||||
/// - cursor=0: decide -> longIdle, next=1800. menuOpen@50: candidate=170 < 1800 -> next=170
|
||||
/// (advance 1). menuOpen@100 also falls in (0, 170]? No — 100 <= 170 is true, so it's still
|
||||
/// scanned: decide(now:100, lastMenuOpenAt:100) -> recentInteraction, candidate=220. Is 220 <
|
||||
/// next(170)? No, so this second menu open does *not* further advance the schedule (it would
|
||||
/// move the refresh *later*, which `shouldAdvanceAdaptiveTimer` never does). next stays 170.
|
||||
/// - Total: 1 refresh (170), 1 advance (only the first menu open's candidate beat the schedule).
|
||||
private func twoMenuOpensSameWindowTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: self.at(170),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
.menuOpen(timestamp: self.at(100)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a later menu open in the same window cannot postpone an earlier advance`() {
|
||||
let metrics = ReplayEngine.run(trace: self.twoMenuOpensSameWindowTrace(), policy: AdaptiveReplayPolicy())
|
||||
|
||||
#expect(metrics.totalRefreshCount == 1)
|
||||
#expect(metrics.interactionAdvanceCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AdaptiveReplayPolicyTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func input(
|
||||
ageSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool = false,
|
||||
thermalState: ReplayThermalState = .nominal) -> ReplayPolicyInput
|
||||
{
|
||||
ReplayPolicyInput(
|
||||
now: Self.referenceNow,
|
||||
lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalState: thermalState)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(0.0, "recentInteraction", 120.0),
|
||||
(301.0, "warm", 300.0),
|
||||
(3601.0, "idle", 900.0),
|
||||
(14400.0, "longIdle", 1800.0),
|
||||
])
|
||||
func `replay adapter preserves canonical decisions`(
|
||||
ageSeconds: TimeInterval,
|
||||
expectedReason: String,
|
||||
expectedDelaySeconds: TimeInterval)
|
||||
{
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: ageSeconds))
|
||||
#expect(decision.reason == expectedReason)
|
||||
#expect(decision.delaySeconds == expectedDelaySeconds)
|
||||
}
|
||||
|
||||
@Test(arguments: [ReplayThermalState.serious, .critical])
|
||||
func `replay adapter maps serious and critical thermal states to constrained`(
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: 0, thermalState: thermalState))
|
||||
#expect(decision.reason == "constrained")
|
||||
#expect(decision.delaySeconds == TimeInterval(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replay adapter preserves low power precedence`() {
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(
|
||||
ageSeconds: 0,
|
||||
lowPowerModeEnabled: true,
|
||||
thermalState: .nominal))
|
||||
#expect(decision.reason == "constrained")
|
||||
#expect(decision.delaySeconds == TimeInterval(30 * 60))
|
||||
}
|
||||
|
||||
@Test(arguments: [ReplayThermalState.nominal, .fair])
|
||||
func `replay adapter maps nominal and fair thermal states to unconstrained`(
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: 0, thermalState: thermalState))
|
||||
#expect(decision.reason == "recentInteraction")
|
||||
#expect(decision.delaySeconds == TimeInterval(2 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `only adaptive replay advances on interaction`() {
|
||||
#expect(AdaptiveReplayPolicy().advancesOnInteraction)
|
||||
#expect(!FixedIntervalPolicy(minutes: 5).advancesOnInteraction)
|
||||
#expect(!ManualPolicy().advancesOnInteraction)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed interval conversion cannot overflow integer multiplication`() {
|
||||
let decision = FixedIntervalPolicy(minutes: Int.max).decide(self.input(ageSeconds: 0))
|
||||
#expect(decision.delaySeconds == TimeInterval(Int.max) * 60)
|
||||
#expect(decision.delaySeconds?.isFinite == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AdaptiveReplayTraceParserTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func encode(_ record: AdaptiveRefreshTraceRecord) throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(record)
|
||||
return try #require(String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses a well-formed trace and preserves record order`() throws {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.referenceNow),
|
||||
.decision(
|
||||
timestamp: Self.referenceNow.addingTimeInterval(1),
|
||||
menuAgeSeconds: 1,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(121)),
|
||||
]
|
||||
let text = try records.map(self.encode).joined(separator: "\n")
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 3)
|
||||
#expect(parsed[0].kind == .menuOpen)
|
||||
#expect(parsed[1].kind == .decision)
|
||||
#expect(parsed[1].reason == "recentInteraction")
|
||||
#expect(parsed[1].delaySeconds == 120.0)
|
||||
#expect(parsed[2].kind == .refreshCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores blank lines between records`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.menuOpen(timestamp: Self.referenceNow)
|
||||
let text = try "\n\(self.encode(record))\n\n"
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strict parsing accepts multiple CRLF records`() throws {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.referenceNow),
|
||||
.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)),
|
||||
]
|
||||
let text = try records.map(self.encode).joined(separator: "\r\n")
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `empty trace parses to zero records`() throws {
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse("")
|
||||
#expect(parsed.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a malformed line fails the whole parse with a line number`() throws {
|
||||
let good = try self.encode(.menuOpen(timestamp: Self.referenceNow))
|
||||
let text = "\(good)\nnot json\n\(good)"
|
||||
|
||||
#expect(throws: AdaptiveRefreshTraceParseError.self) {
|
||||
try AdaptiveRefreshTraceParser.parse(text)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try AdaptiveRefreshTraceParser.parse(text)
|
||||
Issue.record("expected parse to throw")
|
||||
} catch let error as AdaptiveRefreshTraceParseError {
|
||||
#expect(error.lineNumber == 2)
|
||||
#expect(error.content == "not json")
|
||||
} catch {
|
||||
Issue.record("unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `tolerant parsing skips malformed lines instead of failing`() throws {
|
||||
let good = try self.encode(.menuOpen(timestamp: Self.referenceNow))
|
||||
let text = "\(good)\nnot json\n\(good)"
|
||||
|
||||
let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text)
|
||||
|
||||
#expect(parsed.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `tolerant parsing accepts CRLF and skips only malformed records`() throws {
|
||||
let menuOpen = try self.encode(.menuOpen(timestamp: Self.referenceNow))
|
||||
let refresh = try self.encode(.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)))
|
||||
let text = [menuOpen, "not json", refresh].joined(separator: "\r\n")
|
||||
|
||||
let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text)
|
||||
|
||||
#expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted])
|
||||
}
|
||||
|
||||
/// `timerAdvanced` round-trips its two extra fields (`previousScheduledAt`,
|
||||
/// `candidateScheduledAt`) and leaves the signal fields (`menuAgeSeconds`,
|
||||
/// `lowPowerModeEnabled`, `thermalState`) nil, matching the type's field-presence contract.
|
||||
@Test
|
||||
func `parses a timerAdvanced record and preserves its schedule fields`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: Self.referenceNow,
|
||||
previousScheduledAt: Self.referenceNow.addingTimeInterval(1800),
|
||||
candidateScheduledAt: Self.referenceNow.addingTimeInterval(120),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
#expect(parsed[0].kind == .timerAdvanced)
|
||||
#expect(parsed[0].reason == "recentInteraction")
|
||||
#expect(parsed[0].delaySeconds == 120.0)
|
||||
#expect(parsed[0].previousScheduledAt == Self.referenceNow.addingTimeInterval(1800))
|
||||
#expect(parsed[0].candidateScheduledAt == Self.referenceNow.addingTimeInterval(120))
|
||||
#expect(parsed[0].menuAgeSeconds == nil)
|
||||
#expect(parsed[0].lowPowerModeEnabled == nil)
|
||||
#expect(parsed[0].thermalState == nil)
|
||||
}
|
||||
|
||||
/// A `timerAdvanced` record whose advance had no prior schedule (`previousScheduledAt == nil`)
|
||||
/// — the "always advance" case `UsageStore.shouldAdvanceAdaptiveTimer` returns for a nil
|
||||
/// `scheduledAt` — round-trips the nil correctly rather than defaulting to some sentinel date.
|
||||
@Test
|
||||
func `a timerAdvanced record with no previous schedule round-trips a nil previousScheduledAt`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: Self.referenceNow,
|
||||
previousScheduledAt: nil,
|
||||
candidateScheduledAt: Self.referenceNow.addingTimeInterval(120),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed[0].previousScheduledAt == nil)
|
||||
}
|
||||
|
||||
/// Backward compatibility: the ~500 pre-existing lines in this machine's live trace were
|
||||
/// written before `codexActivitySeconds`/`claudeActivitySeconds` existed. A hand-written
|
||||
/// old-format `decision` line (no activity keys at all) must still decode, with both new
|
||||
/// fields nil rather than failing to parse.
|
||||
@Test
|
||||
func `an old-format decision line without activity fields decodes with nil activity signals`() throws {
|
||||
let oldFormatLine = """
|
||||
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\
|
||||
"lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800}
|
||||
"""
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
#expect(parsed[0].reason == "longIdle")
|
||||
#expect(parsed[0].codexActivitySeconds == nil)
|
||||
#expect(parsed[0].claudeActivitySeconds == nil)
|
||||
}
|
||||
|
||||
/// A `decision` record carrying both activity signals round-trips them exactly.
|
||||
@Test
|
||||
func `a decision record with activity signals round-trips both values`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexActivitySeconds: 42,
|
||||
claudeActivitySeconds: 99)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed[0].codexActivitySeconds == 42)
|
||||
#expect(parsed[0].claudeActivitySeconds == 99)
|
||||
}
|
||||
|
||||
/// Encoding must omit nil activity fields rather than emitting explicit `null`s, so old
|
||||
/// tooling and hand-inspection of a trace stay unsurprised by fields it doesn't expect.
|
||||
@Test
|
||||
func `encoding a decision with nil activity signals omits both keys entirely`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
#expect(!text.contains("codexActivitySeconds"))
|
||||
#expect(!text.contains("claudeActivitySeconds"))
|
||||
}
|
||||
|
||||
/// Backward compatibility for the "B layer" (session duration / transcript bytes /
|
||||
/// active-transcript count): an old-format line written before those three fields per CLI
|
||||
/// existed — including one already carrying the earlier "A layer" activity-seconds fields —
|
||||
/// must still decode, with all six new fields nil.
|
||||
@Test
|
||||
func `an old-format decision line without B-layer fields decodes with nil B-layer signals`() throws {
|
||||
let oldFormatLine = """
|
||||
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\
|
||||
"lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800,\
|
||||
"codexActivitySeconds":42,"claudeActivitySeconds":99}
|
||||
"""
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
#expect(parsed[0].codexActivitySeconds == 42)
|
||||
#expect(parsed[0].claudeActivitySeconds == 99)
|
||||
#expect(parsed[0].codexSessionDurationSeconds == nil)
|
||||
#expect(parsed[0].claudeSessionDurationSeconds == nil)
|
||||
#expect(parsed[0].codexTranscriptBytes == nil)
|
||||
#expect(parsed[0].claudeTranscriptBytes == nil)
|
||||
#expect(parsed[0].codexActiveTranscriptCount == nil)
|
||||
#expect(parsed[0].claudeActiveTranscriptCount == nil)
|
||||
}
|
||||
|
||||
/// A trace mixing a pre-B-layer line, a pre-A-layer (original phase 1) line, and a full
|
||||
/// current-format line all parse together — the parser never requires every line in a trace
|
||||
/// to share the same schema vintage.
|
||||
@Test
|
||||
func `a trace mixing old and new format decision lines parses every line`() throws {
|
||||
let phase1Line = """
|
||||
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","reason":"longIdle","delaySeconds":1800}
|
||||
"""
|
||||
let aLayerOnlyLine = """
|
||||
{"kind":"decision","timestamp":"2026-01-02T00:00:00Z","reason":"warm","delaySeconds":300,\
|
||||
"codexActivitySeconds":10}
|
||||
"""
|
||||
let currentLine = try self.encode(.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexActivitySeconds: 1,
|
||||
claudeActivitySeconds: 2,
|
||||
codexSessionDurationSeconds: 3,
|
||||
claudeSessionDurationSeconds: 4,
|
||||
codexTranscriptBytes: 5,
|
||||
claudeTranscriptBytes: 6,
|
||||
codexActiveTranscriptCount: 7,
|
||||
claudeActiveTranscriptCount: 8))
|
||||
let text = [phase1Line, aLayerOnlyLine, currentLine].joined(separator: "\n")
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 3)
|
||||
#expect(parsed[0].codexActivitySeconds == nil)
|
||||
#expect(parsed[1].codexActivitySeconds == 10)
|
||||
#expect(parsed[1].codexSessionDurationSeconds == nil)
|
||||
#expect(parsed[2].codexSessionDurationSeconds == 3)
|
||||
#expect(parsed[2].claudeActiveTranscriptCount == 8)
|
||||
}
|
||||
|
||||
/// A `decision` record carrying all six B-layer fields round-trips them exactly.
|
||||
@Test
|
||||
func `a decision record with B-layer fields round-trips all six values`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexSessionDurationSeconds: 600,
|
||||
claudeSessionDurationSeconds: 900,
|
||||
codexTranscriptBytes: 12345,
|
||||
claudeTranscriptBytes: 67890,
|
||||
codexActiveTranscriptCount: 2,
|
||||
claudeActiveTranscriptCount: 4)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed[0].codexSessionDurationSeconds == 600)
|
||||
#expect(parsed[0].claudeSessionDurationSeconds == 900)
|
||||
#expect(parsed[0].codexTranscriptBytes == 12345)
|
||||
#expect(parsed[0].claudeTranscriptBytes == 67890)
|
||||
#expect(parsed[0].codexActiveTranscriptCount == 2)
|
||||
#expect(parsed[0].claudeActiveTranscriptCount == 4)
|
||||
}
|
||||
|
||||
/// Encoding must omit nil B-layer fields rather than emitting explicit `null`s, matching the
|
||||
/// A-layer's contract.
|
||||
@Test
|
||||
func `encoding a decision with nil B-layer fields omits all six keys entirely`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
#expect(!text.contains("codexSessionDurationSeconds"))
|
||||
#expect(!text.contains("claudeSessionDurationSeconds"))
|
||||
#expect(!text.contains("codexTranscriptBytes"))
|
||||
#expect(!text.contains("claudeTranscriptBytes"))
|
||||
#expect(!text.contains("codexActiveTranscriptCount"))
|
||||
#expect(!text.contains("claudeActiveTranscriptCount"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct CodingActivityReplayPolicyTests {
|
||||
private static let now = Date(timeIntervalSinceReferenceDate: 10000)
|
||||
|
||||
private func input(
|
||||
menuAge: TimeInterval?,
|
||||
activityAge: TimeInterval?,
|
||||
constrained: Bool = false) -> ReplayPolicyInput
|
||||
{
|
||||
ReplayPolicyInput(
|
||||
now: Self.now,
|
||||
lastMenuOpenAt: menuAge.map { Self.now.addingTimeInterval(-$0) },
|
||||
lastCodingActivityAt: activityAge.map { Self.now.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: constrained,
|
||||
thermalState: .nominal)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `active coding caps idle and long-idle decisions at five minutes`() {
|
||||
let policy = CodingActivityAdaptivePolicy()
|
||||
|
||||
#expect(policy.name == "adaptive-activity")
|
||||
#expect(policy.decide(self.input(menuAge: 2 * 3600, activityAge: 10)).delaySeconds == 300)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).delaySeconds == 300)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).reason == "codingActivity")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `existing recent and warm decisions remain unchanged`() {
|
||||
let policy = CodingActivityAdaptivePolicy()
|
||||
|
||||
#expect(policy.decide(self.input(menuAge: 60, activityAge: 10)).delaySeconds == 120)
|
||||
#expect(policy.decide(self.input(menuAge: 600, activityAge: 10)).delaySeconds == 300)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `constrained state wins and exact threshold is inactive`() {
|
||||
let policy = CodingActivityAdaptivePolicy()
|
||||
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 10, constrained: true)).delaySeconds == 1800)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 300)).delaySeconds == 1800)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: nil)).delaySeconds == 1800)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `future activity samples never backfill an earlier replay decision`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: Self.now.addingTimeInterval(600),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800,
|
||||
codexActivitySeconds: 0),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy())
|
||||
|
||||
#expect(metrics.codingActiveDecisionCount == 0)
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a newer unavailable observation invalidates older activity`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.now),
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: 0,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexActivitySeconds: 0),
|
||||
.decision(
|
||||
timestamp: Self.now.addingTimeInterval(100),
|
||||
menuAgeSeconds: 100,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
.refreshCompleted(timestamp: Self.now.addingTimeInterval(240)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy())
|
||||
|
||||
#expect(metrics.totalRefreshCount == 2)
|
||||
#expect(metrics.codingActiveDecisionCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `active compliance denominator excludes constrained decisions`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: true,
|
||||
thermalState: .nominal,
|
||||
reason: "constrained",
|
||||
delaySeconds: 1800,
|
||||
codexActivitySeconds: 0),
|
||||
.refreshCompleted(timestamp: Self.now.addingTimeInterval(1800)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy())
|
||||
|
||||
#expect(metrics.codingActiveDecisionCount == 0)
|
||||
#expect(metrics.codingActiveDelayViolationCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `manual policy counts as slower than the active freshness cap`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800,
|
||||
codexActivitySeconds: 0),
|
||||
.refreshCompleted(timestamp: Self.now.addingTimeInterval(600)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: ManualPolicy())
|
||||
|
||||
#expect(metrics.codingActiveDecisionCount == 1)
|
||||
#expect(metrics.codingActiveDelayViolationCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct RecordedScheduleAuditTests {
|
||||
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
private func at(_ seconds: TimeInterval) -> Date {
|
||||
Self.epoch.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy recorded advance validates without evaluation records`() {
|
||||
let menu = self.at(50)
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: menu),
|
||||
.timerAdvanced(
|
||||
timestamp: menu,
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
]
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit(trace)
|
||||
|
||||
#expect(audit.isValid)
|
||||
#expect(audit.recordedAdvanceCount == 1)
|
||||
#expect(audit.evaluatedCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `accepted and rejected live evaluations audit independently of replay`() {
|
||||
let accepted = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
let rejected = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(100),
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(220),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: false,
|
||||
refreshInFlight: true)
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
accepted,
|
||||
.timerAdvanced(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
.menuOpen(timestamp: self.at(100)),
|
||||
rejected,
|
||||
]
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit(trace)
|
||||
|
||||
#expect(audit.isValid)
|
||||
#expect(audit.evaluatedCount == 2)
|
||||
#expect(audit.acceptedEvaluationCount == 1)
|
||||
#expect(audit.rejectedEvaluationCount == 1)
|
||||
#expect(audit.ambiguousComparisonCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `evaluation whose accepted flag disagrees with schedule comparison fails`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.timerAdvanceEvaluated(
|
||||
timestamp: self.at(100),
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(220),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false),
|
||||
]
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit(trace)
|
||||
|
||||
#expect(!audit.isValid)
|
||||
#expect(audit.decisionMismatchCount == 1)
|
||||
#expect(audit.payloadMismatchCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unequal schedule dates override a contradictory exact lead`() {
|
||||
let event = AdaptiveRefreshTraceRecord(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: self.at(50),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
previousScheduledAt: self.at(180),
|
||||
candidateScheduledAt: self.at(170),
|
||||
timerAdvanceAccepted: false,
|
||||
scheduleLeadSeconds: -10,
|
||||
refreshInFlight: false)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event])
|
||||
|
||||
#expect(audit.decisionMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `accepted evaluation without a previous schedule remains valid`() {
|
||||
let event = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: nil,
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
|
||||
let advanced = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: nil,
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event, advanced])
|
||||
|
||||
#expect(audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fractional live lead survives whole-second date serialization`() throws {
|
||||
let timestamp = self.at(50.2)
|
||||
let candidate = self.at(170.2)
|
||||
let previous = self.at(170.8)
|
||||
let record = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: timestamp,
|
||||
previousScheduledAt: previous,
|
||||
candidateScheduledAt: candidate,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let line = try #require(String(data: encoder.encode(record), encoding: .utf8))
|
||||
let parsed = try #require(AdaptiveRefreshTraceParser.parse(line).first)
|
||||
|
||||
#expect(parsed.previousScheduledAt == parsed.candidateScheduledAt)
|
||||
#expect(try abs(#require(parsed.scheduleLeadSeconds) - 0.6) < 0.001)
|
||||
let advanced = try AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: parsed.timestamp,
|
||||
previousScheduledAt: parsed.previousScheduledAt,
|
||||
candidateScheduledAt: #require(parsed.candidateScheduledAt),
|
||||
reason: #require(parsed.reason),
|
||||
delaySeconds: #require(parsed.delaySeconds))
|
||||
#expect(RecordedScheduleAuditor.audit([.menuOpen(timestamp: parsed.timestamp), parsed, advanced]).isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy equal timestamps are reported as ambiguous instead of mismatched`() {
|
||||
let event = AdaptiveRefreshTraceRecord(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: self.at(50),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(170),
|
||||
timerAdvanceAccepted: true,
|
||||
refreshInFlight: false)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event])
|
||||
|
||||
#expect(audit.decisionMismatchCount == 0)
|
||||
#expect(audit.ambiguousComparisonCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `evaluation without a menu-open source fails linkage audit`() {
|
||||
let event = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([event])
|
||||
|
||||
#expect(audit.menuLinkMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `duplicate accepted evaluations require matching advance multiplicity`() {
|
||||
let evaluation = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
let advance = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
evaluation,
|
||||
evaluation,
|
||||
advance,
|
||||
])
|
||||
|
||||
#expect(audit.payloadMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `duplicate rejected evaluations require distinct menu opens`() {
|
||||
let evaluation = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(220),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 170,
|
||||
accepted: false,
|
||||
refreshInFlight: true)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
evaluation,
|
||||
evaluation,
|
||||
])
|
||||
|
||||
#expect(audit.menuLinkMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct ReplayTraceSegmentationTests {
|
||||
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
private func at(_ seconds: TimeInterval) -> Date {
|
||||
Self.epoch.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
private func decision(_ seconds: TimeInterval, delay: TimeInterval = 600) -> AdaptiveRefreshTraceRecord {
|
||||
.decision(
|
||||
timestamp: self.at(seconds),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: delay)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `segmentation excludes only time beyond the expected deadline`() {
|
||||
let firstRun = stride(from: 0.0, through: 3000.0, by: 600.0).map { self.decision($0) }
|
||||
let secondRun = stride(from: 68400.0, through: 71400.0, by: 600.0).map { self.decision($0) }
|
||||
let trace = firstRun + secondRun + [.refreshCompleted(timestamp: self.at(72000))]
|
||||
|
||||
let report = ReplayTraceSegmenter.automatic(trace)
|
||||
|
||||
#expect(report.segments.count == 2)
|
||||
#expect(report.segments[0].start == self.at(0))
|
||||
#expect(report.segments[0].end == self.at(3600))
|
||||
#expect(report.segments[1].start == self.at(68400))
|
||||
#expect(report.segments[1].end == self.at(72000))
|
||||
#expect(report.excludedGapSeconds == 18 * 60 * 60)
|
||||
#expect(report.includedSpanSeconds == 2 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `segmented rate uses summed span instead of averaging segment rates`() {
|
||||
let firstRun = stride(from: 0.0, through: 3000.0, by: 600.0).map { self.decision($0) }
|
||||
let secondRun = stride(from: 68400.0, through: 71400.0, by: 600.0).map { self.decision($0) }
|
||||
let trace = firstRun + secondRun + [.refreshCompleted(timestamp: self.at(72000))]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 12)
|
||||
#expect(metrics.simulatedSpanSeconds == 7200)
|
||||
#expect(metrics.refreshCountPer24h == 144)
|
||||
#expect(metrics.segmentCount == 2)
|
||||
#expect(metrics.excludedGapSeconds == 18 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a normal scheduled wait remains in the preceding segment`() {
|
||||
let trace = [
|
||||
self.decision(0, delay: 1800),
|
||||
.menuOpen(timestamp: self.at(1700)),
|
||||
self.decision(4000, delay: 1800),
|
||||
]
|
||||
|
||||
let report = ReplayTraceSegmenter.automatic(trace)
|
||||
|
||||
#expect(report.segments.count == 2)
|
||||
#expect(report.segments[0].end == self.at(1800))
|
||||
#expect(report.excludedGapSeconds == 2200)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `menu opens before the first recorded refresh are censored equally`() throws {
|
||||
let trace = [
|
||||
self.decision(0, delay: 600),
|
||||
.menuOpen(timestamp: self.at(100)),
|
||||
.refreshCompleted(timestamp: self.at(600)),
|
||||
self.decision(600, delay: 600),
|
||||
.menuOpen(timestamp: self.at(700)),
|
||||
.refreshCompleted(timestamp: self.at(1200)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
|
||||
#expect(metrics.boundaryCensoredMenuOpenCount == 1)
|
||||
#expect(try #require(metrics.stalenessAtMenuOpen).sampleCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `recorded refresh anchors staleness before a policy refresh`() throws {
|
||||
let trace = [
|
||||
self.decision(0, delay: 600),
|
||||
.refreshCompleted(timestamp: self.at(600)),
|
||||
.menuOpen(timestamp: self.at(700)),
|
||||
self.decision(1200, delay: 600),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: ManualPolicy())
|
||||
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
||||
|
||||
#expect(staleness.sampleCount == 1)
|
||||
#expect(staleness.mean == 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `recorded refresh supersedes an earlier simulated refresh`() throws {
|
||||
let trace = [
|
||||
self.decision(0, delay: 650),
|
||||
.refreshCompleted(timestamp: self.at(650)),
|
||||
.menuOpen(timestamp: self.at(700)),
|
||||
self.decision(1200, delay: 600),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
||||
|
||||
#expect(staleness.sampleCount == 1)
|
||||
#expect(staleness.mean == 50)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user