diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/parity-exempt.json b/Packages/StrandAnalytics/Sources/StrandAnalytics/parity-exempt.json index 82eca4d248..192b4b1e8e 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/parity-exempt.json +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/parity-exempt.json @@ -4622,11 +4622,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::detectSleep/10[defaults=1,2,3,5,6,7,8,9,10]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::detectSleepCache@property-initializer#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -4647,11 +4642,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::stageSession/6[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::stageSessionCache@property-initializer#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -4662,16 +4652,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::sessionEpochMotion/3[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::sessionEpochSleepState/3[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::nEpochs@computed-property#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -4837,11 +4817,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::remFunnelDiagnostic/6[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::stageDepthRank/1[defaults=-]#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -4872,21 +4847,11 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::hypnogramMetrics/1[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::standardDeviation/1[defaults=-]#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift::stageSession/6[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift::padLo@property-initializer#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift index 075baced96..b964e542d1 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ParityRunner.swift @@ -56,6 +56,26 @@ final class ParityRunner: XCTestCase { private struct SSTStagesInput: Decodable { let startTs: Int; let stagesJSON: String? } private struct SSTOnsetInput: Decodable { let startTs: Int; let onset: Int } private struct SSTHistoryInput: Decodable { let start: Int; let end: Int; let dayKey: String } + private struct StagerSeriesInput: Decodable { + let startTs: Int; let count: Int; let stepSec: Int + let pattern: String; let order: String; let base: Int? + } + private struct StagerIntervalInput: Decodable { let start: Int; let end: Int } + private struct StagerStateInput: Decodable { let ts: Int; let state: Int } + private struct StagerStageInput: Decodable { let start: Int; let end: Int; let stage: String } + private struct StagerSessionInput: Decodable { + let start: Int; let end: Int; let efficiency: Double + let stages: [StagerStageInput]; let restingHR: Int?; let avgHRV: Double? + } + private struct StagerCallInput: Decodable { + let start: Int?; let end: Int? + let gravity: StagerSeriesInput? + let hr: StagerSeriesInput?; let rr: StagerSeriesInput?; let resp: StagerSeriesInput? + let tzOffsetSeconds: Int?; let wristOff: [StagerIntervalInput]? + let bandSleepState: [StagerStateInput]?; let useSleepStagerV2: Bool? + let sleepHRBaseline: Double?; let useDefaults: Bool? + let sleepState: [StagerStateInput]?; let session: StagerSessionInput? + } private enum HistoryArgument: Decodable { case generated(HistoryInput) case sleep([SSTHistoryInput]) @@ -180,6 +200,7 @@ final class ParityRunner: XCTestCase { let manual: [SSTStagesInput]? let onsetByStart: [SSTOnsetInput]? let minDays: Int? + let calls: [StagerCallInput]? } private struct InputRecord: Decodable { @@ -268,6 +289,121 @@ final class ParityRunner: XCTestCase { ? "StrainScorer.trimpToStrain/2" : record.function switch dispatchFunction { + case "SleepStager.detectSleep/10": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + let gravity = try stagerGravity(call.gravity) + let sessions: [SleepSession] + if call.useDefaults == true { + sessions = SleepStager.detectSleep(gravity: gravity) + } else { + sessions = SleepStager.detectSleep( + hr: try stagerHeartRate(call.hr), rr: try stagerRR(call.rr), + resp: try stagerResp(call.resp), gravity: gravity, + tzOffsetSeconds: try XCTUnwrap(call.tzOffsetSeconds), + wristOff: try XCTUnwrap(call.wristOff).map { ($0.start, $0.end) }, + bandSleepState: try XCTUnwrap(call.bandSleepState).map { ($0.ts, $0.state) }, + useSleepStagerV2: try XCTUnwrap(call.useSleepStagerV2), + sleepHRBaseline: call.sleepHRBaseline) + } + return sessions.map(sessionPayload) + } + if negativeSide == "swift", record.id == "sleep_stager_negative_detect_probe" { + payload[0] = []; result["negativeSide"] = "swift" + } + result["valueBits"] = payload + case "SleepStager.stageSession/6": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + let start = try XCTUnwrap(call.start), end = try XCTUnwrap(call.end) + let gravity = try stagerGravity(call.gravity), hr = try stagerHeartRate(call.hr) + let rr = try stagerRR(call.rr), resp = try stagerResp(call.resp) + let stages = SleepStager.stageSession( + start: start, end: end, grav: gravity, hr: hr, rr: rr, resp: resp) + return stages.map(stagePayload) + } + if negativeSide == "swift", record.id == "sleep_stager_negative_v1_probe" { + if var stages = payload[0] as? [[String: Any]], !stages.isEmpty, + var stage = stages[0]["stage"] as? [String: String] { + stage["text"] = stage["text"] == "wake" ? "light" : "wake" + stages[0]["stage"] = stage; payload[0] = stages + } + result["negativeSide"] = "swift" + } + result["valueBits"] = payload + case "SleepStagerV2.stageSession/6": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + let start = try XCTUnwrap(call.start), end = try XCTUnwrap(call.end) + let stages = SleepStagerV2.stageSession( + start: start, end: end, grav: try stagerGravity(call.gravity), + hr: try stagerHeartRate(call.hr), rr: try stagerRR(call.rr), + resp: try stagerResp(call.resp)) + return stages.map(stagePayload) + } + if negativeSide == "swift", record.id == "sleep_stager_negative_v2_probe" { + if var stages = payload[0] as? [[String: Any]], !stages.isEmpty, + var stage = stages[0]["stage"] as? [String: String] { + stage["text"] = stage["text"] == "wake" ? "light" : "wake" + stages[0]["stage"] = stage; payload[0] = stages + } + result["negativeSide"] = "swift" + } + result["valueBits"] = payload + case "SleepStager.sessionEpochMotion/3": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + SleepStager.sessionEpochMotion( + start: try XCTUnwrap(call.start), end: try XCTUnwrap(call.end), + grav: try stagerGravity(call.gravity)).map(exactBit) + } + if negativeSide == "swift", record.id == "sleep_stager_negative_motion_probe" { + if var motion = payload[0] as? [String], !motion.isEmpty { + motion[0] = exactBit(Double.greatestFiniteMagnitude); payload[0] = motion + } + result["negativeSide"] = "swift" + } + result["valueBits"] = payload + case "SleepStager.sessionEpochSleepState/3": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + SleepStager.sessionEpochSleepState( + start: try XCTUnwrap(call.start), end: try XCTUnwrap(call.end), + sleepState: try XCTUnwrap(call.sleepState).map { ($0.ts, $0.state) }) + } + if negativeSide == "swift", record.id == "sleep_stager_negative_state_probe" { + if var states = payload[0] as? [Int], !states.isEmpty { + states[0] = (states[0] + 1) % 4; payload[0] = states + } + result["negativeSide"] = "swift" + } + result["valueBits"] = payload + case "SleepStager.remFunnelDiagnostic/6": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + let value = SleepStager.remFunnelDiagnostic( + start: try XCTUnwrap(call.start), end: try XCTUnwrap(call.end), + grav: try stagerGravity(call.gravity), hr: try stagerHeartRate(call.hr), + rr: try stagerRR(call.rr), resp: try stagerResp(call.resp)) + return value.map(remDiagnosticPayload) ?? NSNull() + } + if negativeSide == "swift", record.id == "sleep_stager_negative_rem_probe" { + if var diagnostic = payload[0] as? [String: Any], + let present = diagnostic["respChannelPresent"] as? Bool { + diagnostic["respChannelPresent"] = !present; payload[0] = diagnostic + } + result["negativeSide"] = "swift" + } + result["valueBits"] = payload + case "SleepStager.hypnogramMetrics/1": + var payload: [Any] = try XCTUnwrap(record.args.calls).map { call in + let input = try XCTUnwrap(call.session) + let session = SleepSession( + start: input.start, end: input.end, efficiency: input.efficiency, + stages: input.stages.map { .init(start: $0.start, end: $0.end, stage: $0.stage) }, + restingHR: input.restingHR, avgHRV: input.avgHRV) + return hypnogramPayload(SleepStager.hypnogramMetrics(session)) + } + if negativeSide == "swift", record.id == "sleep_stager_negative_metrics_probe", + var first = payload[0] as? [String: Any] { + first["disturbances"] = (first["disturbances"] as? Int ?? 0) + 1 + payload[0] = first; result["negativeSide"] = "swift" + } + result["valueBits"] = payload case "SleepStageTotals.minutes/1": var payload = minutesPayload(SleepStageTotals.minutes(fromStagesJSON: record.args.stagesJSON)) if negativeSide == "swift", record.id == "sleep_stage_totals_negative_decode_probe" { @@ -1263,6 +1399,92 @@ final class ParityRunner: XCTestCase { return try baselineState(input, record: record) } + private func stagerOrder(_ rows: [T], _ order: String) throws -> [T] { + if order == "sorted" { return rows } + guard order == "odd-even" else { throw RunnerError.invalidInput("invalid stager order") } + return stride(from: 0, to: rows.count, by: 2).map { rows[$0] } + + stride(from: 1, to: rows.count, by: 2).map { rows[$0] } + } + + private func stagerGravity(_ input: StagerSeriesInput?) throws -> [GravitySample] { + let input = try XCTUnwrap(input) + let rows = (0.. GravitySample in + let xyz: (Double, Double, Double) + if input.pattern == "still-z" { xyz = (0, 0, 1) } + else if input.pattern == "alternating-same-sum" { xyz = index.isMultiple(of: 2) ? (1, 0, 0) : (0, 0, 1) } + else if input.pattern == "pulse-same-sum" { xyz = index % 30 == 15 ? (1, 1, -1) : (0, 0, 1) } + else if input.pattern == "gentle-wave" { xyz = index.isMultiple(of: 2) ? (0.001, 0, 1) : (0, 0, 1) } + else { xyz = (.nan, .nan, .nan) } + return GravitySample(ts: input.startTs + index * input.stepSec, x: xyz.0, y: xyz.1, z: xyz.2) + } + guard rows.allSatisfy({ $0.x.isFinite && $0.y.isFinite && $0.z.isFinite }) else { + throw RunnerError.invalidInput("invalid gravity pattern") + } + return try stagerOrder(rows, input.order) + } + + private func stagerHeartRate(_ input: StagerSeriesInput?) throws -> [HRSample] { + let input = try XCTUnwrap(input), base = try XCTUnwrap(input.base) + let rows = (0.. [RRInterval] { + let input = try XCTUnwrap(input), base = try XCTUnwrap(input.base) + let wave = [0, 40, 0, -40] + let rows = (0.. [RespSample] { + let input = try XCTUnwrap(input), base = try XCTUnwrap(input.base) + let wave = [0, 10, 0, -10] + let rows = (0.. [String: Any] { + ["start": value.start, "end": value.end, "stage": ["text": value.stage]] + } + + private func sessionPayload(_ value: SleepSession) -> [String: Any] { + ["start": value.start, "end": value.end, "efficiency": exactBit(value.efficiency), + "stages": value.stages.map(stagePayload), + "restingHR": value.restingHR.map { $0 as Any } ?? NSNull(), + "avgHRV": value.avgHRV.map(exactBit) ?? NSNull()] + } + + private func remDiagnosticPayload(_ value: SleepStager.REMFunnelDiagnostic) -> [String: Any] { + ["sleepEpochs": value.sleepEpochs, "remAtClassify": value.remAtClassify, + "remAfterReimpose": value.remAfterReimpose, + "remStrippedByOnsetGuard": value.remStrippedByOnsetGuard, + "respChannelPresent": value.respChannelPresent, + "blockedNotStill": value.blockedNotStill, + "blockedNoCardiacActivation": value.blockedNoCardiacActivation, + "blockedRespRegular": value.blockedRespRegular, + "blockedNoRespFallbackBar": value.blockedNoRespFallbackBar, + "wonOtherStage": value.wonOtherStage, "isZeroREM": value.isZeroREM] + } + + private func hypnogramPayload(_ value: SleepStager.HypnogramMetrics) -> [String: Any] { + ["tibS": exactBit(value.tibS), "tstS": exactBit(value.tstS), + "sptS": exactBit(value.sptS), "solS": exactBit(value.solS), + "remLatencyS": exactBit(value.remLatencyS), "wasoS": exactBit(value.wasoS), + "efficiency": exactBit(value.efficiency), "disturbances": value.disturbances, + "deepMin": exactBit(value.deepMin), "remMin": exactBit(value.remMin), + "lightMin": exactBit(value.lightMin), "deepPct": exactBit(value.deepPct), + "remPct": exactBit(value.remPct), "lightPct": exactBit(value.lightPct)] + } + private func sstBlocks(_ input: [SSTBlockInput]?) throws -> [SleepStageTotals.NightBlock] { try XCTUnwrap(input).map { .init(start: $0.start, end: $0.end) } } diff --git a/Tools/parity_cases/sleep_stager.json b/Tools/parity_cases/sleep_stager.json new file mode 100644 index 0000000000..8c24ce396f --- /dev/null +++ b/Tools/parity_cases/sleep_stager.json @@ -0,0 +1,29 @@ +[ + {"id":"sleep_stager_detect_default_empty","function":"SleepStager.detectSleep/10","comparison":"exact","args":{"calls":[{"gravity":{"startTs":1749520800,"count":0,"stepSec":1,"pattern":"still-z","order":"sorted"},"useDefaults":true}]}}, + {"id":"sleep_stager_v1_degenerate","function":"SleepStager.stageSession/6","comparison":"exact","args":{"calls":[{"start":1750000000,"end":1750000600,"gravity":{"startTs":1750000000,"count":0,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750000000,"count":0,"stepSec":1,"base":50,"pattern":"flat","order":"sorted"},"rr":{"startTs":1750000000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1750000000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}]}}, + {"id":"sleep_stager_motion_empty","function":"SleepStager.sessionEpochMotion/3","comparison":"exact","args":{"calls":[{"start":1750001000,"end":1750001600,"gravity":{"startTs":1750001000,"count":0,"stepSec":1,"pattern":"still-z","order":"sorted"}}]}}, + {"id":"sleep_stager_state_carry","function":"SleepStager.sessionEpochSleepState/3","comparison":"exact","args":{"calls":[{"start":1750002000,"end":1750002120,"sleepState":[{"ts":1750002010,"state":1},{"ts":1750002070,"state":2}]}]}}, + {"id":"sleep_stager_rem_degenerate","function":"SleepStager.remFunnelDiagnostic/6","comparison":"exact","args":{"calls":[{"start":1750003000,"end":1750003600,"gravity":{"startTs":1750003000,"count":0,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750003000,"count":0,"stepSec":1,"base":50,"pattern":"flat","order":"sorted"},"rr":{"startTs":1750003000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1750003000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}]}}, + {"id":"sleep_stager_metrics_no_sleep","function":"SleepStager.hypnogramMetrics/1","comparison":"exact","args":{"calls":[{"session":{"start":1750004000,"end":1750004600,"efficiency":0.0,"stages":[],"restingHR":null,"avgHRV":null}}]}}, + {"id":"sleep_stager_v2_degenerate","function":"SleepStagerV2.stageSession/6","comparison":"exact","args":{"calls":[{"start":1750005000,"end":1750005600,"gravity":{"startTs":1750005000,"count":0,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750005000,"count":0,"stepSec":1,"base":50,"pattern":"flat","order":"sorted"},"rr":{"startTs":1750005000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1750005000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}]}}, + + {"id":"sleep_stager_issue_66_detect_cache_aba","function":"SleepStager.detectSleep/10","comparison":"exact","regressionIssue":"bhelm/noop#66","args":{"calls":[ + {"gravity":{"startTs":1749520800,"count":5400,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1749520800,"count":5400,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"tzOffsetSeconds":0,"wristOff":[],"bandSleepState":[],"useSleepStagerV2":false,"sleepHRBaseline":null,"useDefaults":false}, + {"gravity":{"startTs":1749520800,"count":5400,"stepSec":1,"pattern":"alternating-same-sum","order":"sorted"},"hr":{"startTs":1749520800,"count":5400,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"tzOffsetSeconds":0,"wristOff":[],"bandSleepState":[],"useSleepStagerV2":false,"sleepHRBaseline":null,"useDefaults":false}, + {"gravity":{"startTs":1749520800,"count":5400,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1749520800,"count":5400,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"tzOffsetSeconds":0,"wristOff":[],"bandSleepState":[],"useSleepStagerV2":false,"sleepHRBaseline":null,"useDefaults":false} + ]}}, + {"id":"sleep_stager_issue_66_v1_cache_aba","function":"SleepStager.stageSession/6","comparison":"exact","regressionIssue":"bhelm/noop#66","args":{"calls":[ + {"start":1749613600,"end":1749614800,"gravity":{"startTs":1749613600,"count":1200,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1749613600,"count":1200,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749613600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749613600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}, + {"start":1749613600,"end":1749614800,"gravity":{"startTs":1749613600,"count":1200,"stepSec":1,"pattern":"alternating-same-sum","order":"sorted"},"hr":{"startTs":1749613600,"count":1200,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749613600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749613600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}, + {"start":1749613600,"end":1749614800,"gravity":{"startTs":1749613600,"count":1200,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1749613600,"count":1200,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749613600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749613600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}} + ]}}, + {"id":"sleep_stager_issue_66_v2_cache_aba","function":"SleepStagerV2.stageSession/6","comparison":"exact","regressionIssue":"bhelm/noop#66","args":{"calls":[ + {"start":1750513600,"end":1750515400,"gravity":{"startTs":1750513600,"count":1800,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750513600,"count":1800,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1750513600,"count":1800,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"},"resp":{"startTs":1750513600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}, + {"start":1750513600,"end":1750515400,"gravity":{"startTs":1750513600,"count":1800,"stepSec":1,"pattern":"pulse-same-sum","order":"sorted"},"hr":{"startTs":1750513600,"count":1800,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1750513600,"count":1800,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"},"resp":{"startTs":1750513600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}, + {"start":1750513600,"end":1750515400,"gravity":{"startTs":1750513600,"count":1800,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750513600,"count":1800,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1750513600,"count":1800,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"},"resp":{"startTs":1750513600,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}} + ]}}, + {"id":"sleep_stager_issue_67_v2_sorted_shuffled","function":"SleepStagerV2.stageSession/6","comparison":"exact","regressionIssue":"bhelm/noop#67","args":{"calls":[ + {"start":1751513600,"end":1751519000,"gravity":{"startTs":1751513000,"count":6600,"stepSec":1,"pattern":"alternating-same-sum","order":"sorted"},"hr":{"startTs":1751513000,"count":6600,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1751513000,"count":6600,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"},"resp":{"startTs":1751513000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}, + {"start":1751513600,"end":1751519000,"gravity":{"startTs":1751513000,"count":6600,"stepSec":1,"pattern":"alternating-same-sum","order":"odd-even"},"hr":{"startTs":1751513000,"count":6600,"stepSec":1,"base":50,"pattern":"minute-wave","order":"odd-even"},"rr":{"startTs":1751513000,"count":6600,"stepSec":1,"base":1000,"pattern":"four-wave","order":"odd-even"},"resp":{"startTs":1751513000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}} + ]}} +] diff --git a/Tools/parity_diff.py b/Tools/parity_diff.py index 0bcbd6eda4..bed57e202b 100644 --- a/Tools/parity_diff.py +++ b/Tools/parity_diff.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +import hashlib import json import math import re @@ -65,9 +66,22 @@ "SleepStageTotals.mainNightIndex/3", "SleepStageTotals.mainNightSelection/3", "SleepStageTotals.dailyAggregateHonoringEdits/6", "SleepStageTotals.habitualMidsleepSec/3", } +SLEEP_STAGER_DETECT_KEY = "SleepStager.detectSleep/10" +SLEEP_STAGER_V1_KEY = "SleepStager.stageSession/6" +SLEEP_STAGER_MOTION_KEY = "SleepStager.sessionEpochMotion/3" +SLEEP_STAGER_STATE_KEY = "SleepStager.sessionEpochSleepState/3" +SLEEP_STAGER_REM_KEY = "SleepStager.remFunnelDiagnostic/6" +SLEEP_STAGER_METRICS_KEY = "SleepStager.hypnogramMetrics/1" +SLEEP_STAGER_V2_KEY = "SleepStagerV2.stageSession/6" +SLEEP_STAGER_KEYS = { + SLEEP_STAGER_DETECT_KEY, SLEEP_STAGER_V1_KEY, SLEEP_STAGER_MOTION_KEY, + SLEEP_STAGER_STATE_KEY, SLEEP_STAGER_REM_KEY, SLEEP_STAGER_METRICS_KEY, + SLEEP_STAGER_V2_KEY, +} SLEEP_FUNCTIONS = { SLEEP_CREDIT_KEY, SLEEP_LEDGER_KEY, SLEEP_AUTO_BED_KEY, SLEEP_DISJOINT_KEY, SLEEP_CLAMP_KEY, SLEEP_WAKE_KEY, SLEEP_RECLIP_KEY, *SLEEP_STAGE_TOTALS_KEYS, + *SLEEP_STAGER_KEYS, } SLEEP_DEFAULT_NEED_HOURS = 8.0 SLEEP_DEFAULT_WINDOW = 14 @@ -75,6 +89,7 @@ SLEEP_MAX_ROWS = 256 SLEEP_MAX_TEXT_BYTES = 16_384 SLEEP_STAGE_TOTALS_SEED = GENERATOR_SEED ^ 0x534C_4545_5053_5447 +SLEEP_STAGER_SEED = GENERATOR_SEED ^ 0x534C_5354_4147_4552 SLEEP_SEEDS = { SLEEP_CREDIT_KEY: (0x534C_4352_4544_0001, 0x534C_4352_4544_0002), SLEEP_LEDGER_KEY: (0x534C_4C45_4447_0001, 0x534C_4C45_4447_0002), @@ -108,6 +123,12 @@ }, }, } +SLEEP_STAGER_REGRESSION_DIGESTS = { + "sleep_stager_issue_66_detect_cache_aba": "d0a077f171c00db014930a430f4456167a38b1f349024320f1f09ee9113bc955", + "sleep_stager_issue_66_v1_cache_aba": "e1da2afe59844b86b366da8ae905890f54d7561a044259bc49716de55796674c", + "sleep_stager_issue_66_v2_cache_aba": "8e4d91f4430459108eadac436e77ecc8da7df73953eea0470bc5cd675c78f186", + "sleep_stager_issue_67_v2_sorted_shuffled": "6094fa8432fa2c1d59bdb7c6664b4d1512f0698c547b8a597d76bacc032368c1", +} RECOVERY_DRIVERS_KEY = ( "RecoveryScorer.chargeDrivers/8=RecoveryDrivers.chargeDrivers/8" ) @@ -489,6 +510,112 @@ def daily_payload(raw: Any, where: str) -> None: if obj["sleep"] is None: raise ParityFormatError(f"{path}.sleep must be non-null") daily_payload(obj["sleep"], f"{path}.sleep") return + if function in SLEEP_STAGER_KEYS: + calls = expected["effectiveArgs"]["calls"] + if not isinstance(value, list) or len(value) != len(calls) or len(value) > 4: + raise ParityFormatError(f"{path} must contain exactly one bounded result per call") + + def text(raw: Any, where: str, allowed: set[str] | None = None) -> str: + obj = _validate_exact_object_keys(raw, {"text"}, where) + if not isinstance(obj["text"], str) or (allowed is not None and obj["text"] not in allowed): + raise ParityFormatError(f"{where}.text is outside the canonical vocabulary") + return obj["text"] + + def segments(raw: Any, call: dict[str, Any], where: str) -> None: + if not isinstance(raw, list) or not raw or len(raw) > 4096: + raise ParityFormatError(f"{where} must be a non-empty bounded segment array") + previous_end: int | None = None + for index, item in enumerate(raw): + segment = _validate_exact_object_keys( + item, {"start", "end", "stage"}, f"{where}[{index}]" + ) + start, end = segment["start"], segment["end"] + if type(start) is not int or type(end) is not int or not ( + call["start"] <= start < end <= call["end"] + ): + raise ParityFormatError(f"{where}[{index}] has invalid bounds") + if previous_end is not None and start != previous_end: + raise ParityFormatError(f"{where}[{index}] does not tile contiguously") + previous_end = end + text(segment["stage"], f"{where}[{index}].stage", {"wake", "light", "deep", "rem"}) + if raw[0]["start"] != call["start"] or previous_end != call["end"]: + raise ParityFormatError(f"{where} must tile the complete window") + + for index, (raw, call) in enumerate(zip(value, calls)): + where = f"{path}[{index}]" + if function == SLEEP_STAGER_DETECT_KEY: + if not isinstance(raw, list) or len(raw) > 32: + raise ParityFormatError(f"{where} must be a bounded session array") + for session_index, item in enumerate(raw): + session_path = f"{where}[{session_index}]" + session = _validate_exact_object_keys( + item, {"start", "end", "efficiency", "stages", "restingHR", "avgHRV"}, + session_path, + ) + if type(session["start"]) is not int or type(session["end"]) is not int or session["end"] <= session["start"]: + raise ParityFormatError(f"{session_path} has invalid session bounds") + _validate_exact_bits(session["efficiency"], f"{session_path}.efficiency") + if session["restingHR"] is not None and type(session["restingHR"]) is not int: + raise ParityFormatError(f"{session_path}.restingHR must be integer or null") + if session["avgHRV"] is not None: + _validate_exact_bits(session["avgHRV"], f"{session_path}.avgHRV") + segments(session["stages"], {"start": session["start"], "end": session["end"]}, f"{session_path}.stages") + elif function in {SLEEP_STAGER_V1_KEY, SLEEP_STAGER_V2_KEY}: + segments(raw, call, where) + elif function == SLEEP_STAGER_MOTION_KEY: + if not isinstance(raw, list) or len(raw) > 4096: + raise ParityFormatError(f"{where} must be a bounded motion array") + for item_index, item in enumerate(raw): + _validate_exact_bits(item, f"{where}[{item_index}]") + elif function == SLEEP_STAGER_STATE_KEY: + if not isinstance(raw, list) or len(raw) > 4096 or not all(type(item) is int and 0 <= item <= 3 for item in raw): + raise ParityFormatError(f"{where} must be a bounded band-state array") + elif function == SLEEP_STAGER_REM_KEY: + if raw is None: + continue + fields = { + "sleepEpochs", "remAtClassify", "remAfterReimpose", + "remStrippedByOnsetGuard", "respChannelPresent", "blockedNotStill", + "blockedNoCardiacActivation", "blockedRespRegular", + "blockedNoRespFallbackBar", "wonOtherStage", "isZeroREM", + } + diagnostic = _validate_exact_object_keys(raw, fields, where) + for field in fields - {"respChannelPresent", "isZeroREM"}: + if type(diagnostic[field]) is not int or not 0 <= diagnostic[field] <= 4096: + raise ParityFormatError(f"{where}.{field} must be a bounded non-negative integer") + if type(diagnostic["respChannelPresent"]) is not bool or type(diagnostic["isZeroREM"]) is not bool: + raise ParityFormatError(f"{where} diagnostic flags must be boolean") + if diagnostic["isZeroREM"] != (diagnostic["remAfterReimpose"] == 0): + raise ParityFormatError(f"{where}.isZeroREM contradicts remAfterReimpose") + classified = sum(diagnostic[field] for field in { + "remAtClassify", "blockedNotStill", "blockedNoCardiacActivation", + "blockedRespRegular", "blockedNoRespFallbackBar", "wonOtherStage", + }) + if classified != diagnostic["sleepEpochs"] or ( + diagnostic["remAfterReimpose"] + diagnostic["remStrippedByOnsetGuard"] + > diagnostic["sleepEpochs"] + ): + raise ParityFormatError(f"{where} diagnostic counts contradict the public funnel") + else: + fields = { + "tibS", "tstS", "sptS", "solS", "remLatencyS", "wasoS", + "efficiency", "disturbances", "deepMin", "remMin", "lightMin", + "deepPct", "remPct", "lightPct", + } + metrics = _validate_exact_object_keys(raw, fields, where) + for field in fields - {"disturbances"}: + _validate_exact_bits(metrics[field], f"{where}.{field}") + if type(metrics["disturbances"]) is not int or not 0 <= metrics["disturbances"] <= 4096: + raise ParityFormatError(f"{where}.disturbances must be a bounded non-negative integer") + + if expected.get("regressionIssue") == "bhelm/noop#66": + if len(value) != 3 or value[0] != value[2] or value[0] == value[1]: + raise ParityFormatError(f"{path} must prove observable A→B→A cache isolation for #66") + if expected.get("regressionIssue") == "bhelm/noop#67": + oracle = [{"start": calls[0]["start"], "end": calls[0]["end"], "stage": {"text": "light"}}] + if len(value) != 2 or value[0] != oracle or value[1] != oracle: + raise ParityFormatError(f"{path} must preserve the frozen sorted/shuffled #67 oracle") + return if function == SLEEP_CREDIT_KEY: if value is not None: _validate_exact_bits(value, path) @@ -1118,6 +1245,79 @@ def seeded_arguments(strategy: str) -> tuple[int, dict[str, Any]]: return records +def _seeded_sleep_stager_cases( + seed_base: int = SLEEP_STAGER_SEED, +) -> list[dict[str, Any]]: + """Exactly two operation-local structured seeds for every public S3 entry point.""" + + records: list[dict[str, Any]] = [] + for function in sorted(SLEEP_STAGER_KEYS): + function_seed = seed_base ^ _stable_fnv1a64(function) + for strategy in ("splitmix64", "affine"): + strategy_seed = function_seed ^ _stable_fnv1a64(f"strategy:{strategy}") + rng = SplitMix64(strategy_seed) + marker = int(strategy_seed & 0x3fff) + int(rng.bounded(300)) + start = 1_760_000_000 + marker * 10 + duration = 600 + int(rng.bounded(11)) * 30 + order = "sorted" if strategy == "splitmix64" else "odd-even" + + def gravity(count: int, pattern: str = "still-z") -> dict[str, Any]: + return {"startTs": start, "count": count, "stepSec": 1, + "pattern": pattern, "order": order} + + def heart_rate(count: int) -> dict[str, Any]: + return {"startTs": start, "count": count, "stepSec": 1, + "base": 48 + int(rng.bounded(8)), "pattern": "minute-wave", "order": order} + + def rr(count: int) -> dict[str, Any]: + return {"startTs": start, "count": count, "stepSec": 1, + "base": 960 + int(rng.bounded(81)), "pattern": "four-wave", "order": order} + + def resp(count: int) -> dict[str, Any]: + return {"startTs": start, "count": count, "stepSec": 1, + "base": 1000 + int(rng.bounded(51)), "pattern": "four-wave", "order": order} + + if function == SLEEP_STAGER_DETECT_KEY: + call = { + "gravity": gravity(duration, "still-z" if strategy == "splitmix64" else "gentle-wave"), + "hr": heart_rate(duration), "rr": rr(duration), "resp": resp(duration), + "tzOffsetSeconds": marker % 3_600, "wristOff": [], "bandSleepState": [], + "useSleepStagerV2": strategy == "affine", "sleepHRBaseline": None, + "useDefaults": False, + } + elif function in {SLEEP_STAGER_V1_KEY, SLEEP_STAGER_V2_KEY, SLEEP_STAGER_REM_KEY}: + call = { + "start": start, "end": start + duration, + "gravity": gravity(duration, "still-z" if strategy == "splitmix64" else "gentle-wave"), + "hr": heart_rate(duration), "rr": rr(duration), "resp": resp(duration), + } + elif function == SLEEP_STAGER_MOTION_KEY: + call = {"start": start, "end": start + duration, + "gravity": gravity(duration, "gentle-wave")} + elif function == SLEEP_STAGER_STATE_KEY: + call = {"start": start, "end": start + duration, "sleepState": [ + {"ts": start + marker % 20, "state": int(rng.bounded(4))}, + {"ts": start + duration // 2, "state": int(rng.bounded(4))}, + ]} + elif function == SLEEP_STAGER_METRICS_KEY: + seam = start + duration // 3 + call = {"session": {"start": start, "end": start + duration, + "efficiency": 0.75, "restingHR": 50, "avgHRV": 42.0, + "stages": [ + {"start": start, "end": seam, "stage": "wake"}, + {"start": seam, "end": start + duration, "stage": + "deep" if strategy == "splitmix64" else "rem"}, + ]}} + else: + raise AssertionError(function) + records.append({ + "args": {"calls": [call]}, "comparison": "exact", "function": function, + "id": f"seeded_sleep_stager_{function.replace('.', '_').replace('/', '_')}_{strategy}", + "source": f"seeded:{function}:{strategy}:{strategy_seed:#018x}", + }) + return records + + def _seeded_sleep_cases() -> list[dict[str, Any]]: """Two function-local deterministic streams per Sleep helper; never share RNG state.""" @@ -1539,6 +1739,7 @@ def add(function: str, comparison: str, args: dict[str, Any]) -> None: records.extend(_seeded_watch_recovery_cases()) records.extend(_seeded_sleep_cases()) records.extend(_seeded_sleep_stage_totals_cases()) + records.extend(_seeded_sleep_stager_cases()) return records @@ -2140,6 +2341,148 @@ def walk(node: Any, depth: int = 0) -> None: ) +def _validate_sleep_stager_args(args: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]: + """Validate compact, deterministic stream recipes for the public Sleep-stager surface.""" + + function, case_id = record["function"], record.get("id") + if set(args) != {"calls"} or not isinstance(args["calls"], list) or not 1 <= len(args["calls"]) <= 4: + raise ParityFormatError(f"case {case_id!r} {function} requires one to four calls") + + def exact(raw: Any, fields: set[str], where: str) -> dict[str, Any]: + if not isinstance(raw, dict) or set(raw) != fields: + raise ParityFormatError(f"case {case_id!r} {function} {where} requires exactly {sorted(fields)}") + return raw + + def integer(value: Any, where: str) -> int: + return _sleep_int(value, record, where) + + def series(raw: Any, kind: str, where: str) -> dict[str, Any]: + fields = {"startTs", "count", "stepSec", "pattern", "order"} + if kind != "gravity": fields.add("base") + value = exact(raw, fields, where) + start = integer(value["startTs"], f"{where}.startTs") + count = value["count"] + step = value["stepSec"] + if not _is_signed_integer(count, 0, 21_600) or not _is_signed_integer(step, 1, 3_600): + raise ParityFormatError(f"case {case_id!r} {function} {where} count/step is outside the bounded domain") + if count: + _checked_add(start, (count - 1) * step, record, f"{where} final timestamp") + if value["order"] not in {"sorted", "odd-even"}: + raise ParityFormatError(f"case {case_id!r} {function} {where}.order is invalid") + patterns = { + "gravity": {"still-z", "alternating-same-sum", "pulse-same-sum", "gentle-wave"}, + "hr": {"flat", "minute-wave"}, "rr": {"flat", "four-wave"}, + "resp": {"flat", "four-wave"}, + } + if value["pattern"] not in patterns[kind]: + raise ParityFormatError(f"case {case_id!r} {function} {where}.pattern is invalid") + if kind != "gravity": + base = value["base"] + bounds = {"hr": (20, 250), "rr": (300, 2_000), "resp": (-1_000_000, 1_000_000)}[kind] + if not _is_signed_integer(base, *bounds): + raise ParityFormatError(f"case {case_id!r} {function} {where}.base is invalid") + return dict(value) + + def window(call: dict[str, Any], where: str) -> tuple[int, int]: + start, end = integer(call["start"], f"{where}.start"), integer(call["end"], f"{where}.end") + duration = _checked_subtract(end, start, record, f"{where} duration") + if not 0 < duration <= 86_400: + raise ParityFormatError(f"case {case_id!r} {function} {where} window must be within one day") + return start, end + + def state_rows(raw: Any, where: str) -> list[dict[str, Any]]: + if not isinstance(raw, list) or len(raw) > 21_600: + raise ParityFormatError(f"case {case_id!r} {function} {where} must be bounded") + rows: list[dict[str, Any]] = [] + for index, item in enumerate(raw): + row = exact(item, {"ts", "state"}, f"{where}[{index}]") + integer(row["ts"], f"{where}[{index}].ts") + if not _is_signed_integer(row["state"], 0, 3): + raise ParityFormatError(f"case {case_id!r} {function} {where}[{index}].state is invalid") + rows.append(dict(row)) + return rows + + effective_calls: list[dict[str, Any]] = [] + for index, raw_call in enumerate(args["calls"]): + where = f"calls[{index}]" + if not isinstance(raw_call, dict): + raise ParityFormatError(f"case {case_id!r} {function} {where} must be an object") + call = dict(raw_call) + if function == SLEEP_STAGER_DETECT_KEY: + use_defaults = call.get("useDefaults") + if type(use_defaults) is not bool: + raise ParityFormatError(f"case {case_id!r} {function} {where}.useDefaults must be boolean") + if use_defaults: + exact(call, {"gravity", "useDefaults"}, where) + else: + exact(call, {"gravity", "hr", "rr", "resp", "tzOffsetSeconds", "wristOff", + "bandSleepState", "useSleepStagerV2", "sleepHRBaseline", "useDefaults"}, where) + effective = dict(call) + effective["gravity"] = series(call["gravity"], "gravity", f"{where}.gravity") + if use_defaults: + effective.update({"hr": None, "rr": None, "resp": None, "tzOffsetSeconds": 0, + "wristOff": [], "bandSleepState": [], "useSleepStagerV2": False, + "sleepHRBaseline": None}) + else: + for key, kind in (("hr", "hr"), ("rr", "rr"), ("resp", "resp")): + effective[key] = series(call[key], kind, f"{where}.{key}") + offset = integer(call["tzOffsetSeconds"], f"{where}.tzOffsetSeconds") + if abs(offset) > 172_800: + raise ParityFormatError(f"case {case_id!r} {function} {where}.tzOffsetSeconds is unsafe") + wrist = call["wristOff"] + if not isinstance(wrist, list) or len(wrist) > 64: + raise ParityFormatError(f"case {case_id!r} {function} {where}.wristOff must be bounded") + for row_index, raw in enumerate(wrist): + row = exact(raw, {"start", "end"}, f"{where}.wristOff[{row_index}]") + if integer(row["end"], "wristOff.end") <= integer(row["start"], "wristOff.start"): + raise ParityFormatError(f"case {case_id!r} {function} wrist-off interval is empty") + effective["bandSleepState"] = state_rows(call["bandSleepState"], f"{where}.bandSleepState") + if type(call["useSleepStagerV2"]) is not bool: + raise ParityFormatError(f"case {case_id!r} {function} {where}.useSleepStagerV2 must be boolean") + baseline = call["sleepHRBaseline"] + if baseline is not None and not (_is_number(baseline) and 20.0 <= baseline <= 250.0): + raise ParityFormatError(f"case {case_id!r} {function} {where}.sleepHRBaseline is invalid") + effective_calls.append(effective) + continue + if function in {SLEEP_STAGER_V1_KEY, SLEEP_STAGER_V2_KEY, SLEEP_STAGER_REM_KEY}: + exact(call, {"start", "end", "gravity", "hr", "rr", "resp"}, where) + window(call, where) + for key, kind in (("gravity", "gravity"), ("hr", "hr"), ("rr", "rr"), ("resp", "resp")): + call[key] = series(call[key], kind, f"{where}.{key}") + elif function == SLEEP_STAGER_MOTION_KEY: + exact(call, {"start", "end", "gravity"}, where); window(call, where) + call["gravity"] = series(call["gravity"], "gravity", f"{where}.gravity") + elif function == SLEEP_STAGER_STATE_KEY: + exact(call, {"start", "end", "sleepState"}, where); window(call, where) + call["sleepState"] = state_rows(call["sleepState"], f"{where}.sleepState") + elif function == SLEEP_STAGER_METRICS_KEY: + exact(call, {"session"}, where) + session = exact(call["session"], {"start", "end", "efficiency", "stages", "restingHR", "avgHRV"}, f"{where}.session") + start, end = window(session, f"{where}.session") + if not _is_number(session["efficiency"]) or not 0.0 <= session["efficiency"] <= 1.0: + raise ParityFormatError(f"case {case_id!r} {function} session efficiency is invalid") + if session["restingHR"] is not None and not _is_signed_integer(session["restingHR"], 20, 250): + raise ParityFormatError(f"case {case_id!r} {function} restingHR is invalid") + if session["avgHRV"] is not None and not (_is_number(session["avgHRV"]) and 0.0 <= session["avgHRV"] <= 1_000.0): + raise ParityFormatError(f"case {case_id!r} {function} avgHRV is invalid") + stages = session["stages"] + if not isinstance(stages, list) or len(stages) > 4096: + raise ParityFormatError(f"case {case_id!r} {function} stages must be bounded") + previous = start + for stage_index, raw in enumerate(stages): + row = exact(raw, {"start", "end", "stage"}, f"{where}.session.stages[{stage_index}]") + row_start = integer(row["start"], "stage.start"); row_end = integer(row["end"], "stage.end") + if row_start != previous or not row_start < row_end <= end or row["stage"] not in {"wake", "light", "deep", "rem"}: + raise ParityFormatError(f"case {case_id!r} {function} stages must be canonical contiguous segments") + previous = row_end + if stages and previous != end: + raise ParityFormatError(f"case {case_id!r} {function} stages must tile the session") + else: + raise AssertionError(function) + effective_calls.append(call) + return {"calls": effective_calls} + + def _validate_sst_args(args: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]: f, cid = record["function"], record.get("id") def exact(required: set[str], optional: set[str] = set()) -> None: @@ -2248,6 +2591,8 @@ def _validate_sleep_args(args: dict[str, Any], record: dict[str, Any]) -> dict[s function = record.get("function") case_id = record.get("id") + if function in SLEEP_STAGER_KEYS: + return _validate_sleep_stager_args(args, record) if function in SLEEP_STAGE_TOTALS_KEYS: return _validate_sst_args(args, record) if function == SLEEP_CREDIT_KEY: @@ -2750,6 +3095,13 @@ def generate_cases(suite: str, nonce: str) -> list[dict[str, Any]]: raise ParityFormatError("nonce must not be empty") if suite == "negative": raw = [ + {"args":{"calls":[{"gravity":{"startTs":1749520800,"count":5400,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1749520800,"count":5400,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1749520800,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"tzOffsetSeconds":0,"wristOff":[],"bandSleepState":[],"useSleepStagerV2":False,"sleepHRBaseline":None,"useDefaults":False}]},"comparison":"exact","function":SLEEP_STAGER_DETECT_KEY,"id":"sleep_stager_negative_detect_probe","source":"negative-control"}, + {"args":{"calls":[{"start":1750100000,"end":1750100600,"gravity":{"startTs":1750100000,"count":600,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750100000,"count":600,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1750100000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"},"resp":{"startTs":1750100000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}]},"comparison":"exact","function":SLEEP_STAGER_V1_KEY,"id":"sleep_stager_negative_v1_probe","source":"negative-control"}, + {"args":{"calls":[{"start":1750200000,"end":1750200120,"gravity":{"startTs":1750200000,"count":120,"stepSec":1,"pattern":"gentle-wave","order":"sorted"}}]},"comparison":"exact","function":SLEEP_STAGER_MOTION_KEY,"id":"sleep_stager_negative_motion_probe","source":"negative-control"}, + {"args":{"calls":[{"start":1750300000,"end":1750300120,"sleepState":[{"ts":1750300010,"state":1},{"ts":1750300070,"state":2}]}]},"comparison":"exact","function":SLEEP_STAGER_STATE_KEY,"id":"sleep_stager_negative_state_probe","source":"negative-control"}, + {"args":{"calls":[{"start":1750400000,"end":1750400600,"gravity":{"startTs":1750400000,"count":600,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750400000,"count":600,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1750400000,"count":600,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"},"resp":{"startTs":1750400000,"count":600,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"}}]},"comparison":"exact","function":SLEEP_STAGER_REM_KEY,"id":"sleep_stager_negative_rem_probe","source":"negative-control"}, + {"args":{"calls":[{"session":{"start":1750500000,"end":1750500600,"efficiency":0.8,"stages":[{"start":1750500000,"end":1750500120,"stage":"wake"},{"start":1750500120,"end":1750500600,"stage":"deep"}],"restingHR":50,"avgHRV":42.0}}]},"comparison":"exact","function":SLEEP_STAGER_METRICS_KEY,"id":"sleep_stager_negative_metrics_probe","source":"negative-control"}, + {"args":{"calls":[{"start":1750600000,"end":1750600900,"gravity":{"startTs":1750600000,"count":900,"stepSec":1,"pattern":"still-z","order":"sorted"},"hr":{"startTs":1750600000,"count":900,"stepSec":1,"base":50,"pattern":"minute-wave","order":"sorted"},"rr":{"startTs":1750600000,"count":900,"stepSec":1,"base":1000,"pattern":"four-wave","order":"sorted"},"resp":{"startTs":1750600000,"count":0,"stepSec":1,"base":1000,"pattern":"flat","order":"sorted"}}]},"comparison":"exact","function":SLEEP_STAGER_V2_KEY,"id":"sleep_stager_negative_v2_probe","source":"negative-control"}, {"args":{"stagesJSON":"[{\"start\":0,\"end\":3600,\"stage\":\"rem\"}]"},"comparison":"exact","function":"SleepStageTotals.minutes/1","id":"sleep_stage_totals_negative_decode_probe","source":"negative-control"}, {"args":{"stagesJSON":"[{\"start\":0,\"end\":3600,\"stage\":\"light\"}]","onsetSec":1800},"comparison":"exact","function":"SleepStageTotals.clampStagesToOnset/2","id":"sleep_stage_totals_negative_clamp_probe","source":"negative-control"}, {"args":{"stagesJSONs":["[{\"start\":0,\"end\":3600,\"stage\":\"light\"}]"],"interFragmentAwakeSeconds":3600.0},"comparison":"exact","function":"SleepStageTotals.dailyAggregate/2","id":"sleep_stage_totals_negative_daily_probe","source":"negative-control"}, @@ -2950,7 +3302,10 @@ def generate_cases(suite: str, nonce: str) -> list[dict[str, Any]]: "forecast_clamp_issue_56_negative_x_positive_upper_zero", } regression_issue = record.get("regressionIssue") - supported_regression_issues = {"bhelm/noop#41", "bhelm/noop#42", "bhelm/noop#56"} + supported_regression_issues = { + "bhelm/noop#41", "bhelm/noop#42", "bhelm/noop#56", + "bhelm/noop#66", "bhelm/noop#67", + } if regression_issue is not None and regression_issue not in supported_regression_issues: raise ParityFormatError( f"case {case_id!r} has unsupported regressionIssue {regression_issue!r}" @@ -2974,6 +3329,30 @@ def generate_cases(suite: str, nonce: str) -> list[dict[str, Any]]: raise ParityFormatError( f"case {case_id!r} may not reuse a Sleep regression issue outside its exact fixture" ) + sleep_stager_regressions = { + "sleep_stager_issue_66_detect_cache_aba": ("bhelm/noop#66", SLEEP_STAGER_DETECT_KEY, 3), + "sleep_stager_issue_66_v1_cache_aba": ("bhelm/noop#66", SLEEP_STAGER_V1_KEY, 3), + "sleep_stager_issue_66_v2_cache_aba": ("bhelm/noop#66", SLEEP_STAGER_V2_KEY, 3), + "sleep_stager_issue_67_v2_sorted_shuffled": ("bhelm/noop#67", SLEEP_STAGER_V2_KEY, 2), + } + if case_id in sleep_stager_regressions: + issue, expected_function, expected_calls = sleep_stager_regressions[case_id] + stager_args = record.get("args") + calls = stager_args.get("calls") if isinstance(stager_args, dict) else None + if regression_issue != issue or record.get("comparison") != "exact" or record.get("function") != expected_function or not isinstance(calls, list) or len(calls) != expected_calls: + raise ParityFormatError( + f"case {case_id!r} must preserve its exact {issue} Sleep-stager regression shape" + ) + locked = {key: record[key] for key in ("id", "function", "comparison", "regressionIssue", "args")} + digest = hashlib.sha256(_canonical_json(locked).encode("utf-8")).hexdigest() + if digest != SLEEP_STAGER_REGRESSION_DIGESTS[case_id]: + raise ParityFormatError( + f"case {case_id!r} must preserve its exact issue-linked Sleep-stager fixture" + ) + elif regression_issue in {"bhelm/noop#66", "bhelm/noop#67"}: + raise ParityFormatError( + f"case {case_id!r} may not reuse a Sleep-stager regression issue outside its exact fixture" + ) function = record.get("function") args = record.get("args", {}) hr = args.get("hr", []) if isinstance(args, dict) else [] diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 822ea9c9c3..cb0377b3d6 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -500,6 +500,41 @@ "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStageTotals.kt::dailyAggregateHonoringEdits/6#1", "evidence": "declared twin reference" }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::detectSleep/10#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::detectSleep/10#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::stageSession/6#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::stageSession/6#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::sessionEpochMotion/3#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::sessionEpochMotion/3#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::sessionEpochSleepState/3#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::sessionEpochSleepState/3#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::remFunnelDiagnostic/6#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::remFunnelDiagnostic/6#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::hypnogramMetrics/1#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::hypnogramMetrics/1#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, + { + "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift::stageSession/6#1", + "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStagerV2.kt::stageSession/6#1", + "evidence": "differential parity harness: public Sleep-stager rollout" + }, { "swift": "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift::posturVarianceG2/1#1", "kotlin": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::posturVarianceG2/1#1", diff --git a/Tools/tests/test_parity_diff.py b/Tools/tests/test_parity_diff.py index d0e66a69e5..8414cc79f4 100644 --- a/Tools/tests/test_parity_diff.py +++ b/Tools/tests/test_parity_diff.py @@ -3,6 +3,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock from Tools import parity_diff @@ -1274,7 +1275,7 @@ def test_hrv_median_cases_cover_shape_edges_and_two_seeded_inputs(self): def test_sleep_foundations_have_curated_boundaries_and_two_isolated_seeds_each(self): cases = parity_diff.generate_cases("pilot", "sleep-seeds") - for function in sorted(parity_diff.SLEEP_FUNCTIONS - parity_diff.SLEEP_STAGE_TOTALS_KEYS): + for function in sorted(parity_diff.SLEEP_FUNCTIONS - parity_diff.SLEEP_STAGE_TOTALS_KEYS - parity_diff.SLEEP_STAGER_KEYS): with self.subTest(function=function): selected = [case for case in cases if case["function"] == function] curated = [case for case in selected if case["source"] == "curated:sleep_foundations.json"] @@ -1538,6 +1539,153 @@ def test_sleep_stage_totals_selection_negative_probe_is_reason_changing(self): self.assertIn('sleep_stage_totals_negative_selection_probe', swift) self.assertIn('sleep_stage_totals_negative_selection_probe', kotlin) + def test_sleep_stager_has_exactly_two_operation_local_seeds(self): + cases = parity_diff.generate_cases("pilot", "s3-seeds") + for function in parity_diff.SLEEP_STAGER_KEYS: + seeded = [case for case in cases if case["function"] == function + and case["source"].startswith(f"seeded:{function}:")] + self.assertEqual(2, len(seeded), function) + self.assertEqual({"splitmix64", "affine"}, {case["source"].split(":")[2] for case in seeded}) + + def test_sleep_stager_seeds_are_distinct_responsive_and_isolated(self): + base = parity_diff._seeded_sleep_stager_cases() + perturbed = parity_diff._seeded_sleep_stager_cases(parity_diff.SLEEP_STAGER_SEED ^ 1) + perturbed_by_id = {case["id"]: case for case in perturbed} + for function in parity_diff.SLEEP_STAGER_KEYS: + selected = [case for case in base if case["function"] == function] + self.assertEqual(2, len(selected), function) + self.assertNotEqual(selected[0]["args"], selected[1]["args"], function) + for case in selected: + self.assertNotEqual(case["args"], perturbed_by_id[case["id"]]["args"], case["id"]) + unrelated = parity_diff.SplitMix64(parity_diff.GENERATOR_SEED) + for _ in range(100): + unrelated.next_u64() + self.assertEqual(base, parity_diff._seeded_sleep_stager_cases()) + + def test_sleep_stager_curated_matrix_and_issue_regressions_are_complete(self): + cases = {case["id"]: case for case in parity_diff.generate_cases("pilot", "s3-matrix")} + curated = [case for case in cases.values() if case["source"] == "curated:sleep_stager.json"] + self.assertEqual(parity_diff.SLEEP_STAGER_KEYS, {case["function"] for case in curated}) + expected = { + "sleep_stager_issue_66_detect_cache_aba": ("bhelm/noop#66", parity_diff.SLEEP_STAGER_DETECT_KEY, 3), + "sleep_stager_issue_66_v1_cache_aba": ("bhelm/noop#66", parity_diff.SLEEP_STAGER_V1_KEY, 3), + "sleep_stager_issue_66_v2_cache_aba": ("bhelm/noop#66", parity_diff.SLEEP_STAGER_V2_KEY, 3), + "sleep_stager_issue_67_v2_sorted_shuffled": ("bhelm/noop#67", parity_diff.SLEEP_STAGER_V2_KEY, 2), + } + for case_id, (issue, function, count) in expected.items(): + case = cases[case_id] + self.assertEqual((issue, function, count), + (case["regressionIssue"], case["function"], len(case["args"]["calls"]))) + self.assertNotIn("bhelm/noop#43", {case.get("knownBehaviorIssue") for case in cases.values()}) + self.assertNotIn("bhelm/noop#43", {case.get("regressionIssue") for case in cases.values()}) + + def test_sleep_stager_issue_fixtures_fail_closed_on_payload_mutation_or_reuse(self): + raw = parity_diff._curated_cases() + fixtures = [case for case in raw if case.get("regressionIssue") in {"bhelm/noop#66", "bhelm/noop#67"}] + self.assertEqual(4, len(fixtures)) + for fixture in fixtures: + mutated = json.loads(json.dumps(fixture)) + call = mutated["args"]["calls"][0] + series = call.get("gravity") + if series is not None: + series["count"] += 1 + with self.subTest(case=fixture["id"]), self.assertRaisesRegex( + parity_diff.ParityFormatError, "exact issue-linked" + ): + with mock.patch.object(parity_diff, "_curated_cases", return_value=[mutated]): + parity_diff.generate_cases("pilot", "mutant") + + def test_sleep_stager_rejects_unknown_unbounded_and_non_common_inputs(self): + base = {"id":"bad","function":parity_diff.SLEEP_STAGER_MOTION_KEY,"comparison":"exact", + "args":{"calls":[{"start":1,"end":61,"gravity":{"startTs":1,"count":60, + "stepSec":1,"pattern":"gentle-wave","order":"sorted"}}]}} + bad = [ + {**base, "args": {**base["args"], "extra": 1}}, + {**base, "args":{"calls":[{**base["args"]["calls"][0], "gravity":{ + **base["args"]["calls"][0]["gravity"], "count":21_601}}]}}, + {**base, "args":{"calls":[{**base["args"]["calls"][0], "gravity":{ + **base["args"]["calls"][0]["gravity"], "pattern":"raw-rows"}}]}}, + ] + for record in bad: + with self.subTest(record=record), self.assertRaises(parity_diff.ParityFormatError): + parity_diff._effective_args(record) + + def test_sleep_stager_exact_output_schema_and_cache_oracles_fail_closed(self): + cases = {case["id"]:case for case in parity_diff.generate_cases("pilot", "s3-output")} + v1 = cases["sleep_stager_v1_degenerate"] + bad_stage = [[{"start":1750000000,"end":1750000600,"stage":{"text":"asleep"}}]] + with self.assertRaisesRegex(parity_diff.ParityFormatError, "vocabulary"): + parity_diff._validate_sleep_output(bad_stage, v1, "valueBits") + + issue66 = cases["sleep_stager_issue_66_v1_cache_aba"] + start, end = issue66["effectiveArgs"]["calls"][0]["start"], issue66["effectiveArgs"]["calls"][0]["end"] + light = [{"start":start,"end":end,"stage":{"text":"light"}}] + with self.assertRaisesRegex(parity_diff.ParityFormatError, "A→B→A"): + parity_diff._validate_sleep_output([light, light, light], issue66, "valueBits") + + issue67 = cases["sleep_stager_issue_67_v2_sorted_shuffled"] + start, end = issue67["effectiveArgs"]["calls"][0]["start"], issue67["effectiveArgs"]["calls"][0]["end"] + wake = [{"start":start,"end":end,"stage":{"text":"wake"}}] + with self.assertRaisesRegex(parity_diff.ParityFormatError, "frozen sorted/shuffled"): + parity_diff._validate_sleep_output([wake, wake], issue67, "valueBits") + + def test_sleep_stager_rem_schema_allows_smoothing_to_create_guard_strips(self): + case = next(case for case in parity_diff.generate_cases("pilot", "s3-rem-seams") + if case["function"] == parity_diff.SLEEP_STAGER_REM_KEY) + diagnostic = { + "sleepEpochs":1, "remAtClassify":0, "remAfterReimpose":0, + "remStrippedByOnsetGuard":1, "respChannelPresent":False, + "blockedNotStill":0, "blockedNoCardiacActivation":0, + "blockedRespRegular":0, "blockedNoRespFallbackBar":0, + "wonOtherStage":1, "isZeroREM":True, + } + parity_diff._validate_sleep_output([diagnostic], case, "valueBits") + + def test_sleep_stager_rem_schema_bounds_disjoint_post_smoothing_counts(self): + case = next(case for case in parity_diff.generate_cases("pilot", "s3-rem-bounds") + if case["function"] == parity_diff.SLEEP_STAGER_REM_KEY) + base = { + "sleepEpochs":1, "remAtClassify":0, "remAfterReimpose":0, + "remStrippedByOnsetGuard":0, "respChannelPresent":False, + "blockedNotStill":0, "blockedNoCardiacActivation":0, + "blockedRespRegular":0, "blockedNoRespFallbackBar":0, + "wonOtherStage":1, "isZeroREM":True, + } + invalid = [ + {**base, "remStrippedByOnsetGuard":2}, + {**base, "remStrippedByOnsetGuard":1, "remAfterReimpose":1, "isZeroREM":False}, + ] + for diagnostic in invalid: + with self.subTest(diagnostic=diagnostic), self.assertRaisesRegex( + parity_diff.ParityFormatError, "contradict" + ): + parity_diff._validate_sleep_output([diagnostic], case, "valueBits") + + def test_sleep_stager_negative_suite_covers_all_public_entries(self): + selected = {case["function"] for case in parity_diff.generate_cases("negative", "s3-negative") + if case["id"].startswith("sleep_stager_negative_")} + self.assertEqual(parity_diff.SLEEP_STAGER_KEYS, selected) + + def test_sleep_stager_exemptions_removed_and_twin_map_declares_all_seven(self): + root = Path(__file__).parents[2] + swift_exempt = (root / "Packages/StrandAnalytics/Sources/StrandAnalytics/parity-exempt.json").read_text() + kotlin_exempt = (root / "android/app/src/main/java/com/noop/analytics/parity-exempt.json").read_text() + twins = json.loads((root / "Tools/parity_twin_map.json").read_text())["function_pairs"] + expected = { + ("SleepStager.swift::detectSleep/10", "SleepStager.kt::detectSleep/10"), + ("SleepStager.swift::stageSession/6", "SleepStager.kt::stageSession/6"), + ("SleepStager.swift::sessionEpochMotion/3", "SleepStager.kt::sessionEpochMotion/3"), + ("SleepStager.swift::sessionEpochSleepState/3", "SleepStager.kt::sessionEpochSleepState/3"), + ("SleepStager.swift::remFunnelDiagnostic/6", "SleepStager.kt::remFunnelDiagnostic/6"), + ("SleepStager.swift::hypnogramMetrics/1", "SleepStager.kt::hypnogramMetrics/1"), + ("SleepStagerV2.swift::stageSession/6", "SleepStagerV2.kt::stageSession/6"), + } + for swift_tail, kotlin_tail in expected: + self.assertNotIn(swift_tail, swift_exempt) + self.assertNotIn(kotlin_tail, kotlin_exempt) + self.assertTrue(any(pair["swift"].endswith(swift_tail + "#1") + and pair["kotlin"].endswith(kotlin_tail + "#1") for pair in twins)) + if __name__ == "__main__": unittest.main() diff --git a/Tools/tests/test_parity_ratchet.py b/Tools/tests/test_parity_ratchet.py index 0e4425576b..2e78ee2c8a 100644 --- a/Tools/tests/test_parity_ratchet.py +++ b/Tools/tests/test_parity_ratchet.py @@ -1267,14 +1267,20 @@ def test_repository_registry_is_runtime_derived_and_shards_hold_no_differential_ "SleepStageTotals.mainNightIndex/3", "SleepStageTotals.mainNightSelection/3", "SleepStageTotals.dailyAggregateHonoringEdits/6", "SleepStageTotals.habitualMidsleepSec/3", } + sleep_stager = { + "SleepStager.detectSleep/10", "SleepStager.stageSession/6", + "SleepStager.sessionEpochMotion/3", "SleepStager.sessionEpochSleepState/3", + "SleepStager.remFunnelDiagnostic/6", "SleepStager.hypnogramMetrics/1", + "SleepStagerV2.stageSession/6", + } expected = ( legacy | added | qualified_strain | strain | recovery | recovery_trace | heart_rate_recovery | recovery_drivers | recovery_forecast | watch_recovery - | sleep_foundations | sleep_stage_totals + | sleep_foundations | sleep_stage_totals | sleep_stager ) self.assertEqual(19, len(legacy)) self.assertEqual(21, len(legacy | added)) - self.assertEqual(71, len(expected)) + self.assertEqual(78, len(expected)) self.assertEqual(expected, registered) self.assertIn("StrainScorer.trimpToStrain/2", registered) self.assertNotIn("trimpToStrain", registered) diff --git a/android/app/src/main/java/com/noop/analytics/parity-exempt.json b/android/app/src/main/java/com/noop/analytics/parity-exempt.json index 9686cb2295..d1850a61a6 100644 --- a/android/app/src/main/java/com/noop/analytics/parity-exempt.json +++ b/android/app/src/main/java/com/noop/analytics/parity-exempt.json @@ -5117,11 +5117,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::detectSleep/10[defaults=1,2,3,5,6,7,8,9,10]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::detectSleepUncached/10[defaults=-]#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -5137,26 +5132,11 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::stageSession/6[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::stageSessionUncached/6[defaults=-]#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::sessionEpochMotion/3[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::sessionEpochSleepState/3[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::nEpochs@computed-property#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -5317,11 +5297,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::remFunnelDiagnostic/6[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::stageDepthRank/1[defaults=-]#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -5352,11 +5327,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::hypnogramMetrics/1[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "android/app/src/main/java/com/noop/analytics/SleepStager.kt::standardDeviation/1[defaults=-]#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", @@ -5377,11 +5347,6 @@ "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", "issue": 17 }, - { - "key": "android/app/src/main/java/com/noop/analytics/SleepStagerV2.kt::stageSession/6[defaults=-]#1", - "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", - "issue": 17 - }, { "key": "android/app/src/main/java/com/noop/analytics/SleepStagerV2.kt::PAD_LO@property-initializer#1", "reason": "Bootstrap debt: no differential or declared platform coverage proof yet.", diff --git a/android/app/src/test/java/com/noop/analytics/ParityRunner.kt b/android/app/src/test/java/com/noop/analytics/ParityRunner.kt index cd8c654ae8..dc6b2a01e1 100644 --- a/android/app/src/test/java/com/noop/analytics/ParityRunner.kt +++ b/android/app/src/test/java/com/noop/analytics/ParityRunner.kt @@ -2,6 +2,8 @@ package com.noop.analytics import com.noop.data.RrInterval import com.noop.data.HrSample +import com.noop.data.GravitySample +import com.noop.data.RespSample import org.json.JSONArray import org.json.JSONObject import org.junit.Assume @@ -72,6 +74,86 @@ class ParityRunner { function } when (dispatchFunction) { + "SleepStager.detectSleep/10" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index-> + val call=calls.getJSONObject(index);val gravity=stagerGravity(call.getJSONObject("gravity")) + val sessions=if(call.getBoolean("useDefaults")) SleepStager.detectSleep(gravity=gravity) else SleepStager.detectSleep( + hr=stagerHeartRate(call.getJSONObject("hr")),rr=stagerRR(call.getJSONObject("rr")), + resp=stagerResp(call.getJSONObject("resp")),gravity=gravity, + tzOffsetSeconds=call.getLong("tzOffsetSeconds"),wristOff=jsonIntervals(call.getJSONArray("wristOff")), + bandSleepState=jsonStates(call.getJSONArray("bandSleepState")), + useSleepStagerV2=call.getBoolean("useSleepStagerV2"),sleepHRBaseline=nullableDouble(call,"sleepHRBaseline")) + sessions.map(::sessionPayload) + } + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_detect_probe"){payload[0]=emptyList();result["negativeSide"]="kotlin"} + result["valueBits"]=payload + } + "SleepStager.stageSession/6" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index-> + val call=calls.getJSONObject(index);val start=call.getLong("start");val end=call.getLong("end") + val gravity=stagerGravity(call.getJSONObject("gravity"));val hr=stagerHeartRate(call.getJSONObject("hr")) + val rr=stagerRR(call.getJSONObject("rr"));val resp=stagerResp(call.getJSONObject("resp")) + SleepStager.stageSession(start,end,gravity,hr,rr,resp).map(::stagePayload) + } + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_v1_probe"){ + val stages=(payload[0] as List>).map{it.toMutableMap()}.toMutableList() + val stage=(stages[0]["stage"] as Map).toMutableMap();stage["text"]=if(stage["text"]=="wake")"light" else "wake";stages[0]["stage"]=stage;payload[0]=stages;result["negativeSide"]="kotlin" + } + result["valueBits"]=payload + } + "SleepStagerV2.stageSession/6" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index-> + val call=calls.getJSONObject(index) + SleepStagerV2.stageSession(call.getLong("start"),call.getLong("end"), + stagerGravity(call.getJSONObject("gravity")),stagerHeartRate(call.getJSONObject("hr")), + stagerRR(call.getJSONObject("rr")),stagerResp(call.getJSONObject("resp"))).map(::stagePayload) + } + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_v2_probe"){ + val stages=(payload[0] as List>).map{it.toMutableMap()}.toMutableList() + val stage=(stages[0]["stage"] as Map).toMutableMap();stage["text"]=if(stage["text"]=="wake")"light" else "wake";stages[0]["stage"]=stage;payload[0]=stages;result["negativeSide"]="kotlin" + } + result["valueBits"]=payload + } + "SleepStager.sessionEpochMotion/3" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index->val call=calls.getJSONObject(index);SleepStager.sessionEpochMotion(call.getLong("start"),call.getLong("end"),stagerGravity(call.getJSONObject("gravity"))).map(::exactBit)} + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_motion_probe"){ + val motion=(payload[0] as List).toMutableList();motion[0]=exactBit(Double.MAX_VALUE);payload[0]=motion;result["negativeSide"]="kotlin" + };result["valueBits"]=payload + } + "SleepStager.sessionEpochSleepState/3" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index->val call=calls.getJSONObject(index);SleepStager.sessionEpochSleepState(call.getLong("start"),call.getLong("end"),jsonStates(call.getJSONArray("sleepState")))} + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_state_probe"){ + val states=(payload[0] as List).toMutableList();states[0]=(states[0]+1)%4;payload[0]=states;result["negativeSide"]="kotlin" + };result["valueBits"]=payload + } + "SleepStager.remFunnelDiagnostic/6" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index->val call=calls.getJSONObject(index);SleepStager.remFunnelDiagnostic( + call.getLong("start"),call.getLong("end"),stagerGravity(call.getJSONObject("gravity")), + stagerHeartRate(call.getJSONObject("hr")),stagerRR(call.getJSONObject("rr")),stagerResp(call.getJSONObject("resp")))?.let(::remDiagnosticPayload)} + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_rem_probe"){ + val diagnostic=(payload[0] as Map).toMutableMap();diagnostic["respChannelPresent"]=!(diagnostic["respChannelPresent"] as Boolean);payload[0]=diagnostic;result["negativeSide"]="kotlin" + };result["valueBits"]=payload + } + "SleepStager.hypnogramMetrics/1" -> { + val calls=args.getJSONArray("calls") + val payload=MutableList(calls.length()){index-> + val input=calls.getJSONObject(index).getJSONObject("session") + val stageRows=input.getJSONArray("stages") + val session=DetectedSleep(input.getLong("start"),input.getLong("end"),input.getDouble("efficiency"), + (0 until stageRows.length()).map{rowIndex->val row=stageRows.getJSONObject(rowIndex);StageSegment(row.getLong("start"),row.getLong("end"),row.getString("stage"))}, + if(input.isNull("restingHR"))null else input.getInt("restingHR"),nullableDouble(input,"avgHRV")) + hypnogramPayload(SleepStager.hypnogramMetrics(session)) + } + if(negativeSide=="kotlin"&&caseId=="sleep_stager_negative_metrics_probe"){ + val first=(payload[0] as Map).toMutableMap();first["disturbances"]=(first["disturbances"] as Int)+1;payload[0]=first;result["negativeSide"]="kotlin" + };result["valueBits"]=payload + } "SleepStageTotals.minutes/1" -> { var payload: Any? = minutesPayload(SleepStageTotals.minutes(nullableString(args,"stagesJSON"))) if (negativeSide == "kotlin" && caseId == "sleep_stage_totals_negative_decode_probe") { payload=minutesPayload(SleepStageTotals.Minutes()); result["negativeSide"]="kotlin" } @@ -989,6 +1071,62 @@ class ParityRunner { return sortedMapOf("shape" to sortedMapOf("text" to "minutes"), "value" to value) } + private fun stagerOrder(rows: List, order: String): List { + if(order=="sorted")return rows + require(order=="odd-even"){"invalid stager order"} + return rows.filterIndexed{index,_->index%2==0}+rows.filterIndexed{index,_->index%2==1} + } + + private fun stagerGravity(input:JSONObject):List{ + val start=input.getLong("startTs");val count=input.getInt("count");val step=input.getLong("stepSec") + val rows=(0 until count).map{index-> + val pattern=input.getString("pattern") + val xyz=if(pattern=="still-z")Triple(0.0,0.0,1.0) + else if(pattern=="alternating-same-sum")if(index%2==0)Triple(1.0,0.0,0.0)else Triple(0.0,0.0,1.0) + else if(pattern=="pulse-same-sum")if(index%30==15)Triple(1.0,1.0,-1.0)else Triple(0.0,0.0,1.0) + else if(pattern=="gentle-wave")if(index%2==0)Triple(0.001,0.0,1.0)else Triple(0.0,0.0,1.0) + else error("invalid gravity pattern") + GravitySample("parity",start+index*step,xyz.first,xyz.second,xyz.third) + };return stagerOrder(rows,input.getString("order")) + } + + private fun stagerHeartRate(input:JSONObject):List{ + val start=input.getLong("startTs");val count=input.getInt("count");val step=input.getLong("stepSec");val base=input.getInt("base") + val rows=(0 until count).map{index->HrSample("parity",start+index*step,base+if(input.getString("pattern")=="minute-wave")(index/60)%3 else 0)} + return stagerOrder(rows,input.getString("order")) + } + + private fun stagerRR(input:JSONObject):List{ + val start=input.getLong("startTs");val count=input.getInt("count");val step=input.getLong("stepSec");val base=input.getInt("base");val wave=listOf(0,40,0,-40) + val rows=(0 until count).map{index->RrInterval("parity",start+index*step,base+if(input.getString("pattern")=="four-wave")wave[index%4]else 0)} + return stagerOrder(rows,input.getString("order")) + } + + private fun stagerResp(input:JSONObject):List{ + val start=input.getLong("startTs");val count=input.getInt("count");val step=input.getLong("stepSec");val base=input.getInt("base");val wave=listOf(0,10,0,-10) + val rows=(0 until count).map{index->RespSample("parity",start+index*step,base+if(input.getString("pattern")=="four-wave")wave[index%4]else 0)} + return stagerOrder(rows,input.getString("order")) + } + + private fun jsonIntervals(rows:JSONArray):List> = (0 until rows.length()).map{index->val row=rows.getJSONObject(index);row.getLong("start") to row.getLong("end")} + private fun jsonStates(rows:JSONArray):List> = (0 until rows.length()).map{index->val row=rows.getJSONObject(index);row.getLong("ts") to row.getInt("state")} + private fun stagePayload(value:StageSegment):Map = sortedMapOf("start" to value.start,"end" to value.end,"stage" to sortedMapOf("text" to value.stage)) + private fun sessionPayload(value:DetectedSleep):Map = sortedMapOf( + "start" to value.start,"end" to value.end,"efficiency" to exactBit(value.efficiency), + "stages" to value.stages.map(::stagePayload),"restingHR" to value.restingHR,"avgHRV" to value.avgHRV?.let(::exactBit)) + private fun remDiagnosticPayload(value:SleepStager.REMFunnelDiagnostic):Map = sortedMapOf( + "sleepEpochs" to value.sleepEpochs,"remAtClassify" to value.remAtClassify, + "remAfterReimpose" to value.remAfterReimpose,"remStrippedByOnsetGuard" to value.remStrippedByOnsetGuard, + "respChannelPresent" to value.respChannelPresent,"blockedNotStill" to value.blockedNotStill, + "blockedNoCardiacActivation" to value.blockedNoCardiacActivation,"blockedRespRegular" to value.blockedRespRegular, + "blockedNoRespFallbackBar" to value.blockedNoRespFallbackBar,"wonOtherStage" to value.wonOtherStage,"isZeroREM" to value.isZeroREM) + private fun hypnogramPayload(value:HypnogramMetrics):Map = sortedMapOf( + "tibS" to exactBit(value.tibS),"tstS" to exactBit(value.tstS),"sptS" to exactBit(value.sptS), + "solS" to exactBit(value.solS),"remLatencyS" to exactBit(value.remLatencyS),"wasoS" to exactBit(value.wasoS), + "efficiency" to exactBit(value.efficiency),"disturbances" to value.disturbances, + "deepMin" to exactBit(value.deepMin),"remMin" to exactBit(value.remMin),"lightMin" to exactBit(value.lightMin), + "deepPct" to exactBit(value.deepPct),"remPct" to exactBit(value.remPct),"lightPct" to exactBit(value.lightPct)) + private fun nullableDouble(value: JSONObject, key: String): Double? = if (!value.has(key) || value.isNull(key)) null else value.getDouble(key)