diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 1bcc91790e..845a9cdbc4 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -1348,7 +1348,11 @@ public enum AnalyticsEngine { return (red: redSum / kept, ir: irSum / kept) } - /// Nightly gated mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed + /// Gap (seconds) that ends one @82 measurement window, and the in-band seconds a window needs to count. + public static let spo2WindowMaxGapSec = 2 + public static let spo2WindowMinValidSec = 5 + + /// Nightly windowed mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed /// `sessions`, with the sample count it rests on — or nil when no in-band reading fell inside any /// span. (#112, tracking #103.) /// @@ -1377,17 +1381,45 @@ public enum AnalyticsEngine { /// value in range is positive (70...100), so round-half-up and round-half-away-from-zero agree — /// no platform-divergence risk from the rounding rule itself. Byte-parity twin of the Kotlin /// `nightlySpo2CandidateMean`. + /// + /// WINDOWED (wearable5.0 FINDINGS §13): the strap measures in 30 s windows every 1,200 s during sleep; + /// @82 is 0 outside them and inside holds a % or a status code per second. Seconds with a nonzero @82 + /// are grouped into windows (gap <= `spo2WindowMaxGapSec`), each window with at least + /// `spo2WindowMinValidSec` in-band seconds yields its median, and the night is the mean of those + /// medians, so a thin window (1-4 valid seconds) cannot weigh like a full one. `samples` = the in-band + /// seconds of the windows that counted. public static func nightlySpo2CandidateMean(_ sessions: [SleepSession], aux: [V18AuxSample]) -> (mean: Int, samples: Int)? { guard !sessions.isEmpty, !aux.isEmpty else { return nil } - var sum = 0, kept = 0 - for a in aux { - guard let v = a.auxByte82, (70...100).contains(v) else { continue } - guard sessions.contains(where: { $0.start <= a.ts && a.ts <= $0.end }) else { continue } - sum += v; kept += 1 + let rows = aux.filter { a in + guard let v = a.auxByte82, v != 0 else { return false } + return sessions.contains(where: { $0.start <= a.ts && a.ts <= $0.end }) + }.sorted { $0.ts < $1.ts } + // Split into windows first (a gap over `spo2WindowMaxGapSec` starts a new one), then reduce each. + var windows: [[Int]] = [] + var lastTs: Int? + for a in rows { + if let l = lastTs, a.ts - l <= spo2WindowMaxGapSec { + windows[windows.count - 1].append(a.auxByte82!) + } else { + windows.append([a.auxByte82!]) + } + lastTs = a.ts } - guard kept > 0 else { return nil } - return (mean: Int((Double(sum) / Double(kept)).rounded()), samples: kept) + var medians: [Double] = [] + var kept = 0 + for w in windows { + let valid = w.filter { (70...100).contains($0) }.sorted() + guard valid.count >= spo2WindowMinValidSec else { continue } + let m = valid.count / 2 + medians.append(valid.count % 2 == 1 ? Double(valid[m]) : Double(valid[m - 1] + valid[m]) / 2.0) + kept += valid.count + } + guard !medians.isEmpty else { return nil } + // Sequential left-to-right sum so the Kotlin twin's sum is bit-identical. + var sum = 0.0 + for m in medians { sum += m } + return (mean: Int((sum / Double(medians.count)).rounded()), samples: kept) } /// The plausible range for a raw Oura `0x6F` SpO2 sample before the ceiling transform below. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift index 583b9db6d4..54e1291852 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift @@ -3,12 +3,11 @@ import WhoopProtocol import WhoopStore @testable import StrandAnalytics -/// #112 / #103 — the nightly gated mean of the 5/MG SpO2 candidate byte. +/// #112 / #103 — the nightly windowed SpO2 from the 5/MG @82 byte (wearable5.0 FINDINGS §13). /// -/// This exists to make a volunteer's contribution checkable. The candidate cannot be promoted while two -/// straps disagree about it, and breaking that tie means a third wearer comparing their nightly figure -/// against the one the WHOOP app reports. Reading it off a scrolling chart is not an instrument; one -/// number is. These pin what that number counts and — more importantly — what it refuses to count. +/// Byte-parity twin of the Kotlin `Spo2CandidateNightlyTest`: same fixtures, same expected values. The strap +/// measures in 30 s windows; each window with enough in-band seconds yields its median and the night is +/// the mean of those medians. These pin what counts and, more importantly, what does not. final class Spo2CandidateNightlyTests: XCTestCase { private func session(_ start: Int, _ durSec: Int) -> SleepSession { @@ -18,67 +17,56 @@ final class Spo2CandidateNightlyTests: XCTestCase { private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) } - func testMeanAndSampleCountOverTheSession() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], aux: [aux(1100, 94), aux(1200, 96), aux(1300, 95)]) - XCTAssertEqual(r?.mean, 95) - XCTAssertEqual(r?.samples, 3) - } - - /// The count is not decoration: a mean over 3 readings and a mean over 3000 are different evidence, - /// and a volunteer's correlation built on the first would be worthless. - func testSampleCountTravelsWithTheMean() { - let many = (0..<50).map { aux(1000 + $0, 93) } - XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateMean([session(900, 600)], aux: many)?.samples, 50) + /// One window of consecutive seconds starting at `start`. + private func window(_ start: Int, _ values: [Int?]) -> [V18AuxSample] { + values.enumerated().map { aux(start + $0.offset, $0.element) } } - /// Out-of-band values are DIAGNOSTIC CODES and SATURATION SENTINELS, not low blood oxygen. Averaging - /// them in would produce a number that is not a percentage of anything — the single most damaging - /// thing this helper could do, because it would look plausible. - func testSubSeventyAndSentinelValuesAreExcluded() { + func testOneWindowYieldsItsMedian() { let r = AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], - aux: [aux(1100, 94), aux(1150, 3), aux(1200, 0), aux(1250, 0x80), aux(1300, 96)]) - XCTAssertEqual(r?.mean, 95, "only the two in-band readings may count") - XCTAssertEqual(r?.samples, 2) + [session(1000, 3600)], aux: window(1100, [94, 95, 96, 96, 96, 97, 97, 97, 98, 97])) + XCTAssertEqual(r?.mean, 97) // median (96 + 97) / 2 = 96.5, rounded half up + XCTAssertEqual(r?.samples, 10) } - func testDaytimeSamplesOutsideTheSessionAreExcluded() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], aux: [aux(500, 99), aux(1100, 94), aux(5000, 88)]) - XCTAssertEqual(r?.samples, 1) - XCTAssertEqual(r?.mean, 94) + func testNightIsTheMeanOfWindowMedians() { + let a = window(1100, [96, 96, 96, 96, 96]) + window(2300, [99, 99, 99, 99, 99, 99, 99]) + let r = AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: a) + XCTAssertEqual(r?.mean, 98) // (96 + 99) / 2 = 97.5, not the per-second mean 97.75 + XCTAssertEqual(r?.samples, 12) } - func testNilWhenNothingInBandFallsInsideASession() { - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [aux(1100, 5)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [aux(9999, 95)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([], aux: [aux(1100, 95)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [])) + /// Status codes (sub-70 and bit-7 values) stay in the window but never count as readings; a window + /// with fewer than five in-band seconds is dropped instead of weighing like a full one. + func testCodesAndThinWindowsDoNotCount() { + let full = window(1100, [160, 95, 128, 95, 95, 32, 96, 95]) + let thin = window(2300, [78, 160, 128, 16]) + let r = AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: full + thin) + XCTAssertEqual(r?.mean, 95) + XCTAssertEqual(r?.samples, 5) + XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: thin)) } - /// A record with no @82 at all is not a zero reading. - func testMissingBytesAreSkippedNotCountedAsZero() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], aux: [aux(1100, nil), aux(1200, 96)]) - XCTAssertEqual(r?.mean, 96) - XCTAssertEqual(r?.samples, 1) + /// A gap longer than two seconds ends a window, so two halves of three seconds are two thin windows. + func testGapSplitsWindows() { + let a = window(1100, [95, 95, 95]) + window(1106, [95, 95, 95]) + XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: a)) } - /// Found 2026-08-24 on the Oura twin (`nightlySpo2CeilingMean`) and fixed here too — same bug, same - /// helper shape. `sum / kept` on two `Int`s truncates toward zero; three samples averaging - /// 97.666... must round to 98, not floor to 97. - func testMeanRoundsRatherThanFloors() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], aux: [aux(1100, 98), aux(1200, 98), aux(1300, 97)]) - XCTAssertEqual(r?.mean, 98, "97.666... must round to 98, not floor to 97") + func testOutsideSessionZeroAndMissingAreSkipped() { + let a = window(500, [99, 99, 99, 99, 99]) + window(1100, [94, nil, 94, 94, 94, 94]) + let r = AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: a) + XCTAssertEqual(r?.mean, 94) + XCTAssertEqual(r?.samples, 5) + XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([], aux: a)) + XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [])) } - /// The boundaries are inclusive, matching the decoder's own `70...100` emit gate exactly. + /// Boundaries are inclusive, matching the decoder's own 70...100 emit gate exactly. func testBandBoundariesMatchTheDecoderGate() { XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], aux: [aux(1100, 70), aux(1200, 100)])?.samples, 2) + [session(1000, 600)], aux: window(1100, [70, 70, 100, 100, 100]))?.samples, 5) XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean( - [session(1000, 600)], aux: [aux(1100, 69), aux(1200, 101)])) + [session(1000, 600)], aux: window(1100, [69, 69, 101, 101, 101]))) } } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..6cb62de3ee 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -21,11 +21,11 @@ "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, "functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, - "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, + "constants": {"count": 2005, "sha256": "6242483adfc62637dcf25457e21efbc562a444c6056793a1f7211074271cf165"}, "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, - "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, + "constant_pairs": {"count": 704, "sha256": "5d0e4243925599ca41eb4511a4573b7e1528a1dfb7e0837dc6a84c68f5571166"}, "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, "unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 66b99085a3..f0af208061 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -1150,6 +1150,12 @@ object AnalyticsEngine { return (redSum / kept).toInt() to (irSum / kept).toInt() } + /** Gap (seconds) that ends one @82 measurement window (wearable5.0 FINDINGS §13). */ + const val SPO2_WINDOW_MAX_GAP_SEC: Long = 2L + + /** In-band seconds a window needs before its median counts. */ + const val SPO2_WINDOW_MIN_VALID_SEC: Int = 5 + /** * Nightly gated mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed [sessions], * paired with the sample count it rests on — or null when no in-band reading fell inside any span. @@ -1176,23 +1182,41 @@ object AnalyticsEngine { * toward zero, silently biasing every candidate down by up to 0.99. All values here are positive * (70..100), so the rounding-rule choice (half-up vs half-away-from-zero) is inert. Byte-parity * twin of the Swift `nightlySpo2CandidateMean`. + * + * WINDOWED (wearable5.0 FINDINGS §13): nonzero @82 seconds are grouped into measurement windows, each + * window with at least [SPO2_WINDOW_MIN_VALID_SEC] in-band seconds yields its median, and the night is + * the mean of those medians. Byte-parity twin of the Swift version. */ internal fun nightlySpo2CandidateMean( sessions: List, aux: List, ): Pair? { if (sessions.isEmpty() || aux.isEmpty()) return null - var sum = 0L + val rows = aux.filter { a -> + val v = a.auxByte82 + v != null && v != 0L && sessions.any { a.ts in it.start..it.end } + }.sortedBy { it.ts } + // Split into windows first (a gap over SPO2_WINDOW_MAX_GAP_SEC starts a new one), then reduce each. + val windows = ArrayList>() + var lastTs: Long? = null + for (a in rows) { + val l = lastTs + if (l != null && a.ts - l <= SPO2_WINDOW_MAX_GAP_SEC) windows.last() += a.auxByte82!! else windows += mutableListOf(a.auxByte82!!) + lastTs = a.ts + } + val medians = ArrayList() var kept = 0 - for (a in aux) { - val v = a.auxByte82 ?: continue - if (v < 70L || v > 100L) continue - if (sessions.none { a.ts in it.start..it.end }) continue - sum += v - kept += 1 + for (w in windows) { + val valid = w.filter { it in 70L..100L }.sorted() + if (valid.size < SPO2_WINDOW_MIN_VALID_SEC) continue + val m = valid.size / 2 + medians += if (valid.size % 2 == 1) valid[m].toDouble() else (valid[m - 1] + valid[m]).toDouble() / 2.0 + kept += valid.size } - if (kept == 0) return null - return Pair((sum.toDouble() / kept.toDouble()).roundToInt(), kept) + if (medians.isEmpty()) return null + var sum = 0.0 + for (m in medians) sum += m + return Pair((sum / medians.size).roundToInt(), kept) } /** diff --git a/android/app/src/test/java/com/noop/analytics/Spo2CandidateNightlyTest.kt b/android/app/src/test/java/com/noop/analytics/Spo2CandidateNightlyTest.kt index cec6566afa..c0662ac34a 100644 --- a/android/app/src/test/java/com/noop/analytics/Spo2CandidateNightlyTest.kt +++ b/android/app/src/test/java/com/noop/analytics/Spo2CandidateNightlyTest.kt @@ -6,14 +6,9 @@ import org.junit.Assert.assertNull import org.junit.Test /** - * #112 / #103 — the nightly gated mean of the 5/MG SpO2 candidate byte. + * #112 / #103 — the nightly windowed SpO2 from the 5/MG @82 byte (wearable5.0 FINDINGS §13). * * Byte-parity twin of the Swift `Spo2CandidateNightlyTests`: same fixtures, same expected values. - * - * This exists to make a volunteer's contribution checkable. The candidate cannot be promoted while two - * straps disagree about it, and breaking that tie means a third wearer comparing their nightly figure - * against the one the WHOOP app reports. Reading it off a scrolling chart is not an instrument; one - * number is. These pin what that number counts and — more importantly — what it refuses to count. */ class Spo2CandidateNightlyTest { @@ -24,70 +19,49 @@ class Spo2CandidateNightlyTest { private fun aux(ts: Long, v: Long?) = V18AuxRow(ts = ts, auxByte82 = v) - @Test fun meanAndSampleCountOverTheSession() { + private fun window(start: Long, values: List) = values.mapIndexed { i, v -> aux(start + i, v?.toLong()) } + + @Test fun oneWindowYieldsItsMedian() { val r = AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), listOf(aux(1100, 94), aux(1200, 96), aux(1300, 95))) - assertEquals(95, r?.first) - assertEquals(3, r?.second) + listOf(session(1000, 3600)), window(1100, listOf(94, 95, 96, 96, 96, 97, 97, 97, 98, 97))) + assertEquals(97, r?.first) + assertEquals(10, r?.second) } - /** The count is not decoration: 3 readings and 3000 are different evidence. */ - @Test fun sampleCountTravelsWithTheMean() { - val many = (0 until 50).map { aux(1000L + it, 93L) } - assertEquals(50, AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(900, 600)), many)?.second) + @Test fun nightIsTheMeanOfWindowMedians() { + val a = window(1100, listOf(96, 96, 96, 96, 96)) + window(2300, listOf(99, 99, 99, 99, 99, 99, 99)) + val r = AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 3600)), a) + assertEquals(98, r?.first) + assertEquals(12, r?.second) } - /** - * Out-of-band values are DIAGNOSTIC CODES and SATURATION SENTINELS, not low blood oxygen. Averaging - * them in would produce a number that is not a percentage of anything — the most damaging thing this - * helper could do, because the result would still look plausible. - */ - @Test fun subSeventyAndSentinelValuesAreExcluded() { - val r = AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), - listOf(aux(1100, 94), aux(1150, 3), aux(1200, 0), aux(1250, 0x80), aux(1300, 96))) + @Test fun codesAndThinWindowsDoNotCount() { + val full = window(1100, listOf(160, 95, 128, 95, 95, 32, 96, 95)) + val thin = window(2300, listOf(78, 160, 128, 16)) + val r = AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 3600)), full + thin) assertEquals(95, r?.first) - assertEquals(2, r?.second) + assertEquals(5, r?.second) + assertNull(AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 3600)), thin)) } - @Test fun daytimeSamplesOutsideTheSessionAreExcluded() { - val r = AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), listOf(aux(500, 99), aux(1100, 94), aux(5000, 88))) - assertEquals(1, r?.second) - assertEquals(94, r?.first) + @Test fun gapSplitsWindows() { + val a = window(1100, listOf(95, 95, 95)) + window(1106, listOf(95, 95, 95)) + assertNull(AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 3600)), a)) } - @Test fun nullWhenNothingInBandFallsInsideASession() { - assertNull(AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 600)), listOf(aux(1100, 5)))) - assertNull(AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 600)), listOf(aux(9999, 95)))) - assertNull(AnalyticsEngine.nightlySpo2CandidateMean(emptyList(), listOf(aux(1100, 95)))) + @Test fun outsideSessionZeroAndMissingAreSkipped() { + val a = window(500, listOf(99, 99, 99, 99, 99)) + window(1100, listOf(94, null, 94, 94, 94, 94)) + val r = AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 600)), a) + assertEquals(94, r?.first) + assertEquals(5, r?.second) + assertNull(AnalyticsEngine.nightlySpo2CandidateMean(emptyList(), a)) assertNull(AnalyticsEngine.nightlySpo2CandidateMean(listOf(session(1000, 600)), emptyList())) } - /** A record with no @82 at all is not a zero reading. */ - @Test fun missingBytesAreSkippedNotCountedAsZero() { - val r = AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), listOf(aux(1100, null), aux(1200, 96))) - assertEquals(96, r?.first) - assertEquals(1, r?.second) - } - - /** - * Found 2026-08-24 on the Oura twin ([Spo2CeilingNightlyTest]) and fixed here too — same bug, same - * helper shape. `sum / kept` on two integer types truncates toward zero; three samples averaging - * 97.666... must round to 98, not floor to 97. - */ - @Test fun meanRoundsRatherThanFloors() { - val r = AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), listOf(aux(1100, 98), aux(1200, 98), aux(1300, 97))) - assertEquals(98, r?.first) - } - - /** Boundaries are inclusive, matching the decoder's own 70..100 emit gate exactly. */ @Test fun bandBoundariesMatchTheDecoderGate() { - assertEquals(2, AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), listOf(aux(1100, 70), aux(1200, 100)))?.second) + assertEquals(5, AnalyticsEngine.nightlySpo2CandidateMean( + listOf(session(1000, 600)), window(1100, listOf(70, 70, 100, 100, 100)))?.second) assertNull(AnalyticsEngine.nightlySpo2CandidateMean( - listOf(session(1000, 600)), listOf(aux(1100, 69), aux(1200, 101)))) + listOf(session(1000, 600)), window(1100, listOf(69, 69, 101, 101, 101)))) } }