154 lines
6.8 KiB
Swift
154 lines
6.8 KiB
Swift
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)
|
|
}
|
|
}
|