From 23c3c17a382eaa20ecd2ebbb3fb2afc54a092aa5 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 20 Aug 2026 12:40:25 +1200 Subject: [PATCH 1/3] Add per-chunk historical clock + R-R packing diagnostic (#1008) The session summary logs one (device, wall) correlation, captured on the first chunk. That cannot show the offset moving across a long offload, and it cannot tell the two candidate explanations for the R-R overcount apart. extractHistoricalStreams stamps every R-R interval inside a type-47 record with that record's single unix second, so a record carrying 8 intervals emits 8 rows on one timestamp. That inflates beats-per-second without any record arriving twice. The new line separates the readings: pack (intervals per STAMPED second) high, dens (per SPAN second) near the true beat rate -> packing pack and dens moving together -> genuine duplication corr=on|off reports whether the stale-RTC correction actually engaged. It reads the same threshold the decoder uses, now hoisted to a shared constant on both platforms so the log cannot claim a correction the stored stamps never received. Log-only: nothing here feeds a stored value or a gate. Verification: 7 unit tests per platform asserting the rendered line verbatim, so the two logs stay byte-identical and one parser reads either. WhoopProtocol 614 tests pass on Linux; Android compileFullDebugKotlin clean and the JVM twin reports tests=7. The Swift Backfiller call site is app-target, so it is covered by an on-demand app-build run, not default CI. Not yet exercised on a strap. --- .../WhoopProtocol/ChunkClockDiag.swift | 71 ++++++++++++++ .../WhoopProtocol/HistoricalStreams.swift | 8 +- .../ChunkClockDiagTests.swift | 74 +++++++++++++++ Strand/Collect/Backfiller.swift | 14 +++ .../src/main/java/com/noop/ble/Backfiller.kt | 12 +++ .../java/com/noop/protocol/ChunkClockDiag.kt | 78 ++++++++++++++++ .../com/noop/protocol/HistoricalStreams.kt | 11 ++- .../com/noop/protocol/ChunkClockDiagTest.kt | 93 +++++++++++++++++++ 8 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift create mode 100644 Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift create mode 100644 android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt create mode 100644 android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift new file mode 100644 index 0000000000..faa4fa9917 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift @@ -0,0 +1,71 @@ +import Foundation + +/// #1008 diagnostic: the per-CHUNK view of the historical clock basis and how densely each decoded +/// chunk packs its R-R intervals onto wall seconds. +/// +/// Why per-chunk and not per-session. The session summary already logs the ONE `(device, wall)` +/// correlation captured on the first chunk (`sessionClockDevice`/`sessionClockWall`, #67). That is +/// enough to say whether the stale-RTC correction could engage at all, but it cannot answer the two +/// questions #1008 actually turns on: +/// +/// 1. **Does the offset move within a single offload?** A strap RTC that drifts (measured at ~8 s/hour +/// on a 4.0) has a different `wall - device` offset at the start and the end of a long session. +/// One number per session hides the trajectory; one per chunk shows it. +/// 2. **Is the R-R overcount duplication, or packing?** `extractHistoricalStreams` stamps EVERY R-R +/// interval inside one type-47 record with that record's single `unix` second, so a record carrying +/// 8 intervals emits 8 rows on one timestamp (`ord` 0...7). That inflates beats-per-second without +/// any record being delivered twice. `pack` (intervals per STAMPED second) separates the two: packing +/// shows `pack` well above 1 while `dens` (intervals per SPAN second) stays near the true heart rate; +/// genuine duplication moves both together. +/// +/// Log-only and allocation-light — it walks the chunk's timestamps once. Nothing here feeds a stored +/// value or a gate. Twin of the Kotlin `ChunkClockDiag`. +public enum ChunkClockDiag { + + /// Build the per-chunk line, or `nil` when the chunk decoded no R-R at all (a motion/temp-only or + /// console-only chunk has no clock story to tell and would only add noise to a strap log). + /// + /// - Parameters: + /// - chunk: 1-based index of this chunk within the session. + /// - deviceClockRef: the strap-side half of the session correlation, as passed to the decoder. + /// - wallClockRef: the phone-side half of the same correlation. + /// - rrTimestamps: the resolved wall seconds of every R-R interval this chunk decoded, in + /// emission order. Duplicates are meaningful (they ARE the packing) and must not be pre-uniqued. + public static func line(chunk: Int, + deviceClockRef: Int, + wallClockRef: Int, + rrTimestamps: [Int]) -> String? { + guard !rrTimestamps.isEmpty else { return nil } + + let offset = wallClockRef - deviceClockRef + // Mirrors the gate inside `extractHistoricalStreams`: below the threshold the offset is DISCARDED + // and each record keeps its own raw unix second. Logging `corr` makes it explicit that a small + // drift (tens of seconds) never reaches the stored timestamps — the drift is baked into the + // strap's own stamps instead, which is a different problem with a different fix. + let corrected = abs(offset) > histStaleClockThresholdSec + + var perSecond: [Int: Int] = [:] + var oldest = rrTimestamps[0], newest = rrTimestamps[0] + for ts in rrTimestamps { + perSecond[ts, default: 0] += 1 + if ts < oldest { oldest = ts } + if ts > newest { newest = ts } + } + let stampedSeconds = perSecond.count + let maxPerSecond = perSecond.values.max() ?? 0 + let spanSeconds = newest - oldest + 1 // inclusive; a single-second chunk spans 1 + let pack = Double(rrTimestamps.count) / Double(stampedSeconds) + let dens = Double(rrTimestamps.count) / Double(spanSeconds) + + return "Backfill: hist clock chunk=\(chunk) offset=\(signed(offset))s corr=\(corrected ? "on" : "off")" + + " rr=\(rrTimestamps.count) secs=\(stampedSeconds) pack=\(fixed2(pack)) max=\(maxPerSecond)" + + " span=\(spanSeconds)s dens=\(fixed2(dens))" + } + + /// Always-signed so a drift trajectory reads at a glance across chunks (`+15s` → `+48s` → `+200s`). + static func signed(_ v: Int) -> String { v >= 0 ? "+\(v)" : "\(v)" } + + /// Locale-independent 2dp — a strap log is parsed by tooling, so a comma decimal separator would + /// break it on a non-English device. + static func fixed2(_ v: Double) -> String { String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v) } +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift index 34e1ef8549..61d31320bc 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift @@ -8,6 +8,12 @@ import Foundation /// Keep in lockstep with the Android `EXPECTED_UNHANDLED_HISTORICAL_TYPES`. let expectedUnhandledHistoricalTypes: Set = ["METADATA", "CONSOLE_LOGS"] +/// How stale the strap RTC must be before the `(wall - device)` offset is applied to a historical +/// record's own unix second. BELOW this the offset is discarded and the raw stamp is kept verbatim, so +/// an everyday drift of seconds-to-minutes never reaches stored timestamps. Shared with `ChunkClockDiag` +/// so the strap log's `corr=on|off` cannot disagree with what the decoder actually did. +public let histStaleClockThresholdSec = 86_400 // 1 day + /// Shared plausibility bounds for a type-47 record's own unix timestamp (#547). A WHOOP strap with a /// bad clock/flash (repeated trim=0xFFFFFFFF no-cursor) emits records whose decoded unix is scattered /// garbage — far-past (2024/2029), a bogus 2027=1827642881, and even FUTURE dates. NOOP used to trust @@ -159,7 +165,7 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame], // grid so the same record re-syncs to the SAME corrected ts (offloaded rows dedupe by (deviceId, ts); // an un-snapped, slightly-different offset on re-sync would duplicate every row). For a normal or // identity clockRef the offset is ~0 (< threshold) → rawTs is returned unchanged (current behavior). - let staleThreshold = 86_400 // 1 day + let staleThreshold = histStaleClockThresholdSec let snapGranularity = 300 // 5 min let clockOffset = wallClockRef - deviceClockRef // The wall "now" the plausibility gate's FUTURE bound measures against. A record genuinely can't diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift new file mode 100644 index 0000000000..378f93683c --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import WhoopProtocol + +/// #1008: the per-chunk clock/packing diag. These assert the two readings the line exists to +/// distinguish — packing vs duplication — and that `corr` agrees with the decoder's own gate. +final class ChunkClockDiagTests: XCTestCase { + + func testNoRrYieldsNoLine() { + XCTAssertNil(ChunkClockDiag.line(chunk: 1, deviceClockRef: 1_000, wallClockRef: 1_000, + rrTimestamps: [])) + } + + /// One interval per second at a steady rate: `pack` and `dens` both sit at 1.00 — the honest case. + func testHonestStreamPacksAndDensifiesAtOne() { + let ts = Array(1_700_000_000 ..< 1_700_000_010) + let line = ChunkClockDiag.line(chunk: 1, deviceClockRef: 1_000, wallClockRef: 1_000, + rrTimestamps: ts) + XCTAssertEqual(line, "Backfill: hist clock chunk=1 offset=+0s corr=off" + + " rr=10 secs=10 pack=1.00 max=1 span=10s dens=1.00") + } + + /// PACKING — the shape actually observed on 4.0 (`ord` 0...7 on one second). Every interval of a + /// record lands on that record's single stamp, so `pack`/`max` blow up while `dens` stays at the true + /// beat rate. This is what must NOT be misread as re-delivery. + func testPackedRecordShowsHighPackButHonestDensity() { + // 3 records, 10s apart, 8 intervals each — 24 beats over a 21s span (~1.14 beats/s). + var ts: [Int] = [] + for record in 0 ..< 3 { + ts.append(contentsOf: Array(repeating: 1_700_000_000 + record * 10, count: 8)) + } + let line = ChunkClockDiag.line(chunk: 4, deviceClockRef: 1_000, wallClockRef: 1_048, + rrTimestamps: ts) + XCTAssertEqual(line, "Backfill: hist clock chunk=4 offset=+48s corr=off" + + " rr=24 secs=3 pack=8.00 max=8 span=21s dens=1.14") + } + + /// DUPLICATION — the same seconds delivered twice moves `pack` AND `dens` together (both double), + /// which is how a strap log tells the two apart at a glance. + func testDuplicatedDeliveryMovesPackAndDensityTogether() { + let once = Array(1_700_000_000 ..< 1_700_000_010) + let line = ChunkClockDiag.line(chunk: 2, deviceClockRef: 1_000, wallClockRef: 1_000, + rrTimestamps: once + once) + XCTAssertEqual(line, "Backfill: hist clock chunk=2 offset=+0s corr=off" + + " rr=20 secs=10 pack=2.00 max=2 span=10s dens=2.00") + } + + /// `corr` must mirror `extractHistoricalStreams`: an everyday drift of minutes is DISCARDED by the + /// decoder, so it must not read as a correction the stored stamps never received. + func testCorrOffBelowThresholdAndOnAboveIt() { + let ts = [1_700_000_000] + let below = ChunkClockDiag.line(chunk: 1, deviceClockRef: 0, + wallClockRef: histStaleClockThresholdSec, rrTimestamps: ts) + XCTAssertTrue(below!.contains("corr=off"), "at exactly the threshold the decoder keeps rawTs") + let above = ChunkClockDiag.line(chunk: 1, deviceClockRef: 0, + wallClockRef: histStaleClockThresholdSec + 1, rrTimestamps: ts) + XCTAssertTrue(above!.contains("corr=on")) + } + + /// A strap RTC AHEAD of the phone must render as a negative offset, not an unsigned number — the + /// trajectory across chunks is only readable if the sign survives. + func testNegativeOffsetKeepsItsSign() { + let line = ChunkClockDiag.line(chunk: 7, deviceClockRef: 1_200, wallClockRef: 1_000, + rrTimestamps: [1_700_000_000]) + XCTAssertTrue(line!.contains("offset=-200s"), line!) + } + + /// Timestamps arrive in emission order, which is not guaranteed sorted across a record boundary; + /// the span must come from the true min/max, not the first/last element. + func testSpanUsesMinAndMaxNotFirstAndLast() { + let line = ChunkClockDiag.line(chunk: 1, deviceClockRef: 0, wallClockRef: 0, + rrTimestamps: [1_700_000_005, 1_700_000_000, 1_700_000_002]) + XCTAssertTrue(line!.contains("span=6s"), line!) + } +} diff --git a/Strand/Collect/Backfiller.swift b/Strand/Collect/Backfiller.swift index b4a4872057..4362bb4e80 100644 --- a/Strand/Collect/Backfiller.swift +++ b/Strand/Collect/Backfiller.swift @@ -132,6 +132,9 @@ final class Backfiller { private(set) var sessionClockDevice: Int? private(set) var sessionClockWall: Int? private(set) var sessionUsedIdentityRef = false + /// #1008: 1-based chunk counter for the per-chunk `hist clock` diag line, so a strap log can be read + /// as a trajectory across one offload. Reset per session alongside the clock ref above. + private var chunkIndex = 0 /// Logged once per session when the strap reports trim=0xFFFFFFFF — the "no valid flash cursor" /// sentinel: it has no banked history to offload (a clock/charge state, not a decode bug). private var loggedNoCursor = false @@ -261,6 +264,7 @@ final class Backfiller { sessionClockDevice = nil // #67: re-capture the decode clock ref for this session sessionClockWall = nil sessionUsedIdentityRef = false + chunkIndex = 0 loggedNoCursor = false loggedFutureRtc = false sessionDroppedImplausible = 0 @@ -481,6 +485,16 @@ final class Backfiller { return DecodedChunk(parsed: parsed, decoded: decoded, rejected: rejected) }.value let parsed = d.parsed + // #1008: per-chunk clock basis + R-R packing. The session summary logs only the FIRST chunk's + // correlation, which cannot show the offset moving across a long offload nor separate "the same + // beats arrived twice" from "one record stamped 8 intervals on one second". Log-only. + chunkIndex += 1 + if let l = ChunkClockDiag.line(chunk: chunkIndex, + deviceClockRef: ref.device, + wallClockRef: ref.wall, + rrTimestamps: d.decoded.rr.map(\.ts)) { + log?(l) + } // Observability (PR #241): log which layout this strap emits on a HEALTHY sync too — the // unmapped-version path below only fires for layouts NOOP can't decode, so a normal log // never revealed v18/v24/v25/v26. Once per distinct layout this session. diff --git a/android/app/src/main/java/com/noop/ble/Backfiller.kt b/android/app/src/main/java/com/noop/ble/Backfiller.kt index 3086319eee..43b896e2c5 100644 --- a/android/app/src/main/java/com/noop/ble/Backfiller.kt +++ b/android/app/src/main/java/com/noop/ble/Backfiller.kt @@ -6,6 +6,7 @@ import com.noop.data.InsertCounts import com.noop.data.StreamBatch import com.noop.data.WhoopRepository import com.noop.protocol.BadClockDiagnostics +import com.noop.protocol.ChunkClockDiag import com.noop.protocol.DeviceFamily import com.noop.protocol.Framing import com.noop.protocol.HistoricalMeta @@ -253,6 +254,9 @@ class Backfiller( * [begin]; each distinct layout is logged once per session. (PR #241, ryanbr.) */ private val loggedLayoutVersions = HashSet() + /** #1008: 1-based chunk counter for the per-chunk `hist clock` diag line, so a strap log can be read + * as a trajectory across one offload. Reset per session. Twin of the Swift `chunkIndex`. */ + private var chunkIndex = 0 /** SpO2 RE dump (PR #945, reimplemented): how many full-record dumps this session emitted, bounded by * [com.noop.analytics.Spo2ReTrace.MAX_SAMPLES]. Session-scoped so the cap spans chunks; reset in begin. */ @@ -315,6 +319,7 @@ class Backfiller( loggedNoCursor = false loggedFutureRtc = false loggedLayoutVersions.clear() + chunkIndex = 0 spo2Dumped = 0 loggedImplausibleClock = false sessionDroppedImplausible = 0 @@ -397,6 +402,13 @@ class Backfiller( sessionOldestUnix = sessionOldestUnix, sessionNewestUnix = sessionNewestUnix, ppgHrSubLagInterp = ppgHrSubLagInterp(), ) + // #1008: per-chunk clock basis + R-R packing. The session summary logs only the FIRST chunk's + // correlation, which cannot show the offset moving across a long offload nor separate "the same + // beats arrived twice" from "one record stamped 8 intervals on one second". Log-only. Twin of + // the Swift Backfiller emit. + chunkIndex += 1 + ChunkClockDiag.line(chunkIndex, ref.device, ref.wall, decoded.rr.map { it.ts }) + ?.let { log(it) } // Observability (PR #241): which historical layout does this strap emit? Only the unmapped/ // reject path logged a version before, so a healthy sync never revealed v24/v25 (4.0) or // v18/v26 (5/MG). Sample the chunk's first genuine record (null ⇒ console/CRC-fail); log diff --git a/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt b/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt new file mode 100644 index 0000000000..3051323221 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt @@ -0,0 +1,78 @@ +package com.noop.protocol + +import java.util.Locale +import kotlin.math.abs + +/** + * #1008 diagnostic: the per-CHUNK view of the historical clock basis and how densely each decoded chunk + * packs its R-R intervals onto wall seconds. + * + * Why per-chunk and not per-session. The session summary already logs the ONE `(device, wall)` + * correlation captured on the first chunk (#67). That is enough to say whether the stale-RTC correction + * could engage at all, but it cannot answer the two questions #1008 actually turns on: + * + * 1. **Does the offset move within a single offload?** A strap RTC that drifts (measured at ~8 s/hour on + * a 4.0) has a different `wall - device` offset at the start and the end of a long session. One + * number per session hides the trajectory; one per chunk shows it. + * 2. **Is the R-R overcount duplication, or packing?** [extractHistoricalStreams] stamps EVERY R-R + * interval inside one type-47 record with that record's single `unix` second, so a record carrying 8 + * intervals emits 8 rows on one timestamp (`ord` 0..7). That inflates beats-per-second without any + * record being delivered twice. `pack` (intervals per STAMPED second) separates the two: packing + * shows `pack` well above 1 while `dens` (intervals per SPAN second) stays near the true heart rate; + * genuine duplication moves both together. + * + * Log-only and allocation-light — it walks the chunk's timestamps once. Nothing here feeds a stored value + * or a gate. Twin of the Swift `ChunkClockDiag`. + */ +object ChunkClockDiag { + + /** + * Build the per-chunk line, or `null` when the chunk decoded no R-R at all (a motion/temp-only or + * console-only chunk has no clock story to tell and would only add noise to a strap log). + * + * @param chunk 1-based index of this chunk within the session. + * @param deviceClockRef the strap-side half of the session correlation, as passed to the decoder. + * @param wallClockRef the phone-side half of the same correlation. + * @param rrTimestamps the resolved wall seconds of every R-R interval this chunk decoded, in emission + * order. Duplicates are meaningful (they ARE the packing) and must not be pre-uniqued. `Long` here + * because the Room `rrInterval.ts` is `Long`; the Swift twin takes `Int` for the same reason (its + * `RRInterval.ts` is `Int`). The rendered line is identical on both. + */ + fun line(chunk: Int, deviceClockRef: Int, wallClockRef: Int, rrTimestamps: List): String? { + if (rrTimestamps.isEmpty()) return null + + val offset = wallClockRef - deviceClockRef + // Mirrors the gate inside extractHistoricalStreams: below the threshold the offset is DISCARDED + // and each record keeps its own raw unix second. Logging `corr` makes it explicit that a small + // drift (tens of seconds) never reaches the stored timestamps — the drift is baked into the + // strap's own stamps instead, which is a different problem with a different fix. + val corrected = abs(offset) > HIST_STALE_CLOCK_THRESHOLD_SEC + + val perSecond = HashMap() + var oldest = rrTimestamps[0] + var newest = rrTimestamps[0] + for (ts in rrTimestamps) { + perSecond[ts] = (perSecond[ts] ?: 0) + 1 + if (ts < oldest) oldest = ts + if (ts > newest) newest = ts + } + val stampedSeconds = perSecond.size + val maxPerSecond = perSecond.values.maxOrNull() ?: 0 + val spanSeconds = newest - oldest + 1 // inclusive; a single-second chunk spans 1 + val pack = rrTimestamps.size.toDouble() / stampedSeconds.toDouble() + val dens = rrTimestamps.size.toDouble() / spanSeconds.toDouble() + + return "Backfill: hist clock chunk=$chunk offset=${signed(offset)}s corr=${if (corrected) "on" else "off"}" + + " rr=${rrTimestamps.size} secs=$stampedSeconds pack=${fixed2(pack)} max=$maxPerSecond" + + " span=${spanSeconds}s dens=${fixed2(dens)}" + } + + /** Always-signed so a drift trajectory reads at a glance across chunks (`+15s` -> `+48s` -> `+200s`). */ + internal fun signed(v: Int): String = if (v >= 0) "+$v" else "$v" + + /** + * Locale-independent 2dp — a strap log is parsed by tooling, so a comma decimal separator (the default + * on a German/French device) would break it. Twin of the Swift en_US_POSIX format. + */ + internal fun fixed2(v: Double): String = String.format(Locale.US, "%.2f", v) +} diff --git a/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt b/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt index ef1e6b8027..69d9b862cb 100644 --- a/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt +++ b/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt @@ -18,6 +18,15 @@ import com.noop.data.StepRow import com.noop.data.StreamBatch import com.noop.data.StreamPersistence +/** + * How stale the strap RTC must be before the `(wall - device)` offset is applied to a historical record's + * own unix second. BELOW this the offset is discarded and the raw stamp is kept verbatim, so an everyday + * drift of seconds-to-minutes never reaches stored timestamps. Shared with [ChunkClockDiag] so the strap + * log's `corr=on|off` cannot disagree with what the decoder actually did. Twin of the Swift + * `histStaleClockThresholdSec`. + */ +const val HIST_STALE_CLOCK_THRESHOLD_SEC = 86_400 // 1 day + /** * #891: packet types an offload legitimately carries that [extractHistoricalStreams] has no rows for, so * reaching the `else` branch is expected rather than a finding. Both decode to zero rows BY DESIGN — @@ -710,7 +719,7 @@ fun extractHistoricalStreams( // record re-syncs to the SAME corrected ts (offloaded rows dedupe by (deviceId, ts); an un-snapped, // slightly-different offset on re-sync would duplicate every row). A normal/identity clockRef has // offset ~0 (< threshold) → rawTs unchanged (current behavior). - val staleThreshold = 86_400 // 1 day + val staleThreshold = HIST_STALE_CLOCK_THRESHOLD_SEC val snapGranularity = 300 // 5 min val clockOffset = wallClockRef - deviceClockRef // #547: now NULLABLE. After resolving the final candidate ts (BOTH the raw pass-through branch AND the diff --git a/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt b/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt new file mode 100644 index 0000000000..61f1056d59 --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt @@ -0,0 +1,93 @@ +package com.noop.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #1008: the per-chunk clock/packing diag. Twin of the Swift `ChunkClockDiagTests` — the expected + * strings are asserted VERBATIM on both platforms so the two logs stay byte-identical and one strap-log + * parser reads either. + */ +class ChunkClockDiagTest { + + @Test + fun `no rr yields no line`() { + assertNull(ChunkClockDiag.line(1, 1_000, 1_000, emptyList())) + } + + /** One interval per second at a steady rate: `pack` and `dens` both sit at 1.00 — the honest case. */ + @Test + fun `honest stream packs and densifies at one`() { + val ts = (1_700_000_000L until 1_700_000_010L).toList() + assertEquals( + "Backfill: hist clock chunk=1 offset=+0s corr=off" + + " rr=10 secs=10 pack=1.00 max=1 span=10s dens=1.00", + ChunkClockDiag.line(1, 1_000, 1_000, ts), + ) + } + + /** + * PACKING — the shape actually observed on 4.0 (`ord` 0..7 on one second). Every interval of a record + * lands on that record's single stamp, so `pack`/`max` blow up while `dens` stays at the true beat + * rate. This is what must NOT be misread as re-delivery. + */ + @Test + fun `packed record shows high pack but honest density`() { + // 3 records, 10s apart, 8 intervals each — 24 beats over a 21s span (~1.14 beats/s). + val ts = (0 until 3).flatMap { record -> List(8) { 1_700_000_000L + record * 10 } } + assertEquals( + "Backfill: hist clock chunk=4 offset=+48s corr=off" + + " rr=24 secs=3 pack=8.00 max=8 span=21s dens=1.14", + ChunkClockDiag.line(4, 1_000, 1_048, ts), + ) + } + + /** + * DUPLICATION — the same seconds delivered twice moves `pack` AND `dens` together (both double), + * which is how a strap log tells the two apart at a glance. + */ + @Test + fun `duplicated delivery moves pack and density together`() { + val once = (1_700_000_000L until 1_700_000_010L).toList() + assertEquals( + "Backfill: hist clock chunk=2 offset=+0s corr=off" + + " rr=20 secs=10 pack=2.00 max=2 span=10s dens=2.00", + ChunkClockDiag.line(2, 1_000, 1_000, once + once), + ) + } + + /** + * `corr` must mirror [extractHistoricalStreams]: an everyday drift of minutes is DISCARDED by the + * decoder, so it must not read as a correction the stored stamps never received. + */ + @Test + fun `corr off below threshold and on above it`() { + val ts = listOf(1_700_000_000L) + val below = ChunkClockDiag.line(1, 0, HIST_STALE_CLOCK_THRESHOLD_SEC, ts)!! + assertTrue("at exactly the threshold the decoder keeps rawTs", below.contains("corr=off")) + val above = ChunkClockDiag.line(1, 0, HIST_STALE_CLOCK_THRESHOLD_SEC + 1, ts)!! + assertTrue(above.contains("corr=on")) + } + + /** + * A strap RTC AHEAD of the phone must render as a negative offset, not an unsigned number — the + * trajectory across chunks is only readable if the sign survives. + */ + @Test + fun `negative offset keeps its sign`() { + val line = ChunkClockDiag.line(7, 1_200, 1_000, listOf(1_700_000_000L))!! + assertTrue(line, line.contains("offset=-200s")) + } + + /** + * Timestamps arrive in emission order, which is not guaranteed sorted across a record boundary; the + * span must come from the true min/max, not the first/last element. + */ + @Test + fun `span uses min and max not first and last`() { + val line = ChunkClockDiag.line(1, 0, 0, listOf(1_700_000_005L, 1_700_000_000L, 1_700_000_002L))!! + assertTrue(line, line.contains("span=6s")) + } +} From 347c8e13685bbb28cd881c0e6e7f1f137bddbd4e Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 20 Aug 2026 13:06:53 +1200 Subject: [PATCH 2/3] Render the diag's 2dp fields by integer half-up, not printf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review parity defect. The line's pack/dens were formatted with String(format:"%.2f") on Swift and String.format("%.2f") on Kotlin, which do not agree: C/Swift rounds half-to-EVEN, Java rounds half-UP. They diverge on any exact binary tie, and ties are ordinary values here — a pack of 9/8 is 1.125, rendering 1.12 on Swift and 1.13 on Kotlin. That silently breaks the property the tests assert and the whole point of the line: that one strap-log parser reads either platform. Both now compute the two decimals by integer arithmetic with an explicit half-up bias, so the rounding mode is defined in the code rather than inherited from a platform's printf. It also removes the locale hazard outright — there is no longer any float formatting to hand a comma decimal separator. Adds the 9/8 tie as a regression test on both platforms. WhoopProtocol 615 pass on Linux; the Android twin reports tests=8 under --rerun-tasks (the first run reported UP-TO-DATE against a stale result file). --- .../WhoopProtocol/ChunkClockDiag.swift | 27 +++++++++++++----- .../ChunkClockDiagTests.swift | 12 ++++++++ .../java/com/noop/protocol/ChunkClockDiag.kt | 28 +++++++++++++------ .../com/noop/protocol/ChunkClockDiagTest.kt | 14 ++++++++++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift index faa4fa9917..08356395ee 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift @@ -54,18 +54,31 @@ public enum ChunkClockDiag { let stampedSeconds = perSecond.count let maxPerSecond = perSecond.values.max() ?? 0 let spanSeconds = newest - oldest + 1 // inclusive; a single-second chunk spans 1 - let pack = Double(rrTimestamps.count) / Double(stampedSeconds) - let dens = Double(rrTimestamps.count) / Double(spanSeconds) return "Backfill: hist clock chunk=\(chunk) offset=\(signed(offset))s corr=\(corrected ? "on" : "off")" - + " rr=\(rrTimestamps.count) secs=\(stampedSeconds) pack=\(fixed2(pack)) max=\(maxPerSecond)" - + " span=\(spanSeconds)s dens=\(fixed2(dens))" + + " rr=\(rrTimestamps.count) secs=\(stampedSeconds)" + + " pack=\(fixed2(rrTimestamps.count, stampedSeconds)) max=\(maxPerSecond)" + + " span=\(spanSeconds)s dens=\(fixed2(rrTimestamps.count, spanSeconds))" } /// Always-signed so a drift trajectory reads at a glance across chunks (`+15s` → `+48s` → `+200s`). static func signed(_ v: Int) -> String { v >= 0 ? "+\(v)" : "\(v)" } - /// Locale-independent 2dp — a strap log is parsed by tooling, so a comma decimal separator would - /// break it on a non-English device. - static func fixed2(_ v: Double) -> String { String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v) } + /// `numerator/denominator` to 2dp, by INTEGER half-up rounding — deliberately NOT `String(format:)`. + /// + /// Two traps this sidesteps, both of which would silently desync the Swift and Kotlin logs that the + /// tests assert are byte-identical: + /// - **Rounding mode.** C/Swift `printf("%.2f")` rounds half-to-EVEN; Java's `String.format` rounds + /// half-UP. They disagree on any exact tie, and ties are ordinary here — `pack` of 9/8 renders + /// `1.12` on Swift and `1.13` on Kotlin. + /// - **Locale.** A comma decimal separator on a German/French device would break a log parser. + /// + /// Integer math has neither problem: `+denominator` before the divide is the half-up bias, and the + /// operands are small (interval counts), nowhere near overflow. + static func fixed2(_ numerator: Int, _ denominator: Int) -> String { + guard denominator > 0 else { return "0.00" } + let hundredths = (numerator * 200 + denominator) / (denominator * 2) + let frac = hundredths % 100 + return "\(hundredths / 100)." + (frac < 10 ? "0\(frac)" : "\(frac)") + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift index 378f93683c..5f0aba67c1 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift @@ -64,6 +64,18 @@ final class ChunkClockDiagTests: XCTestCase { XCTAssertTrue(line!.contains("offset=-200s"), line!) } + /// PARITY REGRESSION. `pack` of 9/8 is an exact binary tie (1.125). `printf("%.2f")` rounds + /// half-to-EVEN and would render `1.12`, while Kotlin's `String.format` rounds half-UP and renders + /// `1.13` — the two strap logs would silently disagree. Both platforms now round half-UP by integer + /// math, so this asserts `1.13` and its Kotlin twin asserts the identical string. + func testExactTieRoundsHalfUpNotHalfEven() { + // 9 intervals over 8 stamped seconds: one second carries 2, the rest 1. + var ts = Array(1_700_000_000 ..< 1_700_000_008) + ts.append(1_700_000_000) + let line = ChunkClockDiag.line(chunk: 1, deviceClockRef: 0, wallClockRef: 0, rrTimestamps: ts) + XCTAssertTrue(line!.contains("pack=1.13"), line!) + } + /// Timestamps arrive in emission order, which is not guaranteed sorted across a record boundary; /// the span must come from the true min/max, not the first/last element. func testSpanUsesMinAndMaxNotFirstAndLast() { diff --git a/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt b/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt index 3051323221..85d49a31a2 100644 --- a/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt +++ b/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt @@ -1,6 +1,5 @@ package com.noop.protocol -import java.util.Locale import kotlin.math.abs /** @@ -59,20 +58,33 @@ object ChunkClockDiag { val stampedSeconds = perSecond.size val maxPerSecond = perSecond.values.maxOrNull() ?: 0 val spanSeconds = newest - oldest + 1 // inclusive; a single-second chunk spans 1 - val pack = rrTimestamps.size.toDouble() / stampedSeconds.toDouble() - val dens = rrTimestamps.size.toDouble() / spanSeconds.toDouble() return "Backfill: hist clock chunk=$chunk offset=${signed(offset)}s corr=${if (corrected) "on" else "off"}" + - " rr=${rrTimestamps.size} secs=$stampedSeconds pack=${fixed2(pack)} max=$maxPerSecond" + - " span=${spanSeconds}s dens=${fixed2(dens)}" + " rr=${rrTimestamps.size} secs=$stampedSeconds" + + " pack=${fixed2(rrTimestamps.size.toLong(), stampedSeconds.toLong())} max=$maxPerSecond" + + " span=${spanSeconds}s dens=${fixed2(rrTimestamps.size.toLong(), spanSeconds)}" } /** Always-signed so a drift trajectory reads at a glance across chunks (`+15s` -> `+48s` -> `+200s`). */ internal fun signed(v: Int): String = if (v >= 0) "+$v" else "$v" /** - * Locale-independent 2dp — a strap log is parsed by tooling, so a comma decimal separator (the default - * on a German/French device) would break it. Twin of the Swift en_US_POSIX format. + * `numerator/denominator` to 2dp, by INTEGER half-up rounding — deliberately NOT [String.format]. + * + * Two traps this sidesteps, both of which would silently desync the Kotlin and Swift logs that the + * tests assert are byte-identical: + * - **Rounding mode.** Java's `String.format` rounds half-UP; C/Swift `printf("%.2f")` rounds + * half-to-EVEN. They disagree on any exact tie, and ties are ordinary here — `pack` of 9/8 renders + * `1.13` on Kotlin and `1.12` on Swift. + * - **Locale.** A comma decimal separator on a German/French device would break a log parser. + * + * Integer math has neither problem: `+denominator` before the divide is the half-up bias, and the + * operands are small (interval counts), nowhere near overflow. */ - internal fun fixed2(v: Double): String = String.format(Locale.US, "%.2f", v) + internal fun fixed2(numerator: Long, denominator: Long): String { + if (denominator <= 0L) return "0.00" + val hundredths = (numerator * 200L + denominator) / (denominator * 2L) + val frac = hundredths % 100L + return "${hundredths / 100}." + if (frac < 10L) "0$frac" else "$frac" + } } diff --git a/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt b/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt index 61f1056d59..e77b82c46e 100644 --- a/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt +++ b/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt @@ -81,6 +81,20 @@ class ChunkClockDiagTest { assertTrue(line, line.contains("offset=-200s")) } + /** + * PARITY REGRESSION. `pack` of 9/8 is an exact binary tie (1.125). Java's [String.format] rounds + * half-UP and would render `1.13`, while Swift's `printf("%.2f")` rounds half-to-EVEN and renders + * `1.12` — the two strap logs would silently disagree. Both platforms now round half-UP by integer + * math, so this asserts `1.13` and its Swift twin asserts the identical string. + */ + @Test + fun `exact tie rounds half up not half even`() { + // 9 intervals over 8 stamped seconds: one second carries 2, the rest 1. + val ts = (1_700_000_000L until 1_700_000_008L).toList() + listOf(1_700_000_000L) + val line = ChunkClockDiag.line(1, 0, 0, ts)!! + assertTrue(line, line.contains("pack=1.13")) + } + /** * Timestamps arrive in emission order, which is not guaranteed sorted across a record boundary; the * span must come from the true min/max, not the first/last element. From c162fcde0c8e388b2ce97c4d2fa5bee4c86a41aa Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 20 Aug 2026 13:26:17 +1200 Subject: [PATCH 3/3] Report the stale-clock GATE, not a correction the decoder may not have applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review. The field was named `corr=on|off` and documented as "did the stale-RTC correction engage". It cannot know that. It mirrored only the FIRST gate in extractHistoricalStreams — whether |wall - device| exceeds the 1-day threshold. Past that gate the #471 overshoot guard re-checks EVERY record and keeps the raw ts whenever the corrected value would post-date wall time. So `corr=on` claimed an outcome that may not have happened, and it was most wrong in the case the guard exists for: a drained strap whose RTC reset to ~epoch has an offset of decades, so every record overshoots and NONE are corrected — while the log asserted a correction throughout. That is the year-2081 class of bug, where a misleading diagnostic costs the most. The decision is per-record and depends on each raw stamp, so no single per-chunk flag can state it. The field is now `staleGate=open|closed`, named for what it actually observes, and the doc says plainly that an open gate does not prove the correction was applied. Renaming only; the threshold comparison is unchanged. WhoopProtocol 615 pass on Linux, Android twin tests=8, compileFullDebugKotlin clean. --- .../WhoopProtocol/ChunkClockDiag.swift | 19 +++++++++++++------ .../ChunkClockDiagTests.swift | 14 +++++++------- .../java/com/noop/protocol/ChunkClockDiag.kt | 19 +++++++++++++------ .../com/noop/protocol/ChunkClockDiagTest.kt | 14 +++++++------- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift index 08356395ee..53fb4ffcf7 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/ChunkClockDiag.swift @@ -38,11 +38,18 @@ public enum ChunkClockDiag { guard !rrTimestamps.isEmpty else { return nil } let offset = wallClockRef - deviceClockRef - // Mirrors the gate inside `extractHistoricalStreams`: below the threshold the offset is DISCARDED - // and each record keeps its own raw unix second. Logging `corr` makes it explicit that a small - // drift (tens of seconds) never reaches the stored timestamps — the drift is baked into the - // strap's own stamps instead, which is a different problem with a different fix. - let corrected = abs(offset) > histStaleClockThresholdSec + // Mirrors the FIRST gate inside `extractHistoricalStreams`: below the threshold the offset is + // DISCARDED and each record keeps its own raw unix second, so a small drift (tens of seconds) never + // reaches the stored timestamps — the drift is baked into the strap's own stamps instead, which is + // a different problem with a different fix. + // + // Named for the GATE, not the outcome, and deliberately so: an open gate does NOT prove the + // correction was applied. Past it, the #471 overshoot guard re-checks EVERY record and keeps the + // raw ts whenever the corrected value would post-date wall time — which is exactly the field case + // that motivated it (a drained strap whose RTC reset to ~epoch has an offset of decades, so every + // record overshoots and none are corrected). That decision is per-record and depends on each raw + // stamp, so no single per-chunk flag can state it; reporting the gate is the honest half. + let staleGateOpen = abs(offset) > histStaleClockThresholdSec var perSecond: [Int: Int] = [:] var oldest = rrTimestamps[0], newest = rrTimestamps[0] @@ -55,7 +62,7 @@ public enum ChunkClockDiag { let maxPerSecond = perSecond.values.max() ?? 0 let spanSeconds = newest - oldest + 1 // inclusive; a single-second chunk spans 1 - return "Backfill: hist clock chunk=\(chunk) offset=\(signed(offset))s corr=\(corrected ? "on" : "off")" + return "Backfill: hist clock chunk=\(chunk) offset=\(signed(offset))s staleGate=\(staleGateOpen ? "open" : "closed")" + " rr=\(rrTimestamps.count) secs=\(stampedSeconds)" + " pack=\(fixed2(rrTimestamps.count, stampedSeconds)) max=\(maxPerSecond)" + " span=\(spanSeconds)s dens=\(fixed2(rrTimestamps.count, spanSeconds))" diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift index 5f0aba67c1..fa69c9e30e 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ChunkClockDiagTests.swift @@ -15,7 +15,7 @@ final class ChunkClockDiagTests: XCTestCase { let ts = Array(1_700_000_000 ..< 1_700_000_010) let line = ChunkClockDiag.line(chunk: 1, deviceClockRef: 1_000, wallClockRef: 1_000, rrTimestamps: ts) - XCTAssertEqual(line, "Backfill: hist clock chunk=1 offset=+0s corr=off" + XCTAssertEqual(line, "Backfill: hist clock chunk=1 offset=+0s staleGate=closed" + " rr=10 secs=10 pack=1.00 max=1 span=10s dens=1.00") } @@ -30,7 +30,7 @@ final class ChunkClockDiagTests: XCTestCase { } let line = ChunkClockDiag.line(chunk: 4, deviceClockRef: 1_000, wallClockRef: 1_048, rrTimestamps: ts) - XCTAssertEqual(line, "Backfill: hist clock chunk=4 offset=+48s corr=off" + XCTAssertEqual(line, "Backfill: hist clock chunk=4 offset=+48s staleGate=closed" + " rr=24 secs=3 pack=8.00 max=8 span=21s dens=1.14") } @@ -40,20 +40,20 @@ final class ChunkClockDiagTests: XCTestCase { let once = Array(1_700_000_000 ..< 1_700_000_010) let line = ChunkClockDiag.line(chunk: 2, deviceClockRef: 1_000, wallClockRef: 1_000, rrTimestamps: once + once) - XCTAssertEqual(line, "Backfill: hist clock chunk=2 offset=+0s corr=off" + XCTAssertEqual(line, "Backfill: hist clock chunk=2 offset=+0s staleGate=closed" + " rr=20 secs=10 pack=2.00 max=2 span=10s dens=2.00") } - /// `corr` must mirror `extractHistoricalStreams`: an everyday drift of minutes is DISCARDED by the + /// `staleGate` must mirror `extractHistoricalStreams`: an everyday drift of minutes is DISCARDED by the /// decoder, so it must not read as a correction the stored stamps never received. - func testCorrOffBelowThresholdAndOnAboveIt() { + func testStaleGateClosedBelowThresholdAndOpenAboveIt() { let ts = [1_700_000_000] let below = ChunkClockDiag.line(chunk: 1, deviceClockRef: 0, wallClockRef: histStaleClockThresholdSec, rrTimestamps: ts) - XCTAssertTrue(below!.contains("corr=off"), "at exactly the threshold the decoder keeps rawTs") + XCTAssertTrue(below!.contains("staleGate=closed"), "at exactly the threshold the decoder keeps rawTs") let above = ChunkClockDiag.line(chunk: 1, deviceClockRef: 0, wallClockRef: histStaleClockThresholdSec + 1, rrTimestamps: ts) - XCTAssertTrue(above!.contains("corr=on")) + XCTAssertTrue(above!.contains("staleGate=open")) } /// A strap RTC AHEAD of the phone must render as a negative offset, not an unsigned number — the diff --git a/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt b/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt index 85d49a31a2..160beb5d18 100644 --- a/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt +++ b/android/app/src/main/java/com/noop/protocol/ChunkClockDiag.kt @@ -41,11 +41,18 @@ object ChunkClockDiag { if (rrTimestamps.isEmpty()) return null val offset = wallClockRef - deviceClockRef - // Mirrors the gate inside extractHistoricalStreams: below the threshold the offset is DISCARDED - // and each record keeps its own raw unix second. Logging `corr` makes it explicit that a small - // drift (tens of seconds) never reaches the stored timestamps — the drift is baked into the - // strap's own stamps instead, which is a different problem with a different fix. - val corrected = abs(offset) > HIST_STALE_CLOCK_THRESHOLD_SEC + // Mirrors the FIRST gate inside extractHistoricalStreams: below the threshold the offset is + // DISCARDED and each record keeps its own raw unix second, so a small drift (tens of seconds) never + // reaches the stored timestamps — the drift is baked into the strap's own stamps instead, which is + // a different problem with a different fix. + // + // Named for the GATE, not the outcome, and deliberately so: an open gate does NOT prove the + // correction was applied. Past it, the #471 overshoot guard re-checks EVERY record and keeps the + // raw ts whenever the corrected value would post-date wall time — which is exactly the field case + // that motivated it (a drained strap whose RTC reset to ~epoch has an offset of decades, so every + // record overshoots and none are corrected). That decision is per-record and depends on each raw + // stamp, so no single per-chunk flag can state it; reporting the gate is the honest half. + val staleGateOpen = abs(offset) > HIST_STALE_CLOCK_THRESHOLD_SEC val perSecond = HashMap() var oldest = rrTimestamps[0] @@ -59,7 +66,7 @@ object ChunkClockDiag { val maxPerSecond = perSecond.values.maxOrNull() ?: 0 val spanSeconds = newest - oldest + 1 // inclusive; a single-second chunk spans 1 - return "Backfill: hist clock chunk=$chunk offset=${signed(offset)}s corr=${if (corrected) "on" else "off"}" + + return "Backfill: hist clock chunk=$chunk offset=${signed(offset)}s staleGate=${if (staleGateOpen) "open" else "closed"}" + " rr=${rrTimestamps.size} secs=$stampedSeconds" + " pack=${fixed2(rrTimestamps.size.toLong(), stampedSeconds.toLong())} max=$maxPerSecond" + " span=${spanSeconds}s dens=${fixed2(rrTimestamps.size.toLong(), spanSeconds)}" diff --git a/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt b/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt index e77b82c46e..1981dcfd84 100644 --- a/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt +++ b/android/app/src/test/java/com/noop/protocol/ChunkClockDiagTest.kt @@ -22,7 +22,7 @@ class ChunkClockDiagTest { fun `honest stream packs and densifies at one`() { val ts = (1_700_000_000L until 1_700_000_010L).toList() assertEquals( - "Backfill: hist clock chunk=1 offset=+0s corr=off" + + "Backfill: hist clock chunk=1 offset=+0s staleGate=closed" + " rr=10 secs=10 pack=1.00 max=1 span=10s dens=1.00", ChunkClockDiag.line(1, 1_000, 1_000, ts), ) @@ -38,7 +38,7 @@ class ChunkClockDiagTest { // 3 records, 10s apart, 8 intervals each — 24 beats over a 21s span (~1.14 beats/s). val ts = (0 until 3).flatMap { record -> List(8) { 1_700_000_000L + record * 10 } } assertEquals( - "Backfill: hist clock chunk=4 offset=+48s corr=off" + + "Backfill: hist clock chunk=4 offset=+48s staleGate=closed" + " rr=24 secs=3 pack=8.00 max=8 span=21s dens=1.14", ChunkClockDiag.line(4, 1_000, 1_048, ts), ) @@ -52,23 +52,23 @@ class ChunkClockDiagTest { fun `duplicated delivery moves pack and density together`() { val once = (1_700_000_000L until 1_700_000_010L).toList() assertEquals( - "Backfill: hist clock chunk=2 offset=+0s corr=off" + + "Backfill: hist clock chunk=2 offset=+0s staleGate=closed" + " rr=20 secs=10 pack=2.00 max=2 span=10s dens=2.00", ChunkClockDiag.line(2, 1_000, 1_000, once + once), ) } /** - * `corr` must mirror [extractHistoricalStreams]: an everyday drift of minutes is DISCARDED by the + * `staleGate` must mirror [extractHistoricalStreams]: an everyday drift of minutes is DISCARDED by the * decoder, so it must not read as a correction the stored stamps never received. */ @Test - fun `corr off below threshold and on above it`() { + fun `stale gate closed below threshold and open above it`() { val ts = listOf(1_700_000_000L) val below = ChunkClockDiag.line(1, 0, HIST_STALE_CLOCK_THRESHOLD_SEC, ts)!! - assertTrue("at exactly the threshold the decoder keeps rawTs", below.contains("corr=off")) + assertTrue("at exactly the threshold the decoder keeps rawTs", below.contains("staleGate=closed")) val above = ChunkClockDiag.line(1, 0, HIST_STALE_CLOCK_THRESHOLD_SEC + 1, ts)!! - assertTrue(above.contains("corr=on")) + assertTrue(above.contains("staleGate=open")) } /**